use blends_domain::ast::AstGraph;
use blends_domain::syntax;
use blends_domain::syntax::cfg;
use blends_domain::syntax::cfg::SyntaxCfgError;
use blends_domain::syntax::SyntaxGraph;
use blends_domain::syntax::SyntaxNode;
use crate::content::Content;
fn trace_missing_readers(syntax_graph: &SyntaxGraph, content: &Content) {
for (n_id, node) in &syntax_graph.nodes {
if let SyntaxNode::MissingNode { node_type } = node {
tracing::debug!(
node_type,
language = ?content.language,
node_id = n_id.0,
path = %content.path.display(),
"missing syntax reader"
);
}
}
}
fn apply_cfg_overlay(
syntax_graph: &mut SyntaxGraph,
content: &Content,
is_multifile: bool,
) -> bool {
match cfg::add_syntax_cfg(syntax_graph, is_multifile) {
Ok(()) => true,
Err(error @ SyntaxCfgError::MissingCfgBuilder { .. }) => {
tracing::error!(
%error,
path = %content.path.display(),
"missing cfg builder"
);
true
}
Err(
error @ (SyntaxCfgError::MissingSyntaxNode { .. }
| SyntaxCfgError::UnexpectedSyntaxShape { .. }),
) => {
tracing::error!(
%error,
path = %content.path.display(),
"unable to build the syntax_graph"
);
false
}
}
}
#[must_use]
pub fn get_syntax_graph(
ast_graph: &AstGraph,
content: &Content,
with_cfg: Option<bool>,
with_metadata: Option<bool>,
) -> Option<SyntaxGraph> {
let path = content.path.display().to_string();
let with_metadata = with_metadata.unwrap_or(false);
let mut syntax_graph =
match syntax::build_syntax_graph(ast_graph, &path, content.language, with_metadata) {
Ok(syntax_graph) => syntax_graph,
Err(syntax::SyntaxGraphError::UnsupportedLanguage) => {
tracing::debug!(
language = ?content.language,
path = %content.path.display(),
"syntax dispatcher not implemented yet"
);
return None;
}
Err(error) => {
tracing::error!(
%error,
path = %content.path.display(),
"unable to build the syntax_graph"
);
return None;
}
};
trace_missing_readers(&syntax_graph, content);
if with_cfg.unwrap_or(true) && !apply_cfg_overlay(&mut syntax_graph, content, with_metadata) {
return None;
}
syntax_graph.finalize_symbol_index();
Some(syntax_graph)
}
#[cfg(test)]
mod tests {
use super::get_syntax_graph;
use crate::ast::get_ast_graph;
use crate::content::Content;
use crate::language::Language;
use blends_domain::ast::{AstGraph, AstNode};
use blends_domain::path_search::get_backward_paths;
use blends_domain::path_search::search::definition_search;
use blends_domain::query::get_all_scope_definitions;
use blends_domain::stack::import_sites;
use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
use blends_domain::NodeId;
use std::path::PathBuf;
use test_case::test_case;
fn content_for(language: Language, extension: &str, text: &str) -> Content {
Content {
bytes: text.as_bytes().to_vec(),
text: text.to_owned(),
language,
path: PathBuf::from(format!("test.{extension}")),
}
}
fn single_node_ast(node_type: &str) -> AstGraph {
let mut ast = AstGraph::new();
ast.add_node(NodeId(1), AstNode::new(1, 1, node_type.to_owned()));
ast
}
#[test]
fn builds_the_syntax_graph_for_a_supported_language() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = single_node_ast("stream");
let result = get_syntax_graph(&ast, &content, None, None);
let graph = result.expect("syntax graph should be built");
assert!(!graph.nodes.is_empty());
}
#[test]
fn definition_search_resolves_the_second_local_declarator() {
let content = content_for(
Language::Java,
"java",
"class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
);
let ast = get_ast_graph(&content).expect("java source should parse");
let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
let lookup_id = graph
.nodes
.iter()
.find_map(|(n_id, node)| match node {
SyntaxNode::SymbolLookup { symbol, .. } if symbol == "plane" => Some(*n_id),
_ => None,
})
.expect("plane lookup should exist");
let def_id = get_backward_paths(&graph, lookup_id, None)
.iter()
.find_map(|path| definition_search(&graph, path, "plane"))
.expect("plane definition should resolve");
assert!(matches!(
graph.nodes.get(&def_id),
Some(SyntaxNode::VariableDeclaration { variable, .. }) if variable == "plane"
));
}
fn assignment_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
let lookup_id = graph
.nodes
.iter()
.filter(|(_, node)| matches!(node, SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted))
.map(|(n_id, _)| *n_id)
.next_back()
.expect("lookup should exist");
get_backward_paths(graph, lookup_id, None)
.iter()
.find_map(|path| definition_search(graph, path, wanted))
.expect("definition should resolve")
}
#[test]
fn definition_search_resolves_a_go_name_declared_beside_another() {
let content = content_for(
Language::Go,
"go",
"package main\nfunc f() {\n\ta, b := 1, 2\n\tprintln(b)\n}\n",
);
let ast = get_ast_graph(&content).expect("go source should parse");
let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
assert!(matches!(
graph.nodes.get(&assignment_for(&graph, "b")),
Some(SyntaxNode::Assignment { .. })
));
}
#[test]
fn definition_search_resolves_a_python_name_declared_beside_another() {
let content = content_for(
Language::Python,
"py",
"def f():\n a, b = 1, 2\n print(b)\n",
);
let ast = get_ast_graph(&content).expect("python source should parse");
let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
assert!(matches!(
graph.nodes.get(&assignment_for(&graph, "b")),
Some(SyntaxNode::Assignment { .. })
));
}
fn graph_for(language: Language, extension: &str, source: &str) -> SyntaxGraph {
let content = content_for(language, extension, source);
let ast = get_ast_graph(&content).expect("source should parse");
get_syntax_graph(&ast, &content, None, None).expect("graph should be built")
}
#[test_case("import a.b\n", "a" ; "a dotted name defines its top level package")]
#[test_case("import a.b as c\n", "c" ; "an aliased dotted name defines the alias")]
fn a_plain_python_import_defines_the_name_it_binds(source: &str, bound: &str) {
let graph = graph_for(Language::Python, "py", source);
assert_eq!(
graph
.symbol_index
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
[bound]
);
}
#[test_case("import os\n", &[("os", "os")] ; "a single name binds the module of that name")]
#[test_case("import a.b\n", &[("a", "a")] ; "a dotted name binds its top level package")]
#[test_case("import a.b, a.c\n", &[("a", "a"), ("a", "a")] ; "two submodules of one package both bind the package")]
#[test_case("import a.b as c\n", &[("c", "a.b")] ; "an alias binds the full dotted module")]
#[test_case("import a.py as c\n", &[("c", "a.py")] ; "a submodule named like a file extension stays in the module path")]
#[test_case("import os, sys as system\n", &[("os", "os"), ("system", "sys")] ; "every name of a multi name import binds its own module")]
#[test_case("from x import y\n", &[] ; "a from import is not read yet")]
fn plain_python_imports_bind_modules(source: &str, expected: &[(&str, &str)]) {
let graph = graph_for(Language::Python, "py", source);
let sites: Vec<_> = import_sites(&graph, Language::Python)
.into_iter()
.map(|site| {
let binding = site.binding;
(
site.scope,
site.symbol,
binding.module_stem,
binding.source_symbol,
binding.relative_level,
binding.binds_module,
)
})
.collect();
let expected: Vec<_> = expected
.iter()
.map(|(symbol, module_stem)| {
(
NodeId(1),
(*symbol).to_owned(),
(*module_stem).to_owned(),
None,
0,
true,
)
})
.collect();
assert_eq!(sites, expected);
}
#[test_case("def f():\n import os\n" ; "a single name")]
#[test_case("def f():\n import a.b\n" ; "a dotted name")]
fn an_import_site_keeps_its_import_node_and_innermost_scope(source: &str) {
let graph = graph_for(Language::Python, "py", source);
let first_of = |is_wanted: fn(&SyntaxNode) -> bool| {
graph
.nodes
.iter()
.find_map(|(n_id, node)| is_wanted(node).then_some(*n_id))
};
let function = first_of(|node| matches!(node, SyntaxNode::MethodDeclaration { .. }));
let import = first_of(|node| matches!(node, SyntaxNode::Import { .. }));
assert_eq!(
import_sites(&graph, Language::Python)
.iter()
.map(|site| (Some(site.node), Some(site.scope)))
.collect::<Vec<_>>(),
[(import, function)]
);
}
#[test]
fn imports_of_other_languages_are_not_read_yet() {
let graph = graph_for(Language::Java, "java", "import a.b.C;\nclass T {}\n");
assert!(import_sites(&graph, Language::Java).is_empty());
}
fn lookup_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
graph
.nodes
.iter()
.find_map(|(n_id, node)| match node {
SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted => Some(*n_id),
_ => None,
})
.expect("lookup should exist")
}
#[test]
fn scope_definitions_resolve_the_second_local_declarator() {
let content = content_for(
Language::Java,
"java",
"class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
);
let ast = get_ast_graph(&content).expect("java source should parse");
let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "plane"));
let &[def_id] = definitions.as_slice() else {
panic!("expected one scope definition for plane");
};
assert!(matches!(
graph.nodes.get(&def_id),
Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
if variable == "plane" && variable_type == "String"
));
}
#[test]
fn scope_definitions_resolve_the_second_interface_constant() {
let content = content_for(
Language::Java,
"java",
"interface Limits { int MIN = 1, MAX = 10; default int clamp(int value) { return value > MAX ? MAX : value; } }",
);
let ast = get_ast_graph(&content).expect("java source should parse");
let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "MAX"));
let &[def_id] = definitions.as_slice() else {
panic!("expected one scope definition for MAX");
};
assert!(matches!(
graph.nodes.get(&def_id),
Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
if variable == "MAX" && variable_type == "int"
));
}
#[test]
fn metadata_node_added_at_zero_when_requested() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = single_node_ast("stream");
let result = get_syntax_graph(&ast, &content, None, Some(true));
let graph = result.expect("syntax graph should be built");
assert_eq!(
graph.nodes.get(&NodeId(0)),
Some(&SyntaxNode::Metadata {
path: "test.yaml".to_owned(),
structure: std::collections::BTreeMap::new(),
instances: std::collections::BTreeMap::new(),
imports: std::vec::Vec::new(),
package: None,
})
);
}
#[test_case(None ; "by default")]
#[test_case(Some(false) ; "when explicitly disabled")]
fn no_metadata_node(with_metadata: Option<bool>) {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = single_node_ast("stream");
let result = get_syntax_graph(&ast, &content, None, with_metadata);
let graph = result.expect("syntax graph should be built");
assert!(!graph.nodes.contains_key(&NodeId(0)));
}
#[test]
fn language_without_dispatcher_has_no_syntax_graph() {
let content = content_for(Language::Elixir, "ex", "defmodule A do end");
let ast = single_node_ast("program");
assert!(get_syntax_graph(&ast, &content, None, None).is_none());
}
#[test]
fn unhandled_root_node_type_degrades_to_missing_node_and_recurses() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let mut ast = single_node_ast("unknown_node_type_xyz");
ast.add_node(NodeId(2), AstNode::new(1, 5, "another_unknown".to_owned()));
ast.add_edge(NodeId(1), NodeId(2), 0);
let result = get_syntax_graph(&ast, &content, None, None);
let graph = result.expect("syntax graph should be built");
assert_eq!(
graph.nodes.get(&NodeId(1)),
Some(&SyntaxNode::MissingNode {
node_type: "unknown_node_type_xyz".to_owned()
})
);
assert_eq!(
graph.nodes.get(&NodeId(2)),
Some(&SyntaxNode::MissingNode {
node_type: "another_unknown".to_owned()
})
);
assert!(graph
.edges
.get(&NodeId(1))
.is_some_and(|adjacent| adjacent.contains_key(&NodeId(2))));
}
#[test]
fn fatal_reader_error_returns_none() {
let content = content_for(Language::Yaml, "yaml", "[]");
let ast = single_node_ast("flow_node");
assert!(get_syntax_graph(&ast, &content, None, None).is_none());
}
#[test]
fn empty_ast_graph_returns_none() {
let content = content_for(Language::Yaml, "yaml", "");
assert!(get_syntax_graph(&AstGraph::new(), &content, None, None).is_none());
}
#[test]
fn ast_graph_without_root_node_returns_none() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let mut ast = AstGraph::new();
ast.add_node(NodeId(7), AstNode::new(1, 1, "stream".to_owned()));
assert!(get_syntax_graph(&ast, &content, None, None).is_none());
}
#[test]
fn cfg_marks_overlay_the_ast_edges_by_default() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = get_ast_graph(&content).expect("yaml should parse");
let result = get_syntax_graph(&ast, &content, None, None);
let graph = result.expect("syntax graph should be built");
assert!(graph
.edges
.get(&NodeId(1))
.is_some_and(|adjacent| adjacent.values().any(|edge| edge.cfg.is_some())));
}
#[test]
fn no_cfg_marks_when_disabled() {
let content = content_for(Language::Yaml, "yaml", "key: value");
let ast = get_ast_graph(&content).expect("yaml should parse");
let result = get_syntax_graph(&ast, &content, Some(false), None);
let graph = result.expect("syntax graph should be built");
assert!(graph
.edges
.values()
.flat_map(|adjacent| adjacent.values())
.all(|edge| edge.cfg.is_none()));
}
#[test]
fn empty_yaml_builds_a_lone_file_node() {
let content = content_for(Language::Yaml, "yaml", "");
let ast = get_ast_graph(&content).expect("empty yaml should parse");
let result = get_syntax_graph(&ast, &content, None, None);
let graph = result.expect("syntax graph should be built");
assert_eq!(graph.nodes.len(), 1);
assert_eq!(graph.nodes.get(&NodeId(1)), Some(&SyntaxNode::File));
}
}