tsv_css 0.8.0

CSS formatter and parser compatible with Svelte's parseCss — part of tsv
Documentation
//! CSS formatting, and parsing to the wire JSON of Svelte's `parseCss` — part of
//! [tsv](https://github.com/fuzdev/tsv).
//!
//! [`format()`] formats one stylesheet in one call; a [`Formatter`] formats many on one
//! thread, keeping its arenas warm between calls. The formatter is non-configurable:
//! Prettier's style with a print width of 100, tabs and single quotes. Formatting output
//! is not frozen — it changes between releases as formatting bugs are fixed.
//!
//! ## Usage
//!
//! ```
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! use tsv_css::{Formatter, format};
//!
//! assert_eq!(format("a{color:red}")?, "a {\n\tcolor: red;\n}\n");
//! let mut formatter = Formatter::new();
//! assert_eq!(formatter.format("b{}")?, "b {\n}\n");
//!
//! // Parse to the wire JSON AST (the `json` feature, on by default)
//! # #[cfg(feature = "json")]
//! # {
//! let json = tsv_css::parse_to_json("a{}")?;
//! assert!(json.starts_with(r#"{"type":"StyleSheetFile""#));
//! # }
//! # Ok(())
//! # }
//! ```
//!
//! The wire JSON is the AST as JSON in the canonical parser's shape — Svelte's `parseCss`'s
//! here. Its `start` / `end` are UTF-16 offsets, and the default wire is span-only: no `loc`
//! (the `locations` feature adds it). [`Error::span`], unlike the wire, is a UTF-8 byte range.
//!
//! ## Errors
//!
//! A source that does not parse is an [`Error`]:
//!
//! ```
//! use tsv_css::{ErrorKind, format};
//!
//! let err = format("a{").unwrap_err();
//! assert_eq!(err.kind(), ErrorKind::Syntax);
//! assert_eq!(err.span(), Some(2..2));
//! ```
//!
//! Every tsv language crate returns the same [`Error`]: its [`Error::kind`], its
//! [`Error::span`] in the input's UTF-8 bytes, and [`Error::to_report`] — how tsv's bindings
//! (its npm packages) report it — in the wire's UTF-16 offsets and lines; one line under
//! `{}`, the `tsv` CLI's frame under `{:#}`.
//!
//! ## Deep input
//!
//! The parser refuses a tree past its depth limits with [`ErrorKind::TooDeep`], so every
//! walk of an accepted tree fits a 2 MiB thread in an optimized build and a 16 MiB one in a
//! debug build. A stack overflow aborts the process rather than panicking, so a debug
//! build handling input it does not control — `cargo test` runs each test on a 2 MiB thread —
//! wraps the call in [`run_with_stack`], which runs it on a thread reserving [`STACK_SIZE`].
//!
//! ## Features
//!
#![cfg_attr(
    feature = "json",
    doc = " - `json` (default) — [`parse_to_json`], [`parse_to_json_with`] and [`JsonOptions`]. Turn"
)]
#![cfg_attr(
    not(feature = "json"),
    doc = " - `json` (default) — `parse_to_json`, `parse_to_json_with` and `JsonOptions`. Turn"
)]
//!   default features off for a format-only build.
//! - `locations` — `JsonOptions::locations`, adding a `loc` (line and column) to every
//!   positioned object of the wire.
//!
//! The `_internal_*` features are tsv's own diagnostics, not for consumers
//! ([Stability](#stability)).
//!
//! ## Stability
//!
//! This release exposes no AST: parsing returns the wire JSON, in `parseCss`'s shape, which
//! `@fuzdev/tsv-parse-wasm`'s [`tsv_ast.d.ts`] types.
//!
//! [`tsv_ast.d.ts`]: https://github.com/fuzdev/tsv/blob/main/crates/tsv_wasm/types/tsv_ast.d.ts
//!
//! Items hidden from these docs (`__private`, `ast` and every other `#[doc(hidden)]` item)
//! and the `_internal_*` features are tsv's own plumbing for its sibling crates, which pin
//! each other exactly; they change in any release, outside semver.

#![warn(missing_docs)]

// The README's example compiles as a doctest, so the crates.io page cannot drift.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

#[doc(hidden)]
pub mod __private;
#[doc(hidden)]
// A hidden module renders nowhere public, so its links to private items are maintainer
// navigation (`docs:audit` documents private items); only the public-only docs.rs build
// fires the lint, which `docs:audit:docsrs` denies everywhere else.
#[expect(rustdoc::private_intra_doc_links)]
pub mod ast;
mod color;
mod comments;
mod escapes;
mod formatter;
mod keyword_set;
mod lexer;
mod number;
mod parser;
mod printer;
mod url;
mod whitespace;

