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