Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .claude/skills/css-codemod/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
name: css-codemod
description: Add or change a CSS codemod in igniter_css (native/igniter_css). Use when editing Rust in this repo, adding an operation, touching selector or at-rule matching, or debugging a codemod that reformats too much. Covers the byte-range architecture, the Biome CST, and the required tests.
---

# CSS codemods in igniter_css

## The one rule

**Never reprint the tree.** Parse losslessly → locate byte ranges → splice text
into the original source. Text outside an edit cannot change, which is why
comments survive by construction. Any change that produces output by printing a
node is wrong.

```
parse_css() → locate nodes → node.text_trimmed_range() → Vec<Edit> → apply_edits(original)
```

`text_range()` includes leading trivia (the comment above the node).
`text_trimmed_range()` is the node's own bytes. Almost always you want trimmed.

## Decide from the CST, never by scanning text

Biome already models what you are about to hand-parse:

| need | node/token |
|---|---|
| combinator | `CSS_COMPLEX_SELECTOR` + token; descendant is `CSS_SPACE_LITERAL` |
| nested rule | `CSS_NESTED_QUALIFIED_RULE` + `CSS_RELATIVE_SELECTOR_LIST` (**not** `CSS_QUALIFIED_RULE`) |
| hex colour | `CSS_COLOR` / `CSS_COLOR_LITERAL` (validate digits: `#notahex` parses as a colour) |
| colour fn | `CSS_FUNCTION` + identifier name |
| url payload | `CSS_URL_FUNCTION` / `CSS_URL_VALUE_RAW` — structurally not an identifier |
| at-rule target | first `CSS_STRING_LITERAL` / `CSS_URL_VALUE_RAW_LITERAL` before the block |
| comments | token leading/trailing trivia |

Text scanning is acceptable in exactly two places: caller-supplied strings that
are not CSS, and the pre-parse nesting guard (which must not recurse, because
recursing is what it prevents).

## Safety

- No `unwrap`/`expect`/indexing reachable from a NIF. Rustler catches panics, but
a **stack overflow aborts** and takes the VM down.
- Every parse goes through `ParseCtx::try_new` — Biome 0.5.8 panics on some
input ("parser is no longer progressing").
- `check_nesting` before any parse. Limit 256; real CSS is depth ~7.
- Caller text spliced in must pass the same nesting limit, or you write a file
you would refuse to read.

## Matching

Top-level only unless the caller opts in. Normalised comparison, never substring
or fuzzy. A selector list matches whole. **More than one match is an error** —
never pick one.

## Required per codemod

Same commit, no exceptions:

1. golden test (input + op → expected output, exact string)
2. idempotency test (twice == once, second reports `changed: false`)
3. comment-placement test (trailing, adjacent-above, blank-line-separated, section header)

Then add the op to the sweeps in `tests/corpus_invariants.rs` and
`test/corpus_invariants_test.exs`, which assert those properties for every op
against every fixture.

## Verify

```bash
cd native/igniter_css
cargo test && cargo fmt --check && cargo clippy --all-targets -- -D warnings
cd - && IGNITERCSS_BUILD=1 mix test && mix credo --strict
```

`tests/roundtrip.rs` is the gate: `parse.syntax().to_string() == source` across
the whole corpus. If it fails, byte-range editing is unsafe — stop.

## Gotchas

- BOM: stripped in `ParseCtx`, restored on output. Biome lexes U+FEFF into the
first identifier and silently breaks selector matching otherwise.
- Unbalanced braces: refuse. "Top level" is meaningless in such a file.
- Never call `find_all_*` inside a per-node loop — that is quadratic. Build the
ref from the node you already hold (`locate::at_rule_ref`).
3 changes: 3 additions & 0 deletions .claude/skills/css-codemod/SKILL.md.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2025 igniter_css contributors <https://github.com/ash-project/igniter_css/graphs/contributors>

SPDX-License-Identifier: MIT
54 changes: 54 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
name: release
description: Cut an igniter_css release. Use when asked to release, tag, publish to Hex, bump the version, or when a release failed. Covers the tag-triggered CI flow, the precompiled NIF matrix, and the checksum file.
---

# Release igniter_css

A tag is the only trigger. Everything after it is automatic — do not run the
checksum or publish steps by hand unless CI is broken.

## Steps

