tsv_svelte 0.8.0

Svelte formatter and parser compatible with Svelte's parse() — part of tsv
Documentation
// Script and Style section formatting for Svelte components
//
// Handles the top-level <script> and <style> sections in .svelte files.
// These sections contain TypeScript/JS and CSS respectively,
// which are formatted using their dedicated printers.

use crate::ast;
use crate::printer::Printer;
use tsv_lang::TAB_WIDTH;
use tsv_lang::doc::{self, DocBuf, DocContext, arena::DocId};

impl<'a> Printer<'a> {
    /// Format a Script tag
    ///
    /// Formats both regular `<script>` and `<script context="module">` tags.
    /// The TypeScript content is formatted using the TypeScript printer with indentation.
    ///
    /// Uses Doc-based integration instead of string post-processing. The Doc system
    /// naturally handles template literals correctly: `indent(doc)` only affects `Line` docs
    /// (hardline, softline, line), not newlines inside `text()` which output as-is.
    pub(super) fn print_script(&mut self, script: &ast::Script<'_>) {
        // Check if script had any original content (including whitespace)
        let had_content = script.content.span.start != script.content.span.end;

        // Opening tag with doc-based attribute wrapping
        self.write_section_opening_tag("script", script.attributes, had_content);

        // A frozen-language body (`lang="coffee"`, `type="application/json"`, …) freezes:
        // the author's bytes ride out verbatim between the two delimiter newlines —
        // prettier's `preformattedBody` shape exactly, which is what it emits for its own
        // frozen scripts. The AST is ignored, but the body still PARSED (canonical Svelte
        // runs acorn on every script body regardless of `lang`, and so does tsv), so its JS
        // comments are registered on the print-once ledger and the slice must declare
        // them. See `ast::EmbeddedLang` for the allowlist.
        if ast::EmbeddedLang::Script.is_frozen(script.attributes, self.source) {
            return self.write_frozen_section("script", script.content.span);
        }

        if had_content {
            self.write("\n");
        }

        // Build Doc for script content
        // Width calculations are handled by:
        // - start_column for the first line
        // - start_indent_level for subsequent lines after hardline
        // Template indent fallback (when source has no whitespace) is handled
        // separately in the TypeScript printer with a hardcoded default of 1 for Svelte
        // context.
        // A `<script>` body is the one Svelte island whose printer owns whole LINES: its
        // statements end with breaks of their own and the closing `</script>` takes a fresh
        // line, so a `//` deferred to a line end still lands in JS. Every TEMPLATE island
        // takes the safe default instead — past a `{…}` the rest of the line is markup, and
        // a deferred comment would come out as page text.
        let embed = tsv_lang::EmbedContext::line_owning();
        let script_doc_id = tsv_ts::__private::build_program_doc(
            self.d(),
            &script.content,
            self.source(),
            self.line_table(),
            embed,
        );

        // Render with indent
        // The Doc system naturally handles template literals: text() newlines are NOT indented
        // We render at indent_level=1 so hardlines produce proper indentation
        // The first line needs manual indentation since there's no hardline before it
        //
        // start_column = tab_width (2) to account for the initial indent we'll add
        // start_indent_level = 1 to account for the Svelte wrapper indent
        // Render into the arena-parked scratch (one warm buffer, no per-block alloc).
        let mut output = self.d().take_render_scratch();
        // Pass the document source: the embedded TS doc carries `DocText::SourceSpan`
        // leaves (comments etc.) whose spans are absolute into the host source.
        doc::arena_print_doc_with_indent_resolved_into(
            self.d(),
            script_doc_id,
            &embed,
            TAB_WIDTH, // start column = 1 tab's visual width
            1,         // start indent level = 1 (accounts for Svelte wrapper)
            self.source(),
            &mut output,
        );

        // Only write content if there is any (skip indent for empty scripts)
        // The output always ends with a hardline (\n), so non-empty content is at least "\n"
        // For empty content (just comments or empty body), output is just "\n"
        if !output.trim().is_empty() {
            // Write first line's indent manually (doc only indents after hardlines)
            self.indent_level += 1;
            self.write_indent();
            self.indent_level -= 1;
            self.write(&output);
        }
        self.d().park_render_scratch(output);

        // Closing tag
        self.write("</script>\n");
    }

