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 lookup_for(graph: &blends_domain::syntax::SyntaxGraph, wanted: &str) -> NodeId {
168        graph
169            .nodes
170            .iter()
171            .find_map(|(n_id, node)| match node {
172                SyntaxNode::SymbolLookup { symbol, .. } if symbol == wanted => Some(*n_id),
173                _ => None,
174            })
175            .expect("lookup should exist")
176    }
177
178    #[test]
179    fn scope_definitions_resolve_the_second_local_declarator() {
180        let content = content_for(
181            Language::Java,
182            "java",
183            "class T { void run() { String car = \"a\", plane = \"b\", boat = \"c\"; System.out.println(plane); } }",
184        );
185        let ast = get_ast_graph(&content).expect("java source should parse");
186
187        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
188
189        let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "plane"));
190
191        let &[def_id] = definitions.as_slice() else {
192            panic!("expected one scope definition for plane");
193        };
194        assert!(matches!(
195            graph.nodes.get(&def_id),
196            Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
197                if variable == "plane" && variable_type == "String"
198        ));
199    }
200
201    #[test]
202    fn scope_definitions_resolve_the_second_interface_constant() {
203        let content = content_for(
204            Language::Java,
205            "java",
206            "interface Limits { int MIN = 1, MAX = 10; default int clamp(int value) { return value > MAX ? MAX : value; } }",
207        );
208        let ast = get_ast_graph(&content).expect("java source should parse");
209
210        let graph = get_syntax_graph(&ast, &content, None, None).expect("graph should be built");
211
212        let definitions = get_all_scope_definitions(&graph, lookup_for(&graph, "MAX"));
213
214        let &[def_id] = definitions.as_slice() else {
215            panic!("expected one scope definition for MAX");
216        };
217        assert!(matches!(
218            graph.nodes.get(&def_id),
219            Some(SyntaxNode::VariableDeclaration { variable, variable_type: Some(variable_type), .. })
220                if variable == "MAX" && variable_type == "int"
221        ));
222    }
223
224    #[test]
225    fn metadata_node_added_at_zero_when_requested() {
226        let content = content_for(Language::Yaml, "yaml", "key: value");
227        let ast = single_node_ast("stream");
228
229        let result = get_syntax_graph(&ast, &content, None, Some(true));
230
231        let graph = result.expect("syntax graph should be built");
232        assert_eq!(
233            graph.nodes.get(&NodeId(0)),
234            Some(&SyntaxNode::Metadata {
235                path: "test.yaml".to_owned(),
236                structure: std::collections::BTreeMap::new(),
237                instances: std::collections::BTreeMap::new(),
238                imports: std::vec::Vec::new(),
239                package: None,
240            })
241        );
242    }
243
244    #[test_case(None ; "by default")]
245    #[test_case(Some(false) ; "when explicitly disabled")]
246    fn no_metadata_node(with_metadata: Option<bool>) {
247        let content = content_for(Language::Yaml, "yaml", "key: value");
248        let ast = single_node_ast("stream");
249
250        let result = get_syntax_graph(&ast, &content, None, with_metadata);
251
252        let graph = result.expect("syntax graph should be built");
253        assert!(!graph.nodes.contains_key(&NodeId(0)));
254    }
255
256    #[test]
257    fn language_without_dispatcher_has_no_syntax_graph() {
258        let content = content_for(Language::Kotlin, "kt", "class A {}");
259        let ast = single_node_ast("program");
260
261        assert!(get_syntax_graph(&ast, &content, None, None).is_none());
262    }
263
264    #[test]
265    fn unhandled_root_node_type_degrades_to_missing_node_and_recurses() {
266        let content = content_for(Language::Yaml, "yaml", "key: value");
267        let mut ast = single_node_ast("unknown_node_type_xyz");
268        ast.add_node(NodeId(2), AstNode::new(1, 5, "another_unknown".to_owned()));
269        ast.add_edge(NodeId(1), NodeId(2), 0);
270
271        let result = get_syntax_graph(&ast, &content, None, None);
272
273        let graph = result.expect("syntax graph should be built");
274        assert_eq!(
275            graph.nodes.get(&NodeId(1)),
276            Some(&SyntaxNode::MissingNode {
277                node_type: "unknown_node_type_xyz".to_owned()
278            })
279        );
280        assert_eq!(
281            graph.nodes.get(&NodeId(2)),
282            Some(&SyntaxNode::MissingNode {
283                node_type: "another_unknown".to_owned()
284            })
285        );
286        assert!(graph
287            .edges
288            .get(&NodeId(1))
289            .is_some_and(|adjacent| adjacent.contains_key(&NodeId(2))));
290    }
291
292    #[test]
293    fn fatal_reader_error_returns_none() {
294        let content = content_for(Language::Yaml, "yaml", "[]");
295        let ast = single_node_ast("flow_node");
296
297        assert!(get_syntax_graph(&ast, &content, None, None).is_none());
298    }
299
300    #[test]
301    fn empty_ast_graph_returns_none() {
302        let content = content_for(Language::Yaml, "yaml", "");
303
304        assert!(get_syntax_graph(&AstGraph::new(), &content, None, None).is_none());
305    }
306
307    #[test]
308    fn ast_graph_without_root_node_returns_none() {
309        let content = content_for(Language::Yaml, "yaml", "key: value");
310        let mut ast = AstGraph::new();
311        ast.add_node(NodeId(7), AstNode::new(1, 1, "stream".to_owned()));
312
313        assert!(get_syntax_graph(&ast, &content, None, None).is_none());
314    }
315
316    #[test]
317    fn cfg_marks_overlay_the_ast_edges_by_default() {
318        let content = content_for(Language::Yaml, "yaml", "key: value");
319        let ast = get_ast_graph(&content).expect("yaml should parse");
320
321        let result = get_syntax_graph(&ast, &content, None, None);
322
323        let graph = result.expect("syntax graph should be built");
324        assert!(graph
325            .edges
326            .get(&NodeId(1))
327            .is_some_and(|adjacent| adjacent.values().any(|edge| edge.cfg.is_some())));
328    }
329
330    #[test]
331    fn no_cfg_marks_when_disabled() {
332        let content = content_for(Language::Yaml, "yaml", "key: value");
333        let ast = get_ast_graph(&content).expect("yaml should parse");
334
335        let result = get_syntax_graph(&ast, &content, Some(false), None);
336
337        let graph = result.expect("syntax graph should be built");
338        assert!(graph
339            .edges
340            .values()
341            .flat_map(|adjacent| adjacent.values())
342            .all(|edge| edge.cfg.is_none()));
343    }
344
345    #[test]
346    fn empty_yaml_builds_a_lone_file_node() {
347        let content = content_for(Language::Yaml, "yaml", "");
348        let ast = get_ast_graph(&content).expect("empty yaml should parse");
349
350        let result = get_syntax_graph(&ast, &content, None, None);
351
352        let graph = result.expect("syntax graph should be built");
353        assert_eq!(graph.nodes.len(), 1);
354        assert_eq!(graph.nodes.get(&NodeId(1)), Some(&SyntaxNode::File));
355    }
356}