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::query::get_all_scope_definitions;
106 use blends_domain::stack::{
107 build_stack_graph, import_sites, BindingReach, StackGraph, StackNode,
108 };
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 = (
115 NodeId,
116 String,
117 String,
118 Option<String>,
119 u32,
120 bool,
121 BindingReach,
122 );
123
124 fn site_rows(graph: &SyntaxGraph, language: Language) -> Vec<SiteRow> {
125 import_sites(graph, language)
126 .into_iter()
127 .map(|site| {
128 let binding = site.binding;
129 (
130 site.scope,
131 site.symbol,
132 binding.module_stem,
133 binding.source_symbol,
134 binding.relative_level,
135 binding.binds_module,
136 site.reach,
137 )
138 })
139 .collect()
140 }
141
142 fn site_row(
143 symbol: &str,
144 module_stem: &str,
145 relative_level: u32,
146 source_symbol: Option<&str>,
147 binds_module: bool,
148 ) -> SiteRow {
149 (
150 NodeId(1),
151 symbol.to_owned(),
152 module_stem.to_owned(),
153 source_symbol.map(str::to_owned),
154 relative_level,
155 binds_module,
156 BindingReach::Scope,
157 )
158 }
159
160 fn content_for(language: Language, extension: &str, text: &str) -> Content {
161 Content {
162 bytes: text.as_bytes().to_vec(),
163 text: text.to_owned(),
164 language,
165 path: PathBuf::from(format!("test.{extension}")),
166 }
167 }
168
169 fn single_node_ast(node_type: &str) -> AstGraph {
170 let mut ast = AstGraph::new();
171 ast.add_node(NodeId(1), AstNode::new(1, 1, node_type.to_owned()));
172 ast
173 }
174
175 #[test]
176 fn builds_the_syntax_graph_for_a_supported_language() {
177 let content = content_for(Language::Yaml, "yaml", "key: value");
178 let ast = single_node_ast("stream");
179
180 let result = get_syntax_graph(&ast, &content, None, None);
181
182 let graph = result.expect("syntax graph should be built");
183 assert!(!graph.nodes.is_empty());
184 }
185
186 fn graph_for(language: Language, extension: &str, source: &str) -> SyntaxGraph {
187 let content = content_for(language, extension, source);
188 let ast = get_ast_graph(&content).expect("source should parse");
189 get_syntax_graph(&ast, &content, None, None).expect("graph should be built")
190 }
191
192 #[test_case("import a.b\n", "a" ; "a dotted name defines its top level package")]
193 #[test_case("import a.b as c\n", "c" ; "an aliased dotted name defines the alias")]
194 fn a_plain_python_import_defines_the_name_it_binds(source: &str, bound: &str) {
195 let graph = graph_for(Language::Python, "py", source);
196
197 assert_eq!(
198 graph
199 .symbol_index
200 .keys()
201 .map(String::as_str)
202 .collect::<Vec<_>>(),
203 [bound]
204 );
205 }
206
207 #[test_case("import os\n", &[("os", "os")] ; "a single name binds the module of that name")]
208 #[test_case("import a.b\n", &[("a", "a")] ; "a dotted name binds its top level package")]
209 #[test_case("import a.b, a.c\n", &[("a", "a"), ("a", "a")] ; "two submodules of one package both bind the package")]
210 #[test_case("import a.b as c\n", &[("c", "a.b")] ; "an alias binds the full dotted module")]
211 #[test_case("import a.py as c\n", &[("c", "a.py")] ; "a submodule named like a file extension stays in the module path")]
212 #[test_case("import os, sys as system\n", &[("os", "os"), ("system", "sys")] ; "every name of a multi name import binds its own module")]
213 fn plain_python_imports_bind_modules(source: &str, expected: &[(&str, &str)]) {
214 let graph = graph_for(Language::Python, "py", source);
215
216 let expected: Vec<_> = expected
217 .iter()
218 .map(|(symbol, module_stem)| site_row(symbol, module_stem, 0, None, true))
219 .collect();
220 assert_eq!(site_rows(&graph, Language::Python), expected);
221 }
222
223 #[test_case("from x import y\n", &[("y", "x", 0, None)] ; "a from import binds the name of the module symbol")]
224 #[test_case("from x import x\n", &[("x", "x", 0, None)] ; "a symbol named like its module binds that symbol")]
225 #[test_case("from x import y as z\n", &[("z", "x", 0, Some("y"))] ; "an alias binds the module symbol under the alias")]
226 #[test_case("from a.b import y\n", &[("y", "a.b", 0, None)] ; "a dotted module keeps every segment")]
227 #[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")]
228 #[test_case("from ..x import y\n", &[("y", "x", 2, None)] ; "each leading dot climbs one package level")]
229 #[test_case("from . import y\n", &[("y", "", 1, None)] ; "a bare dot imports from the current package")]
230 #[test_case("from .. import q as r\n", &[("r", "", 2, Some("q"))] ; "an aliased name from a parent package keeps its source name")]
231 #[test_case("from x import *\n", &[("*", "x", 0, None)] ; "a wildcard binds every public name of the module")]
232 #[test_case("from .m import *\n", &[("*", "m", 1, None)] ; "a relative wildcard keeps its package level")]
233 fn python_from_imports_bind_module_symbols(
234 source: &str,
235 expected: &[(&str, &str, u32, Option<&str>)],
236 ) {
237 let graph = graph_for(Language::Python, "py", source);
238
239 let expected: Vec<_> = expected
240 .iter()
241 .map(|(symbol, module_stem, relative_level, source_symbol)| {
242 site_row(symbol, module_stem, *relative_level, *source_symbol, false)
243 })
244 .collect();
245 assert_eq!(site_rows(&graph, Language::Python), expected);
246 }
247
248 #[test_case("js", "import * as ns from \"m\";\n", Some("namespace_import") ; "a namespace import is marked as such")]
249 #[test_case("ts", "import x = require(\"m\");\n", Some("import_require_clause") ; "an import require is marked as such")]
250 #[test_case("js", "import d from \"m\";\n", None ; "a default import carries no import type")]
251 fn script_import_nodes_record_their_clause(
252 extension: &str,
253 source: &str,
254 expected: Option<&str>,
255 ) {
256 let language = if extension == "ts" {
257 Language::TypeScript
258 } else {
259 Language::JavaScript
260 };
261 let graph = graph_for(language, extension, source);
262
263 let import_types: Vec<_> = graph
264 .nodes
265 .values()
266 .filter_map(|node| match node {
267 SyntaxNode::Import { import_type, .. } => Some(import_type.as_deref()),
268 _ => None,
269 })
270 .collect();
271 assert_eq!(import_types, [expected]);
272 }
273
274 #[test_case("js", "import d from \"./m.js\";\n", &[("d", "m", 1, Some("default"), false)] ; "a default import binds the default export")]
275 #[test_case("js", "import { a } from \"lib/x\";\n", &[("a", "lib.x", 0, None, false)] ; "a named import binds the export of that name")]
276 #[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")]
277 #[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")]
278 #[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")]
279 #[test_case("js", "import * as ns from \"../pkg/ns\";\n", &[("ns", "pkg.ns", 2, None, true)] ; "a namespace import binds the whole module")]
280 #[test_case("js", "import x from \"../../a/b\";\n", &[("x", "a.b", 3, Some("default"), false)] ; "each parent directory climbs one more level")]
281 #[test_case("js", "import x from \"..\";\n", &[("x", "", 2, Some("default"), false)] ; "a bare parent directory imports its index module")]
282 #[test_case("js", "import \"side\";\n", &[] ; "a side effect import binds nothing")]
283 #[test_case("ts", "import type { T } from \"./types\";\n", &[("T", "types", 1, None, false)] ; "a type only import binds the export of that name")]
284 #[test_case("ts", "import x = require(\"old\");\n", &[("x", "old", 0, None, true)] ; "an import require binds the whole module")]
285 #[test_case("js", "const svc = require(\"../svc\");\n", &[("svc", "svc", 2, None, true)] ; "a required module binds the whole module")]
286 #[test_case("js", "const { alpha } = require(\"./lib\");\n", &[("alpha", "lib", 1, None, false)] ; "a destructured name binds the export of that name")]
287 #[test_case("js", "const { beta: b } = require(\"lib/x\");\n", &[("b", "lib.x", 0, Some("beta"), false)] ; "a renamed destructured name binds the export under the new name")]
288 #[test_case("js", "const { a, b: c } = require(\"m\");\n", &[("a", "m", 0, None, false), ("c", "m", 0, Some("b"), false)] ; "every name of a destructured require is bound")]
289 #[test_case("ts", "const svc = require(\"./svc\");\n", &[("svc", "svc", 1, None, true)] ; "a typescript require binds the whole module")]
290 #[test_case("js", "const p = require(name);\n", &[] ; "a computed module binds nothing")]
291 #[test_case("js", "const r = load(\"m\");\n", &[] ; "a call to another function binds nothing")]
292 fn script_imports_bind_module_exports(
293 extension: &str,
294 source: &str,
295 expected: &[(&str, &str, u32, Option<&str>, bool)],
296 ) {
297 let language = if extension == "ts" {
298 Language::TypeScript
299 } else {
300 Language::JavaScript
301 };
302 let graph = graph_for(language, extension, source);
303
304 let expected: Vec<_> = expected
305 .iter()
306 .map(
307 |(symbol, module_stem, relative_level, source_symbol, binds_module)| {
308 site_row(
309 symbol,
310 module_stem,
311 *relative_level,
312 *source_symbol,
313 *binds_module,
314 )
315 },
316 )
317 .collect();
318 assert_eq!(site_rows(&graph, language), expected);
319 }
320
321 #[test_case("js", "export { a } from \"./m\";\n", &[("a", "m", 1, None, false)] ; "a named re-export exports the module export of that name")]
322 #[test_case("js", "export { b as c } from \"lib/x\";\n", &[("c", "lib.x", 0, Some("b"), false)] ; "a renamed re-export exports the module export under the new name")]
323 #[test_case("js", "export { default } from \"../m\";\n", &[("default", "m", 2, None, false)] ; "a re-exported default stays the default export")]
324 #[test_case("js", "export { e as default } from \"m\";\n", &[("default", "m", 0, Some("e"), false)] ; "a named export re-exported as default becomes the default export")]
325 #[test_case("js", "export { default as d } from \"m\";\n", &[("d", "m", 0, Some("default"), false)] ; "a default re-exported under a name exports that name")]
326 #[test_case("js", "export { a, b as c } from \"m\";\n", &[("a", "m", 0, None, false), ("c", "m", 0, Some("b"), false)] ; "every name of a re-export clause is exported")]
327 #[test_case("js", "export * as ns from \"./pkg/ns\";\n", &[("ns", "pkg.ns", 1, None, true)] ; "a namespace re-export exports the whole module")]
328 #[test_case("ts", "export type { T } from \"./types\";\n", &[("T", "types", 1, None, false)] ; "a type only re-export exports the module export of that name")]
329 #[test_case("js", "const a = 1;\nexport { a };\n", &[] ; "an export of a local name binds no module")]
330 #[test_case("js", "export { \"a-b\" as c } from \"m\";\n", &[] ; "a string named re-export binds nothing while its name is unread")]
331 #[test_case("js", "export * from \"./m\";\n", &[("*", "m", 1, None, false)] ; "a star re-export exports every name of the module")]
332 #[test_case("ts", "export * from \"lib/x\";\n", &[("*", "lib.x", 0, None, false)] ; "a typescript star re-export exports every name of the module")]
333 #[test_case("ts", "export = foo;\n", &[] ; "an export assignment binds no module")]
334 #[test_case("ts", "export as namespace Foo;\n", &[] ; "a global namespace export binds no module")]
335 #[test_case("ts", "declare module \"x\" { export { a } from \"./b\"; }\n", &[] ; "a re-export inside an ambient module is not exported by the file")]
336 fn script_reexports_bind_module_exports_for_importers(
337 extension: &str,
338 source: &str,
339 expected: &[(&str, &str, u32, Option<&str>, bool)],
340 ) {
341 let language = if extension == "ts" {
342 Language::TypeScript
343 } else {
344 Language::JavaScript
345 };
346 let graph = graph_for(language, extension, source);
347
348 let expected: Vec<_> = expected
349 .iter()
350 .map(
351 |(symbol, module_stem, relative_level, source_symbol, binds_module)| {
352 let (
353 scope,
354 symbol,
355 module_stem,
356 source_symbol,
357 relative_level,
358 binds_module,
359 _,
360 ) = site_row(
361 symbol,
362 module_stem,
363 *relative_level,
364 *source_symbol,
365 *binds_module,
366 );
367 (
368 scope,
369 symbol,
370 module_stem,
371 source_symbol,
372 relative_level,
373 binds_module,
374 BindingReach::Importers,
375 )
376 },
377 )
378 .collect();
379 assert_eq!(site_rows(&graph, language), expected);
380 }
381
382 #[test_case("js", "export * from \"m\";\n", Some("*") ; "a star re-export exports under the wildcard alias")]
383 #[test_case("js", "export * as ns from \"m\";\n", Some("ns") ; "a namespace re-export exports under its namespace")]
384 #[test_case("ts", "export = foo;\n", None ; "an export assignment carries no alias")]
385 fn script_export_statements_record_their_exported_alias(
386 extension: &str,
387 source: &str,
388 expected: Option<&str>,
389 ) {
390 let language = if extension == "ts" {
391 Language::TypeScript
392 } else {
393 Language::JavaScript
394 };
395 let graph = graph_for(language, extension, source);
396
397 let aliases: Vec<_> = graph
398 .nodes
399 .values()
400 .filter_map(|node| match node {
401 SyntaxNode::Export { alias, .. } => Some(alias.as_deref()),
402 _ => None,
403 })
404 .collect();
405 assert_eq!(aliases, [expected]);
406 }
407
408 #[test]
409 fn an_import_beside_a_reexport_stays_visible_in_its_scope() {
410 let graph = graph_for(
411 Language::JavaScript,
412 "js",
413 "import { a } from \"m\";\nexport { a } from \"m\";\n",
414 );
415
416 assert_eq!(
417 site_rows(&graph, Language::JavaScript)
418 .into_iter()
419 .map(|row| (row.1, row.6))
420 .collect::<Vec<_>>(),
421 [
422 ("a".to_owned(), BindingReach::Scope),
423 ("a".to_owned(), BindingReach::Importers)
424 ]
425 );
426 }
427
428 #[test_case("def f():\n import os\n" ; "a single name")]
429 #[test_case("def f():\n import a.b\n" ; "a dotted name")]
430 fn an_import_site_keeps_its_import_node_and_innermost_scope(source: &str) {
431 let graph = graph_for(Language::Python, "py", source);
432 let first_of = |is_wanted: fn(&SyntaxNode) -> bool| {
433 graph
434 .nodes
435 .iter()
436 .find_map(|(n_id, node)| is_wanted(node).then_some(*n_id))
437 };
438 let function = first_of(|node| matches!(node, SyntaxNode::MethodDeclaration { .. }));
439 let import = first_of(|node| matches!(node, SyntaxNode::Import { .. }));
440
441 assert_eq!(
442 import_sites(&graph, Language::Python)
443 .iter()
444 .map(|site| (Some(site.node), Some(site.scope)))
445 .collect::<Vec<_>>(),
446 [(import, function)]
447 );
448 }
449
450 #[test]
451 fn imports_of_other_languages_are_not_read_yet() {
452 let graph = graph_for(Language::Java, "java", "import a.b.C;\nclass T {}\n");
453
454 assert!(import_sites(&graph, Language::Java).is_empty());
455 }
456
457 fn lookup_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
458 graph
459 .nodes
460 .iter()
461 .find_map(|(n_id, node)| match node {
462 SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted => Some(*n_id),
463 _ => None,
464 })
465 .expect("lookup should exist")
466 }
467
468 #[test]
469 fn scope_definitions_resolve_the_second_local_declarator() {
470 let content = content_for(
471 Language::Java,
472 "java",
473 "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
474 );
475 let ast = get_ast_graph(&content).expect("java source should parse");
476
477 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
478
479 let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "plane"));
480
481 let &[def_id] = definitions.as_slice() else {
482 panic!("expected one scope definition for plane");
483 };
484 assert!(matches!(
485 graph.nodes.get(&def_id),
486 Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
487 if variable == "plane" && variable_type == "String"
488 ));
489 }
490
491 #[test]
492 fn scope_definitions_resolve_the_second_interface_constant() {
493 let content = content_for(
494 Language::Java,
495 "java",
496 "interface Limits { int MIN = 1, MAX = 10; default int clamp(int value) { return value > MAX ? MAX : value; } }",
497 );
498 let ast = get_ast_graph(&content).expect("java source should parse");
499
500 let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
501
502 let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "MAX"));
503
504 let &[def_id] = definitions.as_slice() else {
505 panic!("expected one scope definition for MAX");
506 };
507 assert!(matches!(
508 graph.nodes.get(&def_id),
509 Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
510 if variable == "MAX" && variable_type == "int"
511 ));
512 }
513
514 #[test]
515 fn metadata_node_added_at_zero_when_requested() {
516 let content = content_for(Language::Yaml, "yaml", "key: value");
517 let ast = single_node_ast("stream");
518
519 let result = get_syntax_graph(&ast, &content, None, Some(true));
520
521 let graph = result.expect("syntax graph should be built");
522 assert_eq!(
523 graph.nodes.get(&NodeId(0)),
524 Some(&SyntaxNode::Metadata {
525 path: "test.yaml".to_owned(),
526 structure: std::collections::BTreeMap::new(),
527 instances: std::collections::BTreeMap::new(),
528 imports: std::vec::Vec::new(),
529 package: None,
530 })
531 );
532 }
533
534 #[test_case(None ; "by default")]
535 #[test_case(Some(false) ; "when explicitly disabled")]
536 fn no_metadata_node(with_metadata: Option<bool>) {
537 let content = content_for(Language::Yaml, "yaml", "key: value");
538 let ast = single_node_ast("stream");
539
540 let result = get_syntax_graph(&ast, &content, None, with_metadata);
541
542 let graph = result.expect("syntax graph should be built");
543 assert!(!graph.nodes.contains_key(&NodeId(0)));
544 }
545
546 #[test]
547 fn language_without_dispatcher_has_no_syntax_graph() {
548 let content = content_for(Language::Elixir, "ex", "defmodule A do end");
549 let ast = single_node_ast("program");
550
551 assert!(get_syntax_graph(&ast, &content, None, None).is_none());
552 }
553
554 #[test]
555 fn unhandled_root_node_type_degrades_to_missing_node_and_recurses() {
556 let content = content_for(Language::Yaml, "yaml", "key: value");
557 let mut ast = single_node_ast("unknown_node_type_xyz");
558 ast.add_node(NodeId(2), AstNode::new(1, 5, "another_unknown".to_owned()));
559 ast.add_edge(NodeId(1), NodeId(2), 0);
560
561 let result = get_syntax_graph(&ast, &content, None, None);
562
563 let graph = result.expect("syntax graph should be built");
564 assert_eq!(
565 graph.nodes.get(&NodeId(1)),
566 Some(&SyntaxNode::MissingNode {
567 node_type: "unknown_node_type_xyz".to_owned()
568 })
569 );
570 assert_eq!(
571 graph.nodes.get(&NodeId(2)),
572 Some(&SyntaxNode::MissingNode {
573 node_type: "another_unknown".to_owned()
574 })
575 );
576 assert!(graph
577 .edges
578 .get(&NodeId(1))
579 .is_some_and(|adjacent| adjacent.contains_key(&NodeId(2))));
580 }
581
582 #[test]
583 fn fatal_reader_error_returns_none() {
584 let content = content_for(Language::Yaml, "yaml", "[]");
585 let ast = single_node_ast("flow_node");
586
587 assert!(get_syntax_graph(&ast, &content, None, None).is_none());
588 }
589
590 #[test]
591 fn empty_ast_graph_returns_none() {
592 let content = content_for(Language::Yaml, "yaml", "");
593
594 assert!(get_syntax_graph(&AstGraph::new(), &content, None, None).is_none());
595 }
596
597 #[test]
598 fn ast_graph_without_root_node_returns_none() {
599 let content = content_for(Language::Yaml, "yaml", "key: value");
600 let mut ast = AstGraph::new();
601 ast.add_node(NodeId(7), AstNode::new(1, 1, "stream".to_owned()));
602
603 assert!(get_syntax_graph(&ast, &content, None, None).is_none());
604 }
605
606 #[test]
607 fn cfg_marks_overlay_the_ast_edges_by_default() {
608 let content = content_for(Language::Yaml, "yaml", "key: value");
609 let ast = get_ast_graph(&content).expect("yaml should parse");
610
611 let result = get_syntax_graph(&ast, &content, None, None);
612
613 let graph = result.expect("syntax graph should be built");
614 assert!(graph
615 .edges
616 .get(&NodeId(1))
617 .is_some_and(|adjacent| adjacent.values().any(|edge| edge.cfg.is_some())));
618 }
619
620 #[test]
621 fn no_cfg_marks_when_disabled() {
622 let content = content_for(Language::Yaml, "yaml", "key: value");
623 let ast = get_ast_graph(&content).expect("yaml should parse");
624
625 let result = get_syntax_graph(&ast, &content, Some(false), None);
626
627 let graph = result.expect("syntax graph should be built");
628 assert!(graph
629 .edges
630 .values()
631 .flat_map(|adjacent| adjacent.values())
632 .all(|edge| edge.cfg.is_none()));
633 }
634
635 #[test]
636 fn empty_yaml_builds_a_lone_file_node() {
637 let content = content_for(Language::Yaml, "yaml", "");
638 let ast = get_ast_graph(&content).expect("empty yaml should parse");
639
640 let result = get_syntax_graph(&ast, &content, None, None);
641
642 let graph = result.expect("syntax graph should be built");
643 assert_eq!(graph.nodes.len(), 1);
644 assert_eq!(graph.nodes.get(&NodeId(1)), Some(&SyntaxNode::File));
645 }
646
647 #[test_case(Language::Python, "py", "for i in items:\n pass\n", &["items"] ; "a python for target is not a reference")]
648 #[test_case(Language::Python, "py", "r = [y for y in ys]\n", &["y", "ys"] ; "a comprehension variable is not a reference")]
649 #[test_case(Language::Python, "py", "def f(a, b=dflt, *rest):\n pass\n", &["dflt"] ; "a parameter is not a reference but its default is")]
650 #[test_case(Language::Python, "py", "a, *b = pair\n", &["pair"] ; "an unpacking target is not a reference")]
651 #[test_case(Language::Python, "py", "with open(p) as h:\n pass\n", &["open", "p"] ; "a with target is not a reference")]
652 #[test_case(Language::Python, "py", "x += 1\n", &["x"] ; "an augmented assignment target is a reference")]
653 #[test_case(Language::JavaScript, "js", "for (const x of xs) {}\n", &["xs"] ; "a javascript loop variable is not a reference")]
654 #[test_case(Language::JavaScript, "js", "y = z;\n", &["y", "z"] ; "a javascript assignment target is a reference")]
655 #[test_case(Language::JavaScript, "js", "const { a: b } = load();\n", &["load"] ; "a destructured name is not a reference")]
656 #[test_case(Language::JavaScript, "js", "try {} catch (e) {}\n", &[] ; "a catch parameter is not a reference")]
657 fn only_lookups_that_read_a_name_become_references(
658 language: Language,
659 extension: &str,
660 source: &str,
661 expected: &[&str],
662 ) {
663 let syntax = graph_for(language, extension, source);
664 let stack = build_stack_graph(&syntax, language, "mod");
665
666 let references: Vec<_> = stack
667 .nodes
668 .values()
669 .filter(|node| node.is_reference())
670 .filter_map(StackNode::symbol)
671 .collect();
672 assert_eq!(references, expected);
673 }
674
675 fn path_to_root(stack: &StackGraph, start: NodeId) -> Option<Vec<&str>> {
676 let mut pushed = Vec::new();
677 let mut current = start;
678 loop {
679 match stack.nodes.get(¤t)? {
680 StackNode::Root => {
681 pushed.reverse();
682 return Some(pushed);
683 }
684 StackNode::PushSymbol(_) => pushed.extend(stack.nodes.get(¤t)?.symbol()),
685 _ => {}
686 }
687 current = match stack.successors(current).as_slice() {
688 [(next, _)] => *next,
689 _ => return None,
690 };
691 }
692 }
693
694 type ImportPath<'a> = (Option<&'a str>, Option<Vec<&'a str>>);
695
696 fn import_paths(stack: &StackGraph) -> Vec<ImportPath<'_>> {
697 stack
698 .nodes
699 .iter()
700 .filter(|(_, node)| matches!(node, StackNode::Scope(_)))
701 .flat_map(|(scope, _)| stack.successors(*scope))
702 .filter_map(|(start, _)| match stack.nodes.get(&start)? {
703 StackNode::PushSymbol(_) => Some((None, path_to_root(stack, start))),
704 node if node.import_binding().is_some() => {
705 let next = stack.successors(start).first().map(|(next, _)| *next);
706 Some((
707 node.symbol(),
708 next.and_then(|next| path_to_root(stack, next)),
709 ))
710 }
711 _ => None,
712 })
713 .collect()
714 }
715
716 #[test_case(Language::Python, "pkg/sub/mod.py", "import os\n", &[(Some("os"), Some(&["os"]))] ; "a plain import reaches the module it names")]
717 #[test_case(Language::Python, "pkg/sub/mod.py", "import a.b\n", &[(Some("a"), Some(&["a"]))] ; "a dotted import reaches its top level package")]
718 #[test_case(Language::Python, "pkg/sub/mod.py", "import a.b as c\n", &[(Some("c"), Some(&["a", ".", "b"]))] ; "an aliased dotted import reaches the full module")]
719 #[test_case(Language::Python, "pkg/sub/mod.py", "from a.b import y\n", &[(Some("y"), Some(&["a", ".", "b", "y"]))] ; "a from import reaches the symbol inside its module")]
720 #[test_case(Language::Python, "pkg/sub/mod.py", "from x import y as z\n", &[(Some("z"), Some(&["x", "y"]))] ; "an aliased from import reaches the source symbol")]
721 #[test_case(Language::Python, "pkg/sub/mod.py", "from . import y\n", &[(Some("y"), Some(&["pkg", ".", "sub", "y"]))] ; "a bare dot reaches the importing package")]
722 #[test_case(Language::Python, "pkg/sub/mod.py", "from ..m import y\n", &[(Some("y"), Some(&["pkg", ".", "m", "y"]))] ; "a second dot reaches a sibling of the importing package")]
723 #[test_case(Language::Python, "pkg/sub/mod.py", "from ... import y\n", &[(Some("y"), None)] ; "a relative import above the top package binds without reaching a module")]
724 #[test_case(Language::Python, "pkg/sub/mod.py", "def f():\n import os\n", &[(Some("os"), Some(&["os"]))] ; "an import inside a function binds in the function scope")]
725 #[test_case(Language::Python, "pkg/sub/mod.py", "from x import *\n", &[(None, Some(&["x"]))] ; "a wildcard lets lookups continue into its module")]
726 #[test_case(Language::JavaScript, "src/app.js", "import d from \"./m\";\n", &[(Some("d"), Some(&["src", ".", "m", "default"]))] ; "a default import reaches the default export")]
727 #[test_case(Language::JavaScript, "src/app.js", "import * as ns from \"m\";\n", &[(Some("ns"), Some(&["m"]))] ; "a namespace import reaches the module")]
728 #[test_case(Language::JavaScript, "src/app.js", "const { a: b } = require(\"../lib\");\n", &[(Some("b"), Some(&["lib", "a"]))] ; "a destructured require reaches the source export")]
729 #[test_case(Language::JavaScript, "src/app.js", "import d from \"../m\";\n", &[(Some("d"), Some(&["m", "default"]))] ; "a script relative import may reach the top directory")]
730 #[test_case(Language::JavaScript, "src/app.js", "import d from \"../../m\";\n", &[(Some("d"), None)] ; "a script relative import above the top directory binds without reaching a module")]
731 #[test_case(Language::JavaScript, "src/app.js", "export { a } from \"./m\";\n", &[] ; "a re-export binds nothing for the code of its file")]
732 fn imports_reach_the_root_with_their_qualified_name(
733 language: Language,
734 path: &str,
735 source: &str,
736 expected: &[(Option<&str>, Option<&[&str]>)],
737 ) {
738 let extension = path.rsplit_once('.').map_or("", |(_, extension)| extension);
739 let syntax = graph_for(language, extension, source);
740 let stack = build_stack_graph(&syntax, language, path);
741
742 let expected: Vec<_> = expected
743 .iter()
744 .map(|(bound, pushed)| (*bound, pushed.map(<[&str]>::to_vec)))
745 .collect();
746 assert_eq!(import_paths(&stack), expected);
747 }
748
749 #[test_case(Language::Python, "py", "from m import a\n", false ; "a python import stays visible to importers of the module")]
750 #[test_case(Language::JavaScript, "js", "import { a } from \"m\";\n", true ; "a javascript import is private to its module")]
751 fn an_import_binding_is_private_only_where_imports_stay_in_the_module(
752 language: Language,
753 extension: &str,
754 source: &str,
755 expected: bool,
756 ) {
757 let stack = build_stack_graph(&graph_for(language, extension, source), language, "mod");
758
759 let privacy: Vec<_> = stack
760 .nodes
761 .values()
762 .filter_map(|node| match node {
763 StackNode::PopSymbol(pop) if pop.import.is_some() => pop
764 .definition
765 .as_ref()
766 .map(|definition| definition.is_private),
767 _ => None,
768 })
769 .collect();
770 assert_eq!(privacy, [expected]);
771 }
772}