1use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::{Path, PathBuf};
8
9use nova_diagnostics::{Diagnostic, FileDb, FileId, Severity, Span};
10use nova_pm::{Edge, Graph, GraphPackage, Offline, PackageId};
11use nova_resolver::ImportTarget;
12
13use crate::analyze::Sources;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Roots {
18 Program,
20 Check,
22 Test,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum RootKind {
30 Src,
32 Tests,
34 Loose,
36 Doc,
43}
44
45#[derive(Debug, Clone)]
47pub struct Root {
48 pub path: PathBuf,
49 pub kind: RootKind,
50}
51
52pub struct Program {
54 pub db: FileDb,
56 pub graph: Option<Graph>,
59 pub roots: Vec<Root>,
61 pub runs: bool,
63 pub diagnostics: Vec<Diagnostic>,
65}
66
67impl Program {
68 pub fn loose(path: &Path) -> Program {
70 Program {
71 db: FileDb::new(),
72 graph: None,
73 roots: vec![Root {
74 path: path.to_path_buf(),
75 kind: RootKind::Loose,
76 }],
77 runs: true,
78 diagnostics: Vec::new(),
79 }
80 }
81
82 pub fn for_file(path: &Path) -> Program {
86 let Some((root, kind)) = package_of(path) else {
87 return Program::loose(path);
88 };
89 let mut db = FileDb::new();
90 let (graph, diagnostics) = nova_pm::graph(&root, &mut db);
91 let runs = kind == RootKind::Src && path.file_stem().is_some_and(|stem| stem == "main");
92 Program {
93 db,
94 graph,
95 roots: vec![Root {
96 path: path.to_path_buf(),
97 kind,
98 }],
99 runs,
100 diagnostics,
101 }
102 }
103
104 pub fn for_package(root: &Path, roots: Roots) -> Program {
107 Program::for_package_in(root, roots, &Offline::from_env())
108 }
109
110 pub fn for_package_in(root: &Path, roots: Roots, offline: &Offline) -> Program {
114 let mut db = FileDb::new();
115 let (graph, diagnostics) = nova_pm::graph_with(root, None, offline, &mut db);
116 let mut list = Vec::new();
117 let main = under(root, "src/main.nova");
118 if main.is_file() {
119 list.push(Root {
120 path: main,
121 kind: RootKind::Src,
122 });
123 }
124 let runs = !list.is_empty();
125 let lib = under(root, "src/lib.nova");
126 if roots != Roots::Program && lib.is_file() {
127 list.push(Root {
128 path: lib,
129 kind: RootKind::Src,
130 });
131 }
132 if roots == Roots::Test {
133 list.extend(
134 test_files(&under(root, "tests"))
135 .into_iter()
136 .map(|path| Root {
137 path,
138 kind: RootKind::Tests,
139 }),
140 );
141 }
142 Program {
143 db,
144 graph,
145 roots: list,
146 runs,
147 diagnostics,
148 }
149 }
150
151 pub fn doc_tests(package: Option<&Path>, paths: Vec<PathBuf>) -> Program {
166 let mut roots: Vec<Root> = paths
167 .into_iter()
168 .map(|path| Root {
169 path,
170 kind: RootKind::Doc,
171 })
172 .collect();
173 match package {
174 Some(root) => {
175 let lib = under(root, "src/lib.nova");
176 if !roots.is_empty() && lib.is_file() {
177 roots.push(Root {
178 path: lib,
179 kind: RootKind::Src,
180 });
181 }
182 Program {
183 roots,
184 runs: false,
185 ..Program::for_package(root, Roots::Program)
186 }
187 }
188 None => Program {
189 db: FileDb::new(),
190 graph: None,
191 roots,
192 runs: false,
193 diagnostics: Vec::new(),
194 },
195 }
196 }
197
198 pub fn has_errors(&self) -> bool {
200 self.diagnostics
201 .iter()
202 .any(|d| d.severity == Severity::Error)
203 }
204
205 pub fn manifest(&self) -> Option<FileId> {
208 match &self.graph {
209 Some(graph) => Some(graph.root().manifest_file),
210 None => self
213 .diagnostics
214 .iter()
215 .flat_map(|d| &d.labels)
216 .map(|label| label.span.file)
217 .next(),
218 }
219 }
220}
221
222fn under(root: &Path, relative: &str) -> PathBuf {
226 if root.as_os_str().is_empty() {
227 PathBuf::from(relative)
228 } else {
229 relative
230 .split('/')
231 .fold(root.to_path_buf(), |path, part| path.join(part))
232 }
233}
234
235pub fn package_of(path: &Path) -> Option<(PathBuf, RootKind)> {
239 let parent = path.parent()?;
240 let kind = match parent.file_name()?.to_str()? {
241 "src" => RootKind::Src,
242 "tests" => RootKind::Tests,
243 _ => return None,
244 };
245 let root = parent.parent()?;
246 root.join(nova_pm::MANIFEST)
247 .is_file()
248 .then(|| (root.to_path_buf(), kind))
249}
250
251fn test_files(dir: &Path) -> Vec<PathBuf> {
254 let Ok(entries) = std::fs::read_dir(dir) else {
255 return Vec::new();
256 };
257 let mut files: Vec<PathBuf> = entries
258 .filter_map(Result::ok)
259 .map(|entry| entry.path())
260 .filter(|path| path.extension().is_some_and(|ext| ext == "nova") && path.is_file())
261 .collect();
262 files.sort();
263 files
264}
265
266pub(crate) fn registry_files(graph: Option<&Graph>, modules: &[Loaded]) -> HashSet<FileId> {
269 let Some(graph) = graph else {
270 return HashSet::new();
271 };
272 modules
273 .iter()
274 .filter(|m| m.package.is_some_and(|p| graph.package(p).registry))
275 .map(|m| m.file)
276 .collect()
277}
278
279pub(crate) fn a_dependencys_warning(d: &Diagnostic, registry: &HashSet<FileId>) -> bool {
283 d.severity == Severity::Warning
284 && !d.labels.is_empty()
285 && d.labels
286 .iter()
287 .all(|label| registry.contains(&label.span.file))
288}
289
290pub(crate) struct Loaded {
292 pub name: String,
295 pub path: PathBuf,
296 pub file: FileId,
297 pub ast: nova_ast::File,
298 pub package: Option<PackageId>,
300 pub test_file: bool,
303 pub doc: bool,
305 pub imports: HashMap<String, ImportTarget>,
307 pub importable: Vec<(String, usize)>,
310}
311
312#[derive(Default)]
314pub(crate) struct Load {
315 pub modules: Vec<Loaded>,
317 pub diagnostics: Vec<Diagnostic>,
319 pub dropped: Vec<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Hash)]
325enum Place {
326 Package {
328 package: PackageId,
329 tests: bool,
330 stem: String,
331 },
332 Loose { dir: PathBuf, stem: String },
334 Doc {
337 package: Option<PackageId>,
338 path: PathBuf,
339 },
340}
341
342impl Place {
343 fn package(&self) -> Option<PackageId> {
344 match self {
345 Place::Package { package, .. } => Some(*package),
346 Place::Loose { .. } => None,
347 Place::Doc { package, .. } => *package,
348 }
349 }
350
351 fn is_test(&self) -> bool {
352 matches!(self, Place::Package { tests: true, .. } | Place::Doc { .. })
353 }
354
355 fn is_doc(&self) -> bool {
356 matches!(self, Place::Doc { .. })
357 }
358}
359
360enum Pending {
362 Place(Place),
363 Reported,
364}
365
366enum Found {
368 Module(Place, PathBuf),
370 Reported(Diagnostic),
372 Nothing,
374}
375
376pub(crate) fn load_program(
383 graph: Option<&Graph>,
384 roots: &[Root],
385 sources: &dyn Sources,
386 db: &mut FileDb,
387) -> std::io::Result<Load> {
388 let mut loader = Loader {
389 graph,
390 sources,
391 listings: HashMap::new(),
392 known: HashMap::new(),
393 };
394 let mut load = Load::default();
395 let mut pending: Vec<Vec<(String, Pending)>> = Vec::new();
396 let mut places: HashMap<Place, usize> = HashMap::new();
397 let mut tried: HashSet<Place> = HashSet::new();
398 let mut queue: VecDeque<(Place, PathBuf)> = roots
399 .iter()
400 .map(|root| (loader.root_place(root), root.path.clone()))
401 .collect();
402 let mut is_entry = true;
403
404 while let Some((place, path)) = queue.pop_front() {
405 let entry = std::mem::replace(&mut is_entry, false);
406 if !tried.insert(place.clone()) {
407 continue;
408 }
409 let source = match sources.read(&path) {
410 Ok(source) => source,
411 Err(e) if entry => return Err(e),
412 Err(_) => continue,
413 };
414 let file = db.add(path.display().to_string(), source.as_str());
415
416 let (tokens, lex_errors) = nova_lexer::lex(&source, file);
417 load.diagnostics.extend(lex_errors.iter().map(|e| {
418 Diagnostic::error("L0001", e.to_string()).with_primary_label(e.span(), "here")
419 }));
420 let parsed = nova_parser::parse_recovering(&tokens, file);
421 load.diagnostics.extend(parsed.errors.iter().map(|e| {
422 Diagnostic::error("P0001", e.to_string()).with_primary_label(e.span(), "here")
423 }));
424 load.dropped
425 .extend(parsed.dropped.into_iter().map(|n| n.value));
426
427 let mut imports = Vec::new();
430 for item in &parsed.file.items {
431 let nova_ast::Item::Import(import) = &item.value else {
432 continue;
433 };
434 let [segment] = import.path.value.segments.as_slice() else {
435 continue;
436 };
437 let name = segment.value.clone();
438 match loader.find(&place, &name, import.path.span, db) {
439 Found::Module(target, target_path) => {
440 queue.push_back((target.clone(), target_path));
441 imports.push((name, Pending::Place(target)));
442 }
443 Found::Reported(diagnostic) => {
444 load.diagnostics.push(diagnostic);
445 imports.push((name, Pending::Reported));
446 }
447 Found::Nothing => {}
448 }
449 }
450 places.insert(place.clone(), load.modules.len());
451 load.modules.push(Loaded {
452 name: loader.label(&place),
453 path,
454 file,
455 ast: parsed.file,
456 package: place.package(),
457 test_file: place.is_test(),
458 doc: place.is_doc(),
459 imports: HashMap::new(),
460 importable: Vec::new(),
461 });
462 pending.push(imports);
463 }
464
465 for (module, imports) in load.modules.iter_mut().zip(pending) {
466 for (name, target) in imports {
467 let target = match target {
468 Pending::Place(place) => match places.get(&place) {
469 Some(&index) => ImportTarget::Module(index),
470 None => continue,
472 },
473 Pending::Reported => ImportTarget::Reported,
474 };
475 module.imports.insert(name, target);
476 }
477 }
478 let mut place_of: Vec<Option<Place>> = vec![None; load.modules.len()];
481 for (place, &i) in &places {
482 place_of[i] = Some(place.clone());
483 }
484 let nowhere = Span::point(0, FileId::DUMMY);
485 let mut importables: Vec<Vec<(String, usize)>> = Vec::new();
486 for (i, from) in place_of.iter().enumerate() {
487 let mut importable = Vec::new();
488 if let Some(from) = from {
489 let mut names: Vec<String> = place_of
490 .iter()
491 .flatten()
492 .filter_map(|p| sibling_stem(from, p))
493 .collect();
494 let edges = match (from, graph) {
495 (Place::Package { package, tests, .. }, Some(graph)) => {
496 visible_edges(graph, *package, *tests)
497 }
498 (
499 Place::Doc {
500 package: Some(package),
501 ..
502 },
503 Some(graph),
504 ) => visible_edges(graph, *package, true),
505 _ => Vec::new(),
506 };
507 names.extend(edges.into_iter().map(|e| e.import_name));
508 names.sort();
509 names.dedup();
510 for name in names {
511 if let Found::Module(target, _) = loader.find(from, &name, nowhere, db) {
512 if let Some(&j) = places.get(&target) {
513 if j != i {
514 importable.push((name, j));
515 }
516 }
517 }
518 }
519 }
520 importables.push(importable);
521 }
522 for (module, importable) in load.modules.iter_mut().zip(importables) {
523 module.importable = importable;
524 }
525 Ok(load)
526}
527
528struct Loader<'a> {
529 graph: Option<&'a Graph>,
530 sources: &'a dyn Sources,
531 listings: HashMap<PathBuf, Vec<String>>,
533 known: HashMap<(PathBuf, String), bool>,
537}
538
539impl<'a> Loader<'a> {
540 fn root_place(&self, root: &Root) -> Place {
541 let stem = crate::FrontendContext::module_name(&root.path);
542 match (root.kind, self.graph) {
543 (RootKind::Src, Some(_)) => Place::Package {
544 package: PackageId(0),
545 tests: false,
546 stem,
547 },
548 (RootKind::Tests, Some(_)) => Place::Package {
549 package: PackageId(0),
550 tests: true,
551 stem,
552 },
553 (RootKind::Doc, graph) => Place::Doc {
554 package: graph.map(|_| PackageId(0)),
555 path: root.path.clone(),
556 },
557 _ => Place::Loose {
558 dir: root
559 .path
560 .parent()
561 .map(Path::to_path_buf)
562 .unwrap_or_default(),
563 stem,
564 },
565 }
566 }
567
568 fn label(&self, place: &Place) -> String {
570 match place {
571 Place::Loose { stem, .. } => stem.clone(),
572 Place::Doc { path, .. } => format!("doc/{}", crate::FrontendContext::module_name(path)),
573 Place::Package {
574 package: PackageId(0),
575 tests,
576 stem,
577 } => {
578 if *tests {
579 format!("tests/{stem}")
580 } else {
581 stem.clone()
582 }
583 }
584 Place::Package { package, stem, .. } => {
585 let import = &self.graph().package(*package).import_name;
586 if stem == "lib" {
587 import.clone()
588 } else {
589 format!("{import}/{stem}")
590 }
591 }
592 }
593 }
594
595 fn graph(&self) -> &'a Graph {
598 self.graph.expect("a package's module has a graph")
599 }
600
601 fn find(&mut self, from: &Place, name: &str, at: Span, db: &FileDb) -> Found {
604 let (package, tests, siblings) = match from {
605 Place::Loose { dir, .. } => {
606 if !self.has(dir, name) {
607 return Found::Nothing;
608 }
609 let place = Place::Loose {
610 dir: dir.clone(),
611 stem: name.to_string(),
612 };
613 return Found::Module(place, dir.join(format!("{name}.nova")));
614 }
615 Place::Package { package, tests, .. } => (*package, *tests, true),
616 Place::Doc {
619 package: Some(package),
620 ..
621 } => (*package, true, false),
622 Place::Doc { package: None, .. } => return Found::Nothing,
624 };
625 let graph = self.graph();
626 let dir = module_dir(graph.package(package), tests);
627 let edge = visible_edges(graph, package, tests)
628 .into_iter()
629 .find(|edge| edge.import_name == name);
630 let file = (siblings && self.has(&dir, name)).then(|| {
631 (
632 Place::Package {
633 package,
634 tests,
635 stem: name.to_string(),
636 },
637 dir.join(format!("{name}.nova")),
638 )
639 });
640 match (file, edge) {
641 (Some((_, path)), Some(edge)) => Found::Reported(clash(name, &path, &edge, at, db)),
642 (Some((place, path)), None) => Found::Module(place, path),
643 (None, Some(edge)) => {
644 let target = graph.package(edge.package);
645 if !target.has_lib {
646 return Found::Reported(
648 Diagnostic::error("E0001", format!("cannot find module `{name}`"))
649 .with_primary_label(at, "no such module")
650 .with_note(format!("`{}` has no src/lib.nova", target.name)),
651 );
652 }
653 Found::Module(
654 Place::Package {
655 package: edge.package,
656 tests: false,
657 stem: "lib".to_string(),
658 },
659 module_dir(target, false).join("lib.nova"),
660 )
661 }
662 (None, None) => {
663 let dev = !tests
664 && package == PackageId(0)
665 && graph
666 .root()
667 .dev_dependencies
668 .iter()
669 .any(|edge| edge.import_name == name);
670 if dev {
671 Found::Reported(
672 Diagnostic::error("E0001", format!("cannot find module `{name}`"))
673 .with_primary_label(at, "no such module")
674 .with_note(format!(
675 "`{name}` is a dev-dependency, which only `tests/` files can import"
676 )),
677 )
678 } else {
679 Found::Nothing
680 }
681 }
682 }
683 }
684
685 fn has(&mut self, dir: &Path, stem: &str) -> bool {
688 let key = (dir.to_path_buf(), stem.to_string());
689 if let Some(&known) = self.known.get(&key) {
690 return known;
691 }
692 let found = self.look(dir, stem);
693 self.known.insert(key, found);
694 found
695 }
696
697 fn look(&mut self, dir: &Path, stem: &str) -> bool {
705 let name = format!("{stem}.nova");
706 let listed = if dir.as_os_str().is_empty() {
707 Path::new(".")
708 } else {
709 dir
710 };
711 let listing = self.listings.entry(dir.to_path_buf()).or_insert_with(|| {
712 std::fs::read_dir(listed)
713 .map(|entries| {
714 entries
715 .filter_map(Result::ok)
716 .filter_map(|entry| entry.file_name().into_string().ok())
717 .collect()
718 })
719 .unwrap_or_default()
720 });
721 if listing.contains(&name) {
722 return true;
723 }
724 if listing.iter().any(|n| n.eq_ignore_ascii_case(&name)) {
725 return false;
726 }
727 self.sources.read(&dir.join(&name)).is_ok()
728 }
729}
730
731fn module_dir(package: &GraphPackage, tests: bool) -> PathBuf {
733 package.dir.join(if tests { "tests" } else { "src" })
734}
735
736fn sibling_stem(from: &Place, p: &Place) -> Option<String> {
740 match (from, p) {
741 (Place::Loose { dir: a, .. }, Place::Loose { dir: b, stem }) if a == b => {
742 Some(stem.clone())
743 }
744 (
745 Place::Package {
746 package: a,
747 tests: s,
748 ..
749 },
750 Place::Package {
751 package: b,
752 tests: t,
753 stem,
754 },
755 ) if a == b && s == t => Some(stem.clone()),
756 _ => None,
757 }
758}
759
760fn visible_edges(graph: &Graph, package: PackageId, tests: bool) -> Vec<Edge> {
764 let p = graph.package(package);
765 let mut edges = p.dependencies.clone();
766 if tests {
767 edges.extend(p.dev_dependencies.iter().cloned());
768 edges.push(Edge {
769 import_name: p.import_name.clone(),
770 package,
771 span: p.manifest.package.span,
772 });
773 }
774 edges
775}
776
777fn clash(name: &str, file: &Path, edge: &Edge, at: Span, db: &FileDb) -> Diagnostic {
781 let manifest = db.get_name(edge.span.file).unwrap_or("nova.toml");
782 let line = db
783 .location(edge.span.file, edge.span.start)
784 .map_or(0, |(line, _)| line);
785 Diagnostic::error(
786 "E0004",
787 format!(
788 "`{name}` is both a module of this package and a dependency: {}, and the entry \
789 at {manifest}:{line}",
790 file.display()
791 ),
792 )
793 .with_primary_label(at, "this import is ambiguous")
794 .with_secondary_label(edge.span, "the dependency")
795}