1. Bump `@version` in `mix.exs`.
2. Add a `# Changelog for IgniterCss X.Y.Z` section to `CHANGELOG.md` — the
GitHub release notes are extracted from it by heading match.
3. Commit and push to `main`.
4. `git tag vX.Y.Z && git push origin vX.Y.Z`

## What CI then does

| job | result |
|---|---|
| checks | credo, dialyzer, test, format, sobelow, reuse, cargo test/fmt/clippy |
| `build-release` | 10 targets → `libigniter_css-vX.Y.Z-nif-2.15-<target>.so.tar.gz` attached to the GitHub release |
| `hex_publish` | `mix rustler_precompiled.download IgniterCss.Native --only-local --all --print` → `checksum-Elixir.IgniterCss.Native.exs`, then `mix hex.publish` |
| `github_release` | release notes from CHANGELOG |

## Invariants

- `checksum-Elixir.IgniterCss.Native.exs` is **never committed**. It hashes
artifacts that do not exist until the tag builds. It is listed in `files:` in
`mix.exs` so it ships in the Hex package.
- `targets:` in `lib/igniter_css/native.ex` must match the CI matrix exactly. A
target built but not listed is never downloaded; one listed but not built is a
hard failure for those users.
- `release:` is not passed in `.github/workflows/elixir.yml`, so it defaults to
`true`. That is what enables `hex_publish`.

## Failure modes

| symptom | cause |
|---|---|
| `startup_failure`, "workflow file issue" | caller lacks `permissions:` the callee needs. `elixir.yml` must grant `contents/pages/id-token/security-events: write` |
| `Could not mix rebar from any hex.pm mirror` | OTP too old for hex.pm's cert chain. Needs OTP 28+ |
| `Hex.State ... does not exist` | poisoned `mix-home`/`hex-home` Actions cache from a failed run on a different OTP. Delete the caches via `gh api -X DELETE repos/OWNER/REPO/actions/caches/ID` and re-run |
| 404 downloading the NIF locally | no release exists yet. Use `IGNITERCSS_BUILD=1 mix compile` |

## Manual fallback

```bash
mix rustler_precompiled.download IgniterCss.Native --all --print
mix hex.build --unpack
mix hex.publish
```
3 changes: 3 additions & 0 deletions .claude/skills/release/SKILL.md.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2025 igniter_css contributors <https://github.com/ash-project/igniter_css/graphs/contributors>

SPDX-License-Identifier: MIT
4 changes: 2 additions & 2 deletions lib/igniter_css/native.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

defmodule IgniterCss.Native do
@moduledoc false
# Precompiled NIFs, so end users never need a Rust toolchain
# (hard constraint #5). Set IGNITERCSS_BUILD=1 to force a local build.
# Precompiled NIFs, so end users never need a Rust toolchain.
# Set IGNITERCSS_BUILD=1 to force a local build.

mix_config = Mix.Project.config()
version = mix_config[:version]
Expand Down
16 changes: 6 additions & 10 deletions lib/igniter_css/parsers/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,14 @@ defmodule IgniterCss.Parsers.Parser do
Every function accepts either CSS content or a file path, selected by the
trailing `type` argument (`:content`, the default, or `:path`).

This module covers the same ground the previous Python/tinycss2 implementation
did, reimplemented on the Rust parser. Two differences are worth knowing:

* The mutating functions here are now **diff-minimal** — they patch byte
ranges instead of reprinting the stylesheet, so comments and formatting
outside the edit are preserved exactly.
* `minify/2` and `beautify/2` still rewrite the whole file, because that is
what they are for. Do not use them to patch a file a user maintains.
The mutating functions are **diff-minimal**: they patch byte ranges rather
than reprinting the stylesheet, so comments and formatting outside the edit
are preserved exactly. `minify/2` and `beautify/2` are the exception — they
rewrite the whole file, because that is what they are for. Do not point them
at a file a user maintains.

