# `IgniterCss`
[🔗](https://github.com/ash-project/igniter_css/blob/v1.1.2/lib/igniter_css.ex#L5)

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.

# `opts`

```elixir
@type opts() :: keyword()
```

# `result`

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

# `add_import`

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

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

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

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

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

```elixir
@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_at_rule_declarations`

```elixir
@spec ensure_at_rule_declarations(
  String.t(),
  String.t(),
  String.t() | nil,
  String.t(),
  opts()
) :: result()
```

Set each of `declarations` inside the top-level at-rule `name`, and leave
everything else in its block as it is.

`ensure_at_rule_block/5` gives the block exactly the body you pass, which is
right when the block is yours. When it is shared — a project's `@theme` holds
the tokens a library installs *and* the project's own — use this instead: a
property the block already has keeps its place and its comments and only its
value is rewritten, and only when it differs token for token; a property it
does not have is appended in the block's own indentation; every other
declaration, nested rule and comment stays byte for byte. Running it again
changes nothing.

`declarations` is parsed, not scanned — a `;` inside a string or `url()` ends
nothing — and must be declarations only. A property given twice is set once,
at its last value. When the at-rule is absent the whole rule is inserted, with
`declarations` spliced in verbatim, as `ensure_at_rule_block/5` does.
`matching` narrows to one target the way `remove_at_rule/4` does; `""` is the
block written with nothing after its name, `@theme` and not `@theme inline`.
`remove_at_rule_declarations/5` takes them out again.

    iex> css = ~s|@theme {\n  --font-display: "Satoshi";\n  --color-a: red;\n}\n|
    iex> {:ok, out} = IgniterCss.ensure_at_rule_declarations(css, "theme", nil, "--color-a: blue; --color-b: green;")
    iex> out.source
    ~s|@theme {\n  --font-display: "Satoshi";\n  --color-a: blue;\n  --color-b: green;\n}\n|

# `ensure_rule`

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

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

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

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

```elixir
@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 the way `remove_at_rule/4` does: to a target — the
string or `url()` before the block, quoted or not — or, for an at-rule without
one, to its whole prelude, `""` being nothing after the name. 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`

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

```elixir
@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?`

```elixir
@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?`

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

Does the rule matching `selector` set `property`?

# `has_rule?`

```elixir
@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_at_rules`

```elixir
@spec list_at_rules(String.t(), opts()) ::
  {:ok, [IgniterCss.AtRule.t()]} | {:error, String.t()}
```

Every at-rule in the file, as `IgniterCss.AtRule` structs in source order,
wherever it is: nested in `@media`, `@layer`, a rule or another at-rule's
block, and where the parser could not place one, such as an `@import` inside
`@font-face` or a keyframe. Biome keeps that one as bogus tokens, but a reader
with a looser grammar, Tailwind for one, still acts on it, so it is here too,
running from its `@` to the `;` or the block that ends it.

An `@` inside a string, a comment or a `url()` starts nothing. The name keeps
any escape as written (`@\69mport` is named `\69mport`).

For a caller that must know everything a stylesheet asks for, such as one
that keeps a build tool from reading files; the codemods stay top-level.

    iex> css = ~s|@media print {\n  @import "a.css";\n}\n@font-face { @plugin "p"; }\n|
    iex> {:ok, rules} = IgniterCss.list_at_rules(css)
    iex> Enum.map(rules, &{&1.name, &1.target})
    [{"media", nil}, {"import", "a.css"}, {"font-face", nil}, {"plugin", "p"}]

# `list_selectors`

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

Every top-level selector, exactly as written.

# `remove_at_rule`

```elixir
@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, so `""`
is an at-rule with nothing after its name: `@theme`, not `@theme inline`);
`nil` removes every at-rule with that name. Comments the removed rule owns go
with it — see `IgniterCss.Codemods` for the ownership rules.

# `remove_at_rule_declarations`

```elixir
@spec remove_at_rule_declarations(
  String.t(),
  String.t(),
  String.t() | nil,
  String.t(),
  opts()
) :: result()
```

Remove each of `declarations` from the top-level at-rule `name`, and leave
everything else in its block as it is — the inverse of
`ensure_at_rule_declarations/5`.

A declaration goes only while it is still the one given: the same property,
the same value token for token and the same `!important`. One whose value has
been changed since is someone's edit, and stays. A removed declaration takes
the comments it owns — see `IgniterCss.Codemods` for the ownership rules — and
a block left with nothing in it takes its at-rule with it.

`declarations` is parsed the way `ensure_at_rule_declarations/5` parses it.
`matching` narrows to one target the way `remove_at_rule/4` does. An at-rule
that is not there, or has no block, has nothing to remove.

    iex> css = ~s|@theme {\n  --font-display: "Satoshi";\n  --color-a: blue;\n}\n|
    iex> {:ok, out} = IgniterCss.remove_at_rule_declarations(css, "theme", "", "--color-a: blue;")
    iex> out.source
    ~s|@theme {\n  --font-display: "Satoshi";\n}\n|

# `remove_at_rules`

```elixir
@spec remove_at_rules(String.t(), [IgniterCss.AtRule.t()], opts()) :: result()
```

Remove, wherever it is, every at-rule written exactly as one of `at_rules`:
at-rules `list_at_rules/2` returned for this `source`, read with the same
`opts`. One inside another being removed goes with it, and the comments a
removed at-rule owns go too, as with `remove_at_rule/4`.

Matched on the at-rule's text, so a second run on the output finds nothing
and reports `changed: false`.

    iex> css = ~s|@media print {\n  @import "a.css";\n  .a { color: red; }\n}\n|
    iex> {:ok, rules} = IgniterCss.list_at_rules(css)
    iex> {:ok, out} = IgniterCss.remove_at_rules(css, Enum.filter(rules, &(&1.name == "import")))
    iex> out.source
    ~s|@media print {\n  .a { color: red; }\n}\n|

# `remove_declaration`

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

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

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

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

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

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

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

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

# `validate_block`

```elixir
@spec validate_block(String.t(), opts()) ::
  {:ok, IgniterCss.Validation.t()} | {:error, IgniterCss.Validation.t()}
```

Is `body` valid as the contents of one rule's block?

What an editor that styles one element takes: declarations, nested rules
such as `&:hover { … }`, and at-rules such as `@media (…) { … }` or Tailwind's
`@apply`. It is read as the parser reads a block, by putting it inside one,
and every error is placed by line and column in `body` itself.

On top of what `validate/2` checks, a `}` that closes the block the body is
written in is an error, though what follows it may parse: in a stylesheet,
everything after it would be outside the block.

No error points past the body or names the block it is put in. A body that
ends in the middle of a statement has one error on that statement, and one
that leaves a block open has one where its `}` is missing. A declaration
with no `:`, which the parser reads as a nested rule, is named as one. A
string with no closing quote keeps the parser's own error, whatever it
misreads after it. A message quotes the author's text on one line, a line
break in it written `\n`.

Returns `{:ok, %IgniterCss.Validation{}}` or `{:error, %IgniterCss.Validation{}}`.

    iex> {:ok, v} = IgniterCss.validate_block("padding: 4px;\n&:hover { color: red; }")
    iex> v.errors
    []

    iex> {:error, v} = IgniterCss.validate_block("gap: 1px;\ncolor: red background: blue;")
    iex> [%IgniterCss.Diagnostic{line: 2, column: 22, text: ": blue"}] = v.errors
    iex> v.valid
    false

    iex> {:error, v} = IgniterCss.validate_block("gap: 1px;\ncolor crimson")
    iex> [%IgniterCss.Diagnostic{line: 2, column: 1, text: "color crimson", message: message}] = v.errors
    iex> message
    "Expected a `:` after the property name `color`"

    iex> {:error, v} = IgniterCss.validate_block("gap: 1px; } body { display: none; ")
    iex> [%IgniterCss.Diagnostic{line: 1, column: 11, text: "}"}] = v.errors
    iex> v.valid
    false

---

*Consult [api-reference.md](api-reference.md) for complete listing*
