Repository navigation
Adopt core sasso 0.14.0, align the gem version with it, drop Ruby 3.1 - #3
Conversation
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.
There was a problem hiding this comment.
🔵 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 eithercompilecall has resolved any@use/@import, so this passes an empty snapshot towith_quiet_deps. As a result,quiet_deps: truewill 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 anRc<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 unlesson_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, soon_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:
CompileResultis aStruct.new(:css, :source_map)and is instantiated with two arguments inlib/sasso.rb:91, but an RBS class without aninitializesignature exposes the inherited zero-argument constructor. Type-checkers will reject the implementation (and users cannot type-check constructing the documented result). Add the two-argumentinitializesignature, 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`.
|
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. AdoptedWarnings lost on the error path ( Rather than document it, fixed it — dart-sass hands warnings to its logger and then throws, so delivery-before-raise is the parity behaviour. Unconditional sink allocation (
Not adopted
"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: Suite is 30 runs / 80 assertions green, |
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.
Brings the gem up to the current core compiler (
sasso0.6.3 → 0.14.0, sevenreleases 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.rsneeded no change to compile against 0.14.0. The onebreaking marker in the range —
WarnEventbecoming#[non_exhaustive]— touchesnothing the binding constructs.
One test expectation moved.
test_compressed_color_shortest_formnow expectsa{x:rgb(15%,30%,45%)}where it expecteda{x:hsl(210,50%,30%)}: core 0.9.0routes 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 emitsa{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_VERSIONreads 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, soadopting a compiler option costs a key rather than an arity change on both sides.
Then:
charset:—falseomits the@charset "UTF-8";prefix (a U+FEFF BOM whencompressed) that non-ASCII output carries. Verified byte-for-byte against
dart-sass 1.104.1, including that
--no-charsetsuppresses 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 toturn 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 duringthe compile, so it records events Rust-side and hands them to Ruby after
compilereturns — Ruby is never re-entered mid-compile.
Deliberately deferred: custom functions (
Options::with_function). They needRuby callbacks during compilation — the same GC-pinning / GVL re-entrancy hazard
for which
lib.rsalready defers a Ruby-callback importer. Worth doing on its own,not smuggled into a version bump.
Removals
whiteness()/blackness()now error (core 0.9.0, matchingdart-sass — they are
sass:color-only). The one change here that can stop anexisting stylesheet compiling.
>= 3.2.0in thegemspec, off the CI matrix and off the precompiled-gem matrix.
Performance, remeasured
benchmark/Gemfile.lockhad been pinning gem 0.1.1 andsass-embedded1.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
x86_64 (Arch, Ruby 3.4).
cargo clippyclean;cargo fmt --all -- --checkclean.gem buildproducessasso-0.14.0.gem.Notes for review
style(ext): apply rustfmtis formatting only — no behaviour in it.sig/sasso.rbsgains the new options andsource_map:/CompileResult,which shipped in gem 0.2.0 and never reached the signatures or the README.
>= 0.14.0insasso-rails,bridgetown-sassoandhanami-sasso. Taggingv0.14.0is whattriggers the release workflow.
🤖 Generated with Claude Code