logicaffeine_language/analysis/
dependencies.rs

1//! Dependency Scanner for the Hyperlink Module System.
2//!
3//! Scans the "Abstract" (first paragraph) of a LOGOS document for Markdown links,
4//! which are interpreted as module dependencies.
5//!
6//! Syntax: `[Alias](URI)` where:
7//! - Alias: The name to reference the module by (e.g., "Geometry")
8//! - URI: The location of the module source (e.g., "file:./geo.md", "logos:std")
9
10/// A dependency declaration found in the document's abstract.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Dependency {
13    /// The alias to use when referencing this module (e.g., "Geometry")
14    pub alias: String,
15    /// The URI pointing to the module source (e.g., "file:./geo.md")
16    pub uri: String,
17    /// Start position in the source for error reporting
18    pub start: usize,
19    /// End position in the source for error reporting
20    pub end: usize,
21}
22
23/// Scans the first paragraph (Abstract) of a LOGOS file for `[Alias](URI)` links.
24///
25/// The Abstract is defined as the first non-empty block of text following the
26/// module header (# Name). Links inside this paragraph are treated as imports.
27/// Scanning stops at the first empty line after the abstract or when a code
28/// block header (`##`) is encountered.
29///
30/// # Example
31///
32/// ```text
33/// # My Game
34///
35/// This module uses [Geometry](file:./geo.md) for math.
36///
37/// ## Main
38/// Let x be 1.
39/// ```
40///
41/// Returns: `[Dependency { alias: "Geometry", uri: "file:./geo.md", ... }]`
42pub fn scan_dependencies(source: &str) -> Vec<Dependency> {
43    let mut dependencies = Vec::new();
44    let mut in_abstract = false;
45    let mut abstract_started = false;
46    let mut current_pos = 0;
47
48    for line in source.lines() {
49        let line_start = current_pos;
50        let trimmed = line.trim();
51
52        // Track position for the next line
53        current_pos += line.len() + 1; // +1 for newline
54
55        // Skip completely empty lines before the abstract starts
56        if trimmed.is_empty() {
57            if abstract_started && in_abstract {
58                // Empty line after abstract content - we're done
59                break;
60            }
61            continue;
62        }
63
64        // Skip the header line (# Title)
65        if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
66            continue;
67        }
68
69        // Stop at code block headers (## Main, ## Definition, etc.)
70        if trimmed.starts_with("## ") {
71            break;
72        }
73
74        // We found non-empty, non-header content - this is the abstract
75        in_abstract = true;
76        abstract_started = true;
77
78        // Scan this line for Markdown links [Alias](URI)
79        scan_line_for_links(line, line_start, &mut dependencies);
80    }
81
82    dependencies
83}
84
85/// Scans a single line for Markdown link patterns `[Alias](URI)`.
86fn scan_line_for_links(line: &str, line_start: usize, deps: &mut Vec<Dependency>) {
87    let bytes = line.as_bytes();
88    let mut i = 0;
89
90    while i < bytes.len() {
91        // Look for opening bracket
92        if bytes[i] == b'[' {
93            let link_start = line_start + i;
94            i += 1;
95
96            // Read the alias (text between [ and ])
97            let alias_start = i;
98            while i < bytes.len() && bytes[i] != b']' {
99                i += 1;
100            }
101
102            if i >= bytes.len() {
103                // No closing bracket found
104                break;
105            }
106
107            let alias = &line[alias_start..i];
108            i += 1; // Skip ]
109
110            // Expect immediate opening parenthesis
111            if i >= bytes.len() || bytes[i] != b'(' {
112                continue;
113            }
114            i += 1; // Skip (
115
116            // Read the URI (text between ( and ))
117            let uri_start = i;
118            let mut paren_depth = 1;
119            while i < bytes.len() && paren_depth > 0 {
120                if bytes[i] == b'(' {
121                    paren_depth += 1;
122                } else if bytes[i] == b')' {
123                    paren_depth -= 1;
124                }
125                if paren_depth > 0 {
126                    i += 1;
127                }
128            }
129
130            if paren_depth != 0 {
131                // No closing parenthesis found
132                break;
133            }
134
135            let uri = &line[uri_start..i];
136            let link_end = line_start + i + 1;
137            i += 1; // Skip )
138
139            // Skip empty aliases or URIs
140            if alias.is_empty() || uri.is_empty() {
141                continue;
142            }
143
144            deps.push(Dependency {
145                alias: alias.trim().to_string(),
146                uri: uri.trim().to_string(),
147                start: link_start,
148                end: link_end,
149            });
150        } else {
151            i += 1;
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn basic_dependency_scanning() {
162        let source = r#"
163# My Game
164
165This uses [Geometry](file:./geo.md) and [Physics](logos:std).
166
167## Main
168Let x be 1.
169"#;
170        let deps = scan_dependencies(source);
171        assert_eq!(deps.len(), 2);
172        assert_eq!(deps[0].alias, "Geometry");
173        assert_eq!(deps[0].uri, "file:./geo.md");
174        assert_eq!(deps[1].alias, "Physics");
175        assert_eq!(deps[1].uri, "logos:std");
176    }
177
178    #[test]
179    fn ignores_links_after_abstract() {
180        let source = r#"
181# Header
182
183This is the abstract with [Dep1](file:a.md).
184
185This second paragraph has [Dep2](file:b.md).
186
187## Main
188Let x be 1.
189"#;
190        let deps = scan_dependencies(source);
191        assert_eq!(deps.len(), 1);
192        assert_eq!(deps[0].alias, "Dep1");
193    }
194
195    #[test]
196    fn no_dependencies_without_abstract() {
197        let source = r#"
198# Module
199
200## Main
201Let x be 1.
202"#;
203        let deps = scan_dependencies(source);
204        assert_eq!(deps.len(), 0);
205    }
206
207    #[test]
208    fn multiline_abstract() {
209        let source = r#"
210# My Project
211
212This project uses [Math](file:./math.md) for calculations
213and [IO](file:./io.md) for input/output operations.
214
215## Main
216Let x be 1.
217"#;
218        let deps = scan_dependencies(source);
219        assert_eq!(deps.len(), 2);
220        assert_eq!(deps[0].alias, "Math");
221        assert_eq!(deps[1].alias, "IO");
222    }
223
224    #[test]
225    fn handles_spaces_in_alias() {
226        let source = r#"
227# App
228
229Uses the [Standard Library](logos:std).
230
231## Main
232"#;
233        let deps = scan_dependencies(source);
234        assert_eq!(deps.len(), 1);
235        assert_eq!(deps[0].alias, "Standard Library");
236        assert_eq!(deps[0].uri, "logos:std");
237    }
238
239    #[test]
240    fn handles_https_urls() {
241        let source = r#"
242# App
243
244Uses [Physics](https://logicaffeine.dev/pkg/physics).
245
246## Main
247"#;
248        let deps = scan_dependencies(source);
249        assert_eq!(deps.len(), 1);
250        assert_eq!(deps[0].alias, "Physics");
251        assert_eq!(deps[0].uri, "https://logicaffeine.dev/pkg/physics");
252    }
253
254    #[test]
255    fn handles_multiple_links_on_one_line() {
256        let source = r#"
257# App
258
259Uses [A](file:a.md), [B](file:b.md), and [C](file:c.md).
260
261## Main
262"#;
263        let deps = scan_dependencies(source);
264        assert_eq!(deps.len(), 3);
265        assert_eq!(deps[0].alias, "A");
266        assert_eq!(deps[1].alias, "B");
267        assert_eq!(deps[2].alias, "C");
268    }
269}