Skip to main content

graphy_parser/
svelte.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use graphy_core::{
5    EdgeKind, GirEdge, GirNode, Language, NodeKind, ParseOutput, SymbolId, Visibility,
6};
7use tree_sitter::Parser;
8
9use crate::frontend::LanguageFrontend;
10use crate::helpers::node_span;
11use crate::typescript::TypeScriptFrontend;
12
13pub struct SvelteFrontend;
14
15impl SvelteFrontend {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl LanguageFrontend for SvelteFrontend {
22    fn parse(&self, path: &Path, source: &str) -> Result<ParseOutput> {
23        let mut parser = Parser::new();
24        parser
25            .set_language(&tree_sitter_svelte_ng::LANGUAGE.into())
26            .context("Failed to set Svelte language")?;
27
28        let tree = parser
29            .parse(source, None)
30            .context("tree-sitter parse returned None")?;
31
32        let root = tree.root_node();
33        let mut output = ParseOutput::new();
34
35        // Create file node
36        let file_node = GirNode {
37            id: SymbolId::new(path, path.to_string_lossy().as_ref(), NodeKind::File, 0),
38            name: path
39                .file_stem()
40                .map(|s| s.to_string_lossy().into_owned())
41                .unwrap_or_else(|| path.to_string_lossy().into_owned()),
42            kind: NodeKind::File,
43            file_path: path.to_path_buf(),
44            span: node_span(&root),
45            visibility: Visibility::Public,
46            language: Language::Svelte,
47            signature: None,
48            complexity: None,
49            confidence: 1.0,
50            doc: None,
51            coverage: None,
52        };
53        let file_id = file_node.id;
54        output.add_node(file_node);
55
56        // Find <script> elements and extract their content
57        let mut cursor = root.walk();
58        for child in root.children(&mut cursor) {
59            if child.kind() == "script_element" {
60                // Find the raw_text child (the JS/TS content)
61                let mut inner = child.walk();
62                for sc in child.children(&mut inner) {
63                    if sc.kind() == "raw_text" {
64                        let script_source = sc.utf8_text(source.as_bytes()).unwrap_or("");
65                        if script_source.trim().is_empty() {
66                            continue;
67                        }
68
69                        // Parse the script content as TypeScript
70                        let ts_frontend = TypeScriptFrontend::new();
71                        if let Ok(ts_output) = ts_frontend.parse(path, script_source) {
72                            let line_offset = sc.start_position().row as u32;
73
74                            // Build old-ID -> new-ID mapping for edge remapping
75                            let mut id_remap =
76                                std::collections::HashMap::<SymbolId, SymbolId>::new();
77
78                            // Merge nodes, adjusting language and spans
79                            for mut node in ts_output.nodes {
80                                if node.kind == NodeKind::File {
81                                    // Skip duplicate file node, link its children to our file
82                                    continue;
83                                }
84                                let old_id = node.id;
85                                node.language = Language::Svelte;
86                                node.span.start_line += line_offset;
87                                node.span.end_line += line_offset;
88                                // Recompute ID with corrected start_line
89                                node.id = SymbolId::new(
90                                    &node.file_path,
91                                    &node.name,
92                                    node.kind,
93                                    node.span.start_line,
94                                );
95                                id_remap.insert(old_id, node.id);
96                                let node_id = node.id;
97                                output.add_node(node);
98                                output.add_edge(
99                                    file_id,
100                                    node_id,
101                                    GirEdge::new(EdgeKind::Contains),
102                                );
103                            }
104
105                            let ts_file_id = SymbolId::new(
106                                path,
107                                path.to_string_lossy().as_ref(),
108                                NodeKind::File,
109                                0,
110                            );
111
112                            // Merge edges, remapping IDs that changed due to offset
113                            for (src, tgt, edge) in ts_output.edges {
114                                // Skip Contains edges from the TS file node
115                                // (we added our own above)
116                                if edge.kind == EdgeKind::Contains && src == ts_file_id {
117                                    continue;
118                                }
119                                // Remap source and target IDs to their offset-corrected versions
120                                let new_src = id_remap.get(&src).copied().unwrap_or(src);
121                                let new_tgt = id_remap.get(&tgt).copied().unwrap_or(tgt);
122                                output.add_edge(new_src, new_tgt, edge);
123                            }
124                        }
125                    }
126                }
127            }
128        }
129
130        Ok(output)
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use graphy_core::NodeKind;
138
139    #[test]
140    fn parse_svelte_component() {
141        let source = r#"<script>
142    function greet(name) {
143        return "Hello, " + name;
144    }
145
146    const message = greet("World");
147</script>
148
149<h1>{message}</h1>
150"#;
151        let output = SvelteFrontend::new()
152            .parse(Path::new("App.svelte"), source)
153            .unwrap();
154
155        // File node present
156        assert!(output.nodes.iter().any(|n| n.kind == NodeKind::File));
157
158        // Function extracted
159        let funcs: Vec<_> = output
160            .nodes
161            .iter()
162            .filter(|n| n.kind == NodeKind::Function)
163            .collect();
164        assert!(funcs.iter().any(|f| f.name == "greet"));
165
166        // Language is Svelte
167        for node in &output.nodes {
168            assert_eq!(node.language, Language::Svelte);
169        }
170    }
171
172    #[test]
173    fn parse_svelte_with_typescript() {
174        let source = r#"<script>
175    export function add(a, b) {
176        return a + b;
177    }
178
179    export function multiply(a, b) {
180        return a * b;
181    }
182</script>
183
184<div>
185    <p>{add(2, 3)}</p>
186</div>
187"#;
188        let output = SvelteFrontend::new()
189            .parse(Path::new("Math.svelte"), source)
190            .unwrap();
191
192        let funcs: Vec<_> = output
193            .nodes
194            .iter()
195            .filter(|n| n.kind == NodeKind::Function)
196            .collect();
197        assert!(funcs.len() >= 2);
198        assert!(funcs.iter().any(|f| f.name == "add"));
199        assert!(funcs.iter().any(|f| f.name == "multiply"));
200    }
201
202    #[test]
203    fn parse_svelte_empty_script() {
204        let source = r#"<script>
205</script>
206
207<h1>Hello</h1>
208"#;
209        let output = SvelteFrontend::new()
210            .parse(Path::new("Empty.svelte"), source)
211            .unwrap();
212
213        // Just the file node
214        assert_eq!(
215            output.nodes.iter().filter(|n| n.kind == NodeKind::File).count(),
216            1
217        );
218    }
219
220    // ── Edge case tests ───────────────────────────────────
221
222    #[test]
223    fn parse_svelte_no_script() {
224        // Template-only component with no <script> block
225        let source = "<h1>Hello World</h1>\n<p>No script here</p>\n";
226        let output = SvelteFrontend::new()
227            .parse(Path::new("NoScript.svelte"), source)
228            .unwrap();
229        // Should still have a File node
230        assert!(output.nodes.iter().any(|n| n.kind == NodeKind::File));
231        // No functions or classes
232        let code_nodes: Vec<_> = output.nodes.iter()
233            .filter(|n| n.kind == NodeKind::Function || n.kind == NodeKind::Class)
234            .collect();
235        assert!(code_nodes.is_empty());
236    }
237
238    #[test]
239    fn parse_svelte_module_context() {
240        let source = r#"<script context="module">
241    export const API_URL = "https://example.com";
242</script>
243
244<script>
245    function handleClick() {
246        console.log("clicked");
247    }
248</script>
249
250<button on:click={handleClick}>Click</button>
251"#;
252        let output = SvelteFrontend::new()
253            .parse(Path::new("Module.svelte"), source)
254            .unwrap();
255        // Should parse at least the handleClick function
256        assert!(output.nodes.iter().any(|n| n.kind == NodeKind::File));
257    }
258
259    #[test]
260    fn parse_svelte_with_imports() {
261        let source = r#"<script>
262    import { onMount } from 'svelte';
263    import Button from './Button.svelte';
264
265    onMount(() => {
266        console.log('mounted');
267    });
268</script>
269
270<Button />
271"#;
272        let output = SvelteFrontend::new()
273            .parse(Path::new("WithImports.svelte"), source)
274            .unwrap();
275        let imports: Vec<_> = output.nodes.iter()
276            .filter(|n| n.kind == NodeKind::Import)
277            .collect();
278        assert!(imports.len() >= 1);
279    }
280
281    #[test]
282    fn parse_svelte_script_context_module_extracts_functions() {
283        // Svelte context="module" script blocks contain code that runs once at module level.
284        // The parser should extract symbols from both module and instance script blocks.
285        let source = r#"<script context="module">
286    export function formatDate(date) {
287        return date.toISOString();
288    }
289</script>
290
291<script>
292    export let date;
293
294    function handleReset() {
295        date = new Date();
296    }
297</script>
298
299<p>{formatDate(date)}</p>
300<button on:click={handleReset}>Reset</button>
301"#;
302        let output = SvelteFrontend::new()
303            .parse(Path::new("DatePicker.svelte"), source)
304            .unwrap();
305
306        // Should have a File node
307        assert!(output.nodes.iter().any(|n| n.kind == NodeKind::File));
308
309        // At least one script block should have its functions extracted.
310        // Both formatDate and handleReset may be found depending on how
311        // the Svelte tree-sitter grammar exposes context="module" blocks.
312        let funcs: Vec<_> = output.nodes.iter()
313            .filter(|n| n.kind == NodeKind::Function)
314            .collect();
315        assert!(!funcs.is_empty(), "Expected at least one function from script blocks");
316
317        // All nodes should be tagged as Svelte language
318        for node in &output.nodes {
319            assert_eq!(node.language, Language::Svelte);
320        }
321    }
322}