tsv_svelte 0.8.0

Svelte formatter and parser compatible with Svelte's parse() — part of tsv
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
// Svelte parser - main entry point for parsing .svelte files

use crate::ast::*;
use crate::lexer::TokenKind;
use crate::parser::element::{ElementParent, ParsedElement, RootOnlyMetaTag};
use crate::whitespace::{is_svelte_ws, skip_svelte_ws, svelte_ws_width_at};
use bumpalo::collections::Vec as BumpVec;
use tsv_lang::source_scan::{TriviaProfile, skip_template_literal, skip_trivia};
use tsv_lang::{Error, Span};

// Module declarations
mod attribute;
mod block;
mod element;
mod expression_tag;
mod fragment;
mod parser_impl;
mod script;
mod style;
mod tag;

// Re-export parser implementation
use parser_impl::SvelteParser;

// The crate's one `{…}` brace matcher, for the lexer's quoted-attribute arm.
pub(crate) use expression_tag::scan_to_matching_brace;

/// Parse a Svelte file and return a Root AST node.
///
/// `arena` owns the entire parsed graph (the template AST plus the embedded TS
/// `<script>`/`{expr}` ASTs, which share this one `Bump`); the returned
/// `Root<'arena>` borrows from it.
pub(crate) fn parse_svelte<'arena>(
    source: &str,
    arena: &'arena bumpalo::Bump,
) -> Result<Root<'arena>, Error> {
    let mut parser = SvelteParser::new(source, arena)?;
    parser.parse_root()
}

impl<'a, 'arena> SvelteParser<'a, 'arena> {
    /// Parse the root node of a Svelte file
    ///
    /// Script and style tags can appear in any order, before/after/between markup.
    /// This parser handles all orderings by parsing linearly and categorizing nodes.
    pub(crate) fn parse_root(&mut self) -> Result<Root<'arena>, Error> {
        let mut instance = None;
        let mut module = None;
        let mut css = None;
        let mut options = None;
        let mut fragment_nodes = self.bvec();
        // Start gap tracking where the lexer's cursor starts: past a leading byte-order mark
        let mut last_end = tsv_lang::leading_bom_len(self.source);

        // Parse the entire file linearly
        while !self.check(TokenKind::Eof) {
            // Check for svelte:options tag (must come first, before other special handling)
            if self.check(TokenKind::LeftAngle) && self.is_next_tag("svelte:options")? {
                // Capture any text before the options tag
                self.capture_text_if_gap(last_end, &mut fragment_nodes)?;

                // The root-only rule, where Svelte raises it — on the tag name, ahead of
                // the attributes. It is what makes this the component's only one.
                self.admit_root_only_meta_tag(
                    RootOnlyMetaTag::Options,
                    "svelte:options",
                    self.current_start(),
                    ElementParent::Root,
                )?;

                // Parse svelte:options tag
                let svelte_options = self.parse_svelte_options()?;
                last_end = svelte_options.span.end_usize();
                debug_assert!(options.is_none(), "the root-only rule admits one");
                options = Some(svelte_options);
            // Check for script or style tags
            } else if self.check(TokenKind::LeftAngle) && self.is_next_tag("script")? {
                // Capture any text before the script tag
                self.capture_text_if_gap(last_end, &mut fragment_nodes)?;

                // Parse script tag
                let script = self.parse_script_tag()?;
                last_end = script.span.end_usize();

                // Assign to instance or module based on script context
                // Valid script configurations:
                //   - 0 scripts
                //   - 1 instance script
                //   - 1 module script
                //   - 2 scripts: exactly 1 instance + 1 module (in any order)
                // Invalid: 2 instance scripts, 2 module scripts, 3+ scripts
                match script.context {
                    ScriptContext::Module => {
                        if module.is_some() {
                            return Err(self.error_duplicate("module script"));
                        }
                        module = Some(self.alloc(script));
                    }
                    ScriptContext::Default => {
                        if instance.is_some() {
                            return Err(self.error_duplicate("instance script"));
                        }
                        instance = Some(self.alloc(script));
                    }
                }
            } else if self.check(TokenKind::LeftAngle) && self.is_next_tag("style")? {
                // Capture any text before the style tag
                self.capture_text_if_gap(last_end, &mut fragment_nodes)?;

                // Parse style tag
                let style = self.parse_style_tag()?;
                last_end = style.span.end_usize();

                if css.is_some() {
                    return Err(self.error_duplicate("style tag"));
                }
                css = Some(self.alloc(style));
            } else {
                // Regular markup: capture text and parse elements/expressions/comments

                // Capture any leading text
                self.capture_text_if_gap(last_end, &mut fragment_nodes)?;

                if self.check(TokenKind::Comment) {
                    let comment = self.parse_comment()?;
                    last_end = comment.span.end_usize();
                    fragment_nodes.push(FragmentNode::Comment(comment));
                } else if self.check(TokenKind::LeftAngle) {
                    match self.parse_element_or_special(ElementParent::Root)? {
                        ParsedElement::Element(elem) => {
                            last_end = elem.span.end_usize();
                            fragment_nodes.push(FragmentNode::Element(elem));
                        }
                        ParsedElement::SpecialElement(elem) => {
                            last_end = elem.span.end_usize();
                            fragment_nodes.push(FragmentNode::SpecialElement(elem));
                        }
                    }
                } else if self.check(TokenKind::LeftBrace) {
                    let tag = self.parse_brace_tag()?;
                    last_end = tag.span().end_usize();
                    fragment_nodes.push(tag);
                } else if self.check(TokenKind::BlockOpen) {
                    let block = self.parse_block()?;
                    last_end = block.span().end_usize();
                    fragment_nodes.push(block);
                } else if self.check(TokenKind::TagOpen) {
                    let tag = self.parse_template_tag()?;
                    last_end = tag.span().end_usize();
                    fragment_nodes.push(tag);
                } else {
                    return Err(self.error_msg(&format!(
                        "Unexpected token in markup: {}",
                        self.current_kind()
                    )));
                }
            }
        }

