Skip to main content

seqc/
call_graph.rs

1//! Call graph analysis for detecting mutual recursion
2//!
3//! This module builds a call graph from a Seq program and detects
4//! strongly connected components (SCCs) to identify mutual recursion cycles.
5//!
6//! # Usage
7//!
8//! ```ignore
9//! let call_graph = CallGraph::build(&program);
10//! let cycles = call_graph.recursive_cycles();
11//! ```
12//!
13//! # Primary Use Cases
14//!
15//! 1. **Type checker divergence detection**: The type checker uses the call graph
16//!    to identify mutually recursive tail calls, enabling correct type inference
17//!    for patterns like even/odd that would otherwise require branch unification.
18//!
19//! 2. **Future optimizations**: The call graph infrastructure can support dead code
20//!    detection, inlining decisions, and diagnostic tools.
21//!
22//! # Implementation Details
23//!
24//! - **Algorithm**: Tarjan's SCC algorithm, O(V + E) time complexity
25//! - **Builtins**: Calls to builtins/external words are excluded from the graph
26//!   (they don't affect recursion detection since they always return)
27//! - **Quotations**: Calls within quotations are included in the analysis
28//! - **Match arms**: Calls within match arms are included in the analysis
29//!
30//! # Note on Tail Call Optimization
31//!
32//! The existing codegen already emits `musttail` for all tail calls to user-defined
33//! words (see `codegen/statements.rs`). This means mutual TCO works automatically
34//! without needing explicit call graph checks in codegen. The call graph is primarily
35//! used for type checking, not for enabling TCO.
36
37use crate::ast::{Program, Statement};
38use std::collections::{HashMap, HashSet};
39
40/// A call graph representing which words call which other words.
41#[derive(Debug, Clone)]
42pub struct CallGraph {
43    /// Map from word name to the set of words it calls
44    edges: HashMap<String, HashSet<String>>,
45    /// All word names in the program
46    words: HashSet<String>,
47    /// Strongly connected components with more than one member (mutual recursion)
48    /// or single members that call themselves (direct recursion)
49    recursive_sccs: Vec<HashSet<String>>,
50}
51
52impl CallGraph {
53    /// Build a call graph from a program.
54    ///
55    /// This extracts all word-to-word call relationships, including calls
56    /// within quotations, if branches, and match arms.
57    pub fn build(program: &Program) -> Self {
58        let mut edges: HashMap<String, HashSet<String>> = HashMap::new();
59        let words: HashSet<String> = program.words.iter().map(|w| w.name.clone()).collect();
60
61        for word in &program.words {
62            let callees = extract_calls(&word.body, &words);
63            edges.insert(word.name.clone(), callees);
64        }
65
66        let mut graph = CallGraph {
67            edges,
68            words,
69            recursive_sccs: Vec::new(),
70        };
71
72        // Compute SCCs and identify recursive cycles
73        graph.recursive_sccs = graph.find_sccs();
74
75        graph
76    }
77
78    /// Check if a word is part of any recursive cycle (direct or mutual).
79    pub fn is_recursive(&self, word: &str) -> bool {
80        self.recursive_sccs.iter().any(|scc| scc.contains(word))
81    }
82
83    /// Check if a word calls itself directly (self-tail-recursion).
84    ///
85    /// This is stricter than `is_recursive`: mutual recursion (e.g. `ping`/
86    /// `pong`) is *not* self-recursive. Used by loop lowering
87    /// (`docs/design/LOOP_LOWERING.md`) to find candidates for native loop
88    /// codegen — only direct self-recursion can be lowered to a single loop.
89    pub fn is_self_recursive(&self, word: &str) -> bool {
90        self.edges
91            .get(word)
92            .is_some_and(|callees| callees.contains(word))
93    }
94
95    /// Check if two words are in the same recursive cycle (mutually recursive).
96    pub fn are_mutually_recursive(&self, word1: &str, word2: &str) -> bool {
97        self.recursive_sccs
98            .iter()
99            .any(|scc| scc.contains(word1) && scc.contains(word2))
100    }
101
102    /// Get all recursive cycles (SCCs with recursion).
103    pub fn recursive_cycles(&self) -> &[HashSet<String>] {
104        &self.recursive_sccs
105    }
106
107    /// Get the words that a given word calls.
108    pub fn callees(&self, word: &str) -> Option<&HashSet<String>> {
109        self.edges.get(word)
110    }
111
112    /// Find strongly connected components using Tarjan's algorithm.
113    ///
114    /// Returns only SCCs that represent recursion:
115    /// - Multi-word SCCs (mutual recursion)
116    /// - Single-word SCCs where the word calls itself (direct recursion)
117    fn find_sccs(&self) -> Vec<HashSet<String>> {
118        let mut state = TarjanState::new();
119
120        for word in &self.words {
121            if !state.indices.contains_key(word) {
122                self.tarjan_visit(word, &mut state);
123            }
124        }
125
126        // Filter to only recursive SCCs
127        state
128            .sccs
129            .into_iter()
130            .filter(|scc| {
131                if scc.len() > 1 {
132                    // Multi-word SCC = mutual recursion
133                    true
134                } else if scc.len() == 1 {
135                    // Single-word SCC: check if it calls itself
136                    let word = scc.iter().next().expect("scc.len() == 1");
137                    self.edges
138                        .get(word)
139                        .map(|callees| callees.contains(word))
140                        .unwrap_or(false)
141                } else {
142                    false
143                }
144            })
145            .collect()
146    }
147
148    /// Tarjan's algorithm recursive visit.
149    fn tarjan_visit(&self, word: &str, state: &mut TarjanState) {
150        let index = state.index_counter;
151        state.index_counter += 1;
152        state.indices.insert(word.to_string(), index);
153        state.lowlinks.insert(word.to_string(), index);
154        state.stack.push(word.to_string());
155        state.on_stack.insert(word.to_string());
156
157        // Visit all callees
158        if let Some(callees) = self.edges.get(word) {
159            for callee in callees {
160                if !self.words.contains(callee) {
161                    // External word (builtin), skip
162                    continue;
163                }
164                if !state.indices.contains_key(callee) {
165                    // Not yet visited
166                    self.tarjan_visit(callee, state);
167                    let callee_lowlink = *state
168                        .lowlinks
169                        .get(callee)
170                        .expect("Tarjan invariant: callee was just visited");
171                    state.relax_lowlink(word, callee_lowlink);
172                } else if state.on_stack.contains(callee) {
173                    // Callee is on stack, part of current SCC
174                    let callee_index = *state
175                        .indices
176                        .get(callee)
177                        .expect("Tarjan invariant: on-stack callee is indexed");
178                    state.relax_lowlink(word, callee_index);
179                }
180            }
181        }
182
183        // If word is a root node, pop the SCC
184        if state.lowlinks.get(word) == state.indices.get(word) {
185            let mut scc = HashSet::new();
186            loop {
187                let w = state
188                    .stack
189                    .pop()
190                    .expect("Tarjan invariant: stack non-empty until root");
191                state.on_stack.remove(&w);
192                scc.insert(w.clone());
193                if w == word {
194                    break;
195                }
196            }
197            state.sccs.push(scc);
198        }
199    }
200}
201
202/// Mutable working state for Tarjan's SCC algorithm, threaded through the
203/// recursive `tarjan_visit`.
204struct TarjanState {
205    index_counter: usize,
206    stack: Vec<String>,
207    on_stack: HashSet<String>,
208    indices: HashMap<String, usize>,
209    lowlinks: HashMap<String, usize>,
210    sccs: Vec<HashSet<String>>,
211}
212
213impl TarjanState {
214    fn new() -> Self {
215        TarjanState {
216            index_counter: 0,
217            stack: Vec::new(),
218            on_stack: HashSet::new(),
219            indices: HashMap::new(),
220            lowlinks: HashMap::new(),
221            sccs: Vec::new(),
222        }
223    }
224
225    /// Lower `word`'s lowlink to `candidate` if it is smaller.
226    fn relax_lowlink(&mut self, word: &str, candidate: usize) {
227        let lowlink = self
228            .lowlinks
229            .get_mut(word)
230            .expect("Tarjan invariant: word has a lowlink");
231        *lowlink = (*lowlink).min(candidate);
232    }
233}
234
235/// Extract all word calls from a list of statements.
236///
237/// This recursively descends into quotations, if branches, and match arms.
238fn extract_calls(statements: &[Statement], known_words: &HashSet<String>) -> HashSet<String> {
239    let mut calls = HashSet::new();
240    extract_each(statements, known_words, &mut calls);
241    calls
242}
243
244/// Run `extract_calls_from_statement` over every statement in `statements`.
245fn extract_each(
246    statements: &[Statement],
247    known_words: &HashSet<String>,
248    calls: &mut HashSet<String>,
249) {
250    for stmt in statements {
251        extract_calls_from_statement(stmt, known_words, calls);
252    }
253}
254
255/// Extract word calls from a single statement.
256fn extract_calls_from_statement(
257    stmt: &Statement,
258    known_words: &HashSet<String>,
259    calls: &mut HashSet<String>,
260) {
261    match stmt {
262        Statement::WordCall { name, .. } => {
263            // Only track calls to user-defined words
264            if known_words.contains(name) {
265                calls.insert(name.clone());
266            }
267        }
268        Statement::If {
269            then_branch,
270            else_branch,
271            span: _,
272        } => {
273            extract_each(then_branch, known_words, calls);
274            if let Some(else_stmts) = else_branch {
275                extract_each(else_stmts, known_words, calls);
276            }
277        }
278        Statement::Quotation { body, .. } => {
279            extract_each(body, known_words, calls);
280        }
281        Statement::Match { arms, span: _ } => {
282            for arm in arms {
283                extract_each(&arm.body, known_words, calls);
284            }
285        }
286        // Literals don't contain calls
287        Statement::IntLiteral(_)
288        | Statement::FloatLiteral(_)
289        | Statement::BoolLiteral(_)
290        | Statement::StringLiteral(_)
291        | Statement::Symbol(_) => {}
292    }
293}
294
295#[cfg(test)]
296mod tests;