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}