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 two words are in the same recursive cycle (mutually recursive).
84    pub fn are_mutually_recursive(&self, word1: &str, word2: &str) -> bool {
85        self.recursive_sccs
86            .iter()
87            .any(|scc| scc.contains(word1) && scc.contains(word2))
88    }
89
90    /// Get all recursive cycles (SCCs with recursion).
91    pub fn recursive_cycles(&self) -> &[HashSet<String>] {
92        &self.recursive_sccs
93    }
94
95    /// Get the words that a given word calls.
96    pub fn callees(&self, word: &str) -> Option<&HashSet<String>> {
97        self.edges.get(word)
98    }
99
100    /// Find strongly connected components using Tarjan's algorithm.
101    ///
102    /// Returns only SCCs that represent recursion:
103    /// - Multi-word SCCs (mutual recursion)
104    /// - Single-word SCCs where the word calls itself (direct recursion)
105    fn find_sccs(&self) -> Vec<HashSet<String>> {
106        let mut state = TarjanState::new();
107
108        for word in &self.words {
109            if !state.indices.contains_key(word) {
110                self.tarjan_visit(word, &mut state);
111            }
112        }
113
114        // Filter to only recursive SCCs
115        state
116            .sccs
117            .into_iter()
118            .filter(|scc| {
119                if scc.len() > 1 {
120                    // Multi-word SCC = mutual recursion
121                    true
122                } else if scc.len() == 1 {
123                    // Single-word SCC: check if it calls itself
124                    let word = scc.iter().next().expect("scc.len() == 1");
125                    self.edges
126                        .get(word)
127                        .map(|callees| callees.contains(word))
128                        .unwrap_or(false)
129                } else {
130                    false
131                }
132            })
133            .collect()
134    }
135
136    /// Tarjan's algorithm recursive visit.
137    fn tarjan_visit(&self, word: &str, state: &mut TarjanState) {
138        let index = state.index_counter;
139        state.index_counter += 1;
140        state.indices.insert(word.to_string(), index);
141        state.lowlinks.insert(word.to_string(), index);
142        state.stack.push(word.to_string());
143        state.on_stack.insert(word.to_string());
144
145        // Visit all callees
146        if let Some(callees) = self.edges.get(word) {
147            for callee in callees {
148                if !self.words.contains(callee) {
149                    // External word (builtin), skip
150                    continue;
151                }
152                if !state.indices.contains_key(callee) {
153                    // Not yet visited
154                    self.tarjan_visit(callee, state);
155                    let callee_lowlink = *state
156                        .lowlinks
157                        .get(callee)
158                        .expect("Tarjan invariant: callee was just visited");
159                    state.relax_lowlink(word, callee_lowlink);
160                } else if state.on_stack.contains(callee) {
161                    // Callee is on stack, part of current SCC
162                    let callee_index = *state
163                        .indices
164                        .get(callee)
165                        .expect("Tarjan invariant: on-stack callee is indexed");
166                    state.relax_lowlink(word, callee_index);
167                }
168            }
169        }
170
171        // If word is a root node, pop the SCC
172        if state.lowlinks.get(word) == state.indices.get(word) {
173            let mut scc = HashSet::new();
174            loop {
175                let w = state
176                    .stack
177                    .pop()
178                    .expect("Tarjan invariant: stack non-empty until root");
179                state.on_stack.remove(&w);
180                scc.insert(w.clone());
181                if w == word {
182                    break;
183                }
184            }
185            state.sccs.push(scc);
186        }
187    }
188}
189
190/// Mutable working state for Tarjan's SCC algorithm, threaded through the
191/// recursive `tarjan_visit`.
192struct TarjanState {
193    index_counter: usize,
194    stack: Vec<String>,
195    on_stack: HashSet<String>,
196    indices: HashMap<String, usize>,
197    lowlinks: HashMap<String, usize>,
198    sccs: Vec<HashSet<String>>,
199}
200
201impl TarjanState {
202    fn new() -> Self {
203        TarjanState {
204            index_counter: 0,
205            stack: Vec::new(),
206            on_stack: HashSet::new(),
207            indices: HashMap::new(),
208            lowlinks: HashMap::new(),
209            sccs: Vec::new(),
210        }
211    }
212
213    /// Lower `word`'s lowlink to `candidate` if it is smaller.
214    fn relax_lowlink(&mut self, word: &str, candidate: usize) {
215        let lowlink = self
216            .lowlinks
217            .get_mut(word)
218            .expect("Tarjan invariant: word has a lowlink");
219        *lowlink = (*lowlink).min(candidate);
220    }
221}
222
223/// Extract all word calls from a list of statements.
224///
225/// This recursively descends into quotations, if branches, and match arms.
226fn extract_calls(statements: &[Statement], known_words: &HashSet<String>) -> HashSet<String> {
227    let mut calls = HashSet::new();
228    extract_each(statements, known_words, &mut calls);
229    calls
230}
231
232/// Run `extract_calls_from_statement` over every statement in `statements`.
233fn extract_each(
234    statements: &[Statement],
235    known_words: &HashSet<String>,
236    calls: &mut HashSet<String>,
237) {
238    for stmt in statements {
239        extract_calls_from_statement(stmt, known_words, calls);
240    }
241}
242
243/// Extract word calls from a single statement.
244fn extract_calls_from_statement(
245    stmt: &Statement,
246    known_words: &HashSet<String>,
247    calls: &mut HashSet<String>,
248) {
249    match stmt {
250        Statement::WordCall { name, .. } => {
251            // Only track calls to user-defined words
252            if known_words.contains(name) {
253                calls.insert(name.clone());
254            }
255        }
256        Statement::If {
257            then_branch,
258            else_branch,
259            span: _,
260        } => {
261            extract_each(then_branch, known_words, calls);
262            if let Some(else_stmts) = else_branch {
263                extract_each(else_stmts, known_words, calls);
264            }
265        }
266        Statement::Quotation { body, .. } => {
267            extract_each(body, known_words, calls);
268        }
269        Statement::Match { arms, span: _ } => {
270            for arm in arms {
271                extract_each(&arm.body, known_words, calls);
272            }
273        }
274        // Literals don't contain calls
275        Statement::IntLiteral(_)
276        | Statement::FloatLiteral(_)
277        | Statement::BoolLiteral(_)
278        | Statement::StringLiteral(_)
279        | Statement::Symbol(_) => {}
280    }
281}
282
283#[cfg(test)]
284mod tests;