Skip to main content

nova_driver/
analyze.rs

1//! The front end for the language server (spec
2//! `docs/superpowers/specs/2026-10-08-phase-3-2-lsp-core-design.md` §3).
3//!
4//! `analyze` runs what `check_file` runs, with three differences:
5//! - its sources come through [`Sources`], so an editor's unsaved buffers
6//!   are read;
7//! - with `keep_going`, every stage runs whatever the earlier ones found;
8//! - its diagnostics are returned rather than printed.
9//!
10//! The CLI's entry points share [`crate::program::load_program`] with it,
11//! through [`DiskSources`], and keep their own staged behaviour.
12
13use std::collections::HashSet;
14use std::path::{Path, PathBuf};
15
16use nova_diagnostics::{Diagnostic, FileDb, FileId, Severity};
17use nova_pm::{Graph, PackageId};
18use nova_resolver::{Definitions, ModuleSource};
19use nova_typeck::{CheckOptions, ProbePoint, ProbeResult};
20
21use crate::program::{a_dependencys_warning, load_program, registry_files, Load, Program};
22
23/// Where `analyze` reads a module's text.
24pub trait Sources {
25    /// The text of `path`: an editor's buffer if one is open, else the file.
26    fn read(&self, path: &Path) -> std::io::Result<String>;
27
28    /// Whether `a` and `b` name the same file. The default compares them as
29    /// written; the language server compares normalised paths.
30    fn same_file(&self, a: &Path, b: &Path) -> bool {
31        a == b
32    }
33}
34
35/// Reads every module from disk: the CLI's sources.
36pub struct DiskSources;
37
38impl Sources for DiskSources {
39    fn read(&self, path: &Path) -> std::io::Result<String> {
40        std::fs::read_to_string(path)
41    }
42}
43
44/// What `analyze` does beyond checking.
45#[derive(Debug, Clone, Default)]
46pub struct Options {
47    /// Run every stage whatever the earlier ones found (spec §3.2).
48    pub keep_going: bool,
49    /// Keep `@test` functions and put `std/test` in scope, as `nova test`
50    /// does (spec §13, decision 14).
51    pub tests: bool,
52    /// Check the entry as a module, not a program: no MIR lowering (spec
53    /// §3.3).
54    pub module_only: bool,
55    /// Record what is at this place (spec §4.1).
56    pub probe: Option<Probe>,
57    /// Record the language server's index (spec
58    /// `docs/superpowers/specs/2026-10-10-phase-3-4a-navigation-design.md`
59    /// §3, §3.6). Off for every CLI command.
60    pub index: bool,
61}
62
63/// A place in one of the program's files.
64#[derive(Debug, Clone)]
65pub struct Probe {
66    pub path: PathBuf,
67    pub offset: u32,
68}
69
70/// What `analyze` found.
71pub struct Analysis {
72    /// Every file read, std included.
73    pub db: FileDb,
74    pub diagnostics: Vec<Diagnostic>,
75    /// The program's own modules, by the paths they were read from, in load
76    /// order: `modules[i]` is `ModuleId(i)`.
77    pub modules: Vec<(FileId, PathBuf)>,
78    /// Each module's package, parallel to `modules`. `None` for a loose
79    /// module (spec 3.3a §6).
80    pub module_packages: Vec<Option<PackageId>>,
81    /// The package graph, as far as it resolved.
82    pub graph: Option<Graph>,
83    /// The root package's `nova.toml`, when the program has one
84    /// ([`Program::manifest`]).
85    pub manifest: Option<FileId>,
86    pub definitions: Option<Definitions>,
87    /// The merged AST the resolver built and the checker read: every
88    /// module's items, std's last, which [`Definitions::module_of`] maps to
89    /// their modules. Set whenever `definitions` is (spec 3.5a §15.2).
90    pub file: Option<nova_ast::File>,
91    /// The typed module, partial when errors were found.
92    pub module: Option<nova_hir::Module>,
93    pub probe: ProbeResult,
94    /// Every name the front end resolved, the resolver's imports merged
95    /// in: `None` unless [`Options::index`].
96    pub index: Option<nova_resolver::Index>,
97}
98
99/// Run the front end on `entry` for the language server (spec §3): the
100/// program [`Program::for_file`] finds for it.
101pub fn analyze(
102    entry: &Path,
103    sources: &dyn Sources,
104    options: &Options,
105) -> std::io::Result<Analysis> {
106    analyze_program(Program::for_file(entry), sources, options)
107}
108
109/// Run the front end on `program` (spec 3.3a §4.4, §6). MIR runs only
110/// when the program runs and `options.module_only` is off.
111pub fn analyze_program(
112    program: Program,
113    sources: &dyn Sources,
114    options: &Options,
115) -> std::io::Result<Analysis> {
116    let manifest = program.manifest();
117    let Program {
118        mut db,
119        graph,
120        roots,
121        runs,
122        mut diagnostics,
123    } = program;
124    let stop = |diags: &[Diagnostic]| !options.keep_going && has_error(diags);
125    let load = if stop(&diagnostics) {
126        Load::default()
127    } else {
128        load_program(graph.as_ref(), &roots, sources, &mut db)?
129    };
130    diagnostics.extend(load.diagnostics);
131    let dropped = load.dropped;
132    let registry = registry_files(graph.as_ref(), &load.modules);
133    let mut modules = load.modules;
134    let mut analysis = Analysis {
135        db,
136        diagnostics: Vec::new(),
137        modules: modules.iter().map(|m| (m.file, m.path.clone())).collect(),
138        module_packages: modules.iter().map(|m| m.package).collect(),
139        graph,
140        manifest,
141        definitions: None,
142        file: None,
143        module: None,
144        probe: ProbeResult::default(),
145        index: None,
146    };
147    if stop(&diagnostics) {
148        analysis.diagnostics = shown(diagnostics, &registry);
149        return Ok(analysis);
150    }
151
152    crate::strip_test_functions(&mut modules, options.tests);
153    let std_files: Vec<FileId> = nova_resolver::STD_MODULES
154        .iter()
155        .map(|&(name, src)| {
156            let short = name.strip_prefix("$std.").unwrap_or(name);
157            analysis.db.add(format!("<std/{short}>"), src)
158        })
159        .collect();
160    let extra_std = options.tests.then(|| {
161        let (name, src) = nova_resolver::STD_TEST_MODULE;
162        let short = name.strip_prefix("$std.").unwrap_or(name);
163        let file = analysis.db.add(format!("<std/{short}>"), src);
164        (nova_resolver::STD_TEST_MODULE, file)
165    });
166    let module_sources: Vec<ModuleSource> = modules
167        .iter()
168        .map(|m| ModuleSource {
169            name: m.name.clone(),
170            file: &m.ast,
171            imports: m.imports.clone(),
172            text: analysis.db.get_source(m.file),
173            package: m.package.map(|p| p.0),
174            importable: m.importable.clone(),
175        })
176        .collect();
177    let resolved = nova_resolver::resolve_program(&module_sources, &std_files, extra_std);
178    diagnostics.extend(resolved.diagnostics);
179    let imports = resolved.imports;
180    if stop(&diagnostics) {
181        analysis.diagnostics = shown(diagnostics, &registry);
182        analysis.definitions = Some(resolved.definitions);
183        analysis.file = Some(resolved.file);
184        return Ok(analysis);
185    }
186
187    let probe = options.probe.as_ref().and_then(|p| {
188        analysis
189            .modules
190            .iter()
191            .find(|(_, path)| sources.same_file(path, &p.path))
192            .map(|(file, _)| ProbePoint {
193                file: *file,
194                offset: p.offset,
195            })
196    });
197    let checked = nova_typeck::check_with(
198        &resolved.file,
199        &resolved.definitions,
200        &CheckOptions {
201            probe,
202            index: options.index,
203            sources: Some(&analysis.db),
204        },
205    );
206    diagnostics.extend(checked.diagnostics);
207    // MIR lowering assumes a well-formed program (spec §3.3), and a
208    // program without an entry `main` is checked as a module (3.3a §4.4).
209    let module_only = options.module_only || !runs;
210    let mut module = checked.module;
211    if !module_only && !has_error(&diagnostics) {
212        crate::keep_the_entry_main(&mut module, &resolved.definitions);
213        if let Err(mir) = nova_mir::lower_module(&module) {
214            diagnostics.extend(mir);
215        }
216    }
217    if options.keep_going {
218        diagnostics.retain(|d| !about_a_dropped_name(d, &dropped));
219    }
220    analysis.diagnostics = shown(diagnostics, &registry);
221    analysis.definitions = Some(resolved.definitions);
222    analysis.module = Some(module);
223    analysis.probe = checked.probe;
224    if let Some(mut index) = checked.index {
225        index.extend(imports);
226        analysis.index = Some(index);
227    }
228    analysis.file = Some(resolved.file);
229    Ok(analysis)
230}
231
232fn has_error(diagnostics: &[Diagnostic]) -> bool {
233    diagnostics.iter().any(|d| d.severity == Severity::Error)
234}
235
236/// `diagnostics` without a registry package's warnings (spec 3.3b §5.4).
237fn shown(mut diagnostics: Vec<Diagnostic>, registry: &HashSet<FileId>) -> Vec<Diagnostic> {
238    diagnostics.retain(|d| !a_dependencys_warning(d, registry));
239    diagnostics
240}
241
242/// Whether `d` is an E0001 about an item the parser dropped mid-edit (spec
243/// §3.2): its message's first backticked name is that item's.
244///
245/// Every E0001 message names what it cannot find first, in backticks:
246/// - "cannot find `x` in this scope";
247/// - "cannot find function `f` in this scope";
248/// - "cannot find type `T`", "cannot find record `R`", "cannot find trait
249///   `T`";
250/// - "`x` is not a public item of module `m`".
251///
252/// `tests/analyze.rs` pins each of them.
253fn about_a_dropped_name(d: &Diagnostic, dropped: &[String]) -> bool {
254    if d.code != "E0001" {
255        return false;
256    }
257    let mut parts = d.message.split('`');
258    parts.next();
259    parts
260        .next()
261        .is_some_and(|name| dropped.iter().any(|n| n == name))
262}