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 blends_domain::ast::AstGraph;
42    use blends_domain::syntax::{
43        FileInstanceData, FileStructData, FileStructValue, SyntaxEdge, SyntaxGraph, SyntaxNode,
44    };
45    use blends_domain::Ast;
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 sorted_object(attrs: BTreeMap<String, Value>) -> Value {
66        let mut map = Map::new();
67        for (key, value) in attrs {
68            map.insert(key, value);
69        }
70        Value::Object(map)
71    }
72
73    fn export_ast_graph_as_json(graph: &AstGraph) -> Value {
74        let mut nodes = Map::new();
75        for (id, node) in &graph.nodes {
76            let mut attrs = BTreeMap::new();
77            attrs.insert("label_l".to_owned(), Value::from(node.line.to_string()));
78            attrs.insert("label_c".to_owned(), Value::from(node.col.to_string()));
79            attrs.insert("label_type".to_owned(), Value::from(node.kind.clone()));
80            if let Some(text) = &node.text {
81                attrs.insert("label_text".to_owned(), Value::from(text.clone()));
82            }
83            for (name, child) in &node.fields {
84                attrs.insert(name.clone(), Value::from(child.0));
85            }
86            nodes.insert(id.0.to_string(), sorted_object(attrs));
87        }
88
89        let mut edges = Map::new();
90        for (from, targets) in &graph.edges {
91            let mut inner = Map::new();
92            for (to, edge) in targets {
93                let mut attrs = BTreeMap::new();
94                let Ast = edge.kind;
95                attrs.insert("label_ast".to_owned(), Value::from("AST"));
96                attrs.insert(
97                    "label_index".to_owned(),
98                    Value::from(edge.index.to_string()),
99                );
100                inner.insert(to.0.to_string(), sorted_object(attrs));
101            }
102            edges.insert(from.0.to_string(), Value::Object(inner));
103        }
104
105        let mut root = BTreeMap::new();
106        root.insert("edges".to_owned(), Value::Object(edges));
107        root.insert("nodes".to_owned(), Value::Object(nodes));
108        sorted_object(root)
109    }
110
111    fn file_struct_to_json(data: &FileStructData) -> Value {
112        let mut attrs = BTreeMap::new();
113        attrs.insert("node".to_owned(), Value::from(data.node.0));
114        attrs.insert("type".to_owned(), Value::from(data.kind.clone()));
115        attrs.insert(
116            "data".to_owned(),
117            match &data.data {
118                FileStructValue::MethodName(name) => Value::from(name.clone()),
119                FileStructValue::Children(children) => struct_children_to_json(children),
120            },
121        );
122        if let Some(node_range) = &data.node_range {
123            attrs.insert(
124                "node_range".to_owned(),
125                Value::from(node_range.iter().map(|id| id.0).collect::<Vec<_>>()),
126            );
127        }
128        sorted_object(attrs)
129    }
130
131    fn struct_children_to_json(children: &BTreeMap<String, FileStructData>) -> Value {
132        let mut map = Map::new();
133        for (name, data) in children {
134            map.insert(name.clone(), file_struct_to_json(data));
135        }
136        Value::Object(map)
137    }
138
139    fn instances_to_json(
140        instances: &BTreeMap<String, BTreeMap<String, FileInstanceData>>,
141    ) -> Value {
142        let mut classes = Map::new();
143        for (class_name, class_instances) in instances {
144            let mut variables = Map::new();
145            for (variable_name, data) in class_instances {
146                let mut fields = BTreeMap::new();
147                fields.insert("object".to_owned(), Value::from(data.object.clone()));
148                fields.insert("source".to_owned(), Value::from(data.source.clone()));
149                fields.insert(
150                    "source_type".to_owned(),
151                    Value::from(data.source_type.clone()),
152                );
153                variables.insert(variable_name.clone(), sorted_object(fields));
154            }
155            classes.insert(class_name.clone(), Value::Object(variables));
156        }
157        Value::Object(classes)
158    }
159
160    #[allow(
161        clippy::too_many_lines,
162        reason = "exhaustive per-variant attribute export; grows with each migrated node type"
163    )]
164    fn syntax_node_attrs(node: &SyntaxNode) -> BTreeMap<String, Value> {
165        let mut attrs = BTreeMap::new();
166        attrs.insert("label_type".to_owned(), Value::from(node.label_type()));
167        match node {
168            SyntaxNode::Argument
169            | SyntaxNode::ArgumentList
170            | SyntaxNode::ArrayInitializer
171            | SyntaxNode::Break
172            | SyntaxNode::CatchDeclaration
173            | SyntaxNode::ClassBody
174            | SyntaxNode::Continue
175            | SyntaxNode::DeclarationBlock
176            | SyntaxNode::ExecutionBlock
177            | SyntaxNode::ExpressionStatement
178            | SyntaxNode::File
179            | SyntaxNode::Modifiers
180            | SyntaxNode::ParameterList
181            | SyntaxNode::ParenthesizedExpression
182            | SyntaxNode::SwitchBody => {}
183            SyntaxNode::If {
184                condition_id,
185                true_id,
186                false_id,
187                initializer,
188            } => {
189                attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
190                if let Some(true_id) = true_id {
191                    attrs.insert("true_id".to_owned(), Value::from(true_id.0));
192                }
193                if let Some(false_id) = false_id {
194                    attrs.insert("false_id".to_owned(), Value::from(false_id.0));
195                }
196                if let Some(initializer) = initializer {
197                    attrs.insert("initializer_id".to_owned(), Value::from(initializer.0));
198                }
199            }
200            SyntaxNode::ForStatement {
201                block_id,
202                initializer_id,
203                condition_id,
204                update_id,
205            } => {
206                attrs.insert("block_id".to_owned(), Value::from(block_id.0));
207                if let Some(initializer_id) = initializer_id {
208                    attrs.insert("initializer_id".to_owned(), Value::from(initializer_id.0));
209                }
210                if let Some(condition_id) = condition_id {
211                    attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
212                }
213                if let Some(update_id) = update_id {
214                    attrs.insert("update_id".to_owned(), Value::from(update_id.0));
215                }
216            }
217            SyntaxNode::ForEachStatement {
218                variable_id,
219                iterable_item_id,
220                block_id,
221            } => {
222                attrs.insert("variable_id".to_owned(), Value::from(variable_id.0));
223                attrs.insert(
224                    "iterable_item_id".to_owned(),
225                    Value::from(iterable_item_id.0),
226                );
227                if let Some(block_id) = block_id {
228                    attrs.insert("block_id".to_owned(), Value::from(block_id.0));
229                }
230            }
231            SyntaxNode::SwitchStatement { block_id, value_id } => {
232                attrs.insert("block_id".to_owned(), Value::from(block_id.0));
233                attrs.insert("value_id".to_owned(), Value::from(value_id.0));
234            }
235            SyntaxNode::TernaryOperation {
236                condition_id,
237                true_id,
238                false_id,
239            } => {
240                attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
241                attrs.insert("true_id".to_owned(), Value::from(true_id.0));
242                attrs.insert("false_id".to_owned(), Value::from(false_id.0));
243            }
244            SyntaxNode::SwitchSection { case_expression } => {
245                attrs.insert(
246                    "case_expression".to_owned(),
247                    Value::from(case_expression.clone()),
248                );
249            }
250            SyntaxNode::DoStatement {
251                block_id,
252                condition_id,
253            } => {
254                attrs.insert("block_id".to_owned(), Value::from(block_id.0));
255                attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
256            }
257            SyntaxNode::MethodInvocation {
258                expression,
259                object,
260                symbol_scope,
261                expression_id,
262                arguments_id,
263                object_id,
264                block_id,
265                receiver_type_fqn,
266            } => {
267                attrs.insert("expression".to_owned(), Value::from(expression.clone()));
268                if let Some(object) = object {
269                    attrs.insert("object".to_owned(), Value::from(object.clone()));
270                }
271                if let Some(symbol_scope) = symbol_scope {
272                    attrs.insert("symbol_scope".to_owned(), Value::from(symbol_scope.0));
273                }
274                if let Some(expression_id) = expression_id {
275                    attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
276                }
277                if let Some(arguments_id) = arguments_id {
278                    attrs.insert("arguments_id".to_owned(), Value::from(arguments_id.0));
279                }
280                if let Some(object_id) = object_id {
281                    attrs.insert("object_id".to_owned(), Value::from(object_id.0));
282                }
283                if let Some(block_id) = block_id {
284                    attrs.insert("block_id".to_owned(), Value::from(block_id.0));
285                }
286                if let Some(receiver_type_fqn) = receiver_type_fqn {
287                    attrs.insert(
288                        "receiver_type_fqn".to_owned(),
289                        Value::from(receiver_type_fqn.clone()),
290                    );
291                }
292            }
293            SyntaxNode::ReservedWord { value } | SyntaxNode::This { value } => {
294                attrs.insert("value".to_owned(), Value::from(value.clone()));
295            }
296            SyntaxNode::Attribute { name } => {
297                attrs.insert("name".to_owned(), Value::from(name.clone()));
298            }
299            SyntaxNode::Import {
300                expression,
301                alias,
302                method_name,
303                import_type,
304            } => {
305                if let Some(expression) = expression {
306                    attrs.insert("expression".to_owned(), Value::from(expression.clone()));
307                }
308                if let Some(alias) = alias {
309                    attrs.insert("label_alias".to_owned(), Value::from(alias.clone()));
310                }
311                if let Some(method_name) = method_name {
312                    attrs.insert("method_name".to_owned(), Value::from(method_name.clone()));
313                }
314                if let Some(import_type) = import_type {
315                    attrs.insert("import_type".to_owned(), Value::from(import_type.clone()));
316                }
317            }
318            SyntaxNode::UsingStatement {
319                block_id,
320                declaration_id,
321            } => {
322                attrs.insert("block_id".to_owned(), Value::from(block_id.0));
323                if let Some(declaration_id) = declaration_id {
324                    attrs.insert("declaration_id".to_owned(), Value::from(declaration_id.0));
325                }
326            }
327            SyntaxNode::ObjectCreation {
328                name,
329                arguments_id,
330                initializer_id,
331            } => {
332                attrs.insert("name".to_owned(), Value::from(name.clone()));
333                if let Some(arguments_id) = arguments_id {
334                    attrs.insert("arguments_id".to_owned(), Value::from(arguments_id.0));
335                }
336                if let Some(initializer_id) = initializer_id {
337                    attrs.insert("initializer_id".to_owned(), Value::from(initializer_id.0));
338                }
339            }
340            SyntaxNode::BinaryOperation {
341                operator,
342                left_id,
343                right_id,
344            } => {
345                attrs.insert("operator".to_owned(), Value::from(operator.clone()));
346                if let Some(left_id) = left_id {
347                    attrs.insert("left_id".to_owned(), Value::from(left_id.0));
348                }
349                if let Some(right_id) = right_id {
350                    attrs.insert("right_id".to_owned(), Value::from(right_id.0));
351                }
352            }
353            SyntaxNode::NamedArgument {
354                value_id,
355                argument_name,
356            } => {
357                attrs.insert("value_id".to_owned(), Value::from(value_id.0));
358                if let Some(argument_name) = argument_name {
359                    attrs.insert(
360                        "argument_name".to_owned(),
361                        Value::from(argument_name.clone()),
362                    );
363                }
364            }
365            SyntaxNode::UnaryExpression {
366                operator,
367                operand_id,
368            } => {
369                attrs.insert("operator".to_owned(), Value::from(operator.clone()));
370                attrs.insert("operand_id".to_owned(), Value::from(operand_id.0));
371            }
372            SyntaxNode::Assignment {
373                variable_id,
374                value_id,
375                operator,
376            } => {
377                attrs.insert("variable_id".to_owned(), Value::from(variable_id.0));
378                if let Some(value_id) = value_id {
379                    attrs.insert("value_id".to_owned(), Value::from(value_id.0));
380                }
381                if let Some(operator) = operator {
382                    attrs.insert("operator".to_owned(), Value::from(operator.clone()));
383                }
384            }
385            SyntaxNode::MemberAccess {
386                member,
387                expression,
388                expression_id,
389                symbol_scope,
390            } => {
391                attrs.insert("member".to_owned(), Value::from(member.clone()));
392                attrs.insert("expression".to_owned(), Value::from(expression.clone()));
393                attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
394                if let Some(symbol_scope) = symbol_scope {
395                    attrs.insert("symbol_scope".to_owned(), Value::from(symbol_scope.0));
396                }
397            }
398            SyntaxNode::ElementAccess {
399                expression_id,
400                arguments_id,
401            } => {
402                attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
403                if let Some(arguments_id) = arguments_id {
404                    attrs.insert("arguments_id".to_owned(), Value::from(arguments_id.0));
405                }
406            }
407            SyntaxNode::AwaitExpression { expression_id } => {
408                attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
409            }
410            SyntaxNode::Annotation { name, arguments_id } => {
411                attrs.insert("name".to_owned(), Value::from(name.clone()));
412                if let Some(arguments_id) = arguments_id {
413                    attrs.insert("arguments_id".to_owned(), Value::from(arguments_id.0));
414                }
415            }
416            SyntaxNode::Return { value_id } => {
417                if let Some(value_id) = value_id {
418                    attrs.insert("value_id".to_owned(), Value::from(value_id.0));
419                }
420            }
421            SyntaxNode::ThrowStatement { expression_id } => {
422                if let Some(expression_id) = expression_id {
423                    attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
424                }
425            }
426            SyntaxNode::WhileStatement {
427                block_id,
428                condition_id,
429            } => {
430                attrs.insert("block_id".to_owned(), Value::from(block_id.0));
431                if let Some(condition_id) = condition_id {
432                    attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
433                }
434            }
435            SyntaxNode::ElseClause { block_id } => {
436                attrs.insert("block_id".to_owned(), Value::from(block_id.0));
437            }
438            SyntaxNode::RestPattern { value_id } | SyntaxNode::SpreadElement { value_id } => {
439                attrs.insert("value_id".to_owned(), Value::from(value_id.0));
440            }
441            SyntaxNode::TryStatement {
442                block_id,
443                resources_id,
444            } => {
445                attrs.insert("block_id".to_owned(), Value::from(block_id.0));
446                if let Some(resources_id) = resources_id {
447                    attrs.insert("resources_id".to_owned(), Value::from(resources_id.0));
448                }
449            }
450            SyntaxNode::CatchClause {
451                block_id,
452                catch_declaration,
453            } => {
454                if let Some(block_id) = block_id {
455                    attrs.insert("block_id".to_owned(), Value::from(block_id.0));
456                }
457                if let Some(catch_declaration) = catch_declaration {
458                    attrs.insert(
459                        "catch_declaration".to_owned(),
460                        Value::from(catch_declaration.0),
461                    );
462                }
463            }
464            SyntaxNode::FinallyClause { block_id } => {
465                if let Some(block_id) = block_id {
466                    attrs.insert("block_id".to_owned(), Value::from(block_id.0));
467                }
468            }
469            SyntaxNode::Class {
470                name,
471                block_id,
472                modifiers_id,
473                inherited_class,
474                access_modifiers,
475            } => {
476                attrs.insert("name".to_owned(), Value::from(name.clone()));
477                if let Some(block_id) = block_id {
478                    attrs.insert("block_id".to_owned(), Value::from(block_id.0));
479                }
480                if let Some(modifiers_id) = modifiers_id {
481                    attrs.insert("modifiers_id".to_owned(), Value::from(modifiers_id.0));
482                }
483                if let Some(inherited_class) = inherited_class {
484                    attrs.insert(
485                        "inherited_class".to_owned(),
486                        Value::from(inherited_class.clone()),
487                    );
488                }
489                if let Some(access_modifiers) = access_modifiers {
490                    attrs.insert(
491                        "access_modifiers".to_owned(),
492                        Value::from(access_modifiers.clone()),
493                    );
494                }
495            }
496            SyntaxNode::Comment { comment } => {
497                attrs.insert("comment".to_owned(), Value::from(comment.clone()));
498            }
499            SyntaxNode::Literal { value, value_type } => {
500                attrs.insert("value".to_owned(), Value::from(value.clone()));
501                attrs.insert("value_type".to_owned(), Value::from(value_type.clone()));
502            }
503            SyntaxNode::Metadata {
504                path,
505                structure,
506                instances,
507                imports,
508                package,
509            } => {
510                attrs.insert("path".to_owned(), Value::from(path.clone()));
511                attrs.insert("structure".to_owned(), struct_children_to_json(structure));
512                attrs.insert("instances".to_owned(), instances_to_json(instances));
513                attrs.insert("imports".to_owned(), Value::from(imports.clone()));
514                if let Some(package) = package {
515                    attrs.insert("package".to_owned(), Value::from(package.clone()));
516                }
517            }
518            SyntaxNode::MethodDeclaration {
519                name,
520                access_modifiers,
521                block_id,
522                modifiers_id,
523                parameters_id,
524            } => {
525                if let Some(name) = name {
526                    attrs.insert("name".to_owned(), Value::from(name.clone()));
527                }
528                if let Some(access_modifiers) = access_modifiers {
529                    attrs.insert(
530                        "access_modifiers".to_owned(),
531                        Value::from(access_modifiers.clone()),
532                    );
533                }
534                if let Some(block_id) = block_id {
535                    attrs.insert("block_id".to_owned(), Value::from(block_id.0));
536                }
537                if let Some(modifiers_id) = modifiers_id {
538                    attrs.insert("modifiers_id".to_owned(), Value::from(modifiers_id.0));
539                }
540                if let Some(parameters_id) = parameters_id {
541                    attrs.insert("parameters_id".to_owned(), Value::from(parameters_id.0));
542                }
543            }
544            SyntaxNode::MissingNode { node_type } => {
545                attrs.insert("node_type".to_owned(), Value::from(node_type.clone()));
546            }
547            SyntaxNode::Namespace { name, block_id } => {
548                attrs.insert("name".to_owned(), Value::from(name.clone()));
549                if let Some(block_id) = block_id {
550                    attrs.insert("block_id".to_owned(), Value::from(block_id.0));
551                }
552            }
553            SyntaxNode::Object { name, tf_reference } => {
554                if let Some(name) = name {
555                    attrs.insert("name".to_owned(), Value::from(name.clone()));
556                }
557                if let Some(tf_reference) = tf_reference {
558                    attrs.insert("tf_reference".to_owned(), Value::from(tf_reference.clone()));
559                }
560            }
561            SyntaxNode::Parameter {
562                variable,
563                variable_type,
564                value_id,
565                parameter_mode,
566            } => {
567                if let Some(variable) = variable {
568                    attrs.insert("variable".to_owned(), Value::from(variable.clone()));
569                }
570                if let Some(variable_type) = variable_type {
571                    attrs.insert(
572                        "variable_type".to_owned(),
573                        Value::from(variable_type.clone()),
574                    );
575                }
576                if let Some(value_id) = value_id {
577                    attrs.insert("value_id".to_owned(), Value::from(value_id.0));
578                }
579                if let Some(parameter_mode) = parameter_mode {
580                    attrs.insert(
581                        "parameter_mode".to_owned(),
582                        Value::from(parameter_mode.clone()),
583                    );
584                }
585            }
586            SyntaxNode::VariableDeclaration {
587                variable,
588                variable_type,
589                value_id,
590                variable_id: _,
591                access_modifier,
592            } => {
593                attrs.insert("variable".to_owned(), Value::from(variable.clone()));
594                if let Some(variable_type) = variable_type {
595                    attrs.insert(
596                        "variable_type".to_owned(),
597                        Value::from(variable_type.clone()),
598                    );
599                }
600                if let Some(value_id) = value_id {
601                    attrs.insert("value_id".to_owned(), Value::from(value_id.0));
602                }
603                if let Some(access_modifier) = access_modifier {
604                    attrs.insert(
605                        "access_modifier".to_owned(),
606                        Value::from(access_modifier.clone()),
607                    );
608                }
609            }
610            SyntaxNode::Pair { key_id, value_id } => {
611                attrs.insert("key_id".to_owned(), Value::from(key_id.0));
612                attrs.insert("value_id".to_owned(), Value::from(value_id.0));
613            }
614            SyntaxNode::SymbolLookup {
615                symbol,
616                symbol_scope,
617                value,
618            } => {
619                attrs.insert("symbol".to_owned(), Value::from(symbol.clone()));
620                if let Some(scope) = symbol_scope {
621                    attrs.insert("symbol_scope".to_owned(), Value::from(scope.0));
622                }
623                if let Some(value) = value {
624                    attrs.insert("value".to_owned(), Value::from(value.clone()));
625                }
626            }
627            other => panic!("syntax export not implemented for {}", other.label_type()),
628        }
629        attrs
630    }
631
632    fn syntax_edge_attrs(edge: SyntaxEdge) -> BTreeMap<String, Value> {
633        let mut attrs = BTreeMap::new();
634        if edge.ast.is_some() {
635            attrs.insert("label_ast".to_owned(), Value::from("AST"));
636        }
637        if edge.cfg.is_some() {
638            attrs.insert("label_cfg".to_owned(), Value::from("CFG"));
639        }
640        attrs
641    }
642
643    fn export_syntax_graph_as_json(graph: &SyntaxGraph) -> Value {
644        let mut nodes = Map::new();
645        for (id, node) in &graph.nodes {
646            nodes.insert(id.0.to_string(), sorted_object(syntax_node_attrs(node)));
647        }
648
649        let mut edges = Map::new();
650        for (from, targets) in &graph.edges {
651            let mut inner = Map::new();
652            for (to, edge) in targets {
653                inner.insert(to.0.to_string(), sorted_object(syntax_edge_attrs(*edge)));
654            }
655            edges.insert(from.0.to_string(), Value::Object(inner));
656        }
657
658        let mut root = BTreeMap::new();
659        root.insert("edges".to_owned(), Value::Object(edges));
660        root.insert("nodes".to_owned(), Value::Object(nodes));
661        sorted_object(root)
662    }
663
664    #[test]
665    fn empty_set_for_unsupported_file() {
666        let dir = tempfile::tempdir().unwrap();
667        let path = dir.path().join("a.unknown");
668        fs::write(&path, b"whatever").unwrap();
669
670        assert!(get_graphs_from_path(&path, None, None).ast.is_none());
671    }
672
673    #[test]
674    fn empty_set_for_malformed_supported_file() {
675        let dir = tempfile::tempdir().unwrap();
676        let path = dir.path().join("a.java");
677        fs::write(&path, b"class A {").unwrap();
678
679        assert!(get_graphs_from_path(&path, None, None).ast.is_none());
680    }
681
682    fn rename_field_key(key: &str) -> String {
683        key.strip_prefix("label_field_")
684            .map_or_else(|| key.to_owned(), |field| format!("{field}_id"))
685    }
686
687    fn rename_node_attrs(attrs: &Value) -> Value {
688        let Some(attrs) = attrs.as_object() else {
689            return attrs.clone();
690        };
691        let mut renamed = Map::new();
692        for (key, value) in attrs {
693            renamed.insert(rename_field_key(key), value.clone());
694        }
695        Value::Object(renamed)
696    }
697
698    // The python golden keys node fields as `label_field_<field>`; the Rust engine
699    // keys them as `<field>_id`. Rewrite the golden's keys in memory so the two
700    // graphs compare on content. The `results/` file on disk is untouched.
701    fn normalize_field_keys(graph: &Value) -> Value {
702        let mut nodes = Map::new();
703        if let Some(original) = graph.get("nodes").and_then(Value::as_object) {
704            for (id, attrs) in original {
705                nodes.insert(id.clone(), rename_node_attrs(attrs));
706            }
707        }
708
709        let mut result = Map::new();
710        if let Some(edges) = graph.get("edges") {
711            result.insert("edges".to_owned(), edges.clone());
712        }
713        result.insert("nodes".to_owned(), Value::Object(nodes));
714        Value::Object(result)
715    }
716
717    fn write_rust_output(suffix: &str, relative: &str, ast: &Value, syntax: Option<&Value>) {
718        let mut entry = Map::new();
719        entry.insert("graph".to_owned(), ast.clone());
720        if let Some(syntax) = syntax {
721            entry.insert("syntax_graph".to_owned(), syntax.clone());
722        }
723        let mut by_path = Map::new();
724        by_path.insert(relative.to_owned(), Value::Object(entry));
725        let mut root = Map::new();
726        root.insert("graphs".to_owned(), Value::Object(by_path));
727
728        let pretty = serde_json::to_string_pretty(&Value::Object(root)).expect("serialize output");
729        let dir = output_dir();
730        fs::create_dir_all(&dir).expect("create output dir");
731        fs::write(dir.join(format!("root-graph_{suffix}.json")), pretty).expect("write output");
732    }
733
734    fn section(graph: &Value, key: &str) -> Map<String, Value> {
735        graph
736            .get(key)
737            .and_then(Value::as_object)
738            .cloned()
739            .unwrap_or_default()
740    }
741
742    // The python golden dumps the ast after the syntax readers run, and some
743    // readers overwrite `label_l` (c# class/method declaration take the line of
744    // their identifier). Until those readers exist in rust, drop only `label_l`
745    // on those node types from both sides, so just the mutated line is ignored
746    // while every other attribute (line column, type, fields) is still compared.
747    fn ignore_line_for(nodes: Map<String, Value>, skip_types: &[&str]) -> Map<String, Value> {
748        nodes
749            .into_iter()
750            .map(|(id, mut attrs)| {
751                let skip = attrs
752                    .get("label_type")
753                    .and_then(Value::as_str)
754                    .is_some_and(|kind| skip_types.contains(&kind));
755                if let Some(node) = attrs.as_object_mut().filter(|_| skip) {
756                    node.remove("label_l");
757                }
758                (id, attrs)
759            })
760            .collect()
761    }
762
763    // Concise per-entry diff: only the ids whose content differs, rust vs python.
764    fn diff_section(
765        kind: &str,
766        rust: &Map<String, Value>,
767        python: &Map<String, Value>,
768    ) -> Vec<String> {
769        let mut diffs = Vec::new();
770        for (id, rust_entry) in rust {
771            match python.get(id) {
772                None => diffs.push(format!(
773                    "{kind} {id}: in rust output, missing in python golden"
774                )),
775                Some(python_entry) if python_entry != rust_entry => diffs.push(format!(
776                    "{kind} {id} differs:\n  rust:   {rust_entry}\n  python: {python_entry}"
777                )),
778                Some(_) => {}
779            }
780        }
781        for id in python.keys() {
782            if !rust.contains_key(id) {
783                diffs.push(format!(
784                    "{kind} {id}: in python golden, missing in rust output"
785                ));
786            }
787        }
788        diffs
789    }
790
791    const MAX_REPORTED_DIFFS: usize = 30;
792
793    // Fixture suffixes whose language has no rust syntax dispatcher yet.
794    const SYNTAX_NOT_YET_MIGRATED: &[&str] = &[
795        "elixir",
796        "go",
797        "hcl",
798        "javascript",
799        "kotlin",
800        "php",
801        "ruby",
802        "rust",
803        "scala",
804        "swift",
805        "typescript",
806    ];
807
808    // Fixture suffixes whose language dispatcher exists but still degrades
809    // some node types to MissingNode: implemented nodes compare exactly,
810    // diffs anchored at the missing placeholders are ignored.
811    const SYNTAX_IN_PROGRESS: &[&str] = &[];
812
813    fn ast_diffs(rust_ast: &Value, golden: &Value, suffix: &str) -> Vec<String> {
814        let expected = golden
815            .get("graph")
816            .map(normalize_field_keys)
817            .expect("locate graph block in python golden");
818
819        // c# alone has syntax readers that overwrite `label_l` on class/method
820        // declarations; ignore that attribute only for c#, never other languages.
821        let line_skip: &[&str] = match suffix {
822            "c_sharp" => &["class_declaration", "method_declaration"],
823            _ => &[],
824        };
825        let mut diffs = diff_section(
826            "node",
827            &ignore_line_for(section(rust_ast, "nodes"), line_skip),
828            &ignore_line_for(section(&expected, "nodes"), line_skip),
829        );
830        diffs.extend(diff_section(
831            "edge",
832            &section(rust_ast, "edges"),
833            &section(&expected, "edges"),
834        ));
835        diffs
836    }
837
838    fn syntax_diffs(generated_syntax: &Value, golden: &Value) -> Vec<String> {
839        let expected = golden
840            .get("syntax_graph")
841            .cloned()
842            .expect("locate syntax_graph block in python golden");
843
844        let mut diffs = diff_section(
845            "syntax node",
846            &section(generated_syntax, "nodes"),
847            &section(&expected, "nodes"),
848        );
849        diffs.extend(diff_section(
850            "syntax edge",
851            &section(generated_syntax, "edges"),
852            &section(&expected, "edges"),
853        ));
854        diffs
855    }
856
857    // The ids the rust engine degraded to MissingNode: subtrees whose reader
858    // is pending, plus punctuation children the python readers consumed
859    // without creating a syntax node.
860    fn missing_ids(generated_syntax: &Value) -> BTreeSet<String> {
861        section(generated_syntax, "nodes")
862            .into_iter()
863            .filter(|(_, attrs)| {
864                attrs.get("label_type").and_then(Value::as_str) == Some("MissingNode")
865            })
866            .map(|(id, _)| id)
867            .collect()
868    }
869
870    fn edge_target_ids(edges: &Map<String, Value>, from: &str) -> Vec<String> {
871        edges
872            .get(from)
873            .and_then(Value::as_object)
874            .map(|targets| targets.keys().cloned().collect())
875            .unwrap_or_default()
876    }
877
878    fn pending_subtree_ids(generated_syntax: &Value) -> BTreeSet<String> {
879        let edges = section(generated_syntax, "edges");
880        let mut skip = missing_ids(generated_syntax);
881        let mut stack: Vec<String> = skip.iter().cloned().collect();
882        while let Some(from) = stack.pop() {
883            let fresh: Vec<String> = edge_target_ids(&edges, &from)
884                .into_iter()
885                .filter(|to| skip.insert(to.clone()))
886                .collect();
887            stack.extend(fresh);
888        }
889        skip
890    }
891
892    fn drop_missing_nodes(
893        nodes: Map<String, Value>,
894        skip: &BTreeSet<String>,
895    ) -> Map<String, Value> {
896        nodes
897            .into_iter()
898            .filter(|(id, _)| !skip.contains(id))
899            .collect()
900    }
901
902    fn drop_missing_targets(targets: &Value, skip: &BTreeSet<String>) -> Map<String, Value> {
903        targets
904            .as_object()
905            .cloned()
906            .unwrap_or_default()
907            .into_iter()
908            .filter(|(to, _)| !skip.contains(to))
909            .collect()
910    }
911
912    fn drop_missing_edges(
913        edges: Map<String, Value>,
914        skip: &BTreeSet<String>,
915    ) -> Map<String, Value> {
916        edges
917            .into_iter()
918            .filter(|(from, _)| !skip.contains(from))
919            .map(|(from, targets)| (from, drop_missing_targets(&targets, skip)))
920            .filter(|(_, kept)| !kept.is_empty())
921            .map(|(from, kept)| (from, Value::Object(kept)))
922            .collect()
923    }
924
925    // In-progress compare: everything not anchored at a MissingNode id must
926    // match the golden exactly, on both sides — a golden-only entry between
927    // implemented nodes is still a real diff.
928    fn syntax_diffs_partial(generated_syntax: &Value, golden: &Value) -> Vec<String> {
929        let expected = golden
930            .get("syntax_graph")
931            .cloned()
932            .expect("locate syntax_graph block in python golden");
933        let skip = pending_subtree_ids(generated_syntax);
934
935        let mut diffs = diff_section(
936            "syntax node",
937            &drop_missing_nodes(section(generated_syntax, "nodes"), &skip),
938            &drop_missing_nodes(section(&expected, "nodes"), &skip),
939        );
940        diffs.extend(diff_section(
941            "syntax edge",
942            &drop_missing_edges(section(generated_syntax, "edges"), &skip),
943            &drop_missing_edges(section(&expected, "edges"), &skip),
944        ));
945        diffs
946    }
947
948    #[test_case("c_sharp.cs", "c_sharp")]
949    #[test_case("elixir.ex", "elixir")]
950    #[test_case("go.go", "go")]
951    #[test_case("terraform.tf", "hcl")]
952    #[test_case("java.java", "java")]
953    #[test_case("javascript.js", "javascript")]
954    #[test_case("json.json", "json")]
955    #[test_case("kotlin.kt", "kotlin")]
956    #[test_case("python.py", "python")]
957    #[test_case("php.php", "php")]
958    #[test_case("ruby.rb", "ruby")]
959    #[test_case("rust.rs", "rust")]
960    #[test_case("scala.scala", "scala")]
961    #[test_case("swift.swift", "swift")]
962    #[test_case("syntax_cfg.ts", "typescript")]
963    #[test_case("yaml.yaml", "yaml")]
964    #[test_case("templates/helm_configmap.yaml", "helm_configmap_yaml")]
965    #[test_case("templates/helm_configmap.json", "helm_configmap_json")]
966    #[test_case("flow_mapping.yaml", "flow_mapping")]
967    #[test_case("flow_sequence.yaml", "flow_sequence")]
968    fn graph_generation(test_file: &str, suffix: &str) {
969        let path = fixtures_dir().join(test_file);
970        let graph_set = get_graphs_from_path(&path, None, None);
971
972        assert!(
973            !(SYNTAX_NOT_YET_MIGRATED.contains(&suffix) && SYNTAX_IN_PROGRESS.contains(&suffix)),
974            "suffix {suffix} cannot be pending and in progress at the same time"
975        );
976        assert_eq!(
977            graph_set.syntax.is_none(),
978            SYNTAX_NOT_YET_MIGRATED.contains(&suffix),
979            "\n[Syntax Parity Error] Inconsistency detected for language extension: .{suffix}\n\
980             - Was syntax graph generated (None)? -> {}\n\
981             - Is it marked as pending in SYNTAX_NOT_YET_MIGRATED? -> {}\n\
982             šŸ‘‰ Hint: If it was generated but is marked as pending, move '.{suffix}' to \
983             SYNTAX_IN_PROGRESS (partial compare) or drop it from both lists (strict compare).\n\
984             šŸ‘‰ Hint: If it is not pending but returned None, a regression occurred in the language dispatcher.",
985            graph_set.syntax.is_none(),
986            SYNTAX_NOT_YET_MIGRATED.contains(&suffix)
987        );
988
989        let generated_ast = graph_set
990            .ast
991            .as_ref()
992            .map(export_ast_graph_as_json)
993            .expect("AST graph should be built for the fixture");
994
995        let generated_syntax = graph_set.syntax.as_ref().map(export_syntax_graph_as_json);
996
997        let relative = format!("test/data/test_files/{test_file}");
998        write_rust_output(suffix, &relative, &generated_ast, generated_syntax.as_ref());
999
1000        let python_results: Value = serde_json::from_str(
1001            &fs::read_to_string(results_dir().join(format!("root-graph_{suffix}.json")))
1002                .expect("read python golden"),
1003        )
1004        .expect("parse python golden");
1005        let golden = python_results
1006            .get("graphs")
1007            .and_then(|graphs| graphs.get(&relative))
1008            .expect("locate the fixture entry in python golden");
1009
1010        let mut diffs = ast_diffs(&generated_ast, golden, suffix);
1011        if let Some(generated_syntax) = &generated_syntax {
1012            if SYNTAX_IN_PROGRESS.contains(&suffix) {
1013                diffs.extend(syntax_diffs_partial(generated_syntax, golden));
1014            } else {
1015                diffs.extend(syntax_diffs(generated_syntax, golden));
1016            }
1017        }
1018
1019        assert_graph_parity(suffix, &diffs);
1020    }
1021
1022    #[test_case("java.java", "java")]
1023    fn graph_generation_with_metadata(test_file: &str, suffix: &str) {
1024        let path = fixtures_dir().join(test_file);
1025        let mut graph_set = get_graphs_from_path(&path, None, Some(true));
1026
1027        let relative_fixture = format!("test/data/test_files/syntax_graph/{test_file}");
1028        if let Some(syntax) = graph_set.syntax.as_mut() {
1029            if let Some(SyntaxNode::Metadata {
1030                path: metadata_path,
1031                ..
1032            }) = syntax.nodes.get_mut(&NodeId(0))
1033            {
1034                *metadata_path = relative_fixture;
1035            }
1036        }
1037
1038        let generated_ast = graph_set
1039            .ast
1040            .as_ref()
1041            .map(export_ast_graph_as_json)
1042            .expect("AST graph should be built for the fixture");
1043        let generated_syntax = graph_set
1044            .syntax
1045            .as_ref()
1046            .map(export_syntax_graph_as_json)
1047            .expect("syntax graph should be built with metadata");
1048
1049        let relative = format!("test/data/test_files/{test_file}");
1050        write_rust_output(
1051            &format!("metadata_{suffix}"),
1052            &relative,
1053            &generated_ast,
1054            Some(&generated_syntax),
1055        );
1056
1057        let python_results: Value = serde_json::from_str(
1058            &fs::read_to_string(results_dir().join(format!("root-graph-metadata_{suffix}.json")))
1059                .expect("read python golden"),
1060        )
1061        .expect("parse python golden");
1062        let golden = python_results
1063            .get("graphs")
1064            .and_then(|graphs| graphs.get(&relative))
1065            .expect("locate the fixture entry in python golden");
1066
1067        let mut diffs = ast_diffs(&generated_ast, golden, suffix);
1068        diffs.extend(syntax_diffs(&generated_syntax, golden));
1069        assert_graph_parity(suffix, &diffs);
1070    }
1071
1072    fn assert_graph_parity(suffix: &str, diffs: &[String]) {
1073        let shown = diffs
1074            .iter()
1075            .take(MAX_REPORTED_DIFFS)
1076            .cloned()
1077            .collect::<Vec<_>>()
1078            .join("\n");
1079        let extra = diffs.len().saturating_sub(MAX_REPORTED_DIFFS);
1080        let more = if extra > 0 {
1081            format!("\n… and {extra} more differing entries")
1082        } else {
1083            String::new()
1084        };
1085
1086        assert!(
1087            diffs.is_empty(),
1088            "graph parity mismatch for {suffix} ({} differing entries):\n{shown}{more}",
1089            diffs.len()
1090        );
1091    }
1092}