Skip to main content

fluidattacks_blends/
syntax.rs

1//! Syntax-graph construction seam: builds the syntax graph for a file,
2//! applies the cfg overlay and logs the outcomes.
3
4use 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/// Builds `ast_graph`'s syntax graph and applies the CFG overlay.
54///
55/// `with_cfg` defaults to on, `with_metadata` to off. Returns `None` when the
56/// language has no syntax dispatcher yet, the AST graph is malformed, or the
57/// CFG overlay hits a syntax shape its builder cannot handle.
58#[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(&current)? {
680                StackNode::Root => {
681                    pushed.reverse();
682                    return Some(pushed);
683                }
684                StackNode::PushSymbol(_) => pushed.extend(stack.nodes.get(&current)?.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}