Skip to content

feat: ensure_at_rule_block/5 and AtRule.body, and fix three transform defects - #41

Merged
shahryarjb merged 8 commits into
ash-project:mainfrom
shahryarjb:main
Sep 6, 2026
Merged

shahryarjb merged 8 commits into
ash-project:mainfrom
shahryarjb:main

Conversation

@shahryarjb

@shahryarjb shahryarjb commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

What this adds

A block at-rule — @theme { … }, @plugin "x" { … } — had no codemod. ensure_at_rule inserts a statement line and replace_rule_body works on style rules, so giving a block a body meant reaching for a regex, and re-running an installer appended a second copy.

ensure_at_rule_block/5 replaces an existing body or inserts the rule whole, so it is idempotent:

{:ok, out} = IgniterCss.ensure_at_rule_block(~s|@import "tailwindcss";\n|, "theme", nil, "--color-a: red;")

out.source
#=> ~s|@import "tailwindcss";\n@theme {\n  --color-a: red;\n}\n|

AtRule.body hands back the block's bytes verbatim, so a block can move between files without losing the comments and grouping its author put there:

{:ok, [theme]} = IgniterCss.get_at_rules(File.read!("theme.css"), "theme")
IgniterCss.Codemods.ensure_at_rule_block(igniter, "assets/css/app.css", "theme", nil, theme.body)

The body is spliced through reindent rather than rebuilt from parsed declarations — rebuilding drops comments, which is exactly what a caller moving a block would not expect.

What this fixes

corpus_invariants runs five invariants over every mutating op, but beautify and minify reprint the whole file and were never in it. Adding tests/transform_invariants.rs — output parses, is idempotent, and keeps every selector, declaration, at-rule and comment — turned up three defects that had been shipping silently:

before after
beautify emitted ": " for every COLON .a:not([x]) .a: not([x]) invalid selector
beautify only kept a space after a word char url(x) no-repeat url(x)no-repeat valid, unreadable
minify dropped the ; before every } @apply x; @apply x} rule stops parsing¹

¹ the enclosing rule was no longer a qualified rule, so .typography vanished from list_selectors — a later codemod would simply not find it.

Each is decided from the CST rather than the text: the colon by its parent node kind (CSS_PSEUDO_CLASS_SELECTOR and its function variants versus CSS_GENERIC_PROPERTY / CSS_QUERY_FEATURE_PLAIN), the semicolon by CSS_DECLARATION_WITH_SEMICOLON.

Separately, detect_indent returned the smallest indent width present while its own doc said "the most common". One stray shallow line then decided the file's indent for every op that re-indents: a four-space stylesheet carrying a couple of two-space lines reported two, and inserted blocks came out half-indented.

Testing

pathological_selectors.css joins the corpus — :not() with a selector list, :nth-child(2n + 1), :has/:is/:where, attribute values containing braces and colons, escaped selectors, data-URIs, nested at-rules and @layer.

  • 402 cargo tests, cargo fmt --check and clippy -D warnings clean
  • 42 doctests + 300 Elixir tests, mix format --check-formatted and credo --strict clean

Comparison in the invariants is by meaning, not bytes: reprinting may write .a, .b where the source said .a,.b, and minifying may drop a comment — neither may change which elements match.

shahryarjb and others added 7 commits August 9, 2026 23:04
`analyze/2` reports that a `@plugin` exists; nothing reported *which* plugin or
how it was configured. That is the difference between an installer knowing
daisyUI is present and knowing it was loaded as
`@plugin "daisyui" { prefix: "d-" }` — and an installer that guesses wrong
generates `@apply btn` into a project where the class is `d-btn`, which simply
fails to build.

`get_at_rules(source, name, matching \\ nil, opts \\ [])` returns
`IgniterCss.AtRule` structs carrying the name, prelude, target, whether there is
a block, the block's declarations in source order, and the at-rule's own bytes
verbatim. Top-level only, for the same reason the codemods are: a `@plugin`
nested in a `@layer` is a different thing, and guessing between them is how a
caller gets a surprising answer. Absence is an empty list, not an error.

Also adds the two Igniter wrappers the pure module already had but `Codemods`
did not — `add_import/5` and `remove_import/4`. Without them an installer
wanting an import had to reach past `Codemods` and lose the diff preview.

And `force_build:` now falls through to `config :rustler_precompiled,
:force_build` instead of only reading `IGNITERCSS_BUILD`. A consuming project
has no reason to know this library's private variable name, so hardcoding it
made rustler_precompiled's own documented switch a no-op — which matters for
anyone depending on this from git before a release carries NIF artifacts.

