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 ast_writer;
39mod builtins;
40mod deprecation;
41mod diag;
42mod emit;
43mod error;
44mod eval;
45mod fxhash;
46mod host_fn;
47mod importer;
48mod musl_math;
49mod parser;
50mod ryu;
51mod sass_parser;
52mod scanner;
53mod selector;
54// Source Map v3 generation: the encoding primitives + JSON model (Phase A),
55// wired into emit (Phase B/C) and surfaced through `compile_with_source_map`.
56mod sourcemap;
57mod value;
58
59/// This compiler's version, as in `Cargo.toml`.
60///
61/// Exposed so a wrapper can report the version of the compiler it actually
62/// bundles rather than its own: the FFI crate (`ffi/`) is versioned separately,
63/// and its `sasso_version()` used to return `CARGO_PKG_VERSION`, i.e. the
64/// wrapper's number. Reading it from here cannot drift.
65pub const VERSION: &str = env!("CARGO_PKG_VERSION");
66
67pub use arena::{set_arena_bytes, ScopedAlloc};
68pub use error::Error;
69pub use host_fn::{host_value_op, HostFunction};
70pub use importer::{
71    CanonicalUrl, CanonicalizeContext, DependencySet, FsImporter, Importer, ImporterError, ImporterResult,
72};
73pub use sourcemap::SourceMap;
74
75/// Output formatting style.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77pub enum OutputStyle {
78    /// Human-readable, indented output (the default).
79    #[default]
80    Expanded,
81    /// Minified, single-line output.
82    Compressed,
83}
84
85/// The input syntax flavour.
86///
87/// Both flavours parse into the same AST and share the evaluator and emitter;
88/// only the *block structure* differs (`{}`/`;` for SCSS, indentation +
89/// newlines for the indented `.sass` syntax).
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum Syntax {
92    /// The brace/semicolon SCSS syntax (the default).
93    #[default]
94    Scss,
95    /// The indented `.sass` syntax: blocks come from indentation, statements
96    /// end at a newline.
97    Sass,
98    /// Plain CSS (a `.css` file loaded via `@use`/`@forward`): the brace/semicolon
99    /// grammar, but Sass features are rejected, nesting is preserved verbatim,
100    /// and values are emitted without SassScript evaluation.
101    Css,
102}
103
104/// Compilation options.
105pub struct Options<'a> {
106    /// Output style.
107    pub style: OutputStyle,
108    /// Input syntax (SCSS or indented `.sass`).
109    pub syntax: Syntax,
110    /// Importer used to resolve `@import`; `None` disables file imports.
111    pub importer: Option<&'a dyn Importer>,
112    /// The input's path/URL as it should appear in diagnostics (e.g.
113    /// `input.scss`). `None` disables byte-exact diagnostic snippets (errors
114    /// then render as the legacy `Error: <msg> (line:col)` one-liner).
115    pub url: Option<&'a str>,
116    /// Whether to draw diagnostic snippets with Unicode box-drawing glyphs
117    /// (`true`, the default) or the ASCII fallback (`false`, dart's
118    /// `--no-unicode`).
119    pub unicode: bool,
120    /// Whether [`compile_with_source_map`] populates the source map's
121    /// `sourcesContent` field with the full text of each source (dart-sass
122    /// `--embed-sources`). Default `false` (the map references sources by URL
123    /// only). Ignored by the plain [`compile`] path.
124    pub source_map_include_sources: bool,
125    /// Host-defined custom functions (dart-sass `functions`), registered via
126    /// [`Options::with_function`]. Consulted after user `@function`s and module
127    /// members but before built-in global functions.
128    pub(crate) functions: Vec<host_fn::HostFn>,
129    /// Diagnostic handler (dart-sass `logger`). When set, every `@warn`/`@debug`/
130    /// deprecation warning is delivered here instead of printed to stderr.
131    pub(crate) warn: Option<WarnHandler>,
132    /// dart-sass `quietDeps`: deprecation warnings raised inside a dependency
133    /// (a file this set marks, see [`FsImporter::dependencies`]) are dropped
134    /// before they are counted or delivered.
135    pub(crate) quiet_deps: Option<DependencySet>,
136    /// dart-sass `silenceDeprecations`: deprecation ids dropped before they
137    /// are counted or delivered. Empty means every deprecation is emitted.
138    pub(crate) silenced_deprecations: Vec<String>,
139    /// Emit a `@charset "UTF-8";` (expanded) / U+FEFF BOM (compressed) prefix
140    /// when the output contains non-ASCII (dart-sass `charset`, default `true`).
141    /// `false` suppresses it.
142    pub charset: bool,
143}
144
145/// The kind of a diagnostic delivered to a [`WarnHandler`].
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum WarnKind {
148    /// A `@warn` directive (or a deprecation warning).
149    Warn,
150    /// A `@debug` directive.
151    Debug,
152}
153
154/// A `@warn` / `@debug` / deprecation diagnostic delivered to an embedder's
155/// [`WarnHandler`] (dart-sass `logger`).
156///
157/// Produced by the compiler and read by handlers; `#[non_exhaustive]` so a
158/// future field is not a breaking change for anyone matching on it.
159#[non_exhaustive]
160pub struct WarnEvent<'a> {
161    /// Warning vs debug.
162    pub kind: WarnKind,
163    /// True for a deprecation warning.
164    pub deprecation: bool,
165    /// The deprecation id (e.g. `"slash-div"`), or `""` when not a deprecation.
166    pub deprecation_id: &'a str,
167    /// The raw message text (the `@warn`/`@debug` value, or deprecation message).
168    pub message: &'a str,
169    /// The full dart-style block sasso would otherwise print to stderr (header +
170    /// snippet + stack trace), for a faithful default logger.
171    pub formatted: &'a str,
172    /// The source URL for the diagnostic's span, as dart-sass displays it in a
173    /// stack frame: the entry's [`Options::url`] as given, or a loaded file's
174    /// path from the working directory (absolute when that would be longer;
175    /// a custom importer's non-path key shows its last segment). For an entry
176    /// compiled without [`Options::url`] it is `""` under [`compile`] and
177    /// `"stdin"` under [`compile_with_source_map`] (the source map has to name
178    /// the entry somehow, like dart's `-`); the line is still set. `""` for the
179    /// "repetitive deprecation warnings omitted" footer, which has no span.
180    pub url: &'a str,
181    /// The 1-based line for the diagnostic's span; `0` when not available.
182    pub line: usize,
183    /// The canonical URL of the stylesheet being evaluated when the diagnostic
184    /// fired, or `""` when not available. For a file the importer loaded this
185    /// is the importer's canonical URL — the resolved absolute path with
186    /// [`FsImporter`] — which is what [`DependencySet::is_dependency`] keys on.
187    /// For the entry stylesheet it is [`Options::url`] exactly as supplied
188    /// (the library never canonicalizes the entry). Unlike `url` (dart's display
189    /// form, relative to the working directory) it is stable across working
190    /// directories and identifies the file.
191    pub path: &'a str,
192}
193
194/// An embedder's diagnostic handler (dart-sass `logger`). Receives every
195/// `@warn` / `@debug` / deprecation warning that the options do not suppress;
196/// if unset, they print to stderr.
197///
198/// Two options suppress before this point, because both decide what is worth
199/// reporting rather than how to report it, and dart-sass applies them in the
200/// compiler for the same reason: [`Options::with_quiet_deps`] drops
201/// deprecations raised inside dependencies, and
202/// [`Options::with_silenced_deprecations`] drops the ids it names. A
203/// suppressed deprecation is not merely withheld from the handler — it is not
204/// counted either, so it cannot turn up in the "N repetitive deprecation
205/// warnings omitted" tally.
206pub type WarnHandler = std::rc::Rc<dyn Fn(&WarnEvent<'_>)>;
207
208impl Default for Options<'_> {
209    fn default() -> Self {
210        Options {
211            style: OutputStyle::default(),
212            syntax: Syntax::default(),
213            importer: None,
214            url: None,
215            unicode: true,
216            source_map_include_sources: false,
217            functions: Vec::new(),
218            warn: None,
219            quiet_deps: None,
220            silenced_deprecations: Vec::new(),
221            charset: true,
222        }
223    }
224}
225
226impl<'a> Options<'a> {
227    /// Create default options (expanded, SCSS, no importer).
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Builder: set the output style.
233    #[must_use]
234    pub fn with_style(mut self, style: OutputStyle) -> Self {
235        self.style = style;
236        self
237    }
238
239    /// Builder: set the input syntax.
240    #[must_use]
241    pub fn with_syntax(mut self, syntax: Syntax) -> Self {
242        self.syntax = syntax;
243        self
244    }
245
246    /// Builder: set the importer.
247    #[must_use]
248    pub fn with_importer(mut self, importer: &'a dyn Importer) -> Self {
249        self.importer = Some(importer);
250        self
251    }
252
253    /// Builder: set the diagnostic display URL (enables byte-exact snippets).
254    #[must_use]
255    pub fn with_url(mut self, url: &'a str) -> Self {
256        self.url = Some(url);
257        self
258    }
259
260    /// Builder: select the diagnostic glyph set (`false` = ASCII / `--no-unicode`).
261    #[must_use]
262    pub fn with_unicode(mut self, unicode: bool) -> Self {
263        self.unicode = unicode;
264        self
265    }
266
267    /// Builder: whether [`compile_with_source_map`] embeds each source's full
268    /// text in the map's `sourcesContent` (default `false`).
269    #[must_use]
270    pub fn with_source_map_include_sources(mut self, include: bool) -> Self {
271        self.source_map_include_sources = include;
272        self
273    }
274
275    /// Register a host-defined custom function (dart-sass `functions`).
276    ///
277    /// `signature` is a Sass function signature — a name and parameter list,
278    /// e.g. `"pow($base, $exponent)"` or `"to-list($args...)"`. `callback`
279    /// receives the bound arguments serialized to sasso's host-value wire format
280    /// and returns the result serialized the same way (or an `Err(message)` that
281    /// becomes a compile error). This byte-oriented boundary lets embedders
282    /// (wasm/FFI) bridge to their own value system without exposing sasso's
283    /// internal `Value` type.
284    ///
285    /// Custom functions take precedence over built-in global functions but not
286    /// over user `@function` definitions or `@use`d module members. A malformed
287    /// signature is reported only if the function is actually called.
288    ///
289    /// That precedence is where sasso is deliberately more permissive than
290    /// dart-sass 1.103.1, whose `functions` do not shadow a built-in global at
291    /// all (registering `type-of($v)` there leaves the built-in running and the
292    /// callback never invoked). The `[global-builtin]` deprecation follows
293    /// dart: writing a deprecated global's name warns whether or not a custom
294    /// function of that name is registered.
295    #[must_use]
296    pub fn with_function(mut self, signature: &str, callback: host_fn::HostFunction) -> Self {
297        let (name, params) = host_fn::parse_signature(signature);
298        self.functions.push(host_fn::HostFn {
299            name,
300            params,
301            callback,
302        });
303        self
304    }
305
306    /// Set the diagnostic handler (dart-sass `logger`). Each `@warn`/`@debug`/
307    /// deprecation warning is delivered to `handler` instead of being printed to
308    /// stderr (the default when unset).
309    ///
310    /// Deprecations suppressed by [`Options::with_quiet_deps`] or
311    /// [`Options::with_silenced_deprecations`] never reach `handler`: both are
312    /// applied in the compiler, ahead of the repetition cap, so a suppressed
313    /// deprecation is not counted either. See [`WarnHandler`].
314    #[must_use]
315    pub fn with_warn_handler(mut self, handler: WarnHandler) -> Self {
316        self.warn = Some(handler);
317        self
318    }
319
320    /// Silence deprecation warnings from dependencies (dart-sass `quietDeps`):
321    /// files that `deps` — normally [`FsImporter::dependencies`] of the importer
322    /// in use — marks as reached through a load path. Applied before the
323    /// repetition cap, so silenced warnings do not surface as "N repetitive
324    /// deprecation warnings omitted" either. `@warn`/`@debug` are unaffected,
325    /// as in dart.
326    #[must_use]
327    pub fn with_quiet_deps(mut self, deps: DependencySet) -> Self {
328        self.quiet_deps = Some(deps);
329        self
330    }
331
332    /// dart-sass `silenceDeprecations` / `--silence-deprecation`: drop these
333    /// deprecations by id, keeping every other warning.
334    ///
335    /// Applied where `quiet_deps` is, which is before the per-id cap — so a
336    /// silenced deprecation neither consumes one of the five printed slots nor
337    /// counts towards the "N repetitive deprecation warnings omitted" footer.
338    /// Filtering in a warn handler instead leaves that footer behind, counting
339    /// warnings the caller asked not to see (dart prints nothing at all).
340    ///
341    /// Ids sasso never emits are accepted and do nothing: a build script
342    /// written for `sass` should not fail here for naming one.
343    #[must_use]
344    pub fn with_silenced_deprecations<I, S>(mut self, ids: I) -> Self
345    where
346        I: IntoIterator<Item = S>,
347        S: Into<String>,
348    {
349        self.silenced_deprecations = ids.into_iter().map(Into::into).collect();
350        self
351    }
352
353    /// Set whether to emit the `@charset`/BOM prefix for non-ASCII output
354    /// (dart-sass `charset`, default `true`).
355    #[must_use]
356    pub fn with_charset(mut self, charset: bool) -> Self {
357        self.charset = charset;
358        self
359    }
360}
361
362/// Compile SCSS source to CSS.
363///
364/// # Errors
365///
366/// Returns [`Error`] on a parse or evaluation failure (with a 1-based
367/// source position when known).
368///
369/// # Allocator scope
370///
371/// When the binary installs [`ScopedAlloc`] as its `#[global_allocator]`, this
372/// function brackets the whole compile in a bump-arena scope: every allocation
373/// `compile_inner` makes is a pointer bump from a per-thread arena that is freed
374/// wholesale when the scope ends. The returned `Result` is allocated *in* the
375/// arena, so it is deep-cloned out to the system allocator *before* the arena is
376/// reset — the value handed back to the caller never points into the arena. When
377/// no `ScopedAlloc` is installed the scope primitives are inert (depth tracking
378/// only) and every allocation goes to the system allocator as usual, so this
379/// wrapper is correct (just with a redundant clone) under any global allocator.
380pub fn compile(source: &str, options: &Options<'_>) -> Result<String, Error> {
381    // Enter the arena scope. The RAII guard's `Drop` leaves + resets the arena
382    // on the *panic* path; the success path below finishes manually and forgets
383    // the guard, so there is no double-leave.
384    let guard = arena::Scope::enter();
385    // All allocations here bump from the arena (when ScopedAlloc is installed).
386    let result = compile_inner(source, options);
387    // Leave the scope WITHOUT resetting yet: depth drops to 0, so the arena is
388    // now inactive and subsequent allocations route to the system allocator —
389    // but the arena memory is still intact and `result` may point into it.
390    let outermost = arena::leave_no_reset();
391    // Deep-clone the result to the system allocator while the scope is inactive.
392    // `Error` derives `Clone`, so both the `Ok(String)` and `Err(message)` cases
393    // are copied out byte-for-byte to system-owned memory.
394    let owned = result.clone();
395    // Drop the arena-resident original (in-arena `dealloc` is a no-op) before
396    // the region it lives in is reclaimed.
397    drop(result);
398    // Only the outermost scope owns the arena's lifetime; reset frees it all.
399    if outermost {
400        arena::reset();
401    }
402    // We finished the scope manually; suppress the guard's `Drop` to avoid a
403    // second leave/reset.
404    std::mem::forget(guard);
405    owned
406}
407
408/// The CSS plus its source map, returned by [`compile_with_source_map`].
409#[derive(Clone, Debug)]
410pub struct CompileResult {
411    /// The compiled CSS (identical to what [`compile`] would return for the
412    /// same `source`/`options` — the map is generated alongside, not instead).
413    pub css: String,
414    /// The Source Map v3 describing `css`. Serialize it with
415    /// [`SourceMap::to_json`].
416    pub source_map: SourceMap,
417}
418
419/// Compile SCSS source to CSS *and* a [Source Map v3](SourceMap).
420///
421/// The `css` field is byte-for-byte what [`compile`] returns; the map is built
422/// alongside it. The map's `file` is the basename of [`Options::url`] (or
423/// `"stdin"` when no URL is set) and its `sources` are the source URLs.
424/// [`Options::with_source_map_include_sources`] controls whether each source's
425/// full text is embedded in `sourcesContent`.
426///
427/// Maps the start of each selector, declaration property name, declaration
428/// value, at-rule keyword, and comment. A value that is a bare `$name` maps
429/// back to where that variable was DEFINED, like dart-sass.
430///
431/// # Errors
432///
433/// Returns [`Error`] on a parse or evaluation failure, like [`compile`].
434pub fn compile_with_source_map(source: &str, options: &Options<'_>) -> Result<CompileResult, Error> {
435    // Mirror `compile`'s arena bracketing so the returned value is deep-cloned
436    // out to the system allocator before the arena is reset.
437    let guard = arena::Scope::enter();
438    let result = compile_inner_sm(source, options);
439    let outermost = arena::leave_no_reset();
440    let owned = result.clone();
441    drop(result);
442    if outermost {
443        arena::reset();
444    }
445    std::mem::forget(guard);
446    owned
447}
448
449/// The basename of a path/URL (everything after the last `/`), used for the
450/// source map's `file` field.
451fn basename(url: &str) -> &str {
452    url.rsplit('/').next().unwrap_or(url)
453}
454
455/// The source-map compile pipeline: parse + evaluate exactly like
456/// [`compile_inner`], then emit with the source-map collector and assemble the
457/// [`SourceMap`].
458fn compile_inner_sm(source: &str, options: &Options<'_>) -> Result<CompileResult, Error> {
459    // dart's `quietDeps` is a per-compilation notion: scope the record to this
460    // compile — it starts empty, and a nested compile (a warn handler running
461    // `compile` with the same importer) hands the enclosing record back when
462    // it ends — so provenance cannot leak between compiles sharing an importer.
463    // Entered before parsing, so a compile that fails early still leaves the
464    // record in its per-compilation state.
465    let _dep_scope = options.quiet_deps.as_ref().map(|d| d.enter_compile());
466    let glyphs = if options.unicode {
467        diag::GlyphSet::Unicode
468    } else {
469        diag::GlyphSet::Ascii
470    };
471    // Reject `@function`/`@mixin` declarations in control directives or
472    // function/mixin bodies, and a misplaced `@import` (a compile-time
473    // restriction, checked before eval and rendered like a parse error).
474    let sheet = match options.syntax {
475        Syntax::Scss => parser::parse(source),
476        Syntax::Css => parser::parse_plain_css(source),
477        Syntax::Sass => sass_parser::parse(source),
478    }
479    .and_then(|sheet| {
480        if !matches!(options.syntax, Syntax::Css) {
481            eval::validate_declarations(&sheet)?;
482        }
483        Ok(sheet)
484    });
485    let sheet = match sheet {
486        Ok(s) => s,
487        Err(mut e) => {
488            if let Some(url) = options.url {
489                if e.rendered.is_none() && e.has_position() {
490                    let span = diag::trim_empty_span_to_content(
491                        source,
492                        diag::Span {
493                            line: e.line,
494                            col: e.col,
495                            length: e.length,
496                        },
497                    );
498                    e.line = span.line;
499                    e.col = span.col;
500                    e.rendered = Some(diag::render_error(&e.message, source, url, span, glyphs));
501                }
502            }
503            return Err(e);
504        }
505    };
506    // The entry name labels the entry source in the map (its `sources` entry,
507    // once a mapping references it; an import-only entry has none). It is also
508    // the evaluator's `current_url`, so every entry-file node is stamped with a
509    // non-zero file id; its source text is kept for `sourcesContent`. The
510    // source-map path always passes the real source (so
511    // `sourcesContent` works even without a diagnostic URL); this only enriches
512    // the *error* path with snippets — the CSS/map success path is unaffected.
513    let entry_name = options.url.unwrap_or("stdin");
514    let mut ev = eval::Evaluator::new(eval::EvalOptions {
515        style: options.style,
516        importer: options.importer,
517        functions: &options.functions,
518        source,
519        url: entry_name,
520        glyphs,
521        warn: options.warn.as_ref(),
522        quiet_deps: options.quiet_deps.as_ref(),
523        silenced_deprecations: &options.silenced_deprecations,
524        plain_css: matches!(options.syntax, Syntax::Css),
525        source_map: true,
526    });
527    let mut out = Vec::new();
528    ev.eval_sheet(&sheet, &mut out)?;
529    let (css, body_off, collector) = emit::emit_with_map(&out, options.style, options.charset);
530    let mappings = collector.finalize(&css, body_off);
531    let (sources, sources_content) =
532        ev.source_table(mappings.source_ids(), options.source_map_include_sources);
533    let mappings = mappings.encode();
534    let source_map = SourceMap {
535        file: Some(basename(entry_name).to_string()),
536        sources,
537        sources_content,
538        mappings,
539    };
540    Ok(CompileResult { css, source_map })
541}
542
543/// The actual compile pipeline. Runs inside the arena scope established by
544/// [`compile`]; all of its allocations may be arena-resident, so its result is
545/// copied out by the wrapper before the arena is reset.
546fn compile_inner(source: &str, options: &Options<'_>) -> Result<String, Error> {
547    // dart's `quietDeps` is a per-compilation notion: scope the record to this
548    // compile — it starts empty, and a nested compile (a warn handler running
549    // `compile` with the same importer) hands the enclosing record back when
550    // it ends — so provenance cannot leak between compiles sharing an importer.
551    // Entered before parsing, so a compile that fails early still leaves the
552    // record in its per-compilation state.
553    let _dep_scope = options.quiet_deps.as_ref().map(|d| d.enter_compile());
554    let glyphs_for = || {
555        if options.unicode {
556            diag::GlyphSet::Unicode
557        } else {
558            diag::GlyphSet::Ascii
559        }
560    };
561    // Reject `@function`/`@mixin` declarations in control directives or
562    // function/mixin bodies, and a misplaced `@import` (a compile-time
563    // restriction, checked before eval and rendered like a parse error).
564    let sheet = match options.syntax {
565        Syntax::Scss => parser::parse(source),
566        Syntax::Css => parser::parse_plain_css(source),
567        Syntax::Sass => sass_parser::parse(source),
568    }
569    .and_then(|sheet| {
570        if !matches!(options.syntax, Syntax::Css) {
571            eval::validate_declarations(&sheet)?;
572        }
573        Ok(sheet)
574    });
575    // A parse error never reached the evaluator, so render its snippet here
576    // (single `root stylesheet` frame) when a diagnostic URL is configured.
577    let sheet = match sheet {
578        Ok(s) => s,
579        Err(mut e) => {
580            if let Some(url) = options.url {
581                if e.rendered.is_none() && e.has_position() {
582                    let span = diag::trim_empty_span_to_content(
583                        source,
584                        diag::Span {
585                            line: e.line,
586                            col: e.col,
587                            length: e.length,
588                        },
589                    );
590                    e.line = span.line;
591                    e.col = span.col;
592                    e.rendered = Some(diag::render_error(&e.message, source, url, span, glyphs_for()));
593                }
594            }
595            return Err(e);
596        }
597    };
598    // Diagnostics are enabled only when the caller supplies a display URL; then
599    // the evaluator renders byte-exact `Error:`/`WARNING:` blocks against the
600    // source. Without a URL it falls back to the legacy one-liner.
601    let (diag_source, diag_url) = match options.url {
602        Some(url) => (source, url),
603        None => ("", ""),
604    };
605    let glyphs = if options.unicode {
606        diag::GlyphSet::Unicode
607    } else {
608        diag::GlyphSet::Ascii
609    };
610    let mut ev = eval::Evaluator::new(eval::EvalOptions {
611        style: options.style,
612        importer: options.importer,
613        functions: &options.functions,
614        source: diag_source,
615        url: diag_url,
616        glyphs,
617        warn: options.warn.as_ref(),
618        quiet_deps: options.quiet_deps.as_ref(),
619        silenced_deprecations: &options.silenced_deprecations,
620        plain_css: matches!(options.syntax, Syntax::Css),
621        source_map: false,
622    });
623    let mut out = Vec::new();
624    ev.eval_sheet(&sheet, &mut out)?;
625    Ok(emit::emit(&out, options.style, options.charset))
626}