Skip to main content

fluidattacks_blends/
graphs.rs

1//! Top-level orchestration: a path to its set of graphs.
2
3use std::path::Path;
4
5use blends_domain::graph_set::GraphSet;
6
7use crate::ast::get_ast_graph;
8use crate::content::Content;
9use crate::syntax::get_syntax_graph;
10
11#[must_use]
12pub fn get_graphs_from_path(
13    path: &Path,
14    with_cfg: Option<bool>,
15    with_metadata: Option<bool>,
16) -> GraphSet {
17    let Some(content) = Content::from_path(path, None) else {
18        return GraphSet::default();
19    };
20
21    let Some(ast) = get_ast_graph(&content) else {
22        return GraphSet::default();
23    };
24
25    let Some(syntax) = get_syntax_graph(&ast, &content, with_cfg, with_metadata) else {
26        return GraphSet {
27            ast: Some(ast),
28            syntax: None,
29        };
30    };
31
32    GraphSet {
33        ast: Some(ast),
34        syntax: Some(syntax),
35    }
36}
37
38#[cfg(test)]
39mod tests {
40    use super::get_graphs_from_path;
41    use crate::attrs::{
42        ast_edge_attrs, ast_node_attrs, sorted_object, syntax_edge_attrs, syntax_node_attrs,
43    };
44    use blends_domain::ast::AstGraph;
45    use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
46    use blends_domain::NodeId;
47    use serde_json::{Map, Value};
48    use std::collections::{BTreeMap, BTreeSet};
49    use std::fs;
50    use std::path::{Path, PathBuf};
51    use test_case::test_case;
52
53    fn fixtures_dir() -> PathBuf {
54        Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/test_files/syntax_graph")
55    }
56
57    fn results_dir() -> PathBuf {
58        Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/results")
59    }
60
61    fn output_dir() -> PathBuf {
62        Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/output")
63    }
64
65    fn export_ast_graph_as_json(graph: &AstGraph) -> Value {
66        let mut nodes = Map::new();
67        for (id, node) in &graph.nodes {
68            nodes.insert(id.0.to_string(), sorted_object(ast_node_attrs(node)));
69        }
70
71        let mut edges = Map::new();
72        for (from, targets) in &graph.edges {
73            let mut inner = Map::new();
74            for (to, edge) in targets {
75                inner.insert(to.0.to_string(), sorted_object(ast_edge_attrs(*edge)));
76            }
77            edges.insert(from.0.to_string(), Value::Object(inner));
78        }
79
80        let mut root = BTreeMap::new();
81        root.insert("edges".to_owned(), Value::Object(edges));
82        root.insert("nodes".to_owned(), Value::Object(nodes));
83        sorted_object(root)
84    }
85
86    fn export_syntax_graph_as_json(graph: &SyntaxGraph) -> Value {
87        let mut nodes = Map::new();
88        for (id, node) in &graph.nodes {
89            let attrs = syntax_node_attrs(node).unwrap_or_else(|| {
90                panic!("syntax export not implemented for {}", node.label_type())
91            });
92            nodes.insert(id.0.to_string(), sorted_object(attrs));
93        }
94
95        let mut edges = Map::new();
96        for (from, targets) in &graph.edges {
97            let mut inner = Map::new();
98            for (to, edge) in targets {
99                inner.insert(to.0.to_string(), sorted_object(syntax_edge_attrs(*edge)));
100            }
101            edges.insert(from.0.to_string(), Value::Object(inner));
102        }
103
104        let mut root = BTreeMap::new();
105        root.insert("edges".to_owned(), Value::Object(edges));
106        root.insert("nodes".to_owned(), Value::Object(nodes));
107        sorted_object(root)
108    }
109
110    #[test]
111    fn empty_set_for_unsupported_file() {
112        let dir = tempfile::tempdir().unwrap();
113        let path = dir.path().join("a.unknown");
114        fs::write(&path, b"whatever").unwrap();
115
116        assert!(get_graphs_from_path(&path, None, None).ast.is_none());
117    }
118
119    #[test]
120    fn empty_set_for_malformed_supported_file() {
121        let dir = tempfile::tempdir().unwrap();
122        let path = dir.path().join("a.java");
123        fs::write(&path, b"class A {").unwrap();
124
125        assert!(get_graphs_from_path(&path, None, None).ast.is_none());
126    }
127
128    fn rename_field_key(key: &str) -> String {
129        key.strip_prefix("label_field_")
130            .map_or_else(|| key.to_owned(), |field| format!("{field}_id"))
131    }
132
133    fn rename_node_attrs(attrs: &Value) -> Value {
134        let Some(attrs) = attrs.as_object() else {
135            return attrs.clone();
136        };
137        let mut renamed = Map::new();
138        for (key, value) in attrs {
139            renamed.insert(rename_field_key(key), value.clone());
140        }
141        Value::Object(renamed)
142    }
143
144    // The python golden keys node fields as `label_field_<field>`; the Rust engine
145    // keys them as `<field>_id`. Rewrite the golden's keys in memory so the two
146    // graphs compare on content. The `results/` file on disk is untouched.
147    fn normalize_field_keys(graph: &Value) -> Value {
148        let mut nodes = Map::new();
149        if let Some(original) = graph.get("nodes").and_then(Value::as_object) {
150            for (id, attrs) in original {
151                nodes.insert(id.clone(), rename_node_attrs(attrs));
152            }
153        }
154
155        let mut result = Map::new();
156        if let Some(edges) = graph.get("edges") {
157            result.insert("edges".to_owned(), edges.clone());
158        }
159        result.insert("nodes".to_owned(), Value::Object(nodes));
160        Value::Object(result)
161    }
162
163    fn write_rust_output(suffix: &str, relative: &str, ast: &Value, syntax: Option<&Value>) {
164        let mut entry = Map::new();
165        entry.insert("graph".to_owned(), ast.clone());
166        if let Some(syntax) = syntax {
167            entry.insert("syntax_graph".to_owned(), syntax.clone());
168        }
169        let mut by_path = Map::new();
170        by_path.insert(relative.to_owned(), Value::Object(entry));
171        let mut root = Map::new();
172        root.insert("graphs".to_owned(), Value::Object(by_path));
173
174        let pretty = serde_json::to_string_pretty(&Value::Object(root)).expect("serialize output");
175        let dir = output_dir();
176        fs::create_dir_all(&dir).expect("create output dir");
177        fs::write(dir.join(format!("root-graph_{suffix}.json")), pretty).expect("write output");
178    }
179
180    fn section(graph: &Value, key: &str) -> Map<String, Value> {
181        graph
182            .get(key)
183            .and_then(Value::as_object)
184            .cloned()
185            .unwrap_or_default()
186    }
187
188    // The python golden dumps the ast after the syntax readers run, and some
189    // readers overwrite `label_l` (c# class/method declaration take the line of
190    // their identifier). Until those readers exist in rust, drop only `label_l`
191    // on those node types from both sides, so just the mutated line is ignored
192    // while every other attribute (line column, type, fields) is still compared.
193    fn ignore_line_for(nodes: Map<String, Value>, skip_types: &[&str]) -> Map<String, Value> {
194        nodes
195            .into_iter()
196            .map(|(id, mut attrs)| {
197                let skip = attrs
198                    .get("label_type")
199                    .and_then(Value::as_str)
200                    .is_some_and(|kind| skip_types.contains(&kind));
201                if let Some(node) = attrs.as_object_mut().filter(|_| skip) {
202                    node.remove("label_l");
203                }
204                (id, attrs)
205            })
206            .collect()
207    }
208
209    // Concise per-entry diff: only the ids whose content differs, rust vs python.
210    fn diff_section(
211        kind: &str,
212        rust: &Map<String, Value>,
213        python: &Map<String, Value>,
214    ) -> Vec<String> {
215        let mut diffs = Vec::new();
216        for (id, rust_entry) in rust {
217            match python.get(id) {
218                None => diffs.push(format!(
219                    "{kind} {id}: in rust output, missing in python golden"
220                )),
221                Some(python_entry) if python_entry != rust_entry => diffs.push(format!(
222                    "{kind} {id} differs:\n  rust:   {rust_entry}\n  python: {python_entry}"
223                )),
224                Some(_) => {}
225            }
226        }
227        for id in python.keys() {
228            if !rust.contains_key(id) {
229                diffs.push(format!(
230                    "{kind} {id}: in python golden, missing in rust output"
231                ));
232            }
233        }
234        diffs
235    }
236
237    // `diff_section` is what makes every graph_generation parity assertion
238    // meaningful: if it stopped reporting a category of mismatch, the suite
239    // would keep passing while the rust and python graphs drifted apart.
240    // Passing fixtures agree, so the mismatch arms are only reachable here.
241    #[test_case(&[("n1", 1)], &[], "in rust output, missing in python golden" ; "only rust produced the entry")]
242    #[test_case(&[("n1", 1)], &[("n1", 2)], "node n1 differs" ; "both produced it with different content")]
243    #[test_case(&[], &[("n1", 1)], "in python golden, missing in rust output" ; "only the python golden has it")]
244    fn diff_section_reports_each_kind_of_mismatch(
245        rust_entries: &[(&str, i32)],
246        python_entries: &[(&str, i32)],
247        expected: &str,
248    ) {
249        let to_map = |entries: &[(&str, i32)]| -> Map<String, Value> {
250            entries
251                .iter()
252                .map(|(id, value)| ((*id).to_owned(), Value::from(*value)))
253                .collect()
254        };
255
256        let diffs = diff_section("node", &to_map(rust_entries), &to_map(python_entries));
257
258        let [only] = diffs.as_slice() else {
259            panic!("expected exactly one diff, got {diffs:?}");
260        };
261        assert!(
262            only.contains(expected),
263            "diff {only:?} does not report {expected:?}"
264        );
265    }
266
267    #[test]
268    fn diff_section_reports_nothing_when_both_graphs_agree() {
269        let mut entries = Map::new();
270        entries.insert("n1".to_owned(), Value::from(1));
271
272        assert!(diff_section("node", &entries, &entries).is_empty());
273    }
274
275    const MAX_REPORTED_DIFFS: usize = 30;
276
277    // Fixture suffixes whose language has no rust syntax dispatcher yet.
278    const SYNTAX_NOT_YET_MIGRATED: &[&str] = &[
279        "elixir", "go", "hcl", "kotlin", "php", "ruby", "rust", "scala", "swift",
280    ];
281
282    // Fixture suffixes whose language dispatcher exists but still degrades
283    // some node types to MissingNode: implemented nodes compare exactly,
284    // diffs anchored at the missing placeholders are ignored.
285    const SYNTAX_IN_PROGRESS: &[&str] = &[];
286
287    fn ast_diffs(rust_ast: &Value, golden: &Value, suffix: &str) -> Vec<String> {
288        let expected = golden
289            .get("graph")
290            .map(normalize_field_keys)
291            .expect("locate graph block in python golden");
292
293        // c# alone has syntax readers that overwrite `label_l` on class/method
294        // declarations; ignore that attribute only for c#, never other languages.
295        let line_skip: &[&str] = match suffix {
296            "c_sharp" => &["class_declaration", "method_declaration"],
297            _ => &[],
298        };
299        let mut diffs = diff_section(
300            "node",
301            &ignore_line_for(section(rust_ast, "nodes"), line_skip),
302            &ignore_line_for(section(&expected, "nodes"), line_skip),
303        );
304        diffs.extend(diff_section(
305            "edge",
306            &section(rust_ast, "edges"),
307            &section(&expected, "edges"),
308        ));
309        diffs
310    }
311
312    fn syntax_diffs(generated_syntax: &Value, golden: &Value) -> Vec<String> {
313        let expected = golden
314            .get("syntax_graph")
315            .cloned()
316            .expect("locate syntax_graph block in python golden");
317
318        let mut diffs = diff_section(
319            "syntax node",
320            &section(generated_syntax, "nodes"),
321            &section(&expected, "nodes"),
322        );
323        diffs.extend(diff_section(
324            "syntax edge",
325            &section(generated_syntax, "edges"),
326            &section(&expected, "edges"),
327        ));
328        diffs
329    }
330
331    // The ids the rust engine degraded to MissingNode: subtrees whose reader
332    // is pending, plus punctuation children the python readers consumed
333    // without creating a syntax node.
334    fn missing_ids(generated_syntax: &Value) -> BTreeSet<String> {
335        section(generated_syntax, "nodes")
336            .into_iter()
337            .filter(|(_, attrs)| {
338                attrs.get("label_type").and_then(Value::as_str) == Some("MissingNode")
339            })
340            .map(|(id, _)| id)
341            .collect()
342    }
343
344    fn edge_target_ids(edges: &Map<String, Value>, from: &str) -> Vec<String> {
345        edges
346            .get(from)
347            .and_then(Value::as_object)
348            .map(|targets| targets.keys().cloned().collect())
349            .unwrap_or_default()
350    }
351
352    fn pending_subtree_ids(generated_syntax: &Value) -> BTreeSet<String> {
353        let edges = section(generated_syntax, "edges");
354        let mut skip = missing_ids(generated_syntax);
355        let mut stack: Vec<String> = skip.iter().cloned().collect();
356        while let Some(from) = stack.pop() {
357            let fresh: Vec<String> = edge_target_ids(&edges, &from)
358                .into_iter()
359                .filter(|to| skip.insert(to.clone()))
360                .collect();
361            stack.extend(fresh);
362        }
363        skip
364    }
365
366    fn drop_missing_nodes(
367        nodes: Map<String, Value>,
368        skip: &BTreeSet<String>,
369    ) -> Map<String, Value> {
370        nodes
371            .into_iter()
372            .filter(|(id, _)| !skip.contains(id))
373            .collect()
374    }
375
376    fn drop_missing_targets(targets: &Value, skip: &BTreeSet<String>) -> Map<String, Value> {
377        targets
378            .as_object()
379            .cloned()
380            .unwrap_or_default()
381            .into_iter()
382            .filter(|(to, _)| !skip.contains(to))
383            .collect()
384    }
385
386    fn drop_missing_edges(
387        edges: Map<String, Value>,
388        skip: &BTreeSet<String>,
389    ) -> Map<String, Value> {
390        edges
391            .into_iter()
392            .filter(|(from, _)| !skip.contains(from))
393            .map(|(from, targets)| (from, drop_missing_targets(&targets, skip)))
394            .filter(|(_, kept)| !kept.is_empty())
395            .map(|(from, kept)| (from, Value::Object(kept)))
396            .collect()
397    }
398
399    // In-progress compare: everything not anchored at a MissingNode id must
400    // match the golden exactly, on both sides — a golden-only entry between
401    // implemented nodes is still a real diff.
402    fn syntax_diffs_partial(generated_syntax: &Value, golden: &Value) -> Vec<String> {
403        let expected = golden
404            .get("syntax_graph")
405            .cloned()
406            .expect("locate syntax_graph block in python golden");
407        let skip = pending_subtree_ids(generated_syntax);
408
409        let mut diffs = diff_section(
410            "syntax node",
411            &drop_missing_nodes(section(generated_syntax, "nodes"), &skip),
412            &drop_missing_nodes(section(&expected, "nodes"), &skip),
413        );
414        diffs.extend(diff_section(
415            "syntax edge",
416            &drop_missing_edges(section(generated_syntax, "edges"), &skip),
417            &drop_missing_edges(section(&expected, "edges"), &skip),
418        ));
419        diffs
420    }
421
422    #[test_case("c_sharp.cs", "c_sharp")]
423    #[test_case("elixir.ex", "elixir")]
424    #[test_case("go.go", "go")]
425    #[test_case("terraform.tf", "hcl")]
426    #[test_case("java.java", "java")]
427    #[test_case("javascript.js", "javascript")]
428    #[test_case("json.json", "json")]
429    #[test_case("kotlin.kt", "kotlin")]
430    #[test_case("python.py", "python")]
431    #[test_case("php.php", "php")]
432    #[test_case("ruby.rb", "ruby")]
433    #[test_case("rust.rs", "rust")]
434    #[test_case("scala.scala", "scala")]
435    #[test_case("swift.swift", "swift")]
436    #[test_case("syntax_cfg.ts", "typescript")]
437    #[test_case("yaml.yaml", "yaml")]
438    #[test_case("templates/helm_configmap.yaml", "helm_configmap_yaml")]
439    #[test_case("templates/helm_configmap.json", "helm_configmap_json")]
440    #[test_case("flow_mapping.yaml", "flow_mapping")]
441    #[test_case("flow_sequence.yaml", "flow_sequence")]
442    fn graph_generation(test_file: &str, suffix: &str) {
443        let path = fixtures_dir().join(test_file);
444        let graph_set = get_graphs_from_path(&path, None, None);
445
446        assert!(
447            !(SYNTAX_NOT_YET_MIGRATED.contains(&suffix) && SYNTAX_IN_PROGRESS.contains(&suffix)),
448            "suffix {suffix} cannot be pending and in progress at the same time"
449        );
450        assert_eq!(
451            graph_set.syntax.is_none(),
452            SYNTAX_NOT_YET_MIGRATED.contains(&suffix),
453            "\n[Syntax Parity Error] Inconsistency detected for language extension: .{suffix}\n\
454             - Was syntax graph generated (None)? -> {}\n\
455             - Is it marked as pending in SYNTAX_NOT_YET_MIGRATED? -> {}\n\
456             šŸ‘‰ Hint: If it was generated but is marked as pending, move '.{suffix}' to \
457             SYNTAX_IN_PROGRESS (partial compare) or drop it from both lists (strict compare).\n\
458             šŸ‘‰ Hint: If it is not pending but returned None, a regression occurred in the language dispatcher.",
459            graph_set.syntax.is_none(),
460            SYNTAX_NOT_YET_MIGRATED.contains(&suffix)
461        );
462
463        let generated_ast = graph_set
464            .ast
465            .as_ref()
466            .map(export_ast_graph_as_json)
467            .expect("AST graph should be built for the fixture");
468
469        let generated_syntax = graph_set.syntax.as_ref().map(export_syntax_graph_as_json);
470
471        let relative = format!("test/data/test_files/{test_file}");
472        write_rust_output(suffix, &relative, &generated_ast, generated_syntax.as_ref());
473
474        let python_results: Value = serde_json::from_str(
475            &fs::read_to_string(results_dir().join(format!("root-graph_{suffix}.json")))
476                .expect("read python golden"),
477        )
478        .expect("parse python golden");
479        let golden = python_results
480            .get("graphs")
481            .and_then(|graphs| graphs.get(&relative))
482            .expect("locate the fixture entry in python golden");
483
484        let mut diffs = ast_diffs(&generated_ast, golden, suffix);
485        if let Some(generated_syntax) = &generated_syntax {
486            if SYNTAX_IN_PROGRESS.contains(&suffix) {
487                diffs.extend(syntax_diffs_partial(generated_syntax, golden));
488            } else {
489                diffs.extend(syntax_diffs(generated_syntax, golden));
490            }
491        }
492
493        assert_graph_parity(suffix, &diffs);
494    }
495
496    #[test_case("java.java", "java")]
497    fn graph_generation_with_metadata(test_file: &str, suffix: &str) {
498        let path = fixtures_dir().join(test_file);
499        let mut graph_set = get_graphs_from_path(&path, None, Some(true));
500
501        let relative_fixture = format!("test/data/test_files/syntax_graph/{test_file}");
502        if let Some(syntax) = graph_set.syntax.as_mut() {
503            if let Some(SyntaxNode::Metadata {
504                path: metadata_path,
505                ..
506            }) = syntax.nodes.get_mut(&NodeId(0))
507            {
508                *metadata_path = relative_fixture;
509            }
510        }
511
512        let generated_ast = graph_set
513            .ast
514            .as_ref()
515            .map(export_ast_graph_as_json)
516            .expect("AST graph should be built for the fixture");
517        let generated_syntax = graph_set
518            .syntax
519            .as_ref()
520            .map(export_syntax_graph_as_json)
521            .expect("syntax graph should be built with metadata");
522
523        let relative = format!("test/data/test_files/{test_file}");
524        write_rust_output(
525            &format!("metadata_{suffix}"),
526            &relative,
527            &generated_ast,
528            Some(&generated_syntax),
529        );
530
531        let python_results: Value = serde_json::from_str(
532            &fs::read_to_string(results_dir().join(format!("root-graph-metadata_{suffix}.json")))
533                .expect("read python golden"),
534        )
535        .expect("parse python golden");
536        let golden = python_results
537            .get("graphs")
538            .and_then(|graphs| graphs.get(&relative))
539            .expect("locate the fixture entry in python golden");
540
541        let mut diffs = ast_diffs(&generated_ast, golden, suffix);
542        diffs.extend(syntax_diffs(&generated_syntax, golden));
543        assert_graph_parity(suffix, &diffs);
544    }
545
546    fn assert_graph_parity(suffix: &str, diffs: &[String]) {
547        let shown = diffs
548            .iter()
549            .take(MAX_REPORTED_DIFFS)
550            .cloned()
551            .collect::<Vec<_>>()
552            .join("\n");
553        let extra = diffs.len().saturating_sub(MAX_REPORTED_DIFFS);
554        let more = if extra > 0 {
555            format!("\n… and {extra} more differing entries")
556        } else {
557            String::new()
558        };
559
560        assert!(
561            diffs.is_empty(),
562            "graph parity mismatch for {suffix} ({} differing entries):\n{shown}{more}",
563            diffs.len()
564        );
565    }
566}