Skip to main content

sem_core/parser/plugins/
svelte.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::fmt;
4use std::path::Path;
5
6use tree_sitter::{Node as TsNode, Parser};
7
8use crate::model::entity::{build_entity_id, SemanticEntity};
9use crate::parser::plugin::SemanticParserPlugin;
10use crate::utils::hash::{content_hash, structural_hash};
11
12use super::code::CodeParserPlugin;
13
14const SVELTE_KIND_KEY: &str = "svelte.kind";
15const SVELTE_CONTEXT_KEY: &str = "svelte.context";
16const SVELTE_LANG_KEY: &str = "svelte.lang";
17
18thread_local! {
19    static SVELTE_PARSER: RefCell<Parser> = RefCell::new({
20        let mut parser = Parser::new();
21        parser
22            .set_language(&tree_sitter_htmlx_svelte::language())
23            .expect("failed to load Svelte grammar");
24        parser
25    });
26}
27
28pub struct SvelteParserPlugin;
29
30impl SemanticParserPlugin for SvelteParserPlugin {
31    fn id(&self) -> &str {
32        "svelte"
33    }
34
35    fn extensions(&self) -> &[&str] {
36        &[
37            ".svelte",
38            ".svelte.js",
39            ".svelte.ts",
40            ".svelte.test.js",
41            ".svelte.test.ts",
42            ".svelte.spec.js",
43            ".svelte.spec.ts",
44        ]
45    }
46
47    fn extract_entities(&self, content: &str, file_path: &str) -> Vec<SemanticEntity> {
48        match classify_svelte_file(file_path) {
49            Some(SvelteFileKind::Module { lang }) => {
50                return extract_svelte_module_entities(content, file_path, lang);
51            }
52            Some(SvelteFileKind::Component) => {}
53            None => return Vec::new(),
54        }
55
56        let tree = match SVELTE_PARSER
57            .with(|parser| parser.borrow_mut().parse(content.as_bytes(), None))
58        {
59            Some(tree) => tree,
60            None => return Vec::new(),
61        };
62
63        let root = tree.root_node();
64        SvelteLowerer::new(content, file_path).lower_document(root)
65    }
66}
67
68#[derive(Clone, Copy, Eq, PartialEq)]
69enum ScriptBlockContext {
70    Default,
71    Module,
72}
73
74#[derive(Clone, Copy, Eq, PartialEq)]
75enum ScriptLanguage {
76    JavaScript,
77    TypeScript,
78}
79
80impl ScriptLanguage {
81    fn from_attr(lang: Option<&str>) -> Self {
82        match lang {
83            Some(lang)
84                if lang.eq_ignore_ascii_case("ts")
85                    || lang.eq_ignore_ascii_case("tsx")
86                    || lang.eq_ignore_ascii_case("typescript") =>
87            {
88                Self::TypeScript
89            }
90            _ => Self::JavaScript,
91        }
92    }
93
94    fn from_svelte_module_path(file_path: &str) -> Self {
95        if ends_with_ignore_ascii_case(file_path, ".svelte.ts")
96            || ends_with_ignore_ascii_case(file_path, ".svelte.test.ts")
97            || ends_with_ignore_ascii_case(file_path, ".svelte.spec.ts")
98        {
99            Self::TypeScript
100        } else {
101            Self::JavaScript
102        }
103    }
104
105    fn metadata_value(self) -> &'static str {
106        match self {
107            Self::JavaScript => "js",
108            Self::TypeScript => "ts",
109        }
110    }
111
112    fn virtual_script_extension(self) -> &'static str {
113        match self {
114            Self::JavaScript => "script.js",
115            Self::TypeScript => "script.ts",
116        }
117    }
118}
119
120#[derive(Clone, Copy, Eq, PartialEq)]
121enum SvelteFileKind {
122    Component,
123    Module { lang: ScriptLanguage },
124}
125
126#[derive(Clone, Copy)]
127enum SvelteEntityKind {
128    ModuleFile,
129    InstanceScript,
130    ModuleScript,
131    Style,
132    Fragment,
133    Element,
134    Snippet,
135    IfBlock,
136    EachBlock,
137    KeyBlock,
138    AwaitBlock,
139    Component,
140    SlotElement,
141    HeadElement,
142    BodyElement,
143    WindowElement,
144    DocumentElement,
145    DynamicComponentElement,
146    DynamicElementElement,
147    SelfElement,
148    FragmentElement,
149    BoundaryElement,
150    OptionsElement,
151    TitleElement,
152}
153
154impl fmt::Display for SvelteEntityKind {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.write_str(self.as_str())
157    }
158}
159
160impl SvelteEntityKind {
161    fn as_str(self) -> &'static str {
162        match self {
163            Self::ModuleFile => "svelte_module",
164            Self::InstanceScript => "svelte_instance_script",
165            Self::ModuleScript => "svelte_module_script",
166            Self::Style => "svelte_style",
167            Self::Fragment => "svelte_fragment",
168            Self::Element => "svelte_element",
169            Self::Snippet => "svelte_snippet",
170            Self::IfBlock => "svelte_if_block",
171            Self::EachBlock => "svelte_each_block",
172            Self::KeyBlock => "svelte_key_block",
173            Self::AwaitBlock => "svelte_await_block",
174            Self::Component => "svelte_component",
175            Self::SlotElement => "svelte_slot_element",
176            Self::HeadElement => "svelte_head",
177            Self::BodyElement => "svelte_body",
178            Self::WindowElement => "svelte_window",
179            Self::DocumentElement => "svelte_document",
180            Self::DynamicComponentElement => "svelte_component_dynamic",
181            Self::DynamicElementElement => "svelte_element_dynamic",
182            Self::SelfElement => "svelte_self",
183            Self::FragmentElement => "svelte_fragment_element",
184            Self::BoundaryElement => "svelte_boundary",
185            Self::OptionsElement => "svelte_options",
186            Self::TitleElement => "svelte_title_element",
187        }
188    }
189
190    fn metadata_kind(self) -> &'static str {
191        match self {
192            Self::ModuleFile => "module",
193            Self::InstanceScript => "instance_script",
194            Self::ModuleScript => "module_script",
195            Self::Style => "style",
196            Self::Fragment => "fragment",
197            Self::Element => "element",
198            Self::Snippet => "snippet",
199            Self::IfBlock => "if",
200            Self::EachBlock => "each",
201            Self::KeyBlock => "key",
202            Self::AwaitBlock => "await",
203            Self::Component => "component",
204            Self::SlotElement => "slot",
205            Self::HeadElement => "head",
206            Self::BodyElement => "body",
207            Self::WindowElement => "window",
208            Self::DocumentElement => "document",
209            Self::DynamicComponentElement => "dynamic_component",
210            Self::DynamicElementElement => "dynamic_element",
211            Self::SelfElement => "self",
212            Self::FragmentElement => "fragment_element",
213            Self::BoundaryElement => "boundary",
214            Self::OptionsElement => "options",
215            Self::TitleElement => "title_element",
216        }
217    }
218}
219
220struct ReparentContext<'a> {
221    file_path: &'a str,
222    parent_id: &'a str,
223    start_line_offset: usize,
224}
225
226struct SvelteLowerer<'a> {
227    source: &'a str,
228    source_bytes: &'a [u8],
229    file_path: &'a str,
230    entities: Vec<SemanticEntity>,
231}
232
233impl<'a> SvelteLowerer<'a> {
234    fn new(source: &'a str, file_path: &'a str) -> Self {
235        Self {
236            source,
237            source_bytes: source.as_bytes(),
238            file_path,
239            entities: Vec::new(),
240        }
241    }
242
243    fn lower_document(mut self, root: TsNode<'_>) -> Vec<SemanticEntity> {
244        if root.kind() != "document" {
245            return Vec::new();
246        }
247
248        let mut script_counts = HashMap::<&'static str, usize>::new();
249        let mut style_counts = HashMap::<&'static str, usize>::new();
250        let mut fragment_nodes = Vec::new();
251        let mut cursor = root.walk();
252
253        for node in root.named_children(&mut cursor) {
254            match self.top_level_node_kind(node) {
255                TopLevelNodeKind::Script => {
256                    let context = self.script_context(node);
257                    let base_name = match context {
258                        ScriptBlockContext::Default => "script",
259                        ScriptBlockContext::Module => "script module",
260                    };
261                    let name = disambiguate_name(base_name, &mut script_counts);
262                    self.lower_script(node, name, context);
263                }
264                TopLevelNodeKind::Style => {
265                    let name = disambiguate_name("style", &mut style_counts);
266                    self.lower_style(node, name);
267                }
268                TopLevelNodeKind::Other => fragment_nodes.push(node),
269            }
270        }
271
272        if let Some(fragment_id) = self.lower_fragment_entity(&fragment_nodes, None, "fragment") {
273            for node in fragment_nodes {
274                self.lower_node(node, &fragment_id);
275            }
276        }
277
278        self.entities
279    }
280
281    fn lower_script(&mut self, node: TsNode<'_>, name: String, context: ScriptBlockContext) {
282        let kind = match context {
283            ScriptBlockContext::Default => SvelteEntityKind::InstanceScript,
284            ScriptBlockContext::Module => SvelteEntityKind::ModuleScript,
285        };
286
287        let mut metadata = base_metadata(kind);
288        metadata.insert(
289            SVELTE_CONTEXT_KEY.to_string(),
290            match context {
291                ScriptBlockContext::Default => "default".to_string(),
292                ScriptBlockContext::Module => "module".to_string(),
293            },
294        );
295
296        let lang = ScriptLanguage::from_attr(self.element_attribute_value(node, "lang"));
297        metadata.insert(
298            SVELTE_LANG_KEY.to_string(),
299            lang.metadata_value().to_string(),
300        );
301
302        let entity = self.make_entity(
303            kind,
304            name,
305            None,
306            node,
307            Some(structural_hash(node, self.source_bytes)),
308            Some(metadata),
309        );
310        let block_id = entity.id.clone();
311
312        self.entities.push(entity);
313
314        let Some(raw_text) = element_raw_text_node(node) else {
315            return;
316        };
317
318        let inner_content = text_for_node(self.source, raw_text).unwrap_or_default();
319        if !inner_content.trim().is_empty() {
320            let virtual_path = script_virtual_path(self.file_path, lang);
321            let code_plugin = CodeParserPlugin;
322            let inner = code_plugin.extract_entities(inner_content, &virtual_path);
323            self.reparent_entities(
324                inner,
325                ReparentContext {
326                    file_path: self.file_path,
327                    parent_id: &block_id,
328                    start_line_offset: self.node_start_line(raw_text) - 1,
329                },
330            );
331        }
332    }
333
334    fn lower_style(&mut self, node: TsNode<'_>, name: String) {
335        let entity = self.make_entity(
336            SvelteEntityKind::Style,
337            name,
338            None,
339            node,
340            Some(structural_hash(node, self.source_bytes)),
341            Some(base_metadata(SvelteEntityKind::Style)),
342        );
343        self.entities.push(entity);
344    }
345
346    fn lower_fragment_entity<'tree>(
347        &mut self,
348        nodes: &[TsNode<'tree>],
349        parent_id: Option<String>,
350        name: &str,
351    ) -> Option<String> {
352        if !nodes
353            .iter()
354            .any(|node| self.is_substantive_fragment_node(*node))
355        {
356            return None;
357        }
358
359        let first = *nodes.first()?;
360        let last = *nodes.last()?;
361        let entity = self.make_ranged_entity(
362            SvelteEntityKind::Fragment,
363            name.to_string(),
364            parent_id,
365            first.start_byte(),
366            last.end_byte(),
367            self.node_start_line(first),
368            self.node_end_line(last),
369            self.fragment_structural_hash(nodes),
370            Some(base_metadata(SvelteEntityKind::Fragment)),
371        );
372        let id = entity.id.clone();
373        self.entities.push(entity);
374        Some(id)
375    }
376
377    fn lower_markup_children(&mut self, node: TsNode<'_>, parent_id: &str) {
378        let mut cursor = node.walk();
379        for child in node.named_children(&mut cursor) {
380            if is_semantic_child(child) {
381                self.lower_node(child, parent_id);
382            }
383        }
384    }
385
386    fn reparent_entities(&mut self, entities: Vec<SemanticEntity>, context: ReparentContext<'_>) {
387        let parent_id = context.parent_id.to_string();
388        for mut entity in entities {
389            entity.file_path.clear();
390            entity.file_path.push_str(context.file_path);
391            entity.parent_id = Some(parent_id.clone());
392            entity.start_line += context.start_line_offset;
393            entity.end_line += context.start_line_offset;
394            entity.id = build_entity_id(
395                context.file_path,
396                &entity.entity_type,
397                &entity.name,
398                Some(context.parent_id),
399            );
400            self.entities.push(entity);
401        }
402    }
403
404    fn lower_node(&mut self, node: TsNode<'_>, parent_id: &str) {
405        match node.kind() {
406            "if_block" => self.lower_if_block(node, parent_id),
407            "each_block" => self.lower_each_block(node, parent_id),
408            "key_block" => self.lower_key_block(node, parent_id),
409            "await_block" => self.lower_await_block(node, parent_id),
410            "snippet_block" => self.lower_snippet_block(node, parent_id),
411            "element" => self.lower_element(node, parent_id),
412            _ => {}
413        }
414    }
415
416    fn lower_if_block(&mut self, node: TsNode<'_>, parent_id: &str) {
417        let id = self.push_node_entity(
418            SvelteEntityKind::IfBlock,
419            self.line_named("if", node),
420            parent_id,
421            node,
422        );
423
424        let mut else_ifs = Vec::new();
425        let mut else_clause = None;
426        let mut cursor = node.walk();
427        for child in node.named_children(&mut cursor) {
428            if is_semantic_child(child) {
429                self.lower_node(child, &id);
430            }
431
432            match child.kind() {
433                "else_if_clause" => else_ifs.push(child),
434                "else_clause" => else_clause = Some(child),
435                _ => {}
436            }
437        }
438        self.lower_else_if_chain(&else_ifs, else_clause, &id);
439    }
440
441    fn lower_else_if_chain<'tree>(
442        &mut self,
443        clauses: &[TsNode<'tree>],
444        else_clause: Option<TsNode<'tree>>,
445        parent_id: &str,
446    ) {
447        if let Some((first, rest)) = clauses.split_first() {
448            let entity = self.make_entity(
449                SvelteEntityKind::IfBlock,
450                self.line_named("if", *first),
451                Some(parent_id.to_string()),
452                *first,
453                Some(structural_hash(*first, self.source_bytes)),
454                Some(base_metadata(SvelteEntityKind::IfBlock)),
455            );
456            let id = entity.id.clone();
457            self.entities.push(entity);
458
459            self.lower_markup_children(*first, &id);
460            self.lower_else_if_chain(rest, else_clause, &id);
461        } else if let Some(else_clause) = else_clause {
462            self.lower_markup_children(else_clause, parent_id);
463        }
464    }
465
466    fn lower_each_block(&mut self, node: TsNode<'_>, parent_id: &str) {
467        let id = self.push_node_entity(
468            SvelteEntityKind::EachBlock,
469            self.line_named("each", node),
470            parent_id,
471            node,
472        );
473
474        let mut cursor = node.walk();
475        for child in node.named_children(&mut cursor) {
476            if is_semantic_child(child) {
477                self.lower_node(child, &id);
478            }
479
480            if child.kind() == "else_clause" {
481                self.lower_markup_children(child, &id);
482                break;
483            }
484        }
485    }
486
487    fn lower_key_block(&mut self, node: TsNode<'_>, parent_id: &str) {
488        self.lower_container_node(SvelteEntityKind::KeyBlock, "key", node, parent_id);
489    }
490
491    fn lower_await_block(&mut self, node: TsNode<'_>, parent_id: &str) {
492        let id = self.push_node_entity(
493            SvelteEntityKind::AwaitBlock,
494            self.line_named("await", node),
495            parent_id,
496            node,
497        );
498
499        if let Some(pending) = node.child_by_field_name("pending") {
500            self.lower_markup_children(pending, &id);
501        }
502        if let Some(shorthand_children) = node.child_by_field_name("shorthand_children") {
503            self.lower_markup_children(shorthand_children, &id);
504        }
505
506        let mut cursor = node.walk();
507        for branch in node.named_children(&mut cursor) {
508            if branch.kind() != "await_branch" {
509                continue;
510            }
511            if let Some(children) = branch.child_by_field_name("children") {
512                self.lower_markup_children(children, &id);
513            }
514        }
515    }
516
517    fn lower_snippet_block(&mut self, node: TsNode<'_>, parent_id: &str) {
518        self.lower_container_node(SvelteEntityKind::Snippet, "snippet", node, parent_id);
519    }
520
521    fn lower_element(&mut self, node: TsNode<'_>, parent_id: &str) {
522        let Some(tag_name) = self.element_tag_name(node) else {
523            return;
524        };
525
526        match classify_element_kind(tag_name) {
527            ElementLowering::Ignore => self.lower_markup_children(node, parent_id),
528            ElementLowering::Kind(kind) => {
529                let id =
530                    self.push_node_entity(kind, self.line_named(tag_name, node), parent_id, node);
531                self.lower_markup_children(node, &id);
532            }
533        }
534    }
535
536    fn push_node_entity(
537        &mut self,
538        kind: SvelteEntityKind,
539        name: String,
540        parent_id: &str,
541        node: TsNode<'_>,
542    ) -> String {
543        let entity = self.make_entity(
544            kind,
545            name,
546            Some(parent_id.to_string()),
547            node,
548            Some(structural_hash(node, self.source_bytes)),
549            Some(base_metadata(kind)),
550        );
551        let id = entity.id.clone();
552        self.entities.push(entity);
553        id
554    }
555
556    fn lower_container_node(
557        &mut self,
558        kind: SvelteEntityKind,
559        label: &'static str,
560        node: TsNode<'_>,
561        parent_id: &str,
562    ) {
563        let id = self.push_node_entity(kind, self.line_named(label, node), parent_id, node);
564        self.lower_markup_children(node, &id);
565    }
566
567    fn make_entity(
568        &self,
569        kind: SvelteEntityKind,
570        name: String,
571        parent_id: Option<String>,
572        node: TsNode<'_>,
573        structural_hash: Option<String>,
574        metadata: Option<HashMap<String, String>>,
575    ) -> SemanticEntity {
576        self.make_ranged_entity(
577            kind,
578            name,
579            parent_id,
580            node.start_byte(),
581            node.end_byte(),
582            self.node_start_line(node),
583            self.node_end_line(node),
584            structural_hash,
585            metadata,
586        )
587    }
588
589    fn make_ranged_entity(
590        &self,
591        kind: SvelteEntityKind,
592        name: String,
593        parent_id: Option<String>,
594        start: usize,
595        end: usize,
596        start_line: usize,
597        end_line: usize,
598        structural_hash: Option<String>,
599        metadata: Option<HashMap<String, String>>,
600    ) -> SemanticEntity {
601        let entity_type = kind.as_str().to_string();
602        let content = text_for_byte_range(self.source, start, end).to_string();
603        SemanticEntity {
604            id: build_entity_id(self.file_path, &entity_type, &name, parent_id.as_deref()),
605            file_path: self.file_path.to_string(),
606            entity_type,
607            name,
608            parent_id,
609            content_hash: content_hash(&content),
610            structural_hash,
611            content,
612            start_line,
613            end_line,
614            metadata,
615        }
616    }
617
618    fn node_start_line(&self, node: TsNode<'_>) -> usize {
619        node.start_position().row + 1
620    }
621
622    fn node_end_line(&self, node: TsNode<'_>) -> usize {
623        let end = node.end_byte();
624        if end <= node.start_byte() {
625            return self.node_start_line(node);
626        }
627
628        let end_position = node.end_position();
629        if self.source_bytes.get(end - 1) == Some(&b'\n') {
630            end_position.row
631        } else {
632            end_position.row + 1
633        }
634    }
635
636    fn line_named(&self, prefix: &str, node: TsNode<'_>) -> String {
637        format!("{prefix}@{}", self.node_start_line(node))
638    }
639
640    fn fragment_structural_hash<'tree>(&self, nodes: &[TsNode<'tree>]) -> Option<String> {
641        let mut parts = Vec::new();
642
643        for node in nodes {
644            if let Some(hash) = self.node_structural_hash(*node) {
645                parts.push(hash);
646            }
647        }
648
649        if parts.is_empty() {
650            None
651        } else {
652            Some(content_hash(&format!("fragment:{}", parts.join("|"))))
653        }
654    }
655
656    fn node_structural_hash(&self, node: TsNode<'_>) -> Option<String> {
657        match node.kind() {
658            "comment" | "line_comment" | "block_comment" | "tag_comment" => None,
659            "text" => {
660                let normalized =
661                    normalize_text(text_for_node(self.source, node).unwrap_or_default());
662                if normalized.is_empty() {
663                    None
664                } else {
665                    Some(content_hash(&format!("text:{normalized}")))
666                }
667            }
668            _ => Some(structural_hash(node, self.source_bytes)),
669        }
670    }
671
672    fn is_substantive_fragment_node(&self, node: TsNode<'_>) -> bool {
673        match node.kind() {
674            "comment" | "line_comment" | "block_comment" | "tag_comment" => false,
675            "text" => {
676                !normalize_text(text_for_node(self.source, node).unwrap_or_default()).is_empty()
677            }
678            _ => true,
679        }
680    }
681
682    fn element_tag_name<'tree>(&self, node: TsNode<'tree>) -> Option<&'a str> {
683        let tag = element_tag_node(node)?;
684        let name = tag.child_by_field_name("name")?;
685        text_for_node(self.source, name)
686    }
687
688    fn element_attribute_value<'tree>(&self, node: TsNode<'tree>, attr: &str) -> Option<&'a str> {
689        let tag = element_tag_node(node)?;
690        tag_attribute_value(tag, attr, self.source)
691    }
692
693    fn element_has_attribute(&self, node: TsNode<'_>, attr: &str) -> bool {
694        let Some(tag) = element_tag_node(node) else {
695            return false;
696        };
697
698        tag_has_attribute(tag, attr, self.source)
699    }
700
701    fn script_context(&self, node: TsNode<'_>) -> ScriptBlockContext {
702        if self
703            .element_attribute_value(node, "context")
704            .map(|value| value.eq_ignore_ascii_case("module"))
705            .unwrap_or(false)
706            || self.element_has_attribute(node, "module")
707        {
708            ScriptBlockContext::Module
709        } else {
710            ScriptBlockContext::Default
711        }
712    }
713
714    fn top_level_node_kind(&self, node: TsNode<'_>) -> TopLevelNodeKind {
715        if node.kind() != "element" {
716            return TopLevelNodeKind::Other;
717        }
718
719        match self.element_tag_name(node) {
720            Some(name) if name.eq_ignore_ascii_case("script") => TopLevelNodeKind::Script,
721            Some(name) if name.eq_ignore_ascii_case("style") => TopLevelNodeKind::Style,
722            _ => TopLevelNodeKind::Other,
723        }
724    }
725}
726
727fn extract_svelte_module_entities(
728    content: &str,
729    file_path: &str,
730    lang: ScriptLanguage,
731) -> Vec<SemanticEntity> {
732    let mut metadata = base_metadata(SvelteEntityKind::ModuleFile);
733    metadata.insert(
734        SVELTE_LANG_KEY.to_string(),
735        lang.metadata_value().to_string(),
736    );
737
738    let entity_type = SvelteEntityKind::ModuleFile.as_str().to_string();
739    let module_entity = SemanticEntity {
740        id: build_entity_id(file_path, &entity_type, "module", None),
741        file_path: file_path.to_string(),
742        entity_type,
743        name: "module".to_string(),
744        parent_id: None,
745        content_hash: content_hash(content),
746        structural_hash: None,
747        content: content.to_string(),
748        start_line: 1,
749        end_line: last_line_number(content),
750        metadata: Some(metadata),
751    };
752
753    let module_id = module_entity.id.clone();
754    let code_plugin = CodeParserPlugin;
755    let mut entities = vec![module_entity];
756
757    for mut child in code_plugin.extract_entities(content, file_path) {
758        child.parent_id = Some(module_id.clone());
759        child.id = build_entity_id(file_path, &child.entity_type, &child.name, Some(&module_id));
760        entities.push(child);
761    }
762
763    entities
764}
765
766fn base_metadata(kind: SvelteEntityKind) -> HashMap<String, String> {
767    HashMap::from([(
768        SVELTE_KIND_KEY.to_string(),
769        kind.metadata_kind().to_string(),
770    )])
771}
772
773#[derive(Clone, Copy)]
774enum ElementLowering {
775    Ignore,
776    Kind(SvelteEntityKind),
777}
778
779#[derive(Clone, Copy, Eq, PartialEq)]
780enum TopLevelNodeKind {
781    Script,
782    Style,
783    Other,
784}
785
786fn classify_element_kind(tag_name: &str) -> ElementLowering {
787    if let Some(local_name) = tag_name.strip_prefix("svelte:") {
788        return match local_name {
789            "head" => ElementLowering::Kind(SvelteEntityKind::HeadElement),
790            "body" => ElementLowering::Kind(SvelteEntityKind::BodyElement),
791            "window" => ElementLowering::Kind(SvelteEntityKind::WindowElement),
792            "document" => ElementLowering::Kind(SvelteEntityKind::DocumentElement),
793            "component" => ElementLowering::Kind(SvelteEntityKind::DynamicComponentElement),
794            "element" => ElementLowering::Kind(SvelteEntityKind::DynamicElementElement),
795            "self" => ElementLowering::Kind(SvelteEntityKind::SelfElement),
796            "fragment" => ElementLowering::Kind(SvelteEntityKind::FragmentElement),
797            "boundary" => ElementLowering::Kind(SvelteEntityKind::BoundaryElement),
798            "options" => ElementLowering::Kind(SvelteEntityKind::OptionsElement),
799            _ => ElementLowering::Ignore,
800        };
801    }
802
803    match tag_name {
804        "slot" => ElementLowering::Kind(SvelteEntityKind::SlotElement),
805        "title" => ElementLowering::Kind(SvelteEntityKind::TitleElement),
806        _ if is_component_tag(tag_name) => ElementLowering::Kind(SvelteEntityKind::Component),
807        _ => ElementLowering::Kind(SvelteEntityKind::Element),
808    }
809}
810
811fn is_component_tag(tag_name: &str) -> bool {
812    tag_name
813        .chars()
814        .next()
815        .map(|ch| ch.is_ascii_uppercase())
816        .unwrap_or(false)
817}
818
819fn is_semantic_child(node: TsNode<'_>) -> bool {
820    matches!(
821        node.kind(),
822        "if_block" | "each_block" | "await_block" | "key_block" | "snippet_block" | "element"
823    )
824}
825
826fn element_tag_node<'tree>(node: TsNode<'tree>) -> Option<TsNode<'tree>> {
827    let mut cursor = node.walk();
828    let result = node
829        .named_children(&mut cursor)
830        .find(|child| matches!(child.kind(), "start_tag" | "self_closing_tag"));
831    result
832}
833
834fn element_raw_text_node<'tree>(node: TsNode<'tree>) -> Option<TsNode<'tree>> {
835    let mut cursor = node.walk();
836    let raw_text = node
837        .named_children(&mut cursor)
838        .find(|child| child.kind() == "raw_text");
839    raw_text
840}
841
842fn tag_has_attribute(tag: TsNode<'_>, attr: &str, source: &str) -> bool {
843    let mut cursor = tag.walk();
844    let has_attribute = tag.named_children(&mut cursor).any(|child| {
845        child.kind() == "attribute"
846            && child
847                .child_by_field_name("name")
848                .and_then(|name| text_for_node(source, name))
849                .map(|name| name.eq_ignore_ascii_case(attr))
850                .unwrap_or(false)
851    });
852    has_attribute
853}
854
855fn tag_attribute_value<'a>(tag: TsNode<'_>, attr: &str, source: &'a str) -> Option<&'a str> {
856    let mut cursor = tag.walk();
857    for child in tag.named_children(&mut cursor) {
858        if child.kind() != "attribute" {
859            continue;
860        }
861
862        let Some(name) = child.child_by_field_name("name") else {
863            continue;
864        };
865        if !text_for_node(source, name)
866            .map(|name| name.eq_ignore_ascii_case(attr))
867            .unwrap_or(false)
868        {
869            continue;
870        }
871
872        let Some(value) = child.child_by_field_name("value") else {
873            continue;
874        };
875        return simple_attribute_value(value, source);
876    }
877
878    None
879}
880
881fn simple_attribute_value<'a>(node: TsNode<'_>, source: &'a str) -> Option<&'a str> {
882    match node.kind() {
883        "attribute_value" => text_for_node(source, node),
884        "quoted_attribute_value" | "unquoted_attribute_value" => {
885            let mut cursor = node.walk();
886            let attribute_value = node
887                .named_children(&mut cursor)
888                .find(|child| child.kind() == "attribute_value")
889                .and_then(|child| text_for_node(source, child));
890            attribute_value
891        }
892        _ => None,
893    }
894}
895
896fn text_for_node<'a>(source: &'a str, node: TsNode<'_>) -> Option<&'a str> {
897    Some(text_for_byte_range(
898        source,
899        node.start_byte(),
900        node.end_byte(),
901    ))
902    .filter(|text| !text.is_empty())
903}
904
905fn text_for_byte_range(source: &str, start: usize, end: usize) -> &str {
906    let start = start.min(source.len());
907    let end = end.min(source.len());
908    if start >= end {
909        ""
910    } else {
911        source.get(start..end).unwrap_or_default()
912    }
913}
914
915fn last_line_number(source: &str) -> usize {
916    if source.is_empty() {
917        1
918    } else {
919        source.lines().count().max(1)
920    }
921}
922
923fn script_virtual_path(file_path: &str, lang: ScriptLanguage) -> String {
924    format!("{file_path}:{}", lang.virtual_script_extension())
925}
926
927fn normalize_text(text: &str) -> String {
928    let mut normalized = String::with_capacity(text.len());
929    let mut saw_text = false;
930    let mut pending_space = false;
931
932    for part in text.split_whitespace() {
933        if saw_text && pending_space {
934            normalized.push(' ');
935        }
936        normalized.push_str(part);
937        saw_text = true;
938        pending_space = true;
939    }
940
941    normalized
942}
943
944fn classify_svelte_file(file_path: &str) -> Option<SvelteFileKind> {
945    let name = Path::new(file_path)
946        .file_name()
947        .and_then(|name| name.to_str())?;
948
949    if ends_with_ignore_ascii_case(name, ".svelte")
950        && !ends_with_ignore_ascii_case(name, ".svelte.js")
951        && !ends_with_ignore_ascii_case(name, ".svelte.ts")
952        && !ends_with_ignore_ascii_case(name, ".svelte.test.js")
953        && !ends_with_ignore_ascii_case(name, ".svelte.test.ts")
954        && !ends_with_ignore_ascii_case(name, ".svelte.spec.js")
955        && !ends_with_ignore_ascii_case(name, ".svelte.spec.ts")
956    {
957        Some(SvelteFileKind::Component)
958    } else if ends_with_ignore_ascii_case(name, ".svelte.js")
959        || ends_with_ignore_ascii_case(name, ".svelte.ts")
960        || ends_with_ignore_ascii_case(name, ".svelte.test.js")
961        || ends_with_ignore_ascii_case(name, ".svelte.test.ts")
962        || ends_with_ignore_ascii_case(name, ".svelte.spec.js")
963        || ends_with_ignore_ascii_case(name, ".svelte.spec.ts")
964    {
965        Some(SvelteFileKind::Module {
966            lang: ScriptLanguage::from_svelte_module_path(name),
967        })
968    } else {
969        None
970    }
971}
972
973fn ends_with_ignore_ascii_case(value: &str, suffix: &str) -> bool {
974    value
975        .get(value.len().saturating_sub(suffix.len())..)
976        .map(|tail| tail.eq_ignore_ascii_case(suffix))
977        .unwrap_or(false)
978}
979
980fn disambiguate_name<'a>(base_name: &'a str, counts: &mut HashMap<&'a str, usize>) -> String {
981    let count = counts.entry(base_name).or_insert(0);
982    *count += 1;
983
984    if *count == 1 {
985        base_name.into()
986    } else {
987        format!("{base_name}:{}", *count)
988    }
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994
995    #[test]
996    fn test_svelte_extraction() {
997        let code = r#"<script lang="ts">
998export function hello() {
999  return "hello";
1000}
1001</script>
1002
1003<script context="module">
1004export class Counter {
1005  increment() {
1006    return 1;
1007  }
1008}
1009</script>
1010
1011<style>
1012h1 { color: red; }
1013</style>
1014
1015{#snippet greet(name: string)}
1016  <h1>{hello()} {name}</h1>
1017{/snippet}
1018"#;
1019        let plugin = SvelteParserPlugin;
1020        let entities = plugin.extract_entities(code, "Component.svelte");
1021        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1022
1023        assert!(
1024            names.contains(&"script"),
1025            "Should find instance script block, got: {:?}",
1026            names
1027        );
1028        assert!(
1029            names.contains(&"script module"),
1030            "Should find module script block, got: {:?}",
1031            names
1032        );
1033        assert!(
1034            names.contains(&"style"),
1035            "Should find style block, got: {:?}",
1036            names
1037        );
1038        assert!(
1039            names.contains(&"fragment"),
1040            "Should find fragment entity, got: {:?}",
1041            names
1042        );
1043        assert!(
1044            names.contains(&"hello"),
1045            "Should find script export, got: {:?}",
1046            names
1047        );
1048        assert!(
1049            names.contains(&"Counter"),
1050            "Should find module class, got: {:?}",
1051            names
1052        );
1053        assert!(
1054            names.iter().any(|name| name.starts_with("snippet@")),
1055            "Should find snippet block, got: {:?}",
1056            names
1057        );
1058    }
1059
1060    #[test]
1061    fn test_svelte_line_numbers() {
1062        let code = r#"<script lang="ts">
1063function hello() {
1064  return "hello";
1065}
1066</script>
1067
1068<div>{hello()}</div>
1069"#;
1070        let plugin = SvelteParserPlugin;
1071        let entities = plugin.extract_entities(code, "Hello.svelte");
1072
1073        let script = entities
1074            .iter()
1075            .find(|entity| entity.name == "script")
1076            .unwrap();
1077        assert_eq!(script.start_line, 1);
1078        assert_eq!(script.end_line, 5);
1079
1080        let fragment = entities
1081            .iter()
1082            .find(|entity| entity.name == "fragment")
1083            .unwrap();
1084        assert_eq!(fragment.start_line, 5);
1085        assert_eq!(fragment.end_line, 7);
1086
1087        let hello = entities
1088            .iter()
1089            .find(|entity| entity.name == "hello")
1090            .unwrap();
1091        assert_eq!(hello.start_line, 2);
1092        assert_eq!(hello.end_line, 4);
1093    }
1094
1095    #[test]
1096    fn test_svelte_fragment_nodes() {
1097        let code = r#"<svelte:head>
1098  <title>Hello</title>
1099</svelte:head>
1100
1101{#if visible}
1102  <Widget />
1103{/if}
1104"#;
1105        let plugin = SvelteParserPlugin;
1106        let entities = plugin.extract_entities(code, "FragmentNodes.svelte");
1107        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1108
1109        assert!(
1110            names.contains(&"fragment"),
1111            "missing fragment entity: {:?}",
1112            names
1113        );
1114        assert!(
1115            names.iter().any(|name| name.starts_with("svelte:head@")),
1116            "missing svelte:head entity: {:?}",
1117            names
1118        );
1119        assert!(
1120            names.iter().any(|name| name.starts_with("if@")),
1121            "missing if-block entity: {:?}",
1122            names
1123        );
1124        assert!(
1125            names.iter().any(|name| name.starts_with("Widget@")),
1126            "missing component entity: {:?}",
1127            names
1128        );
1129        assert!(
1130            names.iter().any(|name| name.starts_with("title@")),
1131            "missing title entity: {:?}",
1132            names
1133        );
1134    }
1135
1136    #[test]
1137    fn test_svelte_markup_only_file() {
1138        let code = r#"<svelte:options runes={true} />
1139<div class="app">
1140  {#if visible}
1141    <p>Hello</p>
1142  {/if}
1143</div>
1144"#;
1145        let plugin = SvelteParserPlugin;
1146        let entities = plugin.extract_entities(code, "MarkupOnly.svelte");
1147        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1148
1149        assert!(
1150            names.contains(&"fragment"),
1151            "missing fragment entity: {:?}",
1152            names
1153        );
1154        assert!(
1155            names.iter().any(|name| name.starts_with("svelte:options@")),
1156            "missing svelte:options entity: {:?}",
1157            names
1158        );
1159        assert!(
1160            names.iter().any(|name| name.starts_with("if@")),
1161            "missing if-block entity: {:?}",
1162            names
1163        );
1164        assert!(
1165            names.iter().any(|name| name.starts_with("div@")),
1166            "missing element entity: {:?}",
1167            names
1168        );
1169    }
1170
1171    #[test]
1172    fn test_svelte_tag_comments_are_non_structural() {
1173        let before = r#"<div class="app"></div>"#;
1174        let plugin = SvelteParserPlugin;
1175
1176        for after in [
1177            r#"<div // Svelte 5 tag comment
1178class="app"></div>"#,
1179            r#"<div /* Svelte 5 tag comment */
1180class="app"></div>"#,
1181        ] {
1182            let before_entities = plugin.extract_entities(before, "Commented.svelte");
1183            let after_entities = plugin.extract_entities(after, "Commented.svelte");
1184
1185            let before_div = before_entities
1186                .iter()
1187                .find(|entity| entity.entity_type == "svelte_element")
1188                .unwrap();
1189            let after_div = after_entities
1190                .iter()
1191                .find(|entity| entity.entity_type == "svelte_element")
1192                .unwrap();
1193
1194            assert_ne!(before_div.content_hash, after_div.content_hash);
1195            assert_eq!(before_div.structural_hash, after_div.structural_hash);
1196
1197            let before_fragment = before_entities
1198                .iter()
1199                .find(|entity| entity.entity_type == "svelte_fragment")
1200                .unwrap();
1201            let after_fragment = after_entities
1202                .iter()
1203                .find(|entity| entity.entity_type == "svelte_fragment")
1204                .unwrap();
1205
1206            assert_ne!(before_fragment.content_hash, after_fragment.content_hash);
1207            assert_eq!(
1208                before_fragment.structural_hash,
1209                after_fragment.structural_hash
1210            );
1211        }
1212    }
1213
1214    #[test]
1215    fn test_svelte_typescript_module_extension_creates_module_entity() {
1216        let code = r#"export function createCounter(step: number) {
1217    let count = $state(0);
1218    return {
1219        increment() {
1220            count += step;
1221        }
1222    };
1223}"#;
1224        let plugin = SvelteParserPlugin;
1225        let entities = plugin.extract_entities(code, "state.svelte.ts");
1226        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1227        let module = entities
1228            .iter()
1229            .find(|entity| entity.name == "module")
1230            .unwrap();
1231
1232        assert!(
1233            names.contains(&"createCounter"),
1234            "missing TypeScript entities: {:?}",
1235            names
1236        );
1237        assert_eq!(module.entity_type, "svelte_module");
1238        assert!(
1239            module.parent_id.is_none(),
1240            "module entity should not have a parent"
1241        );
1242        let create_counter = entities
1243            .iter()
1244            .find(|entity| entity.name == "createCounter")
1245            .unwrap();
1246        assert_eq!(
1247            create_counter.parent_id.as_deref(),
1248            Some(module.id.as_str())
1249        );
1250    }
1251
1252    #[test]
1253    fn test_svelte_test_extension_creates_module_entity() {
1254        let code = r#"export function createMultiplier(k) {
1255    return function apply(value) {
1256        return value * k;
1257    };
1258}"#;
1259        let plugin = SvelteParserPlugin;
1260        let entities = plugin.extract_entities(code, "multiplier.svelte.test.js");
1261        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1262
1263        assert!(
1264            names.contains(&"module"),
1265            "missing module entity: {:?}",
1266            names
1267        );
1268        assert!(
1269            names.contains(&"createMultiplier"),
1270            "missing JavaScript entities: {:?}",
1271            names
1272        );
1273        assert!(
1274            !names.contains(&"fragment"),
1275            "unexpected fragment entity for module file: {:?}",
1276            names
1277        );
1278    }
1279
1280    #[test]
1281    fn test_svelte_head() {
1282        let code = r#"<svelte:head>
1283	<title>Hello world!</title>
1284	<meta name="description" content="This is where the description goes for SEO" />
1285</svelte:head>
1286"#;
1287        let plugin = SvelteParserPlugin;
1288        let entities = plugin.extract_entities(code, "Head.svelte");
1289        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1290        let head = entities
1291            .iter()
1292            .find(|entity| entity.name.starts_with("svelte:head@"))
1293            .unwrap();
1294
1295        assert!(
1296            names.contains(&"fragment"),
1297            "missing fragment entity: {:?}",
1298            names
1299        );
1300        assert!(
1301            names.iter().any(|name| name.starts_with("svelte:head@")),
1302            "missing svelte:head entity: {:?}",
1303            names
1304        );
1305        assert_eq!(head.entity_type, "svelte_head");
1306    }
1307
1308    #[test]
1309    fn test_svelte_multiple_scripts() {
1310        let code = r#"<script>
1311	REPLACEME
1312</script>
1313<style>
1314	SHOULD NOT BE REPLACED
1315</style>
1316<script>
1317	REPLACEMETOO
1318</script>
1319"#;
1320        let plugin = SvelteParserPlugin;
1321        let entities = plugin.extract_entities(code, "Scripts.svelte");
1322        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1323
1324        assert!(
1325            names.contains(&"script"),
1326            "missing script block: {:?}",
1327            names
1328        );
1329        assert!(
1330            names.contains(&"script module") || names.contains(&"style"),
1331            "missing top-level block entities: {:?}",
1332            names
1333        );
1334        assert!(names.contains(&"style"), "missing style block: {:?}", names);
1335    }
1336
1337    #[test]
1338    fn test_svelte_snippet() {
1339        let code = r#"<script lang="ts"></script>
1340
1341{#snippet foo(msg: string)}
1342	<p>{msg}</p>
1343{/snippet}
1344
1345{@render foo(msg)}
1346"#;
1347        let plugin = SvelteParserPlugin;
1348        let entities = plugin.extract_entities(code, "Snippets.svelte");
1349        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1350
1351        assert!(
1352            names.contains(&"script"),
1353            "missing script block: {:?}",
1354            names
1355        );
1356        assert!(
1357            names.contains(&"fragment"),
1358            "missing fragment entity: {:?}",
1359            names
1360        );
1361        assert!(
1362            names.iter().any(|name| name.starts_with("snippet@")),
1363            "missing snippet block: {:?}",
1364            names
1365        );
1366        assert!(
1367            names.iter().any(|name| name.starts_with("p@")),
1368            "missing rendered content: {:?}",
1369            names
1370        );
1371    }
1372
1373    #[test]
1374    fn test_svelte_window() {
1375        let code = r#"<script>
1376	function handleKeydown(event) {
1377		alert(`pressed the ${event.key} key`);
1378	}
1379</script>
1380
1381<svelte:window onkeydown={handleKeydown} />
1382"#;
1383        let plugin = SvelteParserPlugin;
1384        let entities = plugin.extract_entities(code, "Window.svelte");
1385        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1386        let window = entities
1387            .iter()
1388            .find(|entity| entity.name.starts_with("svelte:window@"))
1389            .unwrap();
1390
1391        assert!(
1392            names.contains(&"script"),
1393            "missing script block: {:?}",
1394            names
1395        );
1396        assert!(
1397            names.contains(&"handleKeydown"),
1398            "missing extracted function: {:?}",
1399            names
1400        );
1401        assert!(
1402            names.contains(&"fragment"),
1403            "missing fragment entity: {:?}",
1404            names
1405        );
1406        assert!(
1407            names.iter().any(|name| name.starts_with("svelte:window@")),
1408            "missing svelte:window entity: {:?}",
1409            names
1410        );
1411        assert_eq!(window.entity_type, "svelte_window");
1412    }
1413
1414    #[test]
1415    fn test_svelte_if_block() {
1416        let code = r#"{#if foo}bar{/if}
1417"#;
1418        let plugin = SvelteParserPlugin;
1419        let entities = plugin.extract_entities(code, "IfBlock.svelte");
1420        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1421
1422        assert!(
1423            names.contains(&"fragment"),
1424            "missing fragment entity: {:?}",
1425            names
1426        );
1427        assert!(
1428            names.iter().any(|name| name.starts_with("if@")),
1429            "missing if-block entity: {:?}",
1430            names
1431        );
1432    }
1433
1434    #[test]
1435    fn test_svelte_options() {
1436        let code = r#"<svelte:options runes={true} namespace="html" css="injected" customElement="my-custom-element" />
1437"#;
1438        let plugin = SvelteParserPlugin;
1439        let entities = plugin.extract_entities(code, "Options.svelte");
1440        let names: Vec<&str> = entities.iter().map(|entity| entity.name.as_str()).collect();
1441        let options = entities
1442            .iter()
1443            .find(|entity| entity.entity_type == "svelte_options")
1444            .expect("expected svelte:options entity");
1445
1446        assert!(
1447            names.iter().any(|name| name.starts_with("svelte:options@")),
1448            "missing svelte:options entity: {:?}",
1449            names
1450        );
1451        assert_eq!(
1452            options
1453                .metadata
1454                .as_ref()
1455                .and_then(|metadata| metadata.get("svelte.kind"))
1456                .map(String::as_str),
1457            Some("options")
1458        );
1459        assert_eq!(options.content.trim(), code.trim());
1460    }
1461
1462    #[test]
1463    fn test_svelte_each_block_extraction() {
1464        let code = r#"<script>
1465let items = $state(['a', 'b', 'c']);
1466</script>
1467
1468{#each items as item, i (item)}
1469  <li>{i}: {item}</li>
1470{:else}
1471  <p>No items</p>
1472{/each}
1473"#;
1474        let plugin = SvelteParserPlugin;
1475        let entities = plugin.extract_entities(code, "Each.svelte");
1476
1477        let each = entities
1478            .iter()
1479            .find(|e| e.entity_type == "svelte_each_block")
1480            .expect("missing each block");
1481        assert!(each.name.starts_with("each@"));
1482        assert_eq!(each.start_line, 5);
1483        assert_eq!(each.end_line, 9);
1484
1485        let fragment = entities
1486            .iter()
1487            .find(|e| e.entity_type == "svelte_fragment")
1488            .unwrap();
1489        assert_eq!(each.parent_id.as_deref(), Some(fragment.id.as_str()));
1490
1491        let li = entities
1492            .iter()
1493            .find(|e| e.name.starts_with("li@"))
1494            .expect("missing li element inside each block");
1495        assert_eq!(li.parent_id.as_deref(), Some(each.id.as_str()));
1496
1497        let p = entities
1498            .iter()
1499            .find(|e| e.name.starts_with("p@"))
1500            .expect("missing fallback element inside each block");
1501        assert_eq!(
1502            p.parent_id.as_deref(),
1503            Some(each.id.as_str()),
1504            "fallback element should be parented to the each block"
1505        );
1506    }
1507
1508    #[test]
1509    fn test_svelte_key_block_extraction() {
1510        let code = r#"{#key value}
1511  <Widget />
1512{/key}
1513"#;
1514        let plugin = SvelteParserPlugin;
1515        let entities = plugin.extract_entities(code, "Key.svelte");
1516
1517        let key = entities
1518            .iter()
1519            .find(|e| e.entity_type == "svelte_key_block")
1520            .expect("missing key block");
1521        assert!(key.name.starts_with("key@"));
1522        assert_eq!(key.start_line, 1);
1523        assert_eq!(key.end_line, 3);
1524
1525        let widget = entities
1526            .iter()
1527            .find(|e| e.entity_type == "svelte_component" && e.name.starts_with("Widget@"))
1528            .expect("missing component inside key block");
1529        assert_eq!(widget.parent_id.as_deref(), Some(key.id.as_str()));
1530    }
1531
1532    #[test]
1533    fn test_svelte_await_block_extraction() {
1534        let code = r#"{#await promise}
1535  <p>Loading...</p>
1536{:then value}
1537  <p>{value}</p>
1538{:catch error}
1539  <p>{error.message}</p>
1540{/await}
1541"#;
1542        let plugin = SvelteParserPlugin;
1543        let entities = plugin.extract_entities(code, "Await.svelte");
1544
1545        let await_block = entities
1546            .iter()
1547            .find(|e| e.entity_type == "svelte_await_block")
1548            .expect("missing await block");
1549        assert!(await_block.name.starts_with("await@"));
1550        assert_eq!(await_block.start_line, 1);
1551        assert_eq!(await_block.end_line, 7);
1552
1553        let ps: Vec<_> = entities
1554            .iter()
1555            .filter(|e| e.name.starts_with("p@"))
1556            .collect();
1557        assert_eq!(ps.len(), 3, "expected content from all await branches");
1558        for p in &ps {
1559            assert_eq!(
1560                p.parent_id.as_deref(),
1561                Some(await_block.id.as_str()),
1562                "await branch content should be parented to the await block"
1563            );
1564        }
1565    }
1566
1567    #[test]
1568    fn test_svelte_nested_if_else_chain() {
1569        let code = r#"{#if a}
1570  <p>A</p>
1571{:else if b}
1572  <p>B</p>
1573{:else}
1574  <p>C</p>
1575{/if}
1576"#;
1577        let plugin = SvelteParserPlugin;
1578        let entities = plugin.extract_entities(code, "IfElse.svelte");
1579
1580        let ifs: Vec<_> = entities
1581            .iter()
1582            .filter(|e| e.entity_type == "svelte_if_block")
1583            .collect();
1584        assert_eq!(ifs.len(), 2, "expected both if and else-if blocks");
1585
1586        let outer_if = &ifs[0];
1587        let inner_if = &ifs[1];
1588        assert_eq!(
1589            inner_if.parent_id.as_deref(),
1590            Some(outer_if.id.as_str()),
1591            "else-if block should be nested under the outer if block"
1592        );
1593
1594        let ps: Vec<_> = entities
1595            .iter()
1596            .filter(|e| e.name.starts_with("p@"))
1597            .collect();
1598        assert_eq!(ps.len(), 3, "expected content from each branch");
1599    }
1600
1601    #[test]
1602    fn test_svelte_structural_hash_stable_across_whitespace() {
1603        let compact = r#"<div class="app"><span>hello</span></div>"#;
1604        let spaced = r#"<div class="app">
1605  <span>hello</span>
1606</div>"#;
1607
1608        let plugin = SvelteParserPlugin;
1609        let compact_entities = plugin.extract_entities(compact, "Compact.svelte");
1610        let spaced_entities = plugin.extract_entities(spaced, "Spaced.svelte");
1611
1612        let compact_div = compact_entities
1613            .iter()
1614            .find(|e| e.entity_type == "svelte_element" && e.name.starts_with("div@"))
1615            .unwrap();
1616        let spaced_div = spaced_entities
1617            .iter()
1618            .find(|e| e.entity_type == "svelte_element" && e.name.starts_with("div@"))
1619            .unwrap();
1620
1621        assert_ne!(
1622            compact_div.content_hash, spaced_div.content_hash,
1623            "content hash should change when source text changes"
1624        );
1625        assert_eq!(
1626            compact_div.structural_hash, spaced_div.structural_hash,
1627            "structural hash should be stable across whitespace changes"
1628        );
1629    }
1630
1631    #[test]
1632    fn test_svelte_content_hash_changes_on_logic_change() {
1633        let before = r#"<script>
1634function add(a, b) { return a + b; }
1635</script>
1636"#;
1637        let after = r#"<script>
1638function add(a, b) { return a * b; }
1639</script>
1640"#;
1641        let plugin = SvelteParserPlugin;
1642        let before_entities = plugin.extract_entities(before, "Calc.svelte");
1643        let after_entities = plugin.extract_entities(after, "Calc.svelte");
1644
1645        let before_add = before_entities.iter().find(|e| e.name == "add").unwrap();
1646        let after_add = after_entities.iter().find(|e| e.name == "add").unwrap();
1647
1648        assert_ne!(
1649            before_add.content_hash, after_add.content_hash,
1650            "function content hash should change with new logic"
1651        );
1652        assert_eq!(before_add.entity_type, "function");
1653        assert_eq!(after_add.entity_type, "function");
1654
1655        let before_script = before_entities
1656            .iter()
1657            .find(|e| e.entity_type == "svelte_instance_script")
1658            .unwrap();
1659        let after_script = after_entities
1660            .iter()
1661            .find(|e| e.entity_type == "svelte_instance_script")
1662            .unwrap();
1663        assert_ne!(
1664            before_script.content_hash, after_script.content_hash,
1665            "script content hash should change with new logic"
1666        );
1667    }
1668
1669    #[test]
1670    fn test_svelte_entity_parent_hierarchy() {
1671        let code = r#"<script lang="ts">
1672export function greet(name: string) {
1673  return `Hello ${name}`;
1674}
1675</script>
1676
1677<main>
1678  <section>
1679    <p>{greet("world")}</p>
1680  </section>
1681</main>
1682"#;
1683        let plugin = SvelteParserPlugin;
1684        let entities = plugin.extract_entities(code, "App.svelte");
1685
1686        let script = entities
1687            .iter()
1688            .find(|e| e.entity_type == "svelte_instance_script")
1689            .unwrap();
1690        assert!(
1691            script.parent_id.is_none(),
1692            "script block should be top-level"
1693        );
1694
1695        let greet = entities.iter().find(|e| e.name == "greet").unwrap();
1696        assert_eq!(
1697            greet.parent_id.as_deref(),
1698            Some(script.id.as_str()),
1699            "function should be parented to the script block"
1700        );
1701        assert_eq!(greet.entity_type, "function");
1702
1703        let fragment = entities
1704            .iter()
1705            .find(|e| e.entity_type == "svelte_fragment")
1706            .unwrap();
1707        assert!(fragment.parent_id.is_none(), "fragment should be top-level");
1708
1709        let main_el = entities
1710            .iter()
1711            .find(|e| e.name.starts_with("main@"))
1712            .unwrap();
1713        assert_eq!(main_el.parent_id.as_deref(), Some(fragment.id.as_str()));
1714
1715        let section = entities
1716            .iter()
1717            .find(|e| e.name.starts_with("section@"))
1718            .unwrap();
1719        assert_eq!(section.parent_id.as_deref(), Some(main_el.id.as_str()));
1720    }
1721
1722    #[test]
1723    fn test_svelte_metadata_fields() {
1724        let code = r#"<script lang="ts" context="module">
1725export const VERSION = "1.0";
1726</script>
1727
1728<script lang="ts">
1729let count = $state(0);
1730</script>
1731
1732<style>
1733div { color: red; }
1734</style>
1735"#;
1736        let plugin = SvelteParserPlugin;
1737        let entities = plugin.extract_entities(code, "Meta.svelte");
1738
1739        let module_script = entities
1740            .iter()
1741            .find(|e| e.entity_type == "svelte_module_script")
1742            .unwrap();
1743        let meta = module_script.metadata.as_ref().unwrap();
1744        assert_eq!(
1745            meta.get("svelte.kind").map(|s| s.as_str()),
1746            Some("module_script")
1747        );
1748        assert_eq!(
1749            meta.get("svelte.context").map(|s| s.as_str()),
1750            Some("module")
1751        );
1752        assert_eq!(meta.get("svelte.lang").map(|s| s.as_str()), Some("ts"));
1753
1754        let instance_script = entities
1755            .iter()
1756            .find(|e| e.entity_type == "svelte_instance_script")
1757            .unwrap();
1758        let meta = instance_script.metadata.as_ref().unwrap();
1759        assert_eq!(
1760            meta.get("svelte.context").map(|s| s.as_str()),
1761            Some("default")
1762        );
1763        assert_eq!(meta.get("svelte.lang").map(|s| s.as_str()), Some("ts"));
1764
1765        let style = entities
1766            .iter()
1767            .find(|e| e.entity_type == "svelte_style")
1768            .unwrap();
1769        let meta = style.metadata.as_ref().unwrap();
1770        assert_eq!(meta.get("svelte.kind").map(|s| s.as_str()), Some("style"));
1771    }
1772
1773    #[test]
1774    fn test_svelte_rune_declarations_in_script() {
1775        let code = r#"<script lang="ts">
1776let count = $state(0);
1777let doubled = $derived(count * 2);
1778
1779$effect(() => {
1780  console.log(count);
1781});
1782
1783function increment() {
1784  count++;
1785}
1786</script>
1787
1788<button onclick={increment}>{count} (doubled: {doubled})</button>
1789"#;
1790        let plugin = SvelteParserPlugin;
1791        let entities = plugin.extract_entities(code, "Runes.svelte");
1792
1793        let script_children: Vec<_> = entities
1794            .iter()
1795            .filter(|e| {
1796                e.parent_id
1797                    .as_ref()
1798                    .map(|pid| {
1799                        entities
1800                            .iter()
1801                            .any(|p| p.id == *pid && p.entity_type == "svelte_instance_script")
1802                    })
1803                    .unwrap_or(false)
1804            })
1805            .collect();
1806
1807        let child_names: Vec<&str> = script_children.iter().map(|e| e.name.as_str()).collect();
1808        assert!(
1809            child_names.contains(&"count"),
1810            "missing count variable: {:?}",
1811            child_names
1812        );
1813        assert!(
1814            child_names.contains(&"doubled"),
1815            "missing doubled variable: {:?}",
1816            child_names
1817        );
1818        assert!(
1819            child_names.contains(&"increment"),
1820            "missing increment function: {:?}",
1821            child_names
1822        );
1823    }
1824
1825    #[test]
1826    fn test_svelte_component_with_children() {
1827        let code = r#"<Dialog>
1828  <h2>Title</h2>
1829  <p>Content</p>
1830</Dialog>
1831"#;
1832        let plugin = SvelteParserPlugin;
1833        let entities = plugin.extract_entities(code, "Composed.svelte");
1834
1835        let dialog = entities
1836            .iter()
1837            .find(|e| e.entity_type == "svelte_component" && e.name.starts_with("Dialog@"))
1838            .expect("missing Dialog component");
1839
1840        let h2 = entities
1841            .iter()
1842            .find(|e| e.name.starts_with("h2@"))
1843            .expect("missing h2 inside Dialog");
1844        assert_eq!(
1845            h2.parent_id.as_deref(),
1846            Some(dialog.id.as_str()),
1847            "h2 should be parented to Dialog"
1848        );
1849
1850        let p = entities
1851            .iter()
1852            .find(|e| e.name.starts_with("p@"))
1853            .expect("missing p inside Dialog");
1854        assert_eq!(p.parent_id.as_deref(), Some(dialog.id.as_str()));
1855    }
1856
1857    #[test]
1858    fn test_svelte_module_file_lang_detection() {
1859        let ts_code = "export const API_URL: string = 'https://example.com';";
1860        let js_code = "export const API_URL = 'https://example.com';";
1861
1862        let plugin = SvelteParserPlugin;
1863        let ts_entities = plugin.extract_entities(ts_code, "config.svelte.ts");
1864        let js_entities = plugin.extract_entities(js_code, "config.svelte.js");
1865
1866        let ts_module = ts_entities
1867            .iter()
1868            .find(|e| e.entity_type == "svelte_module")
1869            .unwrap();
1870        let ts_meta = ts_module.metadata.as_ref().unwrap();
1871        assert_eq!(ts_meta.get("svelte.lang").map(|s| s.as_str()), Some("ts"));
1872
1873        let js_module = js_entities
1874            .iter()
1875            .find(|e| e.entity_type == "svelte_module")
1876            .unwrap();
1877        let js_meta = js_module.metadata.as_ref().unwrap();
1878        assert_eq!(js_meta.get("svelte.lang").map(|s| s.as_str()), Some("js"));
1879    }
1880
1881    #[test]
1882    fn test_svelte_empty_component_produces_no_fragment() {
1883        let code = "";
1884        let plugin = SvelteParserPlugin;
1885        let entities = plugin.extract_entities(code, "Empty.svelte");
1886        assert!(
1887            entities.is_empty(),
1888            "empty component should produce no entities: {:?}",
1889            entities.iter().map(|e| &e.name).collect::<Vec<_>>()
1890        );
1891    }
1892
1893    #[test]
1894    fn test_svelte_svelte_body_and_document() {
1895        let code = r#"<svelte:body onscroll={() => {}} />
1896<svelte:document onfullscreenchange={() => {}} />
1897"#;
1898        let plugin = SvelteParserPlugin;
1899        let entities = plugin.extract_entities(code, "Special.svelte");
1900
1901        let body = entities
1902            .iter()
1903            .find(|e| e.entity_type == "svelte_body")
1904            .expect("missing svelte:body");
1905        assert!(body.name.starts_with("svelte:body@"));
1906
1907        let doc = entities
1908            .iter()
1909            .find(|e| e.entity_type == "svelte_document")
1910            .expect("missing svelte:document");
1911        assert!(doc.name.starts_with("svelte:document@"));
1912    }
1913
1914    #[test]
1915    fn test_svelte_multiple_scripts_disambiguation() {
1916        let code = r#"<script>
1917let a = 1;
1918</script>
1919<script>
1920let b = 2;
1921</script>
1922"#;
1923        let plugin = SvelteParserPlugin;
1924        let entities = plugin.extract_entities(code, "Multi.svelte");
1925
1926        let scripts: Vec<_> = entities
1927            .iter()
1928            .filter(|e| e.entity_type == "svelte_instance_script")
1929            .collect();
1930        assert_eq!(scripts.len(), 2, "expected both script blocks");
1931        assert_ne!(
1932            scripts[0].name, scripts[1].name,
1933            "script block names should be disambiguated"
1934        );
1935        assert_eq!(scripts[0].name, "script");
1936        assert_eq!(scripts[1].name, "script:2");
1937    }
1938
1939    #[test]
1940    fn test_svelte_entity_id_format() {
1941        let code = r#"<script>
1942function hello() {}
1943</script>
1944
1945<div>text</div>
1946"#;
1947        let plugin = SvelteParserPlugin;
1948        let entities = plugin.extract_entities(code, "src/routes/+page.svelte");
1949
1950        let script = entities
1951            .iter()
1952            .find(|e| e.entity_type == "svelte_instance_script")
1953            .unwrap();
1954        assert!(
1955            script.id.contains("src/routes/+page.svelte"),
1956            "entity id should include file path: {}",
1957            script.id
1958        );
1959        assert!(
1960            script.id.contains("svelte_instance_script"),
1961            "entity id should include entity type: {}",
1962            script.id
1963        );
1964
1965        let hello = entities.iter().find(|e| e.name == "hello").unwrap();
1966        assert!(
1967            hello.id.contains("hello"),
1968            "child entity id should include entity name: {}",
1969            hello.id
1970        );
1971        assert!(
1972            hello.parent_id.is_some(),
1973            "script-extracted function should have a parent id"
1974        );
1975    }
1976
1977    use crate::git::types::{FileChange, FileStatus};
1978    use crate::model::change::ChangeType;
1979    use crate::parser::differ::compute_semantic_diff;
1980    use crate::parser::plugins::create_default_registry;
1981
1982    #[test]
1983    fn test_svelte_diff_new_file_all_entities_added() {
1984        let after = r#"<script>
1985  let count = $state(0);
1986</script>
1987
1988<button onclick={() => count++}>{count}</button>"#;
1989
1990        let registry = create_default_registry();
1991        let result = compute_semantic_diff(
1992            &[FileChange {
1993                file_path: "src/routes/+page.svelte".to_string(),
1994                status: FileStatus::Added,
1995                old_file_path: None,
1996                before_content: None,
1997                after_content: Some(after.to_string()),
1998            }],
1999            &registry,
2000            Some("abc123"),
2001            Some("test-author"),
2002        );
2003
2004        assert!(result.added_count > 0, "expected added entities");
2005        assert_eq!(result.deleted_count, 0);
2006        assert_eq!(result.modified_count, 0);
2007        assert_eq!(result.file_count, 1);
2008
2009        assert!(
2010            result
2011                .changes
2012                .iter()
2013                .all(|c| c.change_type == ChangeType::Added),
2014            "all changes should be added for a new file: {:?}",
2015            result
2016                .changes
2017                .iter()
2018                .map(|c| (&c.entity_name, &c.change_type))
2019                .collect::<Vec<_>>()
2020        );
2021
2022        assert!(
2023            result
2024                .changes
2025                .iter()
2026                .any(|c| c.entity_name == "script" && c.entity_type == "svelte_instance_script"),
2027            "expected script entity: {:?}",
2028            result
2029                .changes
2030                .iter()
2031                .map(|c| (&c.entity_name, &c.entity_type))
2032                .collect::<Vec<_>>()
2033        );
2034        assert!(
2035            result
2036                .changes
2037                .iter()
2038                .any(|c| c.entity_name == "count" && c.entity_type == "variable"),
2039            "expected count variable: {:?}",
2040            result
2041                .changes
2042                .iter()
2043                .map(|c| (&c.entity_name, &c.entity_type))
2044                .collect::<Vec<_>>()
2045        );
2046        assert!(
2047            result
2048                .changes
2049                .iter()
2050                .any(|c| c.entity_name == "button@5" && c.entity_type == "svelte_element"),
2051            "expected button@5 element: {:?}",
2052            result
2053                .changes
2054                .iter()
2055                .map(|c| (&c.entity_name, &c.entity_type))
2056                .collect::<Vec<_>>()
2057        );
2058        for c in &result.changes {
2059            assert_eq!(c.commit_sha.as_deref(), Some("abc123"));
2060            assert_eq!(c.author.as_deref(), Some("test-author"));
2061            assert_eq!(c.file_path, "src/routes/+page.svelte");
2062        }
2063    }
2064
2065}