feat: ensure_at_rule_block/5 and AtRule.body, and fix three transform defects - #41
Merged
Merged
Conversation
`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>
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
A block at-rule —
@theme { … },@plugin "x" { … }— had no codemod.ensure_at_ruleinserts a statement line andreplace_rule_bodyworks 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/5replaces an existing body or inserts the rule whole, so it is idempotent:AtRule.bodyhands back the block's bytes verbatim, so a block can move between files without losing the comments and grouping its author put there:The body is spliced through
reindentrather than rebuilt from parsed declarations — rebuilding drops comments, which is exactly what a caller moving a block would not expect.What this fixes
corpus_invariantsruns five invariants over every mutating op, butbeautifyandminifyreprint the whole file and were never in it. Addingtests/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:beautifyemitted": "for everyCOLON.a:not([x]).a: not([x])beautifyonly kept a space after a word charurl(x) no-repeaturl(x)no-repeatminifydropped the;before every}@apply x;@apply x}¹ the enclosing rule was no longer a qualified rule, so
.typographyvanished fromlist_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_SELECTORand its function variants versusCSS_GENERIC_PROPERTY/CSS_QUERY_FEATURE_PLAIN), the semicolon byCSS_DECLARATION_WITH_SEMICOLON.Separately,
detect_indentreturned 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.cssjoins 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.cargo fmt --checkandclippy -D warningscleanmix format --check-formattedandcredo --strictcleanComparison in the invariants is by meaning, not bytes: reprinting may write
.a, .bwhere the source said.a,.b, and minifying may drop a comment — neither may change which elements match.