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::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}