Skip to main content

bynk_project/
graph.rs

1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use bynk_syntax::ast::ProviderDecl;
5use bynk_syntax::error::CompileError;
6use bynk_syntax::span::Span;
7
8/// #696: `sites` maps a `(consumer, target)` edge to the project-relative
9/// `identity_path` and span of the `consumes` clause that declares it. When a
10/// cycle is detected the diagnostic is anchored on its closing edge's clause (a
11/// real span in a real file) so the CLI renders ariadne source context; an edge
12/// absent from `sites` (e.g. a synthetic adapter's) yields an unattributed,
13/// spanless diagnostic as before.
14pub fn detect_consumes_cycles(
15    consumes: &HashMap<String, Vec<String>>,
16    sites: &HashMap<(String, String), (PathBuf, Span)>,
17    errors: &mut Vec<(Option<PathBuf>, CompileError)>,
18) {
19    // Tarjan / Kosaraju overkill — a simple DFS with a path stack catches
20    // cycles and yields the cycle path for the diagnostic.
21    let mut visited: HashSet<String> = HashSet::new();
22    let mut reported: HashSet<Vec<String>> = HashSet::new();
23    for start in consumes.keys() {
24        if visited.contains(start) {
25            continue;
26        }
27        let mut stack: Vec<String> = Vec::new();
28        let mut on_stack: HashSet<String> = HashSet::new();
29        dfs_consumes(
30            start,
31            consumes,
32            sites,
33            &mut visited,
34            &mut stack,
35            &mut on_stack,
36            &mut reported,
37            errors,
38        );
39    }
40}
41
42#[allow(clippy::too_many_arguments)]
43fn dfs_consumes(
44    node: &str,
45    consumes: &HashMap<String, Vec<String>>,
46    sites: &HashMap<(String, String), (PathBuf, Span)>,
47    visited: &mut HashSet<String>,
48    stack: &mut Vec<String>,
49    on_stack: &mut HashSet<String>,
50    reported: &mut HashSet<Vec<String>>,
51    errors: &mut Vec<(Option<PathBuf>, CompileError)>,
52) {
53    if on_stack.contains(node) {
54        // Found a cycle: extract the path from `node`'s position in stack.
55        let start = stack.iter().position(|s| s == node).unwrap_or(0);
56        let mut cycle: Vec<String> = stack[start..].to_vec();
57        cycle.push(node.to_string());
58        // Canonicalise the cycle for de-dup.
59        let canon = canonicalise_cycle(&cycle);
60        if reported.insert(canon.clone()) {
61            // Anchor the diagnostic on the clause forming the cycle's closing
62            // edge — `node` consuming the next unit on the path (#696) — when its
63            // site is known; fall back to the spanless, unattributed form
64            // otherwise. `cycle` is `[node, …, node]`, so `cycle[1]` is the unit
65            // `node` consumes on the cycle (itself, for a self-loop).
66            let edge = (node.to_string(), cycle[1].clone());
67            let (file, span) = match sites.get(&edge) {
68                Some((path, span)) => (Some(path.clone()), *span),
69                None => (None, Span::default()),
70            };
71            errors.push((file, CompileError::new(
72                "bynk.context.consumes_cycle",
73                span,
74                format!(
75                    "`consumes` cycle detected: {}",
76                    cycle.join(" → ")
77                ),
78            )
79            .with_note(
80                "units must form an acyclic `consumes` graph; remove one of the `consumes` clauses or restructure",
81            )));
82        }
83        return;
84    }
85    if visited.contains(node) {
86        return;
87    }
88    visited.insert(node.to_string());
89    on_stack.insert(node.to_string());
90    stack.push(node.to_string());
91    if let Some(targets) = consumes.get(node) {
92        for t in targets {
93            dfs_consumes(
94                t, consumes, sites, visited, stack, on_stack, reported, errors,
95            );
96        }
97    }
98    stack.pop();
99    on_stack.remove(node);
100}
101
102fn canonicalise_cycle(cycle: &[String]) -> Vec<String> {
103    if cycle.is_empty() {
104        return Vec::new();
105    }
106    // Drop the duplicated last element (cycle vector ends with the start node).
107    let body = &cycle[..cycle.len() - 1];
108    if body.is_empty() {
109        return Vec::new();
110    }
111    let mut min_idx = 0;
112    for (i, s) in body.iter().enumerate() {
113        if s < &body[min_idx] {
114            min_idx = i;
115        }
116    }
117    let mut rotated: Vec<String> = body[min_idx..].to_vec();
118    rotated.extend(body[..min_idx].iter().cloned());
119    rotated
120}
121
122/// v0.12: detect cycles in the provider dependency graph. Each provided
123/// capability depends (via its provider's `given`) on other capabilities; a
124/// cycle means the composition root cannot order instantiation. Emits
125/// `bynk.provider.dependency_cycle` on every provider that participates in a
126/// cycle. `providers` is keyed by capability name.
127pub fn detect_provider_dependency_cycles(
128    providers: &HashMap<String, ProviderDecl>,
129    errors: &mut Vec<CompileError>,
130) {
131    fn visit(
132        node: &str,
133        providers: &HashMap<String, ProviderDecl>,
134        visited: &mut HashSet<String>,
135        stack: &mut Vec<String>,
136        in_stack: &mut HashSet<String>,
137        cyclic: &mut HashSet<String>,
138    ) {
139        if visited.contains(node) {
140            return;
141        }
142        in_stack.insert(node.to_string());
143        stack.push(node.to_string());
144        if let Some(p) = providers.get(node) {
145            for dep in &p.given {
146                // Cross-context dependencies follow the (acyclic) `consumes`
147                // graph; only intra-context provider edges can form a cycle here.
148                if dep.is_cross_context() {
149                    continue;
150                }
151                // Only follow dependencies that have a provider in this context.
152                if !providers.contains_key(dep.key()) {
153                    continue;
154                }
155                if in_stack.contains(dep.key()) {
156                    // A back-edge: everything from `dep` down the current stack
157                    // is on the cycle.
158                    let start = stack.iter().position(|n| n == dep.key()).unwrap_or(0);
159                    for n in &stack[start..] {
160                        cyclic.insert(n.clone());
161                    }
162                } else if !visited.contains(dep.key()) {
163                    visit(dep.key(), providers, visited, stack, in_stack, cyclic);
164                }
165            }
166        }
167        stack.pop();
168        in_stack.remove(node);
169        visited.insert(node.to_string());
170    }
171
172    let mut visited: HashSet<String> = HashSet::new();
173    let mut cyclic: HashSet<String> = HashSet::new();
174    let mut keys: Vec<&String> = providers.keys().collect();
175    keys.sort();
176    for k in keys {
177        let mut stack: Vec<String> = Vec::new();
178        let mut in_stack: HashSet<String> = HashSet::new();
179        visit(
180            k,
181            providers,
182            &mut visited,
183            &mut stack,
184            &mut in_stack,
185            &mut cyclic,
186        );
187    }
188
189    let mut cyclic_sorted: Vec<&String> = cyclic.iter().collect();
190    cyclic_sorted.sort();
191    for cap in cyclic_sorted {
192        if let Some(p) = providers.get(cap) {
193            errors.push(
194                CompileError::new(
195                    "bynk.provider.dependency_cycle",
196                    p.span,
197                    format!(
198                        "provider `{}` for capability `{}` is part of a capability dependency cycle",
199                        p.provider_name.name, cap,
200                    ),
201                )
202                .with_note(
203                    "a capability cannot depend on itself, directly or transitively, through \
204                     provider `given`",
205                ),
206            );
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::collections::HashMap;
215    use std::path::Path;
216
217    fn strs(xs: &[&str]) -> Vec<String> {
218        xs.iter().map(|x| (*x).to_string()).collect()
219    }
220
221    fn graph(edges: &[(&str, &[&str])]) -> HashMap<String, Vec<String>> {
222        edges
223            .iter()
224            .map(|(k, vs)| ((*k).to_string(), strs(vs)))
225            .collect()
226    }
227
228    // -- canonicalise_cycle (the dedup key for consumes cycles) ---------------
229    #[test]
230    fn canonicalise_cycle_is_stable_across_rotations() {
231        // Input always ends with the duplicated start node (the dfs_consumes
232        // shape); every rotation of one cycle yields the same canonical key.
233        assert_eq!(
234            canonicalise_cycle(&strs(&["a", "b", "c", "a"])),
235            strs(&["a", "b", "c"])
236        );
237        assert_eq!(
238            canonicalise_cycle(&strs(&["b", "c", "a", "b"])),
239            strs(&["a", "b", "c"])
240        );
241        assert_eq!(
242            canonicalise_cycle(&strs(&["c", "a", "b", "c"])),
243            strs(&["a", "b", "c"])
244        );
245    }
246
247    #[test]
248    fn canonicalise_cycle_edge_cases() {
249        assert_eq!(canonicalise_cycle(&[]), Vec::<String>::new());
250        assert_eq!(canonicalise_cycle(&strs(&["a", "a"])), strs(&["a"])); // self-loop
251        assert_eq!(
252            canonicalise_cycle(&strs(&["b", "a", "b"])),
253            strs(&["a", "b"])
254        );
255    }
256
257    // -- detect_consumes_cycles over synthetic adjacency maps -----------------
258    #[test]
259    fn detect_consumes_cycles_silent_on_acyclic() {
260        let g = graph(&[("a", &["b"]), ("b", &["c"]), ("c", &[])]);
261        let mut errors = Vec::new();
262        detect_consumes_cycles(&g, &no_sites(), &mut errors);
263        assert!(errors.is_empty());
264    }
265
266    #[test]
267    fn detect_consumes_cycles_reports_each_cycle_once() {
268        let mut e2 = Vec::new();
269        detect_consumes_cycles(
270            &graph(&[("a", &["b"]), ("b", &["a"])]),
271            &no_sites(),
272            &mut e2,
273        );
274        assert_eq!(e2.len(), 1);
275
276        let mut e3 = Vec::new();
277        detect_consumes_cycles(
278            &graph(&[("a", &["b"]), ("b", &["c"]), ("c", &["a"])]),
279            &no_sites(),
280            &mut e3,
281        );
282        assert_eq!(e3.len(), 1);
283
284        let mut eself = Vec::new();
285        detect_consumes_cycles(&graph(&[("a", &["a"])]), &no_sites(), &mut eself);
286        assert_eq!(eself.len(), 1);
287    }
288
289    #[test]
290    fn detect_consumes_cycles_reports_disjoint_cycles_separately() {
291        let mut errors = Vec::new();
292        detect_consumes_cycles(
293            &graph(&[("a", &["b"]), ("b", &["a"]), ("c", &["d"]), ("d", &["c"])]),
294            &no_sites(),
295            &mut errors,
296        );
297        assert_eq!(errors.len(), 2);
298    }
299
300    // #696: with a known consumes-site the cycle diagnostic is anchored on the
301    // closing unit's clause (real span + owning file) so the CLI can render it
302    // with ariadne source context; without one it stays spanless/unattributed.
303    #[test]
304    fn detect_consumes_cycles_attributes_to_closing_edges_site() {
305        // Self-loops keep the closing edge deterministic (a multi-unit cycle
306        // closes on whichever edge the key-ordered DFS reaches first).
307        let sites: HashMap<(String, String), (PathBuf, Span)> = [(
308            ("a".to_string(), "a".to_string()),
309            (PathBuf::from("a.bynk"), Span::new(3, 8)),
310        )]
311        .into_iter()
312        .collect();
313        let mut errors = Vec::new();
314        detect_consumes_cycles(&graph(&[("a", &["a"])]), &sites, &mut errors);
315        assert_eq!(errors.len(), 1);
316        let (file, err) = &errors[0];
317        assert_eq!(file.as_deref(), Some(Path::new("a.bynk")));
318        assert_eq!(err.span, Span::new(3, 8));
319
320        // A cycle whose closing edge has no recorded site stays unattributed.
321        let mut bare = Vec::new();
322        detect_consumes_cycles(&graph(&[("x", &["x"])]), &sites, &mut bare);
323        assert_eq!(bare.len(), 1);
324        assert_eq!(bare[0].0, None);
325        assert_eq!(bare[0].1.span, Span::default());
326    }
327
328    fn no_sites() -> HashMap<(String, String), (PathBuf, Span)> {
329        HashMap::new()
330    }
331}