Semantic patches for CSS files that a user owns, powered by a Rust parser (Biome's lossless CSS CST) integrated via NIFs.
This is a codemod tool, not a formatter, minifier or bundler. Every operation returns the user's original file with only the intended bytes changed.
Guarantees
Four properties hold for every function in this module:
- Comments are never lost. Not mostly preserved — never lost. The implementation edits byte ranges rather than reprinting a tree, so text outside an edit cannot change.
- Diffs are minimal.
git diffafter a codemod shows only the lines the codemod meant to change. There is no whole-file reformatting, ever. - Everything is idempotent. Applying an operation twice produces the same
result as applying it once, and the second run reports
changed: false. Igniter installers get re-run; this is not optional. - Input is never destroyed. If a file cannot be understood well enough to
patch safely — the parse does not reproduce it byte for byte, or its braces
are unbalanced — you get
{:error, reason}and the file is untouched.
Shape
Mutating functions return {:ok, %IgniterCss.Outcome{}} or {:error, reason}:
{:ok, %IgniterCss.Outcome{source: "...", changed: true, diagnostics: []}}Query functions return {:ok, value} or {:error, reason}.
Every function takes an optional trailing keyword list, forwarded to
IgniterCss.ParseOpts.
Selector matching
Matching is deliberately strict, because guessing is how a codemod produces a surprising diff:
- only top-level rules are matched —
.binside@media printis not found byset_declaration/5; - selectors are compared on a normalised form (
.a>.bmatches.a > .b), never on raw equality and never on a substring or fuzzy basis; - a selector list is matched as a whole —
.adoes not match.a, .b; - if more than one top-level rule matches, you get an error rather than an arbitrary choice.
Examples
iex> {:ok, out} = IgniterCss.ensure_at_rule("", ~s|@plugin "daisyui";|)
iex> out.source
~s|@plugin "daisyui";\n|
iex> css = ".btn {\n color: red; /* brand */\n}\n"
iex> {:ok, out} = IgniterCss.set_declaration(css, ".btn", "color", "var(--brand)")
iex> out.source
".btn {\n color: var(--brand); /* brand */\n}\n"
iex> css = ".btn {\n color: red;\n}\n"
iex> {:ok, out} = IgniterCss.set_declaration(css, ".btn", "color", "red")
iex> out.changed
falseWhat is not here
minify/2, beautify/2 and merge_stylesheets/2 live in
IgniterCss.Transform. They rewrite the whole file by design, so they are
kept away from the codemods and must not be used to patch a user's stylesheet.
Summary
Functions
Add an @import, building the line for you.
Add vendor-prefixed copies of property next to every occurrence of it in the
file.
Statistics about a stylesheet.
Append caller-provided raw text to the end of a rule body, re-indented to match the surrounding code. A no-op if the text is already in the body.
Insert a top-level at-rule line unless an equivalent one is already present.
Give the top-level at-rule name this block, replacing an existing body or
inserting the whole rule when there is none.
Create selector { } at the end of the file when no top-level rule with that
selector exists. Pass declarations to seed the body.
@keyframes animations, their steps, and the selectors that use them.
Colour-carrying declarations, grouped by the selector they belong to.
Media queries in the file, each with the rules it contains.
Every top-level at-rule named name, as IgniterCss.AtRule structs.
The value of property in the rule matching selector, as written, or nil.
Every declaration in the rule matching selector, as {property, value}
pairs in source order, or nil when the rule does not exist.
Is an at-rule equivalent to line already present at the top level?
Does the rule matching selector set property?
Does a top-level rule with this selector exist?
Every top-level selector, exactly as written.
Remove top-level at-rules of name.
Remove every declaration of property from the rule matching selector,
together with the comments those declarations own.
Remove redundant declarations and rules.
Remove @import rules pointing at url, however they were written
("x.css", 'x.css' and url("x.css") all match).
Remove every top-level rule with this selector, plus the comments it owns.
Replace everything between a rule's braces.
Set a property inside the rule matching selector.
Sort declarations alphabetically within each block, by moving whole lines.
Is this stylesheet understood well enough to patch?
Types
@type opts() :: keyword()
@type result() :: {:ok, IgniterCss.Outcome.t()} | {:error, String.t()}
Functions
Add an @import, building the line for you.
Absolute URLs are wrapped in url(...); relative paths are quoted.
Add vendor-prefixed copies of property next to every occurrence of it in the
file.
Prefixed declarations go immediately before the standard one, which is the ordering browsers expect. Prefixes already present in the same block are skipped, so re-running is a no-op.
iex> {:ok, out} = IgniterCss.add_vendor_prefixes(".a { user-select: none; }", "user-select", ["-webkit-"])
iex> out.source
".a { -webkit-user-select: none; user-select: none; }"
@spec analyze(String.t(), opts()) :: {:ok, IgniterCss.Analysis.t()} | {:error, String.t()}
Statistics about a stylesheet.
iex> {:ok, a} = IgniterCss.analyze(".a { color: red; }")
iex> {a.rules_count, a.declarations_count}
{1, 1}
Append caller-provided raw text to the end of a rule body, re-indented to match the surrounding code. A no-op if the text is already in the body.
Insert a top-level at-rule line unless an equivalent one is already present.
The insertion anchor is the last existing at-rule of the same name; failing
that, the end of the file's at-rule prologue; failing that, the top of the
file but below any header comment. @import, @charset, @use and
@namespace are never placed after a style rule.
Two at-rules of the same name naming the same target count as the same rule,
so @import "tailwindcss"; is not added again to a file that already says
@import "tailwindcss" source(none);.
iex> css = ~s|@import "tailwindcss";\n@source "../js";\n|
iex> {:ok, out} = IgniterCss.ensure_at_rule(css, ~s|@plugin "daisyui";|)
iex> out.source
~s|@import "tailwindcss";\n@source "../js";\n@plugin "daisyui";\n|
@spec ensure_at_rule_block( String.t(), String.t(), String.t() | nil, String.t(), opts() ) :: result()
Give the top-level at-rule name this block, replacing an existing body or
inserting the whole rule when there is none.
declarations is spliced in verbatim, re-indented to the file. matching
narrows to one target the way remove_at_rule/4 does, and is carried into the
prelude.
iex> css = ~s|@import "tailwindcss";\n|
iex> {:ok, out} = IgniterCss.ensure_at_rule_block(css, "theme", nil, "--color-a: red;")
iex> out.source
~s|@import "tailwindcss";\n@theme {\n --color-a: red;\n}\n|
Create selector { } at the end of the file when no top-level rule with that
selector exists. Pass declarations to seed the body.
iex> {:ok, out} = IgniterCss.ensure_rule("", ".hide-scrollbar", "display: none")
iex> out.source
".hide-scrollbar {\n display: none;\n}\n"
@spec extract_animations(String.t(), opts()) :: {:ok, [IgniterCss.Animation.t()]} | {:error, String.t()}
@keyframes animations, their steps, and the selectors that use them.
@spec extract_colors(String.t(), opts()) :: {:ok, [{String.t(), [String.t()]}]} | {:error, String.t()}
Colour-carrying declarations, grouped by the selector they belong to.
iex> IgniterCss.extract_colors(".a { color: #333; margin: 0; }")
{:ok, [{".a", ["color: #333"]}]}
@spec extract_media_queries(String.t(), opts()) :: {:ok, [{String.t(), [{String.t(), [{String.t(), String.t()}]}]}]} | {:error, String.t()}
Media queries in the file, each with the rules it contains.
@spec get_at_rules(String.t(), String.t(), String.t() | nil, opts()) :: {:ok, [IgniterCss.AtRule.t()]} | {:error, String.t()}
Every top-level at-rule named name, as IgniterCss.AtRule structs.
Pass matching to narrow to a single target — the string or url() before
the block. Returns [] when nothing matches; absence is an answer, not an
error.
Unlike has_at_rule?/3, this hands back the at-rule's block, so a caller can
read a decision out of it rather than only confirm the line exists:
iex> css = ~s|@plugin "daisyui" { prefix: "d-"; }|
iex> {:ok, [rule]} = IgniterCss.get_at_rules(css, "plugin", "daisyui")
iex> rule.declarations
[{"prefix", ~s|"d-"|}]The leading @ in name is optional.
@spec get_declaration(String.t(), String.t(), String.t(), opts()) :: {:ok, String.t() | nil} | {:error, String.t()}
The value of property in the rule matching selector, as written, or nil.
iex> IgniterCss.get_declaration(".a { color: red !important; }", ".a", "color")
{:ok, "red !important"}
@spec get_rule_declarations(String.t(), String.t(), opts()) :: {:ok, [{String.t(), String.t()}] | nil} | {:error, String.t()}
Every declaration in the rule matching selector, as {property, value}
pairs in source order, or nil when the rule does not exist.
iex> IgniterCss.get_rule_declarations(".a { color: red; margin: 0; }", ".a")
{:ok, [{"color", "red"}, {"margin", "0"}]}
Is an at-rule equivalent to line already present at the top level?
iex> IgniterCss.has_at_rule?(~s|@plugin "a";\n|, ~s|@plugin "a";|)
{:ok, true}
@spec has_declaration?(String.t(), String.t(), String.t(), opts()) :: {:ok, boolean()} | {:error, String.t()}
Does the rule matching selector set property?
Does a top-level rule with this selector exist?
iex> IgniterCss.has_rule?(".a > .b { color: red; }", ".a>.b")
{:ok, true}
Every top-level selector, exactly as written.
Remove top-level at-rules of name.
matching filters by target (or, failing that, by the whole prelude); nil
removes every at-rule with that name. Comments the removed rule owns go with
it — see IgniterCss.Codemods for the ownership rules.
Remove every declaration of property from the rule matching selector,
together with the comments those declarations own.
Removing from a rule that does not exist is a no-op, not an error.
Remove redundant declarations and rules.
Only removals that cannot change rendering are made: a declaration goes only when a later one in the same block sets the same property and is at least as important, and a rule goes only when a later top-level rule has the same selector and a byte-identical body.
Options
:declarations— defaults totrue:rules— defaults totrue
Remove @import rules pointing at url, however they were written
("x.css", 'x.css' and url("x.css") all match).
Remove every top-level rule with this selector, plus the comments it owns.
Replace everything between a rule's braces.
Errors when the selector matches no top-level rule, or more than one.
Set a property inside the rule matching selector.
If the property is already there, only its value bytes are replaced — an
inline comment on that line and any !important you did not ask to change
both survive. If it is not, a new declaration is appended in the file's own
indentation and newline style.
Options
:important—trueadds!important,falseremoves it,nil(the default) leaves whatever is there.:create_rule— whentrue, a missing rule is created instead of being an error. Defaults tofalse.
Examples
iex> css = ".btn { color: red !important; }"
iex> {:ok, out} = IgniterCss.set_declaration(css, ".btn", "color", "blue")
iex> out.source
".btn { color: blue !important; }"
iex> {:ok, out} = IgniterCss.set_declaration("", ".x", "display", "none", create_rule: true)
iex> out.source
".x {\n display: none;\n}\n"
Sort declarations alphabetically within each block, by moving whole lines.
Comments move with the declaration they belong to. A block that cannot be
rearranged safely — declarations sharing a line, a nested rule, a section
header between declarations — is left alone and reported in
outcome.diagnostics.
Note this is a semantic change when a block mixes shorthand and longhand
(margin before margin-left behaves differently from the reverse).
@spec validate(String.t(), opts()) :: {:ok, IgniterCss.Validation.t()} | {:error, IgniterCss.Validation.t()}
Is this stylesheet understood well enough to patch?
Returns {:ok, %IgniterCss.Validation{}} when it is and
{:error, %IgniterCss.Validation{}} when it is not, so the details are
available either way.