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