For new code prefer `IgniterCss`, which has a plainer `{:ok, result}` shape
and clearer option handling. This module exists so existing call sites keep
working.
and clearer option handling.
"""

import IgniterCss.Helpers, only: [call_nif_fn: 4]
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,13 @@ defmodule IgniterCss.MixProject do
[
{:rustler, "~> 0.38.0", optional: true},
{:rustler_precompiled, "~> 0.9"},
{:igniter, "~> 0.5", optional: true},
{:igniter, "~> 0.8.3", optional: true},
{:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false},
{:sobelow, ">= 0.0.0", only: [:dev, :test], runtime: false},
{:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false},
{:ex_check, "~> 0.16", only: [:dev, :test]},
{:credo, ">= 0.0.0", only: [:dev, :test], runtime: false},
{:ex_doc, "~> 0.38", only: [:dev, :test], runtime: false}
{:ex_doc, "~> 0.40.3", only: [:dev, :test], runtime: false}
]
end

Expand Down
15 changes: 12 additions & 3 deletions native/igniter_css/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,18 @@ path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]

[dependencies]
# Biome CSS crates are Biome-internal and published at 0.5.x with NO API
# stability guarantee. They are pinned with `=` deliberately. Upgrading is a
# tested activity, never a `cargo update`. See ROADMAP §5/§12.
# The biome crates are Biome-internal, published at 0.5.x with no API stability
# guarantee, and they churn between patch releases. Pinned with `=` on purpose:
# upgrading is a deliberate, tested activity, never a `cargo update`.
#
# 0.5.8 is the newest published version of all three, and it is reachable only
# because this crate does not depend on biome_css_formatter. That crate is stuck
# at 0.5.7 and requires biome_css_syntax ^0.5.7 and biome_rowan ^0.5.7, so
# adding it would drag the whole graph back a release -- which is exactly why
# igniter_js, which does format CSS, pins its entire biome set to 0.5.7.
#
# We do not format: IgniterCss.Transform::beautify is a byte-preserving
# pretty-printer written against the CST. Keep it that way, or this pin drops.
biome_css_parser = "=0.5.8"
biome_css_syntax = "=0.5.8"
biome_rowan = "=0.5.8"
Expand Down
37 changes: 30 additions & 7 deletions native/igniter_css/src/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,14 @@ fn prelude_of(ctx: &ParseCtx, list: &CssSyntaxNode) -> String {
/// `@media`/`@supports`/`@container` preludes enclosing this node, outermost
/// first.
fn conditions_of(ctx: &ParseCtx, node: &CssSyntaxNode) -> Vec<String> {
// Read each ancestor directly. Searching a freshly built list of every
// at-rule in the file, once per ancestor, made this quadratic in nesting
// depth for a node we already hold.
let mut out: Vec<String> = node
.ancestors()
.filter(|a| a.kind() == CssSyntaxKind::CSS_AT_RULE)
.filter_map(|a| {
find_all_at_rules(ctx)
.into_iter()
.find(|r| r.node == a)
crate::locate::at_rule_ref(ctx, &a)
.filter(|r| matches!(r.name.as_str(), "media" | "supports" | "container"))
.map(|r| format!("@{} {}", r.name, r.prelude).trim().to_string())
})
Expand Down Expand Up @@ -308,10 +309,14 @@ pub fn value_has_color(value: &str) -> bool {
if value.trim().is_empty() {
return false;
}
if crate::ctx::check_nesting(value).is_err() {
return false;
}
let probe = format!("a{{b:{value}}}");
let parse = biome_css_parser::parse_css(&probe, biome_css_parser::CssParserOptions::default());
parse
.syntax()
let Ok(ctx) = ParseCtx::try_new(&probe, ParseOptions::default()) else {
return false;
};
ctx.syntax()
.descendants()
.find(|n| n.kind() == CssSyntaxKind::CSS_GENERIC_COMPONENT_VALUE_LIST)
.is_some_and(|list| value_node_has_color(&list))
Expand Down Expand Up @@ -568,7 +573,25 @@ pub struct Validation {
/// raised no errors. The round-trip half is the one that actually matters for
/// safety -- it is what every codemod checks before touching a file.
pub fn validate(source: &str, options: ParseOptions) -> Validation {
let ctx = ParseCtx::new(source, options);
if let Err(e) = crate::ctx::check_nesting(source) {
return Validation {
valid: false,
diagnostics: 0,
round_trips: false,
message: e.to_string(),
};
}
let ctx = match ParseCtx::try_new(source, options) {
Ok(c) => c,
Err(e) => {
return Validation {
valid: false,
diagnostics: 0,
round_trips: false,
message: e.to_string(),
}
}
};
let round_trips = ctx.round_trips();
let diagnostics = ctx.diagnostics_count();
let has_errors = ctx.has_errors();
Expand Down
Loading
Loading