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;