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}