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