    /// Format a Style tag
    ///
    /// Formats the `<style>` tag with its CSS content.
    /// The CSS is formatted using the CSS printer with indentation.
    /// For non-CSS languages (less, scss, etc.), content is preserved raw.
    pub(super) fn print_style(&mut self, style: &ast::Style<'_>) {
        // Check if there was any original content (including whitespace)
        let had_content = style.content_span.start != style.content_span.end;

        // Opening tag with doc-based attribute wrapping
        self.write_section_opening_tag("style", style.attributes, had_content);

        // A frozen-language body freezes: the author's bytes ride out verbatim between the
        // two delimiter lines — the same shape the `<script>` and `<template>` freezes give
        // their bodies, and prettier's `preformattedBody`. The shared gate reads the DECODED
        // value (`lang="&#99;ss"` is css) — see `ast::EmbeddedLang`.
        if ast::EmbeddedLang::Style.is_frozen(style.attributes, self.source) {
            return self.write_frozen_section("style", style.content_span);
        }

        // Format CSS content if present (nodes or comments)
        if !style.css_stylesheet.nodes.is_empty() || !style.css_stylesheet.comments.is_empty() {
            self.write("\n");

            // Pass the entire source to the CSS printer (CSS node spans are
            // absolute, which keeps blank-line detection correct) and share the
            // printer's whole-source line_breaks table rather than rebuilding it
            // per <style> island. `base_indent_offset` is the wrapper indent level
            // the CSS renders at directly (the CSS printer folds it into its
            // starting `indent_level`), so the result needs no post-hoc re-indent.
            let embed = tsv_lang::EmbedContext {
                base_indent_offset: self.indent_level + 1,
                ..tsv_lang::EmbedContext::default()
            };
            // Share the host document's doc arena rather than allocating a fresh
            // whole-host-sized one per `<style>` block. The embedded CSS renders
            // to its own string; only doc-node storage is shared, and the arena is
            // not reset, so the host's in-flight nodes stay valid.
            let formatted_css = tsv_css::__private::format_embedded_in(
                &style.css_stylesheet,
                self.source(),
                self.line_table(),
                embed,
                self.css_host_scan(),
                self.d(),
            );

            // The CSS is already at its final indentation, so it's written
            // verbatim — no per-line re-indent. The old post-hoc re-indenter had to
            // special-case verbatim content (block-comment interiors) because it
            // otherwise compounded a preserved newline one indent level per format
            // pass; rendering at the wrapper level upfront removes that hazard
            // uniformly for every verbatim form (raw at-rule preludes, comment
            // interiors, `Invalid` selector text). Trim the CSS's own trailing
            // newline; the closing tag supplies its line break.
            self.write(formatted_css.trim_end());
            self.write("\n");
        } else if had_content {
            // Preserve block structure when original had whitespace-only content
            self.write("\n");
        }

        // Closing tag
        self.write("</style>\n");
    }

    /// Build the attribute-list doc for the one head this applies to: `<script>` / `<style>`.
    ///
    /// Returns `[line, attr1, line, attr2, …]` — un-indented, so the caller can ask
    /// [`will_break`](tsv_lang::doc::arena::DocArena::will_break) of it before wrapping it,
    /// the same way `print_svelte_options` asks of its own list. Two builders, one question,
    /// one way of asking it: accumulating a `has_multiline` flag here instead answered the
    /// same thing by a second route, and a second route is a second thing to keep in step.
    ///
    /// ⚠️ **Comment-blind by construction** — it walks `attributes` and never probes the gaps
    /// between them, so a comment in such a head would be dropped. That is sound *here and
    /// only here*: a top-level `<script>` / `<style>` head is read by Svelte's
    /// `read_static_attribute`, which has no comment loop, so a `/* c */` in it makes the
    /// required `>` missing and the component is **rejected** — the shape never reaches this
    /// builder. Every other tag head, including `<svelte:options>`, goes through
    /// `Printer::push_attrs_with_comments`, which probes the gaps. Do not reach for this one
    /// from a head whose attributes come from the ordinary `read_attribute`.
    fn build_section_attrs_doc(&self, attributes: &[ast::AttributeNode<'_>]) -> DocId {
        let d = self.d();
        let mut parts: DocBuf = DocBuf::with_capacity(attributes.len() * 2);
        for attr in attributes {
            parts.push(d.line());
            parts.push(self.build_attribute_node_doc(attr, false));
        }
        d.concat(&parts)
    }

    /// Build and render an opening tag for `<script>` or `<style>` with doc-based
    /// attribute wrapping. Attributes wrap when any value contains embedded newlines
    /// or the total line width exceeds print_width.
    ///
    /// `has_content`: when false, the closing tag follows on the same line
    /// (e.g., `<script lang="ts"></script>`), so the render reserves its width on the
    /// tag's last line.
    ///
    /// Flat: `<tag attr1 attr2>`
    /// Break: `<tag\n\tattr1\n\tattr2\n>`
    fn write_section_opening_tag(
        &mut self,
        tag_name: &str,
        attributes: &[ast::AttributeNode<'_>],
        has_content: bool,
    ) {
        if attributes.is_empty() {
            self.write("<");
            self.write(tag_name);
            self.write(">");
            return;
        }

        let d = self.d();
        let attrs = self.build_section_attrs_doc(attributes);
        let has_multiline = d.will_break(attrs);
        let attr_indent = d.indent(attrs);
        let softline = d.softline();
        let tag_text = {
            let mut w = d.pool_writer();
            w.push('<');
            w.push_str(tag_name);
            w.finish_text()
        };
        let inner = d.concat(&[tag_text, attr_indent, softline, d.text(">")]);

        let group = if has_multiline {
            d.group_break(inner)
        } else {
            d.group(inner)
        };

        // When empty (no content), the closing tag follows on the same line as the tag's
        // `>`: `<script lang="ts"></script>`. A measurement-only trailing node (it renders
        // nothing) reserves its width, so the group breaks when that whole line exceeds
        // print_width.
        let doc = if has_content {
            group
        } else {
            // "</tag>" = 3 + tag_name.len()
            let closing_tag_width = 3 + tag_name.len();
            let marker = d.with_context(d.empty(), DocContext::reserving(closing_tag_width));
            d.concat(&[group, marker])
        };
        let col = self.buffer.current_column(TAB_WIDTH);
        // Render into the arena-parked scratch (one warm buffer, no per-tag alloc).
        let mut output = self.arena.take_render_scratch();
        doc::arena_print_doc_with_indent_resolved_preserve_whitespace_into(
            self.arena,
            doc,
            &self.embed,
            col,
            self.indent_level,
            self.source(),
            &mut output,
        );
        self.write(&output);
        self.arena.park_render_scratch(output);
    }
}