use std::path::Path;
use blends_domain::graph_set::GraphSet;
use crate::ast::get_ast_graph;
use crate::content::Content;
use crate::syntax::get_syntax_graph;
#[must_use]
pub fn get_graphs_from_path(
path: &Path,
with_cfg: Option<bool>,
with_metadata: Option<bool>,
) -> GraphSet {
let Some(content) = Content::from_path(path, None) else {
return GraphSet::default();
};
let Some(ast) = get_ast_graph(&content) else {
return GraphSet::default();
};
let Some(syntax) = get_syntax_graph(&ast, &content, with_cfg, with_metadata) else {
return GraphSet {
ast: Some(ast),
syntax: None,
};
};
GraphSet {
ast: Some(ast),
syntax: Some(syntax),
}
}
#[cfg(test)]
mod tests {
use super::get_graphs_from_path;
use crate::attrs::{
ast_edge_attrs, ast_node_attrs, sorted_object, syntax_edge_attrs, syntax_node_attrs,
};
use blends_domain::ast::AstGraph;
use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
use blends_domain::NodeId;
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use test_case::test_case;
fn fixtures_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/test_files/syntax_graph")
}
fn results_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/results")
}
fn output_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/output")
}
fn export_ast_graph_as_json(graph: &AstGraph) -> Value {
let mut nodes = Map::new();
for (id, node) in &graph.nodes {
nodes.insert(id.0.to_string(), sorted_object(ast_node_attrs(node)));
}
let mut edges = Map::new();
for (from, targets) in &graph.edges {
let mut inner = Map::new();
for (to, edge) in targets {
inner.insert(to.0.to_string(), sorted_object(ast_edge_attrs(*edge)));
}
edges.insert(from.0.to_string(), Value::Object(inner));
}
let mut root = BTreeMap::new();
root.insert("edges".to_owned(), Value::Object(edges));
root.insert("nodes".to_owned(), Value::Object(nodes));
sorted_object(root)
}
fn export_syntax_graph_as_json(graph: &SyntaxGraph) -> Value {
let mut nodes = Map::new();
for (id, node) in &graph.nodes {
let attrs = syntax_node_attrs(node).unwrap_or_else(|| {
panic!("syntax export not implemented for {}", node.label_type())
});
nodes.insert(id.0.to_string(), sorted_object(attrs));
}
let mut edges = Map::new();
for (from, targets) in &graph.edges {
let mut inner = Map::new();
for (to, edge) in targets {
inner.insert(to.0.to_string(), sorted_object(syntax_edge_attrs(*edge)));
}
edges.insert(from.0.to_string(), Value::Object(inner));
}
let mut root = BTreeMap::new();
root.insert("edges".to_owned(), Value::Object(edges));
root.insert("nodes".to_owned(), Value::Object(nodes));
sorted_object(root)
}
#[test]
fn empty_set_for_unsupported_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.unknown");
fs::write(&path, b"whatever").unwrap();
assert!(get_graphs_from_path(&path, None, None).ast.is_none());
}
#[test]
fn empty_set_for_malformed_supported_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.java");
fs::write(&path, b"class A {").unwrap();
assert!(get_graphs_from_path(&path, None, None).ast.is_none());
}
fn rename_field_key(key: &str) -> String {
key.strip_prefix("label_field_")
.map_or_else(|| key.to_owned(), |field| format!("{field}_id"))
}
fn rename_node_attrs(attrs: &Value) -> Value {
let Some(attrs) = attrs.as_object() else {
return attrs.clone();
};
let mut renamed = Map::new();
for (key, value) in attrs {
renamed.insert(rename_field_key(key), value.clone());
}
Value::Object(renamed)
}
fn normalize_field_keys(graph: &Value) -> Value {
let mut nodes = Map::new();
if let Some(original) = graph.get("nodes").and_then(Value::as_object) {
for (id, attrs) in original {
nodes.insert(id.clone(), rename_node_attrs(attrs));
}
}
let mut result = Map::new();
if let Some(edges) = graph.get("edges") {
result.insert("edges".to_owned(), edges.clone());
}
result.insert("nodes".to_owned(), Value::Object(nodes));
Value::Object(result)
}
fn write_rust_output(suffix: &str, relative: &str, ast: &Value, syntax: Option<&Value>) {
let mut entry = Map::new();
entry.insert("graph".to_owned(), ast.clone());
if let Some(syntax) = syntax {
entry.insert("syntax_graph".to_owned(), syntax.clone());
}
let mut by_path = Map::new();
by_path.insert(relative.to_owned(), Value::Object(entry));
let mut root = Map::new();
root.insert("graphs".to_owned(), Value::Object(by_path));
let pretty = serde_json::to_string_pretty(&Value::Object(root)).expect("serialize output");
let dir = output_dir();
fs::create_dir_all(&dir).expect("create output dir");
fs::write(dir.join(format!("root-graph_{suffix}.json")), pretty).expect("write output");
}
fn section(graph: &Value, key: &str) -> Map<String, Value> {
graph
.get(key)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default()
}
fn ignore_line_for(nodes: Map<String, Value>, skip_types: &[&str]) -> Map<String, Value> {
nodes
.into_iter()
.map(|(id, mut attrs)| {
let skip = attrs
.get("label_type")
.and_then(Value::as_str)
.is_some_and(|kind| skip_types.contains(&kind));
if let Some(node) = attrs.as_object_mut().filter(|_| skip) {
node.remove("label_l");
}
(id, attrs)
})
.collect()
}
fn diff_section(
kind: &str,
rust: &Map<String, Value>,
python: &Map<String, Value>,
) -> Vec<String> {
let mut diffs = Vec::new();
for (id, rust_entry) in rust {
match python.get(id) {
None => diffs.push(format!(
"{kind} {id}: in rust output, missing in python golden"
)),
Some(python_entry) if python_entry != rust_entry => diffs.push(format!(
"{kind} {id} differs:\n rust: {rust_entry}\n python: {python_entry}"
)),
Some(_) => {}
}
}
for id in python.keys() {
if !rust.contains_key(id) {
diffs.push(format!(
"{kind} {id}: in python golden, missing in rust output"
));
}
}
diffs
}
const MAX_REPORTED_DIFFS: usize = 30;
const SYNTAX_NOT_YET_MIGRATED: &[&str] = &[
"elixir", "go", "hcl", "kotlin", "php", "ruby", "rust", "scala", "swift",
];
const SYNTAX_IN_PROGRESS: &[&str] = &[];
fn ast_diffs(rust_ast: &Value, golden: &Value, suffix: &str) -> Vec<String> {
let expected = golden
.get("graph")
.map(normalize_field_keys)
.expect("locate graph block in python golden");
let line_skip: &[&str] = match suffix {
"c_sharp" => &["class_declaration", "method_declaration"],
_ => &[],
};
let mut diffs = diff_section(
"node",
&ignore_line_for(section(rust_ast, "nodes"), line_skip),
&ignore_line_for(section(&expected, "nodes"), line_skip),
);
diffs.extend(diff_section(
"edge",
§ion(rust_ast, "edges"),
§ion(&expected, "edges"),
));
diffs
}
fn syntax_diffs(generated_syntax: &Value, golden: &Value) -> Vec<String> {
let expected = golden
.get("syntax_graph")
.cloned()
.expect("locate syntax_graph block in python golden");
let mut diffs = diff_section(
"syntax node",
§ion(generated_syntax, "nodes"),
§ion(&expected, "nodes"),
);
diffs.extend(diff_section(
"syntax edge",
§ion(generated_syntax, "edges"),
§ion(&expected, "edges"),
));
diffs
}
fn missing_ids(generated_syntax: &Value) -> BTreeSet<String> {
section(generated_syntax, "nodes")
.into_iter()
.filter(|(_, attrs)| {
attrs.get("label_type").and_then(Value::as_str) == Some("MissingNode")
})
.map(|(id, _)| id)
.collect()
}
fn edge_target_ids(edges: &Map<String, Value>, from: &str) -> Vec<String> {
edges
.get(from)
.and_then(Value::as_object)
.map(|targets| targets.keys().cloned().collect())
.unwrap_or_default()
}
fn pending_subtree_ids(generated_syntax: &Value) -> BTreeSet<String> {
let edges = section(generated_syntax, "edges");
let mut skip = missing_ids(generated_syntax);
let mut stack: Vec<String> = skip.iter().cloned().collect();
while let Some(from) = stack.pop() {
let fresh: Vec<String> = edge_target_ids(&edges, &from)
.into_iter()
.filter(|to| skip.insert(to.clone()))
.collect();
stack.extend(fresh);
}
skip
}
fn drop_missing_nodes(
nodes: Map<String, Value>,
skip: &BTreeSet<String>,
) -> Map<String, Value> {
nodes
.into_iter()
.filter(|(id, _)| !skip.contains(id))
.collect()
}
fn drop_missing_targets(targets: &Value, skip: &BTreeSet<String>) -> Map<String, Value> {
targets
.as_object()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|(to, _)| !skip.contains(to))
.collect()
}
fn drop_missing_edges(
edges: Map<String, Value>,
skip: &BTreeSet<String>,
) -> Map<String, Value> {
edges
.into_iter()
.filter(|(from, _)| !skip.contains(from))
.map(|(from, targets)| (from, drop_missing_targets(&targets, skip)))
.filter(|(_, kept)| !kept.is_empty())
.map(|(from, kept)| (from, Value::Object(kept)))
.collect()
}
fn syntax_diffs_partial(generated_syntax: &Value, golden: &Value) -> Vec<String> {
let expected = golden
.get("syntax_graph")
.cloned()
.expect("locate syntax_graph block in python golden");
let skip = pending_subtree_ids(generated_syntax);
let mut diffs = diff_section(
"syntax node",
&drop_missing_nodes(section(generated_syntax, "nodes"), &skip),
&drop_missing_nodes(section(&expected, "nodes"), &skip),
);
diffs.extend(diff_section(
"syntax edge",
&drop_missing_edges(section(generated_syntax, "edges"), &skip),
&drop_missing_edges(section(&expected, "edges"), &skip),
));
diffs
}
#[test_case("c_sharp.cs", "c_sharp")]
#[test_case("elixir.ex", "elixir")]
#[test_case("go.go", "go")]
#[test_case("terraform.tf", "hcl")]
#[test_case("java.java", "java")]
#[test_case("javascript.js", "javascript")]
#[test_case("json.json", "json")]
#[test_case("kotlin.kt", "kotlin")]
#[test_case("python.py", "python")]
#[test_case("php.php", "php")]
#[test_case("ruby.rb", "ruby")]
#[test_case("rust.rs", "rust")]
#[test_case("scala.scala", "scala")]
#[test_case("swift.swift", "swift")]
#[test_case("syntax_cfg.ts", "typescript")]
#[test_case("yaml.yaml", "yaml")]
#[test_case("templates/helm_configmap.yaml", "helm_configmap_yaml")]
#[test_case("templates/helm_configmap.json", "helm_configmap_json")]
#[test_case("flow_mapping.yaml", "flow_mapping")]
#[test_case("flow_sequence.yaml", "flow_sequence")]
fn graph_generation(test_file: &str, suffix: &str) {
let path = fixtures_dir().join(test_file);
let graph_set = get_graphs_from_path(&path, None, None);
assert!(
!(SYNTAX_NOT_YET_MIGRATED.contains(&suffix) && SYNTAX_IN_PROGRESS.contains(&suffix)),
"suffix {suffix} cannot be pending and in progress at the same time"
);
assert_eq!(
graph_set.syntax.is_none(),
SYNTAX_NOT_YET_MIGRATED.contains(&suffix),
"\n[Syntax Parity Error] Inconsistency detected for language extension: .{suffix}\n\
- Was syntax graph generated (None)? -> {}\n\
- Is it marked as pending in SYNTAX_NOT_YET_MIGRATED? -> {}\n\
👉 Hint: If it was generated but is marked as pending, move '.{suffix}' to \
SYNTAX_IN_PROGRESS (partial compare) or drop it from both lists (strict compare).\n\
👉 Hint: If it is not pending but returned None, a regression occurred in the language dispatcher.",
graph_set.syntax.is_none(),
SYNTAX_NOT_YET_MIGRATED.contains(&suffix)
);
let generated_ast = graph_set
.ast
.as_ref()
.map(export_ast_graph_as_json)
.expect("AST graph should be built for the fixture");
let generated_syntax = graph_set.syntax.as_ref().map(export_syntax_graph_as_json);
let relative = format!("test/data/test_files/{test_file}");
write_rust_output(suffix, &relative, &generated_ast, generated_syntax.as_ref());
let python_results: Value = serde_json::from_str(
&fs::read_to_string(results_dir().join(format!("root-graph_{suffix}.json")))
.expect("read python golden"),
)
.expect("parse python golden");
let golden = python_results
.get("graphs")
.and_then(|graphs| graphs.get(&relative))
.expect("locate the fixture entry in python golden");
let mut diffs = ast_diffs(&generated_ast, golden, suffix);
if let Some(generated_syntax) = &generated_syntax {
if SYNTAX_IN_PROGRESS.contains(&suffix) {
diffs.extend(syntax_diffs_partial(generated_syntax, golden));
} else {
diffs.extend(syntax_diffs(generated_syntax, golden));
}
}
assert_graph_parity(suffix, &diffs);
}
#[test_case("java.java", "java")]
fn graph_generation_with_metadata(test_file: &str, suffix: &str) {
let path = fixtures_dir().join(test_file);
let mut graph_set = get_graphs_from_path(&path, None, Some(true));
let relative_fixture = format!("test/data/test_files/syntax_graph/{test_file}");
if let Some(syntax) = graph_set.syntax.as_mut() {
if let Some(SyntaxNode::Metadata {
path: metadata_path,
..
}) = syntax.nodes.get_mut(&NodeId(0))
{
*metadata_path = relative_fixture;
}
}
let generated_ast = graph_set
.ast
.as_ref()
.map(export_ast_graph_as_json)
.expect("AST graph should be built for the fixture");
let generated_syntax = graph_set
.syntax
.as_ref()
.map(export_syntax_graph_as_json)
.expect("syntax graph should be built with metadata");
let relative = format!("test/data/test_files/{test_file}");
write_rust_output(
&format!("metadata_{suffix}"),
&relative,
&generated_ast,
Some(&generated_syntax),
);
let python_results: Value = serde_json::from_str(
&fs::read_to_string(results_dir().join(format!("root-graph-metadata_{suffix}.json")))
.expect("read python golden"),
)
.expect("parse python golden");
let golden = python_results
.get("graphs")
.and_then(|graphs| graphs.get(&relative))
.expect("locate the fixture entry in python golden");
let mut diffs = ast_diffs(&generated_ast, golden, suffix);
diffs.extend(syntax_diffs(&generated_syntax, golden));
assert_graph_parity(suffix, &diffs);
}
fn assert_graph_parity(suffix: &str, diffs: &[String]) {
let shown = diffs
.iter()
.take(MAX_REPORTED_DIFFS)
.cloned()
.collect::<Vec<_>>()
.join("\n");
let extra = diffs.len().saturating_sub(MAX_REPORTED_DIFFS);
let more = if extra > 0 {
format!("\n… and {extra} more differing entries")
} else {
String::new()
};
assert!(
diffs.is_empty(),
"graph parity mismatch for {suffix} ({} differing entries):\n{shown}{more}",
diffs.len()
);
}
}