use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use nova_diagnostics::{Diagnostic, FileDb, FileId, Severity, Span};
use nova_pm::{Edge, Graph, GraphPackage, Offline, PackageId};
use nova_resolver::ImportTarget;
use crate::analyze::Sources;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Roots {
Program,
Check,
Test,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RootKind {
Src,
Tests,
Loose,
Doc,
}
#[derive(Debug, Clone)]
pub struct Root {
pub path: PathBuf,
pub kind: RootKind,
}
pub struct Program {
pub db: FileDb,
pub graph: Option<Graph>,
pub roots: Vec<Root>,
pub runs: bool,
pub diagnostics: Vec<Diagnostic>,
}
impl Program {
pub fn loose(path: &Path) -> Program {
Program {
db: FileDb::new(),
graph: None,
roots: vec![Root {
path: path.to_path_buf(),
kind: RootKind::Loose,
}],
runs: true,
diagnostics: Vec::new(),
}
}
pub fn for_file(path: &Path) -> Program {
let Some((root, kind)) = package_of(path) else {
return Program::loose(path);
};
let mut db = FileDb::new();
let (graph, diagnostics) = nova_pm::graph(&root, &mut db);
let runs = kind == RootKind::Src && path.file_stem().is_some_and(|stem| stem == "main");
Program {
db,
graph,
roots: vec![Root {
path: path.to_path_buf(),
kind,
}],
runs,
diagnostics,
}
}
pub fn for_package(root: &Path, roots: Roots) -> Program {
Program::for_package_in(root, roots, &Offline::from_env())
}
pub fn for_package_in(root: &Path, roots: Roots, offline: &Offline) -> Program {
let mut db = FileDb::new();
let (graph, diagnostics) = nova_pm::graph_with(root, None, offline, &mut db);
let mut list = Vec::new();
let main = under(root, "src/main.nova");
if main.is_file() {
list.push(Root {
path: main,
kind: RootKind::Src,
});
}
let runs = !list.is_empty();
let lib = under(root, "src/lib.nova");
if roots != Roots::Program && lib.is_file() {
list.push(Root {
path: lib,
kind: RootKind::Src,
});
}
if roots == Roots::Test {
list.extend(
test_files(&under(root, "tests"))
.into_iter()
.map(|path| Root {
path,
kind: RootKind::Tests,
}),
);
}
Program {
db,
graph,
roots: list,
runs,
diagnostics,
}
}
pub fn doc_tests(package: Option<&Path>, paths: Vec<PathBuf>) -> Program {
let mut roots: Vec<Root> = paths
.into_iter()
.map(|path| Root {
path,
kind: RootKind::Doc,
})
.collect();
match package {
Some(root) => {
let lib = under(root, "src/lib.nova");
if !roots.is_empty() && lib.is_file() {
roots.push(Root {
path: lib,
kind: RootKind::Src,
});
}
Program {
roots,
runs: false,
..Program::for_package(root, Roots::Program)
}
}
None => Program {
db: FileDb::new(),
graph: None,
roots,
runs: false,
diagnostics: Vec::new(),
},
}
}
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
}
pub fn manifest(&self) -> Option<FileId> {
match &self.graph {
Some(graph) => Some(graph.root().manifest_file),
None => self
.diagnostics
.iter()
.flat_map(|d| &d.labels)
.map(|label| label.span.file)
.next(),
}
}
}
fn under(root: &Path, relative: &str) -> PathBuf {
if root.as_os_str().is_empty() {
PathBuf::from(relative)
} else {
relative
.split('/')
.fold(root.to_path_buf(), |path, part| path.join(part))
}
}
pub fn package_of(path: &Path) -> Option<(PathBuf, RootKind)> {
let parent = path.parent()?;
let kind = match parent.file_name()?.to_str()? {
"src" => RootKind::Src,
"tests" => RootKind::Tests,
_ => return None,
};
let root = parent.parent()?;
root.join(nova_pm::MANIFEST)
.is_file()
.then(|| (root.to_path_buf(), kind))
}
fn test_files(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "nova") && path.is_file())
.collect();
files.sort();
files
}
pub(crate) fn registry_files(graph: Option<&Graph>, modules: &[Loaded]) -> HashSet<FileId> {
let Some(graph) = graph else {
return HashSet::new();
};
modules
.iter()
.filter(|m| m.package.is_some_and(|p| graph.package(p).registry))
.map(|m| m.file)
.collect()
}
pub(crate) fn a_dependencys_warning(d: &Diagnostic, registry: &HashSet<FileId>) -> bool {
d.severity == Severity::Warning
&& !d.labels.is_empty()
&& d.labels
.iter()
.all(|label| registry.contains(&label.span.file))
}
pub(crate) struct Loaded {
pub name: String,
pub path: PathBuf,
pub file: FileId,
pub ast: nova_ast::File,
pub package: Option<PackageId>,
pub test_file: bool,
pub doc: bool,
pub imports: HashMap<String, ImportTarget>,
pub importable: Vec<(String, usize)>,
}
#[derive(Default)]
pub(crate) struct Load {
pub modules: Vec<Loaded>,
pub diagnostics: Vec<Diagnostic>,
pub dropped: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Place {
Package {
package: PackageId,
tests: bool,
stem: String,
},
Loose { dir: PathBuf, stem: String },
Doc {
package: Option<PackageId>,
path: PathBuf,
},
}
impl Place {
fn package(&self) -> Option<PackageId> {
match self {
Place::Package { package, .. } => Some(*package),
Place::Loose { .. } => None,
Place::Doc { package, .. } => *package,
}
}
fn is_test(&self) -> bool {
matches!(self, Place::Package { tests: true, .. } | Place::Doc { .. })
}
fn is_doc(&self) -> bool {
matches!(self, Place::Doc { .. })
}
}
enum Pending {
Place(Place),
Reported,
}
enum Found {
Module(Place, PathBuf),
Reported(Diagnostic),
Nothing,
}
pub(crate) fn load_program(
graph: Option<&Graph>,
roots: &[Root],
sources: &dyn Sources,
db: &mut FileDb,
) -> std::io::Result<Load> {
let mut loader = Loader {
graph,
sources,
listings: HashMap::new(),
known: HashMap::new(),
};
let mut load = Load::default();
let mut pending: Vec<Vec<(String, Pending)>> = Vec::new();
let mut places: HashMap<Place, usize> = HashMap::new();
let mut tried: HashSet<Place> = HashSet::new();
let mut queue: VecDeque<(Place, PathBuf)> = roots
.iter()
.map(|root| (loader.root_place(root), root.path.clone()))
.collect();
let mut is_entry = true;
while let Some((place, path)) = queue.pop_front() {
let entry = std::mem::replace(&mut is_entry, false);
if !tried.insert(place.clone()) {
continue;
}
let source = match sources.read(&path) {
Ok(source) => source,
Err(e) if entry => return Err(e),
Err(_) => continue,
};
let file = db.add(path.display().to_string(), source.as_str());
let (tokens, lex_errors) = nova_lexer::lex(&source, file);
load.diagnostics.extend(lex_errors.iter().map(|e| {
Diagnostic::error("L0001", e.to_string()).with_primary_label(e.span(), "here")
}));
let parsed = nova_parser::parse_recovering(&tokens, file);
load.diagnostics.extend(parsed.errors.iter().map(|e| {
Diagnostic::error("P0001", e.to_string()).with_primary_label(e.span(), "here")
}));
load.dropped
.extend(parsed.dropped.into_iter().map(|n| n.value));
let mut imports = Vec::new();
for item in &parsed.file.items {
let nova_ast::Item::Import(import) = &item.value else {
continue;
};
let [segment] = import.path.value.segments.as_slice() else {
continue;
};
let name = segment.value.clone();
match loader.find(&place, &name, import.path.span, db) {
Found::Module(target, target_path) => {
queue.push_back((target.clone(), target_path));
imports.push((name, Pending::Place(target)));
}
Found::Reported(diagnostic) => {
load.diagnostics.push(diagnostic);
imports.push((name, Pending::Reported));
}
Found::Nothing => {}
}
}
places.insert(place.clone(), load.modules.len());
load.modules.push(Loaded {
name: loader.label(&place),
path,
file,
ast: parsed.file,
package: place.package(),
test_file: place.is_test(),
doc: place.is_doc(),
imports: HashMap::new(),
importable: Vec::new(),
});
pending.push(imports);
}
for (module, imports) in load.modules.iter_mut().zip(pending) {
for (name, target) in imports {
let target = match target {
Pending::Place(place) => match places.get(&place) {
Some(&index) => ImportTarget::Module(index),
None => continue,
},
Pending::Reported => ImportTarget::Reported,
};
module.imports.insert(name, target);
}
}
let mut place_of: Vec<Option<Place>> = vec![None; load.modules.len()];
for (place, &i) in &places {
place_of[i] = Some(place.clone());
}
let nowhere = Span::point(0, FileId::DUMMY);
let mut importables: Vec<Vec<(String, usize)>> = Vec::new();
for (i, from) in place_of.iter().enumerate() {
let mut importable = Vec::new();
if let Some(from) = from {
let mut names: Vec<String> = place_of
.iter()
.flatten()
.filter_map(|p| sibling_stem(from, p))
.collect();
let edges = match (from, graph) {
(Place::Package { package, tests, .. }, Some(graph)) => {
visible_edges(graph, *package, *tests)
}
(
Place::Doc {
package: Some(package),
..
},
Some(graph),
) => visible_edges(graph, *package, true),
_ => Vec::new(),
};
names.extend(edges.into_iter().map(|e| e.import_name));
names.sort();
names.dedup();
for name in names {
if let Found::Module(target, _) = loader.find(from, &name, nowhere, db) {
if let Some(&j) = places.get(&target) {
if j != i {
importable.push((name, j));
}
}
}
}
}
importables.push(importable);
}
for (module, importable) in load.modules.iter_mut().zip(importables) {
module.importable = importable;
}
Ok(load)
}
struct Loader<'a> {
graph: Option<&'a Graph>,
sources: &'a dyn Sources,
listings: HashMap<PathBuf, Vec<String>>,
known: HashMap<(PathBuf, String), bool>,
}
impl<'a> Loader<'a> {
fn root_place(&self, root: &Root) -> Place {
let stem = crate::FrontendContext::module_name(&root.path);
match (root.kind, self.graph) {
(RootKind::Src, Some(_)) => Place::Package {
package: PackageId(0),
tests: false,
stem,
},
(RootKind::Tests, Some(_)) => Place::Package {
package: PackageId(0),
tests: true,
stem,
},
(RootKind::Doc, graph) => Place::Doc {
package: graph.map(|_| PackageId(0)),
path: root.path.clone(),
},
_ => Place::Loose {
dir: root
.path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default(),
stem,
},
}
}
fn label(&self, place: &Place) -> String {
match place {
Place::Loose { stem, .. } => stem.clone(),
Place::Doc { path, .. } => format!("doc/{}", crate::FrontendContext::module_name(path)),
Place::Package {
package: PackageId(0),
tests,
stem,
} => {
if *tests {
format!("tests/{stem}")
} else {
stem.clone()
}
}
Place::Package { package, stem, .. } => {
let import = &self.graph().package(*package).import_name;
if stem == "lib" {
import.clone()
} else {
format!("{import}/{stem}")
}
}
}
}
fn graph(&self) -> &'a Graph {
self.graph.expect("a package's module has a graph")
}
fn find(&mut self, from: &Place, name: &str, at: Span, db: &FileDb) -> Found {
let (package, tests, siblings) = match from {
Place::Loose { dir, .. } => {
if !self.has(dir, name) {
return Found::Nothing;
}
let place = Place::Loose {
dir: dir.clone(),
stem: name.to_string(),
};
return Found::Module(place, dir.join(format!("{name}.nova")));
}
Place::Package { package, tests, .. } => (*package, *tests, true),
Place::Doc {
package: Some(package),
..
} => (*package, true, false),
Place::Doc { package: None, .. } => return Found::Nothing,
};
let graph = self.graph();
let dir = module_dir(graph.package(package), tests);
let edge = visible_edges(graph, package, tests)
.into_iter()
.find(|edge| edge.import_name == name);
let file = (siblings && self.has(&dir, name)).then(|| {
(
Place::Package {
package,
tests,
stem: name.to_string(),
},
dir.join(format!("{name}.nova")),
)
});
match (file, edge) {
(Some((_, path)), Some(edge)) => Found::Reported(clash(name, &path, &edge, at, db)),
(Some((place, path)), None) => Found::Module(place, path),
(None, Some(edge)) => {
let target = graph.package(edge.package);
if !target.has_lib {
return Found::Reported(
Diagnostic::error("E0001", format!("cannot find module `{name}`"))
.with_primary_label(at, "no such module")
.with_note(format!("`{}` has no src/lib.nova", target.name)),
);
}
Found::Module(
Place::Package {
package: edge.package,
tests: false,
stem: "lib".to_string(),
},
module_dir(target, false).join("lib.nova"),
)
}
(None, None) => {
let dev = !tests
&& package == PackageId(0)
&& graph
.root()
.dev_dependencies
.iter()
.any(|edge| edge.import_name == name);
if dev {
Found::Reported(
Diagnostic::error("E0001", format!("cannot find module `{name}`"))
.with_primary_label(at, "no such module")
.with_note(format!(
"`{name}` is a dev-dependency, which only `tests/` files can import"
)),
)
} else {
Found::Nothing
}
}
}
}
fn has(&mut self, dir: &Path, stem: &str) -> bool {
let key = (dir.to_path_buf(), stem.to_string());
if let Some(&known) = self.known.get(&key) {
return known;
}
let found = self.look(dir, stem);
self.known.insert(key, found);
found
}
fn look(&mut self, dir: &Path, stem: &str) -> bool {
let name = format!("{stem}.nova");
let listed = if dir.as_os_str().is_empty() {
Path::new(".")
} else {
dir
};
let listing = self.listings.entry(dir.to_path_buf()).or_insert_with(|| {
std::fs::read_dir(listed)
.map(|entries| {
entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().into_string().ok())
.collect()
})
.unwrap_or_default()
});
if listing.contains(&name) {
return true;
}
if listing.iter().any(|n| n.eq_ignore_ascii_case(&name)) {
return false;
}
self.sources.read(&dir.join(&name)).is_ok()
}
}
fn module_dir(package: &GraphPackage, tests: bool) -> PathBuf {
package.dir.join(if tests { "tests" } else { "src" })
}
fn sibling_stem(from: &Place, p: &Place) -> Option<String> {
match (from, p) {
(Place::Loose { dir: a, .. }, Place::Loose { dir: b, stem }) if a == b => {
Some(stem.clone())
}
(
Place::Package {
package: a,
tests: s,
..
},
Place::Package {
package: b,
tests: t,
stem,
},
) if a == b && s == t => Some(stem.clone()),
_ => None,
}
}
fn visible_edges(graph: &Graph, package: PackageId, tests: bool) -> Vec<Edge> {
let p = graph.package(package);
let mut edges = p.dependencies.clone();
if tests {
edges.extend(p.dev_dependencies.iter().cloned());
edges.push(Edge {
import_name: p.import_name.clone(),
package,
span: p.manifest.package.span,
});
}
edges
}
fn clash(name: &str, file: &Path, edge: &Edge, at: Span, db: &FileDb) -> Diagnostic {
let manifest = db.get_name(edge.span.file).unwrap_or("nova.toml");
let line = db
.location(edge.span.file, edge.span.start)
.map_or(0, |(line, _)| line);
Diagnostic::error(
"E0004",
format!(
"`{name}` is both a module of this package and a dependency: {}, and the entry \
at {manifest}:{line}",
file.display()
),
)
.with_primary_label(at, "this import is ambiguous")
.with_secondary_label(edge.span, "the dependency")
}