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::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 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 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 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 const SYNTAX_NOT_YET_MIGRATED: &[&str] = &[
811 "elixir", "go", "hcl", "kotlin", "php", "ruby", "rust", "scala", "swift",
812 ];
813
814 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 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 §ion(rust_ast, "edges"),
839 §ion(&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 §ion(generated_syntax, "nodes"),
853 §ion(&expected, "nodes"),
854 );
855 diffs.extend(diff_section(
856 "syntax edge",
857 §ion(generated_syntax, "edges"),
858 §ion(&expected, "edges"),
859 ));
860 diffs
861 }
862
863 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 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}