        // Capture any trailing text after the last element
        // Svelte's behavior: skip trailing whitespace entirely
        //
        // ⚠️ [`is_svelte_ws`] — the JS `\s` Svelte's own reader asks — NOT `str::trim_end`,
        // whose Unicode `White_Space` set disagrees in BOTH directions and so got both
        // witnesses wrong at once: it TRIMS U+0085 NEL, which is not `\s`, dropping the
        // trailing `Text` node canonical emits; and it KEEPS U+FEFF, which is, emitting a
        // node canonical does not (and printing the character where prettier deletes it).
        if self.current_start() > last_end {
            let trailing_text = &self.source[last_end..self.current_start()];
            let trimmed = trailing_text.trim_end_matches(is_svelte_ws);
            if !trimmed.is_empty() {
                // Only capture up to the end of non-whitespace content
                let end_pos = last_end + trimmed.len();
                let text = self.parse_text(last_end, end_pos)?;
                fragment_nodes.push(FragmentNode::Text(text));
            }
        }

        let fragment = Fragment {
            nodes: fragment_nodes.into_bump_slice(),
        };

        // Collect all comments from scripts and template expressions
        let mut comments = Vec::new();
        if let Some(script) = instance {
            comments.extend_from_slice(script.content.comments);
        }
        if let Some(script) = module {
            comments.extend_from_slice(script.content.comments);
        }
        // Add expression comments collected during template parsing
        // Currently extracted from: {@debug} tags (intentional divergence from prettier)
        // Future: could extend to other template tags if needed
        comments.append(&mut self.expression_comments);
        // Sort by position for consistent lookup via comments_to_emit_in_range()
        comments.sort_by_key(|c| c.span.start);
        // CSS comments deliberately stay on the `css` island — the wire emits them under
        // the style node via `write_css_comments`, matching where parseCss puts them —
        // so they are never merged into `Root.comments`.

        let snippet_wire_parameters = std::mem::replace(
            &mut self.snippet_wire_parameters,
            BumpVec::new_in(self.arena),
        )
        .into_bump_slice();

