1use blends_domain::ast::AstGraph;
5use blends_domain::syntax;
6use blends_domain::syntax::cfg;
7use blends_domain::syntax::cfg::SyntaxCfgError;
8use blends_domain::syntax::SyntaxGraph;
9use blends_domain::syntax::SyntaxNode;
10
11use crate::content::Content;
12
13fn trace_missing_readers(syntax_graph: &SyntaxGraph, content: &Content) {
14 for (n_id, node) in &syntax_graph.nodes {
15 if let SyntaxNode::MissingNode { node_type } = node {
16 tracing::debug!(
17 node_type,
18 language = ?content.language,
19 node_id = n_id.0,
20 path = %content.path.display(),
21 "missing syntax reader"
22 );
23 }
24 }
25}
26
27fn apply_cfg_overlay(
28 syntax_graph: &mut SyntaxGraph,
29 content: &Content,
30 is_multifile: bool,
31) -> bool {
32 match cfg::add_syntax_cfg(syntax_graph, is_multifile) {
33 Ok(()) => true,
34 Err(error @ SyntaxCfgError::MissingCfgBuilder { .. }) => {
35 tracing::error!(
36 %error,
37 path = %content.path.display(),
38 "missing cfg builder"
39 );
40 true
41 }
42 Err(
43 error @ (SyntaxCfgError::MissingSyntaxNode { .. }
44 | SyntaxCfgError::UnexpectedSyntaxShape { .. }),
45 ) => {
46 tracing::error!(
47 %error,
48 path = %content.path.display(),
49 "unable to build the syntax_graph"
50 );
51 false
52 }
53 }
54}
55
56#[must_use]
57pub fn get_syntax_graph(
58 ast_graph: &AstGraph,
59 content: &Content,
60 with_cfg: Option<bool>,
61 with_metadata: Option<bool>,
62) -> Option<SyntaxGraph> {
63 let path = content.path.display().to_string();
64 let with_metadata = with_metadata.unwrap_or(false);
65 let mut syntax_graph =
66 match syntax::build_syntax_graph(ast_graph, &path, content.language, with_metadata) {
67 Ok(syntax_graph) => syntax_graph,
68 Err(syntax::SyntaxGraphError::UnsupportedLanguage) => {
69 tracing::debug!(
70 language = ?content.language,
71 path = %content.path.display(),
72 "syntax dispatcher not implemented yet"
73 );
74 return None;
75 }
76 Err(error) => {
77 tracing::error!(
78 %error,
79 path = %content.path.display(),
80 "unable to build the syntax_graph"
81 );
82 return None;
83 }
84 };
85
86 trace_missing_readers(&syntax_graph, content);
87
88 if with_cfg.unwrap_or(true) && !apply_cfg_overlay(&mut syntax_graph, content, with_metadata) {
89 return None;
90 }
91
92 syntax_graph.finalize_symbol_index();
93 Some(syntax_graph)
94}
95
96#[cfg(test)]
97mod tests {
98 use super::get_syntax_graph;
99 use crate::ast::get_ast_graph;
100 use crate::content::Content;
101 use crate::language::Language;
102 use blends_domain::ast::{AstGraph, AstNode};
103 use blends_domain::path_search::get_backward_paths;
104 use blends_domain::path_search::search::definition_search;
105 use blends_domain::query::get_all_scope_definitions;
106 use blends_domain::stack::import_sites;
107 use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
108 use blends_domain::NodeId;
109 use std::path::PathBuf;
110 use test_case::test_case;
111
112 fn content_for(language: Language, extension: &str, text: &str) -> Content {
113 Content {
114 bytes: text.as_bytes().to_vec(),
115 text: text.to_owned(),
116 language,
117 path: PathBuf::from(format!("test.{extension}")),
118 }
119 }
120
121 fn single_node_ast(node_type: &str) -> AstGraph {
122 let mut ast = AstGraph::new();
123 ast.add_node(NodeId(1), AstNode::new(1, 1, node_type.to_owned()));
124 ast
125 }
126
127 #[test]
128 fn builds_the_syntax_graph_for_a_supported_language() {
129 let content = content_for(Language::Yaml, "yaml", "key: value");
130 let ast = single_node_ast("stream");
131
132 let result = get_syntax_graph(&ast, &content, None, None);
133
134 let graph = result.expect("syntax graph should be built");
135 assert!(!graph.nodes.is_empty());
136 }
137
138 #[test]
139 fn definition_search_resolves_the_second_local_declarator() {
140 let content = content_for(
141 Language::Java,
142 "java",
143 "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
144 );
145 let ast = get_ast_graph(&content).expect("java source should parse");
146
147 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
148
149 let lookup_id = graph
150 .nodes
151 .iter()
152 .find_map(|(n_id, node)| match node {
153 SyntaxNode::SymbolLookup { symbol, .. } if symbol == "plane" => Some(*n_id),
154 _ => None,
155 })
156 .expect("plane lookup should exist");
157 let def_id = get_backward_paths(&graph, lookup_id, None)
158 .iter()
159 .find_map(|path| definition_search(&graph, path, "plane"))
160 .expect("plane definition should resolve");
161
162 assert!(matches!(
163 graph.nodes.get(&def_id),
164 Some(SyntaxNode::VariableDeclaration { variable, .. }) if variable == "plane"
165 ));
166 }
167
168 fn assignment_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
169 let lookup_id = graph
170 .nodes
171 .iter()
172 .filter(|(_, node)| matches!(node, SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted))
173 .map(|(n_id, _)| *n_id)
174 .next_back()
175 .expect("lookup should exist");
176 get_backward_paths(graph, lookup_id, None)
177 .iter()
178 .find_map(|path| definition_search(graph, path, wanted))
179 .expect("definition should resolve")
180 }
181
182 #[test]
183 fn definition_search_resolves_a_go_name_declared_beside_another() {
184 let content = content_for(
185 Language::Go,
186 "go",
187 "package main\nfunc f() {\n\ta, b := 1, 2\n\tprintln(b)\n}\n",
188 );
189 let ast = get_ast_graph(&content).expect("go source should parse");
190
191 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
192
193 assert!(matches!(
194 graph.nodes.get(&assignment_for(&graph, "b")),
195 Some(SyntaxNode::Assignment { .. })
196 ));
197 }
198
199 #[test]
200 fn definition_search_resolves_a_python_name_declared_beside_another() {
201 let content = content_for(
202 Language::Python,
203 "py",
204 "def f():\n a, b = 1, 2\n print(b)\n",
205 );
206 let ast = get_ast_graph(&content).expect("python source should parse");
207
208 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
209
210 assert!(matches!(
211 graph.nodes.get(&assignment_for(&graph, "b")),
212 Some(SyntaxNode::Assignment { .. })
213 ));
214 }
215
216 fn graph_for(language: Language, extension: &str, source: &str) -> SyntaxGraph {
217 let content = content_for(language, extension, source);
218 let ast = get_ast_graph(&content).expect("source should parse");
219 get_syntax_graph(&ast, &content, None, None).expect("graph should be built")
220 }
221
222 #[test_case("import a.b\n", "a" ; "a dotted name defines its top level package")]
223 #[test_case("import a.b as c\n", "c" ; "an aliased dotted name defines the alias")]
224 fn a_plain_python_import_defines_the_name_it_binds(source: &str, bound: &str) {
225 let graph = graph_for(Language::Python, "py", source);
226
227 assert_eq!(
228 graph
229 .symbol_index
230 .keys()
231 .map(String::as_str)
232 .collect::<Vec<_>>(),
233 [bound]
234 );
235 }
236
237 #[test_case("import os\n", &[("os", "os")] ; "a single name binds the module of that name")]
238 #[test_case("import a.b\n", &[("a", "a")] ; "a dotted name binds its top level package")]
239 #[test_case("import a.b, a.c\n", &[("a", "a"), ("a", "a")] ; "two submodules of one package both bind the package")]
240 #[test_case("import a.b as c\n", &[("c", "a.b")] ; "an alias binds the full dotted module")]
241 #[test_case("import a.py as c\n", &[("c", "a.py")] ; "a submodule named like a file extension stays in the module path")]
242 #[test_case("import os, sys as system\n", &[("os", "os"), ("system", "sys")] ; "every name of a multi name import binds its own module")]
243 #[test_case("from x import y\n", &[] ; "a from import is not read yet")]
244 fn plain_python_imports_bind_modules(source: &str, expected: &[(&str, &str)]) {
245 let graph = graph_for(Language::Python, "py", source);
246
247 let sites: Vec<_> = import_sites(&graph, Language::Python)
248 .into_iter()
249 .map(|site| {
250 let binding = site.binding;
251 (
252 site.scope,
253 site.symbol,
254 binding.module_stem,
255 binding.source_symbol,
256 binding.relative_level,
257 binding.binds_module,
258 )
259 })
260 .collect();
261 let expected: Vec<_> = expected
262 .iter()
263 .map(|(symbol, module_stem)| {
264 (
265 NodeId(1),
266 (*symbol).to_owned(),
267 (*module_stem).to_owned(),
268 None,
269 0,
270 true,
271 )
272 })
273 .collect();
274 assert_eq!(sites, expected);
275 }
276
277 #[test_case("def f():\n import os\n" ; "a single name")]
278 #[test_case("def f():\n import a.b\n" ; "a dotted name")]
279 fn an_import_site_keeps_its_import_node_and_innermost_scope(source: &str) {
280 let graph = graph_for(Language::Python, "py", source);
281 let first_of = |is_wanted: fn(&SyntaxNode) -> bool| {
282 graph
283 .nodes
284 .iter()
285 .find_map(|(n_id, node)| is_wanted(node).then_some(*n_id))
286 };
287 let function = first_of(|node| matches!(node, SyntaxNode::MethodDeclaration { .. }));
288 let import = first_of(|node| matches!(node, SyntaxNode::Import { .. }));
289
290 assert_eq!(
291 import_sites(&graph, Language::Python)
292 .iter()
293 .map(|site| (Some(site.node), Some(site.scope)))
294 .collect::<Vec<_>>(),
295 [(import, function)]
296 );
297 }
298
299 #[test]
300 fn imports_of_other_languages_are_not_read_yet() {
301 let graph = graph_for(Language::Java, "java", "import a.b.C;\nclass T {}\n");
302
303 assert!(import_sites(&graph, Language::Java).is_empty());
304 }
305
306 fn lookup_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
307 graph
308 .nodes
309 .iter()
310 .find_map(|(n_id, node)| match node {
311 SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted => Some(*n_id),
312 _ => None,
313 })
314 .expect("lookup should exist")
315 }
316
317 #[test]
318 fn scope_definitions_resolve_the_second_local_declarator() {
319 let content = content_for(
320 Language::Java,
321 "java",
322 "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
323 );
324 let ast = get_ast_graph(&content).expect("java source should parse");
325
326 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
327
328 let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "plane"));
329
330 let &[def_id] = definitions.as_slice() else {
331 panic!("expected one scope definition for plane");
332 };
333 assert!(matches!(
334 graph.nodes.get(&def_id),
335 Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
336 if variable == "plane" && variable_type == "String"
337 ));
338 }
339
340 #[test]
341 fn scope_definitions_resolve_the_second_interface_constant() {
342 let content = content_for(
343 Language::Java,
344 "java",
345 "interface Limits { int MIN = 1, MAX = 10; default int clamp(int value) { return value > MAX ? MAX : value; } }",
346 );
347 let ast = get_ast_graph(&content).expect("java source should parse");
348
349 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
350
351 let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "MAX"));
352
353 let &[def_id] = definitions.as_slice() else {
354 panic!("expected one scope definition for MAX");
355 };
356 assert!(matches!(
357 graph.nodes.get(&def_id),
358 Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
359 if variable == "MAX" && variable_type == "int"
360 ));
361 }
362
363 #[test]
364 fn metadata_node_added_at_zero_when_requested() {
365 let content = content_for(Language::Yaml, "yaml", "key: value");
366 let ast = single_node_ast("stream");
367
368 let result = get_syntax_graph(&ast, &content, None, Some(true));
369
370 let graph = result.expect("syntax graph should be built");
371 assert_eq!(
372 graph.nodes.get(&NodeId(0)),
373 Some(&SyntaxNode::Metadata {
374 path: "test.yaml".to_owned(),
375 structure: std::collections::BTreeMap::new(),
376 instances: std::collections::BTreeMap::new(),
377 imports: std::vec::Vec::new(),
378 package: None,
379 })
380 );
381 }
382
383 #[test_case(None ; "by default")]
384 #[test_case(Some(false) ; "when explicitly disabled")]
385 fn no_metadata_node(with_metadata: Option<bool>) {
386 let content = content_for(Language::Yaml, "yaml", "key: value");
387 let ast = single_node_ast("stream");
388
389 let result = get_syntax_graph(&ast, &content, None, with_metadata);
390
391 let graph = result.expect("syntax graph should be built");
392 assert!(!graph.nodes.contains_key(&NodeId(0)));
393 }
394
395 #[test]
396 fn language_without_dispatcher_has_no_syntax_graph() {
397 let content = content_for(Language::Elixir, "ex", "defmodule A do end");
398 let ast = single_node_ast("program");
399
400 assert!(get_syntax_graph(&ast, &content, None, None).is_none());
401 }
402
403 #[test]
404 fn unhandled_root_node_type_degrades_to_missing_node_and_recurses() {
405 let content = content_for(Language::Yaml, "yaml", "key: value");
406 let mut ast = single_node_ast("unknown_node_type_xyz");
407 ast.add_node(NodeId(2), AstNode::new(1, 5, "another_unknown".to_owned()));
408 ast.add_edge(NodeId(1), NodeId(2), 0);
409
410 let result = get_syntax_graph(&ast, &content, None, None);
411
412 let graph = result.expect("syntax graph should be built");
413 assert_eq!(
414 graph.nodes.get(&NodeId(1)),
415 Some(&SyntaxNode::MissingNode {
416 node_type: "unknown_node_type_xyz".to_owned()
417 })
418 );
419 assert_eq!(
420 graph.nodes.get(&NodeId(2)),
421 Some(&SyntaxNode::MissingNode {
422 node_type: "another_unknown".to_owned()
423 })
424 );
425 assert!(graph
426 .edges
427 .get(&NodeId(1))
428 .is_some_and(|adjacent| adjacent.contains_key(&NodeId(2))));
429 }
430
431 #[test]
432 fn fatal_reader_error_returns_none() {
433 let content = content_for(Language::Yaml, "yaml", "[]");
434 let ast = single_node_ast("flow_node");
435
436 assert!(get_syntax_graph(&ast, &content, None, None).is_none());
437 }
438
439 #[test]
440 fn empty_ast_graph_returns_none() {
441 let content = content_for(Language::Yaml, "yaml", "");
442
443 assert!(get_syntax_graph(&AstGraph::new(), &content, None, None).is_none());
444 }
445
446 #[test]
447 fn ast_graph_without_root_node_returns_none() {
448 let content = content_for(Language::Yaml, "yaml", "key: value");
449 let mut ast = AstGraph::new();
450 ast.add_node(NodeId(7), AstNode::new(1, 1, "stream".to_owned()));
451
452 assert!(get_syntax_graph(&ast, &content, None, None).is_none());
453 }
454
455 #[test]
456 fn cfg_marks_overlay_the_ast_edges_by_default() {
457 let content = content_for(Language::Yaml, "yaml", "key: value");
458 let ast = get_ast_graph(&content).expect("yaml should parse");
459
460 let result = get_syntax_graph(&ast, &content, None, None);
461
462 let graph = result.expect("syntax graph should be built");
463 assert!(graph
464 .edges
465 .get(&NodeId(1))
466 .is_some_and(|adjacent| adjacent.values().any(|edge| edge.cfg.is_some())));
467 }
468
469 #[test]
470 fn no_cfg_marks_when_disabled() {
471 let content = content_for(Language::Yaml, "yaml", "key: value");
472 let ast = get_ast_graph(&content).expect("yaml should parse");
473
474 let result = get_syntax_graph(&ast, &content, Some(false), None);
475
476 let graph = result.expect("syntax graph should be built");
477 assert!(graph
478 .edges
479 .values()
480 .flat_map(|adjacent| adjacent.values())
481 .all(|edge| edge.cfg.is_none()));
482 }
483
484 #[test]
485 fn empty_yaml_builds_a_lone_file_node() {
486 let content = content_for(Language::Yaml, "yaml", "");
487 let ast = get_ast_graph(&content).expect("empty yaml should parse");
488
489 let result = get_syntax_graph(&ast, &content, None, None);
490
491 let graph = result.expect("syntax graph should be built");
492 assert_eq!(graph.nodes.len(), 1);
493 assert_eq!(graph.nodes.get(&NodeId(1)), Some(&SyntaxNode::File));
494 }
495}