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(error) => {
43 tracing::error!(
44 %error,
45 path = %content.path.display(),
46 "unable to build the syntax_graph"
47 );
48 false
49 }
50 }
51}
52
53#[must_use]
59pub fn get_syntax_graph(
60 ast_graph: &AstGraph,
61 content: &Content,
62 with_cfg: Option<bool>,
63 with_metadata: Option<bool>,
64) -> Option<SyntaxGraph> {
65 let path = content.path.display().to_string();
66 let with_metadata = with_metadata.unwrap_or(false);
67 let mut syntax_graph =
68 match syntax::build_syntax_graph(ast_graph, &path, content.language, with_metadata) {
69 Ok(syntax_graph) => syntax_graph,
70 Err(syntax::SyntaxGraphError::UnsupportedLanguage) => {
71 tracing::debug!(
72 language = ?content.language,
73 path = %content.path.display(),
74 "syntax dispatcher not implemented yet"
75 );
76 return None;
77 }
78 Err(error) => {
79 tracing::error!(
80 %error,
81 path = %content.path.display(),
82 "unable to build the syntax_graph"
83 );
84 return None;
85 }
86 };
87
88 trace_missing_readers(&syntax_graph, content);
89
90 if with_cfg.unwrap_or(true) && !apply_cfg_overlay(&mut syntax_graph, content, with_metadata) {
91 return None;
92 }
93
94 syntax_graph.finalize_symbol_index();
95 Some(syntax_graph)
96}
97
98#[cfg(test)]
99mod tests {
100 use super::get_syntax_graph;
101 use crate::ast::get_ast_graph;
102 use crate::content::Content;
103 use crate::language::Language;
104 use blends_domain::ast::{AstGraph, AstNode};
105 use blends_domain::path_search::get_backward_paths;
106 use blends_domain::path_search::search::definition_search;
107 use blends_domain::query::get_all_scope_definitions;
108 use blends_domain::stack::import_sites;
109 use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
110 use blends_domain::NodeId;
111 use std::path::PathBuf;
112 use test_case::test_case;
113
114 type SiteRow = (NodeId, String, String, Option<String>, u32, bool);
115
116 fn site_rows(graph: &SyntaxGraph, language: Language) -> Vec<SiteRow> {
117 import_sites(graph, language)
118 .into_iter()
119 .map(|site| {
120 let binding = site.binding;
121 (
122 site.scope,
123 site.symbol,
124 binding.module_stem,
125 binding.source_symbol,
126 binding.relative_level,
127 binding.binds_module,
128 )
129 })
130 .collect()
131 }
132
133 fn site_row(
134 symbol: &str,
135 module_stem: &str,
136 relative_level: u32,
137 source_symbol: Option<&str>,
138 binds_module: bool,
139 ) -> SiteRow {
140 (
141 NodeId(1),
142 symbol.to_owned(),
143 module_stem.to_owned(),
144 source_symbol.map(str::to_owned),
145 relative_level,
146 binds_module,
147 )
148 }
149
150 fn content_for(language: Language, extension: &str, text: &str) -> Content {
151 Content {
152 bytes: text.as_bytes().to_vec(),
153 text: text.to_owned(),
154 language,
155 path: PathBuf::from(format!("test.{extension}")),
156 }
157 }
158
159 fn single_node_ast(node_type: &str) -> AstGraph {
160 let mut ast = AstGraph::new();
161 ast.add_node(NodeId(1), AstNode::new(1, 1, node_type.to_owned()));
162 ast
163 }
164
165 #[test]
166 fn builds_the_syntax_graph_for_a_supported_language() {
167 let content = content_for(Language::Yaml, "yaml", "key: value");
168 let ast = single_node_ast("stream");
169
170 let result = get_syntax_graph(&ast, &content, None, None);
171
172 let graph = result.expect("syntax graph should be built");
173 assert!(!graph.nodes.is_empty());
174 }
175
176 #[test]
177 fn definition_search_resolves_the_second_local_declarator() {
178 let content = content_for(
179 Language::Java,
180 "java",
181 "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
182 );
183 let ast = get_ast_graph(&content).expect("java source should parse");
184
185 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
186
187 let lookup_id = graph
188 .nodes
189 .iter()
190 .find_map(|(n_id, node)| match node {
191 SyntaxNode::SymbolLookup { symbol, .. } if symbol == "plane" => Some(*n_id),
192 _ => None,
193 })
194 .expect("plane lookup should exist");
195 let def_id = get_backward_paths(&graph, lookup_id, None)
196 .iter()
197 .find_map(|path| definition_search(&graph, path, "plane"))
198 .expect("plane definition should resolve");
199
200 assert!(matches!(
201 graph.nodes.get(&def_id),
202 Some(SyntaxNode::VariableDeclaration { variable, .. }) if variable == "plane"
203 ));
204 }
205
206 fn assignment_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
207 let lookup_id = graph
208 .nodes
209 .iter()
210 .filter(|(_, node)| matches!(node, SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted))
211 .map(|(n_id, _)| *n_id)
212 .next_back()
213 .expect("lookup should exist");
214 get_backward_paths(graph, lookup_id, None)
215 .iter()
216 .find_map(|path| definition_search(graph, path, wanted))
217 .expect("definition should resolve")
218 }
219
220 #[test]
221 fn definition_search_resolves_a_go_name_declared_beside_another() {
222 let content = content_for(
223 Language::Go,
224 "go",
225 "package main\nfunc f() {\n\ta, b := 1, 2\n\tprintln(b)\n}\n",
226 );
227 let ast = get_ast_graph(&content).expect("go source should parse");
228
229 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
230
231 assert!(matches!(
232 graph.nodes.get(&assignment_for(&graph, "b")),
233 Some(SyntaxNode::Assignment { .. })
234 ));
235 }
236
237 #[test]
238 fn definition_search_resolves_a_python_name_declared_beside_another() {
239 let content = content_for(
240 Language::Python,
241 "py",
242 "def f():\n a, b = 1, 2\n print(b)\n",
243 );
244 let ast = get_ast_graph(&content).expect("python source should parse");
245
246 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
247
248 assert!(matches!(
249 graph.nodes.get(&assignment_for(&graph, "b")),
250 Some(SyntaxNode::Assignment { .. })
251 ));
252 }
253
254 fn graph_for(language: Language, extension: &str, source: &str) -> SyntaxGraph {
255 let content = content_for(language, extension, source);
256 let ast = get_ast_graph(&content).expect("source should parse");
257 get_syntax_graph(&ast, &content, None, None).expect("graph should be built")
258 }
259
260 #[test_case("import a.b\n", "a" ; "a dotted name defines its top level package")]
261 #[test_case("import a.b as c\n", "c" ; "an aliased dotted name defines the alias")]
262 fn a_plain_python_import_defines_the_name_it_binds(source: &str, bound: &str) {
263 let graph = graph_for(Language::Python, "py", source);
264
265 assert_eq!(
266 graph
267 .symbol_index
268 .keys()
269 .map(String::as_str)
270 .collect::<Vec<_>>(),
271 [bound]
272 );
273 }
274
275 #[test_case("import os\n", &[("os", "os")] ; "a single name binds the module of that name")]
276 #[test_case("import a.b\n", &[("a", "a")] ; "a dotted name binds its top level package")]
277 #[test_case("import a.b, a.c\n", &[("a", "a"), ("a", "a")] ; "two submodules of one package both bind the package")]
278 #[test_case("import a.b as c\n", &[("c", "a.b")] ; "an alias binds the full dotted module")]
279 #[test_case("import a.py as c\n", &[("c", "a.py")] ; "a submodule named like a file extension stays in the module path")]
280 #[test_case("import os, sys as system\n", &[("os", "os"), ("system", "sys")] ; "every name of a multi name import binds its own module")]
281 fn plain_python_imports_bind_modules(source: &str, expected: &[(&str, &str)]) {
282 let graph = graph_for(Language::Python, "py", source);
283
284 let expected: Vec<_> = expected
285 .iter()
286 .map(|(symbol, module_stem)| site_row(symbol, module_stem, 0, None, true))
287 .collect();
288 assert_eq!(site_rows(&graph, Language::Python), expected);
289 }
290
291 #[test_case("from x import y\n", &[("y", "x", 0, None)] ; "a from import binds the name of the module symbol")]
292 #[test_case("from x import x\n", &[("x", "x", 0, None)] ; "a symbol named like its module binds that symbol")]
293 #[test_case("from x import y as z\n", &[("z", "x", 0, Some("y"))] ; "an alias binds the module symbol under the alias")]
294 #[test_case("from a.b import y\n", &[("y", "a.b", 0, None)] ; "a dotted module keeps every segment")]
295 #[test_case("from x import (a, b as c)\n", &[("a", "x", 0, None), ("c", "x", 0, Some("b"))] ; "every name of a parenthesized list is bound")]
296 #[test_case("from ..x import y\n", &[("y", "x", 2, None)] ; "each leading dot climbs one package level")]
297 #[test_case("from . import y\n", &[("y", "", 1, None)] ; "a bare dot imports from the current package")]
298 #[test_case("from .. import q as r\n", &[("r", "", 2, Some("q"))] ; "an aliased name from a parent package keeps its source name")]
299 #[test_case("from x import *\n", &[("*", "x", 0, None)] ; "a wildcard binds every public name of the module")]
300 #[test_case("from .m import *\n", &[("*", "m", 1, None)] ; "a relative wildcard keeps its package level")]
301 fn python_from_imports_bind_module_symbols(
302 source: &str,
303 expected: &[(&str, &str, u32, Option<&str>)],
304 ) {
305 let graph = graph_for(Language::Python, "py", source);
306
307 let expected: Vec<_> = expected
308 .iter()
309 .map(|(symbol, module_stem, relative_level, source_symbol)| {
310 site_row(symbol, module_stem, *relative_level, *source_symbol, false)
311 })
312 .collect();
313 assert_eq!(site_rows(&graph, Language::Python), expected);
314 }
315
316 #[test_case("js", "import * as ns from \"m\";\n", Some("namespace_import") ; "a namespace import is marked as such")]
317 #[test_case("ts", "import x = require(\"m\");\n", Some("import_require_clause") ; "an import require is marked as such")]
318 #[test_case("js", "import d from \"m\";\n", None ; "a default import carries no import type")]
319 fn script_import_nodes_record_their_clause(
320 extension: &str,
321 source: &str,
322 expected: Option<&str>,
323 ) {
324 let language = if extension == "ts" {
325 Language::TypeScript
326 } else {
327 Language::JavaScript
328 };
329 let graph = graph_for(language, extension, source);
330
331 let import_types: Vec<_> = graph
332 .nodes
333 .values()
334 .filter_map(|node| match node {
335 SyntaxNode::Import { import_type, .. } => Some(import_type.as_deref()),
336 _ => None,
337 })
338 .collect();
339 assert_eq!(import_types, [expected]);
340 }
341
342 #[test_case("js", "import d from \"./m.js\";\n", &[("d", "m", 1, Some("default"), false)] ; "a default import binds the default export")]
343 #[test_case("js", "import { a } from \"lib/x\";\n", &[("a", "lib.x", 0, None, false)] ; "a named import binds the export of that name")]
344 #[test_case("js", "import { b as c } from \"lib/x\";\n", &[("c", "lib.x", 0, Some("b"), false)] ; "a renamed import binds the export under the new name")]
345 #[test_case("js", "import { default as d } from \"m\";\n", &[("d", "m", 0, Some("default"), false)] ; "a default renamed in braces binds the default export")]
346 #[test_case("js", "import d, { e } from \"m\";\n", &[("d", "m", 0, Some("default"), false), ("e", "m", 0, None, false)] ; "a default and a named import in one statement both bind")]
347 #[test_case("js", "import * as ns from \"../pkg/ns\";\n", &[("ns", "pkg.ns", 2, None, true)] ; "a namespace import binds the whole module")]
348 #[test_case("js", "import x from \"../../a/b\";\n", &[("x", "a.b", 3, Some("default"), false)] ; "each parent directory climbs one more level")]
349 #[test_case("js", "import x from \"..\";\n", &[("x", "", 2, Some("default"), false)] ; "a bare parent directory imports its index module")]
350 #[test_case("js", "import \"side\";\n", &[] ; "a side effect import binds nothing")]
351 #[test_case("ts", "import type { T } from \"./types\";\n", &[("T", "types", 1, None, false)] ; "a type only import binds the export of that name")]
352 #[test_case("ts", "import x = require(\"old\");\n", &[("x", "old", 0, None, true)] ; "an import require binds the whole module")]
353 fn script_imports_bind_module_exports(
354 extension: &str,
355 source: &str,
356 expected: &[(&str, &str, u32, Option<&str>, bool)],
357 ) {
358 let language = if extension == "ts" {
359 Language::TypeScript
360 } else {
361 Language::JavaScript
362 };
363 let graph = graph_for(language, extension, source);
364
365 let expected: Vec<_> = expected
366 .iter()
367 .map(
368 |(symbol, module_stem, relative_level, source_symbol, binds_module)| {
369 site_row(
370 symbol,
371 module_stem,
372 *relative_level,
373 *source_symbol,
374 *binds_module,
375 )
376 },
377 )
378 .collect();
379 assert_eq!(site_rows(&graph, language), expected);
380 }
381
382 #[test_case("def f():\n import os\n" ; "a single name")]
383 #[test_case("def f():\n import a.b\n" ; "a dotted name")]
384 fn an_import_site_keeps_its_import_node_and_innermost_scope(source: &str) {
385 let graph = graph_for(Language::Python, "py", source);
386 let first_of = |is_wanted: fn(&SyntaxNode) -> bool| {
387 graph
388 .nodes
389 .iter()
390 .find_map(|(n_id, node)| is_wanted(node).then_some(*n_id))
391 };
392 let function = first_of(|node| matches!(node, SyntaxNode::MethodDeclaration { .. }));
393 let import = first_of(|node| matches!(node, SyntaxNode::Import { .. }));
394
395 assert_eq!(
396 import_sites(&graph, Language::Python)
397 .iter()
398 .map(|site| (Some(site.node), Some(site.scope)))
399 .collect::<Vec<_>>(),
400 [(import, function)]
401 );
402 }
403
404 #[test]
405 fn imports_of_other_languages_are_not_read_yet() {
406 let graph = graph_for(Language::Java, "java", "import a.b.C;\nclass T {}\n");
407
408 assert!(import_sites(&graph, Language::Java).is_empty());
409 }
410
411 fn lookup_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
412 graph
413 .nodes
414 .iter()
415 .find_map(|(n_id, node)| match node {
416 SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted => Some(*n_id),
417 _ => None,
418 })
419 .expect("lookup should exist")
420 }
421
422 #[test]
423 fn scope_definitions_resolve_the_second_local_declarator() {
424 let content = content_for(
425 Language::Java,
426 "java",
427 "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
428 );
429 let ast = get_ast_graph(&content).expect("java source should parse");
430
431 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
432
433 let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "plane"));
434
435 let &[def_id] = definitions.as_slice() else {
436 panic!("expected one scope definition for plane");
437 };
438 assert!(matches!(
439 graph.nodes.get(&def_id),
440 Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
441 if variable == "plane" && variable_type == "String"
442 ));
443 }
444
445 #[test]
446 fn scope_definitions_resolve_the_second_interface_constant() {
447 let content = content_for(
448 Language::Java,
449 "java",
450 "interface Limits { int MIN = 1, MAX = 10; default int clamp(int value) { return value > MAX ? MAX : value; } }",
451 );
452 let ast = get_ast_graph(&content).expect("java source should parse");
453
454 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
455
456 let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "MAX"));
457
458 let &[def_id] = definitions.as_slice() else {
459 panic!("expected one scope definition for MAX");
460 };
461 assert!(matches!(
462 graph.nodes.get(&def_id),
463 Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
464 if variable == "MAX" && variable_type == "int"
465 ));
466 }
467
468 #[test]
469 fn metadata_node_added_at_zero_when_requested() {
470 let content = content_for(Language::Yaml, "yaml", "key: value");
471 let ast = single_node_ast("stream");
472
473 let result = get_syntax_graph(&ast, &content, None, Some(true));
474
475 let graph = result.expect("syntax graph should be built");
476 assert_eq!(
477 graph.nodes.get(&NodeId(0)),
478 Some(&SyntaxNode::Metadata {
479 path: "test.yaml".to_owned(),
480 structure: std::collections::BTreeMap::new(),
481 instances: std::collections::BTreeMap::new(),
482 imports: std::vec::Vec::new(),
483 package: None,
484 })
485 );
486 }
487
488 #[test_case(None ; "by default")]
489 #[test_case(Some(false) ; "when explicitly disabled")]
490 fn no_metadata_node(with_metadata: Option<bool>) {
491 let content = content_for(Language::Yaml, "yaml", "key: value");
492 let ast = single_node_ast("stream");
493
494 let result = get_syntax_graph(&ast, &content, None, with_metadata);
495
496 let graph = result.expect("syntax graph should be built");
497 assert!(!graph.nodes.contains_key(&NodeId(0)));
498 }
499
500 #[test]
501 fn language_without_dispatcher_has_no_syntax_graph() {
502 let content = content_for(Language::Elixir, "ex", "defmodule A do end");
503 let ast = single_node_ast("program");
504
505 assert!(get_syntax_graph(&ast, &content, None, None).is_none());
506 }
507
508 #[test]
509 fn unhandled_root_node_type_degrades_to_missing_node_and_recurses() {
510 let content = content_for(Language::Yaml, "yaml", "key: value");
511 let mut ast = single_node_ast("unknown_node_type_xyz");
512 ast.add_node(NodeId(2), AstNode::new(1, 5, "another_unknown".to_owned()));
513 ast.add_edge(NodeId(1), NodeId(2), 0);
514
515 let result = get_syntax_graph(&ast, &content, None, None);
516
517 let graph = result.expect("syntax graph should be built");
518 assert_eq!(
519 graph.nodes.get(&NodeId(1)),
520 Some(&SyntaxNode::MissingNode {
521 node_type: "unknown_node_type_xyz".to_owned()
522 })
523 );
524 assert_eq!(
525 graph.nodes.get(&NodeId(2)),
526 Some(&SyntaxNode::MissingNode {
527 node_type: "another_unknown".to_owned()
528 })
529 );
530 assert!(graph
531 .edges
532 .get(&NodeId(1))
533 .is_some_and(|adjacent| adjacent.contains_key(&NodeId(2))));
534 }
535
536 #[test]
537 fn fatal_reader_error_returns_none() {
538 let content = content_for(Language::Yaml, "yaml", "[]");
539 let ast = single_node_ast("flow_node");
540
541 assert!(get_syntax_graph(&ast, &content, None, None).is_none());
542 }
543
544 #[test]
545 fn empty_ast_graph_returns_none() {
546 let content = content_for(Language::Yaml, "yaml", "");
547
548 assert!(get_syntax_graph(&AstGraph::new(), &content, None, None).is_none());
549 }
550
551 #[test]
552 fn ast_graph_without_root_node_returns_none() {
553 let content = content_for(Language::Yaml, "yaml", "key: value");
554 let mut ast = AstGraph::new();
555 ast.add_node(NodeId(7), AstNode::new(1, 1, "stream".to_owned()));
556
557 assert!(get_syntax_graph(&ast, &content, None, None).is_none());
558 }
559
560 #[test]
561 fn cfg_marks_overlay_the_ast_edges_by_default() {
562 let content = content_for(Language::Yaml, "yaml", "key: value");
563 let ast = get_ast_graph(&content).expect("yaml should parse");
564
565 let result = get_syntax_graph(&ast, &content, None, None);
566
567 let graph = result.expect("syntax graph should be built");
568 assert!(graph
569 .edges
570 .get(&NodeId(1))
571 .is_some_and(|adjacent| adjacent.values().any(|edge| edge.cfg.is_some())));
572 }
573
574 #[test]
575 fn no_cfg_marks_when_disabled() {
576 let content = content_for(Language::Yaml, "yaml", "key: value");
577 let ast = get_ast_graph(&content).expect("yaml should parse");
578
579 let result = get_syntax_graph(&ast, &content, Some(false), None);
580
581 let graph = result.expect("syntax graph should be built");
582 assert!(graph
583 .edges
584 .values()
585 .flat_map(|adjacent| adjacent.values())
586 .all(|edge| edge.cfg.is_none()));
587 }
588
589 #[test]
590 fn empty_yaml_builds_a_lone_file_node() {
591 let content = content_for(Language::Yaml, "yaml", "");
592 let ast = get_ast_graph(&content).expect("empty yaml should parse");
593
594 let result = get_syntax_graph(&ast, &content, None, None);
595
596 let graph = result.expect("syntax graph should be built");
597 assert_eq!(graph.nodes.len(), 1);
598 assert_eq!(graph.nodes.get(&NodeId(1)), Some(&SyntaxNode::File));
599 }
600}