Skip to main content

aver/diagnostics/
context.rs

1//! File-local context analysis.
2//!
3//! `aver context` is inherently multi-file: a project's context is the
4//! entry file **and** its dependency graph. This module provides the
5//! per-file building block. CLI wraps it with dependency traversal;
6//! the playground calls it for a single entry, so the returned
7//! [`FileContext`] has `depends` listed by name but no expanded child
8//! contexts — the caller is responsible for resolving them (or not).
9//!
10//! Runtime-neutral: pure computation over parsed items and source text.
11//! No filesystem access, no VM, wasm-safe.
12
13use std::collections::{HashMap, HashSet};
14
15use serde::Serialize;
16
17use crate::ast::{DecisionBlock, FnDef, TopLevel, TypeDef, VerifyBlock};
18use crate::call_graph::{direct_calls, recursive_callsite_counts, recursive_scc_ids};
19use crate::checker::expr_to_str;
20use crate::verify_law::canonical_spec_ref;
21
22// ─── Canonical, CLI-shaped FileContext ────────────────────────────────────────
23
24#[derive(Clone)]
25pub struct FileContext {
26    pub source_file: String,
27    pub module_name: Option<String>,
28    pub intent: Option<String>,
29    pub depends: Vec<String>,
30    pub exposes: Vec<String>,
31    pub exposes_opaque: Vec<String>,
32    pub api_effects: Vec<String>,
33    pub module_effects: Vec<String>,
34    pub main_effects: Option<Vec<String>>,
35    /// Public fn defs only (filtered by `exposes` when present).
36    pub fn_defs: Vec<FnDef>,
37    /// Every fn def, unfiltered. Used by callers that need private fns too.
38    pub all_fn_defs: Vec<FnDef>,
39    pub fn_auto_tco: HashSet<String>,
40    pub fn_recursive_callsites: HashMap<String, usize>,
41    pub fn_recursive_scc_id: HashMap<String, usize>,
42    pub fn_specs: HashMap<String, Vec<String>>,
43    pub fn_direct_calls: HashMap<String, Vec<String>>,
44    pub type_defs: Vec<TypeDef>,
45    pub verify_blocks: Vec<VerifyBlock>,
46    pub verify_counts: HashMap<String, usize>,
47    pub verify_samples: HashMap<String, Vec<String>>,
48    pub decisions: Vec<DecisionBlock>,
49}
50
51impl FileContext {
52    fn empty(source_file: impl Into<String>) -> Self {
53        Self {
54            source_file: source_file.into(),
55            module_name: None,
56            intent: None,
57            depends: vec![],
58            exposes: vec![],
59            exposes_opaque: vec![],
60            api_effects: vec![],
61            module_effects: vec![],
62            main_effects: None,
63            fn_defs: vec![],
64            all_fn_defs: vec![],
65            fn_auto_tco: HashSet::new(),
66            fn_recursive_callsites: HashMap::new(),
67            fn_recursive_scc_id: HashMap::new(),
68            fn_specs: HashMap::new(),
69            fn_direct_calls: HashMap::new(),
70            type_defs: vec![],
71            verify_blocks: vec![],
72            verify_counts: HashMap::new(),
73            verify_samples: HashMap::new(),
74            decisions: vec![],
75        }
76    }
77}
78
79// ─── Single-file entry point ──────────────────────────────────────────────────
80
81/// Build the per-file context record from parsed items and source text.
82///
83/// `module_root` is passed to the typechecker so it can resolve external
84/// references during signature inference; pass `None` for single-file
85/// analysis (playground). Callers that need the dependency graph wrap
86/// this with their own recursion.
87pub fn build_context_for_items(
88    items: &[TopLevel],
89    _source: &str,
90    file_label: impl Into<String>,
91    module_root: Option<&str>,
92) -> FileContext {
93    let mut ctx = FileContext::empty(file_label);
94
95    let mut declared_module_effects: Option<Vec<String>> = None;
96    for item in items {
97        match item {
98            TopLevel::Module(m) => {
99                ctx.module_name = Some(m.name.clone());
100                ctx.intent = if m.intent.is_empty() {
101                    None
102                } else {
103                    Some(m.intent.clone())
104                };
105                ctx.depends = m.depends.clone();
106                ctx.exposes = m.exposes.clone();
107                ctx.exposes_opaque = m.exposes_opaque.clone();
108                declared_module_effects = m.effects.clone();
109            }
110            TopLevel::FnDef(fd) => {
111                ctx.fn_defs.push(fd.clone());
112                ctx.all_fn_defs.push(fd.clone());
113            }
114            TopLevel::TypeDef(td) => ctx.type_defs.push(td.clone()),
115            TopLevel::Verify(vb) => ctx.verify_blocks.push(vb.clone()),
116            TopLevel::Decision(db) => ctx.decisions.push(db.clone()),
117            _ => {}
118        }
119    }
120
121    let flags = compute_context_fn_flags(items, module_root);
122    let ContextFnFlags {
123        auto_tco,
124        recursive_callsites,
125        recursive_scc_id,
126        fn_sigs,
127    } = flags;
128    ctx.fn_auto_tco = auto_tco;
129    ctx.fn_recursive_callsites = recursive_callsites;
130    ctx.fn_recursive_scc_id = recursive_scc_id;
131    ctx.fn_direct_calls = direct_calls(items);
132
133    for vb in &ctx.verify_blocks {
134        let crate::ast::VerifyKind::Law(law) = &vb.kind else {
135            continue;
136        };
137        let Some(spec_ref) = canonical_spec_ref(&vb.fn_name, law, &fn_sigs) else {
138            continue;
139        };
140        ctx.fn_specs
141            .entry(vb.fn_name.clone())
142            .or_default()
143            .push(spec_ref.spec_fn_name);
144    }
145    for specs in ctx.fn_specs.values_mut() {
146        specs.sort();
147        specs.dedup();
148    }
149
150    let (verify_counts, verify_samples) = build_verify_summaries(&ctx.verify_blocks, &fn_sigs);
151    ctx.verify_counts = verify_counts;
152    ctx.verify_samples = verify_samples;
153
154    // Prefer the module-level `effects [...]` declaration when the
155    // user spelled it out — it's the source-of-truth boundary, and
156    // may use namespace shorthand (`Disk` covers `Disk.*`) that the
157    // per-fn aggregation can't recover. Fall back to walking every
158    // fn's `! [...]` only for legacy (0.12-style) modules that omit
159    // the boundary.
160    ctx.module_effects = if let Some(declared) = declared_module_effects {
161        unique_sorted_effects(declared.iter())
162    } else {
163        unique_sorted_effects(
164            ctx.fn_defs
165                .iter()
166                .flat_map(|fd| fd.effects.iter().map(|e| &e.node)),
167        )
168    };
169    ctx.api_effects = unique_sorted_effects(
170        ctx.fn_defs
171            .iter()
172            .filter(|fd| ctx.exposes.contains(&fd.name))
173            .flat_map(|fd| fd.effects.iter().map(|e| &e.node)),
174    );
175    ctx.main_effects = ctx
176        .fn_defs
177        .iter()
178        .find(|fd| fd.name == "main")
179        .map(|fd| unique_sorted_effects(fd.effects.iter().map(|e| &e.node)));
180
181    if !ctx.exposes.is_empty() {
182        let exposes = ctx.exposes.clone();
183        ctx.fn_defs.retain(|fd| exposes.contains(&fd.name));
184    }
185
186    ctx
187}
188
189// ─── Serializable projection for playground / LSP ────────────────────────────
190
191#[derive(Clone, Debug, Serialize)]
192pub struct ContextSummary {
193    pub file_label: String,
194    pub module_name: Option<String>,
195    pub intent: Option<String>,
196    pub depends: Vec<String>,
197    pub exposes: Vec<String>,
198    pub exposes_opaque: Vec<String>,
199    pub api_effects: Vec<String>,
200    pub module_effects: Vec<String>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub main_effects: Option<Vec<String>>,
203    pub functions: Vec<ContextFnSummary>,
204    pub types: Vec<ContextTypeSummary>,
205    pub decisions: Vec<ContextDecisionSummary>,
206}
207
208#[derive(Clone, Debug, Serialize)]
209pub struct ContextFnSummary {
210    pub name: String,
211    pub signature: String,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub description: Option<String>,
214    pub effects: Vec<String>,
215    pub auto_tco: bool,
216    pub recursive_callsites: usize,
217    pub verify_count: usize,
218    pub verify_samples: Vec<String>,
219    pub is_exposed: bool,
220    pub specs: Vec<String>,
221    pub direct_calls: Vec<String>,
222}
223
224#[derive(Clone, Debug, Serialize)]
225pub struct ContextTypeSummary {
226    pub name: String,
227    pub kind: &'static str,
228    pub fields_or_variants: Vec<String>,
229}
230
231#[derive(Clone, Debug, Serialize)]
232pub struct ContextDecisionSummary {
233    pub name: String,
234    pub date: String,
235    pub reason_prefix: String,
236    pub impacts: Vec<String>,
237}
238
239/// Project a full [`FileContext`] into a serde-serializable summary.
240///
241/// The summary is lossy (signatures rendered as strings, private fns
242/// dropped, type bodies reduced to field/variant names). It's designed
243/// for JSON emission — playground Context panel, future LSP metadata.
244pub fn summarize(ctx: &FileContext) -> ContextSummary {
245    let functions = ctx
246        .all_fn_defs
247        .iter()
248        .map(|fd| {
249            let effects: Vec<String> = fd.effects.iter().map(|e| e.node.clone()).collect();
250            let description = fd.desc.clone();
251            let signature = render_signature(fd);
252            let is_exposed = ctx.exposes.is_empty() || ctx.exposes.contains(&fd.name);
253            let specs = ctx.fn_specs.get(&fd.name).cloned().unwrap_or_default();
254            let direct = ctx
255                .fn_direct_calls
256                .get(&fd.name)
257                .cloned()
258                .unwrap_or_default();
259            ContextFnSummary {
260                name: fd.name.clone(),
261                signature,
262                description,
263                effects,
264                auto_tco: ctx.fn_auto_tco.contains(&fd.name),
265                recursive_callsites: ctx
266                    .fn_recursive_callsites
267                    .get(&fd.name)
268                    .copied()
269                    .unwrap_or(0),
270                verify_count: ctx.verify_counts.get(&fd.name).copied().unwrap_or(0),
271                verify_samples: ctx
272                    .verify_samples
273                    .get(&fd.name)
274                    .cloned()
275                    .unwrap_or_default(),
276                is_exposed,
277                specs,
278                direct_calls: direct,
279            }
280        })
281        .collect();
282
283    let types = ctx
284        .type_defs
285        .iter()
286        .map(|td| match td {
287            TypeDef::Sum { name, variants, .. } => ContextTypeSummary {
288                name: name.clone(),
289                kind: "sum",
290                fields_or_variants: variants.iter().map(|v| v.name.clone()).collect(),
291            },
292            TypeDef::Product { name, fields, .. } => ContextTypeSummary {
293                name: name.clone(),
294                kind: "product",
295                fields_or_variants: fields.iter().map(|(n, _)| n.clone()).collect(),
296            },
297        })
298        .collect();
299
300    let decisions = ctx
301        .decisions
302        .iter()
303        .map(|d| {
304            let reason_prefix: String = d.reason.chars().take(60).collect();
305            let reason_prefix = if d.reason.len() > 60 {
306                format!("{}...", reason_prefix.trim_end())
307            } else {
308                reason_prefix
309            };
310            ContextDecisionSummary {
311                name: d.name.clone(),
312                date: d.date.clone(),
313                reason_prefix,
314                impacts: d
315                    .impacts
316                    .iter()
317                    .map(|i| i.node.text().to_string())
318                    .collect(),
319            }
320        })
321        .collect();
322
323    ContextSummary {
324        file_label: ctx.source_file.clone(),
325        module_name: ctx.module_name.clone(),
326        intent: ctx.intent.clone(),
327        depends: ctx.depends.clone(),
328        exposes: ctx.exposes.clone(),
329        exposes_opaque: ctx.exposes_opaque.clone(),
330        api_effects: ctx.api_effects.clone(),
331        module_effects: ctx.module_effects.clone(),
332        main_effects: ctx.main_effects.clone(),
333        functions,
334        types,
335        decisions,
336    }
337}
338
339/// Render a [`ContextSummary`] as markdown — same shape as the CLI's
340/// `aver context --md` for a single-entry project. Playground uses
341/// this for the ⬇ Download as .md button so the browser and the CLI
342/// emit identical files for identical source.
343pub fn render_context_md(summary: &ContextSummary) -> String {
344    let mut out = String::new();
345    out.push_str(&format!("# Aver Context — {}\n\n", summary.file_label));
346    out.push_str("_Generated by `aver context`_\n\n");
347
348    out.push_str("---\n\n");
349    if let Some(name) = &summary.module_name {
350        out.push_str(&format!("## Module: {}\n\n", name));
351    } else {
352        out.push_str(&format!("## {}\n\n", summary.file_label));
353    }
354
355    if let Some(intent) = &summary.intent {
356        out.push_str(&format!("> {}\n\n", intent));
357    }
358
359    if !summary.depends.is_empty() {
360        out.push_str(&format!("depends: `[{}]`  \n", summary.depends.join(", ")));
361    }
362    if !summary.exposes.is_empty() {
363        out.push_str(&format!("exposes: `[{}]`  \n", summary.exposes.join(", ")));
364    }
365    if !summary.exposes_opaque.is_empty() {
366        out.push_str(&format!(
367            "exposes opaque: `[{}]`  \n",
368            summary.exposes_opaque.join(", ")
369        ));
370    }
371
372    // Collapse api/module effects when equal, mirror CLI output.
373    if effects_equal(&summary.api_effects, &summary.module_effects) {
374        if !summary.module_effects.is_empty() {
375            out.push_str(&format!(
376                "effects: `[{}]`\n",
377                summary.module_effects.join(", ")
378            ));
379        }
380    } else {
381        out.push_str(&format!(
382            "api_effects: `[{}]`  \nmodule_effects: `[{}]`\n",
383            summary.api_effects.join(", "),
384            summary.module_effects.join(", ")
385        ));
386    }
387    if let Some(main_effects) = &summary.main_effects {
388        out.push_str(&format!("main_effects: `[{}]`\n", main_effects.join(", ")));
389    }
390    out.push('\n');
391
392    for ty in &summary.types {
393        let header = match ty.kind {
394            "record" => format!("### record {}\n", ty.name),
395            _ => format!("### type {}\n", ty.name),
396        };
397        out.push_str(&header);
398        if !ty.fields_or_variants.is_empty() {
399            out.push_str(&format!("`{}`\n\n", ty.fields_or_variants.join("` | `")));
400        } else {
401            out.push('\n');
402        }
403    }
404
405    for fd in &summary.functions {
406        if fd.name == "main" {
407            continue;
408        }
409        out.push_str(&format!("### `{}`\n", fd.signature));
410        if !fd.effects.is_empty() {
411            out.push_str(&format!("effects: `[{}]`  \n", fd.effects.join(", ")));
412        }
413        if fd.auto_tco {
414            out.push_str("tco: `true`  \n");
415        }
416        if !fd.specs.is_empty() {
417            let label = if fd.specs.len() == 1 { "spec" } else { "specs" };
418            out.push_str(&format!("{}: `[{}]`  \n", label, fd.specs.join(", ")));
419        }
420        if !fd.is_exposed {
421            out.push_str("visibility: `private`  \n");
422        }
423        if let Some(desc) = &fd.description {
424            out.push_str(&format!("> {}\n", desc));
425        }
426        if !fd.verify_samples.is_empty() {
427            let samples: Vec<String> = fd
428                .verify_samples
429                .iter()
430                .map(|s| format!("`{}`", s))
431                .collect();
432            out.push_str(&format!("verify: {}\n", samples.join(", ")));
433        }
434        out.push('\n');
435    }
436
437    if !summary.decisions.is_empty() {
438        out.push_str("---\n\n## Decisions\n\n");
439        for dec in &summary.decisions {
440            out.push_str(&format!("### {} ({})\n", dec.name, dec.date));
441            if !dec.reason_prefix.is_empty() {
442                out.push_str(&format!("> {}\n", dec.reason_prefix));
443            }
444            if !dec.impacts.is_empty() {
445                out.push_str(&format!("impacts: `{}`\n", dec.impacts.join("`, `")));
446            }
447            out.push('\n');
448        }
449    }
450
451    out
452}
453
454fn effects_equal(a: &[String], b: &[String]) -> bool {
455    if a.len() != b.len() {
456        return false;
457    }
458    let mut a = a.to_vec();
459    let mut b = b.to_vec();
460    a.sort();
461    b.sort();
462    a == b
463}
464
465fn render_signature(fd: &FnDef) -> String {
466    // Type-only signature: params + return type. Effects live on the
467    // separate `effects: [...]` JSON field so renderers (playground,
468    // LLMs) can show them alongside without duplicating on screen.
469    let params = fd
470        .params
471        .iter()
472        .map(|(name, type_str)| format!("{}: {}", name, type_str))
473        .collect::<Vec<_>>()
474        .join(", ");
475    format!("fn {}({}) -> {}", fd.name, params, fd.return_type)
476}
477
478// ─── Internal helpers ────────────────────────────────────────────────────────
479
480const VERIFY_SAMPLE_LIMIT: usize = 3;
481const VERIFY_CASE_MAX_LEN: usize = 150;
482
483fn unique_sorted_effects<'a, I>(effects: I) -> Vec<String>
484where
485    I: Iterator<Item = &'a String>,
486{
487    let mut uniq = effects
488        .cloned()
489        .collect::<HashSet<_>>()
490        .into_iter()
491        .collect::<Vec<_>>();
492    uniq.sort();
493    uniq
494}
495
496fn classify_verify_case(lhs: &str, rhs: &str, ret_category: Option<&str>) -> Vec<String> {
497    let combined = format!("{lhs} -> {rhs}");
498    let mut categories = Vec::new();
499
500    match ret_category {
501        Some("result") => {
502            if rhs.contains("Result.Ok(") || rhs.contains("Ok(") {
503                categories.push("ok".to_string());
504            }
505            if rhs.contains("Result.Err(") || rhs.contains("Err(") {
506                categories.push("err".to_string());
507            }
508        }
509        Some("option") => {
510            if rhs.contains("Option.Some(") || rhs.contains("Some(") {
511                categories.push("some".to_string());
512            }
513            if rhs.contains("Option.None") || rhs == "None" {
514                categories.push("none".to_string());
515            }
516        }
517        Some("bool") => {
518            if rhs == "true" {
519                categories.push("true".to_string());
520            }
521            if rhs == "false" {
522                categories.push("false".to_string());
523            }
524        }
525        _ => {}
526    }
527
528    if combined.contains("[]") || combined.contains("{}") {
529        categories.push("empty".to_string());
530    }
531    if combined.contains("-1") || combined.contains("(0 - ") {
532        categories.push("negative".to_string());
533    }
534    if combined.contains("(0)") || rhs == "0" {
535        categories.push("zero".to_string());
536    }
537    if combined.contains("\"\"") {
538        categories.push("empty-string".to_string());
539    }
540
541    if ret_category == Some("named")
542        && let Some(dot_pos) = rhs.find('.')
543    {
544        let after_dot = &rhs[dot_pos + 1..];
545        let ctor = after_dot.split('(').next().unwrap_or(after_dot);
546        categories.push(format!("ctor:{ctor}"));
547    }
548
549    categories.sort();
550    categories.dedup();
551    categories
552}
553
554fn base_verify_case_score(lhs: &str, rhs: &str) -> i32 {
555    let combined_len = lhs.len() + rhs.len();
556    let mut score = 400 - combined_len as i32;
557    let combined = format!("{lhs} -> {rhs}");
558    if rhs.contains("Result.Err(")
559        || rhs.contains("ParseResult.Err(")
560        || rhs.contains("Option.None")
561    {
562        score += 120;
563    }
564    if combined.contains("[]") || combined.contains("{}") {
565        score += 60;
566    }
567    if combined.contains("\"\"") {
568        score += 45;
569    }
570    if combined.contains("-1") || combined.contains("(0 - ") {
571        score += 45;
572    }
573    if combined.contains(", 0") || combined.contains("(0)") || rhs == "0" {
574        score += 30;
575    }
576    if rhs == "true" || rhs == "false" {
577        score += 20;
578    }
579    score
580}
581
582fn scored_verify_samples(cases: &[(String, String)], ret_category: Option<&str>) -> Vec<String> {
583    #[derive(Clone)]
584    struct ScoredVerifyCase {
585        rendered: String,
586        base_score: i32,
587        categories: Vec<String>,
588        original_index: usize,
589    }
590
591    let mut scored = cases
592        .iter()
593        .enumerate()
594        .filter_map(|(original_index, (lhs_text, rhs_text))| {
595            if lhs_text.len() + rhs_text.len() > VERIFY_CASE_MAX_LEN {
596                return None;
597            }
598            Some(ScoredVerifyCase {
599                rendered: format!("{lhs_text} => {rhs_text}"),
600                base_score: base_verify_case_score(lhs_text, rhs_text),
601                categories: classify_verify_case(lhs_text, rhs_text, ret_category),
602                original_index,
603            })
604        })
605        .collect::<Vec<_>>();
606
607    let mut selected = Vec::new();
608    let mut seen_categories: HashSet<String> = HashSet::new();
609    while selected.len() < VERIFY_SAMPLE_LIMIT && !scored.is_empty() {
610        let best_idx = scored
611            .iter()
612            .enumerate()
613            .max_by_key(|(_, case)| {
614                let novelty = case
615                    .categories
616                    .iter()
617                    .filter(|cat| !seen_categories.contains(cat.as_str()))
618                    .count() as i32;
619                (
620                    case.base_score + novelty * 35,
621                    case.base_score,
622                    -(case.original_index as i32),
623                )
624            })
625            .map(|(idx, _)| idx)
626            .expect("verify samples should be non-empty");
627        let chosen = scored.swap_remove(best_idx);
628        for category in &chosen.categories {
629            seen_categories.insert(category.clone());
630        }
631        selected.push(chosen.rendered);
632    }
633    selected
634}
635
636fn return_type_category(
637    fn_name: &str,
638    fn_sigs: &HashMap<String, (Vec<crate::types::Type>, crate::types::Type, Vec<String>)>,
639) -> Option<&'static str> {
640    let (_, ret, _) = fn_sigs.get(fn_name)?;
641    match ret {
642        crate::types::Type::Result(_, _) => Some("result"),
643        crate::types::Type::Option(_) => Some("option"),
644        crate::types::Type::Bool => Some("bool"),
645        crate::types::Type::List(_) => Some("list"),
646        crate::types::Type::Named { .. } => Some("named"),
647        _ => None,
648    }
649}
650
651fn build_verify_summaries(
652    verify_blocks: &[VerifyBlock],
653    fn_sigs: &HashMap<String, (Vec<crate::types::Type>, crate::types::Type, Vec<String>)>,
654) -> (HashMap<String, usize>, HashMap<String, Vec<String>>) {
655    let mut cases_by_fn: HashMap<String, Vec<(String, String)>> = HashMap::new();
656    for block in verify_blocks {
657        let entry = cases_by_fn.entry(block.fn_name.clone()).or_default();
658        for (lhs, rhs) in &block.cases {
659            entry.push((expr_to_str(lhs), expr_to_str(rhs)));
660        }
661    }
662
663    let verify_counts = cases_by_fn
664        .iter()
665        .map(|(fn_name, cases)| (fn_name.clone(), cases.len()))
666        .collect::<HashMap<_, _>>();
667    let verify_samples = cases_by_fn
668        .into_iter()
669        .map(|(fn_name, cases)| {
670            let ret_cat = return_type_category(&fn_name, fn_sigs);
671            (fn_name, scored_verify_samples(&cases, ret_cat))
672        })
673        .collect::<HashMap<_, _>>();
674
675    (verify_counts, verify_samples)
676}
677
678struct ContextFnFlags {
679    auto_tco: HashSet<String>,
680    recursive_callsites: HashMap<String, usize>,
681    recursive_scc_id: HashMap<String, usize>,
682    fn_sigs: HashMap<String, (Vec<crate::types::Type>, crate::types::Type, Vec<String>)>,
683}
684
685fn expr_has_tail_call(expr: &crate::ast::Spanned<crate::ast::Expr>) -> bool {
686    use crate::ast::Expr;
687    match &expr.node {
688        Expr::TailCall(_) => true,
689        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => false,
690        Expr::Attr(obj, _) => expr_has_tail_call(obj),
691        Expr::FnCall(f, args) => expr_has_tail_call(f) || args.iter().any(expr_has_tail_call),
692        Expr::BinOp(_, l, r) => expr_has_tail_call(l) || expr_has_tail_call(r),
693        Expr::Neg(inner) => expr_has_tail_call(inner),
694        Expr::Match { subject, arms, .. } => {
695            expr_has_tail_call(subject) || arms.iter().any(|arm| expr_has_tail_call(&arm.body))
696        }
697        Expr::Constructor(_, arg) => arg.as_ref().is_some_and(|a| expr_has_tail_call(a)),
698        Expr::ErrorProp(inner) => expr_has_tail_call(inner),
699        Expr::InterpolatedStr(parts) => parts.iter().any(|part| match part {
700            crate::ast::StrPart::Literal(_) => false,
701            crate::ast::StrPart::Parsed(e) => expr_has_tail_call(e),
702        }),
703        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
704            items.iter().any(expr_has_tail_call)
705        }
706        Expr::MapLiteral(entries) => entries
707            .iter()
708            .any(|(k, v)| expr_has_tail_call(k) || expr_has_tail_call(v)),
709        Expr::RecordCreate { fields, .. } => fields.iter().any(|(_, e)| expr_has_tail_call(e)),
710        Expr::RecordUpdate { base, updates, .. } => {
711            expr_has_tail_call(base) || updates.iter().any(|(_, e)| expr_has_tail_call(e))
712        }
713    }
714}
715
716fn fn_has_tail_call(fd: &FnDef) -> bool {
717    fd.body.stmts().iter().any(|stmt| match stmt {
718        crate::ast::Stmt::Binding(_, _, expr) | crate::ast::Stmt::Expr(expr) => {
719            expr_has_tail_call(expr)
720        }
721    })
722}
723
724fn compute_context_fn_flags(items: &[TopLevel], module_root: Option<&str>) -> ContextFnFlags {
725    let mut transformed = items.to_vec();
726    crate::ir::pipeline::tco(&mut transformed);
727    let tco_fns = transformed
728        .iter()
729        .filter_map(|item| match item {
730            TopLevel::FnDef(fd) if fn_has_tail_call(fd) => Some(fd.name.clone()),
731            _ => None,
732        })
733        .collect::<HashSet<_>>();
734    let recursive_callsites = recursive_callsite_counts(&transformed);
735    let recursive_scc_id = recursive_scc_ids(&transformed);
736
737    let tc_result = crate::ir::pipeline::typecheck(
738        &transformed,
739        &crate::ir::TypecheckMode::Full {
740            base_dir: module_root,
741        },
742    );
743
744    ContextFnFlags {
745        auto_tco: tco_fns,
746        recursive_callsites,
747        recursive_scc_id,
748        fn_sigs: tc_result.fn_sigs,
749    }
750}