tsv_css 0.8.0

CSS formatter and parser compatible with Svelte's parseCss — part of tsv
Documentation
//! Plumbing for the sibling tsv crates — the Svelte embedding (`tsv_svelte`), the
//! bindings' shared format path (`tsv_arena`), and the dev tooling. Not public API:
//! nothing here is covered by semver, and any item may change or go away in any release.

use super::*;
use crate::ast::CssStyleSheet;

/// [`parse`] over a document the format path folded
/// (`tsv_lang::printing::normalize_carriage_returns`) — the parse every CSS format entry
/// point runs (the CLI, the bindings' `parse_format!`, [`format()`]), its error reported in
/// the caller's own coordinates (`FoldedSource::parse_with`). The fold keeps meaning
/// everywhere in CSS, whose syntax normalizes a `<CR>` to `<LF>` itself (css-syntax-3
/// §Preprocessing the input stream), so this is the folded parse alone;
/// `tsv_svelte::__private::parse_folded` is the sibling that is not.
pub fn parse_folded<'arena>(
    folded: &tsv_lang::printing::FoldedSource<'_>,
    arena: &'arena bumpalo::Bump,
) -> Result<CssStyleSheet<'arena>> {
    folded.parse_with(|text| parse(text, arena))
}

/// Parse embedded CSS source into internal AST
///
/// Use this when parsing CSS embedded in another language (e.g., Svelte `<style>` tags)
/// where span positions need to reflect the offset in the parent file.
///
/// # Arguments
/// * `source` - CSS source code
/// * `base_offset` - Offset in parent file (for error reporting and span calculation)
/// * `host_nesting` - The nesting the host document has spent around the stylesheet, in
///   wire levels: the parse counts its own on top of it against `tsv_lang::NESTING_LIMIT`
///
/// # Returns
/// * `Ok(CssStyleSheet)` - Parsed AST with nodes and value comments
/// * `Err(Error)` - Parse error positioned in the parent file
///
/// `arena` owns the returned AST's nodes (shared with the host document's arena
/// when CSS is embedded in a Svelte `<style>`).
///
/// # Errors
///
/// An error is positioned in the parent file (`base_offset` included) but carries no
/// point — [`Error::wire_point`] is `None` — until the host fills its context over
/// the whole document ([`Error::with_context`], as `tsv_svelte::__private::parse` does): a
/// context taken over `source`, a slice, would read that position against the wrong text.
pub fn parse_embedded<'arena>(
    source: &str,
    base_offset: usize,
    host_nesting: u32,
    arena: &'arena bumpalo::Bump,
) -> Result<CssStyleSheet<'arena>> {
    parser::parse_css(source, base_offset, host_nesting, arena)
}

/// [`format_ast_in`] over a document the caller folded ahead of the parse
/// (`tsv_lang::printing::normalize_carriage_returns`) — the format entry points that fold
/// (the CLI, the bindings, [`format()`]). Identical output; the document's line verdict
/// comes from the fold's own pass instead of a second walk of the source.
pub fn format_folded_in(
    stylesheet: &CssStyleSheet<'_>,
    folded: &tsv_lang::printing::FoldedSource<'_>,
    arena: &tsv_lang::doc::arena::DocArena,
) -> String {
    printer::format_css_folded_in(stylesheet, folded, arena)
}

/// Format an embedded CSS stylesheet into a caller-provided doc arena.
///
/// `embed.base_indent_offset` seeds the indent so wrapped lines respect the host's
/// indentation, and `source` is the whole host document (spans are absolute), so
/// `line_table` is the host's whole-source table — never island-local.
///
/// The doc IR is built into `arena` rather than a freshly allocated one, so a Svelte host
/// formatting a `<style>` block shares its own document arena instead of allocating a
/// second whole-host-sized `DocArena` per block. Nothing borrowed from `arena` escapes (the
/// embedded CSS renders to an owned `String`), and the arena is **not** reset, so the
/// host's in-flight doc nodes stay valid across the call.
///
/// `host_scan` is [`HostBoundaryScan::of`] over the same `source`, taken once per host and
/// handed to every island in it.
///
/// There is no fresh-arena twin: an embedded stylesheet always has a host arena to lend,
/// and the Svelte host is the only caller.
pub fn format_embedded_in(
    stylesheet: &CssStyleSheet<'_>,
    source: &str,
    line_table: tsv_lang::printing::LineTable<'_>,
    embed: tsv_lang::EmbedContext,
    host_scan: HostBoundaryScan,
    arena: &tsv_lang::doc::arena::DocArena,
) -> String {
    printer::format_css_embedded_in(stylesheet, source, line_table, embed, host_scan, arena)
}

/// A host document's answer to the CSS printer's boundary-whitespace precondition — whether
/// the whole source holds a member of the class anywhere (the CSS printer's
/// §The document precondition, in `printer/boundary_ws.rs`).
///
/// One answer per host, not per island: every `<style>` island of a Svelte component is
/// spanned against the same host source, so the host takes it once ([`Self::of`]) and hands
/// it to each [`format_embedded_in`] call instead of paying a whole-source scan per island.
/// Opaque, so the only way to hold one is to have scanned a source.
#[derive(Clone, Copy, Debug)]
pub struct HostBoundaryScan {
    pub(crate) holds_boundary_ws: bool,
}

impl HostBoundaryScan {
    /// Scan `source`, the whole host document the islands are spanned against.
    #[must_use]
    pub fn of(source: &str) -> Self {
        Self {
            holds_boundary_ws: printer::source_holds_boundary_ws(source),
        }
    }
}

