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::syntax::SyntaxNode;
107    use blends_domain::NodeId;
108    use std::path::PathBuf;
109    use test_case::test_case;
110
111    fn content_for(language: Language, extension: &str, text: &str) -> Content {
112        Content {
113            bytes: text.as_bytes().to_vec(),
114            text: text.to_owned(),
115            language,
116            path: PathBuf::from(format!("test.{extension}")),
117        }
118    }
119
120    fn single_node_ast(node_type: &str) -> AstGraph {
121        let mut ast = AstGraph::new();
122        ast.add_node(NodeId(1), AstNode::new(1, 1, node_type.to_owned()));
123        ast
124    }
125
126    #[test]
127    fn builds_the_syntax_graph_for_a_supported_language() {
128        let content = content_for(Language::Yaml, "yaml", "key: value");
129        let ast = single_node_ast("stream");
130
131        let result = get_syntax_graph(&ast, &content, None, None);
132
133        let graph = result.expect("syntax graph should be built");
134        assert!(!graph.nodes.is_empty());
135    }
136
137    #[test]
138    fn definition_search_resolves_the_second_local_declarator() {
139        let content = content_for(
140            Language::Java,
141            "java",
142            "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
143        );
144        let ast = get_ast_graph(&content).expect("java source should parse");
145
146        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
147
148        let lookup_id = graph
149            .nodes
150            .iter()
151            .find_map(|(n_id, node)| match node {
152                SyntaxNode::SymbolLookup { symbol, .. } if symbol == "plane" => Some(*n_id),
153                _ => None,
154            })
155            .expect("plane lookup should exist");
156        let def_id = get_backward_paths(&graph, lookup_id, None)
157            .iter()
158            .find_map(|path| definition_search(&graph, path, "plane"))
159            .expect("plane definition should resolve");
160
161        assert!(matches!(
162            graph.nodes.get(&def_id),
163            Some(SyntaxNode::VariableDeclaration { variable, .. }) if variable == "plane"
164        ));
165    }
166
167    fn assignment_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
168        let lookup_id = graph
169            .nodes
170            .iter()
171            .filter(|(_, node)| matches!(node, SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted))
172            .map(|(n_id, _)| *n_id)
173            .next_back()
174            .expect("lookup should exist");
175        get_backward_paths(graph, lookup_id, None)
176            .iter()
177            .find_map(|path| definition_search(graph, path, wanted))
178            .expect("definition should resolve")
179    }
180
181    #[test]
182    fn definition_search_resolves_a_go_name_declared_beside_another() {
183        let content = content_for(
184            Language::Go,
185            "go",
186            "package main\nfunc f() {\n\ta, b := 1, 2\n\tprintln(b)\n}\n",
187        );
188        let ast = get_ast_graph(&content).expect("go source should parse");
189
190        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
191
192        assert!(matches!(
193            graph.nodes.get(&assignment_for(&graph, "b")),
194            Some(SyntaxNode::Assignment { .. })
195        ));
196    }
197
198    #[test]
199    fn definition_search_resolves_a_python_name_declared_beside_another() {
200        let content = content_for(
201            Language::Python,
202            "py",
203            "def f():\n    a, b = 1, 2\n    print(b)\n",
204        );
205        let ast = get_ast_graph(&content).expect("python source should parse");
206
207        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
208
209        assert!(matches!(
210            graph.nodes.get(&assignment_for(&graph, "b")),
211            Some(SyntaxNode::Assignment { .. })
212        ));
213    }
214
215    fn lookup_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
216        graph
217            .nodes
218            .iter()
219            .find_map(|(n_id, node)| match node {
220                SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted => Some(*n_id),
221                _ => None,
222            })
223            .expect("lookup should exist")
224    }
225
226    #[test]
227    fn scope_definitions_resolve_the_second_local_declarator() {
228        let content = content_for(
229            Language::Java,
230            "java",
231            "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
232        );
233        let ast = get_ast_graph(&content).expect("java source should parse");
234
235        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
236
237        let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "plane"));
238
239        let &[def_id] = definitions.as_slice() else {
240            panic!("expected one scope definition for plane");
241        };
242        assert!(matches!(
243            graph.nodes.get(&def_id),
244            Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
245                if variable == "plane" && variable_type == "String"
246        ));
247    }
248
249    #[test]
250    fn scope_definitions_resolve_the_second_interface_constant() {
251        let content = content_for(
252            Language::Java,
253            "java",
254            "interface Limits { int MIN = 1, MAX = 10; default int clamp(int value) { return value > MAX ? MAX : value; } }",
255        );
256        let ast = get_ast_graph(&content).expect("java source should parse");
257
258        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
259
260        let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "MAX"));
261
262        let &[def_id] = definitions.as_slice() else {
263            panic!("expected one scope definition for MAX");
264        };
265        assert!(matches!(
266            graph.nodes.get(&def_id),
267            Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
268                if variable == "MAX" && variable_type == "int"
269        ));
270    }
271
272    #[test]
273    fn metadata_node_added_at_zero_when_requested() {
274        let content = content_for(Language::Yaml, "yaml", "key: value");
275        let ast = single_node_ast("stream");
276
277        let result = get_syntax_graph(&ast, &content, None, Some(true));
278
279        let graph = result.expect("syntax graph should be built");
280        assert_eq!(
281            graph.nodes.get(&NodeId(0)),
282            Some(&SyntaxNode::Metadata {
283                path: "test.yaml".to_owned(),
284                structure: std::collections::BTreeMap::new(),
285                instances: std::collections::BTreeMap::new(),
286                imports: std::vec::Vec::new(),
287                package: None,
288            })
289        );
290    }
291
292    #[test_case(None ; "by default")]
293    #[test_case(Some(false) ; "when explicitly disabled")]
294    fn no_metadata_node(with_metadata: Option<bool>) {
295        let content = content_for(Language::Yaml, "yaml", "key: value");
296        let ast = single_node_ast("stream");
297
298        let result = get_syntax_graph(&ast, &content, None, with_metadata);
299
300        let graph = result.expect("syntax graph should be built");
301        assert!(!graph.nodes.contains_key(&NodeId(0)));
302    }
303
304    #[test]
305    fn language_without_dispatcher_has_no_syntax_graph() {
306        let content = content_for(Language::Elixir, "ex", "defmodule A do end");
307        let ast = single_node_ast("program");
308
309        assert!(get_syntax_graph(&ast, &content, None, None).is_none());
310    }
311
312    #[test]
313    fn unhandled_root_node_type_degrades_to_missing_node_and_recurses() {
314        let content = content_for(Language::Yaml, "yaml", "key: value");
315        let mut ast = single_node_ast("unknown_node_type_xyz");
316        ast.add_node(NodeId(2), AstNode::new(1, 5, "another_unknown".to_owned()));
317        ast.add_edge(NodeId(1), NodeId(2), 0);
318
319        let result = get_syntax_graph(&ast, &content, None, None);
320
321        let graph = result.expect("syntax graph should be built");
322        assert_eq!(
323            graph.nodes.get(&NodeId(1)),
324            Some(&SyntaxNode::MissingNode {
325                node_type: "unknown_node_type_xyz".to_owned()
326            })
327        );
328        assert_eq!(
329            graph.nodes.get(&NodeId(2)),
330            Some(&SyntaxNode::MissingNode {
331                node_type: "another_unknown".to_owned()
332            })
333        );
334        assert!(graph
335            .edges
336            .get(&NodeId(1))
337            .is_some_and(|adjacent| adjacent.contains_key(&NodeId(2))));
338    }
339
340    #[test]
341    fn fatal_reader_error_returns_none() {
342        let content = content_for(Language::Yaml, "yaml", "[]");
343        let ast = single_node_ast("flow_node");
344
345        assert!(get_syntax_graph(&ast, &content, None, None).is_none());
346    }
347
348    #[test]
349    fn empty_ast_graph_returns_none() {
350        let content = content_for(Language::Yaml, "yaml", "");
351
352        assert!(get_syntax_graph(&AstGraph::new(), &content, None, None).is_none());
353    }
354
355    #[test]
356    fn ast_graph_without_root_node_returns_none() {
357        let content = content_for(Language::Yaml, "yaml", "key: value");
358        let mut ast = AstGraph::new();
359        ast.add_node(NodeId(7), AstNode::new(1, 1, "stream".to_owned()));
360
361        assert!(get_syntax_graph(&ast, &content, None, None).is_none());
362    }
363
364    #[test]
365    fn cfg_marks_overlay_the_ast_edges_by_default() {
366        let content = content_for(Language::Yaml, "yaml", "key: value");
367        let ast = get_ast_graph(&content).expect("yaml should parse");
368
369        let result = get_syntax_graph(&ast, &content, None, None);
370
371        let graph = result.expect("syntax graph should be built");
372        assert!(graph
373            .edges
374            .get(&NodeId(1))
375            .is_some_and(|adjacent| adjacent.values().any(|edge| edge.cfg.is_some())));
376    }
377
378    #[test]
379    fn no_cfg_marks_when_disabled() {
380        let content = content_for(Language::Yaml, "yaml", "key: value");
381        let ast = get_ast_graph(&content).expect("yaml should parse");
382
383        let result = get_syntax_graph(&ast, &content, Some(false), None);
384
385        let graph = result.expect("syntax graph should be built");
386        assert!(graph
387            .edges
388            .values()
389            .flat_map(|adjacent| adjacent.values())
390            .all(|edge| edge.cfg.is_none()));
391    }
392
393    #[test]
394    fn empty_yaml_builds_a_lone_file_node() {
395        let content = content_for(Language::Yaml, "yaml", "");
396        let ast = get_ast_graph(&content).expect("empty yaml should parse");
397
398        let result = get_syntax_graph(&ast, &content, None, None);
399
400        let graph = result.expect("syntax graph should be built");
401        assert_eq!(graph.nodes.len(), 1);
402        assert_eq!(graph.nodes.get(&NodeId(1)), Some(&SyntaxNode::File));
403    }
404}