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;