Skip to main content

aver/diagnostics/
analyze.rs

1//! Single-file analysis pipeline.
2//!
3//! `analyze_source` is the canonical entry for going from source text to
4//! diagnostics. Runtime-neutral: no file IO, no config, no VM. Multi-file
5//! concerns (unused exposes, config suppression, dependency resolution)
6//! stay in CLI / LSP callers.
7
8use super::factories::{from_check_finding, from_type_error, unused_binding_diagnostic};
9use super::model::{AnalysisReport, Diagnostic, Severity, Span};
10use crate::checker::{
11    CheckFinding, check_module_intent_with_sigs_in, collect_cse_warnings_in,
12    collect_independence_warnings_in, collect_naming_warnings_in, collect_perf_warnings_in,
13    collect_verify_coverage_warnings_in,
14};
15#[cfg(feature = "runtime")]
16use crate::checker::{FindingSpan, collect_verify_law_dependency_warnings_in};
17use crate::source::{LoadedModule, parse_source};
18#[cfg(feature = "runtime")]
19use crate::tail_check::collect_non_tail_recursion_warnings_with_sigs;
20use crate::tco;
21use crate::types::checker::{run_type_check_full, run_type_check_with_loaded};
22
23/// Options for `analyze_source`. Defaults enable every available collector.
24#[derive(Clone, Debug)]
25pub struct AnalyzeOptions {
26    pub file_label: String,
27    pub module_base_dir: Option<String>,
28    /// Pre-resolved dependency modules (e.g. from a virtual filesystem
29    /// in the playground). When set, takes precedence over
30    /// `module_base_dir` — the type checker integrates these directly
31    /// instead of loading from disk.
32    pub loaded_modules: Option<Vec<LoadedModule>>,
33    pub include_intent_warnings: bool,
34    pub include_coverage_warnings: bool,
35    pub include_law_dependency_warnings: bool,
36    pub include_cse_warnings: bool,
37    pub include_perf_warnings: bool,
38    pub include_independence_warnings: bool,
39    pub include_naming_warnings: bool,
40    pub include_non_tail_warnings: bool,
41    pub include_unused_bindings: bool,
42    /// When `true` **and** the `runtime` feature is enabled, execute every
43    /// verify block found in the source and emit a diagnostic per failing
44    /// case. Off by default: analysis should stay pure static checks;
45    /// callers opt in explicitly.
46    pub include_verify_run: bool,
47    /// When `true`, populate `AnalysisReport::why_summary` with
48    /// per-function justification data. Off by default.
49    pub include_why_summary: bool,
50    /// When `true`, populate `AnalysisReport::context_summary` with
51    /// module shape / function / type / decision summary.
52    pub include_context_summary: bool,
53}
54
55impl Default for AnalyzeOptions {
56    fn default() -> Self {
57        Self {
58            file_label: "<input>".to_string(),
59            module_base_dir: None,
60            loaded_modules: None,
61            include_intent_warnings: true,
62            include_coverage_warnings: true,
63            include_law_dependency_warnings: true,
64            include_cse_warnings: true,
65            include_perf_warnings: true,
66            include_independence_warnings: true,
67            include_naming_warnings: true,
68            include_non_tail_warnings: true,
69            include_unused_bindings: true,
70            include_verify_run: false,
71            include_why_summary: false,
72            include_context_summary: false,
73        }
74    }
75}
76
77impl AnalyzeOptions {
78    pub fn new(file_label: impl Into<String>) -> Self {
79        Self {
80            file_label: file_label.into(),
81            ..Default::default()
82        }
83    }
84
85    pub fn with_module_base_dir(mut self, dir: impl Into<String>) -> Self {
86        self.module_base_dir = Some(dir.into());
87        self
88    }
89
90    pub fn with_loaded_modules(mut self, loaded: Vec<LoadedModule>) -> Self {
91        self.loaded_modules = Some(loaded);
92        self
93    }
94}
95
96/// Run the single-file analysis pipeline.
97///
98/// Pipeline: parse → TCO → typecheck → collectors → canonical diagnostics.
99/// Returns all diagnostics encountered; does not stop at first error.
100pub fn analyze_source(source: &str, options: &AnalyzeOptions) -> AnalysisReport {
101    let items = match parse_source(source) {
102        Ok(items) => items,
103        Err(e) => {
104            return AnalysisReport::with_diagnostics(
105                options.file_label.clone(),
106                vec![parse_error_diagnostic(&e, source, &options.file_label)],
107            );
108        }
109    };
110
111    let mut transformed = items.clone();
112    tco::transform_program(&mut transformed);
113
114    let tc_result = if let Some(loaded) = options.loaded_modules.as_deref() {
115        run_type_check_with_loaded(&items, loaded)
116    } else {
117        run_type_check_full(&items, options.module_base_dir.as_deref())
118    };
119
120    let mut diagnostics: Vec<Diagnostic> = Vec::new();
121
122    for te in &tc_result.errors {
123        diagnostics.push(from_type_error(te, source, &options.file_label));
124    }
125
126    let findings = if options.include_intent_warnings {
127        Some(check_module_intent_with_sigs_in(
128            &items,
129            Some(&tc_result.fn_sigs),
130            None,
131        ))
132    } else {
133        None
134    };
135
136    if let Some(ref findings) = findings {
137        for e in &findings.errors {
138            diagnostics.push(from_check_finding(
139                Severity::Error,
140                e,
141                source,
142                &options.file_label,
143            ));
144        }
145    }
146
147    if options.include_unused_bindings {
148        for (binding, fn_name, line) in &tc_result.unused_bindings {
149            diagnostics.push(unused_binding_diagnostic(
150                binding,
151                fn_name,
152                *line,
153                source,
154                &options.file_label,
155            ));
156        }
157    }
158
159    if let Some(ref findings) = findings {
160        for w in &findings.warnings {
161            diagnostics.push(from_check_finding(
162                Severity::Warning,
163                w,
164                source,
165                &options.file_label,
166            ));
167        }
168    }
169
170    if options.include_coverage_warnings {
171        for w in collect_verify_coverage_warnings_in(&items, None) {
172            diagnostics.push(from_check_finding(
173                Severity::Warning,
174                &w,
175                source,
176                &options.file_label,
177            ));
178        }
179    }
180
181    #[cfg(feature = "runtime")]
182    if options.include_law_dependency_warnings {
183        for w in collect_verify_law_dependency_warnings_in(&items, &tc_result.fn_sigs, None) {
184            diagnostics.push(from_check_finding(
185                Severity::Warning,
186                &w,
187                source,
188                &options.file_label,
189            ));
190        }
191    }
192
193    if options.include_cse_warnings {
194        for w in collect_cse_warnings_in(&transformed, None) {
195            diagnostics.push(from_check_finding(
196                Severity::Warning,
197                &w,
198                source,
199                &options.file_label,
200            ));
201        }
202    }
203
204    if options.include_perf_warnings {
205        for w in collect_perf_warnings_in(&transformed, None) {
206            diagnostics.push(from_check_finding(
207                Severity::Warning,
208                &w,
209                source,
210                &options.file_label,
211            ));
212        }
213    }
214
215    if options.include_independence_warnings {
216        for w in collect_independence_warnings_in(&transformed, &tc_result.fn_sigs, None) {
217            diagnostics.push(from_check_finding(
218                Severity::Warning,
219                &w,
220                source,
221                &options.file_label,
222            ));
223        }
224    }
225
226    if options.include_naming_warnings {
227        for w in collect_naming_warnings_in(&items, None) {
228            diagnostics.push(from_check_finding(
229                Severity::Warning,
230                &w,
231                source,
232                &options.file_label,
233            ));
234        }
235    }
236
237    #[cfg(feature = "runtime")]
238    let verify_summary_opt = if options.include_verify_run && tc_result.errors.is_empty() {
239        // Verify execution only runs when typecheck is clean — otherwise
240        // the compiled VM would crash on missing symbols. Multi-file
241        // now works through the same VM path via loaded_modules →
242        // compile_program_with_loaded_modules.
243        let runnable_items = items.clone();
244        let (verify_diags, verify_summary) = if let Some(loaded) = options.loaded_modules.clone() {
245            super::verify_run::run_verify_blocks_with_loaded(
246                runnable_items,
247                loaded,
248                &options.file_label,
249                source,
250            )
251        } else {
252            super::verify_run::run_verify_blocks(
253                runnable_items,
254                options.module_base_dir.as_deref(),
255                &options.file_label,
256                source,
257            )
258        };
259        for diag in verify_diags {
260            diagnostics.push(diag);
261        }
262        Some(verify_summary)
263    } else {
264        None
265    };
266    #[cfg(not(feature = "runtime"))]
267    let verify_summary_opt: Option<super::model::VerifySummary> = None;
268
269    #[cfg(feature = "runtime")]
270    if options.include_non_tail_warnings {
271        let non_tail =
272            collect_non_tail_recursion_warnings_with_sigs(&transformed, &tc_result.fn_sigs);
273        for w in &non_tail {
274            let mut line_counts: Vec<(usize, usize)> = Vec::new();
275            for &ln in &w.callsite_lines {
276                if let Some(entry) = line_counts.iter_mut().find(|(l, _)| *l == ln) {
277                    entry.1 += 1;
278                } else {
279                    line_counts.push((ln, 1));
280                }
281            }
282            let max_shown = 3;
283            let extra_spans: Vec<FindingSpan> = line_counts
284                .iter()
285                .take(max_shown)
286                .map(|&(ln, count)| {
287                    let label = if count > 1 {
288                        format!("{} non-tail calls", count)
289                    } else {
290                        "non-tail call".to_string()
291                    };
292                    FindingSpan {
293                        line: ln,
294                        col: 0,
295                        len: 0,
296                        label,
297                    }
298                })
299                .collect();
300            let finding = CheckFinding {
301                line: w.line,
302                module: None,
303                file: None,
304                fn_name: Some(w.fn_name.clone()),
305                message: w.message.clone(),
306                extra_spans,
307            };
308            diagnostics.push(from_check_finding(
309                Severity::Warning,
310                &finding,
311                source,
312                &options.file_label,
313            ));
314        }
315    }
316
317    let mut report = AnalysisReport::with_diagnostics(options.file_label.clone(), diagnostics);
318    report.verify_summary = verify_summary_opt;
319
320    if options.include_why_summary {
321        report.why_summary = Some(super::why::summarize(
322            &items,
323            source,
324            options.file_label.clone(),
325        ));
326    }
327
328    if options.include_context_summary {
329        let ctx = super::context::build_context_for_items(
330            &items,
331            source,
332            options.file_label.clone(),
333            options.module_base_dir.as_deref(),
334        );
335        report.context_summary = Some(super::context::summarize(&ctx));
336    }
337
338    report
339}
340
341/// Build a `Diagnostic` for a parser error.
342///
343/// Parser emits its message as `error[LINE:COL]: <body>` (see
344/// `ParseError::Display`). We strip the prefix to rebuild the real
345/// span, add a source region anchored on that line, and map common
346/// patterns to a repair hint — otherwise the CLI / playground showed
347/// parse errors pointing at line 1:1 with no fix suggestion.
348fn parse_error_diagnostic(msg: &str, source: &str, file: &str) -> Diagnostic {
349    use super::classify::{estimate_span_len, extract_source_lines_range};
350    use super::model::{AnnotatedRegion, Underline};
351    let (line, col, body) = strip_parse_error_prefix(msg);
352    let regions = if line > 0 {
353        // Include one line of pre-context so the reader sees the
354        // surrounding code, but stop at the target line so the
355        // underline renders directly beneath it (tty_render draws
356        // the caret after the last line of the region).
357        let start = line.saturating_sub(1).max(1);
358        let source_lines = extract_source_lines_range(source, start, line);
359        if source_lines.is_empty() {
360            Vec::new()
361        } else {
362            // Underline the offending token. Parser emits col =
363            // line_len + 1 for errors that fire at the newline
364            // (e.g. Unterminated string literal); clamp to the last
365            // real char so the caret doesn't float off the end of
366            // the line.
367            let underline = source.lines().nth(line.saturating_sub(1)).map(|l| {
368                let line_chars = l.chars().count();
369                let anchor = if col > line_chars && line_chars > 0 {
370                    line_chars
371                } else {
372                    col.max(1)
373                };
374                Underline {
375                    col: anchor,
376                    len: estimate_span_len(l, anchor),
377                    label: String::new(),
378                }
379            });
380            vec![AnnotatedRegion {
381                source_lines,
382                underline,
383            }]
384        }
385    } else {
386        Vec::new()
387    };
388    Diagnostic {
389        severity: Severity::Error,
390        slug: "parse-error",
391        summary: body.to_string(),
392        span: Span {
393            file: file.to_string(),
394            line: line.max(1),
395            col: col.max(1),
396        },
397        fn_name: None,
398        intent: None,
399        fields: Vec::new(),
400        conflict: None,
401        repair: parse_error_repair(body),
402        regions,
403        related: Vec::new(),
404    }
405}
406
407fn strip_parse_error_prefix(msg: &str) -> (usize, usize, &str) {
408    // `error[LINE:COL]: body` — the parser's Display impl (see
409    // src/parser/mod.rs). Lexer errors may share the shape.
410    let Some(rest) = msg.strip_prefix("error[") else {
411        return (0, 0, msg);
412    };
413    let Some(close) = rest.find("]: ") else {
414        return (0, 0, msg);
415    };
416    let (coord, tail) = rest.split_at(close);
417    let body = &tail[3..];
418    let Some((line_s, col_s)) = coord.split_once(':') else {
419        return (0, 0, body);
420    };
421    let line = line_s.parse::<usize>().unwrap_or(0);
422    let col = col_s.parse::<usize>().unwrap_or(0);
423    (line, col, body)
424}
425
426fn parse_error_repair(body: &str) -> super::model::Repair {
427    // Map common parser messages to a concrete nudge. The parser
428    // emits short human strings (`Expected X, found Y`, `Expected '['
429    // after '!'`, ...); we pattern-match on the shape so the repair
430    // points at a likely fix instead of leaving the user staring at
431    // "found EOF".
432    use super::model::Repair;
433    let hint = if body.contains("after '?'") {
434        Some("Description needs a string literal: `? \"what this does\"`")
435    } else if body.contains("after 'intent ='") {
436        Some(
437            "Module intent is a string or an indented block of strings: `intent = \"one line\"` or `intent =\\n    \"line one\"\\n    \"line two\"`",
438        )
439    } else if body.contains("Expected '[' after '!'") {
440        Some("Effects are a bracketed list: `! [Console.print, Random.int]`")
441    } else if body.contains("Expected '=>' between key and value in map literal") {
442        Some("Map literal uses `=>`: `{\"k\" => 1, \"other\" => 2}`")
443    } else if body.contains("Tuple type must have at least 2 elements") {
444        Some("Single-element tuples aren't allowed — use the bare type, or add a second element.")
445    } else if body.contains("Constructor patterns must be qualified") {
446        Some(
447            "Qualify variant patterns with the type name: `Shape.Circle(r) ->` not `Circle(r) ->`.",
448        )
449    } else if body.contains("bind the whole value with a lower-case name") {
450        Some(
451            "Record patterns don't take positional args — bind the whole record: `match user ... u -> u.name`.",
452        )
453    } else if body.starts_with("Expected ") && body.contains(", found ") {
454        Some(
455            "Replace the unexpected token with the expected form; check for a missing keyword, bracket, or separator above.",
456        )
457    } else if body.contains("must place `module <Name>`") {
458        Some("Move `module <Name>` so it's the very first top-level item in the file.")
459    } else if body.contains("must declare `module <Name>`") {
460        Some("Add `module <Name>` as the first line of the file.")
461    } else if body.contains("must contain exactly one module declaration") {
462        Some("Keep one `module` per file — split multi-module files into one file each.")
463    } else {
464        None
465    };
466    Repair {
467        primary: hint.map(String::from),
468        ..Repair::default()
469    }
470}