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