        Ok(Root {
            fragment,
            instance,
            module,
            css,
            options,
            comments,
            snippet_wire_parameters,
            holds_nested_raw_text: self.holds_nested_raw_text,
            nested_raw_text_nesting: self.nested_raw_text_nesting,
        })
    }

    /// Parse `<svelte:options ... />` tag
    ///
    /// svelte:options has no children, but it may be written with an explicit closing tag
    /// (`<svelte:options></svelte:options>`), whose `>` the span must reach.
    /// It configures component behavior via attributes like `runes`, `customElement`, etc.
    fn parse_svelte_options(&mut self) -> Result<SvelteOptions<'arena>, Error> {
        let start = self.current_start();

        // Parse opening: <svelte:options
        self.expect(TokenKind::LeftAngle)?;
        // Read the name's end before consuming it — the attribute list's leading comment gap
        // starts here.
        let name_end = self.current_end() as u32;
        self.expect(TokenKind::Identifier)?; // "svelte:options"

        // Parse attributes
        let attributes = self.parse_attributes()?;

        // Terminate the opening tag through the shared seam, so this tag can't drift from
        // element parsing on what `/` and `>` mean. `after_gt` is both the span end for the
        // self-closing form and where the closing tag must begin for the other.
        let opening = self.finish_opening_tag()?;

        let end = if opening.self_closing {
            opening.after_gt as u32
        } else {
            // `<svelte:options>` holds no children — Svelte's `disallow_children`, run
            // over the RAW fragment before any whitespace cleaning, so a lone space and a
            // comment are content too (`svelte_meta_invalid_content`). tsv rejects rather
            // than defers because `SvelteOptions` carries no fragment at all: a child is
            // unrepresentable, so the alternatives are dropping it (content loss) or
            // letting it fall out to the ROOT fragment, which fabricates a node Svelte's
            // AST never contains. One position test covers every kind of content, since
            // the lexer skips whitespace but never moves a token backward.
            if self.current_start() != opening.after_gt {
                return Err(
                    self.error_msg_at("<svelte:options> cannot have children", opening.after_gt)
                );
            }
            self.parse_closing_tag("svelte:options")?
        };

        Ok(SvelteOptions {
            attributes: attributes.into_bump_slice(),
            span: Span {
                start: start as u32,
                end,
            },
            name_end,
            open_tag_end: opening.gt,
        })
    }
}

/// Byte offset of `inner` within `outer`, derived from pointer identity.
///
/// `inner` MUST be a subslice of `outer` (the product of `trim`, `strip_prefix`, or
/// range slicing — all zero-copy). Searching by content (`str::find`) misattributes
/// the position whenever the text also occurs earlier in `outer` — `{@html html}`
/// resolved the expression to the `html` inside the keyword.
pub(crate) fn subslice_offset(outer: &str, inner: &str) -> usize {
    debug_assert!(
        (inner.as_ptr() as usize) >= (outer.as_ptr() as usize)
            && (inner.as_ptr() as usize) + inner.len() <= (outer.as_ptr() as usize) + outer.len(),
        "inner is not a subslice of outer"
    );
    (inner.as_ptr() as usize) - (outer.as_ptr() as usize)
}

/// Find the closing tag of a raw-text `<script>` / `<style>` at or after `from` — the
/// byte offset of the `<` in `</tag…>`, or `None` if none exists. `tag` is the bare
/// lowercase name (e.g. `b"script"`), matched as a full token: the byte after it must
/// be whitespace or `>`, so `</scripts>` never matches.
///
/// A raw byte scan — like Svelte's `read_until(regex_closing_script_tag)` it matches the
/// first *textual* `</tag…>` regardless of string/comment context, so a literal
/// `"</script>"` in the body closes the block for both parsers (the shared, documented
/// raw-text limitation).
///
/// Two variants mirror Svelte's own two code paths — **do not collapse them:**
///
/// - `find_raw_text_close` tolerates whitespace before `>` (`/<\/tag\s*>/`), for the
///   **top-level** component `<script>` / `<style>` Svelte reads via `read_script` /
///   `read_style` — so `</script  >` and `</style\n>` parse.
/// - `find_exact_tag_close` requires an exact `</tag>`, for a **nested** `<script>` /
///   `<style>` in markup, which Svelte reads through its generic element parser: it
///   rejects `</script  >` there, and tsv matches that (verified parity). Migrating it
///   to the whitespace-tolerant scan would introduce a divergence.
pub(crate) fn find_raw_text_close(source: &str, from: usize, tag: &[u8]) -> Option<usize> {
    find_tag_close(source, from, tag, true)
}

/// Exact-`</tag>` sibling of `find_raw_text_close` (no whitespace before `>`); see there
/// for the whitespace-tolerant-vs-exact split and why both exist.
pub(crate) fn find_exact_tag_close(source: &str, from: usize, tag: &[u8]) -> Option<usize> {
    find_tag_close(source, from, tag, false)
}

