Skip to main content

sasso/
lib.rs

1//! `sasso` — a pure-Rust SCSS → CSS compiler.
2//!
3//! A small, zero-dependency, embeddable Sass engine aiming at byte-exact
4//! parity with **current** dart-sass on the subset it implements (e.g.
5//! computed colors serialize as `rgb(25%, 50%, 75%)`, not rounded
6//! hex). It is sandbox-friendly: `@import` resolution goes through a
7//! caller-supplied [`Importer`], so an embedder controls all file access.
8//!
9//! # Example
10//!
11//! ```
12//! use sasso::{compile, Options};
13//!
14//! let css = compile("$c: #333; a { color: $c; &:hover { color: $c; } }", &Options::default()).unwrap();
15//! assert!(css.contains("a {"));
16//! assert!(css.contains("a:hover {"));
17//! ```
18//!
19//! ## Scope
20//!
21//! This covers a large slice of Sass: variables (`!default`/`!global`),
22//! nesting and the `&` parent selector, `#{}` interpolation, `//` and
23//! `/* */` comments, unit arithmetic, the color functions, control flow,
24//! mixins/functions, `@extend`, `@import`, and the `@use`/`@forward` module
25//! system. Both input syntaxes are supported — the brace/semicolon SCSS
26//! syntax and the indented `.sass` syntax (selected via [`Options::with_syntax`]
27//! or, in the CLI, the input file's extension) — parsing into the same AST and
28//! sharing the evaluator and emitter. The north-star target is 100% of the
29//! official `sass-spec` suite, tracked by the harness in `spec/`.
30
31// The library's `unsafe` is confined to one audited module — `arena`, the
32// scoped bump allocator (perf #5), verified by unit tests + Miri. Every other
33// module is `deny(unsafe_code)` (see Cargo.toml `[lints]`); `arena` is the only
34// `#[allow]`. The wasm wrapper (`/wasm`, a separate crate) has its own FFI unsafe.
35mod arena;
36
37mod ast;
38mod builtins;
39mod deprecation;
40mod diag;
41mod emit;
42mod error;
43mod eval;
44mod fxhash;
45mod host_fn;
46mod importer;
47mod musl_math;
48mod parser;
49mod ryu;
50mod sass_parser;
51mod scanner;
52mod selector;
53// Source Map v3 generation: the encoding primitives + JSON model (Phase A),
54// wired into emit (Phase B/C) and surfaced through `compile_with_source_map`.
55mod sourcemap;
56mod value;
57
58pub use arena::{set_arena_bytes, ScopedAlloc};
59pub use error::Error;
60pub use host_fn::{host_value_op, HostFunction};
61pub use importer::{CanonicalUrl, CanonicalizeContext, FsImporter, Importer, ImporterError, ImporterResult};
62pub use sourcemap::SourceMap;
63
64/// Output formatting style.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum OutputStyle {
67    /// Human-readable, indented output (the default).
68    #[default]
69    Expanded,
70    /// Minified, single-line output.
71    Compressed,
72}
73
74/// The input syntax flavour.
75///
76/// Both flavours parse into the same AST and share the evaluator and emitter;
77/// only the *block structure* differs (`{}`/`;` for SCSS, indentation +
78/// newlines for the indented `.sass` syntax).
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum Syntax {
81    /// The brace/semicolon SCSS syntax (the default).
82    #[default]
83    Scss,
84    /// The indented `.sass` syntax: blocks come from indentation, statements
85    /// end at a newline.
86    Sass,
87    /// Plain CSS (a `.css` file loaded via `@use`/`@forward`): the brace/semicolon
88    /// grammar, but Sass features are rejected, nesting is preserved verbatim,
89    /// and values are emitted without SassScript evaluation.
90    Css,
91}
92
93/// Compilation options.
94pub struct Options<'a> {
95    /// Output style.
96    pub style: OutputStyle,
97    /// Input syntax (SCSS or indented `.sass`).
98    pub syntax: Syntax,
99    /// Importer used to resolve `@import`; `None` disables file imports.
100    pub importer: Option<&'a dyn Importer>,
101    /// The input's path/URL as it should appear in diagnostics (e.g.
102    /// `input.scss`). `None` disables byte-exact diagnostic snippets (errors
103    /// then render as the legacy `Error: <msg> (line:col)` one-liner).
104    pub url: Option<&'a str>,
105    /// Whether to draw diagnostic snippets with Unicode box-drawing glyphs
106    /// (`true`, the default) or the ASCII fallback (`false`, dart's
107    /// `--no-unicode`).
108    pub unicode: bool,
109    /// Whether [`compile_with_source_map`] populates the source map's
110    /// `sourcesContent` field with the full text of each source (dart-sass
111    /// `--embed-sources`). Default `false` (the map references sources by URL
112    /// only). Ignored by the plain [`compile`] path.
113    pub source_map_include_sources: bool,
114    /// Host-defined custom functions (dart-sass `functions`), registered via
115    /// [`Options::with_function`]. Consulted after user `@function`s and module
116    /// members but before built-in global functions.
117    pub(crate) functions: Vec<host_fn::HostFn>,
118    /// Diagnostic handler (dart-sass `logger`). When set, every `@warn`/`@debug`/
119    /// deprecation warning is delivered here instead of printed to stderr.
120    pub(crate) warn: Option<WarnHandler>,
121    /// Emit a `@charset "UTF-8";` (expanded) / U+FEFF BOM (compressed) prefix
122    /// when the output contains non-ASCII (dart-sass `charset`, default `true`).
123    /// `false` suppresses it.
124    pub charset: bool,
125}
126
127/// The kind of a diagnostic delivered to a [`WarnHandler`].
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum WarnKind {
130    /// A `@warn` directive (or a deprecation warning).
131    Warn,
132    /// A `@debug` directive.
133    Debug,
134}
135
136/// A `@warn` / `@debug` / deprecation diagnostic delivered to an embedder's
137/// [`WarnHandler`] (dart-sass `logger`).
138pub struct WarnEvent<'a> {
139    /// Warning vs debug.
140    pub kind: WarnKind,
141    /// True for a deprecation warning.
142    pub deprecation: bool,
143    /// The deprecation id (e.g. `"slash-div"`), or `""` when not a deprecation.
144    pub deprecation_id: &'a str,
145    /// The raw message text (the `@warn`/`@debug` value, or deprecation message).
146    pub message: &'a str,
147    /// The full dart-style block sasso would otherwise print to stderr (header +
148    /// snippet + stack trace), for a faithful default logger.
149    pub formatted: &'a str,
150    /// The source URL for the diagnostic's span; `""` when not available.
151    pub url: &'a str,
152    /// The 1-based line for the diagnostic's span; `0` when not available.
153    pub line: usize,
154}
155
156/// An embedder's diagnostic handler (dart-sass `logger`). Receives every
157/// `@warn` / `@debug` / deprecation warning; if unset, they print to stderr.
158pub type WarnHandler = std::rc::Rc<dyn Fn(&WarnEvent<'_>)>;
159
160impl Default for Options<'_> {
161    fn default() -> Self {
162        Options {
163            style: OutputStyle::default(),
164            syntax: Syntax::default(),
165            importer: None,
166            url: None,
167            unicode: true,
168            source_map_include_sources: false,
169            functions: Vec::new(),
170            warn: None,
171            charset: true,
172        }
173    }
174}
175
176impl<'a> Options<'a> {
177    /// Create default options (expanded, SCSS, no importer).
178    pub fn new() -> Self {
179        Self::default()
180    }
181
182    /// Builder: set the output style.
183    #[must_use]
184    pub fn with_style(mut self, style: OutputStyle) -> Self {
185        self.style = style;
186        self
187    }
188
189    /// Builder: set the input syntax.
190    #[must_use]
191    pub fn with_syntax(mut self, syntax: Syntax) -> Self {
192        self.syntax = syntax;
193        self
194    }
195
196    /// Builder: set the importer.
197    #[must_use]
198    pub fn with_importer(mut self, importer: &'a dyn Importer) -> Self {
199        self.importer = Some(importer);
200        self
201    }
202
203    /// Builder: set the diagnostic display URL (enables byte-exact snippets).
204    #[must_use]
205    pub fn with_url(mut self, url: &'a str) -> Self {
206        self.url = Some(url);
207        self
208    }
209
210    /// Builder: select the diagnostic glyph set (`false` = ASCII / `--no-unicode`).
211    #[must_use]
212    pub fn with_unicode(mut self, unicode: bool) -> Self {
213        self.unicode = unicode;
214        self
215    }
216
217    /// Builder: whether [`compile_with_source_map`] embeds each source's full
218    /// text in the map's `sourcesContent` (default `false`).
219    #[must_use]
220    pub fn with_source_map_include_sources(mut self, include: bool) -> Self {
221        self.source_map_include_sources = include;
222        self
223    }
224
225    /// Register a host-defined custom function (dart-sass `functions`).
226    ///
227    /// `signature` is a Sass function signature — a name and parameter list,
228    /// e.g. `"pow($base, $exponent)"` or `"to-list($args...)"`. `callback`
229    /// receives the bound arguments serialized to sasso's host-value wire format
230    /// and returns the result serialized the same way (or an `Err(message)` that
231    /// becomes a compile error). This byte-oriented boundary lets embedders
232    /// (wasm/FFI) bridge to their own value system without exposing sasso's
233    /// internal `Value` type.
234    ///
235    /// Custom functions take precedence over built-in global functions but not
236    /// over user `@function` definitions or `@use`d module members. A malformed
237    /// signature is reported only if the function is actually called.
238    #[must_use]
239    pub fn with_function(mut self, signature: &str, callback: host_fn::HostFunction) -> Self {
240        let (name, params) = host_fn::parse_signature(signature);
241        self.functions.push(host_fn::HostFn {
242            name,
243            params,
244            callback,
245        });
246        self
247    }
248
249    /// Set the diagnostic handler (dart-sass `logger`). Every `@warn`/`@debug`/
250    /// deprecation warning is delivered to `handler` instead of being printed to
251    /// stderr (the default when unset).
252    #[must_use]
253    pub fn with_warn_handler(mut self, handler: WarnHandler) -> Self {
254        self.warn = Some(handler);
255        self
256    }
257
258    /// Set whether to emit the `@charset`/BOM prefix for non-ASCII output
259    /// (dart-sass `charset`, default `true`).
260    #[must_use]
261    pub fn with_charset(mut self, charset: bool) -> Self {
262        self.charset = charset;
263        self
264    }
265}
266
267/// Compile SCSS source to CSS.
268///
269/// # Errors
270///
271/// Returns [`Error`] on a parse or evaluation failure (with a 1-based
272/// source position when known).
273///
274/// # Allocator scope
275///
276/// When the binary installs [`ScopedAlloc`] as its `#[global_allocator]`, this
277/// function brackets the whole compile in a bump-arena scope: every allocation
278/// `compile_inner` makes is a pointer bump from a per-thread arena that is freed
279/// wholesale when the scope ends. The returned `Result` is allocated *in* the
280/// arena, so it is deep-cloned out to the system allocator *before* the arena is
281/// reset — the value handed back to the caller never points into the arena. When
282/// no `ScopedAlloc` is installed the scope primitives are inert (depth tracking
283/// only) and every allocation goes to the system allocator as usual, so this
284/// wrapper is correct (just with a redundant clone) under any global allocator.
285pub fn compile(source: &str, options: &Options<'_>) -> Result<String, Error> {
286    // Enter the arena scope. The RAII guard's `Drop` leaves + resets the arena
287    // on the *panic* path; the success path below finishes manually and forgets
288    // the guard, so there is no double-leave.
289    let guard = arena::Scope::enter();
290    // All allocations here bump from the arena (when ScopedAlloc is installed).
291    let result = compile_inner(source, options);
292    // Leave the scope WITHOUT resetting yet: depth drops to 0, so the arena is
293    // now inactive and subsequent allocations route to the system allocator —
294    // but the arena memory is still intact and `result` may point into it.
295    let outermost = arena::leave_no_reset();
296    // Deep-clone the result to the system allocator while the scope is inactive.
297    // `Error` derives `Clone`, so both the `Ok(String)` and `Err(message)` cases
298    // are copied out byte-for-byte to system-owned memory.
299    let owned = result.clone();
300    // Drop the arena-resident original (in-arena `dealloc` is a no-op) before
301    // the region it lives in is reclaimed.
302    drop(result);
303    // Only the outermost scope owns the arena's lifetime; reset frees it all.
304    if outermost {
305        arena::reset();
306    }
307    // We finished the scope manually; suppress the guard's `Drop` to avoid a
308    // second leave/reset.
309    std::mem::forget(guard);
310    owned
311}
312
313/// The CSS plus its source map, returned by [`compile_with_source_map`].
314#[derive(Clone, Debug)]
315pub struct CompileResult {
316    /// The compiled CSS (identical to what [`compile`] would return for the
317    /// same `source`/`options` — the map is generated alongside, not instead).
318    pub css: String,
319    /// The Source Map v3 describing `css`. Serialize it with
320    /// [`SourceMap::to_json`].
321    pub source_map: SourceMap,
322}
323
324/// Compile SCSS source to CSS *and* a [Source Map v3](SourceMap).
325///
326/// The `css` field is byte-for-byte what [`compile`] returns; the map is built
327/// alongside it. The map's `file` is the basename of [`Options::url`] (or
328/// `"stdin"` when no URL is set) and its `sources` are the source URLs.
329/// [`Options::with_source_map_include_sources`] controls whether each source's
330/// full text is embedded in `sourcesContent`.
331///
332/// Maps the start of each selector, declaration property name, declaration
333/// value, at-rule keyword, and comment. A value that is a bare `$name` maps
334/// back to where that variable was DEFINED, like dart-sass.
335///
336/// # Errors
337///
338/// Returns [`Error`] on a parse or evaluation failure, like [`compile`].
339pub fn compile_with_source_map(source: &str, options: &Options<'_>) -> Result<CompileResult, Error> {
340    // Mirror `compile`'s arena bracketing so the returned value is deep-cloned
341    // out to the system allocator before the arena is reset.
342    let guard = arena::Scope::enter();
343    let result = compile_inner_sm(source, options);
344    let outermost = arena::leave_no_reset();
345    let owned = result.clone();
346    drop(result);
347    if outermost {
348        arena::reset();
349    }
350    std::mem::forget(guard);
351    owned
352}
353
354/// The basename of a path/URL (everything after the last `/`), used for the
355/// source map's `file` field.
356fn basename(url: &str) -> &str {
357    url.rsplit('/').next().unwrap_or(url)
358}
359
360/// The source-map compile pipeline: parse + evaluate exactly like
361/// [`compile_inner`], then emit with the source-map collector and assemble the
362/// [`SourceMap`].
363fn compile_inner_sm(source: &str, options: &Options<'_>) -> Result<CompileResult, Error> {
364    let glyphs = if options.unicode {
365        diag::GlyphSet::Unicode
366    } else {
367        diag::GlyphSet::Ascii
368    };
369    let sheet = match options.syntax {
370        Syntax::Scss => parser::parse(source),
371        Syntax::Css => parser::parse_plain_css(source),
372        Syntax::Sass => sass_parser::parse(source),
373    };
374    let sheet = match sheet {
375        Ok(s) => s,
376        Err(mut e) => {
377            if let Some(url) = options.url {
378                if e.rendered.is_none() && e.has_position() {
379                    let span = diag::Span {
380                        line: e.line,
381                        col: e.col,
382                        length: e.length,
383                    };
384                    e.rendered = Some(diag::render_error(&e.message, source, url, span, glyphs));
385                }
386            }
387            return Err(e);
388        }
389    };
390    eval::validate_declarations(&sheet)?;
391    // The entry name labels the entry source in the map (`file`/`sources[0]`).
392    // It is also the evaluator's `current_url`, so every entry-file node is
393    // stamped with a non-zero file id; its source text is kept for
394    // `sourcesContent`. The source-map path always passes the real source (so
395    // `sourcesContent` works even without a diagnostic URL); this only enriches
396    // the *error* path with snippets — the CSS/map success path is unaffected.
397    let entry_name = options.url.unwrap_or("stdin");
398    let mut ev = eval::Evaluator::new(eval::EvalOptions {
399        style: options.style,
400        importer: options.importer,
401        functions: &options.functions,
402        source,
403        url: entry_name,
404        glyphs,
405        warn: options.warn.as_ref(),
406        source_map: true,
407    });
408    let mut out = Vec::new();
409    ev.eval_sheet(&sheet, &mut out)?;
410    let (css, body_off, collector) = emit::emit_with_map(&out, options.style, options.charset);
411    let mappings = collector.finalize(&css, body_off).encode();
412    let (sources, sources_content) = ev.source_table(entry_name, options.source_map_include_sources);
413    let source_map = SourceMap {
414        file: Some(basename(entry_name).to_string()),
415        sources,
416        sources_content,
417        mappings,
418    };
419    Ok(CompileResult { css, source_map })
420}
421
422/// The actual compile pipeline. Runs inside the arena scope established by
423/// [`compile`]; all of its allocations may be arena-resident, so its result is
424/// copied out by the wrapper before the arena is reset.
425fn compile_inner(source: &str, options: &Options<'_>) -> Result<String, Error> {
426    let glyphs_for = || {
427        if options.unicode {
428            diag::GlyphSet::Unicode
429        } else {
430            diag::GlyphSet::Ascii
431        }
432    };
433    let sheet = match options.syntax {
434        Syntax::Scss => parser::parse(source),
435        Syntax::Css => parser::parse_plain_css(source),
436        Syntax::Sass => sass_parser::parse(source),
437    };
438    // A parse error never reached the evaluator, so render its snippet here
439    // (single `root stylesheet` frame) when a diagnostic URL is configured.
440    let sheet = match sheet {
441        Ok(s) => s,
442        Err(mut e) => {
443            if let Some(url) = options.url {
444                if e.rendered.is_none() && e.has_position() {
445                    let span = diag::Span {
446                        line: e.line,
447                        col: e.col,
448                        length: e.length,
449                    };
450                    e.rendered = Some(diag::render_error(&e.message, source, url, span, glyphs_for()));
451                }
452            }
453            return Err(e);
454        }
455    };
456    // Reject `@function`/`@mixin` declarations in control directives or
457    // function/mixin bodies (a compile-time restriction, checked before eval).
458    eval::validate_declarations(&sheet)?;
459    // Diagnostics are enabled only when the caller supplies a display URL; then
460    // the evaluator renders byte-exact `Error:`/`WARNING:` blocks against the
461    // source. Without a URL it falls back to the legacy one-liner.
462    let (diag_source, diag_url) = match options.url {
463        Some(url) => (source, url),
464        None => ("", ""),
465    };
466    let glyphs = if options.unicode {
467        diag::GlyphSet::Unicode
468    } else {
469        diag::GlyphSet::Ascii
470    };
471    let mut ev = eval::Evaluator::new(eval::EvalOptions {
472        style: options.style,
473        importer: options.importer,
474        functions: &options.functions,
475        source: diag_source,
476        url: diag_url,
477        glyphs,
478        warn: options.warn.as_ref(),
479        source_map: false,
480    });
481    let mut out = Vec::new();
482    ev.eval_sheet(&sheet, &mut out)?;
483    Ok(emit::emit(&out, options.style, options.charset))
484}