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 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 let mut cursor = root.walk();
58 for child in root.children(&mut cursor) {
59 if child.kind() == "script_element" {
60 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 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 let mut id_remap =
76 std::collections::HashMap::<SymbolId, SymbolId>::new();
77
78 for mut node in ts_output.nodes {
80 if node.kind == NodeKind::File {
81 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 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 for (src, tgt, edge) in ts_output.edges {
114 if edge.kind == EdgeKind::Contains && src == ts_file_id {
117 continue;
118 }
119 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 assert!(output.nodes.iter().any(|n| n.kind == NodeKind::File));
157
158 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 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 assert_eq!(
215 output.nodes.iter().filter(|n| n.kind == NodeKind::File).count(),
216 1
217 );
218 }
219
220 #[test]
223 fn parse_svelte_no_script() {
224 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 assert!(output.nodes.iter().any(|n| n.kind == NodeKind::File));
231 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 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 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 assert!(output.nodes.iter().any(|n| n.kind == NodeKind::File));
308
309 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 for node in &output.nodes {
319 assert_eq!(node.language, Language::Svelte);
320 }
321 }
322}