/// If a whitespace/attribute-tolerant closing tag `</tag…>` starts **exactly** at byte
/// `i`, return `(lt, gt)` — the `<` offset (`== i`) and the closing `>` offset;
/// otherwise `None`.
///
/// Ports Svelte's RCDATA close `regex_closing_textarea_tag = /<\/textarea(\s[^>]*)?>/iy`
/// (`1-parse/state/element.js`): sticky at `i`, case-insensitive on the tag name, then
/// either `>` immediately or **one** whitespace followed by any run of non-`>` up to the
/// first `>`. The RCDATA reader calls it at every content byte (the sticky-at-each-index
/// model of Svelte's `read_sequence` `done()` predicate).
///
/// Distinct from the raw-text finders above on three counts, so it can't reuse them: it
/// tolerates attributes on the close (`</textarea data-x >`, not just `\s*>`), it matches
/// the tag name case-insensitively (`</TEXTAREA>`), and it reports the `>` offset (the
/// reader needs it for the element end).
pub(crate) fn rcdata_close_at(source: &str, i: usize, tag: &[u8]) -> Option<(usize, usize)> {
    let bytes = source.as_bytes();
    if bytes.get(i) != Some(&b'<') || bytes.get(i + 1) != Some(&b'/') {
        return None;
    }
    let name_start = i + 2;
    let rest = bytes.get(name_start..)?;
    if rest.len() < tag.len() || !rest[..tag.len()].eq_ignore_ascii_case(tag) {
        return None;
    }
    // `</` + an ASCII tag name, so this is a char boundary — safe to decode from.
    let after_name = name_start + tag.len();
    match bytes.get(after_name) {
        // `</textarea>` — closes directly.
        Some(b'>') => Some((i, after_name)),
        // `</textarea\s[^>]*>` — the required whitespace (the full `\s`, so
        // `</textarea\u{3000}>` closes), then non-`>`* to the first `>`. Any other
        // trailing character (`</textareax`, `</textarea/`) is not a close, and falls
        // out as the `?` on the width lookup.
        Some(_) => {
            let mut j = after_name + svelte_ws_width_at(source, after_name)?;
            while let Some(&c) = bytes.get(j) {
                if c == b'>' {
                    return Some((i, j));
                }
                j += 1;
            }
            None
        }
        // Trailing EOF (`</textarea`) is not a close.
        None => None,
    }
}

/// Shared core of `find_raw_text_close` / `find_exact_tag_close`; `allow_ws_before_gt`
/// selects the whitespace-tolerant (`\s*>`) vs exact (`>`) close. Callers pick a named
/// wrapper so the boolean never reaches a call site.
fn find_tag_close(
    source: &str,
    from: usize,
    tag: &[u8],
    allow_ws_before_gt: bool,
) -> Option<usize> {
    let bytes = source.as_bytes();
    // Every caller anchors `from` just past a `>`, so it is a char boundary; the hop below
    // then only ever lands on `<`, which is ASCII. Stated as an assert because the `?` on
    // the re-slice would turn a mid-character `from` into a silent "no close tag" instead
    // of a panic.
    debug_assert!(source.is_char_boundary(from));
    let mut i = from;
    // Hop to each `<` rather than testing every byte. A single-`char` pattern reaches
    // `memchr` — through `CharSearcher`, so it is an out-of-line `next_match` call plus
    // that searcher's own setup around it, not a bare `memchr` — and the run between
    // two `<`s in a raw-text body is long (a real component averages one per ~1.2-1.5 KB
    // of `<script>`/`<style>` body), so the per-byte test this loop used to run was
    // paying its whole cost on bytes that cannot begin a close tag. `i` only ever sits on `from` (a tag boundary) or on a `<`, both
    // ASCII, so the re-slice is always on a char boundary; an `i` past the end answers
    // `None`, which is this scan's own "no close tag" result.
    //
    // ⚠️ It trades a per-BYTE cost for a per-`<` one, so it has a density axis: measured
    // `instructions:u` **−2.45%** on a 1,695-file Svelte corpus and **−1.40%** on a
    // synthetic body carrying a `<` every 42 bytes, but **+0.91%** at one every 15.
    // Break-even is near one `<` per ~25 bytes, and the densest body in that whole real
    // corpus is 28.8 — so the tax needs a script an order of magnitude more
    // angle-bracket-dense than anything real. Re-entering `find` per candidate (rather
    // than holding one `match_indices` searcher across them) is what buys the mainline
    // case: the searcher form is ~0.14 points worse there and ~0.1-0.3 better on the
    // dense shapes, and the mainline is where every measured document sits.
    while let Some(rel) = source.get(i..)?.find('<') {
        i += rel;
        if bytes.get(i + 1) == Some(&b'/')
            && bytes.get(i + 2..).is_some_and(|rest| rest.starts_with(tag))
        {
            // `</` + an ASCII tag name, so this is a char boundary — safe to decode from.
            let mut j = i + 2 + tag.len();
            if allow_ws_before_gt {
                // The full `\s` of `/<\/tag\s*>/`, so `</style\u{a0}>` closes.
                j = skip_svelte_ws(source, j);
            }
            if bytes.get(j) == Some(&b'>') {
                return Some(i);
            }
        }
        i += 1;
    }
    None
}

