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