IgniterCss (igniter_css v1.0.0)

Copy Markdown View Source

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:

  1. 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.
  2. Diffs are minimal. git diff after a codemod shows only the lines the codemod meant to change. There is no whole-file reformatting, ever.
  3. 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.
  4. 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 — .b inside @media print is not found by set_declaration/5;
  • selectors are compared on a normalised form (.a>.b matches .a > .b), never on raw equality and never on a substring or fuzzy basis;
  • a selector list is matched as a whole — .a does 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
false

What 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 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

opts()

@type opts() :: keyword()

result()

@type result() :: {:ok, IgniterCss.Outcome.t()} | {:error, String.t()}

Functions

add_import(source, url, media \\ nil, opts \\ [])

@spec add_import(String.t(), String.t(), String.t() | nil, opts()) :: result()

Add an @import, building the line for you.

Absolute URLs are wrapped in url(...); relative paths are quoted.

add_vendor_prefixes(source, property, prefixes, opts \\ [])

@spec add_vendor_prefixes(String.t(), String.t(), [String.t()], opts()) :: result()

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; }"

analyze(source, opts \\ [])

@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_raw_to_rule(source, selector, raw, opts \\ [])

@spec append_raw_to_rule(String.t(), String.t(), String.t(), opts()) :: result()

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.

ensure_at_rule(source, line, opts \\ [])

@spec ensure_at_rule(String.t(), String.t(), opts()) :: result()

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|

ensure_at_rule_block(source, name, matching \\ nil, declarations, opts \\ [])

@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|

ensure_rule(source, selector, declarations \\ "", opts \\ [])

@spec ensure_rule(String.t(), String.t(), String.t(), opts()) :: result()

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"

extract_animations(source, opts \\ [])

@spec extract_animations(String.t(), opts()) ::
  {:ok, [IgniterCss.Animation.t()]} | {:error, String.t()}

@keyframes animations, their steps, and the selectors that use them.

extract_colors(source, opts \\ [])

@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"]}]}

extract_media_queries(source, opts \\ [])

@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.

get_at_rules(source, name, matching \\ nil, opts \\ [])

@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.

get_declaration(source, selector, property, opts \\ [])

@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"}

get_rule_declarations(source, selector, opts \\ [])

@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"}]}

has_at_rule?(source, line, opts \\ [])

@spec has_at_rule?(String.t(), String.t(), opts()) ::
  {:ok, boolean()} | {:error, String.t()}

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}

has_declaration?(source, selector, property, opts \\ [])

@spec has_declaration?(String.t(), String.t(), String.t(), opts()) ::
  {:ok, boolean()} | {:error, String.t()}

Does the rule matching selector set property?

has_rule?(source, selector, opts \\ [])

@spec has_rule?(String.t(), String.t(), opts()) ::
  {:ok, boolean()} | {:error, String.t()}

Does a top-level rule with this selector exist?

iex> IgniterCss.has_rule?(".a  >  .b { color: red; }", ".a>.b")
{:ok, true}

list_selectors(source, opts \\ [])

@spec list_selectors(String.t(), opts()) :: {:ok, [String.t()]} | {:error, String.t()}

Every top-level selector, exactly as written.

remove_at_rule(source, name, matching \\ nil, opts \\ [])

@spec remove_at_rule(String.t(), String.t(), String.t() | nil, opts()) :: result()

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_declaration(source, selector, property, opts \\ [])

@spec remove_declaration(String.t(), String.t(), String.t(), opts()) :: result()

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_duplicates(source, opts \\ [])

@spec remove_duplicates(String.t(), opts()) :: result()

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 to true
  • :rules — defaults to true

remove_import(source, url, opts \\ [])

@spec remove_import(String.t(), String.t(), opts()) :: result()

Remove @import rules pointing at url, however they were written ("x.css", 'x.css' and url("x.css") all match).

remove_rule(source, selector, opts \\ [])

@spec remove_rule(String.t(), String.t(), opts()) :: result()

Remove every top-level rule with this selector, plus the comments it owns.

replace_rule_body(source, selector, declarations, opts \\ [])

@spec replace_rule_body(String.t(), String.t(), String.t(), opts()) :: result()

Replace everything between a rule's braces.

Errors when the selector matches no top-level rule, or more than one.

set_declaration(source, selector, property, value, opts \\ [])

@spec set_declaration(String.t(), String.t(), String.t(), String.t(), opts()) ::
  result()

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 — true adds !important, false removes it, nil (the default) leaves whatever is there.
  • :create_rule — when true, a missing rule is created instead of being an error. Defaults to false.

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_properties(source, opts \\ [])

@spec sort_properties(String.t(), opts()) :: result()

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).

validate(source, opts \\ [])

@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.