/// Find the first `target` byte in `bytes[start..end]` that sits at bracket depth 0
/// (outside `()`/`[]`/`{}`) and outside trivia (comments + strings per `profile`),
/// returning its byte offset or `None`.
///
/// The trivia-aware replacement for the hand-rolled top-level scans over Svelte
/// binding/declaration strings — the `{@const}` init's separator `,`, the `{@debug}`
/// printer's identifier gap: a `target` glyph inside a
/// comment or string can't mis-anchor the scan. `target` must not itself be a bracket
/// or a trivia-introducing byte (`/`, `'`, `"`, `` ` ``).
///
/// ⚠️ **Depth is `()`/`[]`/`{}` only — `<`/`>` is deliberately UNTRACKED**, because the
/// glyphs are ambiguous: a relational `>` (`{ a = b > c }`) would decrement a depth it
/// never opened. So over a region that can hold **TS type syntax** a hit is not evidence
/// of a top-level anything — a type argument list's `,` (`Map<A, B>`) and a function
/// type's `=>` both land here at depth 0. Every such caller has to narrow the question
/// until the scan is sound on it, and each states its own narrowing:
/// `SvelteParser::reject_multi_declarator` gates the scan on the parsed node. A boundary
/// the grammar defines — the `{@const}` declarator's `=`, a binding's annotation end — is
/// the parser's to find, never this scan's (`SvelteParser::parse_block_pattern`).
pub(crate) fn find_top_level_delim(
    bytes: &[u8],
    start: usize,
    end: usize,
    target: u8,
    profile: TriviaProfile,
) -> Option<usize> {
    let mut depth: i32 = 0;
    let mut i = start;
    while i < end {
        if let Some(past) = skip_trivia(bytes, i, end, profile) {
            i = past;
            continue;
        }
        match bytes[i] {
            b'(' | b'[' | b'{' => depth += 1,
            b')' | b']' | b'}' => depth = depth.saturating_sub(1),
            b if b == target && depth == 0 => return Some(i),
            _ => {}
        }
        i += 1;
    }
    None
}

/// Find the byte offset of the bracket matching the `open` byte at `bytes[open_pos]`,
/// scanning forward past nested `open`/`close` pairs and skipping trivia (comments +
/// strings per `profile`). Returns the matching `close` offset, or `None` if the
/// brackets never balance before `end`.
///
/// The trivia-aware core shared by the Svelte binding bracket matchers
/// (`{ }`/`[ ]` destructuring patterns, `< >` snippet generics, `( )` snippet
/// params) — a `close` inside a comment or string can't end the match early, and
/// the cursor's escape-correct string handling fixes the former `ends_with('\\')`
/// escape bug. Template literals (in a pattern default like `{ a = `…` }`) are
/// intercepted and skipped interpolation-aware via `skip_template_literal`, since
/// `skip_trivia`'s opaque `` ` ``-to-`` ` `` scan mis-pairs across a nested template.
pub(crate) fn match_bracket(
    bytes: &[u8],
    open_pos: usize,
    end: usize,
    open: u8,
    close: u8,
    profile: TriviaProfile,
) -> Option<usize> {
    debug_assert_eq!(
        bytes.get(open_pos),
        Some(&open),
        "match_bracket must start at the opening bracket"
    );
    let mut depth: u32 = 1;
    let mut i = open_pos + 1;
    while i < end {
        // Template literal — skip it whole, interpolation-aware. `skip_trivia`'s
        // opaque quote-to-quote handling mis-pairs backticks across a nested
        // template (`` `${`x`}` ``), so a pattern default like `{ a = `${`"`}` }`
        // would swallow past the closing bracket. Gated on `profile.strings` (the
        // JS binding-pattern callers), matching where `skip_trivia` treats `` ` ``
        // as a string. An interpolation is acorn's to parse, like every island.
        if profile.strings && bytes[i] == b'`' {
            i = skip_template_literal(bytes, i, end, tsv_ts::__private::ACORN_ISLAND_GRAMMAR);
            continue;
        }
        if let Some(past) = skip_trivia(bytes, i, end, profile) {
            i = past;
            continue;
        }
        let b = bytes[i];
        if b == open {
            depth += 1;
        } else if b == close {
            depth -= 1;
            if depth == 0 {
                return Some(i);
            }
        }
        i += 1;
    }
    None
}