use __private::{format_folded_in, parse_folded};
pub use formatter::Formatter;
pub use tsv_lang::{Error, ErrorKind, ErrorReport, Result, STACK_SIZE, WirePoint, run_with_stack};
use tsv_lang::{LeadingBom, LineRule, WireCoordinates};

// `tests/public_reexports.rs` (outside this crate) names every item above through this
// crate's path: cargo-semver-checks cannot see a dropped or demoted re-export.

/// The coordinates a CSS document's positions are reported in — `parseCss`'s: a leading
/// BOM **elided** (`parseCss` strips it before parsing, so every offset indexes the
/// BOM-less string), and `\n` alone ending a line. The wire writer's tables and every
/// parse error's point (`Error::wire_point`, and the `line:col` its message prints)
/// read this one statement. Hidden: plumbing for the sibling crates.
#[doc(hidden)]
pub const WIRE_COORDINATES: WireCoordinates =
    WireCoordinates::new(LineRule::Lf, LeadingBom::Elided);

/// Parse and format `source` in one call.
///
/// Carriage returns (`\r\n` and a lone `\r`) are folded to `\n` ahead of the parse, so
/// the output is LF-only.
/// A leading byte-order mark is not echoed — except ahead of a content U+FEFF at the start of
/// the output, which needs it to survive the next read — so a caller that keeps a file's BOM
/// writes it back only when the output does not already begin with U+FEFF. A source with
/// nothing to print — empty, or whitespace alone — formats to the empty string, not to a newline.
///
/// ```
/// # fn main() -> tsv_css::Result<()> {
/// assert_eq!(tsv_css::format("a{color:RED}")?, "a {\n\tcolor: RED;\n}\n");
/// # Ok(())
/// # }
/// ```
///
/// # Errors
///
/// A parse error.
pub fn format(source: &str) -> Result<String> {
    format_in(
        source,
        &bumpalo::Bump::new(),
        tsv_lang::doc::arena::DocArena::for_source,
    )
}

/// The one format path, shared by [`format()`] and [`Formatter`]: fold, parse into `arena`,
/// then print into the doc arena `doc_arena` hands over once the parse has succeeded (handed
/// the folded text, so a fresh one is sized to it and a failed parse never builds one).
/// Nothing borrowed from either arena escapes: the result is owned.
pub(crate) fn format_in<D: std::borrow::Borrow<tsv_lang::doc::arena::DocArena>>(
    source: &str,
    arena: &bumpalo::Bump,
    doc_arena: impl FnOnce(&str) -> D,
) -> Result<String> {
    // The format path's line-terminator fold, ahead of the parse (see
    // `tsv_lang::printing::normalize_carriage_returns`); `parse` leaves the author's bytes
    // alone so its offsets stay a drop-in contract with `parseCss`'s.
    let folded = tsv_lang::printing::normalize_carriage_returns(source);
    let stylesheet = parse_folded(&folded, arena)?;
    let doc_arena = doc_arena(folded.text());
    Ok(format_folded_in(&stylesheet, &folded, doc_arena.borrow()))
}

/// What [`parse_to_json_with`]'s wire carries. The default is [`parse_to_json`]'s: the
/// span-only wire. Its one option, `locations`, requires the `locations` feature.
///
/// ```
/// # fn main() -> tsv_css::Result<()> {
/// # #[cfg(feature = "locations")]
/// # {
/// use tsv_css::{JsonOptions, parse_to_json_with};
///
/// let json = parse_to_json_with("a{}", JsonOptions::new().locations(true))?;
/// assert!(json.contains(r#""loc":"#));
/// # }
/// # Ok(())
/// # }
/// ```
#[cfg(feature = "json")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub struct JsonOptions {
    #[cfg(feature = "locations")]
    locations: bool,
}

#[cfg(feature = "json")]
impl JsonOptions {
    /// The default options: the span-only wire.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            #[cfg(feature = "locations")]
            locations: false,
        }
    }

    /// Add a `loc` to every object carrying `start` / `end`: the line (1-based) and column
    /// (0-based, UTF-16 code units) of those offsets, under `\n` alone. `tsv parse
    /// --locations` writes it. Default `false`, the span-only wire. Requires the `locations`
    /// feature.
    #[cfg(feature = "locations")]
    #[must_use]
    pub const fn locations(mut self, locations: bool) -> Self {
        self.locations = locations;
        self
    }
}

