Skip to main content

normalize_languages/
svelte.rs

1//! Svelte language support.
2
3use crate::component::extract_embedded_content;
4use crate::external_packages::ResolvedPackage;
5use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
6use std::path::{Path, PathBuf};
7use tree_sitter::Node;
8
9/// Svelte language support.
10pub struct Svelte;
11
12impl Language for Svelte {
13    fn name(&self) -> &'static str {
14        "Svelte"
15    }
16    fn extensions(&self) -> &'static [&'static str] {
17        &["svelte"]
18    }
19    fn grammar_name(&self) -> &'static str {
20        "svelte"
21    }
22
23    fn has_symbols(&self) -> bool {
24        true
25    }
26
27    fn container_kinds(&self) -> &'static [&'static str] {
28        &["script_element", "style_element"]
29    }
30
31    fn function_kinds(&self) -> &'static [&'static str] {
32        &[] // JS functions are in embedded script, not Svelte grammar
33    }
34
35    fn type_kinds(&self) -> &'static [&'static str] {
36        &[]
37    }
38
39    fn import_kinds(&self) -> &'static [&'static str] {
40        &[] // JS imports are in embedded script, not Svelte grammar
41    }
42
43    fn public_symbol_kinds(&self) -> &'static [&'static str] {
44        &[] // JS exports are in embedded script, not Svelte grammar
45    }
46
47    fn visibility_mechanism(&self) -> VisibilityMechanism {
48        VisibilityMechanism::ExplicitExport
49    }
50
51    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
52        // Look for export let/const/function
53        let text = &content[node.byte_range()];
54
55        if node.kind() == "export_statement" || text.contains("export ") {
56            if let Some(name) = self.node_name(node, content) {
57                let kind = if text.contains("function") {
58                    SymbolKind::Function
59                } else {
60                    SymbolKind::Variable
61                };
62
63                return vec![Export {
64                    name: name.to_string(),
65                    kind,
66                    line: node.start_position().row + 1,
67                }];
68            }
69        }
70
71        Vec::new()
72    }
73
74    fn scope_creating_kinds(&self) -> &'static [&'static str] {
75        &["if_statement", "each_statement", "await_statement"]
76    }
77
78    fn control_flow_kinds(&self) -> &'static [&'static str] {
79        &["if_statement", "each_statement", "await_statement"]
80    }
81
82    fn complexity_nodes(&self) -> &'static [&'static str] {
83        &["if_statement", "each_statement", "else_if_block"]
84    }
85
86    fn nesting_nodes(&self) -> &'static [&'static str] {
87        &[
88            "if_statement",
89            "each_statement",
90            "await_statement",
91            "script_element",
92        ]
93    }
94
95    fn signature_suffix(&self) -> &'static str {
96        ""
97    }
98
99    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
100        let name = self.node_name(node, content)?;
101        let text = &content[node.byte_range()];
102        let first_line = text.lines().next().unwrap_or(text);
103
104        Some(Symbol {
105            name: name.to_string(),
106            kind: SymbolKind::Function,
107            signature: first_line.trim().to_string(),
108            docstring: self.extract_docstring(node, content),
109            attributes: Vec::new(),
110            start_line: node.start_position().row + 1,
111            end_line: node.end_position().row + 1,
112            visibility: self.get_visibility(node, content),
113            children: Vec::new(),
114            is_interface_impl: false,
115            implements: Vec::new(),
116        })
117    }
118
119    fn extract_container(&self, node: &Node, _content: &str) -> Option<Symbol> {
120        let kind = match node.kind() {
121            "script_element" => SymbolKind::Module,
122            "style_element" => SymbolKind::Class,
123            _ => return None,
124        };
125
126        let name = if node.kind() == "script_element" {
127            "<script>".to_string()
128        } else {
129            "<style>".to_string()
130        };
131
132        Some(Symbol {
133            name: name.clone(),
134            kind,
135            signature: name,
136            docstring: None,
137            attributes: Vec::new(),
138            start_line: node.start_position().row + 1,
139            end_line: node.end_position().row + 1,
140            visibility: Visibility::Public,
141            children: Vec::new(),
142            is_interface_impl: false,
143            implements: Vec::new(),
144        })
145    }
146
147    fn extract_type(&self, _node: &Node, _content: &str) -> Option<Symbol> {
148        None
149    }
150
151    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
152        // JavaScript-style comments
153        let mut prev = node.prev_sibling();
154        let mut doc_lines = Vec::new();
155
156        while let Some(sibling) = prev {
157            let text = &content[sibling.byte_range()];
158            if sibling.kind() == "comment" {
159                if text.starts_with("/**") {
160                    let inner = text
161                        .strip_prefix("/**")
162                        .unwrap_or(text)
163                        .strip_suffix("*/")
164                        .unwrap_or(text);
165                    let lines: Vec<&str> = inner
166                        .lines()
167                        .map(|l| l.trim().trim_start_matches('*').trim())
168                        .filter(|l| !l.is_empty() && !l.starts_with('@'))
169                        .collect();
170                    if !lines.is_empty() {
171                        return Some(lines.join(" "));
172                    }
173                } else if text.starts_with("//") {
174                    doc_lines.push(text.strip_prefix("//").unwrap_or(text).trim().to_string());
175                }
176                prev = sibling.prev_sibling();
177            } else {
178                break;
179            }
180        }
181
182        if doc_lines.is_empty() {
183            return None;
184        }
185
186        doc_lines.reverse();
187        Some(doc_lines.join(" "))
188    }
189
190    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
191        Vec::new()
192    }
193
194    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
195        if node.kind() != "import_statement" {
196            return Vec::new();
197        }
198
199        let text = &content[node.byte_range()];
200        let line = node.start_position().row + 1;
201
202        // Extract from clause
203        if let Some(from_idx) = text.find(" from ") {
204            let rest = &text[from_idx + 6..];
205            if let Some(start) = rest.find('"').or_else(|| rest.find('\'')) {
206                let quote = rest.chars().nth(start).unwrap();
207                let inner = &rest[start + 1..];
208                if let Some(end) = inner.find(quote) {
209                    let module = inner[..end].to_string();
210                    return vec![Import {
211                        module: module.clone(),
212                        names: Vec::new(),
213                        alias: None,
214                        is_wildcard: text.contains(" * "),
215                        is_relative: module.starts_with('.'),
216                        line,
217                    }];
218                }
219            }
220        }
221
222        Vec::new()
223    }
224
225    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
226        // Svelte uses JS import syntax
227        let names_to_use: Vec<&str> = names
228            .map(|n| n.to_vec())
229            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
230        if names_to_use.is_empty() {
231            format!("import '{}';", import.module)
232        } else {
233            format!(
234                "import {{ {} }} from '{}';",
235                names_to_use.join(", "),
236                import.module
237            )
238        }
239    }
240
241    fn is_public(&self, node: &Node, content: &str) -> bool {
242        let text = &content[node.byte_range()];
243        text.contains("export ")
244    }
245
246    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
247        if self.is_public(node, content) {
248            Visibility::Public
249        } else {
250            Visibility::Private
251        }
252    }
253
254    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
255        let name = symbol.name.as_str();
256        match symbol.kind {
257            crate::SymbolKind::Function | crate::SymbolKind::Method => {
258                name.starts_with("test_")
259                    || name.starts_with("Test")
260                    || name == "describe"
261                    || name == "it"
262                    || name == "test"
263            }
264            crate::SymbolKind::Module => name == "tests" || name == "test" || name == "__tests__",
265            _ => false,
266        }
267    }
268
269    fn embedded_content(&self, node: &Node, content: &str) -> Option<crate::EmbeddedBlock> {
270        extract_embedded_content(node, content)
271    }
272
273    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
274        // Find the content of script/style elements
275        let mut cursor = node.walk();
276        for child in node.children(&mut cursor) {
277            if child.kind() == "raw_text" {
278                return Some(child);
279            }
280        }
281        None
282    }
283
284    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
285        false
286    }
287
288    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
289        node.child_by_field_name("name")
290            .or_else(|| node.child_by_field_name("function"))
291            .map(|n| &content[n.byte_range()])
292    }
293
294    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
295        let ext = path.extension()?.to_str()?;
296        if ext != "svelte" {
297            return None;
298        }
299        let stem = path.file_stem()?.to_str()?;
300        Some(stem.to_string())
301    }
302
303    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
304        vec![
305            format!("{}.svelte", module),
306            format!("src/lib/{}.svelte", module),
307            format!("src/routes/{}.svelte", module),
308        ]
309    }
310
311    fn lang_key(&self) -> &'static str {
312        "svelte"
313    }
314
315    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
316        import_name == "svelte"
317            || import_name.starts_with("svelte/")
318            || import_name.starts_with("$app/")
319            || import_name.starts_with("$lib/")
320    }
321
322    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
323        None
324    }
325
326    fn resolve_local_import(
327        &self,
328        import: &str,
329        current_file: &Path,
330        project_root: &Path,
331    ) -> Option<PathBuf> {
332        // Handle relative imports
333        if import.starts_with('.') {
334            if let Some(dir) = current_file.parent() {
335                let candidates = [
336                    import.to_string(),
337                    format!("{}.svelte", import),
338                    format!("{}/index.svelte", import),
339                ];
340                for c in &candidates {
341                    let full = dir.join(c);
342                    if full.is_file() {
343                        return Some(full);
344                    }
345                }
346            }
347        }
348
349        // Handle $lib alias (SvelteKit convention)
350        if import.starts_with("$lib/") {
351            let rest = import.strip_prefix("$lib/")?;
352            let lib_dir = project_root.join("src/lib");
353            let candidates = [
354                rest.to_string(),
355                format!("{}.svelte", rest),
356                format!("{}.js", rest),
357                format!("{}.ts", rest),
358            ];
359            for c in &candidates {
360                let full = lib_dir.join(c);
361                if full.is_file() {
362                    return Some(full);
363                }
364            }
365        }
366
367        None
368    }
369
370    fn resolve_external_import(
371        &self,
372        _import_name: &str,
373        _project_root: &Path,
374    ) -> Option<ResolvedPackage> {
375        // npm package resolution would go here
376        None
377    }
378
379    fn get_version(&self, project_root: &Path) -> Option<String> {
380        let pkg_json = project_root.join("package.json");
381        if pkg_json.is_file() {
382            if let Ok(content) = std::fs::read_to_string(&pkg_json) {
383                // Look for svelte version in dependencies
384                if let Some(idx) = content.find("\"svelte\"") {
385                    let rest = &content[idx..];
386                    if let Some(colon) = rest.find(':') {
387                        let after = rest[colon + 1..].trim();
388                        if let Some(start) = after.find('"') {
389                            let inner = &after[start + 1..];
390                            if let Some(end) = inner.find('"') {
391                                return Some(inner[..end].to_string());
392                            }
393                        }
394                    }
395                }
396            }
397        }
398        None
399    }
400
401    fn find_package_cache(&self, project_root: &Path) -> Option<PathBuf> {
402        let node_modules = project_root.join("node_modules");
403        if node_modules.is_dir() {
404            return Some(node_modules);
405        }
406        None
407    }
408
409    fn indexable_extensions(&self) -> &'static [&'static str] {
410        &["svelte"]
411    }
412    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
413        Vec::new()
414    }
415
416    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
417        use crate::traits::{has_extension, skip_dotfiles};
418        if skip_dotfiles(name) {
419            return true;
420        }
421        if is_dir && (name == "node_modules" || name == ".svelte-kit" || name == "build") {
422            return true;
423        }
424        !is_dir && !has_extension(name, self.indexable_extensions())
425    }
426
427    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
428        Vec::new()
429    }
430
431    fn package_module_name(&self, entry_name: &str) -> String {
432        entry_name
433            .strip_suffix(".svelte")
434            .unwrap_or(entry_name)
435            .to_string()
436    }
437
438    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
439        if path.is_file() {
440            Some(path.to_path_buf())
441        } else {
442            None
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::validate_unused_kinds_audit;
451
452    #[test]
453    fn unused_node_kinds_audit() {
454        // Run cross_check_node_kinds to populate
455        #[rustfmt::skip]
456        let documented_unused: &[&str] = &[
457            "await_end", "await_start", "block_end_tag", "block_start_tag",
458            "block_tag", "catch_block", "catch_start", "doctype", "else_block",
459            "else_if_start", "else_start", "expression", "expression_tag",
460            "if_end", "if_start", "key_statement", "snippet_statement", "then_block",
461        ];
462        validate_unused_kinds_audit(&Svelte, documented_unused)
463            .expect("Svelte unused node kinds audit failed");
464    }
465}