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