Skip to main content

gruel_lsp/
analysis.rs

1//! Compile analysis worker (ADR-0091).
2//!
3//! The LSP runs one frontend compile per open root: the root is one open
4//! editor buffer, and its compilation unit is the root plus every file
5//! transitively reachable through `@import("...")` (open buffers preferred
6//! over disk). Unrelated workspace files are never merged together — that
7//! avoids the cascade of duplicate-`fn main()` diagnostics that the old
8//! whole-workspace-as-one-unit model produced the moment a workspace held
9//! more than one program.
10
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use gruel_compiler::{
15    FileId, JsonDiagnostic, MultiFileJsonFormatter, PreviewFeatures, SourceFile, SourceInfo, Type,
16    TypeInternPool, compile_frontend_from_ast_with_file_paths, merge_symbols,
17    parse_all_files_with_preview, prepend_prelude,
18};
19use gruel_parser::ast::Ast;
20use gruel_target::Target;
21use gruel_util::Span;
22use rustc_hash::FxHashMap;
23
24use crate::position::LineMap;
25use crate::workspace::build_root_closure;
26
27/// One source file the worker can see (either an open editor buffer or a
28/// file on disk).
29#[derive(Debug, Clone)]
30pub struct WorkspaceFile {
31    pub path: PathBuf,
32    pub text: String,
33    /// The file_id assigned to this file (stable across compiles within a
34    /// single workspace pass).
35    pub file_id: FileId,
36}
37
38/// Successful compile snapshot (sema completed even if errors were reported).
39///
40/// The LSP keeps the most recent `Snapshot` available via `ArcSwap` so that
41/// hover/goto/references can serve queries while a new compile is in flight.
42#[derive(Debug)]
43pub struct Snapshot {
44    pub ast: Ast,
45    /// Shared interner used to resolve identifiers from the AST.
46    ///
47    /// Phase 1 only walks the AST for hover/goto; the interner is owned here
48    /// so later phases can re-resolve `Spur`s without re-parsing.
49    pub interner: Arc<lasso::ThreadedRodeo>,
50    /// File contents at the time this snapshot was captured.
51    pub sources: FxHashMap<FileId, WorkspaceFile>,
52    /// Source path -> file_id reverse map.
53    pub path_to_file_id: FxHashMap<PathBuf, FileId>,
54    /// Line maps for each open file.
55    pub line_maps: FxHashMap<FileId, LineMap>,
56    /// Type intern pool from sema. Owned so `format_type_name` works for
57    /// hover queries.
58    pub type_pool: Option<Arc<TypeInternPool>>,
59    /// Phase 4 side-table: span → result type for every AIR instruction
60    /// produced during sema. Built post-analysis by walking
61    /// `AnalyzedFunction.air` so we don't have to instrument sema.
62    pub expr_types: FxHashMap<Span, Type>,
63}
64
65/// Result of one compile pass.
66pub struct AnalysisResult {
67    pub diagnostics: Vec<JsonDiagnostic>,
68    pub snapshot: Option<Snapshot>,
69}
70
71/// Analyze a single root file plus its transitively-`@import`-reachable
72/// closure. Builds the closure (open-buffer text wins over disk), runs the
73/// frontend, and returns per-file diagnostics with an optional `Snapshot`
74/// when sema completed.
75pub fn analyze_root<F>(
76    root: WorkspaceFile,
77    workspace_root: Option<&Path>,
78    preview_features: &PreviewFeatures,
79    target: &Target,
80    open_text: F,
81) -> AnalysisResult
82where
83    F: FnMut(&Path) -> Option<String>,
84{
85    let closure = build_root_closure(root, workspace_root, preview_features, open_text);
86    analyze(&closure, preview_features, target)
87}
88
89/// Compile the given workspace files via the frontend and return
90/// diagnostics + an optional successful snapshot.
91pub fn analyze(
92    files: &[WorkspaceFile],
93    preview_features: &PreviewFeatures,
94    target: &Target,
95) -> AnalysisResult {
96    if files.is_empty() {
97        return AnalysisResult {
98            diagnostics: vec![],
99            snapshot: None,
100        };
101    }
102
103    // Build SourceFile views.
104    let sources: Vec<SourceFile<'_>> = files
105        .iter()
106        .map(|f| SourceFile::new(path_str(&f.path), f.text.as_str(), f.file_id))
107        .collect();
108
109    // Source info for diagnostic formatting.
110    let source_infos: Vec<(FileId, SourceInfo<'_>)> = files
111        .iter()
112        .map(|f| {
113            (
114                f.file_id,
115                SourceInfo::new(f.text.as_str(), path_str(&f.path)),
116            )
117        })
118        .collect();
119    let formatter = MultiFileJsonFormatter::new(source_infos);
120
121    let mut diagnostics = Vec::new();
122
123    // Parse all files with the shared interner.
124    let parsed = match parse_all_files_with_preview(&sources, preview_features) {
125        Ok(p) => p,
126        Err(errors) => {
127            for e in errors.iter() {
128                diagnostics.push(formatter.format_error(e));
129            }
130            return AnalysisResult {
131                diagnostics,
132                snapshot: None,
133            };
134        }
135    };
136
137    // Merge symbols.
138    let merged = match merge_symbols(parsed) {
139        Ok(m) => m,
140        Err(errors) => {
141            for e in errors.iter() {
142                diagnostics.push(formatter.format_error(e));
143            }
144            return AnalysisResult {
145                diagnostics,
146                snapshot: None,
147            };
148        }
149    };
150
151    // Inline the prelude so sema's TypeKind / Vec / etc. injections see
152    // their target types. The CLI driver does the same thing via
153    // `CompilationUnit::parse`; we follow suit for parity (and for the
154    // diagnostic differential to hold).
155    let (ast_with_prelude, interner_with_prelude) =
156        match prepend_prelude(merged.ast, merged.interner, preview_features) {
157            Ok(p) => p,
158            Err(errors) => {
159                for e in errors.iter() {
160                    diagnostics.push(formatter.format_error(e));
161                }
162                return AnalysisResult {
163                    diagnostics,
164                    snapshot: None,
165                };
166            }
167        };
168    let ast_for_snapshot = ast_with_prelude.clone();
169
170    // Pass file_paths so sema's @import resolver can find the closure's
171    // siblings. Without this, every `@import("foo.gruel")` would fail with
172    // ModuleNotFound even though we just parsed `foo.gruel` for the unit.
173    let file_paths: FxHashMap<FileId, String> = files
174        .iter()
175        .map(|f| (f.file_id, f.path.to_string_lossy().into_owned()))
176        .collect();
177    let state = match compile_frontend_from_ast_with_file_paths(
178        ast_with_prelude,
179        interner_with_prelude,
180        preview_features,
181        true, // suppress comptime @dbg print
182        target,
183        file_paths,
184    ) {
185        Ok(state) => state,
186        Err(errors) => {
187            for e in errors.iter() {
188                diagnostics.push(formatter.format_error(e));
189            }
190            // Sema failed before producing a CompileState; we still need an
191            // AST-only snapshot for syntactic queries. Re-parse to recover an
192            // interner — simpler than threading a clone through every error
193            // path.
194            return AnalysisResult {
195                diagnostics,
196                snapshot: build_ast_snapshot(files, preview_features).map(|s| s).ok(),
197            };
198        }
199    };
200
201    for warning in &state.warnings {
202        diagnostics.push(formatter.format_warning(warning));
203    }
204
205    let interner_for_snapshot = Arc::new(state.interner);
206    let type_pool = Arc::new(state.type_pool);
207
208    // Build expr_types side-table by walking each function's AIR.
209    let mut expr_types: FxHashMap<Span, Type> = FxHashMap::default();
210    for f in &state.functions {
211        for (_air_ref, inst) in f.analyzed.air.iter() {
212            // Skip Span::default() / unknown spans — they belong to synthesized
213            // instructions (e.g. drop glue) with no source location.
214            if inst.span.start == 0 && inst.span.end == 0 {
215                continue;
216            }
217            // Don't overwrite a narrower entry with a wider one — earlier
218            // instructions are typically more specific. We dedupe on (span,
219            // type) so the value is deterministic regardless of insertion
220            // order.
221            expr_types.entry(inst.span).or_insert(inst.ty);
222        }
223    }
224
225    AnalysisResult {
226        diagnostics,
227        snapshot: Some(build_snapshot_full(
228            files,
229            ast_for_snapshot,
230            interner_for_snapshot,
231            Some(type_pool),
232            expr_types,
233        )),
234    }
235}
236
237/// Re-parse the workspace once to produce an AST-only snapshot (used when
238/// sema fails — diagnostics already cover the errors; we still want the AST
239/// for syntactic LSP queries).
240fn build_ast_snapshot(
241    files: &[WorkspaceFile],
242    preview_features: &PreviewFeatures,
243) -> Result<Snapshot, ()> {
244    let sources: Vec<SourceFile<'_>> = files
245        .iter()
246        .map(|f| SourceFile::new(path_str(&f.path), f.text.as_str(), f.file_id))
247        .collect();
248    let parsed = parse_all_files_with_preview(&sources, preview_features).map_err(|_| ())?;
249    let merged = merge_symbols(parsed).map_err(|_| ())?;
250    let (ast, interner) =
251        prepend_prelude(merged.ast, merged.interner, preview_features).map_err(|_| ())?;
252    let interner = Arc::new(interner);
253    Ok(build_snapshot(files, ast, interner))
254}
255
256fn build_snapshot(
257    files: &[WorkspaceFile],
258    ast: Ast,
259    interner: Arc<lasso::ThreadedRodeo>,
260) -> Snapshot {
261    build_snapshot_full(files, ast, interner, None, FxHashMap::default())
262}
263
264fn build_snapshot_full(
265    files: &[WorkspaceFile],
266    ast: Ast,
267    interner: Arc<lasso::ThreadedRodeo>,
268    type_pool: Option<Arc<TypeInternPool>>,
269    expr_types: FxHashMap<Span, Type>,
270) -> Snapshot {
271    let mut sources: FxHashMap<FileId, WorkspaceFile> = FxHashMap::default();
272    let mut path_to_file_id: FxHashMap<PathBuf, FileId> = FxHashMap::default();
273    let mut line_maps: FxHashMap<FileId, LineMap> = FxHashMap::default();
274    for f in files {
275        line_maps.insert(f.file_id, LineMap::new(&f.text));
276        path_to_file_id.insert(f.path.clone(), f.file_id);
277        sources.insert(f.file_id, f.clone());
278    }
279    Snapshot {
280        ast,
281        interner,
282        sources,
283        path_to_file_id,
284        line_maps,
285        type_pool,
286        expr_types,
287    }
288}
289
290fn path_str(path: &std::path::Path) -> &str {
291    path.to_str().unwrap_or("<non-utf8>")
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn wsf(path: &str, text: &str, id: u32) -> WorkspaceFile {
299        WorkspaceFile {
300            path: PathBuf::from(path),
301            text: text.to_string(),
302            file_id: FileId::new(id),
303        }
304    }
305
306    #[test]
307    fn compiles_clean_program() {
308        let files = vec![wsf("main.gruel", "fn main() -> i32 { 0 }", 1)];
309        let res = analyze(&files, &PreviewFeatures::default(), &Target::host());
310        assert!(
311            res.diagnostics.is_empty(),
312            "expected no diagnostics, got: {:?}",
313            res.diagnostics
314        );
315        assert!(res.snapshot.is_some());
316    }
317
318    #[test]
319    fn reports_type_error() {
320        let files = vec![wsf("main.gruel", "fn main() -> i32 { true }", 1)];
321        let res = analyze(&files, &PreviewFeatures::default(), &Target::host());
322        assert!(
323            !res.diagnostics.is_empty(),
324            "expected diagnostics for type error"
325        );
326        assert!(res.diagnostics.iter().any(|d| d.severity == "error"));
327    }
328
329    #[test]
330    fn reports_warnings() {
331        let files = vec![wsf("main.gruel", "fn main() -> i32 { let x = 42; 0 }", 1)];
332        let res = analyze(&files, &PreviewFeatures::default(), &Target::host());
333        // Unused-variable warning.
334        assert!(res.diagnostics.iter().any(|d| d.severity == "warning"));
335    }
336}