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