#[cfg(feature = "json")]
pub use crate::ast::convert::{write_css_children, write_css_comments};

/// Format a CSS stylesheet to a formatted string — the hidden, AST-taking primitive beneath
/// [`format()`], the public one-shot (parse and format a `&str`). Unlike [`format()`] it does no fold.
///
/// `source` must hold **no carriage return**: the printers split lines on `\n` alone, so a
/// `\r` would reach the output verbatim and the same document could format two ways on two
/// passes. Parse a CR-folded text (`tsv_lang::printing::normalize_carriage_returns`, via
/// `parse_folded`) and format it with `format_folded_in`, or call [`format()`], which
/// does both. Debug builds assert it.
///
/// # Arguments
/// * `stylesheet` - CSS stylesheet (nodes + value comments)
/// * `source` - Original CSS source code (for blank line preservation)
///
/// # Returns
/// * Formatted CSS string
///
/// # Example
/// ```
/// use tsv_css::__private::{format_ast, parse};
///
/// let css = "div{color:red;}";
/// let arena = bumpalo::Bump::new();
/// let stylesheet = parse(css, &arena).expect("Failed to parse CSS");
/// let formatted = format_ast(&stylesheet, css);
/// assert_eq!(formatted, "div {\n\tcolor: red;\n}\n");
/// ```
pub fn format_ast(stylesheet: &CssStyleSheet<'_>, source: &str) -> String {
    printer::format_css(stylesheet, source)
}

/// Format into a caller-provided doc arena.
///
/// The same precondition on `source` as [`fn@format_ast`]: no carriage return.
///
/// Identical output to [`fn@format_ast`], but the doc IR is built into `arena` instead
/// of a freshly allocated one, so a driver that formats many files can reuse one
/// arena across them (`arena.reset()` between files retains the buffers). Nothing
/// borrowed from `arena` escapes — the result is an owned `String`.
pub fn format_ast_in(
    stylesheet: &CssStyleSheet<'_>,
    source: &str,
    arena: &tsv_lang::doc::arena::DocArena,
) -> String {
    printer::format_css_in(stylesheet, source, arena)
}

/// Parse CSS source into internal AST
///
/// # Arguments
/// * `source` - CSS source code
///
/// # Returns
/// * `Ok(CssStyleSheet)` - Parsed AST with nodes and value comments
/// * `Err(Error)` - Parse error with position and context
///
/// # Example
/// ```
/// use tsv_css::__private::parse;
///
/// let css = "div { color: red; }";
/// let arena = bumpalo::Bump::new();
/// let stylesheet = parse(css, &arena).expect("Failed to parse CSS");
/// ```
///
/// `arena` owns every AST node of the returned [`CssStyleSheet`]
/// (caller-owns-`Bump`); the stylesheet borrows from it for `'arena`.
pub fn parse<'arena>(source: &str, arena: &'arena bumpalo::Bump) -> Result<CssStyleSheet<'arena>> {
    Error::ensure_source_fits(source)?;
    parser::parse_css(source, 0, 0, arena).map_err(|e| e.with_context(source, WIRE_COORDINATES))
}

/// Convert CSS AST to compact JSON wire bytes — the **sole emission path** for its wire
///
/// The span-only wire every binding ships, and `tsv parse`'s default: a standalone
/// `StyleSheetFile` matching Svelte's `parseCss()` JSON shape exactly (no `attributes` or
/// `content` fields, `end` set to the full source length), `start`/`end` offsets with no
/// `loc`, and no line table built at all. The writer (`ast/convert/write.rs`) walks the
/// internal AST once and emits the wire JSON directly, never materializing a typed public
/// tree, fusing the byte→char offset translation into the walk (each position through a
/// byte→UTF-16 map; identity on ASCII). The bytes are valid UTF-8 by construction (source
/// slices + ASCII fragments), and byte-oriented consumers skip the O(output) validation a
/// `String` requires.
#[cfg(feature = "json")]
pub fn convert_ast_json_bytes(stylesheet: &CssStyleSheet<'_>, source: &str) -> Vec<u8> {
    ast::convert::write_stylesheet_file_bytes(stylesheet, source, tsv_lang::Wire::Span)
}

/// Convert internal AST to compact JSON wire bytes **with** per-node `loc`.
///
/// The `convert_ast_json_bytes` wire plus a `loc` on every object carrying `start`/`end` —
/// `parseCss` emits none; tsv's is the line (1-based, LF-only) and column (0-based, UTF-16
/// code units) of those offsets. `tsv parse --locations` writes it.
#[cfg(feature = "locations")]
pub fn convert_ast_json_bytes_with_locations(
    stylesheet: &CssStyleSheet<'_>,
    source: &str,
) -> Vec<u8> {
    ast::convert::write_stylesheet_file_bytes(stylesheet, source, tsv_lang::Wire::Loc)
}

/// The `String` form of `convert_ast_json_bytes` for `&str` boundaries (the WASM
/// binding's `JSON.parse`, N-API strings): same wire bytes plus one UTF-8 validation of
/// the output. Byte-oriented consumers should prefer the bytes variant.
///
/// There is no `_with_locations` `String` twin: the bindings this form serves ship the
/// span-only wire alone, and `parse_to_json_with` validates the loc-bearing bytes itself.
#[cfg(feature = "json")]
#[expect(clippy::expect_used)]
pub fn convert_ast_json_string(stylesheet: &CssStyleSheet<'_>, source: &str) -> String {
    String::from_utf8(convert_ast_json_bytes(stylesheet, source))
        .expect("writer emits valid UTF-8 (source slices + ASCII fragments)")
}