// Options are plain data a caller may build once and share across threads; pin that so a
// field added later cannot take it away unnoticed.
#[cfg(feature = "json")]
const _: () = {
    const fn assert_send_sync_static<T: Send + Sync + 'static>() {}
    assert_send_sync_static::<JsonOptions>();
};

/// Parse `source` and return its wire JSON AST: compact JSON in Svelte's `parseCss()` shape
/// (a standalone `StyleSheetFile`), every node's `start` / `end` a UTF-16 offset and no
/// `loc`.
///
/// The span-only wire every binding ships and `tsv parse` writes by default. Line and
/// column are a pure function of those offsets plus the source, so a consumer holding the
/// source loses nothing; `JsonOptions::locations` (the `locations` feature) adds them. The
/// source is read as written — no line-terminator fold — so the offsets index the caller's
/// own text.
///
/// ```
/// # fn main() -> tsv_css::Result<()> {
/// let json = tsv_css::parse_to_json("a{}")?;
/// assert!(json.starts_with(r#"{"type":"StyleSheetFile","start":0,"end":3,"#));
/// # Ok(())
/// # }
/// ```
///
/// # Errors
///
/// A parse error.
#[cfg(feature = "json")]
pub fn parse_to_json(source: &str) -> Result<String> {
    parse_to_json_with(source, JsonOptions::new())
}

/// [`parse_to_json`] under `options`: whether the wire carries `loc`.
///
/// Each call parses into a fresh arena sized to the source, dropped once the wire is
/// written.
///
/// ```
/// # fn main() -> tsv_css::Result<()> {
/// use tsv_css::{JsonOptions, parse_to_json, parse_to_json_with};
///
/// // the default options are `parse_to_json`'s
/// assert_eq!(parse_to_json_with("a{}", JsonOptions::new())?, parse_to_json("a{}")?);
/// # Ok(())
/// # }
/// ```
///
/// # Errors
///
/// A parse error.
#[cfg(feature = "json")]
pub fn parse_to_json_with(source: &str, options: JsonOptions) -> Result<String> {
    let arena = bumpalo::Bump::with_capacity(tsv_lang::estimated_ast_arena_capacity(source.len()));
    let ast = __private::parse(source, &arena)?;
    #[cfg(feature = "locations")]
    if options.locations {
        return Ok(wire_string(
            __private::convert_ast_json_bytes_with_locations(&ast, source),
        ));
    }
    #[cfg(not(feature = "locations"))]
    let _ = options;
    Ok(__private::convert_ast_json_string(&ast, source))
}

/// The wire's bytes as the `String` they are: the writer emits source slices and ASCII
/// fragments alone.
#[cfg(feature = "locations")]
#[expect(clippy::expect_used)]
fn wire_string(bytes: Vec<u8>) -> String {
    String::from_utf8(bytes).expect("writer emits valid UTF-8 (source slices + ASCII fragments)")
}

/// Drive the raw lexer over `source` and return a deterministic, line-per-token
/// dump (`<kind> <start>..<end> [text=…]`, terminated by `Eof` or `ERROR …`).
///
/// The differential gate for lexer changes: two lexer implementations are
/// token-identical iff this string matches for every corpus file. Behind the
/// `_internal_debug_lex` feature (off in production builds); used by `tsv_debug lex_diff`.
///
/// For `Identifier` tokens it prints the **resolved text** — the decoded value
/// when escapes were present, otherwise the verbatim source slice. That keeps the
/// golden invariant across the lazy-decode change: a no-escape identifier's
/// decoded value equals its source slice, so the stream is stable whether the
/// lexer allocates the decode eagerly or only when an escape is seen, while a
/// wrong escape decode still shows up as a divergent `text=`.
#[cfg(feature = "_internal_debug_lex")]
#[doc(hidden)]
pub fn debug_token_stream(source: &str) -> String {
    use crate::lexer::{Lexer, TokenKind};
    use std::fmt::Write as _;

    let mut lexer = Lexer::at_offset(source, 0);
    let mut out = String::new();
    loop {
        match lexer.next_token() {
            Ok(token) => {
                let _ = write!(out, "{:?} {}..{}", token.kind, token.start, token.end);
                if matches!(token.kind, TokenKind::Identifier) {
                    let slice = &source[token.start as usize..token.end as usize];
                    let text = lexer.decoded_str().unwrap_or(slice);
                    let _ = write!(out, " text={text:?}");
                }
                out.push('\n');
                if matches!(token.kind, TokenKind::Eof) {
                    break;
                }
            }
            Err(err) => {
                let _ = writeln!(out, "ERROR {err:?}");
                break;
            }
        }
    }
    out
}