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 blends_domain::ast::AstGraph;
use blends_domain::syntax::{
FileInstanceData, FileStructData, FileStructValue, SyntaxEdge, SyntaxGraph, SyntaxNode,
};
use blends_domain::Ast;
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 sorted_object(attrs: BTreeMap<String, Value>) -> Value {
let mut map = Map::new();
for (key, value) in attrs {
map.insert(key, value);
}
Value::Object(map)
}
fn export_ast_graph_as_json(graph: &AstGraph) -> Value {
let mut nodes = Map::new();
for (id, node) in &graph.nodes {
let mut attrs = BTreeMap::new();
attrs.insert("label_l".to_owned(), Value::from(node.line.to_string()));
attrs.insert("label_c".to_owned(), Value::from(node.col.to_string()));
attrs.insert("label_type".to_owned(), Value::from(node.kind.clone()));
if let Some(text) = &node.text {
attrs.insert("label_text".to_owned(), Value::from(text.clone()));
}
for (name, child) in &node.fields {
attrs.insert(name.clone(), Value::from(child.0));
}
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 {
let mut attrs = BTreeMap::new();
let Ast = edge.kind;
attrs.insert("label_ast".to_owned(), Value::from("AST"));
attrs.insert(
"label_index".to_owned(),
Value::from(edge.index.to_string()),
);
inner.insert(to.0.to_string(), sorted_object(attrs));
}
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 file_struct_to_json(data: &FileStructData) -> Value {
let mut attrs = BTreeMap::new();
attrs.insert("node".to_owned(), Value::from(data.node.0));
attrs.insert("type".to_owned(), Value::from(data.kind.clone()));
attrs.insert(
"data".to_owned(),
match &data.data {
FileStructValue::MethodName(name) => Value::from(name.clone()),
FileStructValue::Children(children) => struct_children_to_json(children),
},
);
if let Some(node_range) = &data.node_range {
attrs.insert(
"node_range".to_owned(),
Value::from(node_range.iter().map(|id| id.0).collect::<Vec<_>>()),
);
}
sorted_object(attrs)
}
fn struct_children_to_json(children: &BTreeMap<String, FileStructData>) -> Value {
let mut map = Map::new();
for (name, data) in children {
map.insert(name.clone(), file_struct_to_json(data));
}
Value::Object(map)
}
fn instances_to_json(
instances: &BTreeMap<String, BTreeMap<String, FileInstanceData>>,
) -> Value {
let mut classes = Map::new();
for (class_name, class_instances) in instances {
let mut variables = Map::new();
for (variable_name, data) in class_instances {
let mut fields = BTreeMap::new();
fields.insert("object".to_owned(), Value::from(data.object.clone()));
fields.insert("source".to_owned(), Value::from(data.source.clone()));
fields.insert(
"source_type".to_owned(),
Value::from(data.source_type.clone()),
);
variables.insert(variable_name.clone(), sorted_object(fields));
}
classes.insert(class_name.clone(), Value::Object(variables));
}
Value::Object(classes)
}
#[allow(
clippy::too_many_lines,
reason = "exhaustive per-variant attribute export; grows with each migrated node type"
)]
fn syntax_node_attrs(node: &SyntaxNode) -> BTreeMap<String, Value> {
let mut attrs = BTreeMap::new();
attrs.insert("label_type".to_owned(), Value::from(node.label_type()));
match node {
SyntaxNode::Argument
| SyntaxNode::ArgumentList
| SyntaxNode::ArrayInitializer
| SyntaxNode::Break
| SyntaxNode::CatchDeclaration
| SyntaxNode::ClassBody
| SyntaxNode::Continue
| SyntaxNode::DeclarationBlock
| SyntaxNode::ExecutionBlock
| SyntaxNode::ExpressionStatement
| SyntaxNode::File
| SyntaxNode::JsxElement
| SyntaxNode::Modifiers
| SyntaxNode::ParameterList
| SyntaxNode::ParenthesizedExpression
| SyntaxNode::SwitchBody => {}
SyntaxNode::If {
condition_id,
true_id,
false_id,
initializer,
} => {
attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
if let Some(true_id) = true_id {
attrs.insert("true_id".to_owned(), Value::from(true_id.0));
}
if let Some(false_id) = false_id {
attrs.insert("false_id".to_owned(), Value::from(false_id.0));
}
if let Some(initializer) = initializer {
attrs.insert("initializer_id".to_owned(), Value::from(initializer.0));
}
}
SyntaxNode::ForStatement {
block_id,
initializer_id,
condition_id,
update_id,
} => {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
if let Some(initializer_id) = initializer_id {
attrs.insert("initializer_id".to_owned(), Value::from(initializer_id.0));
}
if let Some(condition_id) = condition_id {
attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
}
if let Some(update_id) = update_id {
attrs.insert("update_id".to_owned(), Value::from(update_id.0));
}
}
SyntaxNode::ForEachStatement {
variable_id,
iterable_item_id,
block_id,
} => {
attrs.insert("variable_id".to_owned(), Value::from(variable_id.0));
attrs.insert(
"iterable_item_id".to_owned(),
Value::from(iterable_item_id.0),
);
if let Some(block_id) = block_id {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
}
}
SyntaxNode::SwitchStatement { block_id, value_id } => {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
attrs.insert("value_id".to_owned(), Value::from(value_id.0));
}
SyntaxNode::TernaryOperation {
condition_id,
true_id,
false_id,
} => {
attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
attrs.insert("true_id".to_owned(), Value::from(true_id.0));
attrs.insert("false_id".to_owned(), Value::from(false_id.0));
}
SyntaxNode::SwitchSection { case_expression } => {
attrs.insert(
"case_expression".to_owned(),
Value::from(case_expression.clone()),
);
}
SyntaxNode::DoStatement {
block_id,
condition_id,
} => {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
}
SyntaxNode::MethodInvocation {
expression,
object,
symbol_scope,
expression_id,
arguments_id,
object_id,
block_id,
receiver_type_fqn,
} => {
attrs.insert("expression".to_owned(), Value::from(expression.clone()));
if let Some(object) = object {
attrs.insert("object".to_owned(), Value::from(object.clone()));
}
if let Some(symbol_scope) = symbol_scope {
attrs.insert("symbol_scope".to_owned(), Value::from(symbol_scope.0));
}
if let Some(expression_id) = expression_id {
attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
}
if let Some(arguments_id) = arguments_id {
attrs.insert("arguments_id".to_owned(), Value::from(arguments_id.0));
}
if let Some(object_id) = object_id {
attrs.insert("object_id".to_owned(), Value::from(object_id.0));
}
if let Some(block_id) = block_id {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
}
if let Some(receiver_type_fqn) = receiver_type_fqn {
attrs.insert(
"receiver_type_fqn".to_owned(),
Value::from(receiver_type_fqn.clone()),
);
}
}
SyntaxNode::ReservedWord { value } | SyntaxNode::This { value } => {
attrs.insert("value".to_owned(), Value::from(value.clone()));
}
SyntaxNode::Attribute { name } => {
attrs.insert("name".to_owned(), Value::from(name.clone()));
}
SyntaxNode::Import {
expression,
alias,
method_name,
import_type,
} => {
if let Some(expression) = expression {
attrs.insert("expression".to_owned(), Value::from(expression.clone()));
}
if let Some(alias) = alias {
attrs.insert("label_alias".to_owned(), Value::from(alias.clone()));
}
if let Some(method_name) = method_name {
attrs.insert("method_name".to_owned(), Value::from(method_name.clone()));
}
if let Some(import_type) = import_type {
attrs.insert("import_type".to_owned(), Value::from(import_type.clone()));
}
}
SyntaxNode::UsingStatement {
block_id,
declaration_id,
} => {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
if let Some(declaration_id) = declaration_id {
attrs.insert("declaration_id".to_owned(), Value::from(declaration_id.0));
}
}
SyntaxNode::ObjectCreation {
name,
arguments_id,
initializer_id,
} => {
attrs.insert("name".to_owned(), Value::from(name.clone()));
if let Some(arguments_id) = arguments_id {
attrs.insert("arguments_id".to_owned(), Value::from(arguments_id.0));
}
if let Some(initializer_id) = initializer_id {
attrs.insert("initializer_id".to_owned(), Value::from(initializer_id.0));
}
}
SyntaxNode::BinaryOperation {
operator,
left_id,
right_id,
} => {
attrs.insert("operator".to_owned(), Value::from(operator.clone()));
if let Some(left_id) = left_id {
attrs.insert("left_id".to_owned(), Value::from(left_id.0));
}
if let Some(right_id) = right_id {
attrs.insert("right_id".to_owned(), Value::from(right_id.0));
}
}
SyntaxNode::NamedArgument {
value_id,
argument_name,
} => {
attrs.insert("value_id".to_owned(), Value::from(value_id.0));
if let Some(argument_name) = argument_name {
attrs.insert(
"argument_name".to_owned(),
Value::from(argument_name.clone()),
);
}
}
SyntaxNode::UnaryExpression {
operator,
operand_id,
} => {
attrs.insert("operator".to_owned(), Value::from(operator.clone()));
attrs.insert("operand_id".to_owned(), Value::from(operand_id.0));
}
SyntaxNode::Assignment {
variable_id,
value_id,
operator,
} => {
attrs.insert("variable_id".to_owned(), Value::from(variable_id.0));
if let Some(value_id) = value_id {
attrs.insert("value_id".to_owned(), Value::from(value_id.0));
}
if let Some(operator) = operator {
attrs.insert("operator".to_owned(), Value::from(operator.clone()));
}
}
SyntaxNode::MemberAccess {
member,
expression,
expression_id,
symbol_scope,
} => {
attrs.insert("member".to_owned(), Value::from(member.clone()));
attrs.insert("expression".to_owned(), Value::from(expression.clone()));
attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
if let Some(symbol_scope) = symbol_scope {
attrs.insert("symbol_scope".to_owned(), Value::from(symbol_scope.0));
}
}
SyntaxNode::ElementAccess {
expression_id,
arguments_id,
} => {
attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
if let Some(arguments_id) = arguments_id {
attrs.insert("arguments_id".to_owned(), Value::from(arguments_id.0));
}
}
SyntaxNode::AwaitExpression { expression_id } => {
attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
}
SyntaxNode::Annotation { name, arguments_id } => {
attrs.insert("name".to_owned(), Value::from(name.clone()));
if let Some(arguments_id) = arguments_id {
attrs.insert("arguments_id".to_owned(), Value::from(arguments_id.0));
}
}
SyntaxNode::Return { value_id } => {
if let Some(value_id) = value_id {
attrs.insert("value_id".to_owned(), Value::from(value_id.0));
}
}
SyntaxNode::ThrowStatement { expression_id } => {
if let Some(expression_id) = expression_id {
attrs.insert("expression_id".to_owned(), Value::from(expression_id.0));
}
}
SyntaxNode::WhileStatement {
block_id,
condition_id,
} => {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
if let Some(condition_id) = condition_id {
attrs.insert("condition_id".to_owned(), Value::from(condition_id.0));
}
}
SyntaxNode::ElseClause { block_id } => {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
}
SyntaxNode::RestPattern { value_id } | SyntaxNode::SpreadElement { value_id } => {
attrs.insert("value_id".to_owned(), Value::from(value_id.0));
}
SyntaxNode::TryStatement {
block_id,
resources_id,
} => {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
if let Some(resources_id) = resources_id {
attrs.insert("resources_id".to_owned(), Value::from(resources_id.0));
}
}
SyntaxNode::CatchClause {
block_id,
catch_declaration,
} => {
if let Some(block_id) = block_id {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
}
if let Some(catch_declaration) = catch_declaration {
attrs.insert(
"catch_declaration".to_owned(),
Value::from(catch_declaration.0),
);
}
}
SyntaxNode::FinallyClause { block_id } => {
if let Some(block_id) = block_id {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
}
}
SyntaxNode::Class {
name,
block_id,
modifiers_id,
inherited_class,
access_modifiers,
} => {
attrs.insert("name".to_owned(), Value::from(name.clone()));
if let Some(block_id) = block_id {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
}
if let Some(modifiers_id) = modifiers_id {
attrs.insert("modifiers_id".to_owned(), Value::from(modifiers_id.0));
}
if let Some(inherited_class) = inherited_class {
attrs.insert(
"inherited_class".to_owned(),
Value::from(inherited_class.clone()),
);
}
if let Some(access_modifiers) = access_modifiers {
attrs.insert(
"access_modifiers".to_owned(),
Value::from(access_modifiers.clone()),
);
}
}
SyntaxNode::Comment { comment } => {
attrs.insert("comment".to_owned(), Value::from(comment.clone()));
}
SyntaxNode::Literal { value, value_type } => {
attrs.insert("value".to_owned(), Value::from(value.clone()));
attrs.insert("value_type".to_owned(), Value::from(value_type.clone()));
}
SyntaxNode::Metadata {
path,
structure,
instances,
imports,
package,
} => {
attrs.insert("path".to_owned(), Value::from(path.clone()));
attrs.insert("structure".to_owned(), struct_children_to_json(structure));
attrs.insert("instances".to_owned(), instances_to_json(instances));
attrs.insert("imports".to_owned(), Value::from(imports.clone()));
if let Some(package) = package {
attrs.insert("package".to_owned(), Value::from(package.clone()));
}
}
SyntaxNode::MethodDeclaration {
name,
access_modifiers,
block_id,
modifiers_id,
parameters_id,
} => {
if let Some(name) = name {
attrs.insert("name".to_owned(), Value::from(name.clone()));
}
if let Some(access_modifiers) = access_modifiers {
attrs.insert(
"access_modifiers".to_owned(),
Value::from(access_modifiers.clone()),
);
}
if let Some(block_id) = block_id {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
}
if let Some(modifiers_id) = modifiers_id {
attrs.insert("modifiers_id".to_owned(), Value::from(modifiers_id.0));
}
if let Some(parameters_id) = parameters_id {
attrs.insert("parameters_id".to_owned(), Value::from(parameters_id.0));
}
}
SyntaxNode::MissingNode { node_type } => {
attrs.insert("node_type".to_owned(), Value::from(node_type.clone()));
}
SyntaxNode::Namespace { name, block_id } => {
attrs.insert("name".to_owned(), Value::from(name.clone()));
if let Some(block_id) = block_id {
attrs.insert("block_id".to_owned(), Value::from(block_id.0));
}
}
SyntaxNode::NewExpression {
constructor_id,
arguments_id,
} => {
attrs.insert("constructor_id".to_owned(), Value::from(constructor_id.0));
if let Some(arguments_id) = arguments_id {
attrs.insert("arguments_id".to_owned(), Value::from(arguments_id.0));
}
}
SyntaxNode::Object { name, tf_reference } => {
if let Some(name) = name {
attrs.insert("name".to_owned(), Value::from(name.clone()));
}
if let Some(tf_reference) = tf_reference {
attrs.insert("tf_reference".to_owned(), Value::from(tf_reference.clone()));
}
}
SyntaxNode::Parameter {
variable,
variable_type,
value_id,
parameter_mode,
} => {
if let Some(variable) = variable {
attrs.insert("variable".to_owned(), Value::from(variable.clone()));
}
if let Some(variable_type) = variable_type {
attrs.insert(
"variable_type".to_owned(),
Value::from(variable_type.clone()),
);
}
if let Some(value_id) = value_id {
attrs.insert("value_id".to_owned(), Value::from(value_id.0));
}
if let Some(parameter_mode) = parameter_mode {
attrs.insert(
"parameter_mode".to_owned(),
Value::from(parameter_mode.clone()),
);
}
}
SyntaxNode::VariableDeclaration {
variable,
variable_type,
value_id,
variable_id: _,
access_modifier,
} => {
attrs.insert("variable".to_owned(), Value::from(variable.clone()));
if let Some(variable_type) = variable_type {
attrs.insert(
"variable_type".to_owned(),
Value::from(variable_type.clone()),
);
}
if let Some(value_id) = value_id {
attrs.insert("value_id".to_owned(), Value::from(value_id.0));
}
if let Some(access_modifier) = access_modifier {
attrs.insert(
"access_modifier".to_owned(),
Value::from(access_modifier.clone()),
);
}
}
SyntaxNode::Pair { key_id, value_id } => {
attrs.insert("key_id".to_owned(), Value::from(key_id.0));
attrs.insert("value_id".to_owned(), Value::from(value_id.0));
}
SyntaxNode::SymbolLookup {
symbol,
symbol_scope,
value,
} => {
attrs.insert("symbol".to_owned(), Value::from(symbol.clone()));
if let Some(scope) = symbol_scope {
attrs.insert("symbol_scope".to_owned(), Value::from(scope.0));
}
if let Some(value) = value {
attrs.insert("value".to_owned(), Value::from(value.clone()));
}
}
SyntaxNode::ModuleImport { expression, alias } => {
attrs.insert("expression".to_owned(), Value::from(expression.clone()));
if let Some(alias) = alias {
attrs.insert("label_alias".to_owned(), Value::from(alias.clone()));
}
}
other => panic!("syntax export not implemented for {}", other.label_type()),
}
attrs
}
fn syntax_edge_attrs(edge: SyntaxEdge) -> BTreeMap<String, Value> {
let mut attrs = BTreeMap::new();
if edge.ast.is_some() {
attrs.insert("label_ast".to_owned(), Value::from("AST"));
}
if edge.cfg.is_some() {
attrs.insert("label_cfg".to_owned(), Value::from("CFG"));
}
attrs
}
fn export_syntax_graph_as_json(graph: &SyntaxGraph) -> Value {
let mut nodes = Map::new();
for (id, node) in &graph.nodes {
nodes.insert(id.0.to_string(), sorted_object(syntax_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(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] = &["javascript", "typescript"];
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()
);
}
}