`@plugin` is not standard CSS, so Biome parses its body as
`CSS_DECLARATION_OR_RULE_BLOCK`; `locate::at_rule_body/1` hands
`declarations_in_block` the block's owner, which is the shape it expects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two assertions in the new at-rule tests were written past the width rustfmt
wraps at, so `cargo fmt --check` failed in CI. Formatting only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ensure_at_rule` inserts a statement line and `replace_rule_body` works on style
rules, so nothing owned a block at-rule: giving `@theme { ... }` a body meant
regex, and re-running an installer appended a second one.

`ensure_at_rule_block/5` replaces an existing body or inserts the whole rule when
there is none, so it is idempotent. `matching` narrows to one target the way
`remove_at_rule/4` does and is carried into the inserted prelude.

The body is spliced verbatim through `reindent` rather than rebuilt from parsed
declarations. Rebuilding drops comments and blank-line grouping, which a caller
moving a block between files would not expect to lose. `reindent` needs the
block's own common base to strip, so the first line keeps its indentation.

`IgniterCss.AtRule` gains `body`: the bytes between the braces, `None` for an
at-rule without a block. Reading a block out of one file and writing it into
another is what makes the pair useful together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`detect_indent` documented "the most common non-empty prefix" but returned the
smallest width present. One stray shallow line then decided the file's indent
for every codemod that re-indents: a stylesheet indented with four spaces
throughout, carrying a couple of two-space lines, reported two, and inserted
blocks came out half-indented.

Now it counts widths and takes the most frequent, preferring the shallower on a
tie. Tabs still win outright, and a file with nothing indented still falls back
to two spaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`beautify/2` emitted `": "` for every COLON token, so `.a:not([data-x])` came
back as `.a: not([data-x])` — invalid CSS, from a function whose whole job is to
reprint a stylesheet unchanged but tidier. Any caller formatting a file with a
pseudo-class was corrupting it.

The distinction is grammatical, not textual, so it is read off the node the
token hangs from: `CSS_PSEUDO_CLASS_SELECTOR` and its function and
pseudo-element variants take a bare colon, while `CSS_GENERIC_PROPERTY` and
`CSS_QUERY_FEATURE_PLAIN` — declarations and media features — keep the space.

Covered for `:not`, `::before`, a nested `:not(:hover)`, and both colons that
must keep their space.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… invariants

`corpus_invariants` covers the mutating ops, but `beautify` and `minify` reprint
the whole file and were never in it. That is how a colon bug reached a released
formatter, and running the same style of invariants over the transforms found
three more, all silent:

  * `needs_space` only fired after a word character, so anything following `)`,
    `"` or `*/` lost its separator: `url(x) no-repeat` became `url(x)no-repeat`,
    `minmax(12rem, 1fr) 3fr` became `minmax(12rem, 1fr)3fr`, and the two strings
    of a `grid-template-areas` ran together. It now keeps a space wherever the
    source had whitespace, which is both simpler and faithful.
  * `minify` dropped the semicolon before every `}`, including an at-rule's.
    `@apply text-2xl;` became `@apply text-2xl}` and the enclosing rule stopped
    parsing as a rule — `.typography` was no longer findable by
    `list_selectors`. Only a `CSS_DECLARATION_WITH_SEMICOLON` terminator is
    dropped now.
  * a comment inside a selector list lost the space after it.

`tests/transform_invariants.rs` runs both transforms over every fixture and
asserts the output parses, is idempotent, and keeps every selector,
declaration, at-rule and comment — plus that no pseudo colon is ever split.
Comparison is by meaning, so reprinting may write `.a, .b` for `.a,.b` and
minifying may drop a comment, but neither may change what matches.

`pathological_selectors.css` adds the corpus's adversarial case: `:not()` with a
selector list, `:nth-child(2n + 1)`, `:has`/`:is`/`:where`, attribute selectors
whose values contain braces and colons, escaped selectors, a data-URI, nested
at-rules and `@layer`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shahryarjb shahryarjb self-assigned this Sep 6, 2026
@shahryarjb shahryarjb added bug Something isn't working enhancement New feature or request dependencies Pull requests that update a dependency file labels Sep 6, 2026
`mint` carried two advisories, one HIGH: unbounded HTTP/1 status-line and
chunk-extension buffering (CVE-2026-82728) and quadratic chunk-size parsing
(CVE-2026-82729), both memory or CPU exhaustion. 1.10.0 clears them, and
`mix deps.audit` and `mix hex.audit` are both clean again.

Elixir: mint 1.9.3 -> 1.10.0, req 0.7.2 -> 0.7.4, spitfire 0.3.13 -> 0.4.1,
dialyxir 1.4.7 -> 1.4.8, ex_doc 0.40.3 -> 0.40.4.

Rust: eight transitive crates moved within semver. The four direct ones are
already at the newest published version — biome_css_parser, biome_css_syntax and
biome_rowan at 0.5.8, rustler at 0.38.0 — so the `=` pins stand as they are, for
the reason the manifest gives.

402 cargo tests, 42 doctests and 300 Elixir tests still pass, with fmt, clippy
and credo clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shahryarjb
shahryarjb merged commit 23827ee into ash-project:main Sep 6, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working dependencies Pull requests that update a dependency file enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant