Skip to content

Adopt core sasso 0.14.0, align the gem version with it, drop Ruby 3.1 - #3

Merged
linyiru merged 19 commits into
mainfrom
feat/adopt-core-0.14.0
Sep 17, 2026
Merged

linyiru merged 19 commits into
mainfrom
feat/adopt-core-0.14.0

Conversation

@linyiru

@linyiru linyiru commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Brings the gem up to the current core compiler (sasso 0.6.3 → 0.14.0, seven
releases at once), renumbers the gem to match, exposes the compiler options those
releases added, and drops Ruby 3.1.

The core bump is API-compatible

ext/sasso/src/lib.rs needed no change to compile against 0.14.0. The one
breaking marker in the range — WarnEvent becoming #[non_exhaustive] — touches
nothing the binding constructs.

One test expectation moved. test_compressed_color_shortest_form now expects
a{x:rgb(15%,30%,45%)} where it expected a{x:hsl(210,50%,30%)}: core 0.9.0
routes compressed hsl/hwb through rgb like every other legacy space. Rather than
copy the compiler's own output into the expectation, I checked it against
npx sass@1.104.1 --style=compressed, which emits a{x:rgb(15%,30%,45%)}b{y:#369}
— the new output is the correct dart-sass form. The CHANGELOG lists every other
output change in the range for anyone who byte-compares CSS.

Version alignment: 0.2.7 → 0.14.0

The gem version floated independently of the crate, so "sasso 0.2.7" said nothing
about which compiler it carried — the three framework gems each documented the
mapping by hand, and a bug report needed both numbers to be actionable. From here
a gem release adopting core X.Y.Z is gem X.Y.Z.

A gem-only fix takes the next patch, so the gem can sit ahead of the crate within
a minor; the new Sasso::CORE_VERSION reads the version out of the linked binary,
so it cannot drift from what is actually loaded. A test asserts it equals the pin
in ext/sasso/Cargo.toml.

New options

refactor(ext) first moves the native ABI to one hash in, one array out, so
adopting a compiler option costs a key rather than an arity change on both sides.
Then:

  • charset: — false omits the @charset "UTF-8"; prefix (a U+FEFF BOM when
    compressed) that non-ASCII output carries. Verified byte-for-byte against
    dart-sass 1.104.1, including that --no-charset suppresses both.
  • quiet:, quiet_deps:, on_warn: — control over diagnostics.
    Correction to something I said while analysing this: warnings were never
    missing, they have always printed to $stderr. What was missing was any way to
    turn them off or read them programmatically.

The diagnostics design matters more than the surface: the default path installs
no warn handler, which is what keeps the core printing each block as it is
raised and costs nothing. Only on_warn: allocates. And the handler runs during
the compile, so it records events Rust-side and hands them to Ruby after compile
returns — Ruby is never re-entered mid-compile.

Deliberately deferred: custom functions (Options::with_function). They need
Ruby callbacks during compilation — the same GC-pinning / GVL re-entrancy hazard
for which lib.rs already defers a Ruby-callback importer. Worth doing on its own,
not smuggled into a version bump.

Removals

  • Global whiteness() / blackness() now error (core 0.9.0, matching
    dart-sass — they are sass:color-only). The one change here that can stop an
    existing stylesheet compiling.
  • Ruby 3.1, which left security maintenance in March 2025. >= 3.2.0 in the
    gemspec, off the CI matrix and off the precompiled-gem matrix.

Performance, remeasured

benchmark/Gemfile.lock had been pinning gem 0.1.1 and sass-embedded 1.101.0;
relocked to the current pair. The ~180-rule case is ~10% faster.

The cold-start row is corrected downward: README claimed 1.1 ms / 35×, and I
could not reproduce that on either the old or the new gem (3.2–3.5 ms on two
machines) — it predates this release. It now reads 3.2 ms / 12.7×.

A 44% regression on the small input turned out to be noise: three rounds each on
this Mac Studio and on a Ryzen 7 8745HS put NEW at 13.7–14.5 µs against OLD at
13.4–15.1 µs, with the sign flipping between hosts.

Verification

  • 29 tests, 76 assertions, green on macOS arm64 (Ruby 3.4.1) and on Linux
    x86_64
    (Arch, Ruby 3.4).
  • cargo clippy clean; cargo fmt --all -- --check clean.
  • gem build produces sasso-0.14.0.gem.

Notes for review

  • style(ext): apply rustfmt is formatting only — no behaviour in it.
  • sig/sasso.rbs gains the new options and source_map: / CompileResult,
    which shipped in gem 0.2.0 and never reached the signatures or the README.
  • After this releases, the dependency floor wants bumping to >= 0.14.0 in
    sasso-rails, bridgetown-sasso and hanami-sasso. Tagging v0.14.0 is what
    triggers the release workflow.

🤖 Generated with Claude Code

Moves the exact pin from 0.6.3 to 0.14.0, spanning seven core releases
(0.7.0, 0.8.0, 0.8.1, 0.9.0, 0.9.1, 0.10.0, 0.14.0). The binding needs no
code change: the crate's public API only gained items over that span
(`VERSION`, `DependencySet`, `Options::with_quiet_deps`, `WarnEvent::path`),
and the one breaking marker — `WarnEvent` becoming `#[non_exhaustive]` —
touches nothing this binding uses today.

One test expectation moves with the core. Core 0.9.0 aligned legacy-color
serialization with dart-sass 1.101.4: a color with any fractional channel
writes its rgb triple as percentages, and compressed hsl/hwb route through
rgb like every other legacy space. So `darken(#336699,10%)` compresses to
`rgb(15%,30%,45%)` rather than `hsl(210,50%,30%)`. Verified against
dart-sass 1.104.1 rather than taken from the compiler's own output.
Ruby 3.1 left security maintenance in March 2025. Dropping it takes a
platform off the cross-gem matrix (seven platforms x four minors becomes
seven x three) and lets the gem require 3.2. sasso-rails already dropped
3.1 from its own matrix; this brings the binding in line.
`_compile` and `_compile_with_map` took six and seven positional arguments,
and every compiler option adopted from the core crate widened both. Replace
them with a single `_compile(source, opts) -> [css, source_map_json_or_nil]`,
reading each option out of a Hash: a new option is now a key on both sides
rather than an arity change, and the source-map variant is the same entry
point with `source_map: true`.

`Sasso::Native` is a private ABI — lib/sasso.rb is its only caller — so this
is not a user-visible change. No behavior change: the public keyword API,
its defaults and its return shapes are untouched.
`@warn`, `@debug` and deprecation warnings have always reached stderr — the
core prints them itself when an embedder installs no handler — but the gem
gave no way to turn them off or to read them, so a build log carrying a
dependency's deprecation spam had no recourse. Three options, mapping onto
what the core has offered since 0.10.0:

- `quiet: true` prints nothing.
- `quiet_deps: true` (dart-sass `quietDeps`) drops deprecation warnings
  raised inside dependencies — files resolved through a load path — while the
  entry stylesheet's own still print, and `@warn` is untouched either way.
- `on_warn:` takes a callable and hands it each diagnostic as a Hash of
  `Sasso::WARNING_KEYS`, which replaces the stderr printing rather than
  duplicating it. `:formatted` is the full dart-style block, so a caller can
  route the compiler's own rendering into its logger unchanged.

Default behavior is unchanged, and deliberately costs nothing: with neither
option set the binding installs no handler at all, so the core keeps printing
its block as each diagnostic is raised rather than after the compile.

The handler runs mid-compile, so it records events on the Rust side and Ruby
receives them once `compile` has returned — no re-entering the VM from inside
the compiler. A failed compile raises and takes its warnings with it; the
diagnostic in `CompileError#message` is the whole story there.

`sig/sasso.rbs` gains these, and picks up the `source_map:` options and
`CompileResult` that the 0.2.0 source-map release never added to it.
dart-sass prefixes output containing non-ASCII with a declaration of its
encoding — `@charset "UTF-8";` when expanded, a U+FEFF BOM when compressed —
and `charset: false` (dart-sass `charset` / `--no-charset`) omits it, which is
what a pipeline concatenating stylesheets or serving with its own charset
header wants. The core has taken the option since before the gem existed; it
was simply never plumbed through.

Both forms and their suppression are asserted byte-for-byte against
dart-sass 1.104.1.
Which compiler a given install actually carries was previously answerable
only by reading ext/sasso/Cargo.toml in the packaged gem. `CORE_VERSION`
reports it from the linked crate, via `sasso::VERSION` — which the core added
in 0.9.1 after its own C ABI shipped artifacts reporting the wrapper crate's
version instead of the compiler's.

Reading it from the binary rather than restating it in Ruby is the point: the
value cannot drift from what is loaded. The accompanying test uses that to
assert the loaded crate matches the manifest's pin.
The table was measured against `sasso` 0.1.1 and `sass-embedded` 1.101.0 —
benchmark/Gemfile.lock was still pinning both. Remeasured with core 0.14.0
against `sass-embedded` 1.104.1, and the lock updated so a reader reproduces
the same pairing.

What moved: the ~180-rule case is ~10% faster (the core's allocation work
between 0.10.0 and 0.14.0), the small-input case is unchanged, and dart-sass's
own numbers barely moved.

The cold-start row is corrected rather than improved: it claimed 1.1 ms, and
neither 0.2.7 nor 0.14.0 measures under 3.2 ms on this machine, so the 35x
became a measured 12.7x. That gap predates this release — 0.2.7 measures the
same 3.4 ms — and its origin is somewhere in 0.1.1 -> 0.2.x, unexamined here.

Also noted the versions each row was measured against, which is what let this
drift sit unnoticed.
Covers `charset:`, `quiet:`, `quiet_deps:`, `on_warn:` and the two version
constants, with an example of routing the compiler's own diagnostic block into
an application logger.

Also documents `source_map:` for the first time. It shipped in gem 0.2.0 and
never reached the README — neither the option table nor the `CompileResult` it
returns, so the only way to find it was the source.
0.2.7 -> 0.14.0. The gem version has floated independently of the compiler
crate since 0.1.0, which meant nothing about "sasso 0.2.7" said which compiler
it carried: sasso-rails, bridgetown-sasso and hanami-sasso each documented the
mapping by hand, and a bug report needed both numbers before anyone could act
on it. From here a release adopting core X.Y.Z is gem X.Y.Z.

Within a minor the two can still diverge — a gem-only fix takes the next patch
and moves ahead of the crate — so `Sasso::CORE_VERSION` remains the authority
on what a given install actually links.
Formatting only, over the code the preceding commits added. The repo has no
rustfmt.toml and CI does not check formatting, so this keeps `cargo fmt --all
-- --check` clean rather than introducing a gate.
Two lines still described the old scheme, where the gem versioned itself
independently of the compiler it bundles: the intro's "releases on its own
cadence" and Versions' "the two float independently". Both are wrong as of
this release.

Say the rule instead — gem X.Y.Z pins crate X.Y.Z, a gem-only fix takes the
next patch, and CORE_VERSION is what settles the question — and show the two
constants side by side, since the whole point of CORE_VERSION is being able
to read them together.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Moderate issues remain in diagnostics handling and the CompileResult signature.

Pull request overview

Updates the Ruby binding to core sasso 0.14.0, adds compiler options and diagnostics support, and drops Ruby 3.1.

Changes:

  • Aligns gem and core versions.
  • Adds charset, warning, quiet-dependency, and source-map APIs.
  • Updates documentation, tests, benchmarks, dependencies, and CI.
File summaries
File Summary
test/compile_test.rb Adds coverage for new options and diagnostics.
sig/sasso.rbs Declares new APIs; CompileResult needs a two-argument constructor signature.
sasso.gemspec Raises the minimum Ruby version to 3.2.
README.md Documents APIs, versions, and benchmarks.
lib/sasso/version.rb Sets gem version to 0.14.0.
lib/sasso.rb Implements the expanded compile API.
ext/sasso/src/lib.rs Adds native options and diagnostics handling; quiet dependencies, allocation, and error-path warnings need changes.
ext/sasso/Cargo.toml Pins core sasso 0.14.0.
CHANGELOG.md Documents the release; release count should be corrected from seven to eight.
Cargo.lock Updates the locked core dependency.
.github/workflows/release.yml Removes Ruby 3.1 release builds.
.github/workflows/ci.yml Removes Ruby 3.1 CI jobs.
Review details

Suppressed comments (5)

CHANGELOG.md:15

  • The stated count is off by one: moving from core 0.6.3 to 0.14.0 adopts 0.7.0, 0.8.0, 0.9.0, 0.10.0, 0.11.0, 0.12.0, 0.13.0, and 0.14.0—eight releases, not seven. Please correct this changelog description so the release history is accurate.
_The gem version now tracks the core compiler's. It jumps 0.2.7 → 0.14.0 to meet
`sasso` 0.14.0, adopting seven core releases at once (0.7.0 through 0.14.0)._

ext/sasso/src/lib.rs:148

  • FsImporter::dependencies() is read before either compile call has resolved any @use/@import, so this passes an empty snapshot to with_quiet_deps. As a result, quiet_deps: true will not suppress deprecation warnings from dependencies (the new regression test should expose this once it exercises the actual behavior). Configure quiet-dependency tracking with the importer’s live/shared dependency set, or otherwise apply the resolved dependency paths through an API that is populated during compilation.
    if flag(ruby, opts, "quiet_deps", false)? {
        copts = copts.with_quiet_deps(importer.dependencies());

ext/sasso/src/lib.rs:154

  • The default warnings: "stderr" path still constructs an Rc<RefCell<Vec<Warning>>> for every compilation, even though no handler ever reads it. This adds an allocation to the hot path and contradicts the documented claim that the default installs no handler and costs nothing; create the capture buffer only for "capture" (or otherwise avoid allocating it unless on_warn: was requested).
    let captured: Rc<RefCell<Vec<Warning>>> = Rc::new(RefCell::new(Vec::new()));

ext/sasso/src/lib.rs:177

  • When compilation emits a warning and then fails, this ? returns before the captured diagnostics are converted and returned to Ruby, so on_warn: silently loses warnings raised before the compile error. The API/docs promise delivery of each diagnostic; preserve/drain the captured events on the error path (or define and document that callbacks are only delivered for successful compilations).
    if want_map {
        let result = sasso::compile_with_source_map(&source, &copts)
            .map_err(|e| compile_error(ruby, e.to_string()))?;
        out.push(result.css)?;
        out.push(result.source_map.to_json())?;
    } else {
        let css =
            sasso::compile(&source, &copts).map_err(|e| compile_error(ruby, e.to_string()))?;

sig/sasso.rbs:28

  • This declaration does not describe the actual constructor: CompileResult is a Struct.new(:css, :source_map) and is instantiated with two arguments in lib/sasso.rb:91, but an RBS class without an initialize signature exposes the inherited zero-argument constructor. Type-checkers will reject the implementation (and users cannot type-check constructing the documented result). Add the two-argument initialize signature, or model the Struct-generated constructor explicitly.
  class CompileResult
    attr_reader css: String
    attr_reader source_map: Hash[String, untyped]
  end
  • Files reviewed: 11/12 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

A compile can `@warn` and then fail. The native side raised the compile error
straight from Rust, which returned before the recorded diagnostics were handed
back — so under `on_warn:` those warnings vanished entirely. Not merely
undelivered: `on_warn:` suppresses the compiler's own printing, so the callable
is their only outlet, and nothing else reported them. The default stderr path
never had the problem, since the core prints each block as it is raised.

dart-sass hands warnings to its logger and throws after, so delivery-then-raise
is also the parity behaviour.

The ext now RETURNS the failure as a String in a fourth slot instead of raising,
and `compile_string` delivers every diagnostic before raising CompileError from
Ruby. Both compile entry points normalize to one `Result<(css, map), _>` so the
error path is written once. Raising from Ruby also drops the Rust-side constant
lookup for `Sasso::CompileError` and its RuntimeError fallback — the message is
identical, it was only ever reaching for a class Ruby already has in hand.
The sink was built unconditionally, so the default stderr path — where no
handler is installed and nothing ever reads it — still paid an Rc allocation per
compile, contradicting the comment right above it. Move it inside the "capture"
arm and make it an Option; the comment is now true.

The cost was one small allocation against a ~13 µs compile, so this is about the
claim rather than the microseconds.
CompileResult is a Struct.new(:css, :source_map) built with two arguments, but
an RBS class body with no `initialize` inherits Object's zero-argument `new`, so
a type-checker rejects the implementation's own construction and anyone
type-checking against the gem cannot build the documented result. Declare the
constructor and the Struct superclass. `rbs -I sig validate` is clean, and
`rbs method --instance ::Sasso::CompileResult initialize` now resolves to
`(::String, ::Hash[::String, untyped]) -> void`.
@linyiru

linyiru commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Went through the five points. Three were real and are fixed in 24cb5b3, 2807082 and 1aebee3; two I checked against the core crate and the suite and am not taking.

Adopted

Warnings lost on the error path (lib.rs:177) — real, and worse than the review says. It isn't just non-delivery: on_warn: suppresses the compiler's own printing, so the callable is the only outlet, and a warning raised before a failure was reported by nothing at all. Reproduced before the fix:

raised CompileError: Error: 1px and 1em have incompatible units.
on_warn saw: []

Rather than document it, fixed it — dart-sass hands warnings to its logger and then throws, so delivery-before-raise is the parity behaviour. _compile now returns the failure as a String in a fourth slot instead of raising, and compile_string delivers every diagnostic before raising CompileError from Ruby. Both compile entry points normalize to one Result<(css, map), _> so the error path is written once. A side benefit: raising from Ruby drops the Rust-side constant lookup for Sasso::CompileError and its RuntimeError fallback. Regression test added; the default stderr path is unchanged.

Unconditional sink allocation (lib.rs:154) — correct, and it made the comment above it untrue. Now an Option built inside the "capture" arm. Worth saying the cost was one small Rc allocation against a ~13 µs compile, so this is about the claim, not the microseconds.

CompileResult constructor (sig/sasso.rbs:28) — correct. Declared, along with the Struct[untyped] superclass. rbs -I sig validate is clean and rbs method --instance ::Sasso::CompileResult initialize resolves to (::String, ::Hash[::String, untyped]) -> void.

Not adopted

quiet_deps reads an empty snapshot (lib.rs:148) — FsImporter::dependencies() does not return a snapshot. It is DependencySet(Arc<Mutex<DependencyState>>) and dependencies() clones the shared handle; the core's own doc calls it "a shared handle that keeps filling in as the compile proceeds" and says to "hand it to Options::with_quiet_deps". Reading it before compile is the only way to use it. The existing test is not vacuous either — it asserts 2 color-functions deprecations without the flag and exactly 1 with it, plus that the dependency's @warn still comes through.

"seven releases" is off by one — it is seven. 0.11.0, 0.12.0 and 0.13.0 were never published; the core went 0.10.0 → 0.14.0. crates.io above 0.6.3: 0.7.0, 0.8.0, 0.8.1, 0.9.0, 0.9.1, 0.10.0, 0.14.0.

Suite is 30 runs / 80 assertions green, cargo fmt --check and clippy clean.

Diagnostics are delivered before the CompileError is raised, so an exception
from the callable escaped first and the Sass error was gone: a transiently
failing logger (full disk, closed Rails logger) turned a diagnosable compile
failure into an unrelated RuntimeError.

Deliver inside a begin/ensure so the CompileError wins. Ruby records the
callable's exception as its #cause, so neither is lost, and a raising callable
on a SUCCESSFUL compile still propagates as before. Tests cover all three
cases.

This commit also tests the deprecation repetition cap, which the next one
documents.
Two behaviours a caller cannot guess, both verified against the built extension:

The compiler caps a repeated deprecation at five per id (as dart-sass does) and
then emits one summary diagnostic, "N repetitive deprecation warnings omitted."
It is the only diagnostic with no :deprecation_id, no :url and :line of 0, so
the README's own filtering example a few lines above would have logged it, and
code formatting "url:line" would render ":0". The core offers no verbose mode
that lifts the cap, so say that rather than leave the reader hunting for one.

Also replaces the "never loses one" claim about the error path, which was true
of the error but overclaimed against the cap, with the precise statement: the
warnings survive the failure, and a raising callable does not mask it.
The entry claimed the global functions "now error" and called it "the one change
here that can stop an existing stylesheet compiling". Neither is true. Verified
against the built extension: `a{x:whiteness(#f00)}` compiles to
`a { x: whiteness(#f00); }` — the call is simply no longer a built-in, so it
falls through as an unknown plain-CSS function. Old core 0.6.3 evaluated it to
`0%`, and dart-sass 1.104.1 emits the same passthrough as 0.14.0, arithmetic
included (`1 + whiteness(#f00)` → `1whiteness(#f00)` in both).

So the entry had the risk backwards: the change is silent, not loud, which makes
it the one to grep for rather than one the compiler will point at. Moved out of
Removed into Changed (output), where a silent output change belongs, with the
before/after and the dart-parity note.
`benchmark/Gemfile` declared a bare `gem "sasso"`, so the harness always
resolved the PUBLISHED gem and could not time an unreleased change. The README's
table was produced with a path: override in a scratch bundle instead — which
works, but is not reproducible from the repo, and `benchmark/Gemfile.lock` is
gitignored so a reader gets whatever rubygems resolves.

`SASSO_PATH=..` now switches the harness to a path: gem, documented in
benchmark/README.md alongside the reminder to `rake compile` first. The default
stays the published gem: mixing a released number with an unreleased one is how
the two get compared by accident.

Also fixes the changelog's account of the remeasurement: 1.1 ms → 3.2 ms is
corrected UPWARD, not downward (only the multiplier fell, 35× → 12.7×), and it
no longer cites a gitignored lockfile as the evidence.
`FsImporter::new(paths.clone())` cloned the Vec<PathBuf> only so the original
could answer `paths.is_empty()` on the next line. Take the emptiness first and
move the Vec in — one Vec plus one String per load path saved on every compile,
on a warm path the README advertises at ~13 µs for per-request compiling.

Also records what the capture buffer costs: the deferred delivery that keeps
Ruby out of the compile means a compile raising N warnings retains N of them,
where the default stderr path streams them and retains nothing. The core's
five-per-id deprecation cap bounds that in practice, leaving only @warn/@debug
in a loop able to grow it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants