1use 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 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 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 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 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 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 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 §ion(rust_ast, "edges"),
833 §ion(&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 §ion(generated_syntax, "nodes"),
847 §ion(&expected, "nodes"),
848 );
849 diffs.extend(diff_section(
850 "syntax edge",
851 §ion(generated_syntax, "edges"),
852 §ion(&expected, "edges"),
853 ));
854 diffs
855 }
856
857 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 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}