Skip to main content

logicaffeine_compile/
loader.rs

1//! Module loader for multi-file LOGOS projects.
2//!
3//! Handles resolution and loading of module sources from various URI schemes,
4//! with caching to prevent duplicate loading.
5//!
6//! # Supported URI Schemes
7//!
8//! | Scheme | Example | Description |
9//! |--------|---------|-------------|
10//! | `file:` | `file:./geometry.md` | Local filesystem (relative) |
11//! | `logos:` | `logos:std` | Built-in standard library |
12//! | (none) | `geometry.md` | Defaults to `file:` scheme |
13//!
14//! # Security
15//!
16//! The loader prevents path traversal attacks by checking that resolved
17//! paths remain within the project root directory.
18//!
19//! # Caching
20//!
21//! Modules are cached by their normalized URI. The same module loaded from
22//! different base paths will be cached separately.
23//!
24//! # Example
25//!
26//! ```no_run
27//! # use logicaffeine_compile::loader::Loader;
28//! # use std::path::{Path, PathBuf};
29//! # fn main() -> Result<(), String> {
30//! # let project_root = PathBuf::from(".");
31//! let mut loader = Loader::new(project_root);
32//! let source = loader.resolve(Path::new("main.md"), "file:./lib/math.md")?;
33//! println!("Loaded: {}", source.path.display());
34//! # Ok(())
35//! # }
36//! ```
37
38use std::collections::HashMap;
39use std::fs;
40use std::path::{Path, PathBuf};
41
42/// A loaded module's source content and metadata.
43#[derive(Debug, Clone)]
44pub struct ModuleSource {
45    /// The source content of the module
46    pub content: String,
47    /// The resolved path (for error reporting and relative resolution)
48    pub path: PathBuf,
49}
50
51/// Module loader that handles multiple URI schemes.
52///
53/// Caches loaded modules to prevent duplicate loading and supports
54/// cycle detection through the cache.
55pub struct Loader {
56    /// Cache of loaded modules (URI -> ModuleSource)
57    cache: HashMap<String, ModuleSource>,
58    /// Root directory of the project (for relative path resolution)
59    root_path: PathBuf,
60}
61
62impl Loader {
63    /// Creates a new Loader with the given root path.
64    pub fn new(root_path: PathBuf) -> Self {
65        Loader {
66            cache: HashMap::new(),
67            root_path,
68        }
69    }
70
71    /// Resolves a URI to a module source.
72    ///
73    /// Supports:
74    /// - `file:./path.md` - Local filesystem (relative to base_path)
75    /// - `logos:std` - Built-in standard library
76    /// - `logos:core` - Built-in core types
77    pub fn resolve(&mut self, base_path: &Path, uri: &str) -> Result<&ModuleSource, String> {
78        // Normalize the URI for caching
79        let cache_key = self.normalize_uri(base_path, uri)?;
80
81        // Check cache first
82        if self.cache.contains_key(&cache_key) {
83            return Ok(&self.cache[&cache_key]);
84        }
85
86        // Load based on scheme
87        let source = if uri.starts_with("file:") {
88            self.load_file(base_path, uri)?
89        } else if uri.starts_with("logos:") {
90            self.load_intrinsic(uri)?
91        } else if uri.starts_with("https://") || uri.starts_with("http://") {
92            // Remote loading not supported in base loader
93            return Err(format!(
94                "Remote module loading not supported for '{}'. \
95                 Use the CLI's 'logos fetch' command to download dependencies locally.",
96                uri
97            ));
98        } else {
99            // Default to file: scheme if no scheme provided
100            self.load_file(base_path, &format!("file:{}", uri))?
101        };
102
103        // Cache and return
104        self.cache.insert(cache_key.clone(), source);
105        Ok(&self.cache[&cache_key])
106    }
107
108    /// Normalizes a URI for consistent caching.
109    fn normalize_uri(&self, base_path: &Path, uri: &str) -> Result<String, String> {
110        if uri.starts_with("file:") {
111            let path_str = uri.trim_start_matches("file:");
112            let base_dir = base_path.parent().unwrap_or(&self.root_path);
113            let resolved = base_dir.join(path_str);
114            Ok(format!("file:{}", resolved.display()))
115        } else {
116            Ok(uri.to_string())
117        }
118    }
119
120    /// Loads a module from the local filesystem.
121    fn load_file(&self, base_path: &Path, uri: &str) -> Result<ModuleSource, String> {
122        let path_str = uri.trim_start_matches("file:");
123
124        // Resolve relative to the base file's directory
125        let base_dir = base_path.parent().unwrap_or(&self.root_path);
126        let resolved_path = base_dir.join(path_str);
127
128        // Security: Check that we're not escaping the root path
129        let canonical_root = self.root_path.canonicalize()
130            .unwrap_or_else(|_| self.root_path.clone());
131
132        // Read the file
133        let content = fs::read_to_string(&resolved_path)
134            .map_err(|e| format!("Failed to read '{}': {}", resolved_path.display(), e))?;
135
136        // Check if escaping root (after we know the file exists)
137        if let Ok(canonical_path) = resolved_path.canonicalize() {
138            if !canonical_path.starts_with(&canonical_root) {
139                return Err(format!(
140                    "Security: Cannot load '{}' - path escapes project root",
141                    uri
142                ));
143            }
144        }
145
146        Ok(ModuleSource {
147            content,
148            path: resolved_path,
149        })
150    }
151
152    /// Loads a built-in module (embedded at compile time).
153    fn load_intrinsic(&self, uri: &str) -> Result<ModuleSource, String> {
154        let name = uri.trim_start_matches("logos:");
155
156        match name {
157            "std" => Ok(ModuleSource {
158                content: include_str!("../assets/std/std.md").to_string(),
159                path: PathBuf::from("logos:std"),
160            }),
161            "core" => Ok(ModuleSource {
162                content: include_str!("../assets/std/core.md").to_string(),
163                path: PathBuf::from("logos:core"),
164            }),
165            _ => Err(format!("Unknown intrinsic module: '{}'", uri)),
166        }
167    }
168
169    /// Checks if a module has already been loaded (for cycle detection).
170    pub fn is_loaded(&self, uri: &str) -> bool {
171        self.cache.contains_key(uri)
172    }
173
174    /// Returns all loaded module URIs (for debugging).
175    pub fn loaded_modules(&self) -> Vec<&str> {
176        self.cache.keys().map(|s| s.as_str()).collect()
177    }
178}
179
180// ─── Standard-library prelude (Phase 10) ────────────────────────────────────
181//
182// The concurrency / net / io / crdt vocabulary, embedded at compile time and made
183// available WITHOUT an explicit import. To keep non-stdlib programs byte-identical
184// (the AOT hot-path contract), a module is prepended ONLY when the program
185// references that module's vocabulary — and per-module, so a program that names a
186// pure net/io type is not forced async by the concurrency helpers it never uses.
187// A `## NoPrelude` line opts out entirely.
188
189const STD_CONCURRENCY: &str = include_str!("../assets/std/concurrency.md");
190const STD_NET: &str = include_str!("../assets/std/net.md");
191const STD_IO: &str = include_str!("../assets/std/io.md");
192const STD_CRDT: &str = include_str!("../assets/std/crdt.md");
193const STD_ENV: &str = include_str!("../assets/std/env.lg");
194const STD_FILE: &str = include_str!("../assets/std/file.lg");
195const STD_RANDOM: &str = include_str!("../assets/std/random.lg");
196const STD_TIME: &str = include_str!("../assets/std/time.lg");
197const STD_CRYPTO: &str = include_str!("../assets/std/crypto.lg");
198const STD_UUID: &str = include_str!("../assets/std/uuid.lg");
199
200/// Every stdlib module that auto-imports, in stable embedding order. The trigger
201/// identifiers and collision keys are not hand-maintained — they are *derived* from
202/// each module's own definitions ([`defined_names`]), so dropping a new module here
203/// makes its whole vocabulary live with nothing else to update. `core`/`std` are
204/// deliberately absent: they redefine builtin generics (`List`/`Map`/`Result`/…) and
205/// stay explicit-import (`logos:core`) to avoid double-definition.
206const PRELUDE_MODULES: &[&str] = &[
207    STD_CONCURRENCY,
208    STD_NET,
209    STD_IO,
210    STD_CRDT,
211    STD_ENV,
212    STD_FILE,
213    STD_RANDOM,
214    STD_TIME,
215    STD_CRYPTO,
216    STD_UUID,
217];
218
219fn is_ident_byte(b: u8) -> bool {
220    b.is_ascii_alphanumeric() || b == b'_'
221}
222
223/// The names a module's CODE defines, used both as auto-import triggers and collision keys
224/// ("declarer wins"): helper / native procedures (`## To [native] <name>`) and the names of
225/// *type definitions* (`A <Name> has …` / `A <Name> is …` / `A <Name> of [T] …`).
226///
227/// Enum variant constructors (`A Debug.`, `A Some (value: T).`) are deliberately NOT taken:
228/// they are common English words (`Info`, `Warning`, …) and triggering an auto-import on a
229/// bare mention of one would wrongly pull a whole module into an unrelated program. A
230/// program names the distinctive *type* (`Severity`) — or defines its own — so the type
231/// name is the safe trigger. Field lines (`a sender, which is Int.`) are lowercase and skip.
232fn defined_names(code: &str) -> Vec<String> {
233    let mut names = Vec::new();
234    for line in code.lines() {
235        let t = line.trim();
236        if let Some(rest) = t.strip_prefix("## To ") {
237            let rest = rest.strip_prefix("native ").unwrap_or(rest);
238            if let Some(name) = rest.split(|c: char| c == '(' || c.is_whitespace()).next() {
239                if !name.is_empty() {
240                    names.push(name.to_string());
241                }
242            }
243            continue;
244        }
245        let after_article = t.strip_prefix("A ").or_else(|| t.strip_prefix("An "));
246        if let Some(rest) = after_article {
247            if let Some(word) = rest.split(|c: char| c == '(' || c == '.' || c.is_whitespace()).next() {
248                if word.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
249                    // A type *header* continues with `has` / `is` / `of`; a bare `A Name.`
250                    // (or `A Name (fields).`) is a variant constructor — not a trigger.
251                    let tail = rest[word.len()..].trim_start();
252                    let is_type_header = tail.starts_with("has")
253                        || tail.starts_with("is")
254                        || tail.starts_with("of");
255                    if is_type_header {
256                        names.push(word.to_string());
257                    }
258                }
259            }
260        }
261    }
262    names
263}
264
265/// The names a prelude module owns (derived from its CODE, notes stripped —
266/// documentation prose must never mint a trigger name).
267fn module_names(src: &str) -> Vec<String> {
268    defined_names(&strip_note_blocks(module_code(src)))
269}
270
271/// Does the user `source` itself define `name`? If so, the user's definition wins and the
272/// owning module is not prepended — no duplicate definition, no shadowing surprise. This is
273/// also what keeps the benchmark corpus (which hand-declares `## To native args`)
274/// byte-identical.
275fn defines(source: &str, name: &str) -> bool {
276    defined_names(source).iter().any(|n| n == name)
277}
278
279/// The CODE of a module — from its first `##` section onward, dropping the
280/// markdown title + leading prose. Literate Logos only skips prose *before* the
281/// first section, so when modules are concatenated only the leading prose is a
282/// hazard; stripping it makes the join parse cleanly. Documentation prose stays
283/// in the source files.
284fn module_code(md: &str) -> &str {
285    if let Some(i) = md.find("\n## ") {
286        &md[i + 1..]
287    } else if md.starts_with("## ") {
288        md
289    } else {
290        ""
291    }
292}
293
294/// A module's code with `## Note` documentation blocks removed — what the
295/// prelude actually prepends. Notes are the IDE's per-definition doc carrier
296/// (see `prelude_module_sources`); the runtime prelude stays lean, note-free,
297/// and byte-identical to the pre-documentation join.
298fn strip_note_blocks(code: &str) -> String {
299    let mut out = String::with_capacity(code.len());
300    let mut in_note = false;
301    for line in code.split_inclusive('\n') {
302        let trimmed = line.trim();
303        if in_note {
304            if trimmed.starts_with("## ") && trimmed != "## Note" {
305                in_note = false;
306                out.push_str(line);
307            }
308            continue;
309        }
310        if trimmed == "## Note" {
311            in_note = true;
312            continue;
313        }
314        out.push_str(line);
315    }
316    out
317}
318
319/// The full embedded prelude — every module's CODE concatenated (what
320/// [`apply_prelude`] prepends), documentation notes stripped. Identical bytes
321/// on every target (`include_str!` is compile-time).
322pub fn prelude() -> String {
323    PRELUDE_MODULES
324        .iter()
325        .map(|src| strip_note_blocks(module_code(src)))
326        .collect::<Vec<_>>()
327        .join("\n\n")
328}
329
330/// The RAW embedded stdlib module sources, `## Note` documentation included —
331/// the seam the LSP reads literate docs from (`teach::extract_literate_docs`).
332pub fn prelude_module_sources() -> &'static [&'static str] {
333    PRELUDE_MODULES
334}
335
336/// Every identifier the prelude defines (across all modules) — derived from the modules.
337pub fn prelude_vocabulary() -> Vec<String> {
338    PRELUDE_MODULES.iter().flat_map(|src| module_names(src)).collect()
339}
340
341/// Does `source` USE `name` — call it, launch it, or name it as a type? We require `name`
342/// to appear as a whole word in a *use position* (immediately called `name(`, or preceded
343/// by an invocation/type keyword like `a`/`an`/`the`/`new`/`of`/`to`/`Call` or a `:`), so a
344/// bare mention in prose, a string literal, or a larger identifier never drags the module
345/// in. This is what lets the auto-import stay invisible without false positives.
346fn references(source: &str, name: &str) -> bool {
347    if name.is_empty() {
348        return false;
349    }
350    let sb = source.as_bytes();
351    let mut search_from = 0;
352    while let Some(off) = source[search_from..].find(name) {
353        let start = search_from + off;
354        let end = start + name.len();
355        search_from = start + 1;
356        // Whole word: the name must not be part of a larger identifier.
357        if start > 0 && is_ident_byte(sb[start - 1]) {
358            continue;
359        }
360        if end < sb.len() && is_ident_byte(sb[end]) {
361            continue;
362        }
363        // Call form: `name(`.
364        if end < sb.len() && sb[end] == b'(' {
365            return true;
366        }
367        // Use position: preceded by an invocation/type keyword, or a `:` (param type).
368        let prefix = source[..start].trim_end();
369        if prefix.ends_with(':') {
370            return true;
371        }
372        let last_word = prefix.rsplit(|c: char| c.is_whitespace()).next().unwrap_or("");
373        if matches!(last_word, "a" | "an" | "the" | "new" | "of" | "to" | "Call") {
374            return true;
375        }
376    }
377    false
378}
379
380/// Prepend the stdlib modules a program actually uses. Returns the source
381/// unchanged when the program references no stdlib vocabulary or opts out with
382/// `## NoPrelude` (in which case the opt-out marker is stripped so it never
383/// reaches the parser). This is the auto-import seam for both the interpreter and
384/// the compiler.
385pub fn apply_prelude(source: &str) -> std::borrow::Cow<'_, str> {
386    if let Some(stripped) = strip_no_prelude(source) {
387        return std::borrow::Cow::Owned(stripped);
388    }
389    // The names the user source itself defines — computed once. A module is prepended only
390    // when the program references one of its names AND does not define any of them. This is
391    // the unified rule: it makes the auto-import demand-driven (invisible), collision-safe
392    // ("declarer wins" — a user `Message`/`args` is never shadowed or double-defined), and
393    // idempotent (a source already carrying a module's definitions is left untouched, so
394    // the AOT hot path stays byte-identical).
395    let user_defined = defined_names(source);
396    let mut needed: Vec<String> = Vec::new();
397    for src in PRELUDE_MODULES {
398        let names = module_names(src);
399        let referenced = names.iter().any(|n| references(source, n));
400        let defined = names.iter().any(|n| user_defined.contains(n));
401        if referenced && !defined {
402            needed.push(strip_note_blocks(module_code(src)));
403        }
404    }
405    if needed.is_empty() {
406        std::borrow::Cow::Borrowed(source)
407    } else {
408        needed.push(source.to_string());
409        std::borrow::Cow::Owned(needed.join("\n\n"))
410    }
411}
412
413/// If `source` has a `## NoPrelude` opt-out line, return the source with that line
414/// removed; otherwise `None`.
415fn strip_no_prelude(source: &str) -> Option<String> {
416    if !source.lines().any(|l| l.trim() == "## NoPrelude") {
417        return None;
418    }
419    let kept: Vec<&str> = source.lines().filter(|l| l.trim() != "## NoPrelude").collect();
420    Some(kept.join("\n"))
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use tempfile::tempdir;
427
428    #[test]
429    fn test_file_scheme_resolution() {
430        let temp_dir = tempdir().unwrap();
431        let geo_path = temp_dir.path().join("geo.md");
432        fs::write(&geo_path, "## Definition\nA Point has:\n    an x, which is Int.\n").unwrap();
433
434        let mut loader = Loader::new(temp_dir.path().to_path_buf());
435        let result = loader.resolve(&temp_dir.path().join("main.md"), "file:./geo.md");
436
437        assert!(result.is_ok(), "Should resolve file: scheme: {:?}", result);
438        assert!(result.unwrap().content.contains("Point"));
439    }
440
441    #[test]
442    fn test_logos_std_scheme() {
443        let mut loader = Loader::new(PathBuf::from("."));
444        let result = loader.resolve(&PathBuf::from("main.md"), "logos:std");
445
446        assert!(result.is_ok(), "Should resolve logos:std: {:?}", result);
447    }
448
449    #[test]
450    fn test_logos_core_scheme() {
451        let mut loader = Loader::new(PathBuf::from("."));
452        let result = loader.resolve(&PathBuf::from("main.md"), "logos:core");
453
454        assert!(result.is_ok(), "Should resolve logos:core: {:?}", result);
455    }
456
457    #[test]
458    fn test_unknown_intrinsic() {
459        let mut loader = Loader::new(PathBuf::from("."));
460        let result = loader.resolve(&PathBuf::from("main.md"), "logos:unknown");
461
462        assert!(result.is_err());
463        assert!(result.unwrap_err().contains("Unknown intrinsic"));
464    }
465
466    #[test]
467    fn test_caching() {
468        let temp_dir = tempdir().unwrap();
469        let geo_path = temp_dir.path().join("geo.md");
470        fs::write(&geo_path, "content").unwrap();
471
472        let mut loader = Loader::new(temp_dir.path().to_path_buf());
473
474        // First load
475        let _ = loader.resolve(&temp_dir.path().join("main.md"), "file:./geo.md");
476
477        // Should be cached now
478        assert!(loader.loaded_modules().len() == 1);
479    }
480
481    #[test]
482    fn test_missing_file() {
483        let temp_dir = tempdir().unwrap();
484        let mut loader = Loader::new(temp_dir.path().to_path_buf());
485
486        let result = loader.resolve(&temp_dir.path().join("main.md"), "file:./nonexistent.md");
487
488        assert!(result.is_err());
489        assert!(result.unwrap_err().contains("Failed to read"));
490    }
491
492    // ─── Prelude auto-import internals ──────────────────────────────────────
493
494    #[test]
495    fn prelude_contains_no_note_blocks() {
496        // `## Note` documentation lives in the module SOURCES (the IDE reads
497        // it from `prelude_module_sources`); the runtime prelude must stay
498        // lean and note-free, byte-identical to the pre-documentation join.
499        assert!(
500            !prelude().contains("## Note"),
501            "prelude() must strip documentation notes before prepending"
502        );
503        for src in prelude_module_sources() {
504            for name in defined_names(&strip_note_blocks(module_code(src))) {
505                assert!(!name.is_empty());
506            }
507        }
508    }
509
510    #[test]
511    fn note_stripping_is_byte_exact_around_headers() {
512        let documented = "## Note\nDoes a thing.\n\n## To f (n: Int) -> Int:\n    Return n.\n";
513        let bare = "## To f (n: Int) -> Int:\n    Return n.\n";
514        assert_eq!(strip_note_blocks(documented), bare);
515
516        let between = "## To a:\n    Show 1.\n\n## Note\nDoc.\n\n## To b:\n    Show 2.\n";
517        let bare_between = "## To a:\n    Show 1.\n\n## To b:\n    Show 2.\n";
518        assert_eq!(strip_note_blocks(between), bare_between);
519    }
520
521    #[test]
522    fn derives_defined_names_per_module() {
523        assert_eq!(module_names(STD_NET), vec!["Message"]);
524        assert_eq!(module_names(STD_CRDT), vec!["Delta"]);
525        // Only the distinctive type name triggers io — never its common-word variants.
526        assert_eq!(module_names(STD_IO), vec!["Severity"]);
527        assert_eq!(module_names(STD_CONCURRENCY), vec!["flush"]);
528        assert_eq!(module_names(STD_ENV), vec!["get", "args"]);
529        assert_eq!(module_names(STD_FILE), vec!["read", "write"]);
530        assert_eq!(module_names(STD_RANDOM), vec!["randomInt", "randomFloat"]);
531        assert_eq!(module_names(STD_TIME), vec!["now", "sleep"]);
532    }
533
534    #[test]
535    fn references_matches_type_and_call_positions() {
536        assert!(references("Let m be a new Message with sender 1.", "Message"));
537        assert!(references("## To rank (s: Severity) -> Int:", "Severity"));
538        assert!(references("Let xs be args().", "args"));
539        assert!(references("Call flush with xs and ch.", "flush"));
540        assert!(references("Launch a task to flush.", "flush"));
541    }
542
543    #[test]
544    fn references_ignores_prose_and_substrings() {
545        // A bare mention in a string is not a use position.
546        assert!(!references("Show \"Message received\".", "Message"));
547        // Part of a larger identifier is not a whole-word match.
548        assert!(!references("Let MessageBox be 1.", "Message"));
549        assert!(!references("Let nowhere be 1.", "now"));
550    }
551
552    #[test]
553    fn defines_detects_native_decl_and_type() {
554        assert!(defines("## To native args -> Seq of Text\n## Main\n    Show 1.", "args"));
555        assert!(defines("## Definition\nA Message has:\n    a kind, which is Int.", "Message"));
556        assert!(!defines("## Main\n    Let x be 1.", "Message"));
557    }
558}