1use std::collections::HashMap;
39use std::fs;
40use std::path::{Path, PathBuf};
41
42#[derive(Debug, Clone)]
44pub struct ModuleSource {
45 pub content: String,
47 pub path: PathBuf,
49}
50
51pub struct Loader {
56 cache: HashMap<String, ModuleSource>,
58 root_path: PathBuf,
60}
61
62impl Loader {
63 pub fn new(root_path: PathBuf) -> Self {
65 Loader {
66 cache: HashMap::new(),
67 root_path,
68 }
69 }
70
71 pub fn resolve(&mut self, base_path: &Path, uri: &str) -> Result<&ModuleSource, String> {
78 let cache_key = self.normalize_uri(base_path, uri)?;
80
81 if self.cache.contains_key(&cache_key) {
83 return Ok(&self.cache[&cache_key]);
84 }
85
86 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 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 self.load_file(base_path, &format!("file:{}", uri))?
101 };
102
103 self.cache.insert(cache_key.clone(), source);
105 Ok(&self.cache[&cache_key])
106 }
107
108 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 fn load_file(&self, base_path: &Path, uri: &str) -> Result<ModuleSource, String> {
122 let path_str = uri.trim_start_matches("file:");
123
124 let base_dir = base_path.parent().unwrap_or(&self.root_path);
126 let resolved_path = base_dir.join(path_str);
127
128 let canonical_root = self.root_path.canonicalize()
130 .unwrap_or_else(|_| self.root_path.clone());
131
132 let content = fs::read_to_string(&resolved_path)
134 .map_err(|e| format!("Failed to read '{}': {}", resolved_path.display(), e))?;
135
136 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 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 pub fn is_loaded(&self, uri: &str) -> bool {
171 self.cache.contains_key(uri)
172 }
173
174 pub fn loaded_modules(&self) -> Vec<&str> {
176 self.cache.keys().map(|s| s.as_str()).collect()
177 }
178}
179
180const 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
200const 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
223fn 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 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
265fn module_names(src: &str) -> Vec<String> {
268 defined_names(&strip_note_blocks(module_code(src)))
269}
270
271fn defines(source: &str, name: &str) -> bool {
276 defined_names(source).iter().any(|n| n == name)
277}
278
279fn 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
294fn 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
319pub 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
330pub fn prelude_module_sources() -> &'static [&'static str] {
333 PRELUDE_MODULES
334}
335
336pub fn prelude_vocabulary() -> Vec<String> {
338 PRELUDE_MODULES.iter().flat_map(|src| module_names(src)).collect()
339}
340
341fn 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 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 if end < sb.len() && sb[end] == b'(' {
365 return true;
366 }
367 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
380pub 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 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
413fn 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 let _ = loader.resolve(&temp_dir.path().join("main.md"), "file:./geo.md");
476
477 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 #[test]
495 fn prelude_contains_no_note_blocks() {
496 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 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 assert!(!references("Show \"Message received\".", "Message"));
547 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}