1use std::collections::{BTreeMap, BTreeSet, VecDeque};
36use std::fmt;
37use std::path::{Path, PathBuf};
38
39use crate::lexer::Lexer;
40use crate::tokens::TokenType;
41
42pub const MAX_MODULES: usize = 512;
46
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct ModulePath(pub Vec<String>);
55
56impl ModulePath {
57 pub fn dotted(&self) -> String {
59 self.0.join(".")
60 }
61
62 pub fn relative_file(&self) -> PathBuf {
64 let mut p = PathBuf::new();
65 for part in &self.0 {
66 p.push(part);
67 }
68 p.set_extension("axon");
69 p
70 }
71
72 pub fn is_scoped(&self) -> bool {
75 self.0.first().map(|s| s.starts_with('@')).unwrap_or(false)
76 }
77
78 pub fn from_relative_file(rel: &str) -> Option<ModulePath> {
83 let normalized = rel.replace('\\', "/");
84 let stripped = normalized.strip_suffix(".axon")?;
85 if stripped.is_empty() {
86 return None;
87 }
88 let segments: Vec<String> = stripped.split('/').map(str::to_string).collect();
89 if segments.iter().all(|s| is_module_ident(s)) {
90 Some(ModulePath(segments))
91 } else {
92 None
93 }
94 }
95}
96
97impl fmt::Display for ModulePath {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 f.write_str(&self.dotted())
100 }
101}
102
103fn is_module_ident(s: &str) -> bool {
104 let mut chars = s.chars();
105 match chars.next() {
106 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
107 _ => return false,
108 }
109 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
110}
111
112#[derive(Debug, Clone)]
118pub struct ScannedImport {
119 pub module_path: ModulePath,
120 pub names: Vec<String>,
122 pub allow_downgrade: bool,
124 pub selective: bool,
126 pub line: u32,
127 pub column: u32,
128}
129
130pub fn scan_imports(source: &str, filename: &str) -> Result<Vec<ScannedImport>, String> {
136 let tokens = Lexer::new(source, filename)
137 .tokenize()
138 .map_err(|e| format!("{}:{}:{} {}", filename, e.line, e.column, e.message))?;
139
140 let toks: Vec<_> = tokens
141 .into_iter()
142 .filter(|t| !is_comment(&t.ttype))
143 .collect();
144
145 let mut out = Vec::new();
146 let mut i = 0usize;
147 while i < toks.len() {
148 if toks[i].ttype != TokenType::Import {
149 i += 1;
150 continue;
151 }
152 let (line, column) = (toks[i].line, toks[i].column);
153 i += 1;
154
155 let mut parts: Vec<String> = Vec::new();
157 let scoped = i < toks.len() && toks[i].ttype == TokenType::At;
158 if scoped {
159 i += 1;
160 match toks.get(i) {
161 Some(t) if t.ttype == TokenType::Identifier => {
162 parts.push(format!("@{}", t.value));
163 i += 1;
164 }
165 _ => continue, }
167 } else {
168 match toks.get(i) {
169 Some(t) if t.ttype == TokenType::Identifier => {
170 parts.push(t.value.clone());
171 i += 1;
172 }
173 _ => continue,
174 }
175 }
176 while i < toks.len() && toks[i].ttype == TokenType::Dot {
177 if toks.get(i + 1).map(|t| &t.ttype) == Some(&TokenType::LBrace) {
180 i += 1;
181 break;
182 }
183 match toks.get(i + 1) {
184 Some(t) if t.ttype == TokenType::Identifier => {
185 parts.push(t.value.clone());
186 i += 2;
187 }
188 _ => break, }
190 }
191
192 let mut names = Vec::new();
194 let mut selective = false;
195 if i < toks.len() && toks[i].ttype == TokenType::LBrace {
196 selective = true;
197 i += 1;
198 loop {
199 match toks.get(i) {
200 Some(t) if t.ttype == TokenType::Identifier => {
201 names.push(t.value.clone());
202 i += 1;
203 }
204 _ => break,
205 }
206 if toks.get(i).map(|t| &t.ttype) == Some(&TokenType::Comma) {
207 i += 1;
208 continue;
209 }
210 break;
211 }
212 if toks.get(i).map(|t| &t.ttype) == Some(&TokenType::RBrace) {
213 i += 1;
214 }
215 }
216
217 let mut allow_downgrade = false;
219 if toks.get(i).map(|t| &t.ttype) == Some(&TokenType::At)
220 && toks
221 .get(i + 1)
222 .map(|t| t.ttype == TokenType::Identifier && t.value == "allow_downgrade")
223 .unwrap_or(false)
224 {
225 allow_downgrade = true;
226 i += 2;
227 }
228
229 out.push(ScannedImport {
230 module_path: ModulePath(parts),
231 names,
232 allow_downgrade,
233 selective,
234 line,
235 column,
236 });
237 }
238 Ok(out)
239}
240
241fn is_comment(tt: &TokenType) -> bool {
242 matches!(
243 tt,
244 TokenType::LineComment
245 | TokenType::BlockComment
246 | TokenType::DocLineComment
247 | TokenType::DocBlockComment
248 | TokenType::InnerDocLineComment
249 | TokenType::InnerDocBlockComment
250 )
251}
252
253#[derive(Debug, Clone)]
260pub struct ModuleError {
261 pub code: &'static str,
262 pub message: String,
263 pub origin: String,
265 pub line: u32,
266 pub column: u32,
267}
268
269impl ModuleError {
270 fn new(code: &'static str, message: String, origin: &str, line: u32, column: u32) -> Self {
271 ModuleError {
272 code,
273 message,
274 origin: origin.to_string(),
275 line,
276 column,
277 }
278 }
279}
280
281impl fmt::Display for ModuleError {
282 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283 write!(
284 f,
285 "{}:{}:{} {} {}",
286 self.origin, self.line, self.column, self.code, self.message
287 )
288 }
289}
290
291#[derive(Debug, Clone)]
297pub struct LoadedModule {
298 pub origin: String,
299 pub source: String,
300}
301
302#[derive(Debug)]
305pub struct ModuleSet {
306 pub entry: ModulePath,
307 modules: BTreeMap<ModulePath, LoadedModule>,
308}
309
310impl ModuleSet {
311 pub fn from_entry_file(
320 entry_file: &Path,
321 modules_root: Option<&Path>,
322 ) -> Result<ModuleSet, ModuleError> {
323 let entry_origin = entry_file.display().to_string();
324 let source = std::fs::read_to_string(entry_file).map_err(|e| {
325 ModuleError::new(
326 "axon-T953",
327 format!("cannot read entry file: {e}"),
328 &entry_origin,
329 0,
330 0,
331 )
332 })?;
333
334 let stem = entry_file
335 .file_stem()
336 .map(|s| s.to_string_lossy().into_owned())
337 .unwrap_or_else(|| "main".to_string());
338 let entry_path = ModulePath(vec![stem]);
339
340 let root: PathBuf = match modules_root {
341 Some(r) => r.to_path_buf(),
342 None => entry_file
343 .parent()
344 .map(|p| p.to_path_buf())
345 .unwrap_or_else(|| PathBuf::from(".")),
346 };
347
348 let mut modules = BTreeMap::new();
349 modules.insert(
350 entry_path.clone(),
351 LoadedModule {
352 origin: entry_origin.clone(),
353 source,
354 },
355 );
356
357 let mut queue: VecDeque<ModulePath> = VecDeque::new();
359 queue.push_back(entry_path.clone());
360 while let Some(current) = queue.pop_front() {
361 let loaded = &modules[¤t];
362 let origin = loaded.origin.clone();
363 let imports = scan_imports(&loaded.source, &origin).map_err(|msg| {
364 ModuleError::new("axon-T953", format!("lex error during discovery: {msg}"), &origin, 0, 0)
365 })?;
366 for imp in imports {
367 if !imp.selective || imp.module_path.is_scoped() {
368 continue; }
370 if modules.contains_key(&imp.module_path) {
371 continue;
372 }
373 if modules.len() >= MAX_MODULES {
374 return Err(ModuleError::new(
375 "axon-T953",
376 format!(
377 "module ceiling exceeded: a project may load at most {MAX_MODULES} modules"
378 ),
379 &origin,
380 imp.line,
381 imp.column,
382 ));
383 }
384 let file = root.join(imp.module_path.relative_file());
385 let dep_source = std::fs::read_to_string(&file).map_err(|_| {
386 ModuleError::new(
387 "axon-T953",
388 format!(
389 "module '{}' not found: searched {}",
390 imp.module_path,
391 file.display()
392 ),
393 &origin,
394 imp.line,
395 imp.column,
396 )
397 })?;
398 modules.insert(
399 imp.module_path.clone(),
400 LoadedModule {
401 origin: file.display().to_string(),
402 source: dep_source,
403 },
404 );
405 queue.push_back(imp.module_path);
406 }
407 }
408
409 Ok(ModuleSet {
410 entry: entry_path,
411 modules,
412 })
413 }
414
415 pub fn from_memory(
422 files: &BTreeMap<String, String>,
423 entry: &str,
424 ) -> Result<ModuleSet, ModuleError> {
425 if files.len() > MAX_MODULES {
426 return Err(ModuleError::new(
427 "axon-T953",
428 format!("bundle exceeds the {MAX_MODULES}-module ceiling"),
429 entry,
430 0,
431 0,
432 ));
433 }
434
435 let mut by_path: BTreeMap<ModulePath, (String, String)> = BTreeMap::new();
437 for (key, source) in files {
438 let mp = ModulePath::from_relative_file(key).ok_or_else(|| {
439 ModuleError::new(
440 "axon-T953",
441 format!(
442 "bundle file '{key}' is not a valid module path: segments must be \
443 identifiers and the extension must be .axon"
444 ),
445 key,
446 0,
447 0,
448 )
449 })?;
450 if by_path
451 .insert(mp.clone(), (key.clone(), source.clone()))
452 .is_some()
453 {
454 return Err(ModuleError::new(
455 "axon-T953",
456 format!("bundle files collide on module path '{mp}'"),
457 key,
458 0,
459 0,
460 ));
461 }
462 }
463
464 let entry_path = ModulePath::from_relative_file(entry).ok_or_else(|| {
465 ModuleError::new(
466 "axon-T953",
467 format!("bundle entry '{entry}' is not a valid module path"),
468 entry,
469 0,
470 0,
471 )
472 })?;
473 if !by_path.contains_key(&entry_path) {
474 return Err(ModuleError::new(
475 "axon-T953",
476 format!("bundle entry '{entry}' is not among the bundle files"),
477 entry,
478 0,
479 0,
480 ));
481 }
482
483 let mut reached: BTreeSet<ModulePath> = BTreeSet::new();
485 reached.insert(entry_path.clone());
486 let mut queue: VecDeque<ModulePath> = VecDeque::new();
487 queue.push_back(entry_path.clone());
488 while let Some(current) = queue.pop_front() {
489 let (origin, source) = &by_path[¤t];
490 let imports = scan_imports(source, origin).map_err(|msg| {
491 ModuleError::new("axon-T953", format!("lex error during discovery: {msg}"), origin, 0, 0)
492 })?;
493 for imp in imports {
494 if !imp.selective || imp.module_path.is_scoped() {
495 continue;
496 }
497 if !by_path.contains_key(&imp.module_path) {
498 return Err(ModuleError::new(
499 "axon-T953",
500 format!(
501 "module '{}' not found in bundle (expected file '{}')",
502 imp.module_path,
503 imp.module_path.relative_file().display()
504 ),
505 origin,
506 imp.line,
507 imp.column,
508 ));
509 }
510 if reached.insert(imp.module_path.clone()) {
511 queue.push_back(imp.module_path);
512 }
513 }
514 }
515 let dead: Vec<String> = by_path
516 .keys()
517 .filter(|p| !reached.contains(*p))
518 .map(|p| p.relative_file().display().to_string())
519 .collect();
520 if !dead.is_empty() {
521 return Err(ModuleError::new(
522 "axon-T953",
523 format!(
524 "bundle contains files unreachable from the entry: {}",
525 dead.join(", ")
526 ),
527 entry,
528 0,
529 0,
530 ));
531 }
532
533 let modules = by_path
534 .into_iter()
535 .map(|(mp, (origin, source))| (mp, LoadedModule { origin, source }))
536 .collect();
537 Ok(ModuleSet {
538 entry: entry_path,
539 modules,
540 })
541 }
542
543 pub fn get(&self, path: &ModulePath) -> Option<&LoadedModule> {
544 self.modules.get(path)
545 }
546
547 pub fn iter(&self) -> impl Iterator<Item = (&ModulePath, &LoadedModule)> {
549 self.modules.iter()
550 }
551
552 pub fn len(&self) -> usize {
553 self.modules.len()
554 }
555
556 pub fn is_empty(&self) -> bool {
557 self.modules.is_empty()
558 }
559}
560
561#[derive(Debug)]
568pub struct ModuleGraph {
569 pub order: Vec<ModulePath>,
572 pub imports: BTreeMap<ModulePath, Vec<ScannedImport>>,
575}
576
577impl ModuleGraph {
578 pub fn build(set: &ModuleSet) -> Result<ModuleGraph, ModuleError> {
581 let mut imports: BTreeMap<ModulePath, Vec<ScannedImport>> = BTreeMap::new();
582 let mut deps: BTreeMap<ModulePath, BTreeSet<ModulePath>> = BTreeMap::new();
585
586 for (path, module) in set.iter() {
587 let scanned = scan_imports(&module.source, &module.origin).map_err(|msg| {
588 ModuleError::new("axon-T953", format!("lex error during discovery: {msg}"), &module.origin, 0, 0)
589 })?;
590 let mut dep_set: BTreeSet<ModulePath> = BTreeSet::new();
591 for imp in &scanned {
592 if imp.selective && !imp.module_path.is_scoped() && set.get(&imp.module_path).is_some()
593 {
594 dep_set.insert(imp.module_path.clone());
597 }
598 }
599 imports.insert(path.clone(), scanned);
600 deps.insert(path.clone(), dep_set);
601 }
602
603 let mut in_degree: BTreeMap<&ModulePath, usize> =
605 deps.iter().map(|(p, d)| (p, d.len())).collect();
606 let mut dependents: BTreeMap<&ModulePath, Vec<&ModulePath>> = BTreeMap::new();
607 for (p, dset) in &deps {
608 for d in dset {
609 dependents.entry(d).or_default().push(p);
610 }
611 }
612
613 let mut ready: BTreeSet<&ModulePath> = in_degree
614 .iter()
615 .filter(|(_, deg)| **deg == 0)
616 .map(|(p, _)| *p)
617 .collect();
618 let mut order: Vec<ModulePath> = Vec::with_capacity(deps.len());
619 while let Some(&next) = ready.iter().next() {
620 ready.remove(next);
621 order.push(next.clone());
622 if let Some(deps_of_next) = dependents.get(next) {
623 for &dependent in deps_of_next {
624 let deg = in_degree.get_mut(dependent).expect("known module");
625 *deg -= 1;
626 if *deg == 0 {
627 ready.insert(dependent);
628 }
629 }
630 }
631 }
632
633 if order.len() != deps.len() {
634 let leftover: BTreeSet<&ModulePath> = deps
638 .keys()
639 .filter(|p| !order.contains(*p))
640 .collect();
641 let start = *leftover.iter().next().expect("non-empty leftover");
642 let mut path_walk: Vec<&ModulePath> = vec![start];
643 let mut seen: BTreeMap<&ModulePath, usize> = BTreeMap::new();
644 seen.insert(start, 0);
645 let mut current = start;
646 let cycle_text = loop {
647 let next = deps[current]
648 .iter()
649 .find(|d| leftover.contains(*d))
650 .expect("a leftover node always has a leftover dependency");
651 if let Some(&idx) = seen.get(next) {
652 let mut cyc: Vec<String> =
653 path_walk[idx..].iter().map(|p| p.dotted()).collect();
654 cyc.push(next.dotted());
655 break cyc.join(" → ");
656 }
657 seen.insert(next, path_walk.len());
658 path_walk.push(next);
659 current = next;
660 };
661 let origin = set
662 .get(start)
663 .map(|m| m.origin.clone())
664 .unwrap_or_default();
665 return Err(ModuleError::new(
666 "axon-T955",
667 format!(
668 "import cycle detected: {cycle_text}. Cognitive modules must form a DAG — \
669 a persona cannot depend on an anchor that depends on that persona's \
670 definition. Break the cycle by moving the shared definitions into a \
671 module both sides import."
672 ),
673 &origin,
674 0,
675 0,
676 ));
677 }
678
679 Ok(ModuleGraph { order, imports })
680 }
681
682 pub fn dependencies_of(&self, module: &ModulePath) -> Vec<&ModulePath> {
684 let mut out: Vec<&ModulePath> = Vec::new();
685 if let Some(imps) = self.imports.get(module) {
686 let mut seen = BTreeSet::new();
687 for imp in imps {
688 if imp.selective && !imp.module_path.is_scoped() && seen.insert(&imp.module_path) {
689 out.push(&imp.module_path);
690 }
691 }
692 }
693 out
694 }
695}
696
697#[cfg(test)]
702mod tests {
703 use super::*;
704
705 fn set_of(pairs: &[(&str, &str)], entry: &str) -> ModuleSet {
706 let files: BTreeMap<String, String> = pairs
707 .iter()
708 .map(|(k, v)| (k.to_string(), v.to_string()))
709 .collect();
710 ModuleSet::from_memory(&files, entry).expect("valid set")
711 }
712
713 #[test]
714 fn scan_finds_selective_import() {
715 let imps = scan_imports("import axon.security.{A, B}\n", "t.axon").unwrap();
716 assert_eq!(imps.len(), 1);
717 assert_eq!(imps[0].module_path.dotted(), "axon.security");
718 assert_eq!(imps[0].names, vec!["A", "B"]);
719 assert!(imps[0].selective);
720 assert!(!imps[0].allow_downgrade);
721 }
722
723 #[test]
724 fn scan_finds_allow_downgrade_valve() {
725 let imps = scan_imports("import a.b.{X} @allow_downgrade\n", "t.axon").unwrap();
726 assert!(imps[0].allow_downgrade);
727 }
728
729 #[test]
730 fn scan_flags_non_selective_and_scoped() {
731 let imps = scan_imports("import a.b\nimport @myscope.pkg.{X}\n", "t.axon").unwrap();
735 assert_eq!(imps.len(), 2);
736 assert!(!imps[0].selective);
737 assert!(imps[1].module_path.is_scoped());
738 }
739
740 #[test]
741 fn kahn_orders_dependencies_first() {
742 let set = set_of(
743 &[
744 ("main.axon", "import lib.a.{X}\n"),
745 ("lib/a.axon", "import lib.b.{Y}\n"),
746 ("lib/b.axon", "persona Y { domain: [\"d\"] }\n"),
747 ],
748 "main.axon",
749 );
750 let g = ModuleGraph::build(&set).unwrap();
751 let pos = |d: &str| g.order.iter().position(|p| p.dotted() == d).unwrap();
752 assert!(pos("lib.b") < pos("lib.a"));
753 assert!(pos("lib.a") < pos("main"));
754 }
755
756 #[test]
757 fn diamond_resolves_once_deterministically() {
758 let set = set_of(
759 &[
760 ("main.axon", "import b.{X}\nimport c.{Y}\n"),
761 ("b.axon", "import d.{Z}\n"),
762 ("c.axon", "import d.{Z}\n"),
763 ("d.axon", "anchor Z { require: source_citation }\n"),
764 ],
765 "main.axon",
766 );
767 let g = ModuleGraph::build(&set).unwrap();
768 assert_eq!(g.order.len(), 4);
769 assert_eq!(g.order.first().unwrap().dotted(), "d");
770 assert_eq!(g.order.last().unwrap().dotted(), "main");
771 }
772
773 #[test]
774 fn cycle_is_refused_with_named_path() {
775 let set = set_of(
776 &[
777 ("main.axon", "import a.{X}\n"),
778 ("a.axon", "import b.{Y}\n"),
779 ("b.axon", "import a.{X}\n"),
780 ],
781 "main.axon",
782 );
783 let err = ModuleGraph::build(&set).unwrap_err();
784 assert_eq!(err.code, "axon-T955");
785 assert!(err.message.contains("a → b → a") || err.message.contains("b → a → b"));
786 }
787
788 #[test]
789 fn self_import_is_a_cycle() {
790 let set = set_of(&[("main.axon", "import main.{X}\n")], "main.axon");
791 let err = ModuleGraph::build(&set).unwrap_err();
792 assert_eq!(err.code, "axon-T955");
793 }
794
795 #[test]
796 fn bundle_missing_module_is_refused() {
797 let files: BTreeMap<String, String> =
798 [("main.axon".to_string(), "import gone.{X}\n".to_string())].into();
799 let err = ModuleSet::from_memory(&files, "main.axon").unwrap_err();
800 assert_eq!(err.code, "axon-T953");
801 assert!(err.message.contains("gone"));
802 }
803
804 #[test]
805 fn bundle_dead_file_is_refused() {
806 let files: BTreeMap<String, String> = [
807 ("main.axon".to_string(), "persona P { domain: [\"x\"] }\n".to_string()),
808 ("dead.axon".to_string(), "persona Q { domain: [\"y\"] }\n".to_string()),
809 ]
810 .into();
811 let err = ModuleSet::from_memory(&files, "main.axon").unwrap_err();
812 assert!(err.message.contains("unreachable"));
813 assert!(err.message.contains("dead.axon"));
814 }
815}