A pure-Rust SCSS → CSS compiler — a from-scratch dart-sass alternative. Zero runtime dependencies, wasm-friendly, usable as a library and a CLI, and designed to match current dart-sass byte-for-byte on the subset it implements.
Status: v0.x, maturing fast. Compiles real-world SCSS and indented
.sassbyte-identically to dart-sass 1.104.1 — on a 148-entry-point production corpus, 147/148 files match byte-for-byte in both output styles and 148/148 source maps match exactly — and passes 99.0% of the official sass-spec suite (14,114 / 14,258 attempted), tracked as a ratchet (see Conformance for what that denominator means). Every divergence we know about is listed in docs/dart-sass-divergences.md.Re-checked on 2026-09-17 against a fresh clone of that corpus with the published 0.15.0 binary — nothing built into the tree, the compiler downloaded from the release page: of its 148 entry points, 138 compile without npm dependencies (dart-sass 1.104.1 fails on the same ten) and 137 of those 138 are byte-identical to dart; the one that differs is the
@extendduplicate-extender case written up in the divergences doc. The npm package, installed from the registry, matches that binary on all 138 — on either engine.
Why another Sass compiler?
grass is the incumbent Rust implementation and a strong one (it compiles
Bootstrap/Bulma byte-accurately and is ~2× faster than dart-sass). But it is
pinned to dart-sass 1.54.3 (mid-2022) and predates the CSS Color Level 4
overhaul, so it diverges from current dart-sass on, e.g., fractional color
channels (rgb(63.75, 127.5, 191.25) vs rounded hex) and emits hex where
dart-sass now keeps rgb()/hsl() forms. sasso targets current
dart-sass exactly, with a span-first parser, a modern color model, and a
zero-dependency, sandbox-friendly core.¹ See
docs/GRASS_LANDSCAPE.md for the full analysis.
¹ Exactly one dependency exists, only on Windows, and only for the CLI's
--update/--watch timestamp: std exposes no local time on any platform,
and Windows has no tz database to read. A POSIX, wasm or wasip1 build still
resolves to nothing at all. See Cargo.toml for why a safe-API crate beats a
second unsafe exemption here.
Features (this slice)
$variables, lexical scoping,!default,!global- Nesting, the
&parent selector (with selector-list multiplication), and combinator normalization (>,+,~) #{}interpolation in selectors, property names and values//(stripped) and/* */(preserved) comments- Numbers with units and unit arithmetic (
$pad * 2 → 16px) - A full color model with fractional channels + author-spelling preservation
(
red,#336699,rgb()/hsl()round-trip unchanged) - Color functions:
rgb/rgba/hsl/hsla/mix/lighten/darken/percentage(+red/green/blue/alpha) @importpartial inlining through a pluggableImporter(CSS imports pass through)expandedandcompressedoutput styles- Source maps (v3) — byte-exact to dart-sass 1.104.1, on the library
(
compile_with_source_map), the CLI (on by default when writing a file, like dart-sass;--embed-source-mapinlines one), and wasm (compile(scss, { sourceMap: true })) - Verbatim preservation of CSS functions it doesn't own (
calc,var,clamp,translateX, …)
Since this slice was written the ratchet has added a great deal more —
@mixin/@function, control flow (@if/@each/@for/@while), @extend
and %placeholders, a calc() engine, the CSS unit system + math functions,
full CSS Color 4 color spaces (oklch/lab/color()…), structured
@media/@supports, maps, the @use/@forward module system (built-in
sass:* modules + user files), and the indented .sass syntax. The
compiler passes 99.0% of the attempted sass-spec suite (14,114 / 14,258)
byte-for-byte against dart-sass 1.104.1 — 11,622 byte-exact CSS outputs plus
2,492 error specs it correctly rejects (see Conformance), and
every known difference is written down in
docs/dart-sass-divergences.md.
Install
CLI — prebuilt binaries. Every release ships static binaries for Linux (gnu + musl), macOS and Windows (x86_64 / aarch64), built with cargo-dist. Grab one from the Releases page, or:
$ curl -fsSL https://github.com/momiji-rs/sasso/releases/latest/download/sasso-installer.sh | sh # Linux/macOS $ cargo binstall sasso # fetch the prebuilt binary $ cargo install sasso # build from source (needs a Rust toolchain)
CLI — Homebrew. macOS and Linux, arm64 and x86_64. It installs the same prebuilt binary as above, from momiji-rs/homebrew-tap:
$ brew trust --formula momiji-rs/tap/sasso $ brew install momiji-rs/tap/sasso
brew trust --formula grants the narrowest trust there is — this formula, not
the tap — and is what lets brew info sasso and brew upgrade sasso work by
short name afterwards.
CLI — Nix. This repo is a flake, so nothing has to be packaged first:
$ nix run github:momiji-rs/sasso -- --version $ nix profile install github:momiji-rs/sasso
In a NixOS or nix-darwin configuration, add the flake's overlays.default and
pkgs.sasso resolves to it — alongside pkgs.sasso-ffi, the C ABI packaged for
building against (libsasso, sasso.h, a pkg-config file). nix develop drops
you into the toolchain CI uses, dart-sass included, so the opt-in parity suite
runs offline (SASSO_PARITY=1 cargo test --test parity). See
nix/README.md for the packaging itself.
Library — crates.io.
npm. Mirrors the dart-sass modern JS API, so it is a drop-in for the
sass package in build tools (no wasm-bindgen, no build step):
One install, two engines. The package carries the wasm build — which works
everywhere, including the browser — and pulls a native addon
(sasso-native-<platform>) as an optionalDependency on macOS and Linux. The
output is byte-identical either way.
The sasso command prefers the addon when it is there and falls back to
wasm when it is not; SASSO_ENGINE=wasm|native forces a choice, and
sasso --engine prints which one an install actually runs (a fallback on a
platform that has a prebuilt addon also says so on stderr — it costs roughly
half the throughput). It prefers
neither when there is something better: if a sasso binary is on PATH and
its version matches the package exactly, the command line is handed to it
and the run ends with its exit code — so brew install momiji-rs/tap/sasso
speeds up the npx sasso in a project's scripts without touching them
(#24). The version has to match because a package pinned in
devDependencies must not silently compile with whatever sasso a developer
happens to have; a mismatch is passed over in silence. SASSO_BINARY=<path>
names a binary explicitly, version unchecked, and SASSO_BINARY=0 turns the
hand-off off. SASSO_DEBUG_ENGINE=1 prints which of these happened and why.
--watch always stays in-process, and now by choice rather than for want of
a watcher: the binary has one, and it polls, because a native watcher would
be a runtime dependency. Measured on macOS, one settled save per process,
twelve fresh processes: the npm CLI answers in 34 ms (median), the binary in
45 ms, dart-sass 1.104.1 in 13196 ms. Handing off would trade the fastest
of the three for the second.
That third number is not a typo and not dart's fault: native filesystem
events are what is slow on macOS. fs.watch on its own, no sasso involved,
delivers in 552 ms (median) on one Mac here and drops 12 of 20 events
entirely on another, against 0.2 ms on Linux. So since #164 the npm CLI does
not rely on them either — it keeps fs.watch for latency and sweeps beside
it for the guarantee. --update hands off only on the version-MATCHED path: the
binary has the flag now, but SASSO_BINARY is unchecked by design and may
name one from before it, so that route stays in-process too. sasso --engine reports the
hand-off rather than taking it.
Importing the library selects nothing: import … from "sasso" is always
the size-optimised wasm build and ignores SASSO_ENGINE, "sasso/speed" is
the faster, larger wasm build, and the addon is the explicit "sasso/native"
subpath. Compiling the 138 stylesheets above in one process, with lila's own
flags, best of five, one run for the whole table — every figure measured against
the published artifacts, the binary downloaded from the release page and the
package from npm install sasso (2026-09-17, macOS / arm64):
npx sasso (native engine) |
266 ms |
npx sasso (wasm engine) |
610 ms |
the sasso 0.15.0 binary |
137 ms |
| dart-sass 1.104.1 | 2268 ms |
What the hand-off is worth, on 40 entry points with --style=compressed --no-source-map and the same published artifacts (2026-09-18, macOS / arm64,
one run for all three): the 0.16.0 binary 15.1 ms, npx sasso on the native
addon 104.1 ms, and npx sasso handing the command line to that same binary
48.2 ms — identical CSS in all 40 files. The 33 ms it does not recover is
Node: starting it and spawning a child costs 35.0 ms of the 48.2 on a single
tiny file. Nothing about a stylesheet makes that cheaper, which is why the
binary is worth installing on its own.
import { compileString } from "sasso"; compileString("a { color: #ffffff }", { style: "compressed" }).css; // a{color:#fff}
Works as the sass implementation in webpack/sass-loader (implementation: require("sasso"), api: "modern") and Vite (alias "sass": "npm:sasso").
Library usage
use sasso::{compile, Options, OutputStyle}; let scss = "$c: #336699; .a { color: $c; &:hover { color: lighten($c, 10%); } }"; let css = compile(scss, &Options::default()).unwrap(); assert!(css.contains("a:hover")); // Minified: let min = compile(scss, &Options::default().with_style(OutputStyle::Compressed)).unwrap();
@import resolution is controlled by an Importer you supply, so file
access stays on your side of any sandbox:
use sasso::{compile, Importer, Options}; struct MyFs; impl Importer for MyFs { fn resolve(&self, path: &str) -> Option<String> { std::fs::read_to_string(format!("scss/_{path}.scss")).ok() } } let css = compile("@import \"base\";", &Options::default().with_importer(&MyFs)).unwrap();
A ready-made FsImporter is provided for standalone/CLI use.
CLI usage
The CLI takes the same arguments as sass, so a build script written for
dart-sass runs unchanged:
$ cargo install --path . # installs the `sasso` binary $ sasso input.scss # CSS to stdout (expanded) $ sasso input.scss out.css # to a file, with out.css.map (dart's default) $ sasso --style=compressed --no-source-map input.scss out.css $ sasso a.scss:out/a.css b.scss:out/b.css # many files, compiled in parallel $ sasso scss/:css/ # a whole tree (partials skipped) $ sasso -I scss/ main.scss # add @use/@import load paths $ echo '.a{color:red}' | sasso --stdin
Several inputs (in:out pairs or a directory pair) compile in parallel, one
worker per physical core where the topology is known — Linux, via
/proc/cpuinfo — and one per CPU where it is not. That is an upper bound: an
affinity mask or a cgroup CPU quota lowers it, and -j N overrides it
outright. Diagnostics are reported in command-line order. Supported dart-sass
flags: --[no-]source-map, --source-map-urls, --[no-]embed-sources,
--[no-]embed-source-map, --[no-]error-css, --[no-]charset, -q/--quiet,
--quiet-deps, --stop-on-error, --[no-]unicode, --[no-]color (accepted;
sasso never colors), --indented, --stdin. Exit codes match on both CLIs (64
usage, 65 compile error, 66 unreadable input or unwritable output; the worse
of the two when one batch has both). Not supported by the binary:
--pkg-importer and the deprecation-selection flags. --update and
-w/--watch are on both CLIs (#86): --update compares the output against
the entry and every stylesheet the entry loads, as dart-sass does, and
--watch follows that same set, refuses the same three shapes dart refuses,
and prints the same banner and per-write line. --[no-]poll selects how the npm CLI
watches — --poll sweeps only, --no-poll uses node's fs.watch only, and
the default is both (#164). On the binary it is still accepted and still does
nothing, because that one always polls: a native watcher would mean a
dependency. src/watch.rs and wasm/npm/_poller.mjs have the measurements.
sasso --help lists everything.
Conformance
The official sass-spec suite is the parity oracle. The harness in
spec/ runs the compiler against every spec case and reports a
pass rate; we ratchet it upward over time.
| Metric | Value |
|---|---|
| sass-spec commit | b39c3276 (2026-09-08), reference dart-sass 1.104.1 |
| Total cases | 14,266 |
Attempted (excl. 8 dart-sass :todo) |
14,258 |
| Passing | 14,114 — 98.99% of attempted (98.93% of all 14,266) |
| ↳ byte-exact CSS output | 11,622 |
| ↳ error specs correctly rejected | 2,492 |
Passing = byte-exact CSS output match plus error specs the compiler
correctly rejects — the standard sass-spec conformance metric (the harness
checks that an error spec errors; the error message is tracked separately as
a non-gating metric). The 8 excluded cases are tagged :todo for dart-sass
itself upstream — dart-sass doesn't pass them either; sasso matches
dart-sass's actual behaviour on all 8 regardless.
The rate is a ratchet, not a high-water mark: CI fails if it drops. It reads
lower than it once did because the pinned suite moved forward — an earlier
snapshot (1b03109a, dart-sass 1.101.0, 13,896 attempted) was passed in full,
and re-pinning to a 2026-09-08 suite added cases we do not pass yet. Re-pinning
is deliberate: a conformance number against a stale oracle measures the wrong
thing.
What the remaining cases are, and every other known difference from dart-sass, is written down in docs/dart-sass-divergences.md — including the handful that affect compiled output, so the claim above has a checkable denominator.
The numbers above are expanded output. sass-spec ships one expectation per
case and dart-sass generated every one of them in the default expanded style,
so for most of this project's life no gate looked at compressed CSS at all. A
second ratchet now does, against a committed manifest of per-case digests of
dart-sass 1.104.1's compressed output for the same cases
(spec/COMPRESSED_EXPECT.txt, see spec/REPORT.md):
| Metric | Value |
|---|---|
| Passing, compressed | 14,052 — 98.56% of attempted |
| ↳ byte-exact compressed CSS | 11,560 |
| ↳ error specs correctly rejected (style-independent) | 2,492 |
The gate found 1,528 cases that compile to byte-exact expanded CSS and to
compressed CSS that is not byte-exact — every one of them byte-shortening
rather than meaning. Four mechanisms have accounted for 1,422 of them: the
%/deg a colour function's lightness and hue keep when compressed, which of
dart's two number writers decides whether a fraction loses its leading zero,
which nodes survive compression empty (@media {} goes, @font-face {} stays
— dart keeps an at-rule it does not know the semantics of), and whether a
preserved call is a calculation or a string (a string carries one spelling for
both styles, so round(1px, 2bar) could not lose its space). A fifth, which of
the legacy rgb()/hsl() forms a colour is written in, accounted for 28 more,
and five smaller ones for the last 14: the order dart multiplies a channel by
its maximum in, the spelling an @import keeps for its media modifiers, the
space a plain-CSS @function's SassScript declaration may drop, the escape a
private-use character trades for its own bytes, and what meta.inspect escapes
whatever the style. The last one took a sixth: which of dart's node classes an
at-rule came from, which its NAME cannot answer, because the parser decides the
class and @#{"media"} (a: 1) is a generic at-rule that merely spells itself
@media. The 62 cases that remain are all ones where dart-sass 1.104.1's own
expanded output no longer matches the expectation sass-spec ships, so they
cannot pass both ratchets at once. This is a ratchet like the other one — it can
only go up.
Strict input validation, too. Matching dart-sass means rejecting what
dart-sass rejects, not just reproducing its output. sasso errors — rather than
silently accepting — on an invalid hex literal (#00000), out-of-grammar
rgb()/hsl() arguments, a duplicate @mixin/@function parameter, a
misplaced @content/@extend, a style rule or declaration inside a
@function body, a malformed :nth-child() / empty :not() selector, a bad
@charset or @at-root (…) query, and more — each with dart-sass's exact
message.
Run it yourself:
$ spec/fetch.sh # clone the suite $ cargo build --release $ SASS_BIN=target/release/sasso python3 spec/run_spec.py $ python3 spec/check_baseline.py # expanded ratchet $ python3 spec/check_baseline.py --style compressed # compressed ratchet
Performance
sasso is a native, in-process library — no subprocess, no Node, no Dart VM —
so startup is effectively free, which dominates when a build compiles many
files. On arm64 macOS it is the fastest of the three engines measured,
beating dart-sass by 19–30× end-to-end and leading grass (the incumbent Rust
compiler) by ~2.3–2.9×:
| Axis | sasso | grass | dart-sass (bin) | npx sass |
|---|---|---|---|---|
| Startup² | 1.8 ms | 1.8 ms | 139 ms | 495 ms |
| Cold single large file | 11.7 ms | 26.5 ms | 354 ms | 710 ms |
| Batch (40 files, 1 process) | 49.0 ms | 136 ms | 916 ms | — |
| Pure compile (startup removed) | 7.4 ms | 21.3 ms | ~216 ms¹ | — |
¹ derived (cold − startup) — dart-sass has no in-process loop mode. So sasso is
~29× faster than dart-sass on pure compute, ~30× on a cold single file, and
~77× on startup; vs grass it is ~2.3× cold / ~2.8× batch / ~2.9× pure.
² Startup compiles a 1-rule file, so it sits at the OS process-spawn floor —
sasso and grass measure identically (1.8 ms) here (the mean is dominated
by scheduler jitter at this sub-2 ms scale). The native library and wasm builds
remove process startup entirely. A
scoped bump-arena allocator (one audited unsafe module, Miri- and
AddressSanitizer-verified; the rest of the library stays unsafe-free) gives a
further ~1.5× by turning each compile's allocations into a pointer bump freed
wholesale at the end. Composite values (strings, lists, maps) are
reference-counted, so reading a $variable is an O(1) refcount bump, not a
deep copy — on a large list passed through a call chain without mutation this
cuts both instructions (~7×) and peak memory (~13×). A round of evaluator
allocation trimming — skipping the per-rule selector clone when nothing extends
it, iterating @each over the list's shared handle, and dropping redundant
per-declaration copies — shaves a further ~2.8% off pure compile on
representative stylesheets (measured by instructions-retired, since the win is
below wall-clock jitter at this ms scale; byte-identical output). Eight further
rounds carry that campaign on — a rule's selector list resolved once and shared,
carried into the output tree rather than re-materialised, the selector scanners
reading from an inline character buffer, @function and
@mixin frames built only where something lands in them, a built-in call that
stops collecting argument spans nothing will read, a nested selector resolved
without the throwaway scaffolding, a number's unit shared rather than copied,
and a template's literal text handed back rather than rebuilt. On the large
corpus that is 133.840M → 109.616M instructions and 377,761 → 155,656
allocations — -18.1% and -58.8%, arithmetic on the endpoints each round
reports — every round verifying the sass-spec ratchet at delta +0. The
per-round tables are in the 0.14.0 and
0.15.0 changelog entries. Full three-way
methodology, per-file numbers and the correctness diff are in
bench/three_way.md — which reports through the earlier
−27% round and none of the allocation campaign above; run it yourself with
cd bench && RUNS=12 WARMUP=3 LOOP_N=200 bash scripts/run_bench.sh.
WebAssembly
Because the library is zero-dependency and pure std on every target that
is not Windows — and the one Windows dependency is the CLI's clock, not the
library — it compiles to wasm32-unknown-unknown and wasm32-wasip1 out of
the box (built in CI). The
deployable .wasm cdylib ships in two variants, published to npm as
sasso:
| Variant | Build | Over the wire | Compile (large, in Node)³ |
|---|---|---|---|
| size (default) | opt-level = "z" + LTO + panic = "abort" + strip + wasm-opt -Oz |
~854 KB / ~356 KB gzip | ~27 ms |
speed (sasso/speed) |
opt-level = 3 + wasm-opt -O3 |
~1.84 MB / ~637 KB gzip | ~12 ms |
³ in-process compile of the same large file, Node 22 (best-of-N). The wasm tax
over native sasso (7.7 ms) is ~1.5× for the speed build and ~3.5× for the
size build; the wasm build runs without the bump arena. Even so the speed
build (~12 ms) beats native grass (21 ms) and every dart-sass form a Node
toolchain can run.
A whole modern Sass compiler — @use/@forward, @extend, the calc engine,
CSS Color 4 — in a few hundred KB gzipped, far smaller than shipping the
dart-sass compiler as JavaScript. A browser playground is tracked in the issues.
Language bindings
sasso ships as a Rust crate and is usable from other languages too. First-party
packages are released by this project and pin a published sasso crate version;
community bindings are maintained in their own repos.
| Language | Package | Maintained by | How |
|---|---|---|---|
| Rust | sasso (crates.io) |
First-party | the core library — see Library usage above |
| JavaScript / wasm | sasso (npm) |
First-party | the in-repo wasm/ cdylib — see WebAssembly above |
| Ruby | sasso (RubyGems) |
First-party | momiji-rs/sasso-ruby — an in-process native extension (magnus + rb-sys) around this crate |
| Python | sasso (PyPI) |
First-party | momiji-rs/sasso-python — ctypes over the C ABI; one prebuilt wheel per platform, no build step |
| Go | sasso-go (go get) |
First-party | momiji-rs/sasso-go — pure Go, no cgo: embeds the wasm build and runs it with wazero, so CGO_ENABLED=0 and cross-compilation just work. String-in/CSS-out (for file-based importers use the C ABI via cgo, below) |
| PHP | shyim/php-sasso (PIE / pecl) |
Community (@shyim) | an ext-php-rs extension (Sasso\Compiler) wrapping this crate in-process; prebuilt for PHP 8.2–8.5 (Linux glibc/musl, macOS) |
Ruby framework integrations build on that gem — drop-in Sass for your stack, compiled in-process (no Node, no Dart, no subprocess) and byte-for-byte identical to dart-sass:
| Framework | Gem | Repo |
|---|---|---|
| Rails (Propshaft + Sprockets) | sasso-rails |
momiji-rs/sasso-rails |
| Bridgetown | bridgetown-sasso |
momiji-rs/bridgetown-sasso |
| Hanami (2.1+) | hanami-sasso |
momiji-rs/hanami-sasso |
Each compiles Sass without a Node toolchain — typically ~6–7× faster per compile
than the Node sass default (and far faster cold, with no process spawn).
C ABI — use sasso from any language
Beyond the packages above, sasso ships a C ABI (ffi/) so any
language with a C FFI can drive the compiler in-process. Each
release attaches a per-target
sasso-<version>-<target>-c-api.tar.xz (.zip on Windows) containing the
prebuilt library and the header — the universal substrate every binding sits on:
include/sasso.h
lib/ libsasso.a # static — link it for a self-contained binary, no runtime dep
libsasso.so|.dylib # dynamic (Windows: sasso.dll + sasso.dll.lib + sasso.lib)
The ABI is two owned calls plus an optional importer callback — see
ffi/include/sasso.h, the contract notes there, and
runnable bindings for 8 languages under
ffi/examples/ (C, Go, Ruby, Swift, Deno, Bun, LuaJIT, C#).
From Go
Statically link libsasso.a via cgo for a single self-contained binary
(no CGO_ENABLED=0, but no runtime dependency either):
package main /* #cgo CFLAGS: -I./sasso-c-api/include #cgo LDFLAGS: ./sasso-c-api/lib/libsasso.a #include <stdlib.h> #include "sasso.h" */ import "C" import ( "fmt" "unsafe" ) func main() { src := ".a { .b { color: #336699 } }" cs := C.CString(src) defer C.free(unsafe.Pointer(cs)) r := C.sasso_compile(cs, C.size_t(len(src)), nil) defer C.sasso_result_free(r) if r.ok == 0 { panic(C.GoString(r.error)) } fmt.Print(C.GoStringN(r.css, C.int(r.css_len))) }
A fuller cgo binding — options, errors, and a custom importer — is in
ffi/examples/go/. Prefer no cgo? The first-party
sasso-go package is ready-made:
go get it and it embeds the wasm build and runs it with
wazero, keeping CGO_ENABLED=0 and
trivial cross-compilation (string-in/CSS-out; use the cgo path above when you
need file-based importers). Or dlopen the dynamic lib at runtime with
purego.
Testing & coverage
$ cargo test # unit + integration + doctests (offline) $ SASSO_PARITY=1 cargo test --test parity # live diff vs dart-sass (needs `npx sass`) $ cargo llvm-cov --workspace # coverage report $ cargo clippy --all-targets -- -D warnings $ cargo fmt --check
Changelog
Notable changes are recorded in CHANGELOG.md.
Code of Conduct
As a Sass implementation, sasso adopts the
Sass Community Guidelines — see
CODE_OF_CONDUCT.md.
License
Licensed under either of Apache-2.0 or MIT at your option.