Skip to main content

svelte_syntax/
lib.rs

1//! Parse Svelte components into typed AST and CST representations.
2//!
3//! This crate provides the syntax layer for working with `.svelte` files in
4//! Rust. It parses components into inspectable tree structures without
5//! compiling them into JavaScript or CSS.
6//!
7//! # Parsing a component
8//!
9//! Use [`parse`] or [`parse_modern_root`] to obtain a typed AST:
10//!
11//! ```
12//! use svelte_syntax::{parse, ParseMode, ParseOptions};
13//!
14//! let doc = parse(
15//!     "<script>let count = 0;</script><button>{count}</button>",
16//!     ParseOptions {
17//!         mode: ParseMode::Modern,
18//!         ..ParseOptions::default()
19//!     },
20//! )?;
21//!
22//! let root = match doc.root {
23//!     svelte_syntax::ast::Root::Modern(root) => root,
24//!     _ => unreachable!(),
25//! };
26//!
27//! // Access the instance script and template fragment.
28//! assert!(root.instance.is_some());
29//! assert!(!root.fragment.nodes.is_empty());
30//! # Ok::<(), svelte_syntax::CompileError>(())
31//! ```
32//!
33//! # Parsing into a CST
34//!
35//! Use [`parse_svelte`] for a tree-sitter concrete syntax tree:
36//!
37//! ```
38//! use svelte_syntax::{SourceId, SourceText, parse_svelte};
39//!
40//! let source = SourceText::new(SourceId::new(0), "<div>hello</div>", None);
41//! let cst = parse_svelte(source)?;
42//!
43//! assert_eq!(cst.root_kind(), "document");
44//! assert!(!cst.has_error());
45//! # Ok::<(), svelte_syntax::CompileError>(())
46//! ```
47//!
48//! # Incremental reparsing
49//!
50//! Both the CST and AST support incremental reparsing. Provide the previous
51//! parse result and a [`CstEdit`] describing the change, and unchanged
52//! subtrees are reused automatically:
53//!
54//! ```
55//! use svelte_syntax::{
56//!     SourceId, SourceText, CstEdit,
57//!     parse_svelte, parse_modern_root, parse_modern_root_incremental,
58//! };
59//!
60//! let before = "<script>let x = 1;</script><div>Hello</div>";
61//! let after  = "<script>let x = 1;</script><div>World</div>";
62//!
63//! let old_root = parse_modern_root(before)?;
64//! let old_cst  = parse_svelte(SourceText::new(SourceId::new(0), before, None))?;
65//!
66//! let edit = CstEdit::replace(before, 37, 42, "World");
67//! let new_root = parse_modern_root_incremental(after, before, &old_root, &old_cst, edit)?;
68//!
69//! // The script was not in the changed range, so it is Arc-reused.
70//! assert!(std::sync::Arc::ptr_eq(
71//!     &old_root.instance.as_ref().unwrap().content,
72//!     &new_root.instance.as_ref().unwrap().content,
73//! ));
74//! # Ok::<(), svelte_syntax::CompileError>(())
75//! ```
76pub mod arena;
77pub mod ast;
78pub mod compat;
79pub mod cst;
80mod error;
81pub mod js;
82mod parse;
83mod primitives;
84mod source;
85
86// --- CST parsing ---
87
88pub use cst::{CstEdit, CstParser, Document, Language, parse_svelte, parse_svelte_incremental};
89
90// --- Errors ---
91
92pub use error::{CompileError, DiagnosticKind, LineColumn, SourcePosition};
93
94// --- JavaScript handles ---
95
96pub use js::{JsExpression, JsProgram};
97
98// --- AST parsing and element/attribute classification ---
99
100pub use parse::{
101    AttributeKind, ElementKind, ParseMode, ParseOptions, SvelteElementKind,
102    classify_attribute_name, classify_element_name, find_matching_brace_close, is_component_name,
103    is_custom_element_name, is_valid_component_name, is_valid_element_name, is_void_element_name,
104    line_column_at_offset, parse, parse_css, parse_modern_css_nodes,
105    parse_modern_expression_from_text, parse_modern_expression_tag, parse_modern_root,
106    parse_modern_root_incremental, parse_svelte_ignores,
107};
108
109// --- Primitives ---
110
111pub use primitives::{BytePos, SourceId, Span};
112pub use source::SourceText;
113
114#[cfg(test)]
115mod tests {
116    use std::sync::Arc;
117
118    use crate::ast::modern::Node;
119    use crate::cst::{CstEdit, parse_svelte};
120    use crate::parse_modern_root;
121    use crate::primitives::SourceId;
122    use crate::source::SourceText;
123
124    #[test]
125    fn modern_root_scripts_and_template_expressions_keep_oxc_handles() {
126        let root = parse_modern_root("<script>let count = 0;</script><button>{count + 1}</button>")
127            .expect("modern root should parse");
128
129        let instance = root.instance.as_ref().expect("instance script");
130        assert_eq!(instance.oxc_program().body.len(), 1);
131
132        let Node::RegularElement(element) = &root.fragment.nodes[0] else {
133            panic!("expected regular element");
134        };
135        let Node::ExpressionTag(tag) = &element.fragment.nodes[0] else {
136            panic!("expected expression tag");
137        };
138        assert!(tag.expression.parsed().is_some());
139        assert!(tag.expression.oxc_expression().is_some());
140    }
141
142    #[test]
143    fn incremental_parse_reuses_unchanged_script() {
144        let before = "<script>let count = 0;</script>\n<div>Hello</div>";
145        let after = "<script>let count = 0;</script>\n<div>World</div>";
146        let edit_start = "<script>let count = 0;</script>\n<div>".len();
147        let edit_old_end = "<script>let count = 0;</script>\n<div>Hello".len();
148
149        let old_root = parse_modern_root(before).expect("initial parse");
150
151        let before_src = SourceText::new(SourceId::new(1), before, None);
152        let old_cst = parse_svelte(before_src).expect("initial CST parse");
153
154        let edit = CstEdit::replace(before, edit_start, edit_old_end, "World");
155
156        let new_root = crate::parse_modern_root_incremental(after, before, &old_root, &old_cst, edit)
157            .expect("incremental parse");
158
159        // Script was not in any changed range, so it should be Arc-identical.
160        let old_script = old_root.instance.as_ref().expect("old instance");
161        let new_script = new_root.instance.as_ref().expect("new instance");
162        assert!(
163            Arc::ptr_eq(&old_script.content, &new_script.content),
164            "unchanged script should be Arc-reused (same pointer)",
165        );
166
167        // The template fragment should have reparsed.
168        // There may be whitespace text nodes before the element.
169        let el_node = new_root.fragment.nodes.iter().find(|n| matches!(n, Node::RegularElement(_)))
170            .expect("expected regular element in new root fragment");
171        let Node::RegularElement(new_el) = el_node else { unreachable!() };
172        let Node::Text(text) = &new_el.fragment.nodes[0] else {
173            panic!("expected text node in element fragment");
174        };
175        assert_eq!(text.data.as_ref(), "World");
176    }
177
178    /// Verify that tree-sitter's `changed_ranges` reports *structural* changes only.
179    /// A content-only edit (same tree shape) yields empty changed ranges.
180    #[test]
181    fn verify_tree_sitter_changed_ranges_are_structural_only() {
182        // Both produce identical CST structure (element > start_tag > tag_name, text, end_tag > tag_name)
183        let before = "<div>A</div>";
184        let after = "<div>X</div>";
185
186        let before_src = SourceText::new(SourceId::new(50), before, None);
187        let old_cst = parse_svelte(before_src).expect("cst");
188        let mut edited = old_cst.clone_for_incremental();
189        let edit = CstEdit::replace(before, 5, 6, "X");
190        edited.apply_edit(edit);
191
192        let after_src = SourceText::new(SourceId::new(51), after, None);
193        let new_cst = crate::cst::parse_svelte_with_old_tree(after_src, &edited).expect("cst");
194        let ranges = new_cst.changed_ranges(&edited);
195
196        // Tree-sitter does NOT report content-only changes as changed ranges.
197        // Both trees have the same shape: (document (element (start_tag (tag_name)) (text) (end_tag (tag_name))))
198        assert!(
199            ranges.is_empty(),
200            "tree-sitter changed_ranges should be empty for same-structure edit, got: {ranges:?}"
201        );
202
203        // Now test a structural change: adding a new element
204        let before2 = "<div>A</div>";
205        let after2 = "<div>A</div><span>B</span>";
206        let before_src2 = SourceText::new(SourceId::new(52), before2, None);
207        let old_cst2 = parse_svelte(before_src2).expect("cst");
208        let mut edited2 = old_cst2.clone_for_incremental();
209        let edit2 = CstEdit::insert(before2, before2.len(), "<span>B</span>");
210        edited2.apply_edit(edit2);
211
212        let after_src2 = SourceText::new(SourceId::new(53), after2, None);
213        let new_cst2 = crate::cst::parse_svelte_with_old_tree(after_src2, &edited2).expect("cst");
214        let ranges2 = new_cst2.changed_ranges(&edited2);
215
216        // Structural change: new element added. Should have changed ranges.
217        assert!(
218            !ranges2.is_empty(),
219            "tree-sitter changed_ranges should be non-empty for structural edit"
220        );
221    }
222
223    #[test]
224    fn incremental_parse_reuses_unchanged_sibling_element() {
225        let before = "<div>A</div><span>B</span>";
226        let after = "<div>X</div><span>B</span>";
227        let edit_start = "<div>".len();
228        let edit_old_end = "<div>A".len();
229
230        let old_root = parse_modern_root(before).expect("initial parse");
231
232        let before_src = SourceText::new(SourceId::new(3), before, None);
233        let old_cst = parse_svelte(before_src).expect("initial CST parse");
234
235        let edit = CstEdit::replace(before, edit_start, edit_old_end, "X");
236
237        let new_root = crate::parse_modern_root_incremental(after, before, &old_root, &old_cst, edit)
238            .expect("incremental parse");
239
240        // <span>B</span> was unchanged — should be reused.
241        assert_eq!(new_root.fragment.nodes.len(), 2);
242        let Node::RegularElement(new_span) = &new_root.fragment.nodes[1] else {
243            panic!("expected span element");
244        };
245        assert_eq!(new_span.name.as_ref(), "span");
246
247        // Verify the div was reparsed with new content.
248        let Node::RegularElement(new_div) = &new_root.fragment.nodes[0] else {
249            panic!("expected div element");
250        };
251        let Node::Text(text) = &new_div.fragment.nodes[0] else {
252            panic!("expected text in div");
253        };
254        assert_eq!(text.data.as_ref(), "X");
255    }
256}