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 ®istry,
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}