From 67310ea55d379286035b0e438d9227d01c0b5db9 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 16:24:13 +0200 Subject: [PATCH 01/11] feat!: replace the Python CSS implementation with a Rust NIF Rewrites igniter_css on top of Biome's lossless CSS CST, and deletes the Python/tinycss2 implementation entirely: `priv/python`, `plibs/`, `rebuild_wheel.sh` and the `pythonx` dependency are all gone. No Python, no Node, no external process -- a single precompiled native library, matching the igniter_js layout. The load-bearing decision is that the tree is never reprinted. Operations parse losslessly, locate byte ranges, and splice text into the original source, so comments, indentation and property order outside an edit are preserved by construction rather than by effort. That gives four properties, each asserted for every operation against every fixture rather than spot-checked: 1. comments are never lost 2. diffs contain only the lines the codemod meant to change 3. every operation is idempotent and reports changed: false on a re-run 4. a file that cannot be patched safely is refused, never half-edited Phase 0 gate: parse.syntax().to_string() == source holds byte-for-byte across the whole fixture corpus, including Tailwind v4 (zero diagnostics), CRLF, BOM, minified vendor CSS, non-ASCII content and deliberately broken files. It stays in CI. Notable findings handled along the way: - Biome lexes a leading U+FEFF into the first identifier, which silently breaks selector matching on BOM'd files. ParseCtx now strips the BOM before parsing and re-attaches it on output. - An unbalanced-brace file makes "top level" meaningless -- an insertion lands inside somebody's unterminated block -- so mutating ops refuse it outright. Analysis still works on those files. API: - IgniterCss -- the codemods, {:ok, %Outcome{}} | {:error, reason} - IgniterCss.Codemods -- Igniter-facing wrappers (diff preview, confirmation) - IgniterCss.Transform -- whole-file minify/beautify/merge, held deliberately apart from the codemods - IgniterCss.Parsers.Parser -- the previous surface, same function names and {:ok, :fun, result} shape, now diff-minimal Selector matching is strict by design: top-level only, normalised comparison, never substring or fuzzy, and more than one match is an error rather than a guess. Comment ownership on delete follows the documented convention -- trailing and adjacent own-line comments go with the node; blank-line-separated comments and section headers stay. Tests: 333 Rust (unit, corpus-invariant, property/fuzz over generated and malformed input) and 238 Elixir (unit, doctests, corpus invariants, Igniter integration). clippy, cargo fmt, mix format, credo --strict and dialyzer are all clean. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/elixir.yml | 1 + .gitignore | 7 +- .tool-versions | 1 + CHANGELOG.md | 44 + README.md | 165 +- lib/igniter_css.ex | 421 +- lib/igniter_css/application.ex | 28 +- lib/igniter_css/codemods.ex | 140 + lib/igniter_css/native.ex | 91 + lib/igniter_css/parsers/css_processor.ex | 201 - lib/igniter_css/parsers/formatter.ex | 89 +- lib/igniter_css/parsers/parser.ex | 1285 ++---- lib/igniter_css/structs.ex | 138 + lib/igniter_css/transform.ex | 66 + mix.exs | 18 +- mix.lock | 35 +- native/igniter_css/.cargo/config.toml | 24 + .../igniter_css/.gitignore | 4 +- native/igniter_css/Cargo.lock | 1132 +++++ .../igniter_css/Cargo.lock.license | 0 native/igniter_css/Cargo.toml | 31 + native/igniter_css/README.md | 71 + native/igniter_css/src/analyze.rs | 827 ++++ native/igniter_css/src/atoms.rs | 44 + native/igniter_css/src/ctx.rs | 462 +++ native/igniter_css/src/edit.rs | 236 ++ native/igniter_css/src/error.rs | 61 + native/igniter_css/src/helpers.rs | 21 + native/igniter_css/src/lib.rs | 31 + native/igniter_css/src/locate.rs | 1054 +++++ native/igniter_css/src/nif.rs | 533 +++ native/igniter_css/src/ops/at_rule.rs | 625 +++ native/igniter_css/src/ops/declaration.rs | 827 ++++ native/igniter_css/src/ops/mod.rs | 377 ++ native/igniter_css/src/ops/rule.rs | 597 +++ native/igniter_css/src/ops/tidy.rs | 428 ++ native/igniter_css/src/transform.rs | 503 +++ native/igniter_css/src/trivia.rs | 371 ++ native/igniter_css/tests/corpus_invariants.rs | 421 ++ native/igniter_css/tests/phase0_roundtrip.rs | 95 + native/igniter_css/tests/property.rs | 277 ++ native/igniter_css/tests/support/mod.rs | 88 + .../dist/css_tools-0.1.2-py3-none-any.whl | Bin 16528 -> 0 bytes plibs/css_tools/dist/css_tools-0.1.2.tar.gz | Bin 14307 -> 0 bytes plibs/css_tools/pyproject.toml | 25 - plibs/css_tools/setup.py | 32 - .../css_tools/src/css_tools.egg-info/PKG-INFO | 13 - .../src/css_tools.egg-info/SOURCES.txt | 12 - .../css_tools.egg-info/dependency_links.txt | 1 - .../src/css_tools.egg-info/requires.txt | 1 - .../src/css_tools.egg-info/top_level.txt | 1 - plibs/css_tools/src/css_tools/extractor.py | 606 --- plibs/css_tools/src/css_tools/minifier.py | 406 -- plibs/css_tools/src/css_tools/modifier.py | 607 --- plibs/css_tools/src/css_tools/parser.py | 394 -- priv/python/css_tools-0.1.2-py3-none-any.whl | Bin 16528 -> 0 bytes rebuild_wheel.sh | 45 - test/codemods_test.exs | 156 + test/corpus_invariants_test.exs | 223 + test/fixtures/bom.css | 4 + .../fixtures/bom.css.license | 0 test/fixtures/comments_everywhere.css | 45 + .../fixtures/comments_everywhere.css.license | 0 test/fixtures/crlf.css | 8 + .../fixtures/crlf.css.license | 0 test/fixtures/empty.css | 0 .../fixtures/empty.css.license | 0 test/fixtures/kitchen_sink.css | 97 + .../fixtures/kitchen_sink.css.license | 0 test/fixtures/line_comments.css | 11 + .../fixtures/line_comments.css.license | 0 test/fixtures/minified.css | 1 + .../fixtures/minified.css.license | 0 test/fixtures/no_trailing_newline.css | 3 + test/fixtures/no_trailing_newline.css.license | 3 + test/fixtures/non_ascii.css | 8 + test/fixtures/non_ascii.css.license | 3 + test/fixtures/only_comment.css | 1 + test/fixtures/only_comment.css.license | 3 + test/fixtures/phoenix_app.css | 32 + test/fixtures/phoenix_app.css.license | 3 + test/fixtures/stray_brace.css | 3 + test/fixtures/stray_brace.css.license | 3 + test/fixtures/tabs.css | 4 + test/fixtures/tabs.css.license | 3 + test/fixtures/tailwind_v4.css | 58 + test/fixtures/tailwind_v4.css.license | 3 + test/fixtures/truncated.css | 2 + test/fixtures/truncated.css.license | 3 + test/igniter_css_test.exs | 665 ++- test/parsers/css/formatter_test.exs | 49 + test/parsers/css/parser_test.exs | 3679 ++--------------- test/support/css_case.ex | 125 + test/transform_test.exs | 129 + 94 files changed, 12630 insertions(+), 6710 deletions(-) create mode 100644 lib/igniter_css/codemods.ex create mode 100644 lib/igniter_css/native.ex delete mode 100644 lib/igniter_css/parsers/css_processor.ex create mode 100644 lib/igniter_css/structs.ex create mode 100644 lib/igniter_css/transform.ex create mode 100644 native/igniter_css/.cargo/config.toml rename plibs/css_tools/src/css_tools/__init__.py => native/igniter_css/.gitignore (71%) create mode 100644 native/igniter_css/Cargo.lock rename plibs/css_tools/dist/css_tools-0.1.2-py3-none-any.whl.license => native/igniter_css/Cargo.lock.license (100%) create mode 100644 native/igniter_css/Cargo.toml create mode 100644 native/igniter_css/README.md create mode 100644 native/igniter_css/src/analyze.rs create mode 100644 native/igniter_css/src/atoms.rs create mode 100644 native/igniter_css/src/ctx.rs create mode 100644 native/igniter_css/src/edit.rs create mode 100644 native/igniter_css/src/error.rs create mode 100644 native/igniter_css/src/helpers.rs create mode 100644 native/igniter_css/src/lib.rs create mode 100644 native/igniter_css/src/locate.rs create mode 100644 native/igniter_css/src/nif.rs create mode 100644 native/igniter_css/src/ops/at_rule.rs create mode 100644 native/igniter_css/src/ops/declaration.rs create mode 100644 native/igniter_css/src/ops/mod.rs create mode 100644 native/igniter_css/src/ops/rule.rs create mode 100644 native/igniter_css/src/ops/tidy.rs create mode 100644 native/igniter_css/src/transform.rs create mode 100644 native/igniter_css/src/trivia.rs create mode 100644 native/igniter_css/tests/corpus_invariants.rs create mode 100644 native/igniter_css/tests/phase0_roundtrip.rs create mode 100644 native/igniter_css/tests/property.rs create mode 100644 native/igniter_css/tests/support/mod.rs delete mode 100644 plibs/css_tools/dist/css_tools-0.1.2-py3-none-any.whl delete mode 100644 plibs/css_tools/dist/css_tools-0.1.2.tar.gz delete mode 100644 plibs/css_tools/pyproject.toml delete mode 100644 plibs/css_tools/setup.py delete mode 100644 plibs/css_tools/src/css_tools.egg-info/PKG-INFO delete mode 100644 plibs/css_tools/src/css_tools.egg-info/SOURCES.txt delete mode 100644 plibs/css_tools/src/css_tools.egg-info/dependency_links.txt delete mode 100644 plibs/css_tools/src/css_tools.egg-info/requires.txt delete mode 100644 plibs/css_tools/src/css_tools.egg-info/top_level.txt delete mode 100644 plibs/css_tools/src/css_tools/extractor.py delete mode 100644 plibs/css_tools/src/css_tools/minifier.py delete mode 100644 plibs/css_tools/src/css_tools/modifier.py delete mode 100644 plibs/css_tools/src/css_tools/parser.py delete mode 100644 priv/python/css_tools-0.1.2-py3-none-any.whl delete mode 100755 rebuild_wheel.sh create mode 100644 test/codemods_test.exs create mode 100644 test/corpus_invariants_test.exs create mode 100644 test/fixtures/bom.css rename plibs/css_tools/dist/css_tools-0.1.2.tar.gz.license => test/fixtures/bom.css.license (100%) create mode 100644 test/fixtures/comments_everywhere.css rename plibs/css_tools/src/css_tools.egg-info/PKG-INFO.license => test/fixtures/comments_everywhere.css.license (100%) create mode 100644 test/fixtures/crlf.css rename plibs/css_tools/src/css_tools.egg-info/SOURCES.txt.license => test/fixtures/crlf.css.license (100%) create mode 100644 test/fixtures/empty.css rename plibs/css_tools/src/css_tools.egg-info/dependency_links.txt.license => test/fixtures/empty.css.license (100%) create mode 100644 test/fixtures/kitchen_sink.css rename plibs/css_tools/src/css_tools.egg-info/requires.txt.license => test/fixtures/kitchen_sink.css.license (100%) create mode 100644 test/fixtures/line_comments.css rename plibs/css_tools/src/css_tools.egg-info/top_level.txt.license => test/fixtures/line_comments.css.license (100%) create mode 100644 test/fixtures/minified.css rename priv/python/css_tools-0.1.2-py3-none-any.whl.license => test/fixtures/minified.css.license (100%) create mode 100644 test/fixtures/no_trailing_newline.css create mode 100644 test/fixtures/no_trailing_newline.css.license create mode 100644 test/fixtures/non_ascii.css create mode 100644 test/fixtures/non_ascii.css.license create mode 100644 test/fixtures/only_comment.css create mode 100644 test/fixtures/only_comment.css.license create mode 100644 test/fixtures/phoenix_app.css create mode 100644 test/fixtures/phoenix_app.css.license create mode 100644 test/fixtures/stray_brace.css create mode 100644 test/fixtures/stray_brace.css.license create mode 100644 test/fixtures/tabs.css create mode 100644 test/fixtures/tabs.css.license create mode 100644 test/fixtures/tailwind_v4.css create mode 100644 test/fixtures/tailwind_v4.css.license create mode 100644 test/fixtures/truncated.css create mode 100644 test/fixtures/truncated.css.license create mode 100644 test/parsers/css/formatter_test.exs create mode 100644 test/support/css_case.ex create mode 100644 test/transform_test.exs diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 8a407af..e337f9a 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -31,6 +31,7 @@ jobs: elixir-version: ${{ matrix.elixir-version }} erlang-version: ${{ matrix.erlang-version }} igniter-upgrade: false + rustler-precompiled-module: IgniterCss.Native publish-docs: ${{ matrix.primary }} release: false reuse: true diff --git a/.gitignore b/.gitignore index a6483b5..f1ff7ff 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,8 @@ igniter_css-*.tar .DS_Store .elixir_ls -.ropeproject -/plibs/css_tools/src/css_tools/__pycache__ + +# Compiled NIF artifacts. The precompiled ones are attached to a release and +# fetched by rustler_precompiled; local builds land here. +/priv/native/ +/native/igniter_css/target/ diff --git a/.tool-versions b/.tool-versions index 0715543..3dbcf4c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,4 @@ erlang 27.1.3 elixir 1.18.3-otp-27 +rust 1.97.1 pipx 1.8.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db19fc..d5e985c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ SPDX-FileCopyrightText: 2025 igniter_css contributors +# Changelog for IgniterCss 0.2.0 + +### Breaking changes: + +- The Python/`tinycss2` implementation is gone, along with the `pythonx` + dependency, `priv/python`, `plibs/` and `rebuild_wheel.sh`. Everything is now + a precompiled Rust NIF built on Biome's lossless CSS CST — no Python, no Node, + no external process. +- `IgniterCss.CSS.CssProcessor` has been removed. Its pipeline mixed codemods + with whole-file rewriting, which the new design keeps deliberately separate: + use `IgniterCss` for patching and `IgniterCss.Transform` for build-time + output. +- `IgniterCss.Parsers.Parser` keeps its function names and its + `{:ok, :function_name, result}` shape, but the mutating functions are now + diff-minimal rather than reprinting the stylesheet, and selector matching is + strict: an ambiguous selector is an error instead of an arbitrary choice. + +### Features: + +- New `IgniterCss` API: `ensure_at_rule/3`, `ensure_rule/4`, + `set_declaration/5`, `remove_declaration/4`, `remove_rule/3`, + `replace_rule_body/4`, `append_raw_to_rule/4`, `add_vendor_prefixes/4`, + `sort_properties/2`, `remove_duplicates/2`, plus read-only queries and + analysis. +- New `IgniterCss.Codemods` — Igniter-facing wrappers that take and return an + `Igniter` struct, so callers get the normal diff preview and confirmation + flow. +- New `IgniterCss.Transform` for whole-file `minify/2`, `beautify/2` and + `merge_stylesheets/2`, held separate from the codemods. +- Tailwind v4 support: `@theme`, `@plugin` (with and without a block), + `@source`, `@custom-variant`, `@variant`, `@utility`, `@apply`, `@reference`. + +### Improvements: + +- Comments are preserved by construction: operations splice byte ranges into the + original source instead of reprinting the tree, so text outside an edit cannot + change. +- Every operation is idempotent and reports `changed: false` on a re-run. +- Files that cannot be patched safely — an unbalanced brace, a parse that does + not reproduce the input byte for byte — are refused rather than half-edited. +- Inserted text follows the file's own newline style, indent unit and trailing + newline; BOM and CRLF files round-trip. +- Precompiled NIFs, so end users need no Rust toolchain. + # Changelog for IgniterCss 0.1.1 ### Improvements: diff --git a/README.md b/README.md index ef48b93..a32de62 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,168 @@ SPDX-License-Identifier: MIT # IgniterCss -IgniterCss is CSS patching functionality for [Igniter](https://hexdocs.pm/igniter) +Semantic patches for CSS files that a user owns, for +[Igniter](https://hexdocs.pm/igniter). Powered by a Rust parser (Biome's +lossless CSS CST) integrated via NIFs. + +This is a **codemod** tool, not a formatter, minifier or bundler. It exists to +change the two lines you meant to change in somebody's `app.css` and nothing +else. ## Installation -IgniterCss can be added to an existing elixir project by adding it to your dependencies: +```elixir +{:igniter_css, "~> 0.2.0", only: [:dev, :test]} +``` + +Precompiled NIFs ship for the standard target matrix, so no Rust toolchain is +needed. Set `IGNITERCSS_BUILD=1` to force a local build. + +## Guarantees + +1. **Comments are never lost.** Not mostly preserved — never lost. +2. **Diffs are minimal.** `git diff` shows only the lines the codemod meant to + change. No whole-file reformatting, ever. +3. **Everything is idempotent.** Installers get re-run; the second run reports + `changed: false` and produces identical bytes. +4. **Input is never destroyed.** A file that cannot be understood well enough to + patch safely comes back untouched with `{:error, reason}`. + +These are not aspirations. Guarantees 1–4 are asserted for every operation +against every fixture in the test corpus — a real Phoenix `app.css`, Tailwind v4 +syntax, comments in awkward places, CRLF, a BOM, no trailing newline, minified +vendor CSS, non-ASCII content, and files that are simply broken — plus property +tests over generated and deliberately malformed input. + +The mechanism is what makes them cheap: the parse is lossless, operations locate +**byte ranges** and splice text into the original string, and the tree is never +reprinted. Text outside an edit cannot change because nothing ever rewrites it. + +## Usage + +```elixir +css = """ +@import "tailwindcss"; +@source "../js"; + +.btn { + color: red; /* brand */ +} +""" + +{:ok, out} = IgniterCss.ensure_at_rule(css, ~s|@plugin "daisyui";|) +{:ok, out} = IgniterCss.set_declaration(out.source, ".btn", "color", "var(--brand)") + +out.source +# @import "tailwindcss"; +# @source "../js"; +# @plugin "daisyui"; +# +# .btn { +# color: var(--brand); /* brand */ +# } +``` + +The `@plugin` line lands after the at-rule prologue rather than at the top of +the file, the inline comment survives, and running the same two calls again +changes nothing. + +### Inside an Igniter installer ```elixir -{:igniter_css, "~> 0.1.1", only: [:dev, :test]} +def install(igniter, _opts) do + path = "assets/css/app.css" + + igniter + |> IgniterCss.Codemods.ensure_at_rule(path, ~s|@plugin "daisyui";|) + |> IgniterCss.Codemods.ensure_rule(path, ".hide-scrollbar") + |> IgniterCss.Codemods.set_declaration(path, ".hide-scrollbar", "scrollbar-width", "none") +end +``` + +Callers get Igniter's normal diff preview and confirmation flow. + +## Operations + +**Codemods** (`IgniterCss`) — diff-minimal, idempotent: + +| | | +|---|---| +| `ensure_at_rule/3`, `remove_at_rule/4` | `@import`, `@plugin`, `@source`, `@layer`, … | +| `add_import/4`, `remove_import/3` | `@import` convenience wrappers | +| `ensure_rule/4`, `remove_rule/3` | whole rules | +| `replace_rule_body/4`, `append_raw_to_rule/4` | rule bodies | +| `set_declaration/5`, `remove_declaration/4` | declarations | +| `add_vendor_prefixes/4` | prefixed copies of a property | +| `sort_properties/2`, `remove_duplicates/2` | tidying, by moving and deleting whole lines | + +**Queries** — read-only: `has_rule?/3`, `has_declaration?/4`, `has_at_rule?/3`, +`get_declaration/4`, `get_rule_declarations/3`, `list_selectors/2`, `analyze/2`, +`validate/2`, `extract_colors/2`, `extract_media_queries/2`, +`extract_animations/2`. + +**Whole-file transforms** (`IgniterCss.Transform`) — `minify/2`, `beautify/2`, +`merge_stylesheets/2`. These rewrite every byte by design and are kept out of +the codemod API deliberately. Do not use them to patch a file a user maintains. + +`IgniterCss.Parsers.Parser` offers the same functionality on the +`{:ok, :function_name, result}` convention shared with `igniter_js`, and accepts +a file path as well as content. + +## Selector matching + +Matching is strict, because guessing is how a codemod produces a surprising +diff: + +- **top-level rules only** — `.b` inside `@media print` is not matched; +- selectors compare on a normalised form (`.a>.b` matches `.a > .b`), never on + raw equality and never on a substring or fuzzy basis; +- a selector list matches as a whole — `.a` does not match `.a, .b`; +- **more than one match is an error**, not an arbitrary choice. + +## Comment ownership on delete + +When a codemod removes a declaration or a rule: + +```css +/* ===== Layout ===== */ <- KEPT (reads as a section header) + +/* used by the sidebar */ <- KEPT (blank line separates it from the target) + +/* brand color */ <- DELETED (adjacent, on its own line) +color: red; /* legacy */ <- DELETED (the target and its trailing comment) ``` -## Status +A section header is a comment spanning several lines, or one containing a rule +of three or more repeated `= - * # ~ _` characters. + +## Tailwind v4 + +`@import`, `@plugin` (with and without a block), `@source`, `@theme`, +`@custom-variant`, `@variant`, `@utility`, `@apply`, `@layer` and `@reference` +all parse cleanly and are covered by the fixture corpus. Where a construct is +not in the grammar, Biome's error tolerance turns it into a node that still +carries its original text, so patching around it stays safe. -We are still working on getting this ready for an initial release. +## Development + +``` +mix test # Elixir suite +cd native/igniter_css && cargo test # Rust suite +mix check # format, credo, dialyzer, reuse +``` + +### Releasing + +The cross-compile matrix in CI attaches a NIF per target to the GitHub release. +Once those artifacts exist, generate the checksum file — the package will not +work without it — and verify the tarball before publishing: + +``` +mix rustler_precompiled.download IgniterCss.Native --all --print +mix hex.build --unpack +``` -The initial codemods will be limited to specific transformations. This is not intended to -be a toolkit (yet) for writing any arbitrary transformation like `Igniter` is for `Elixir`. -We will likely provide a way to do this by the user providing rust code and using our tools -to hook it up to igniter. +`checksum-Elixir.IgniterCss.Native.exs` is listed in `files:` in `mix.exs` and +is deliberately not committed: it is only meaningful once the release artifacts +it hashes exist. diff --git a/lib/igniter_css.ex b/lib/igniter_css.ex index 5dc0a34..4f460aa 100644 --- a/lib/igniter_css.ex +++ b/lib/igniter_css.ex @@ -4,6 +4,425 @@ defmodule IgniterCss do @moduledoc """ - IgniterCss is CSS patching functionality for Igniter. + 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. + """ + + alias IgniterCss.{Analysis, Animation, Native, Outcome, ParseOpts, Validation} + + @type opts :: keyword() + @type result :: {:ok, Outcome.t()} | {:error, String.t()} + + # --------------------------------------------------------------------------- + # At-rules + # --------------------------------------------------------------------------- + + @doc """ + 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(String.t(), String.t(), opts()) :: result() + def ensure_at_rule(source, line, opts \\ []) do + Native.ensure_at_rule_nif(source, line, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + 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. + """ + @spec remove_at_rule(String.t(), String.t(), String.t() | nil, opts()) :: result() + def remove_at_rule(source, name, matching \\ nil, opts \\ []) do + Native.remove_at_rule_nif(source, name, matching, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + 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_at_rule?(String.t(), String.t(), opts()) :: {:ok, boolean()} | {:error, String.t()} + def has_at_rule?(source, line, opts \\ []) do + Native.has_at_rule_nif(source, line, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Add an `@import`, building the line for you. + + Absolute URLs are wrapped in `url(...)`; relative paths are quoted. + """ + @spec add_import(String.t(), String.t(), String.t() | nil, opts()) :: result() + def add_import(source, url, media \\ nil, opts \\ []) do + Native.add_import_nif(source, url, media, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Remove `@import` rules pointing at `url`, however they were written + (`"x.css"`, `'x.css'` and `url("x.css")` all match). + """ + @spec remove_import(String.t(), String.t(), opts()) :: result() + def remove_import(source, url, opts \\ []) do + Native.remove_import_nif(source, url, ParseOpts.new(opts)) |> unwrap() + end + + # --------------------------------------------------------------------------- + # Rules + # --------------------------------------------------------------------------- + + @doc """ + 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 ensure_rule(String.t(), String.t(), String.t(), opts()) :: result() + def ensure_rule(source, selector, declarations \\ "", opts \\ []) do + Native.ensure_rule_nif(source, selector, declarations, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Remove every top-level rule with this selector, plus the comments it owns. + """ + @spec remove_rule(String.t(), String.t(), opts()) :: result() + def remove_rule(source, selector, opts \\ []) do + Native.remove_rule_nif(source, selector, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Replace everything between a rule's braces. + + Errors when the selector matches no top-level rule, or more than one. + """ + @spec replace_rule_body(String.t(), String.t(), String.t(), opts()) :: result() + def replace_rule_body(source, selector, declarations, opts \\ []) do + Native.replace_rule_body_nif(source, selector, declarations, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + 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. + """ + @spec append_raw_to_rule(String.t(), String.t(), String.t(), opts()) :: result() + def append_raw_to_rule(source, selector, raw, opts \\ []) do + Native.append_raw_to_rule_nif(source, selector, raw, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Does a top-level rule with this selector exist? + + iex> IgniterCss.has_rule?(".a > .b { color: red; }", ".a>.b") + {:ok, true} + """ + @spec has_rule?(String.t(), String.t(), opts()) :: {:ok, boolean()} | {:error, String.t()} + def has_rule?(source, selector, opts \\ []) do + Native.has_rule_nif(source, selector, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Every top-level selector, exactly as written. """ + @spec list_selectors(String.t(), opts()) :: {:ok, [String.t()]} | {:error, String.t()} + def list_selectors(source, opts \\ []) do + Native.list_selectors_nif(source, ParseOpts.new(opts)) |> unwrap() + end + + # --------------------------------------------------------------------------- + # Declarations + # --------------------------------------------------------------------------- + + @doc """ + 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" + """ + @spec set_declaration(String.t(), String.t(), String.t(), String.t(), opts()) :: result() + def set_declaration(source, selector, property, value, opts \\ []) do + important = Keyword.get(opts, :important) + create_rule = Keyword.get(opts, :create_rule, false) == true + + Native.set_declaration_nif( + source, + selector, + property, + value, + important, + create_rule, + ParseOpts.new(opts) + ) + |> unwrap() + end + + @doc """ + 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. + """ + @spec remove_declaration(String.t(), String.t(), String.t(), opts()) :: result() + def remove_declaration(source, selector, property, opts \\ []) do + Native.remove_declaration_nif(source, selector, property, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + 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_declaration(String.t(), String.t(), String.t(), opts()) :: + {:ok, String.t() | nil} | {:error, String.t()} + def get_declaration(source, selector, property, opts \\ []) do + Native.get_declaration_nif(source, selector, property, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Does the rule matching `selector` set `property`? + """ + @spec has_declaration?(String.t(), String.t(), String.t(), opts()) :: + {:ok, boolean()} | {:error, String.t()} + def has_declaration?(source, selector, property, opts \\ []) do + Native.has_declaration_nif(source, selector, property, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + 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"}]} + """ + @spec get_rule_declarations(String.t(), String.t(), opts()) :: + {:ok, [{String.t(), String.t()}] | nil} | {:error, String.t()} + def get_rule_declarations(source, selector, opts \\ []) do + Native.get_rule_declarations_nif(source, selector, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + 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 add_vendor_prefixes(String.t(), String.t(), [String.t()], opts()) :: result() + def add_vendor_prefixes(source, property, prefixes, opts \\ []) when is_list(prefixes) do + Native.add_vendor_prefixes_nif(source, property, prefixes, ParseOpts.new(opts)) |> unwrap() + end + + # --------------------------------------------------------------------------- + # Tidying + # --------------------------------------------------------------------------- + + @doc """ + 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 sort_properties(String.t(), opts()) :: result() + def sort_properties(source, opts \\ []) do + Native.sort_properties_nif(source, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + 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` + """ + @spec remove_duplicates(String.t(), opts()) :: result() + def remove_duplicates(source, opts \\ []) do + declarations = Keyword.get(opts, :declarations, true) != false + rules = Keyword.get(opts, :rules, true) != false + + Native.remove_duplicates_nif(source, declarations, rules, ParseOpts.new(opts)) |> unwrap() + end + + # --------------------------------------------------------------------------- + # Analysis (read-only) + # --------------------------------------------------------------------------- + + @doc """ + Statistics about a stylesheet. + + iex> {:ok, a} = IgniterCss.analyze(".a { color: red; }") + iex> {a.rules_count, a.declarations_count} + {1, 1} + """ + @spec analyze(String.t(), opts()) :: {:ok, Analysis.t()} | {:error, String.t()} + def analyze(source, opts \\ []) do + Native.analyze_nif(source, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + 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. + """ + @spec validate(String.t(), opts()) :: {:ok, Validation.t()} | {:error, Validation.t()} + def validate(source, opts \\ []) do + case Native.validate_nif(source, ParseOpts.new(opts)) do + {:ok, _fun, validation} -> {:ok, validation} + {:error, _fun, validation} -> {:error, validation} + end + end + + @doc """ + 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_colors(String.t(), opts()) :: + {:ok, [{String.t(), [String.t()]}]} | {:error, String.t()} + def extract_colors(source, opts \\ []) do + Native.extract_colors_nif(source, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Media queries in the file, each with the rules it contains. + """ + @spec extract_media_queries(String.t(), opts()) :: + {:ok, [{String.t(), [{String.t(), [{String.t(), String.t()}]}]}]} + | {:error, String.t()} + def extract_media_queries(source, opts \\ []) do + Native.extract_media_queries_nif(source, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + `@keyframes` animations, their steps, and the selectors that use them. + """ + @spec extract_animations(String.t(), opts()) :: {:ok, [Animation.t()]} | {:error, String.t()} + def extract_animations(source, opts \\ []) do + Native.extract_animations_nif(source, ParseOpts.new(opts)) |> unwrap() + end + + # --------------------------------------------------------------------------- + + defp unwrap({:ok, _fun, value}), do: {:ok, value} + defp unwrap({:error, _fun, reason}), do: {:error, reason} end diff --git a/lib/igniter_css/application.ex b/lib/igniter_css/application.ex index 92128d7..0a81ca7 100644 --- a/lib/igniter_css/application.ex +++ b/lib/igniter_css/application.ex @@ -11,30 +11,8 @@ defmodule IgniterCss.Application do @impl true def start(_type, _args) do - Application.ensure_all_started(:pythonx) - wheel_path = Application.app_dir(:igniter_css, "priv/python/css_tools-0.1.2-py3-none-any.whl") - - # Set configuration directly - pyproject_toml = """ - [project] - name = "igniter_py" - version = "0.1.1" - requires-python = "==3.13.*" - dependencies = [ - "tinycss2==1.4.0", - "css_tools==0.1.2" - ] - [tool.uv.sources] - css_tools = { path = "#{wheel_path}" } - """ - - Pythonx.uv_init(pyproject_toml) - - children = [] - - # See https://hexdocs.pm/elixir/Supervisor.html - # for other strategies and supported options - opts = [strategy: :one_for_one, name: IgniterCss.Supervisor] - Supervisor.start_link(children, opts) + # Nothing to boot: the parser is a precompiled NIF loaded on first use. + # There is no interpreter to initialise and no external process to start. + Supervisor.start_link([], strategy: :one_for_one, name: IgniterCss.Supervisor) end end diff --git a/lib/igniter_css/codemods.ex b/lib/igniter_css/codemods.ex new file mode 100644 index 0000000..1f0f638 --- /dev/null +++ b/lib/igniter_css/codemods.ex @@ -0,0 +1,140 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.Codemods do + @moduledoc """ + Igniter-facing wrappers: they take and return an `Igniter` struct, so callers + get Igniter's normal diff preview and confirmation flow for free. + + Available only when `igniter` is a dependency. Without it, every function here + returns `{:error, :igniter_not_available}`; use `IgniterCss` directly instead. + + ## Comment ownership on delete + + When a codemod removes a declaration or a rule, these comments go with it: + + * a comment **trailing on the same line**; + * a comment on its **own line directly above**, with no blank line between. + + These are kept: + + * a comment separated from the target by a **blank line**; + * a comment that reads as a **section header** — one spanning several lines, or + containing a rule of three or more repeated `= - * # ~ _` characters, e.g. + `/* ===== Layout ===== */`. + + ## Example + + def install(igniter, _opts) do + igniter + |> IgniterCss.Codemods.ensure_at_rule("assets/css/app.css", ~s|@plugin "daisyui";|) + |> IgniterCss.Codemods.ensure_rule("assets/css/app.css", ".hide-scrollbar") + |> IgniterCss.Codemods.set_declaration( + "assets/css/app.css", ".hide-scrollbar", "scrollbar-width", "none" + ) + end + + Each step is idempotent, so re-running the installer produces no diff. + """ + + @igniter_available Code.ensure_loaded?(Igniter) + + if @igniter_available do + @doc "See `IgniterCss.ensure_at_rule/3`." + def ensure_at_rule(igniter, path, line, opts \\ []) do + update(igniter, path, "ensure_at_rule #{inspect(line)}", fn source -> + IgniterCss.ensure_at_rule(source, line, opts) + end) + end + + @doc "See `IgniterCss.remove_at_rule/4`." + def remove_at_rule(igniter, path, name, matching \\ nil, opts \\ []) do + update(igniter, path, "remove_at_rule #{inspect(name)}", fn source -> + IgniterCss.remove_at_rule(source, name, matching, opts) + end) + end + + @doc "See `IgniterCss.ensure_rule/4`." + def ensure_rule(igniter, path, selector, declarations \\ "", opts \\ []) do + update(igniter, path, "ensure_rule #{inspect(selector)}", fn source -> + IgniterCss.ensure_rule(source, selector, declarations, opts) + end) + end + + @doc "See `IgniterCss.remove_rule/3`." + def remove_rule(igniter, path, selector, opts \\ []) do + update(igniter, path, "remove_rule #{inspect(selector)}", fn source -> + IgniterCss.remove_rule(source, selector, opts) + end) + end + + @doc "See `IgniterCss.set_declaration/5`." + def set_declaration(igniter, path, selector, property, value, opts \\ []) do + label = "set_declaration #{inspect(selector)} #{inspect(property)}" + + update(igniter, path, label, fn source -> + IgniterCss.set_declaration(source, selector, property, value, opts) + end) + end + + @doc "See `IgniterCss.remove_declaration/4`." + def remove_declaration(igniter, path, selector, property, opts \\ []) do + label = "remove_declaration #{inspect(selector)} #{inspect(property)}" + + update(igniter, path, label, fn source -> + IgniterCss.remove_declaration(source, selector, property, opts) + end) + end + + @doc "See `IgniterCss.append_raw_to_rule/4`." + def append_raw_to_rule(igniter, path, selector, raw, opts \\ []) do + update(igniter, path, "append_raw_to_rule #{inspect(selector)}", fn source -> + IgniterCss.append_raw_to_rule(source, selector, raw, opts) + end) + end + + @doc "See `IgniterCss.add_vendor_prefixes/4`." + def add_vendor_prefixes(igniter, path, property, prefixes, opts \\ []) do + update(igniter, path, "add_vendor_prefixes #{inspect(property)}", fn source -> + IgniterCss.add_vendor_prefixes(source, property, prefixes, opts) + end) + end + + # A codemod that cannot be applied raises rather than silently skipping. + # An installer that quietly leaves a file unpatched is worse than one that + # stops and says which file and which operation failed. + defp update(igniter, path, label, fun) do + Igniter.update_file(igniter, path, fn source -> + content = Rewrite.Source.get(source, :content) + + case fun.(content) do + {:ok, %IgniterCss.Outcome{changed: false}} -> + source + + {:ok, %IgniterCss.Outcome{source: patched}} -> + Rewrite.Source.update(source, :content, patched) + + {:error, reason} -> + raise "igniter_css: #{label} failed on #{path}: #{inspect(reason)}" + end + end) + end + else + for {name, arity} <- [ + ensure_at_rule: 4, + remove_at_rule: 5, + ensure_rule: 5, + remove_rule: 4, + set_declaration: 6, + remove_declaration: 5, + append_raw_to_rule: 5, + add_vendor_prefixes: 5 + ] do + @doc false + def unquote(name)(unquote_splicing(Macro.generate_arguments(arity, __MODULE__))) do + {:error, :igniter_not_available} + end + end + end +end diff --git a/lib/igniter_css/native.ex b/lib/igniter_css/native.ex new file mode 100644 index 0000000..3503f11 --- /dev/null +++ b/lib/igniter_css/native.ex @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +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. + + mix_config = Mix.Project.config() + version = mix_config[:version] + github_url = mix_config[:package][:links]["GitHub"] + + use RustlerPrecompiled, + otp_app: :igniter_css, + crate: "igniter_css", + base_url: "#{github_url}/releases/download/v#{version}", + version: version, + targets: ~w( + aarch64-apple-darwin + aarch64-unknown-linux-gnu + aarch64-unknown-linux-musl + riscv64gc-unknown-linux-gnu + x86_64-apple-darwin + x86_64-pc-windows-gnu + x86_64-pc-windows-msvc + x86_64-unknown-freebsd + x86_64-unknown-linux-gnu + x86_64-unknown-linux-musl + ), + force_build: + System.get_env("IGNITERCSS_BUILD") in ["1", "true"] || + System.get_env("ASH_CI_BUILD") in ["1", "true"] + + # -- at-rules --------------------------------------------------------------- + + def ensure_at_rule_nif(_source, _line, _opts), do: error() + def remove_at_rule_nif(_source, _name, _matching, _opts), do: error() + def has_at_rule_nif(_source, _line, _opts), do: error() + def add_import_nif(_source, _url, _media, _opts), do: error() + def remove_import_nif(_source, _url, _opts), do: error() + + # -- rules ------------------------------------------------------------------ + + def ensure_rule_nif(_source, _selector, _declarations, _opts), do: error() + def remove_rule_nif(_source, _selector, _opts), do: error() + def replace_rule_body_nif(_source, _selector, _declarations, _opts), do: error() + def append_raw_to_rule_nif(_source, _selector, _raw, _opts), do: error() + def has_rule_nif(_source, _selector, _opts), do: error() + def list_selectors_nif(_source, _opts), do: error() + + # -- declarations ----------------------------------------------------------- + + def set_declaration_nif( + _source, + _selector, + _property, + _value, + _important, + _create_rule, + _opts + ), + do: error() + + def remove_declaration_nif(_source, _selector, _property, _opts), do: error() + def get_declaration_nif(_source, _selector, _property, _opts), do: error() + def has_declaration_nif(_source, _selector, _property, _opts), do: error() + def get_rule_declarations_nif(_source, _selector, _opts), do: error() + def add_vendor_prefixes_nif(_source, _property, _prefixes, _opts), do: error() + + # -- tidy ------------------------------------------------------------------- + + def sort_properties_nif(_source, _opts), do: error() + def remove_duplicates_nif(_source, _declarations, _rules, _opts), do: error() + + # -- analysis --------------------------------------------------------------- + + def analyze_nif(_source, _opts), do: error() + def validate_nif(_source, _opts), do: error() + def extract_colors_nif(_source, _opts), do: error() + def extract_media_queries_nif(_source, _opts), do: error() + def extract_animations_nif(_source, _opts), do: error() + + # -- whole-file transforms -------------------------------------------------- + + def minify_nif(_source, _opts), do: error() + def beautify_nif(_source, _opts), do: error() + def merge_stylesheets_nif(_sources, _opts), do: error() + + defp error, do: :erlang.nif_error(:nif_not_loaded) +end diff --git a/lib/igniter_css/parsers/css_processor.ex b/lib/igniter_css/parsers/css_processor.ex deleted file mode 100644 index 2573dc8..0000000 --- a/lib/igniter_css/parsers/css_processor.ex +++ /dev/null @@ -1,201 +0,0 @@ -# SPDX-FileCopyrightText: 2025 igniter_css contributors -# -# SPDX-License-Identifier: MIT - -defmodule IgniterCss.CSS.CssProcessor do - @moduledoc """ - A module that provides higher-level CSS processing functionality by leveraging - the CSS.Parser module. - """ - - alias IgniterCss.Parsers.Parser - - @doc """ - Processes a CSS file for production by: - 1. Adding vendor prefixes for browser compatibility - 2. Removing duplicate rules - 3. Sorting properties for better diff comparison - 4. Minifying the CSS - - ## Parameters - - * `css_content` - The CSS content as a string - * `opts` - Options for processing: - * `:minify` - Whether to minify the output (default: `true`) - * `:add_prefixes` - Whether to add vendor prefixes (default: `true`) - * `:sort` - Whether to sort properties (default: `true`) - * `:remove_duplicates` - Whether to remove duplicates (default: `true`) - - ## Returns - - The processed CSS as a string - """ - def process_for_production(css_content, opts \\ []) do - # Default options - opts = - Keyword.merge( - [ - minify: true, - add_prefixes: true, - sort: true, - remove_duplicates: true - ], - opts - ) - - # Process the CSS according to options - css_content - |> maybe_add_prefixes(opts[:add_prefixes]) - |> maybe_remove_duplicates(opts[:remove_duplicates]) - |> maybe_sort_properties(opts[:sort]) - |> maybe_minify(opts[:minify]) - end - - @doc """ - Applies browser compatibility fixes to CSS. - - Makes CSS work across browsers by: - 1. Adding vendor prefixes for properties that need them - 2. Adding standard fallbacks for newer CSS features - 3. Adding the .hide-scrollbar modifier as needed - - ## Returns - - The CSS with compatibility fixes applied - """ - def apply_browser_compatibility(css_content) do - # Properties that need vendor prefixes - properties_needing_prefixes = [ - {"user-select", ["-webkit-", "-moz-", "-ms-"]}, - {"appearance", ["-webkit-", "-moz-"]}, - {"backdrop-filter", ["-webkit-"]}, - {"text-size-adjust", ["-webkit-", "-ms-"]}, - {"font-smoothing", ["-webkit-", "-moz-osx-"]} - ] - - # Start with the original CSS - css_with_prefixes = css_content - - # Add each set of prefixes - css_with_prefixes = - Enum.reduce(properties_needing_prefixes, css_with_prefixes, fn {property, prefixes}, css -> - Parser.add_vendor_prefixes(css, property, prefixes) - end) - - # Add the hide-scrollbar property - css_with_hide_scrollbar = Parser.add_hide_scrollbar_property(css_with_prefixes) - - css_with_hide_scrollbar - end - - @doc """ - Extracts critical CSS by identifying and extracting all styles needed for above-the-fold content. - - ## Parameters - - * `css_content` - The full CSS content as a string - * `critical_selectors` - List of selectors considered critical for above-the-fold content - - ## Returns - - A tuple with `{critical_css, non_critical_css}` - """ - def extract_critical_css(css_content, critical_selectors) do - # Use Enum.reduce to accumulate both results in a single pass - {critical_css, non_critical_css} = - Enum.reduce( - critical_selectors, - # Initial accumulator: {critical_css, non_critical_css} - {"", css_content}, - fn selector, {critical_acc, non_critical_acc} -> - # Find selectors in the CSS that match this critical selector - {result, _globals} = - Pythonx.eval( - """ - import tinycss2 - from css_tools.parser import parse_stylesheet, get_selector_text, get_rule_declarations - - rules = parse_stylesheet(css_code) - matching_rules = [] - - for rule in rules: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - if selector == critical_selector or critical_selector in selector.split(','): - declarations = get_rule_declarations(rule) - serialized_content = tinycss2.serialize(declarations).strip() - serialized_content = "\\n".join(" " + line.strip() for line in serialized_content.splitlines() if line.strip()) - formatted_rule = f"{selector} {{\\n{serialized_content}\\n}}\\n" - matching_rules.append(formatted_rule) - - result = "\\n".join(matching_rules) - result - """, - %{"css_code" => non_critical_acc, "critical_selector" => selector} - ) - - # Extract the matching rules - matching_css = Pythonx.decode(result) - - # Update both critical and non-critical CSS - updated_critical = critical_acc <> matching_css <> "\n" - updated_non_critical = Parser.remove_selector(non_critical_acc, selector) - - # Return updated tuple for next iteration - {updated_critical, updated_non_critical} - end - ) - - # Return the final result - {critical_css, non_critical_css} - end - - @doc """ - Merges multiple CSS files into one optimized stylesheet. - - ## Parameters - - * `css_files` - Map of `{filename, content}` pairs - * `opts` - Options (same as process_for_production) - - ## Returns - - The merged and optimized CSS - """ - def merge_css_files(css_files, opts \\ []) do - # Extract contents - css_contents = Map.values(css_files) - - # Merge the CSS files - merged = Parser.merge_stylesheets(css_contents) - - # Process the merged result for production - process_for_production(merged, opts) - end - - # Helper functions for conditional processing - - defp maybe_add_prefixes(css, true) do - apply_browser_compatibility(css) - end - - defp maybe_add_prefixes(css, false), do: css - - defp maybe_remove_duplicates(css, true) do - Parser.remove_duplicates(css) - end - - defp maybe_remove_duplicates(css, false), do: css - - defp maybe_sort_properties(css, true) do - Parser.sort_properties(css) - end - - defp maybe_sort_properties(css, false), do: css - - defp maybe_minify(css, true) do - Parser.minify(css) - end - - defp maybe_minify(css, false), do: css -end diff --git a/lib/igniter_css/parsers/formatter.ex b/lib/igniter_css/parsers/formatter.ex index 0ec6995..ea7c0a0 100644 --- a/lib/igniter_css/parsers/formatter.ex +++ b/lib/igniter_css/parsers/formatter.ex @@ -4,17 +4,88 @@ defmodule IgniterCss.Parsers.Formatter do @moduledoc """ - Provides formatting functionality that requires igniter_js. - This module's functions will only work when igniter_js is included as a dependency. + CSS formatting. + + If `igniter_js` is present in the running application, its Biome-backed CSS + formatter is used, since that is a full formatter with style options. + Otherwise this falls back to `IgniterCss.Transform.beautify/2`, a simpler + pretty-printer that keeps every comment and needs no extra dependency. + + The choice is made at **run time**, so adding `igniter_js` to your project is + enough — `igniter_css` does not need recompiling, and it does not declare + `igniter_js` as a dependency (which would pin your `rustler` version). + + Formatting rewrites the whole file. It is never used by the codemods in + `IgniterCss` — see the note in `IgniterCss.Transform`. """ - if Code.ensure_loaded?(IgniterJs) do - alias IgniterJs.Parsers.CSS.Formatter - defdelegate format(file_path_or_content, type \\ :content), to: Formatter - defdelegate is_formatted(file_path_or_content, type \\ :content), to: Formatter - else - def format(_) do - {:error, :igniter_js_not_available} + alias IgniterCss.Transform + + # Built at run time rather than written as a literal: `igniter_js` is not a + # declared dependency (declaring it would pin the consumer's `rustler` + # version), so a literal reference would raise an "undefined module" warning + # at compile time even though the call is properly guarded. + defp igniter_js_formatter, do: Module.concat([:IgniterJs, :Parsers, :CSS, :Formatter]) + + @doc """ + Format a stylesheet. + + ## Examples + + iex> {:ok, _, css} = IgniterCss.Parsers.Formatter.format(".a{color:red}") + iex> css + ".a {\\n color: red;\\n}\\n" + """ + @spec format(String.t(), :content | :path) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def format(file_path_or_content, type \\ :content) do + if igniter_js_available?() do + formatter = igniter_js_formatter() + formatter.format(file_path_or_content, type) + else + IgniterCss.Helpers.call_nif_fn( + file_path_or_content, + {:format, 2}, + fn content -> + case Transform.beautify(content) do + {:ok, formatted} -> {:ok, :format, formatted} + {:error, reason} -> {:error, :format, reason} + end + end, + type + ) end end + + @doc """ + Is this stylesheet already formatted? + + ## Examples + + iex> IgniterCss.Parsers.Formatter.is_formatted(".a {\\n color: red;\\n}\\n") + {:ok, :is_formatted, true} + """ + @spec is_formatted(String.t(), :content | :path) :: + {:ok, atom(), boolean()} | {:error, atom(), boolean() | String.t()} + def is_formatted(file_path_or_content, type \\ :content) do + if igniter_js_available?() do + formatter = igniter_js_formatter() + formatter.is_formatted(file_path_or_content, type) + else + IgniterCss.Helpers.call_nif_fn( + file_path_or_content, + {:is_formatted, 2}, + fn content -> + case Transform.beautify(content) do + {:ok, ^content} -> {:ok, :is_formatted, true} + {:ok, _other} -> {:error, :is_formatted, false} + {:error, reason} -> {:error, :is_formatted, reason} + end + end, + type + ) + end + end + + defp igniter_js_available?, do: Code.ensure_loaded?(igniter_js_formatter()) end diff --git a/lib/igniter_css/parsers/parser.ex b/lib/igniter_css/parsers/parser.ex index 4b39f59..a139043 100644 --- a/lib/igniter_css/parsers/parser.ex +++ b/lib/igniter_css/parsers/parser.ex @@ -4,716 +4,375 @@ defmodule IgniterCss.Parsers.Parser do @moduledoc """ - CSS parsing and manipulation using Python's tinycss2 library. + The full CSS toolkit surface, on the `{:ok, :function_name, result}` calling + convention shared with `igniter_js`. - This module provides functions to work with CSS files by leveraging - a Python toolkit built on tinycss2 for parsing, modifying, and analyzing CSS. + Every function accepts either CSS content or a file path, selected by the + trailing `type` argument (`:content`, the default, or `:path`). - > **Please note that the use of Python in Elixir will remain experimental for now, - > as we continue to improve it over time and decide whether to adopt it fully.** + 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. + + 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. """ import IgniterCss.Helpers, only: [call_nif_fn: 4] + alias IgniterCss.{Native, ParseOpts, Transform} + + @type type :: :content | :path + + # --------------------------------------------------------------------------- + # Mutating operations + # --------------------------------------------------------------------------- + @doc """ - Adds a display: none property to the .hide-scrollbar class. - If the class doesn't exist, it creates it. + Add `display: none` to `.hide-scrollbar`, creating the class if it is absent. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.add_hide_scrollbar_property(css_code) - updated css with .hide-scrollbar having display: none - ``` + + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.add_hide_scrollbar_property("") + iex> css + ".hide-scrollbar {\\n display: none;\\n}\\n" """ + @spec add_hide_scrollbar_property(String.t(), type()) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} def add_hide_scrollbar_property(file_path_or_content, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - import tinycss2 - from css_tools.modifier import add_property_to_selector - - try: - # Ensure css_code is a string - if isinstance(css_code, bytes): - css_code = css_code.decode('utf-8') - - # Try the modification - modified_css = add_property_to_selector( - css_code, - ".hide-scrollbar", - "display", - "none" - ) - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_code" => file_content} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end + fn content -> + Native.set_declaration_nif( + content, + ".hide-scrollbar", + "display", + "none", + nil, + true, + ParseOpts.new([]) + ) + |> to_source() end, type ) end @doc """ - Adds vendor prefixes to specified CSS properties throughout the stylesheet. - - ## Parameters - - * `css_code` - The CSS code as a string - * `property_name` - The CSS property to add prefixes to - * `prefixes` - List of prefixes to add (e.g., ["-webkit-", "-moz-"]) + Add vendor-prefixed copies of `property_name` beside every occurrence of it. ## Examples - ```elixir - iex> prefixes = ["-webkit-", "-moz-", "-ms-"] - iex> IgniterCss.Parsers.CSS.Parser.add_vendor_prefixes(css_code, "user-select", prefixes) - "updated css with vendor prefixes" - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.add_vendor_prefixes( + ...> ".a { user-select: none; }", "user-select", ["-webkit-"]) + iex> css + ".a { -webkit-user-select: none; user-select: none; }" """ + @spec add_vendor_prefixes(String.t(), String.t(), [String.t()], type()) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} def add_vendor_prefixes(file_path_or_content, property_name, prefixes, type \\ :content) when is_list(prefixes) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.modifier import add_prefix_to_property - - # Convert all prefixes from bytes to strings if needed - string_prefixes = [] - for prefix in prefixes: - if isinstance(prefix, bytes): - string_prefixes.append(prefix.decode('utf-8')) - else: - string_prefixes.append(prefix) - - try: - modified_css = add_prefix_to_property( - css_code, - property_name, - string_prefixes - ) - - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{ - "css_code" => file_content, - "property_name" => property_name, - "prefixes" => prefixes - } - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end + fn content -> + Native.add_vendor_prefixes_nif(content, property_name, prefixes, ParseOpts.new([])) + |> to_source() end, type ) end @doc """ - Analyzes a CSS stylesheet and returns various statistics. + Set a property value on a selector. + + Only the value bytes change when the property already exists, so an inline + comment on that line survives. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.analyze_css(css_code) - %{ - "selectors_count" => 15, - "unique_selectors" => 12, - "properties_count" => 45, - "unique_properties" => 20, - ... - } - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.modify_property( + ...> ".a { color: red; }", ".a", "color", "blue", false) + iex> css + ".a { color: blue; }" """ - def analyze_css(file_path_or_content, type \\ :content) do + @spec modify_property(String.t(), String.t(), String.t(), String.t(), boolean(), type()) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def modify_property( + file_path_or_content, + selector, + property_name, + new_value, + important \\ false, + type \\ :content + ) + when is_boolean(important) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.parser import analyze_stylesheet - try: - analyze_css = analyze_stylesheet(css_code) - - result = {"status": "ok", "result": analyze_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_code" => file_content} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => analyzed_css} -> - {:ok, __ENV__.function, analyzed_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end + fn content -> + Native.set_declaration_nif( + content, + selector, + property_name, + new_value, + important, + true, + ParseOpts.new([]) + ) + |> to_source() end, type ) end @doc """ - Extracts all color values from a CSS stylesheet. + Remove a top-level selector and everything it owns. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.extract_colors(css_code) - %{ - ".header" => ["color: #333", "background-color: white"], - ".footer" => ["color: rgba(0, 0, 0, 0.8)"] - } - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.remove_selector( + ...> ".a {}\\n.unused {}\\n", ".unused") + iex> css + ".a {}\\n" """ - def extract_colors(file_path_or_content, type \\ :content) do + @spec remove_selector(String.t(), String.t(), type()) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def remove_selector(file_path_or_content, selector, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.extractor import extract_colors - - try: - analyze_css = extract_colors(css_code) - - result = {"status": "ok", "result": analyze_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_code" => file_content} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => analyzed_css} -> - {:ok, __ENV__.function, analyzed_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end + fn content -> + Native.remove_rule_nif(content, selector, ParseOpts.new([])) |> to_source() end, type ) end @doc """ - Minifies a CSS stylesheet by removing comments, whitespace, and unnecessary characters. - We recommend not using this. + Replace a rule's declarations wholesale. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.minify(css_code) - ".header{color:#333;background:#fff;}.footer{color:#000;}" - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.replace_selector_rule( + ...> ".a { color: red; }", ".a", "color: blue; padding: 10px;") + iex> css + ".a { color: blue; padding: 10px; }" """ - def minify(file_path_or_content, type \\ :content) do + @spec replace_selector_rule(String.t(), String.t(), String.t(), type()) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def replace_selector_rule(file_path_or_content, selector, new_declarations, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.minifier import minify_css - - try: - modified_css = minify_css(css_code) - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_code" => file_content} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end + fn content -> + Native.replace_rule_body_nif(content, selector, new_declarations, ParseOpts.new([])) + |> to_source() end, type ) end @doc """ - Beautifies a CSS stylesheet by adding proper indentation and formatting. - We recommend using `IgniterCss.Parsers.CSS.Formatter` module instead. + Add an `@import` unless an equivalent one is already present. - ## Examples + `media_query` may be a media query string, or `false`/`nil` for none. - iex> IgniterCss.Parsers.CSS.Parser.beautify(css_code) - ".header { - color: #333; - background: #fff; - } + ## Examples - .footer { - color: #000; - }" + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.add_import("", "styles.css", false) + iex> css + ~s|@import "styles.css";\\n| """ - def beautify(file_path_or_content, type \\ :content) do + @spec add_import(String.t(), String.t(), String.t() | boolean() | nil, type()) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def add_import(file_path_or_content, import_url, media_query \\ nil, type \\ :content) + when is_boolean(media_query) or is_binary(media_query) or is_nil(media_query) do + media = if is_binary(media_query) and media_query != "", do: media_query + call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.minifier import beautify_css - - try: - modified_css = beautify_css(css_code) - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_code" => file_content} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end + fn content -> + Native.add_import_nif(content, import_url, media, ParseOpts.new([])) |> to_source() end, type ) end @doc """ - Modifies a property value for a specific selector. - - ## Parameters - - * `css_code` - The CSS code as a string - * `selector` - The CSS selector to modify - * `property_name` - The property name to modify - * `new_value` - The new property value - * `important` - Whether to mark the property as !important (default: false) + Remove `@import` rules pointing at `import_url`. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.modify_property(css_code, ".header", "color", "blue") - "updated css with .header color: blue" - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.remove_import( + ...> ~s|@import "a.css";\\n@import "b.css";\\n|, "a.css") + iex> css + ~s|@import "b.css";\\n| """ - def modify_property( - file_path_or_content, - selector, - property_name, - new_value, - important, - type \\ :content - ) - when is_boolean(important) do + @spec remove_import(String.t(), String.t(), type()) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def remove_import(file_path_or_content, import_url, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.modifier import modify_property_value - - try: - modified_css = modify_property_value( - css_code, - selector, - property_name, - new_value, - important - ) - - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{ - "css_code" => file_content, - "selector" => selector, - "property_name" => property_name, - "new_value" => new_value, - "important" => important - } - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end + fn content -> + Native.remove_import_nif(content, import_url, ParseOpts.new([])) |> to_source() end, type ) end @doc """ - Merges multiple CSS stylesheets into one, removing duplicates. + Sort declarations alphabetically within each block, by moving whole lines. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.merge_stylesheets([css_code1, css_code2]) - "merged css" - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.sort_properties( + ...> ".a {\\n color: red;\\n background: #fff;\\n}\\n") + iex> css + ".a {\\n background: #fff;\\n color: red;\\n}\\n" """ - def merge_stylesheets(css_list) when is_list(css_list) do - {result, _globals} = - Pythonx.eval( - """ - from css_tools.modifier import merge_stylesheets - - try: - modified_css = merge_stylesheets(css_list) - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_list" => css_list} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end - end - - @doc """ - Removes a CSS selector and all its properties. - **Note**: If a block is empty after removal, it will be removed as well. - - ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.remove_selector(css_code, ".unused-class") - "css without .unused-class" - ``` - """ - def remove_selector(file_path_or_content, selector, type \\ :content) do + @spec sort_properties(String.t(), type()) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def sort_properties(file_path_or_content, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.modifier import remove_selector - - try: - modified_css = remove_selector(css_code, selector) - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_code" => file_content, "selector" => selector} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end + fn content -> + Native.sort_properties_nif(content, ParseOpts.new([])) |> to_source() end, type ) end @doc """ - Extracts all media queries and their contents. + Remove declarations and rules that a later one makes redundant. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.extract_media_queries(css_code) - %{ - "(max-width: 768px)" => [ - %{ - "selector" => ".header", - "properties" => %{"font-size" => "14px"} - } - ] - } - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.remove_duplicates( + ...> ".a {\\n color: red;\\n color: blue;\\n}\\n") + iex> css + ".a {\\n color: blue;\\n}\\n" """ - def extract_media_queries(file_path_or_content, type \\ :content) do + @spec remove_duplicates(String.t(), type()) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def remove_duplicates(file_path_or_content, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.extractor import extract_media_queries - - try: - modified_css = extract_media_queries(css_code) - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_code" => file_content} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => analyzed_css} -> - {:ok, __ENV__.function, analyzed_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end + fn content -> + Native.remove_duplicates_nif(content, true, true, ParseOpts.new([])) |> to_source() end, type ) end + # --------------------------------------------------------------------------- + # Whole-file transforms + # --------------------------------------------------------------------------- + @doc """ - Extracts all CSS animations and keyframes. + Minify a stylesheet. Rewrites the whole file and drops comments by design. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.extract_animations(css_code) - %{ - "fade-in" => %{ - "keyframes" => %{ - "0%" => %{"opacity" => "0"}, - "100%" => %{"opacity" => "1"} - }, - "used_by" => [".header", ".modal"] - } - } - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.minify(".a {\\n color: red;\\n}\\n") + iex> css + ".a{color:red}" """ - def extract_animations(file_path_or_content, type \\ :content) do + @spec minify(String.t(), type()) :: {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def minify(file_path_or_content, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.extractor import extract_animations - - try: - modified_css = extract_animations(css_code) - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_code" => file_content} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => analyzed_css} -> - {:ok, __ENV__.function, analyzed_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end - end, + fn content -> Native.minify_nif(content, ParseOpts.new([])) end, type ) end @doc """ - Sorts CSS properties alphabetically within each rule. + Pretty-print a stylesheet, keeping every comment. Rewrites the whole file. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.sort_properties(css_code) - ".header { - background: #fff; - color: #333; - font-size: 16px; - }" - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.beautify(".a{color:red}") + iex> css + ".a {\\n color: red;\\n}\\n" """ - def sort_properties(file_path_or_content, type \\ :content) do + @spec beautify(String.t(), type()) :: {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def beautify(file_path_or_content, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.minifier import sort_properties - - try: - modified_css = sort_properties(css_code) - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{"css_code" => file_content} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end - end, + fn content -> Native.beautify_nif(content, ParseOpts.new([])) end, type ) end @doc """ - Removes duplicate selectors and properties from CSS. + Concatenate stylesheets, dropping rules a later identical copy makes + redundant. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.remove_duplicates(css_code) - "css without duplicates" - ``` + iex> {:ok, _, css} = IgniterCss.Parsers.Parser.merge_stylesheets([".a {}", ".b {}"]) + iex> css + ".a {}\\n\\n.b {}\\n" """ - def remove_duplicates(file_path_or_content, type \\ :content) do - call_nif_fn( - file_path_or_content, - __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.minifier import remove_duplicates - - try: - modified_css = remove_duplicates(css_code) - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} + @spec merge_stylesheets([String.t()]) :: + {:ok, atom(), String.t()} | {:error, atom(), String.t()} + def merge_stylesheets(css_list) when is_list(css_list) do + case Transform.merge_stylesheets(css_list) do + {:ok, merged} -> {:ok, :merge_stylesheets, merged} + {:error, reason} -> {:error, :merge_stylesheets, reason} + end + end - result - """, - %{"css_code" => file_content} - ) + # --------------------------------------------------------------------------- + # Analysis + # --------------------------------------------------------------------------- - parsed_result = Pythonx.decode(result) + @doc """ + Statistics about a stylesheet, as a string-keyed map. - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} + ## Examples - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} + iex> {:ok, _, stats} = IgniterCss.Parsers.Parser.analyze_css(".a { color: red; }") + iex> {stats["rules_count"], stats["declarations_count"]} + {1, 1} + """ + @spec analyze_css(String.t(), type()) :: {:ok, atom(), map()} | {:error, atom(), String.t()} + def analyze_css(file_path_or_content, type \\ :content) do + call_nif_fn( + file_path_or_content, + __ENV__.function, + fn content -> + case Native.analyze_nif(content, ParseOpts.new([])) do + {:ok, fun, analysis} -> + map = + analysis + |> Map.from_struct() + |> Map.new(fn + {:property_frequency, pairs} -> + {"property_frequency", Map.new(pairs, fn {k, v} -> {k, v} end)} + + {key, value} -> + {Atom.to_string(key), value} + end) + + {:ok, fun, map} + + other -> + other end end, type @@ -721,51 +380,23 @@ defmodule IgniterCss.Parsers.Parser do end @doc """ - Checks if the CSS code is valid by attempting to parse it. - Returns :ok if valid, or {:error, reason} if invalid. - ## Examples + Colour-carrying declarations, keyed by selector. - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.validate_css(css_code) - :ok + ## Examples - iex> IgniterCss.Parsers.CSS.Parser.validate_css("invalid { css") - {:error, "Parse error at line 1, column 10: Missing closing brace"} - ``` + iex> {:ok, _, colors} = IgniterCss.Parsers.Parser.extract_colors(".a { color: #333; }") + iex> colors + %{".a" => ["color: #333"]} """ - def validate_css(file_path_or_content, type \\ :content) do + @spec extract_colors(String.t(), type()) :: {:ok, atom(), map()} | {:error, atom(), String.t()} + def extract_colors(file_path_or_content, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - import tinycss2 - from css_tools.extractor import validate_css - - try: - if isinstance(css_code, bytes): - css_code = css_code.decode('utf-8') - # Use the validate_css function from extractor - validate_css(css_code) - result = {"valid": True, "message": "CSS is valid"} - except Exception as e: - result = {"valid": False, "message": str(e)} - - result - """, - %{"css_code" => file_content} - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"valid" => true} -> - {:ok, __ENV__.function, true} - - %{"valid" => false, "message" => message} -> - {:error, __ENV__.function, message} + fn content -> + case Native.extract_colors_nif(content, ParseOpts.new([])) do + {:ok, fun, pairs} -> {:ok, fun, Map.new(pairs)} + other -> other end end, type @@ -773,410 +404,170 @@ defmodule IgniterCss.Parsers.Parser do end @doc """ - Replaces an entire CSS rule for a specific selector with new declarations. - - ## Parameters - - * `css_code` - The CSS code as a string - * `selector` - The CSS selector to replace - * `new_declarations` - The new CSS declarations as a string (without curly braces) + Media queries keyed by condition, each holding a list of + `%{"selector" => ..., "properties" => %{...}}`. ## Examples - iex> IgniterCss.Parsers.CSS.Parser.replace_selector_rule(css_code, ".header", "color: blue; font-size: 20px; padding: 10px;") - "css with .header rule replaced" + iex> css = "@media print {\\n .a { display: none; }\\n}\\n" + iex> {:ok, _, queries} = IgniterCss.Parsers.Parser.extract_media_queries(css) + iex> queries + %{"print" => [%{"selector" => ".a", "properties" => %{"display" => "none"}}]} """ - def replace_selector_rule(file_path_or_content, selector, new_declarations, type \\ :content) do - # First validate the CSS using the existing validate_css function - case validate_css(file_path_or_content, type) do - {:ok, _, _} -> - # CSS is valid, proceed with replacement - call_nif_fn( - file_path_or_content, - __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.modifier import replace_selector_rule - try: - # Call the dedicated function - modified_css = replace_selector_rule(css_code, selector, new_declarations) - result = {"status": "ok", "result": modified_css} - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - result - """, - %{ - "css_code" => file_content, - "selector" => selector, - "new_declarations" => new_declarations - } - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end - end, - type - ) - - # If validation fails, return the error - {:error, _, error_message} -> - {:error, :replace_selector_rule, error_message} - end + @spec extract_media_queries(String.t(), type()) :: + {:ok, atom(), map()} | {:error, atom(), String.t()} + def extract_media_queries(file_path_or_content, type \\ :content) do + call_nif_fn( + file_path_or_content, + __ENV__.function, + fn content -> + case Native.extract_media_queries_nif(content, ParseOpts.new([])) do + {:ok, fun, queries} -> + map = + Map.new(queries, fn {query, rules} -> + {query, + Enum.map(rules, fn {selector, declarations} -> + %{"selector" => selector, "properties" => Map.new(declarations)} + end)} + end) + + {:ok, fun, map} + + other -> + other + end + end, + type + ) end @doc """ - Adds an @import rule to the CSS if it doesn't already exist. - - ## Parameters - * `file_path_or_content` - The CSS code as a string or file path - * `import_url` - The URL or path to import (without quotes) - * `media_query` - Optional media query to apply to the import (e.g., "screen and (max-width: 768px)") - or boolean false to indicate no media query - * `type` - `:content` or `:path` to specify if the first parameter is file content or a path + Animations keyed by name, each holding `"keyframes"` and `"used_by"`. ## Examples - iex> IgniterCss.Parsers.CSS.Parser.add_import(css_code, "styles.css", false) - {:ok, :add_import, "css with @import 'styles.css'; added"} - iex> IgniterCss.Parsers.CSS.Parser.add_import(css_code, "mobile.css", "screen and (max-width: 768px)") - {:ok, :add_import, "css with @import 'mobile.css' screen and (max-width: 768px); added"} + iex> css = "@keyframes fade {\\n from { opacity: 0; }\\n}\\n.a { animation: fade 1s; }\\n" + iex> {:ok, _, animations} = IgniterCss.Parsers.Parser.extract_animations(css) + iex> animations["fade"]["used_by"] + [".a"] """ - def add_import(file_path_or_content, import_url, media_query, type \\ :content) - when is_boolean(media_query) or is_binary(media_query) or is_nil(media_query) do - case validate_css(file_path_or_content, type) do - {:ok, _, _} -> - call_nif_fn( - file_path_or_content, - __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - import tinycss2 - from css_tools.parser import parse_stylesheet - - # Ensure we're working with strings - if isinstance(css_code, bytes): - css_code = css_code.decode('utf-8') - if isinstance(import_url, bytes): - import_url = import_url.decode('utf-8') - - # Handle the media query - only use it if it's a string and not a boolean - media_query_str = "" - if media_query is not None and not isinstance(media_query, bool): - if isinstance(media_query, bytes): - media_query = media_query.decode('utf-8') - media_query_str = f" {media_query}" - - # Format the import rule - if import_url.startswith(("http://", "https://", "/")): - # URLs need to be quoted - new_import = f"@import url('{import_url}'){media_query_str};" - else: - # Relative paths can be with or without quotes - new_import = f"@import '{import_url}'{media_query_str};" - - rules = parse_stylesheet(css_code) - - # Check if the import already exists - exists = False - for rule in rules: - if rule.type == "at-rule" and rule.at_keyword.lower() == "import": - if import_url in tinycss2.serialize(rule.prelude): - exists = True - break - - if exists: - # Don't add duplicate import - modified_css = css_code - else: - # Add new import at the beginning - imports must come before other rules - has_imports = any(rule.type == "at-rule" and rule.at_keyword.lower() == "import" for rule in rules) - - if has_imports: - # Add after the last import - modified_parts = [] - last_import_index = -1 - - for i, rule in enumerate(rules): - if rule.type == "at-rule" and rule.at_keyword.lower() == "import": - last_import_index = i - - # Add all rules up to the last import - for i, rule in enumerate(rules): - part = tinycss2.serialize([rule]) - # Ensure this is a string - if isinstance(part, bytes): - part = part.decode('utf-8') - modified_parts.append(part) - - if i == last_import_index: - # Add the new import after the last existing import - modified_parts.append(f"\\n{new_import}\\n") - - modified_css = "".join(modified_parts) - else: - # No existing imports, add at the beginning - # Make sure to convert any bytes to strings - if isinstance(css_code, bytes): - css_code = css_code.decode('utf-8') - modified_css = f"{new_import}\\n{css_code}" - - # Final check to ensure we return a string, not bytes - if isinstance(modified_css, bytes): - modified_css = modified_css.decode('utf-8') - - result = {"status": "ok", "result": modified_css.strip()} - result - """, - %{ - "css_code" => file_content, - "import_url" => import_url, - "media_query" => media_query - } - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end - end, - type - ) - - {:error, _, error_message} -> - {:error, :add_import, error_message} - end + @spec extract_animations(String.t(), type()) :: + {:ok, atom(), map()} | {:error, atom(), String.t()} + def extract_animations(file_path_or_content, type \\ :content) do + call_nif_fn( + file_path_or_content, + __ENV__.function, + fn content -> + case Native.extract_animations_nif(content, ParseOpts.new([])) do + {:ok, fun, animations} -> + map = + Map.new(animations, fn animation -> + {animation.name, + %{ + "keyframes" => + Map.new(animation.keyframes, fn {step, declarations} -> + {step, Map.new(declarations)} + end), + "used_by" => animation.used_by + }} + end) + + {:ok, fun, map} + + other -> + other + end + end, + type + ) end @doc """ - Removes a specific @import rule from the CSS. - - ## Parameters + Is this CSS understood well enough to patch? - * `css_code` - The CSS code as a string - * `import_url` - The URL or path to remove (matches partial URL) + Returns `{:ok, :validate_css, true}` when it is, and + `{:error, :validate_css, message}` when it is not. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.remove_import(css_code, "styles.css") - "css with @import url('styles.css') removed" - ``` + iex> IgniterCss.Parsers.Parser.validate_css(".a { color: red; }") + {:ok, :validate_css, true} """ - def remove_import(file_path_or_content, import_url, type \\ :content) do - case validate_css(file_path_or_content, type) do - {:ok, _, _} -> - call_nif_fn( - file_path_or_content, - __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - import tinycss2 - from css_tools.parser import parse_stylesheet - - if isinstance(import_url, bytes): - import_url = import_url.decode('utf-8') - - try: - rules = parse_stylesheet(css_code) - modified_css = "" - - for rule in rules: - if rule.type == "at-rule" and rule.at_keyword.lower() == "import": - # Check if this import contains the URL we want to remove - serialized = tinycss2.serialize(rule.prelude) - if import_url not in serialized: - # Keep imports that don't match - modified_css += tinycss2.serialize([rule]) - else: - # Keep all other rules - modified_css += tinycss2.serialize([rule]) - - modified_css = modified_css.strip() - result = {"status": "ok", "result": modified_css} - - except Exception as e: - # Return any errors in a structured format - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{ - "css_code" => file_content, - "import_url" => import_url - } - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => modified_css} -> - {:ok, __ENV__.function, modified_css} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} - end - end, - type - ) - - {:error, _, error_message} -> - {:error, :remove_import, error_message} - end + @spec validate_css(String.t(), type()) :: + {:ok, atom(), true} | {:error, atom(), String.t()} + def validate_css(file_path_or_content, type \\ :content) do + call_nif_fn( + file_path_or_content, + __ENV__.function, + fn content -> + case Native.validate_nif(content, ParseOpts.new([])) do + {:ok, fun, _validation} -> {:ok, fun, true} + {:error, fun, validation} -> {:error, fun, validation.message} + end + end, + type + ) end @doc """ - Checks if a specific CSS selector exists in the stylesheet. - - ## Parameters - - * `css_code` - The CSS code as a string - * `selector` - The CSS selector to check for + Does a top-level rule with this selector exist? ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.selector_exists?(css_code, ".header") - true + iex> IgniterCss.Parsers.Parser.selector_exists?(".a { color: red; }", ".a") + {:ok, :selector_exists?, true} - iex> IgniterCss.Parsers.CSS.Parser.selector_exists?(css_code, "#nonexistent") - false - ``` + iex> IgniterCss.Parsers.Parser.selector_exists?(".a { color: red; }", "#nope") + {:error, :selector_exists?, false} """ + @spec selector_exists?(String.t(), String.t(), type()) :: + {:ok, atom(), true} | {:error, atom(), false} def selector_exists?(file_path_or_content, selector, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - from css_tools.parser import parse_stylesheet, get_selector_text - - if isinstance(selector, bytes): - selector = selector.decode('utf-8') - - rules = parse_stylesheet(css_code) - exists = False - - for rule in rules: - if rule.type == "qualified-rule": - rule_selector = get_selector_text(rule) - if rule_selector == selector: - exists = True - break - - result = exists - result - """, - %{ - "css_code" => file_content, - "selector" => selector - } - ) - - parsed_result = Pythonx.decode(result) - - if parsed_result, - do: {:ok, __ENV__.function, true}, - else: {:error, __ENV__.function, false} + fn content -> + case Native.has_rule_nif(content, selector, ParseOpts.new([])) do + {:ok, fun, true} -> {:ok, fun, true} + {_status, fun, _} -> {:error, fun, false} + end end, type ) - rescue - _ -> {:error, __ENV__.function, false} end @doc """ - Gets the CSS properties for a specific selector if it exists, or returns nil. - - ## Parameters - - * `css_code` - The CSS code as a string - * `selector` - The CSS selector to check for + The declarations of a selector as a `%{property => value}` map, or `nil` when + the selector is absent. ## Examples - ```elixir - iex> IgniterCss.Parsers.CSS.Parser.get_selector_properties(css_code, ".header") - %{"color" => "blue", "font-size" => "16px"} - - iex> IgniterCss.Parsers.CSS.Parser.get_selector_properties(css_code, "#nonexistent") - nil - ``` + iex> {:ok, _, props} = IgniterCss.Parsers.Parser.get_selector_properties( + ...> ".a { color: blue; font-size: 16px; }", ".a") + iex> props + %{"color" => "blue", "font-size" => "16px"} """ - + @spec get_selector_properties(String.t(), String.t(), type()) :: + {:ok, atom(), map() | nil} | {:error, atom(), String.t()} def get_selector_properties(file_path_or_content, selector, type \\ :content) do call_nif_fn( file_path_or_content, __ENV__.function, - fn file_content -> - {result, _globals} = - Pythonx.eval( - """ - import tinycss2 - from css_tools.parser import parse_stylesheet, get_selector_text, get_rule_declarations - - try: - if isinstance(selector, bytes): - selector = selector.decode('utf-8') - - rules = parse_stylesheet(css_code) - properties = None - - for rule in rules: - if rule.type == "qualified-rule": - rule_selector = get_selector_text(rule) - if rule_selector == selector: - declarations = get_rule_declarations(rule) - properties = {} - for decl in declarations: - if decl.type == "declaration": - value = tinycss2.serialize(decl.value).strip() - properties[decl.name] = value - break - - result = {"status": "ok", "result": properties} - except Exception as e: - result = {"status": "error", "message": f"Failed to parse CSS: {str(e)}"} - - result - """, - %{ - "css_code" => file_content, - "selector" => selector - } - ) - - parsed_result = Pythonx.decode(result) - - case parsed_result do - %{"status" => "ok", "result" => properties} -> - {:ok, __ENV__.function, properties} - - %{"status" => "error", "message" => message} -> - {:error, __ENV__.function, message} + fn content -> + case Native.get_rule_declarations_nif(content, selector, ParseOpts.new([])) do + {:ok, fun, nil} -> {:ok, fun, nil} + {:ok, fun, declarations} -> {:ok, fun, Map.new(declarations)} + other -> other end end, type ) end + + # --------------------------------------------------------------------------- + + # The native layer returns a rich outcome; this convention only carries the + # patched source. + defp to_source({:ok, fun, outcome}), do: {:ok, fun, outcome.source} + defp to_source({:error, fun, reason}), do: {:error, fun, reason} end diff --git a/lib/igniter_css/structs.ex b/lib/igniter_css/structs.ex new file mode 100644 index 0000000..32d955b --- /dev/null +++ b/lib/igniter_css/structs.ex @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.ParseOpts do + @moduledoc """ + Parser options handed to the native layer. + + * `:allow_wrong_line_comments` — treat `//` as a comment. On by default: the + habit leaks in from css-in-js tooling often enough to be worth tolerating, + and it cannot lose data either way (with the flag off those bytes become + error-tolerant nodes that still carry their text). + * `:css_modules` — enable CSS Modules syntax (`:global`, `composes`). + """ + + @type t :: %__MODULE__{ + allow_wrong_line_comments: boolean(), + css_modules: boolean() + } + + defstruct allow_wrong_line_comments: true, css_modules: false + + @doc """ + Build options from a keyword list, falling back to the defaults. + + iex> IgniterCss.ParseOpts.new(css_modules: true).css_modules + true + """ + @spec new(keyword() | t()) :: t() + def new(%__MODULE__{} = opts), do: opts + + def new(opts) when is_list(opts) do + %__MODULE__{ + allow_wrong_line_comments: + Keyword.get(opts, :allow_wrong_line_comments, true) |> normalize_bool(true), + css_modules: Keyword.get(opts, :css_modules, false) |> normalize_bool(false) + } + end + + defp normalize_bool(value, _default) when is_boolean(value), do: value + defp normalize_bool(_value, default), do: default +end + +defmodule IgniterCss.Outcome do + @moduledoc """ + The result of a codemod. + + `changed?` is authoritative: it is `false` exactly when `source` is + byte-identical to the input, which is what makes every op safe to re-run in an + Igniter installer. + """ + + @type t :: %__MODULE__{ + source: String.t(), + changed: boolean(), + diagnostics: [String.t()] + } + + defstruct source: "", changed: false, diagnostics: [] +end + +defmodule IgniterCss.Analysis do + @moduledoc """ + Statistics about a stylesheet. Read-only; produced by `IgniterCss.analyze/2`. + """ + + @type t :: %__MODULE__{ + rules_count: non_neg_integer(), + top_level_rules_count: non_neg_integer(), + selectors_count: non_neg_integer(), + unique_selectors: non_neg_integer(), + declarations_count: non_neg_integer(), + unique_properties: non_neg_integer(), + at_rules_count: non_neg_integer(), + media_queries_count: non_neg_integer(), + keyframes_count: non_neg_integer(), + imports_count: non_neg_integer(), + comments_count: non_neg_integer(), + colors_count: non_neg_integer(), + important_count: non_neg_integer(), + custom_properties_count: non_neg_integer(), + property_frequency: [{String.t(), non_neg_integer()}], + selectors: [String.t()], + at_rule_names: [String.t()] + } + + defstruct rules_count: 0, + top_level_rules_count: 0, + selectors_count: 0, + unique_selectors: 0, + declarations_count: 0, + unique_properties: 0, + at_rules_count: 0, + media_queries_count: 0, + keyframes_count: 0, + imports_count: 0, + comments_count: 0, + colors_count: 0, + important_count: 0, + custom_properties_count: 0, + property_frequency: [], + selectors: [], + at_rule_names: [] +end + +defmodule IgniterCss.Validation do + @moduledoc """ + Whether a stylesheet is understood well enough to patch. + + `round_trips` is the half that matters for safety: it is what every codemod + checks before touching a file. A stylesheet that round-trips but has + diagnostics is still patchable; one that does not round-trip is not, and + `IgniterCss` will refuse rather than risk a wrong edit. + """ + + @type t :: %__MODULE__{ + valid: boolean(), + diagnostics: non_neg_integer(), + round_trips: boolean(), + message: String.t() + } + + defstruct valid: false, diagnostics: 0, round_trips: false, message: "" +end + +defmodule IgniterCss.Animation do + @moduledoc """ + A `@keyframes` animation and the selectors that use it. + """ + + @type t :: %__MODULE__{ + name: String.t(), + keyframes: [{String.t(), [{String.t(), String.t()}]}], + used_by: [String.t()] + } + + defstruct name: "", keyframes: [], used_by: [] +end diff --git a/lib/igniter_css/transform.ex b/lib/igniter_css/transform.ex new file mode 100644 index 0000000..4182be3 --- /dev/null +++ b/lib/igniter_css/transform.ex @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.Transform do + @moduledoc """ + Whole-file transforms. **These are not codemods.** + + Everything in `IgniterCss` is diff-minimal by construction. The functions here + deliberately are not: minifying and beautifying rewrite every byte, and + minifying discards comments, because that is what minifying *is*. + + Use them for build-time output and reporting. Do not use them to patch a + user's stylesheet — `IgniterCss.set_declaration/5` and friends exist for that, + and they never route their output through here. + """ + + alias IgniterCss.{Native, ParseOpts} + + @type opts :: keyword() + + @doc """ + Strip comments and collapse whitespace. + + Driven by the token stream rather than by text matching, so a `;` inside + `url(...)` or a `/*` inside a string is never mistaken for syntax. Whitespace + is kept only where removing it would change how the result tokenises, so + `@media screen and (min-width: 40em)` keeps the space after `and`. + + iex> IgniterCss.Transform.minify(".a {\\n color: red;\\n}\\n") + {:ok, ".a{color:red}"} + """ + @spec minify(String.t(), opts()) :: {:ok, String.t()} | {:error, String.t()} + def minify(source, opts \\ []) do + Native.minify_nif(source, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Re-print the stylesheet with one declaration per line and consistent + indentation, keeping every comment. + + iex> IgniterCss.Transform.beautify(".a{color:red;margin:0}") + {:ok, ".a {\\n color: red;\\n margin: 0;\\n}\\n"} + """ + @spec beautify(String.t(), opts()) :: {:ok, String.t()} | {:error, String.t()} + def beautify(source, opts \\ []) do + Native.beautify_nif(source, ParseOpts.new(opts)) |> unwrap() + end + + @doc """ + Concatenate stylesheets, then drop rules a later copy makes redundant. + + A rule is dropped only when a later one has the same selector **and** a + byte-identical body, so an intentional override survives. + + iex> IgniterCss.Transform.merge_stylesheets([".a { color: red; }", ".a { color: red; }"]) + {:ok, ".a { color: red; }\\n"} + """ + @spec merge_stylesheets([String.t()], opts()) :: {:ok, String.t()} | {:error, String.t()} + def merge_stylesheets(sources, opts \\ []) when is_list(sources) do + Native.merge_stylesheets_nif(sources, ParseOpts.new(opts)) |> unwrap() + end + + defp unwrap({:ok, _fun, value}), do: {:ok, value} + defp unwrap({:error, _fun, reason}), do: {:error, reason} +end diff --git a/mix.exs b/mix.exs index 6989253..5bdaec1 100644 --- a/mix.exs +++ b/mix.exs @@ -4,11 +4,11 @@ defmodule IgniterCss.MixProject do use Mix.Project - @version "0.1.1" + @version "0.2.0" @source_url "https://github.com/ash-project/igniter_css" @description """ - CSS codemods, powered by a Python parser integrated via NIFs + CSS codemods, powered by a high-performance Rust parser integrated via NIFs """ def project do @@ -33,10 +33,14 @@ defmodule IgniterCss.MixProject do [ files: ~w[ lib - priv + native/igniter_css/src + native/igniter_css/Cargo.* + native/igniter_css/README.md + native/igniter_css/.cargo + checksum-*.exs .formatter.exs mix.exs - LICENSE + LICENSES README* ], maintainers: [ @@ -108,9 +112,9 @@ defmodule IgniterCss.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:pythonx, "~> 0.4"}, - {:rustler, ">= 0.0.0", optional: true}, - {:igniter_js, "~> 0.4.6", optional: true}, + {:rustler, "~> 0.38.0", optional: true}, + {:rustler_precompiled, "~> 0.9"}, + {:igniter, "~> 0.5", 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}, diff --git a/mix.lock b/mix.lock index fc90225..bd706d6 100644 --- a/mix.lock +++ b/mix.lock @@ -1,28 +1,37 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, - "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, - "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, - "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.46", "67607a0532e810c6f630a515c548d0b24949643f168cc556303bee4cf96105c7", [:mix], [], "hexpm", "9c44636e8a1c68c62f526b2dcd85d941dbbcee7ab82cf64ba06ce28bef8e89f5"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, + "ex_ast": {:hex, :ex_ast, "0.13.1", "b3d80ec163733176f63662ac44d2511445c224f6b5e4e3ce01f5eff83c4a5993", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "bd15f68cde5ec945b859bd67416f26cf5499f1aef9b067eb2163ed244c7e703a"}, "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, - "igniter_js": {:hex, :igniter_js, "0.4.11", "f96999a0295cc8a00541e3f280c3fb1dd596e6d92498df53acb1f9926bb3b96c", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36.2", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "a76ec6ea2899aefb19e7c7a965ea19cf835557c330d54997c7987dad6978c6e4"}, + "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "igniter": {:hex, :igniter, "0.8.3", "9de74d3885efae43b0b58dc6f7b816963c4bbd391e6b6fe6922ee21c4e384c76", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "afc5e3848d885e680da5c3b65e5e7717555a08cd12305190ff2be76427af39ff"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "pythonx": {:hex, :pythonx, "0.4.10", "7c3377f07b15f30e51a364a92b5a456a9bbbc9a555b855e09acf551b3f36abed", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.2", [hex: :fine, repo: "hexpm", optional: false]}, {:flame, "~> 0.5", [hex: :flame, repo: "hexpm", optional: true]}], "hexpm", "7b7bb0728e4b69c362a8c0c93953ac44b10a51b21a864f2a0801155a3d8989a4"}, - "rustler": {:hex, :rustler, "0.36.2", "6c2142f912166dfd364017ab2bf61242d4a5a3c88e7b872744642ae004b82501", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "93832a6dbc1166739a19cd0c25e110e4cf891f16795deb9361dfcae95f6c88fe"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.3", "4e741024b0b097fe783add06e53ae9a6f23ddc78df1010f215df0c02915ef5a8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c23f5f33cb6608542de4d04faf0f0291458c352a4648e4d28d17ee1098cddcc4"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, + "req": {:hex, :req, "0.7.2", "364eae2e5f5c984f2dac6d71c07f8c8c89ce0bc49c4d746dacb7a306823020de", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c9cdfa276b05d8db2a27fda5d233e6858b764d47189d76cbb186e130a871ae0b"}, + "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, + "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, - "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, + "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, + "spitfire": {:hex, :spitfire, "0.3.13", "edd207b065eaec57acc5484097d0aa3e97fe4246168c54e67a9af040b8dee4c1", [:mix], [], "hexpm", "3601be88ceed4967b584e96444de3e1d12d6555ae0864a7390b9cd5332d134b4"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, } diff --git a/native/igniter_css/.cargo/config.toml b/native/igniter_css/.cargo/config.toml new file mode 100644 index 0000000..83cb4d1 --- /dev/null +++ b/native/igniter_css/.cargo/config.toml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +[target.'cfg(target_os = "macos")'] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] + +# See https://github.com/rust-lang/rust/issues/59302 +[target.x86_64-unknown-linux-musl] +rustflags = [ + "-C", "target-feature=-crt-static" +] + +[target.aarch64-unknown-linux-musl] +rustflags = [ + "-C", "target-feature=-crt-static" +] + +# Provides a small build size, but takes more time to build. +[profile.release] +lto = false diff --git a/plibs/css_tools/src/css_tools/__init__.py b/native/igniter_css/.gitignore similarity index 71% rename from plibs/css_tools/src/css_tools/__init__.py rename to native/igniter_css/.gitignore index 5d7e350..4380046 100644 --- a/plibs/css_tools/src/css_tools/__init__.py +++ b/native/igniter_css/.gitignore @@ -2,6 +2,4 @@ # # SPDX-License-Identifier: MIT -"""CSS tools for Elixir integration.""" - -__version__ = "0.1.0" +/target diff --git a/native/igniter_css/Cargo.lock b/native/igniter_css/Cargo.lock new file mode 100644 index 0000000..301e98e --- /dev/null +++ b/native/igniter_css/Cargo.lock @@ -0,0 +1,1132 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "biome_console" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678dfa9a976d1978c136ecddcdf9e9f4947aee7ef2003b4b988dfc78606bfd79" +dependencies = [ + "biome_markup", + "biome_text_size", + "schemars", + "serde", + "termcolor", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "biome_css_factory" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a71e647ee0b1d3ba57813709fcab49482ec5ddf93bfc146ed1109efdf3eced8" +dependencies = [ + "biome_css_syntax", + "biome_rowan", +] + +[[package]] +name = "biome_css_parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa0c19ecbde0fce26cf2baa201e234330e2ca9786ad19b7c0634b024a64bcb" +dependencies = [ + "biome_console", + "biome_css_factory", + "biome_css_syntax", + "biome_diagnostics", + "biome_parser", + "biome_rowan", + "biome_unicode_table", + "tracing", +] + +[[package]] +name = "biome_css_syntax" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456a8f0447f0ad2522668a3b30a89d2fb9f34837474025f59e58487df589ab96" +dependencies = [ + "biome_rowan", + "biome_string_case", + "serde", +] + +[[package]] +name = "biome_diagnostics" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef09f4f5e519a73f1a36c0ffbf0a68ac844bf1a6eb3e9ced215a3d0c5b1319e7" +dependencies = [ + "backtrace", + "biome_console", + "biome_diagnostics_categories", + "biome_diagnostics_macros", + "biome_rowan", + "biome_text_edit", + "biome_text_size", + "bpaf", + "enumflags2", + "oxc_resolver", + "serde", + "serde_ini", + "serde_json", + "termcolor", + "unicode-width", +] + +[[package]] +name = "biome_diagnostics_categories" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832080d68a2ee2f198d98ff5d26fc0f5c2566907f773d105a4a049ee07664d19" +dependencies = [ + "quote", + "serde", +] + +[[package]] +name = "biome_diagnostics_macros" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "540fec04d2e789fb992128c63d111b650733274afffff1cb3f26c8dff5167d3b" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "biome_markup" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7f11cf91599594528e97d216044ef4e410a103327212d909f215cbafe2fd9c" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", +] + +[[package]] +name = "biome_parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "811bb6840896a74483426ec61b9727731d6222caa3c09a0440c20d662cd1367e" +dependencies = [ + "biome_console", + "biome_diagnostics", + "biome_rowan", + "biome_unicode_table", + "drop_bomb", + "enumflags2", + "unicode-bom", +] + +[[package]] +name = "biome_rowan" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee4442bc598e3baead7453c4070b8b326f3b54287a583105d6ffd13c28b3ef0" +dependencies = [ + "biome_text_edit", + "biome_text_size", + "countme", + "hashbrown 0.14.5", + "rustc-hash", + "serde", + "tracing", +] + +[[package]] +name = "biome_string_case" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5868798da491b19a5b27a0bad5d8727e1e65060fa2dac360b382df00ff520774" + +[[package]] +name = "biome_text_edit" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b42189daa66f0dd20af2a4dd405c28ccf4f5a687c08a5fa4d8e1b31ce2295b" +dependencies = [ + "biome_text_size", + "serde", + "similar", +] + +[[package]] +name = "biome_text_size" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "672627531edd258f1a9ecdd9bd5ff3ea6c36768622ce2cedc12dc03cb51605c9" +dependencies = [ + "schemars", + "serde", +] + +[[package]] +name = "biome_unicode_table" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9696fda489e25051248bad5a73bdd53f8d063dc3a7f4a71d4c6aadf6fbcb18" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bpaf" +version = "0.9.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c670d65eea33846872d5ccf668c00a94c5c67dbdcc0289ea1c362671ce1ddb82" +dependencies = [ + "bpaf_derive", +] + +[[package]] +name = "bpaf_derive" +version = "0.5.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7e98cee839b19076cb3ce1afdb62bb182e04ff5f71f70188827002fae91094" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "drop_bomb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "igniter_css" +version = "0.2.0" +dependencies = [ + "biome_css_parser", + "biome_css_syntax", + "biome_rowan", + "proptest", + "rustler", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "json-strip-comments" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b271732a960335e715b6b2ae66a086f115c74eb97360e996d2bd809bfc063bba" +dependencies = [ + "memchr", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oxc_resolver" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20bb345f290c46058ba650fef7ca2b579612cf2786b927ebad7b8bec0845a7" +dependencies = [ + "cfg-if", + "dashmap", + "dunce", + "indexmap", + "json-strip-comments", + "once_cell", + "rustc-hash", + "serde", + "serde_json", + "simdutf8", + "thiserror", + "tracing", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "result" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194d8e591e405d1eecf28819740abed6d719d1a2db87fc0bcdedee9a26d55560" + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustler" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60ac8495cb6091c1f8bd0c3bb816cd4e6f0a073f396d77eb602cb7f0615212de" +dependencies = [ + "inventory", + "libc", + "libloading", + "regex-lite", + "rustler_codegen", +] + +[[package]] +name = "rustler_codegen" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780a2a6b7e3cfac35820473f85cec1cd84d1bdd37ebcb4e24fb126ef0f074838" +dependencies = [ + "heck", + "inventory", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap", + "schemars_derive", + "serde", + "serde_json", + "smallvec", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_ini" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb236687e2bb073a7521c021949be944641e671b8505a94069ca37b656c81139" +dependencies = [ + "result", + "serde", + "void", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +dependencies = [ + "bstr", + "unicode-segmentation", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plibs/css_tools/dist/css_tools-0.1.2-py3-none-any.whl.license b/native/igniter_css/Cargo.lock.license similarity index 100% rename from plibs/css_tools/dist/css_tools-0.1.2-py3-none-any.whl.license rename to native/igniter_css/Cargo.lock.license diff --git a/native/igniter_css/Cargo.toml b/native/igniter_css/Cargo.toml new file mode 100644 index 0000000..f456d9c --- /dev/null +++ b/native/igniter_css/Cargo.toml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +[package] +name = "igniter_css" +version = "0.2.0" +authors = ["Shahryar Tavakkoli"] +edition = "2021" + +[lib] +name = "igniter_css" +path = "src/lib.rs" +# `rlib` is required so the Rust test-suite (round-trip, golden, idempotency, +# property tests) can link against the crate. `cdylib` is what Rustler loads. +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. +biome_css_parser = "=0.5.8" +biome_css_syntax = "=0.5.8" +biome_rowan = "=0.5.8" +rustler = "=0.38.0" + +[dev-dependencies] +proptest = "1" + +[profile.release] +lto = false diff --git a/native/igniter_css/README.md b/native/igniter_css/README.md new file mode 100644 index 0000000..fb526a2 --- /dev/null +++ b/native/igniter_css/README.md @@ -0,0 +1,71 @@ + + +# NIF for Elixir.IgniterCss.Native + +CSS codemods over Biome's lossless CSS CST. + +## Architecture + +Parse losslessly → locate byte ranges → splice text. **The tree is never +reprinted.** That is the single most important decision in this crate: it is why +comments, indentation and property order outside an edit are preserved by +construction rather than by effort. + +``` +source (String) + → parse_css() // lossless CST, error tolerant + → locate target nodes // typed queries + → node.text_trimmed_range() // exact byte offsets + → Vec // { start, end, replacement } + → splice into the ORIGINAL source + → new source +``` + +| Module | Responsibility | +|---|---| +| `ctx` | source, parse, newline style, indent unit, BOM, brace balance | +| `locate` | typed CST queries returning byte ranges | +| `trivia` | which comments a deleted node owns (rule D) | +| `edit` | overlap-checked splicing | +| `ops/` | the codemods — diff-minimal and idempotent | +| `analyze` | read-only queries | +| `transform` | whole-file minify/beautify/merge — **not** codemods | +| `nif` | the Elixir boundary | + +`ctx` and `locate` are the only modules that name Biome types. Keeping them +contained means a Biome upgrade touches two files rather than twenty. + +## Building + +The NIF builds along with the Elixir project. To force a local build instead of +downloading a precompiled artifact: + +``` +IGNITERCSS_BUILD=1 mix compile +``` + +## Testing + +``` +cargo test # unit, corpus-invariant and property suites +cargo clippy --all-targets +cargo fmt --check +``` + +`tests/phase0_roundtrip.rs` is the gate everything else rests on: +`parse.syntax().to_string() == source` must hold byte-for-byte across the whole +fixture corpus in `test/fixtures`. If it ever fails, byte-range editing is no +longer safe and the codemods must not run. + +## Dependency pinning + +The `biome_*` crates are Biome-internal, published at 0.5.x with no API +stability guarantee, and they churn between patch releases. They are pinned with +`=` on purpose. Upgrading is a deliberate, tested activity — never a +`cargo update` — and any API call must be checked against + for the pinned version rather than written +from memory. diff --git a/native/igniter_css/src/analyze.rs b/native/igniter_css/src/analyze.rs new file mode 100644 index 0000000..9d4c8a6 --- /dev/null +++ b/native/igniter_css/src/analyze.rs @@ -0,0 +1,827 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Read-only analysis. Nothing in this module produces an edit, so none of it +//! can violate the diff-minimality constraint -- these are the queries the +//! Elixir side uses to report on a stylesheet. + +use crate::ctx::{ParseCtx, ParseOptions}; +use crate::error::Result; +use crate::locate::{ + all_comments, declaration_lists, find_all_at_rules, find_all_rules, find_top_level_rules, + DeclRef, +}; +use crate::ops::query; +use biome_css_syntax::{CssSyntaxKind, CssSyntaxNode}; +use std::collections::{BTreeMap, BTreeSet}; + +/// A block of declarations together with the text that introduced it -- a +/// selector, a keyframe step, or an at-rule prelude. +#[derive(Debug, Clone)] +pub struct Block { + pub prelude: String, + /// `@media`/`@supports`/`@container` conditions enclosing this block, + /// outermost first. + pub conditions: Vec, + pub declarations: Vec<(String, String)>, +} + +fn declaration_pairs(decls: &[DeclRef]) -> Vec<(String, String)> { + decls + .iter() + .map(|d| { + let value = if d.important { + format!("{} !important", d.value_raw.trim()) + } else { + d.value_raw.trim().to_string() + }; + (d.property.clone(), value) + }) + .collect() +} + +/// The text that introduces the block containing `list`: everything from the +/// start of the enclosing node up to its `{`. +fn prelude_of(ctx: &ParseCtx, list: &CssSyntaxNode) -> String { + let Some(block) = list.parent() else { + return String::new(); + }; + let Some(owner) = block.parent() else { + return String::new(); + }; + let start = usize::from(owner.text_trimmed_range().start()); + let brace = usize::from(block.text_trimmed_range().start()); + ctx.source() + .get(start..brace) + .unwrap_or("") + .split_whitespace() + .collect::>() + .join(" ") +} + +/// `@media`/`@supports`/`@container` preludes enclosing this node, outermost +/// first. +fn conditions_of(ctx: &ParseCtx, node: &CssSyntaxNode) -> Vec { + let mut out: Vec = 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) + .filter(|r| matches!(r.name.as_str(), "media" | "supports" | "container")) + .map(|r| format!("@{} {}", r.name, r.prelude).trim().to_string()) + }) + .collect(); + out.reverse(); + out +} + +/// Every declaration-bearing block in the file. +pub fn blocks(ctx: &ParseCtx) -> Vec { + declaration_lists(ctx) + .into_iter() + .filter(|(_, d)| !d.is_empty()) + .map(|(list, decls)| Block { + prelude: prelude_of(ctx, &list), + conditions: conditions_of(ctx, &list), + declarations: declaration_pairs(&decls), + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Colours +// --------------------------------------------------------------------------- + +const COLOR_FUNCTIONS: &[&str] = &[ + "rgb", + "rgba", + "hsl", + "hsla", + "hwb", + "lab", + "lch", + "oklab", + "oklch", + "color", + "color-mix", + "light-dark", +]; + +const NAMED_COLORS: &[&str] = &[ + "aliceblue", + "antiquewhite", + "aqua", + "aquamarine", + "azure", + "beige", + "bisque", + "black", + "blanchedalmond", + "blue", + "blueviolet", + "brown", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "cyan", + "darkblue", + "darkcyan", + "darkgoldenrod", + "darkgray", + "darkgreen", + "darkgrey", + "darkkhaki", + "darkmagenta", + "darkolivegreen", + "darkorange", + "darkorchid", + "darkred", + "darksalmon", + "darkseagreen", + "darkslateblue", + "darkslategray", + "darkslategrey", + "darkturquoise", + "darkviolet", + "deeppink", + "deepskyblue", + "dimgray", + "dimgrey", + "dodgerblue", + "firebrick", + "floralwhite", + "forestgreen", + "fuchsia", + "gainsboro", + "ghostwhite", + "gold", + "goldenrod", + "gray", + "green", + "greenyellow", + "grey", + "honeydew", + "hotpink", + "indianred", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderblush", + "lawngreen", + "lemonchiffon", + "lightblue", + "lightcoral", + "lightcyan", + "lightgoldenrodyellow", + "lightgray", + "lightgreen", + "lightgrey", + "lightpink", + "lightsalmon", + "lightseagreen", + "lightskyblue", + "lightslategray", + "lightslategrey", + "lightsteelblue", + "lightyellow", + "lime", + "limegreen", + "linen", + "magenta", + "maroon", + "mediumaquamarine", + "mediumblue", + "mediumorchid", + "mediumpurple", + "mediumseagreen", + "mediumslateblue", + "mediumspringgreen", + "mediumturquoise", + "mediumvioletred", + "midnightblue", + "mintcream", + "mistyrose", + "moccasin", + "navajowhite", + "navy", + "oldlace", + "olive", + "olivedrab", + "orange", + "orangered", + "orchid", + "palegoldenrod", + "palegreen", + "paleturquoise", + "palevioletred", + "papayawhip", + "peachpuff", + "peru", + "pink", + "plum", + "powderblue", + "purple", + "rebeccapurple", + "red", + "rosybrown", + "royalblue", + "saddlebrown", + "salmon", + "sandybrown", + "seagreen", + "seashell", + "sienna", + "silver", + "skyblue", + "slateblue", + "slategray", + "slategrey", + "snow", + "springgreen", + "steelblue", + "tan", + "teal", + "thistle", + "tomato", + "transparent", + "turquoise", + "violet", + "wheat", + "white", + "whitesmoke", + "yellow", + "yellowgreen", +]; + +fn is_hex_color(token: &str) -> bool { + let Some(rest) = token.strip_prefix('#') else { + return false; + }; + matches!(rest.len(), 3 | 4 | 6 | 8) && rest.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Blank out `url(...)` payloads and quoted strings so a path like +/// `url(/red.png)` is not read as the colour `red`. +fn strip_opaque_runs(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' | '\'' => { + let quote = c; + let mut escaped = false; + for q in chars.by_ref() { + if escaped { + escaped = false; + } else if q == '\\' { + escaped = true; + } else if q == quote { + break; + } + } + out.push(' '); + } + _ => { + out.push(c); + if out.to_lowercase().ends_with("url(") { + let mut depth = 1usize; + for q in chars.by_ref() { + match q { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + } + out.push(')'); + } + } + } + } + out +} + +/// Does this value contain a colour? +pub fn value_has_color(value: &str) -> bool { + let lower = strip_opaque_runs(value).to_lowercase(); + if lower.contains("currentcolor") { + return true; + } + for func in COLOR_FUNCTIONS { + if lower.contains(&format!("{func}(")) { + return true; + } + } + lower + .split(|c: char| !(c.is_alphanumeric() || c == '#' || c == '-')) + .any(|token| is_hex_color(token) || (!token.is_empty() && NAMED_COLORS.contains(&token))) +} + +/// Colour-carrying declarations, grouped by the selector they belong to. +pub fn extract_colors(source: &str, options: ParseOptions) -> Result)>> { + query(source, options, |ctx| { + let mut out: Vec<(String, Vec)> = Vec::new(); + for block in blocks(ctx) { + let hits: Vec = block + .declarations + .iter() + .filter(|(_, v)| value_has_color(v)) + .map(|(p, v)| format!("{p}: {v}")) + .collect(); + if hits.is_empty() { + continue; + } + match out.iter_mut().find(|(sel, _)| *sel == block.prelude) { + Some((_, list)) => list.extend(hits), + None => out.push((block.prelude.clone(), hits)), + } + } + Ok(out) + }) +} + +// --------------------------------------------------------------------------- +// Media queries +// --------------------------------------------------------------------------- + +/// One rule inside a media query. +pub type MediaRule = (String, Vec<(String, String)>); + +/// Media queries in the file, each mapped to the rules it contains. +pub fn extract_media_queries( + source: &str, + options: ParseOptions, +) -> Result)>> { + query(source, options, |ctx| { + let mut out: Vec<(String, Vec)> = Vec::new(); + for at in find_all_at_rules(ctx) { + if at.name != "media" || !at.has_block { + continue; + } + let key = at.prelude.split_whitespace().collect::>().join(" "); + let (Some(open), Some(close)) = (at.body_open, at.body_close) else { + continue; + }; + let rules: Vec = find_all_rules(ctx) + .into_iter() + .filter(|r| r.start >= open && r.end <= close) + .map(|r| { + let decls = crate::locate::declarations_in(ctx, &r); + (r.selector_raw.clone(), declaration_pairs(&decls)) + }) + .collect(); + match out.iter_mut().find(|(k, _)| *k == key) { + Some((_, list)) => list.extend(rules), + None => out.push((key, rules)), + } + } + Ok(out) + }) +} + +// --------------------------------------------------------------------------- +// Animations +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Animation { + pub name: String, + /// `("0%", [("opacity", "0")])` in source order. + pub keyframes: Vec<(String, Vec<(String, String)>)>, + /// Selectors whose `animation` / `animation-name` mentions this animation. + pub used_by: Vec, +} + +/// Does `value` reference the animation `name` as a whole token? +fn references_animation(value: &str, name: &str) -> bool { + value + .split(|c: char| c.is_whitespace() || c == ',') + .any(|t| t.trim() == name) +} + +pub fn extract_animations(source: &str, options: ParseOptions) -> Result> { + query(source, options, |ctx| { + let all_blocks = blocks(ctx); + let mut out = Vec::new(); + + for at in find_all_at_rules(ctx) { + if !at.name.ends_with("keyframes") || !at.has_block { + continue; + } + let name = at.prelude.trim().trim_matches(['"', '\'']).to_string(); + let (Some(open), Some(close)) = (at.body_open, at.body_close) else { + continue; + }; + + let keyframes: Vec<(String, Vec<(String, String)>)> = declaration_lists(ctx) + .into_iter() + .filter(|(list, _)| { + let s = usize::from(list.text_trimmed_range().start()); + s >= open && s <= close + }) + .map(|(list, decls)| (prelude_of(ctx, &list), declaration_pairs(&decls))) + .collect(); + + let used_by: Vec = all_blocks + .iter() + .filter(|b| { + b.declarations.iter().any(|(p, v)| { + matches!(p.to_lowercase().as_str(), "animation" | "animation-name") + && references_animation(v, &name) + }) + }) + .map(|b| b.prelude.clone()) + .collect(); + + out.push(Animation { + name, + keyframes, + used_by, + }); + } + Ok(out) + }) +} + +// --------------------------------------------------------------------------- +// Stylesheet statistics +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Analysis { + pub rules_count: usize, + pub top_level_rules_count: usize, + pub selectors_count: usize, + pub unique_selectors: usize, + pub declarations_count: usize, + pub unique_properties: usize, + pub at_rules_count: usize, + pub media_queries_count: usize, + pub keyframes_count: usize, + pub imports_count: usize, + pub comments_count: usize, + pub colors_count: usize, + pub important_count: usize, + pub custom_properties_count: usize, + /// Properties by descending frequency, then name. + pub property_frequency: Vec<(String, usize)>, + pub selectors: Vec, + pub at_rule_names: Vec, +} + +pub fn analyze(source: &str, options: ParseOptions) -> Result { + query(source, options, |ctx| { + let rules = find_all_rules(ctx); + let at_rules = find_all_at_rules(ctx); + + // A selector list counts once per comma-separated selector. + let mut selectors: Vec = Vec::new(); + for r in &rules { + for part in r.selector_norm.split(',') { + let part = part.trim(); + if !part.is_empty() { + selectors.push(part.to_string()); + } + } + } + let unique_selectors: BTreeSet<&String> = selectors.iter().collect(); + + let mut frequency: BTreeMap = BTreeMap::new(); + let mut declarations_count = 0usize; + let mut colors_count = 0usize; + let mut important_count = 0usize; + let mut custom_properties_count = 0usize; + + for (_, decls) in declaration_lists(ctx) { + for d in &decls { + declarations_count += 1; + *frequency + .entry(crate::locate::normalize_property(&d.property)) + .or_insert(0) += 1; + if d.important { + important_count += 1; + } + if d.property.starts_with("--") { + custom_properties_count += 1; + } + if value_has_color(&d.value_raw) { + colors_count += 1; + } + } + } + + let mut property_frequency: Vec<(String, usize)> = + frequency.iter().map(|(k, v)| (k.clone(), *v)).collect(); + property_frequency.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + + let mut at_rule_names: Vec = at_rules + .iter() + .map(|r| r.name.clone()) + .collect::>() + .into_iter() + .collect(); + at_rule_names.sort(); + + Ok(Analysis { + rules_count: rules.len(), + top_level_rules_count: find_top_level_rules(ctx).len(), + selectors_count: selectors.len(), + unique_selectors: unique_selectors.len(), + declarations_count, + unique_properties: frequency.len(), + at_rules_count: at_rules.len(), + media_queries_count: at_rules.iter().filter(|r| r.name == "media").count(), + keyframes_count: at_rules + .iter() + .filter(|r| r.name.ends_with("keyframes")) + .count(), + imports_count: at_rules.iter().filter(|r| r.name == "import").count(), + comments_count: all_comments(ctx).len(), + colors_count, + important_count, + custom_properties_count, + property_frequency, + selectors: find_top_level_rules(ctx) + .into_iter() + .map(|r| r.selector_raw) + .collect(), + at_rule_names, + }) + }) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Validation { + pub valid: bool, + pub diagnostics: usize, + pub round_trips: bool, + pub message: String, +} + +/// Is this CSS understood well enough to patch? +/// +/// "Valid" here means: the parser reproduced the input byte for byte **and** +/// 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); + let round_trips = ctx.round_trips(); + let diagnostics = ctx.diagnostics_count(); + let has_errors = ctx.has_errors(); + let valid = round_trips && !has_errors; + let message = if !round_trips { + "the parser did not reproduce the input byte for byte; refusing to patch this file" + .to_string() + } else if has_errors { + format!("CSS parsed with {diagnostics} diagnostic(s)") + } else { + "CSS is valid".to_string() + }; + Validation { + valid, + diagnostics, + round_trips, + message, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts() -> ParseOptions { + ParseOptions::default() + } + + // -- colours ------------------------------------------------------------ + + #[test] + fn recognises_colour_values() { + for v in [ + "#fff", + "#ffffff", + "#ffffffcc", + "rgb(0 0 0)", + "rgba(0,0,0,.5)", + "hsl(1 2% 3%)", + "oklch(0.99 0 0)", + "red", + "rebeccapurple", + "transparent", + "currentColor", + "color-mix(in oklab, red, blue)", + "1px solid #333", + ] { + assert!(value_has_color(v), "should be a colour: {v}"); + } + } + + #[test] + fn does_not_mistake_other_values_for_colours() { + for v in [ + "0", + "1rem", + "none", + "flex", + "var(--brand)", + "#notahex", + "url(/red.png)", + "\"redacted\"", + "translate(10px)", + ] { + assert!(!value_has_color(v), "should not be a colour: {v}"); + } + } + + #[test] + fn extracts_colours_by_selector() { + let src = ".a {\n color: #333;\n margin: 0;\n}\n.b {\n background: rgba(0,0,0,.5);\n}\n"; + let out = extract_colors(src, opts()).unwrap(); + assert_eq!( + out, + vec![ + (".a".to_string(), vec!["color: #333".to_string()]), + ( + ".b".to_string(), + vec!["background: rgba(0,0,0,.5)".to_string()] + ), + ] + ); + } + + #[test] + fn colour_extraction_reaches_into_media_blocks() { + let src = "@media print {\n .a { color: red; }\n}\n"; + let out = extract_colors(src, opts()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].0, ".a"); + } + + #[test] + fn colour_extraction_of_an_empty_sheet_is_empty() { + assert!(extract_colors("", opts()).unwrap().is_empty()); + } + + // -- media queries ------------------------------------------------------ + + #[test] + fn extracts_media_queries_with_their_rules() { + let src = "@media (max-width: 768px) {\n .a {\n font-size: 14px;\n }\n}\n"; + let out = extract_media_queries(src, opts()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].0, "(max-width: 768px)"); + assert_eq!( + out[0].1, + vec![(".a".to_string(), vec![("font-size".into(), "14px".into())])] + ); + } + + #[test] + fn merges_repeated_media_queries() { + let src = "@media print {\n .a {}\n}\n@media print {\n .b {}\n}\n"; + let out = extract_media_queries(src, opts()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].1.len(), 2); + } + + #[test] + fn a_sheet_without_media_queries_yields_nothing() { + assert!(extract_media_queries(".a {}\n", opts()).unwrap().is_empty()); + } + + // -- animations --------------------------------------------------------- + + #[test] + fn extracts_keyframes_and_their_users() { + let src = "@keyframes fade-in {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n.a {\n animation: fade-in 1s;\n}\n"; + let out = extract_animations(src, opts()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].name, "fade-in"); + assert_eq!( + out[0].keyframes, + vec![ + ("from".to_string(), vec![("opacity".into(), "0".into())]), + ("to".to_string(), vec![("opacity".into(), "1".into())]), + ] + ); + assert_eq!(out[0].used_by, vec![".a".to_string()]); + } + + #[test] + fn animation_name_matching_is_token_exact() { + let src = "@keyframes slide {\n 0% { left: 0; }\n}\n.a { animation: slide-in 1s; }\n.b { animation-name: slide; }\n"; + let out = extract_animations(src, opts()).unwrap(); + assert_eq!(out[0].used_by, vec![".b".to_string()]); + } + + #[test] + fn an_unused_animation_reports_no_users() { + let src = "@keyframes x {\n 0% { left: 0; }\n}\n"; + let out = extract_animations(src, opts()).unwrap(); + assert_eq!(out.len(), 1); + assert!(out[0].used_by.is_empty()); + } + + #[test] + fn a_sheet_without_keyframes_yields_nothing() { + assert!(extract_animations(".a {}\n", opts()).unwrap().is_empty()); + } + + // -- statistics --------------------------------------------------------- + + #[test] + fn counts_the_basics() { + let src = "/* c */\n@import \"x\";\n.a, .b {\n color: red;\n margin: 0 !important;\n}\n#c {\n --x: 1;\n}\n@media print {\n .d { color: blue; }\n}\n"; + let a = analyze(src, opts()).unwrap(); + assert_eq!(a.rules_count, 3); + assert_eq!(a.top_level_rules_count, 2); + assert_eq!(a.selectors_count, 4); + assert_eq!(a.unique_selectors, 4); + assert_eq!(a.declarations_count, 4); + // color, margin, --x -- `color` appears twice. + assert_eq!(a.unique_properties, 3); + assert_eq!(a.imports_count, 1); + assert_eq!(a.media_queries_count, 1); + assert_eq!(a.comments_count, 1); + assert_eq!(a.important_count, 1); + assert_eq!(a.custom_properties_count, 1); + assert_eq!(a.colors_count, 2); + } + + #[test] + fn counts_repeated_selectors_once_as_unique() { + let a = analyze(".a {}\n.a {}\n", opts()).unwrap(); + assert_eq!(a.selectors_count, 2); + assert_eq!(a.unique_selectors, 1); + } + + #[test] + fn ranks_properties_by_frequency() { + let src = ".a { color: red; }\n.b { color: blue; margin: 0; }\n"; + let a = analyze(src, opts()).unwrap(); + assert_eq!( + a.property_frequency, + vec![("color".to_string(), 2), ("margin".to_string(), 1)] + ); + } + + #[test] + fn analyses_an_empty_sheet() { + let a = analyze("", opts()).unwrap(); + assert_eq!(a, Analysis::default()); + } + + #[test] + fn lists_at_rule_names() { + let src = "@import \"a\";\n@plugin \"b\";\n@import \"c\";\n"; + let a = analyze(src, opts()).unwrap(); + assert_eq!(a.at_rule_names, vec!["import", "plugin"]); + assert_eq!(a.at_rules_count, 3); + } + + // -- validation --------------------------------------------------------- + + #[test] + fn valid_css_validates() { + let v = validate(".a { color: red; }\n", opts()); + assert!(v.valid); + assert!(v.round_trips); + assert_eq!(v.diagnostics, 0); + } + + #[test] + fn malformed_css_is_reported_but_still_round_trips() { + let v = validate(".a { color: red;\n", opts()); + assert!(!v.valid); + assert!(v.round_trips, "error tolerance must keep the round-trip"); + assert!(v.diagnostics > 0); + } + + #[test] + fn an_empty_sheet_is_valid() { + assert!(validate("", opts()).valid); + } + + #[test] + fn tailwind_v4_validates() { + let src = "@import \"tailwindcss\";\n@theme {\n --color-x: red;\n}\n@utility tab-4 {\n tab-size: 4;\n}\n"; + assert!(validate(src, opts()).valid); + } +} diff --git a/native/igniter_css/src/atoms.rs b/native/igniter_css/src/atoms.rs new file mode 100644 index 0000000..adb9c5f --- /dev/null +++ b/native/igniter_css/src/atoms.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +rustler::atoms! { + // Status atoms + ok, + error, + + // One atom per NIF, so `IgniterCss.Helpers.normalize_output/2` can label + // the result with the operation that produced it. + ensure_at_rule_nif, + remove_at_rule_nif, + has_at_rule_nif, + add_import_nif, + remove_import_nif, + + ensure_rule_nif, + remove_rule_nif, + replace_rule_body_nif, + append_raw_to_rule_nif, + has_rule_nif, + list_selectors_nif, + + set_declaration_nif, + remove_declaration_nif, + get_declaration_nif, + has_declaration_nif, + get_rule_declarations_nif, + add_vendor_prefixes_nif, + + sort_properties_nif, + remove_duplicates_nif, + + analyze_nif, + validate_nif, + extract_colors_nif, + extract_media_queries_nif, + extract_animations_nif, + + minify_nif, + beautify_nif, + merge_stylesheets_nif, +} diff --git a/native/igniter_css/src/ctx.rs b/native/igniter_css/src/ctx.rs new file mode 100644 index 0000000..de14d77 --- /dev/null +++ b/native/igniter_css/src/ctx.rs @@ -0,0 +1,462 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! `ParseCtx` owns the source string and its lossless parse, plus the handful +//! of formatting facts every codemod needs so that inserted text looks like the +//! text the user already wrote (ROADMAP Phase 1, and rules B/C in §8). +//! +//! This module and `locate` are the only two places allowed to name Biome +//! types. Isolating them here is the mitigation for R2 (Biome API churn). + +use biome_css_parser::{parse_css, CssParse, CssParserOptions}; +use biome_css_syntax::{CssSyntaxKind, CssSyntaxNode}; +use biome_rowan::TextRange; + +pub const BOM: &str = "\u{feff}"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Newline { + Lf, + CrLf, +} + +impl Newline { + pub fn as_str(self) -> &'static str { + match self { + Self::Lf => "\n", + Self::CrLf => "\r\n", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseOptions { + /// Treat `//` as a comment. Off by default, matching Biome. + pub allow_wrong_line_comments: bool, + pub css_modules: bool, +} + +impl Default for ParseOptions { + fn default() -> Self { + Self { + // css-in-js habits leak `//` into real .css files often enough that + // tolerating them by default is the friendlier behaviour, and it + // cannot lose data: with the flag off those bytes become bogus + // nodes, with it on they become comments. Either way they survive. + allow_wrong_line_comments: true, + css_modules: false, + } + } +} + +impl ParseOptions { + pub fn strict() -> Self { + Self { + allow_wrong_line_comments: false, + css_modules: false, + } + } + + fn to_biome(self) -> CssParserOptions { + let mut o = CssParserOptions::default(); + if self.allow_wrong_line_comments { + o = o.allow_wrong_line_comments(); + } + if self.css_modules { + o = o.allow_css_modules(); + } + o + } +} + +pub struct ParseCtx { + source: String, + parse: CssParse, + newline: Newline, + indent: String, + has_final_newline: bool, + has_bom: bool, +} + +impl ParseCtx { + /// Note on the BOM: Biome lexes a leading U+FEFF as part of the first + /// identifier, which turns `.a` into a type selector named "\u{feff}" and + /// would silently break every selector match on a BOM'd file. So we strip + /// it here, work on BOM-less text throughout, and re-attach it in + /// `restore_bom` on the way out. Every offset in this crate is therefore an + /// offset into the BOM-less source. + pub fn new(source: impl Into, options: ParseOptions) -> Self { + let raw = source.into(); + let has_bom = raw.starts_with(BOM); + let source = if has_bom { + raw[BOM.len()..].to_string() + } else { + raw + }; + let parse = parse_css(&source, options.to_biome()); + let newline = detect_newline(&source); + let indent = detect_indent(&source); + let has_final_newline = source.ends_with('\n'); + Self { + source, + parse, + newline, + indent, + has_final_newline, + has_bom, + } + } + + pub fn parse_default(source: impl Into) -> Self { + Self::new(source, ParseOptions::default()) + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn syntax(&self) -> CssSyntaxNode { + self.parse.syntax() + } + + pub fn diagnostics_count(&self) -> usize { + self.parse.diagnostics().len() + } + + pub fn has_errors(&self) -> bool { + self.parse.has_errors() + } + + /// The parse is lossless by construction, but assert it before we let any + /// codemod compute offsets against it. If this ever fails, the byte-range + /// design's foundation is gone and we must refuse to patch (hard constraint + /// #4: never destroy input). + pub fn round_trips(&self) -> bool { + self.parse.syntax().to_string() == self.source + } + + pub fn newline(&self) -> Newline { + self.newline + } + + pub fn nl(&self) -> &'static str { + self.newline.as_str() + } + + /// The file's indent unit -- one level, e.g. `" "` or `"\t"`. + pub fn indent(&self) -> &str { + &self.indent + } + + pub fn has_final_newline(&self) -> bool { + self.has_final_newline + } + + pub fn has_bom(&self) -> bool { + self.has_bom + } + + /// Put the BOM back on a result produced from `self.source()`. + pub fn restore_bom(&self, out: String) -> String { + if self.has_bom { + let mut s = String::with_capacity(BOM.len() + out.len()); + s.push_str(BOM); + s.push_str(&out); + s + } else { + out + } + } + + /// Are `{` and `}` balanced across the whole file? + /// + /// Checked against the token stream, so braces inside strings and comments + /// do not count. An unbalanced file cannot be patched safely: text inserted + /// "at the top level" would land inside somebody's unterminated block, and + /// hard constraint #4 says a wrong patch is far worse than no patch. + pub fn braces_are_balanced(&self) -> bool { + let mut depth = 0i32; + for token in self + .parse + .syntax() + .descendants_tokens(biome_rowan::Direction::Next) + { + match token.kind() { + CssSyntaxKind::L_CURLY => depth += 1, + CssSyntaxKind::R_CURLY => { + depth -= 1; + if depth < 0 { + return false; + } + } + _ => {} + } + } + depth == 0 + } + + pub fn text(&self, range: TextRange) -> &str { + let start = usize::from(range.start()); + let end = usize::from(range.end()); + self.source.get(start..end).unwrap_or("") + } + + /// Byte offset of the start of the line containing `offset`. + pub fn line_start(&self, offset: usize) -> usize { + self.source[..offset] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0) + } + + /// Byte offset just past the newline terminating the line containing + /// `offset`, or EOF. + pub fn line_end_inclusive(&self, offset: usize) -> usize { + match self.source[offset..].find('\n') { + Some(i) => offset + i + 1, + None => self.source.len(), + } + } + + /// Leading whitespace of the line containing `offset` -- the indentation to + /// copy when inserting a sibling next to it (rule B). + pub fn indent_at(&self, offset: usize) -> &str { + let start = self.line_start(offset); + let line = &self.source[start..]; + let ws = line + .find(|c: char| c != ' ' && c != '\t') + .unwrap_or(line.len()); + &line[..ws] + } + + /// Everything between the start of the line and `offset` is whitespace. + pub fn is_at_line_start(&self, offset: usize) -> bool { + self.source[self.line_start(offset)..offset] + .chars() + .all(|c| c == ' ' || c == '\t') + } + + /// Everything between `offset` and the end of the line is whitespace. + pub fn is_at_line_end(&self, offset: usize) -> bool { + let rest = &self.source[offset..]; + let upto = rest.find('\n').unwrap_or(rest.len()); + rest[..upto] + .chars() + .all(|c| c == ' ' || c == '\t' || c == '\r') + } +} + +fn detect_newline(source: &str) -> Newline { + match (source.find("\r\n"), source.find('\n')) { + // The first newline in the file wins. `find("\r\n")` points at the CR, + // so an equal-or-earlier position means the first LF is part of a CRLF. + (Some(crlf), Some(lf)) if crlf + 1 == lf => Newline::CrLf, + _ => Newline::Lf, + } +} + +/// Infer one indent level from the file's own declarations. +/// +/// We look at the leading whitespace of lines that sit inside a block and take +/// the most common non-empty prefix. Tabs win outright if any indented line +/// uses them, since mixing is worse than guessing the width wrong. +fn detect_indent(source: &str) -> String { + let mut saw_tab = false; + let mut widths: Vec = Vec::new(); + + for line in source.lines() { + let trimmed = line.trim_start_matches([' ', '\t']); + if trimmed.is_empty() { + continue; + } + let ws = &line[..line.len() - trimmed.len()]; + if ws.is_empty() { + continue; + } + if ws.contains('\t') { + saw_tab = true; + } else { + widths.push(ws.len()); + } + } + + if saw_tab { + return "\t".to_string(); + } + + // The smallest indentation width present is one level. + match widths.iter().copied().min() { + Some(n) if n > 0 => " ".repeat(n), + _ => " ".to_string(), + } +} + +/// True for the kinds that make up a comment trivia piece. +pub fn is_comment_kind(kind: biome_rowan::TriviaPieceKind) -> bool { + matches!( + kind, + biome_rowan::TriviaPieceKind::SingleLineComment + | biome_rowan::TriviaPieceKind::MultiLineComment + ) +} + +/// Kinds that represent a rule Biome could not understand. They still carry +/// their original source text, which is exactly why we can patch around them. +pub fn is_bogus(kind: CssSyntaxKind) -> bool { + matches!( + kind, + CssSyntaxKind::CSS_BOGUS + | CssSyntaxKind::CSS_BOGUS_RULE + | CssSyntaxKind::CSS_BOGUS_AT_RULE + | CssSyntaxKind::CSS_BOGUS_BLOCK + | CssSyntaxKind::CSS_BOGUS_DECLARATION_ITEM + | CssSyntaxKind::CSS_BOGUS_PROPERTY + | CssSyntaxKind::CSS_BOGUS_PROPERTY_VALUE + | CssSyntaxKind::CSS_BOGUS_SELECTOR + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_lf() { + assert_eq!(detect_newline(".a {\n color: red;\n}\n"), Newline::Lf); + } + + #[test] + fn detects_crlf() { + assert_eq!( + detect_newline(".a {\r\n color: red;\r\n}\r\n"), + Newline::CrLf + ); + } + + #[test] + fn defaults_to_lf_when_there_are_no_newlines() { + assert_eq!(detect_newline(".a{color:red}"), Newline::Lf); + } + + #[test] + fn a_lone_cr_later_in_the_file_does_not_make_it_crlf() { + assert_eq!(detect_newline(".a {\n content: \"\r\n\";\n}"), Newline::Lf); + } + + #[test] + fn detects_two_space_indent() { + assert_eq!(detect_indent(".a {\n color: red;\n}\n"), " "); + } + + #[test] + fn detects_four_space_indent() { + assert_eq!(detect_indent(".a {\n color: red;\n}\n"), " "); + } + + #[test] + fn detects_tab_indent() { + assert_eq!(detect_indent(".a {\n\tcolor: red;\n}\n"), "\t"); + } + + #[test] + fn falls_back_to_two_spaces_when_nothing_is_indented() { + assert_eq!(detect_indent(".a{color:red}"), " "); + assert_eq!(detect_indent(""), " "); + } + + #[test] + fn nested_indentation_still_reports_one_level() { + let src = "@media (min-width: 1px) {\n .a {\n color: red;\n }\n}\n"; + assert_eq!(detect_indent(src), " "); + } + + #[test] + fn balanced_braces_are_recognised() { + assert!(ParseCtx::parse_default(".a { color: red; }\n").braces_are_balanced()); + assert!(ParseCtx::parse_default("").braces_are_balanced()); + assert!(ParseCtx::parse_default("@media print { .a { b: c; } }").braces_are_balanced()); + } + + #[test] + fn unbalanced_braces_are_rejected() { + assert!(!ParseCtx::parse_default(".a {\n color: red;\n").braces_are_balanced()); + assert!(!ParseCtx::parse_default(".a {}\n}\n").braces_are_balanced()); + assert!(!ParseCtx::parse_default("}").braces_are_balanced()); + } + + #[test] + fn braces_inside_strings_and_comments_do_not_count() { + assert!(ParseCtx::parse_default(".a::after { content: \"{\"; }").braces_are_balanced()); + assert!(ParseCtx::parse_default("/* { */ .a { b: c; }").braces_are_balanced()); + } + + #[test] + fn ctx_reports_file_shape() { + let ctx = ParseCtx::parse_default(".a {\n color: red;\n}\n"); + assert_eq!(ctx.nl(), "\n"); + assert_eq!(ctx.indent(), " "); + assert!(ctx.has_final_newline()); + assert!(!ctx.has_bom()); + assert!(ctx.round_trips()); + } + + #[test] + fn ctx_detects_missing_final_newline_and_bom() { + let ctx = ParseCtx::parse_default("\u{feff}.a { color: red; }"); + assert!(!ctx.has_final_newline()); + assert!(ctx.has_bom()); + assert!(ctx.round_trips()); + } + + #[test] + fn the_bom_is_stripped_from_the_parsed_source_and_restored_on_output() { + let ctx = ParseCtx::parse_default("\u{feff}.a { color: red; }\n"); + assert_eq!(ctx.source(), ".a { color: red; }\n"); + assert_eq!( + ctx.restore_bom(ctx.source().to_string()), + "\u{feff}.a { color: red; }\n" + ); + } + + #[test] + fn restore_bom_is_a_no_op_without_one() { + let ctx = ParseCtx::parse_default(".a { color: red; }\n"); + assert_eq!(ctx.restore_bom("x".into()), "x"); + } + + #[test] + fn indent_at_returns_the_line_prefix() { + let src = ".a {\n color: red;\n}\n"; + let ctx = ParseCtx::parse_default(src); + let at = src.find("color").unwrap(); + assert_eq!(ctx.indent_at(at), " "); + assert!(ctx.is_at_line_start(at - 4)); + } + + #[test] + fn line_helpers_agree_on_boundaries() { + let src = "ab\ncd\n"; + let ctx = ParseCtx::parse_default(src); + assert_eq!(ctx.line_start(4), 3); + assert_eq!(ctx.line_end_inclusive(3), 6); + assert_eq!(ctx.line_end_inclusive(0), 3); + } + + #[test] + fn line_comments_are_tolerated_by_default() { + let ctx = ParseCtx::parse_default("// hi\n.a { color: red; }\n"); + assert!(ctx.round_trips()); + assert!( + !ctx.has_errors(), + "`//` should parse as a comment by default" + ); + } + + #[test] + fn strict_mode_rejects_line_comments_but_still_round_trips() { + let ctx = ParseCtx::new("// hi\n.a { color: red; }\n", ParseOptions::strict()); + assert!(ctx.round_trips()); + assert!(ctx.has_errors()); + } +} diff --git a/native/igniter_css/src/edit.rs b/native/igniter_css/src/edit.rs new file mode 100644 index 0000000..9407fe0 --- /dev/null +++ b/native/igniter_css/src/edit.rs @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! The edit engine (ROADMAP §6, Phase 1). +//! +//! Every codemod in this crate produces `Vec` -- byte ranges into the +//! *original* source plus replacement text -- and never reprints the tree. +//! Anything outside an edit range is untouched by definition, which is why +//! comment preservation here is a property that cannot fail rather than one we +//! have to keep verifying. + +use crate::error::{CssError, Result}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edit { + /// Byte offset into the original source. + pub start: usize, + /// Exclusive byte offset into the original source. + pub end: usize, + pub replacement: String, +} + +impl Edit { + pub fn replace(start: usize, end: usize, replacement: impl Into) -> Self { + Self { + start, + end, + replacement: replacement.into(), + } + } + + pub fn insert(at: usize, text: impl Into) -> Self { + Self { + start: at, + end: at, + replacement: text.into(), + } + } + + pub fn delete(start: usize, end: usize) -> Self { + Self { + start, + end, + replacement: String::new(), + } + } + + /// A pure insertion touches no existing bytes. + pub fn is_insertion(&self) -> bool { + self.start == self.end + } +} + +/// Splice `edits` into `source`. +/// +/// * Overlapping ranges are a hard error -- we never silently merge them. +/// * Edits are applied back-to-front so earlier offsets stay valid. +/// * `apply_edits(src, vec![])` returns `src` byte for byte (Phase 1 acceptance). +pub fn apply_edits(source: &str, mut edits: Vec) -> Result { + if edits.is_empty() { + return Ok(source.to_string()); + } + + let len = source.len(); + for e in &edits { + if e.start > e.end + || e.end > len + || !source.is_char_boundary(e.start) + || !source.is_char_boundary(e.end) + { + return Err(CssError::BadRange { + start: e.start, + end: e.end, + len, + }); + } + } + + // Sort ascending first so overlap detection only has to compare neighbours. + // Ties are broken by `end` so that a pure insertion at offset N sorts before + // a replacement starting at N, which is the only way two edits may share a + // boundary offset. + edits.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end))); + + for pair in edits.windows(2) { + let (a, b) = (&pair[0], &pair[1]); + // Touching ranges (a.end == b.start) are fine. Two insertions at the + // exact same offset are ambiguous in ordering, so we reject them too. + let overlaps = + b.start < a.end || (a.is_insertion() && b.is_insertion() && a.start == b.start); + if overlaps { + return Err(CssError::OverlappingEdits { + first: (a.start, a.end), + second: (b.start, b.end), + }); + } + } + + let grown: usize = edits.iter().map(|e| e.replacement.len()).sum(); + let mut out = String::with_capacity(len + grown); + let mut cursor = 0usize; + for e in &edits { + out.push_str(&source[cursor..e.start]); + out.push_str(&e.replacement); + cursor = e.end; + } + out.push_str(&source[cursor..]); + Ok(out) +} + +/// Drop edits that would not change anything, so a codemod can report +/// `changed: false` honestly (ROADMAP §8 rule A). +pub fn prune_noop_edits(source: &str, edits: Vec) -> Vec { + edits + .into_iter() + .filter(|e| { + if e.start > source.len() || e.end > source.len() { + return true; // let apply_edits report the real error + } + source.get(e.start..e.end) != Some(e.replacement.as_str()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_edit_list_returns_source_byte_for_byte() { + let src = "/* hi */\n.a { color: red; }\n"; + assert_eq!(apply_edits(src, vec![]).unwrap(), src); + } + + #[test] + fn single_replacement() { + let src = ".a { color: red; }"; + let out = apply_edits(src, vec![Edit::replace(12, 15, "blue")]).unwrap(); + assert_eq!(out, ".a { color: blue; }"); + } + + #[test] + fn multiple_edits_apply_back_to_front() { + let src = "abcdef"; + let out = apply_edits( + src, + vec![Edit::replace(0, 1, "X"), Edit::replace(4, 6, "YZ!")], + ) + .unwrap(); + assert_eq!(out, "XbcdYZ!"); + } + + #[test] + fn insertion_at_offset() { + let src = "ac"; + let out = apply_edits(src, vec![Edit::insert(1, "b")]).unwrap(); + assert_eq!(out, "abc"); + } + + #[test] + fn touching_ranges_are_allowed() { + let src = "abcdef"; + let out = apply_edits( + src, + vec![Edit::replace(0, 3, "X"), Edit::replace(3, 6, "Y")], + ) + .unwrap(); + assert_eq!(out, "XY"); + } + + #[test] + fn overlapping_ranges_are_rejected() { + let src = "abcdef"; + let err = apply_edits( + src, + vec![Edit::replace(0, 4, "X"), Edit::replace(2, 6, "Y")], + ) + .unwrap_err(); + assert!(matches!(err, CssError::OverlappingEdits { .. })); + } + + #[test] + fn two_insertions_at_the_same_offset_are_rejected() { + let err = apply_edits("ab", vec![Edit::insert(1, "X"), Edit::insert(1, "Y")]).unwrap_err(); + assert!(matches!(err, CssError::OverlappingEdits { .. })); + } + + #[test] + fn insertion_at_the_start_of_a_replacement_is_allowed() { + let out = apply_edits("abc", vec![Edit::insert(1, "-"), Edit::replace(1, 2, "B")]).unwrap(); + assert_eq!(out, "a-Bc"); + } + + #[test] + fn out_of_bounds_range_is_rejected() { + let err = apply_edits("abc", vec![Edit::replace(0, 99, "X")]).unwrap_err(); + assert!(matches!(err, CssError::BadRange { .. })); + } + + #[test] + fn inverted_range_is_rejected() { + let err = apply_edits("abcdef", vec![Edit::replace(4, 2, "X")]).unwrap_err(); + assert!(matches!(err, CssError::BadRange { .. })); + } + + #[test] + fn non_char_boundary_is_rejected() { + // "é" is two bytes; offset 1 splits it. + let err = apply_edits("é", vec![Edit::replace(0, 1, "e")]).unwrap_err(); + assert!(matches!(err, CssError::BadRange { .. })); + } + + #[test] + fn multibyte_content_is_spliced_correctly() { + let src = ".a::after { content: \"日本語\"; }"; + let start = src.find('日').unwrap(); + let end = start + "日本語".len(); + let out = apply_edits(src, vec![Edit::replace(start, end, "中文")]).unwrap(); + assert_eq!(out, ".a::after { content: \"中文\"; }"); + } + + #[test] + fn prune_drops_edits_that_write_back_identical_text() { + let src = ".a { color: red; }"; + let pruned = prune_noop_edits(src, vec![Edit::replace(12, 15, "red")]); + assert!(pruned.is_empty()); + } + + #[test] + fn prune_keeps_real_edits() { + let src = ".a { color: red; }"; + let pruned = prune_noop_edits(src, vec![Edit::replace(12, 15, "blue")]); + assert_eq!(pruned.len(), 1); + } +} diff --git a/native/igniter_css/src/error.rs b/native/igniter_css/src/error.rs new file mode 100644 index 0000000..0194a12 --- /dev/null +++ b/native/igniter_css/src/error.rs @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +use std::fmt; + +/// Every fallible path in this crate returns `CssError`. Nothing reachable from +/// a NIF call is allowed to panic -- a panic takes down the BEAM scheduler. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CssError { + /// Two edits produced by one operation cover overlapping byte ranges. This + /// is always an internal bug; we hard-error rather than silently merging. + OverlappingEdits { + first: (usize, usize), + second: (usize, usize), + }, + /// An edit range fell outside the source, or did not land on a UTF-8 + /// character boundary. + BadRange { + start: usize, + end: usize, + len: usize, + }, + /// More than one top-level rule matched the selector. ROADMAP §2 rule 4 and + /// §11 R4: error, never guess. + AmbiguousSelector { selector: String, count: usize }, + /// The caller asked to operate on something that isn't there. + NotFound(String), + /// The source could not be understood well enough to patch safely. + Unparseable(String), + /// Caller-supplied text (a selector, a raw block, an at-rule line) is not + /// something we are willing to splice into a file. + InvalidInput(String), +} + +impl fmt::Display for CssError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OverlappingEdits { first, second } => write!( + f, + "internal error: overlapping edits {}..{} and {}..{}", + first.0, first.1, second.0, second.1 + ), + Self::BadRange { start, end, len } => write!( + f, + "internal error: edit range {start}..{end} is not a valid char boundary in a {len}-byte source" + ), + Self::AmbiguousSelector { selector, count } => write!( + f, + "selector {selector:?} matches {count} top-level rules; refusing to guess which one to patch" + ), + Self::NotFound(what) => write!(f, "not found: {what}"), + Self::Unparseable(why) => write!(f, "cannot safely patch this file: {why}"), + Self::InvalidInput(why) => write!(f, "invalid input: {why}"), + } + } +} + +impl std::error::Error for CssError {} + +pub type Result = std::result::Result; diff --git a/native/igniter_css/src/helpers.rs b/native/igniter_css/src/helpers.rs new file mode 100644 index 0000000..f8fa1cd --- /dev/null +++ b/native/igniter_css/src/helpers.rs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Encoding helpers for the NIF boundary, matching the `{status, source, +//! payload}` shape `igniter_js` uses so both libraries feel the same from +//! Elixir. + +use rustler::{Encoder, Env, NifResult, Term}; + +pub fn encode_response( + env: Env<'_>, + status: rustler::types::atom::Atom, + source: rustler::types::atom::Atom, + message: T, +) -> NifResult> +where + T: Encoder, +{ + Ok((status, source, message).encode(env)) +} diff --git a/native/igniter_css/src/lib.rs b/native/igniter_css/src/lib.rs new file mode 100644 index 0000000..6b639ad --- /dev/null +++ b/native/igniter_css/src/lib.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! CSS codemods for Igniter, built on Biome's lossless CSS CST. +//! +//! The architecture in one line: **parse losslessly, locate byte ranges, splice +//! text**. Nothing here ever reprints the tree, which is why comments, +//! indentation and property order outside an edit are preserved by construction +//! rather than by effort. +//! +//! * [`ctx`] -- the source, its parse, and the file's own formatting habits +//! * [`locate`] -- typed CST queries that return byte ranges +//! * [`trivia`] -- which comments a deleted node owns (rule D) +//! * [`edit`] -- overlap-checked text splicing +//! * [`ops`] -- the codemods, all diff-minimal and idempotent +//! * [`analyze`] -- read-only queries +//! * [`transform`] -- whole-file minify/beautify/merge, explicitly *not* codemods +//! * [`nif`] -- the Elixir boundary + +pub mod analyze; +pub mod atoms; +pub mod ctx; +pub mod edit; +pub mod error; +pub mod helpers; +pub mod locate; +pub mod nif; +pub mod ops; +pub mod transform; +pub mod trivia; diff --git a/native/igniter_css/src/locate.rs b/native/igniter_css/src/locate.rs new file mode 100644 index 0000000..12a28cc --- /dev/null +++ b/native/igniter_css/src/locate.rs @@ -0,0 +1,1054 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Phase 2: typed queries over the CST that return **byte ranges**, never owned +//! strings to be reprinted. +//! +//! Matching rules for v1 are deliberately strict (ROADMAP §8 Phase 2): +//! * top-level rules only, unless the caller explicitly opts into descending; +//! * selectors compared on a normalised form, never raw equality and never +//! substring/fuzzy; +//! * more than one match is `MatchResult::Ambiguous` -- we error, we do not +//! pick one. Guessing here is how the Python version produced surprising +//! diffs (R4). + +use crate::ctx::{is_bogus, ParseCtx}; +use biome_css_syntax::{CssSyntaxKind, CssSyntaxNode}; +use biome_rowan::{Direction, TextRange}; + +// --------------------------------------------------------------------------- +// References +// --------------------------------------------------------------------------- + +/// A top-level (or scoped) qualified rule: `selector { ... }`. +#[derive(Debug, Clone)] +pub struct RuleRef { + pub node: CssSyntaxNode, + /// Selector text exactly as the user wrote it. + pub selector_raw: String, + /// Selector text after `normalize_selector`, used for comparisons. + pub selector_norm: String, + /// Start of the rule's own bytes (leading trivia excluded). + pub start: usize, + /// End of the rule's own bytes (trailing trivia excluded). + pub end: usize, + /// Byte offset just after the opening `{`. + pub body_open: usize, + /// Byte offset of the closing `}`. + pub body_close: usize, +} + +impl RuleRef { + /// True when the body contains no declarations or nested rules. + pub fn body_is_empty(&self, ctx: &ParseCtx) -> bool { + ctx.source()[self.body_open..self.body_close] + .trim() + .is_empty() + } +} + +/// An at-rule: `@name prelude;` or `@name prelude { ... }`. +#[derive(Debug, Clone)] +pub struct AtRuleRef { + pub node: CssSyntaxNode, + /// Lowercased at-rule name without the `@`, e.g. `import`, `plugin`, `media`. + pub name: String, + /// Everything between the name and the `;` or `{`, trimmed. + pub prelude: String, + pub start: usize, + pub end: usize, + pub has_block: bool, + /// Present only when `has_block`. + pub body_open: Option, + pub body_close: Option, +} + +/// A declaration: `property: value;`. +#[derive(Debug, Clone)] +pub struct DeclRef { + /// The `CSS_DECLARATION_WITH_SEMICOLON` (or bare `CSS_DECLARATION`) node. + pub node: CssSyntaxNode, + pub property: String, + pub value_raw: String, + pub important: bool, + /// Start of the declaration's own bytes. + pub start: usize, + /// End of the declaration's own bytes, semicolon included when present. + pub end: usize, + /// Range of the value alone -- the only bytes `set_declaration` replaces + /// when the property already exists (rule E). + pub value_start: usize, + pub value_end: usize, + /// Range of the `!important` flag, when present. + pub important_range: Option<(usize, usize)>, +} + +#[derive(Debug, Clone)] +pub enum MatchResult { + None, + One(Box), + /// More than one top-level rule matched. Callers must error out. + Ambiguous(Vec), +} + +impl MatchResult { + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + pub fn one(self) -> Option { + match self { + Self::One(r) => Some(*r), + _ => None, + } + } + + pub fn count(&self) -> usize { + match self { + Self::None => 0, + Self::One(_) => 1, + Self::Ambiguous(v) => v.len(), + } + } +} + +// --------------------------------------------------------------------------- +// Small syntax helpers (the only place that knows Biome's shape) +// --------------------------------------------------------------------------- + +fn range_to_pair(range: TextRange) -> (usize, usize) { + (usize::from(range.start()), usize::from(range.end())) +} + +/// Trimmed byte range of a node -- its own text, without leading or trailing +/// trivia. `text_range()` would include the comment sitting above it. +fn trimmed(node: &CssSyntaxNode) -> (usize, usize) { + range_to_pair(node.text_trimmed_range()) +} + +/// A child node that opens with `{`. Kind-agnostic on purpose: CSS has a dozen +/// block kinds and new ones appear between Biome releases (R2). +fn block_child(node: &CssSyntaxNode) -> Option { + node.children().find(|c| { + c.first_token() + .is_some_and(|t| t.kind() == CssSyntaxKind::L_CURLY) + }) +} + +fn block_bounds(block: &CssSyntaxNode) -> Option<(usize, usize)> { + let open = block + .children_with_tokens() + .filter_map(|e| e.into_token()) + .find(|t| t.kind() == CssSyntaxKind::L_CURLY)?; + let close = block + .children_with_tokens() + .filter_map(|e| e.into_token()) + .filter(|t| t.kind() == CssSyntaxKind::R_CURLY) + .last(); + // Trimmed, so the body starts immediately after `{` rather than after the + // brace's trailing whitespace. Callers rebuilding a body need that space to + // be part of the range they replace. + let open_end = usize::from(open.text_trimmed_range().end()); + let close_start = match close { + // `text_trimmed_range().start()` skips the newline+indent trivia that + // precedes the closing brace, which is what we want as the body end. + Some(t) => usize::from(t.text_trimmed_range().start()), + // Unbalanced input: the block runs to the end of its own text. + None => usize::from(block.text_trimmed_range().end()), + }; + Some((open_end, close_start.max(open_end))) +} + +/// The list node inside a block that holds declarations and nested rules. +fn block_items(block: &CssSyntaxNode) -> Vec { + block + .children() + .filter(|c| { + matches!( + c.kind(), + CssSyntaxKind::CSS_DECLARATION_OR_RULE_LIST + | CssSyntaxKind::CSS_DECLARATION_LIST + | CssSyntaxKind::CSS_DECLARATION_OR_AT_RULE_LIST + | CssSyntaxKind::CSS_RULE_LIST + ) + }) + .flat_map(|list| list.children()) + .collect() +} + +fn is_declaration_item(kind: CssSyntaxKind) -> bool { + matches!( + kind, + CssSyntaxKind::CSS_DECLARATION_WITH_SEMICOLON | CssSyntaxKind::CSS_DECLARATION + ) +} + +// --------------------------------------------------------------------------- +// Selector normalisation +// --------------------------------------------------------------------------- + +/// Canonical form of a selector for comparison purposes. +/// +/// Collapses whitespace runs, puts exactly one space around the `>`, `+`, `~` +/// combinators, and exactly one space after each `,`. Text inside quotes is +/// copied verbatim; text inside `[...]` keeps its own spacing rules so that +/// `[a~="b"]` is not mangled into something unrecognisable. +/// +/// This does not need to be semantically perfect -- it needs to be +/// *deterministic*, so that the same selector written two ways lands on the +/// same string and two different selectors do not collide. +pub fn normalize_selector(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut bracket_depth = 0usize; + let mut paren_depth = 0usize; + let mut pending_space = false; + + while let Some(c) = chars.next() { + match c { + '"' | '\'' => { + if pending_space && !out.is_empty() { + out.push(' '); + } + pending_space = false; + let quote = c; + out.push(quote); + let mut escaped = false; + for q in chars.by_ref() { + out.push(q); + if escaped { + escaped = false; + } else if q == '\\' { + escaped = true; + } else if q == quote { + break; + } + } + } + c if c.is_whitespace() => { + pending_space = true; + } + '>' | '+' | '~' if bracket_depth == 0 && paren_depth == 0 => { + // Combinator: exactly one space on each side. + while out.ends_with(' ') { + out.pop(); + } + if !out.is_empty() { + out.push(' '); + } + out.push(c); + out.push(' '); + pending_space = false; + } + ',' => { + while out.ends_with(' ') { + out.pop(); + } + out.push(','); + out.push(' '); + pending_space = false; + } + _ => { + if pending_space && !out.is_empty() && !out.ends_with(' ') { + out.push(' '); + } + pending_space = false; + match c { + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.saturating_sub(1), + '(' => paren_depth += 1, + ')' => paren_depth = paren_depth.saturating_sub(1), + _ => {} + } + out.push(c); + } + } + } + + out.trim().to_string() +} + +/// Canonical form of a property name: lowercased, trimmed. Custom properties +/// (`--foo`) are case-sensitive per spec, so those keep their case. +pub fn normalize_property(input: &str) -> String { + let t = input.trim(); + if t.starts_with("--") { + t.to_string() + } else { + t.to_lowercase() + } +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +fn rule_ref_from(node: &CssSyntaxNode, ctx: &ParseCtx) -> Option { + if node.kind() != CssSyntaxKind::CSS_QUALIFIED_RULE { + return None; + } + let selector_list = node + .children() + .find(|c| c.kind() == CssSyntaxKind::CSS_SELECTOR_LIST)?; + let block = block_child(node)?; + let (body_open, body_close) = block_bounds(&block)?; + let (start, end) = trimmed(node); + let selector_raw = ctx.text(selector_list.text_trimmed_range()).to_string(); + + Some(RuleRef { + selector_norm: normalize_selector(&selector_raw), + selector_raw, + node: node.clone(), + start, + end, + body_open, + body_close, + }) +} + +fn at_rule_ref_from(node: &CssSyntaxNode, ctx: &ParseCtx) -> Option { + if node.kind() != CssSyntaxKind::CSS_AT_RULE { + return None; + } + // `CSS_AT_RULE` is `AT` + one inner node that carries the name and body. + let inner = node.children().next()?; + let name_token = inner.first_token()?; + let name = name_token.text_trimmed().trim().to_lowercase(); + + let (start, end) = trimmed(node); + let name_end = usize::from(name_token.text_trimmed_range().end()); + + let block = block_child(&inner); + let has_block = block.is_some(); + let (body_open, body_close) = match &block { + Some(b) => { + let (o, c) = block_bounds(b)?; + (Some(o), Some(c)) + } + None => (None, None), + }; + + // Prelude runs from just past the name to the `{` or the trailing `;`. + let prelude_end = match &block { + Some(b) => usize::from(b.text_trimmed_range().start()), + None => { + let semi = inner + .children_with_tokens() + .filter_map(|e| e.into_token()) + .filter(|t| t.kind() == CssSyntaxKind::SEMICOLON) + .last(); + match semi { + Some(t) => usize::from(t.text_trimmed_range().start()), + None => end, + } + } + }; + let prelude = ctx + .source() + .get(name_end..prelude_end.max(name_end)) + .unwrap_or("") + .trim() + .to_string(); + + Some(AtRuleRef { + node: node.clone(), + name, + prelude, + start, + end, + has_block, + body_open, + body_close, + }) +} + +fn decl_ref_from(node: &CssSyntaxNode, ctx: &ParseCtx) -> Option { + let (item, decl) = match node.kind() { + CssSyntaxKind::CSS_DECLARATION_WITH_SEMICOLON => { + let d = node + .children() + .find(|c| c.kind() == CssSyntaxKind::CSS_DECLARATION)?; + (node.clone(), d) + } + CssSyntaxKind::CSS_DECLARATION => (node.clone(), node.clone()), + _ => return None, + }; + + let property_node = decl.children().find(|c| { + matches!( + c.kind(), + CssSyntaxKind::CSS_GENERIC_PROPERTY | CssSyntaxKind::CSS_COMPOSES_PROPERTY + ) + })?; + + let mut property_children = property_node.children(); + let name_node = property_children.next()?; + let property = ctx.text(name_node.text_trimmed_range()).trim().to_string(); + + // The value is everything after the `:` that is not the `!important` flag, + // so replacing this range alone preserves both the flag and any inline + // comment sitting on the declaration (rule E). + let value_node = property_node.children().nth(1); + let (value_start, value_end) = match value_node { + Some(v) => range_to_pair(v.text_trimmed_range()), + None => { + let colon = property_node + .children_with_tokens() + .filter_map(|e| e.into_token()) + .find(|t| t.kind() == CssSyntaxKind::COLON); + let after = colon + .map(|t| usize::from(t.text_range().end())) + .unwrap_or_else(|| usize::from(property_node.text_trimmed_range().end())); + let end = usize::from(property_node.text_trimmed_range().end()); + (after.min(end), end) + } + }; + + let important_range = decl + .children() + .find(|c| c.kind() == CssSyntaxKind::CSS_DECLARATION_IMPORTANT) + .map(|c| range_to_pair(c.text_trimmed_range())); + let important = important_range.is_some(); + + let (start, end) = trimmed(&item); + Some(DeclRef { + node: item, + property, + value_raw: ctx + .source() + .get(value_start..value_end) + .unwrap_or("") + .to_string(), + important, + start, + end, + value_start, + value_end, + important_range, + }) +} + +/// Children of the root `CSS_RULE_LIST`, in source order. +pub fn top_level_nodes(ctx: &ParseCtx) -> Vec { + ctx.syntax() + .children() + .filter(|c| c.kind() == CssSyntaxKind::CSS_RULE_LIST) + .flat_map(|l| l.children()) + .collect() +} + +pub fn find_top_level_rules(ctx: &ParseCtx) -> Vec { + top_level_nodes(ctx) + .iter() + .filter_map(|n| rule_ref_from(n, ctx)) + .collect() +} + +/// Every qualified rule anywhere in the file, including inside `@media`, +/// `@supports`, `@layer` and nested rules. +pub fn find_all_rules(ctx: &ParseCtx) -> Vec { + ctx.syntax() + .descendants() + .filter_map(|n| rule_ref_from(&n, ctx)) + .collect() +} + +pub fn find_top_level_at_rules(ctx: &ParseCtx) -> Vec { + top_level_nodes(ctx) + .iter() + .filter_map(|n| at_rule_ref_from(n, ctx)) + .collect() +} + +pub fn find_all_at_rules(ctx: &ParseCtx) -> Vec { + ctx.syntax() + .descendants() + .filter_map(|n| at_rule_ref_from(&n, ctx)) + .collect() +} + +/// Top-level at-rules with the given (lowercase, no `@`) name. +pub fn find_at_rules_named(ctx: &ParseCtx, name: &str) -> Vec { + let want = name.trim_start_matches('@').to_lowercase(); + find_top_level_at_rules(ctx) + .into_iter() + .filter(|r| r.name == want) + .collect() +} + +/// Find a top-level rule by selector. `Ambiguous` when more than one matches. +pub fn find_rule_by_selector(ctx: &ParseCtx, selector: &str) -> MatchResult { + find_rule_among(find_top_level_rules(ctx), selector) +} + +/// Same, but searching every rule in the file including nested ones. Callers +/// must opt in explicitly (ROADMAP Phase 2). +pub fn find_rule_by_selector_anywhere(ctx: &ParseCtx, selector: &str) -> MatchResult { + find_rule_among(find_all_rules(ctx), selector) +} + +fn find_rule_among(rules: Vec, selector: &str) -> MatchResult { + let want = normalize_selector(selector); + if want.is_empty() { + return MatchResult::None; + } + let mut hits: Vec = rules + .into_iter() + .filter(|r| r.selector_norm == want) + .collect(); + match hits.len() { + 0 => MatchResult::None, + 1 => MatchResult::One(Box::new(hits.remove(0))), + _ => MatchResult::Ambiguous(hits), + } +} + +/// Declarations directly inside a rule body, in source order. Nested rules and +/// nested at-rules (`@apply`, `@media`) are not declarations and are skipped. +pub fn declarations_in(ctx: &ParseCtx, rule: &RuleRef) -> Vec { + let Some(block) = block_child(&rule.node) else { + return Vec::new(); + }; + block_items(&block) + .iter() + .filter(|n| is_declaration_item(n.kind())) + .filter_map(|n| decl_ref_from(n, ctx)) + .collect() +} + +/// Declarations directly inside any block node (used for `@theme`, `@plugin` +/// and other at-rules that carry a declaration body). +pub fn declarations_in_block(ctx: &ParseCtx, block_node: &CssSyntaxNode) -> Vec { + let Some(block) = block_child(block_node) else { + return Vec::new(); + }; + block_items(&block) + .iter() + .filter(|n| is_declaration_item(n.kind())) + .filter_map(|n| decl_ref_from(n, ctx)) + .collect() +} + +/// The last declaration of `property` in `rule`, or `None`. +/// +/// Last, not first: when a rule repeats a property the last one wins in CSS, so +/// that is the one a caller means when they say "set this property". +pub fn find_declaration(ctx: &ParseCtx, rule: &RuleRef, property: &str) -> Option { + let want = normalize_property(property); + declarations_in(ctx, rule) + .into_iter() + .rfind(|d| normalize_property(&d.property) == want) +} + +pub fn find_declarations(ctx: &ParseCtx, rule: &RuleRef, property: &str) -> Vec { + let want = normalize_property(property); + declarations_in(ctx, rule) + .into_iter() + .filter(|d| normalize_property(&d.property) == want) + .collect() +} + +/// Declaration-holding list nodes in the file, each paired with the +/// declarations directly inside it. Used by ops that work block-by-block. +pub fn declaration_lists(ctx: &ParseCtx) -> Vec<(CssSyntaxNode, Vec)> { + ctx.syntax() + .descendants() + .filter(|n| { + matches!( + n.kind(), + CssSyntaxKind::CSS_DECLARATION_OR_RULE_LIST + | CssSyntaxKind::CSS_DECLARATION_LIST + | CssSyntaxKind::CSS_DECLARATION_OR_AT_RULE_LIST + ) + }) + .map(|list| { + let decls = list + .children() + .filter(|c| is_declaration_item(c.kind())) + .filter_map(|c| decl_ref_from(&c, ctx)) + .collect(); + (list, decls) + }) + .collect() +} + +/// Every declaration in the file, wherever it lives. Used by the read-only +/// analysis ops. +pub fn all_declarations(ctx: &ParseCtx) -> Vec { + ctx.syntax() + .descendants() + .filter(|n| is_declaration_item(n.kind())) + // A bare CSS_DECLARATION nested inside CSS_DECLARATION_WITH_SEMICOLON + // would otherwise be counted twice. + .filter(|n| { + n.kind() != CssSyntaxKind::CSS_DECLARATION + || n.parent().map(|p| p.kind()) + != Some(CssSyntaxKind::CSS_DECLARATION_WITH_SEMICOLON) + }) + .filter_map(|n| decl_ref_from(&n, ctx)) + .collect() +} + +/// Insertion anchor: end of the last top-level at-rule whose name is in +/// `names`. `None` when the file has none of them. +pub fn last_top_level_at_rule_end(ctx: &ParseCtx, names: &[&str]) -> Option { + let wanted: Vec = names + .iter() + .map(|n| n.trim_start_matches('@').to_lowercase()) + .collect(); + find_top_level_at_rules(ctx) + .iter() + .filter(|r| wanted.contains(&r.name)) + .map(|r| r.end) + .next_back() +} + +/// Where a new top-level line should go when the file has no anchor at-rule of +/// its own family: after any leading comment block and `@charset`, but before +/// the first real rule (ROADMAP §8, `ensure_at_rule_line`). +pub fn top_of_file_anchor(ctx: &ParseCtx) -> usize { + let src = ctx.source(); + // `ctx.source()` never contains a BOM -- it is stripped at parse time and + // re-attached on output -- so offset 0 is always a safe starting anchor. + let mut anchor = 0usize; + + // `@charset` must stay first in the file. + if let Some(cs) = find_at_rules_named(ctx, "charset").first() { + anchor = ctx.line_end_inclusive(cs.end); + } + + // Skip a leading comment block: consecutive comment lines and blank lines + // that appear before the first rule. + let first_rule_start = top_level_nodes(ctx) + .iter() + .filter(|n| !is_bogus(n.kind())) + .map(|n| usize::from(n.text_trimmed_range().start())) + .find(|start| *start >= anchor); + + let Some(first_rule_start) = first_rule_start else { + // No rules at all: land at the end of whatever leading text exists. + return src.len(); + }; + + // Leading trivia of the first rule holds the comments above it. Walk its + // comment pieces and stop at the first blank line, which we read as the + // boundary between a file header and a comment about the rule itself. + let Some(token) = ctx + .syntax() + .token_at_offset(biome_rowan::TextSize::from(first_rule_start as u32)) + .right_biased() + else { + return anchor; + }; + + let mut cursor = anchor; + let mut newlines_since_comment = 0usize; + for piece in token.leading_trivia().pieces() { + let piece_start = usize::from(piece.text_range().start()); + if piece_start < anchor { + continue; + } + if crate::ctx::is_comment_kind(piece.kind()) { + cursor = usize::from(piece.text_range().end()); + newlines_since_comment = 0; + } else if piece.kind() == biome_rowan::TriviaPieceKind::Newline { + newlines_since_comment += 1; + if newlines_since_comment >= 2 && cursor > anchor { + // Blank line after the header block: stop here. + break; + } + } + } + + if cursor > anchor { + ctx.line_end_inclusive(cursor) + } else { + anchor + } +} + +/// Comments in the file, as `(start, end, text)` triples. +pub fn all_comments(ctx: &ParseCtx) -> Vec<(usize, usize, String)> { + let mut out = Vec::new(); + for token in ctx.syntax().descendants_tokens(Direction::Next) { + for piece in token + .leading_trivia() + .pieces() + .chain(token.trailing_trivia().pieces()) + { + if crate::ctx::is_comment_kind(piece.kind()) { + let (s, e) = range_to_pair(piece.text_range()); + out.push((s, e, piece.text().to_string())); + } + } + } + out.sort_by_key(|(s, _, _)| *s); + out.dedup_by_key(|(s, _, _)| *s); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ctx::ParseCtx; + + fn ctx(src: &str) -> ParseCtx { + ParseCtx::parse_default(src) + } + + // -- normalisation ------------------------------------------------------ + + #[test] + fn normalises_whitespace_runs() { + assert_eq!(normalize_selector(" .a .b "), ".a .b"); + assert_eq!(normalize_selector(".a\n\t.b"), ".a .b"); + } + + #[test] + fn normalises_combinators() { + assert_eq!(normalize_selector(".a>.b"), ".a > .b"); + assert_eq!(normalize_selector(".a > .b"), ".a > .b"); + assert_eq!(normalize_selector(".a+.b"), ".a + .b"); + assert_eq!(normalize_selector(".a~.b"), ".a ~ .b"); + } + + #[test] + fn normalises_selector_lists() { + assert_eq!(normalize_selector(".a,.b , .c"), ".a, .b, .c"); + } + + #[test] + fn leaves_quoted_text_alone() { + assert_eq!( + normalize_selector(r#"a[href^="https://a b"]"#), + r#"a[href^="https://a b"]"# + ); + } + + #[test] + fn does_not_treat_tilde_inside_brackets_as_a_combinator() { + assert_eq!(normalize_selector(r#"[data-x~="y"]"#), r#"[data-x~="y"]"#); + } + + #[test] + fn does_not_treat_plus_inside_parens_as_a_combinator() { + assert_eq!( + normalize_selector("li:nth-child(2n + 1)"), + "li:nth-child(2n + 1)" + ); + assert_eq!( + normalize_selector("li:nth-child(2n+1)"), + "li:nth-child(2n+1)" + ); + } + + #[test] + fn different_selectors_do_not_collide() { + assert_ne!(normalize_selector(".a .b"), normalize_selector(".a > .b")); + assert_ne!(normalize_selector(".ab"), normalize_selector(".a .b")); + } + + #[test] + fn normalisation_is_idempotent() { + for s in [ + ".a>.b", + ".a , .b", + " .a .b ", + r#"a[href^="x"]:not(.y)::after"#, + "li:nth-child(2n+1)", + ] { + let once = normalize_selector(s); + assert_eq!(normalize_selector(&once), once, "not idempotent: {s}"); + } + } + + #[test] + fn property_normalisation_lowercases_but_keeps_custom_property_case() { + assert_eq!(normalize_property(" COLOR "), "color"); + assert_eq!(normalize_property("--Brand"), "--Brand"); + } + + // -- rules -------------------------------------------------------------- + + #[test] + fn finds_top_level_rules_only() { + let c = ctx(".a { color: red; }\n@media print { .b { color: blue; } }\n"); + let rules = find_top_level_rules(&c); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].selector_norm, ".a"); + } + + #[test] + fn find_all_rules_descends_into_at_rules() { + let c = ctx(".a { color: red; }\n@media print { .b { color: blue; } }\n"); + let all = find_all_rules(&c); + assert_eq!(all.len(), 2); + } + + #[test] + fn rule_ranges_exclude_leading_comments() { + let src = "/* above */\n.a { color: red; }\n"; + let c = ctx(src); + let r = &find_top_level_rules(&c)[0]; + assert_eq!(&src[r.start..r.end], ".a { color: red; }"); + } + + #[test] + fn rule_body_bounds_are_correct() { + let src = ".a {\n color: red;\n}\n"; + let c = ctx(src); + let r = &find_top_level_rules(&c)[0]; + assert_eq!(&src[r.body_open..r.body_close], "\n color: red;\n"); + } + + #[test] + fn empty_body_is_detected() { + let c = ctx(".a {}\n.b { color: red; }\n"); + let rules = find_top_level_rules(&c); + assert!(rules[0].body_is_empty(&c)); + assert!(!rules[1].body_is_empty(&c)); + } + + #[test] + fn matches_a_selector_written_differently() { + let c = ctx(".a > .b { color: red; }\n"); + assert!(matches!( + find_rule_by_selector(&c, ".a>.b"), + MatchResult::One(_) + )); + } + + #[test] + fn reports_ambiguity_rather_than_guessing() { + let c = ctx(".a { color: red; }\n.a { color: blue; }\n"); + let m = find_rule_by_selector(&c, ".a"); + assert!(matches!(m, MatchResult::Ambiguous(_))); + assert_eq!(m.count(), 2); + } + + #[test] + fn does_not_match_a_substring_of_a_selector() { + let c = ctx(".header-inner { color: red; }\n"); + assert!(find_rule_by_selector(&c, ".header").is_none()); + } + + #[test] + fn does_not_match_one_member_of_a_selector_list() { + let c = ctx(".a, .b { color: red; }\n"); + assert!(find_rule_by_selector(&c, ".a").is_none()); + assert!(matches!( + find_rule_by_selector(&c, ".a, .b"), + MatchResult::One(_) + )); + } + + #[test] + fn does_not_reach_into_media_blocks_by_default() { + let c = ctx("@media print { .b { color: blue; } }\n"); + assert!(find_rule_by_selector(&c, ".b").is_none()); + assert!(matches!( + find_rule_by_selector_anywhere(&c, ".b"), + MatchResult::One(_) + )); + } + + // -- declarations ------------------------------------------------------- + + #[test] + fn reads_declarations_in_order() { + let c = ctx(".a {\n color: red;\n margin: 0;\n}\n"); + let r = find_rule_by_selector(&c, ".a").one().unwrap(); + let decls = declarations_in(&c, &r); + assert_eq!(decls.len(), 2); + assert_eq!(decls[0].property, "color"); + assert_eq!(decls[1].property, "margin"); + } + + #[test] + fn declaration_value_range_covers_only_the_value() { + let src = ".a { color: red; }"; + let c = ctx(src); + let r = find_rule_by_selector(&c, ".a").one().unwrap(); + let d = find_declaration(&c, &r, "color").unwrap(); + assert_eq!(&src[d.value_start..d.value_end], "red"); + } + + #[test] + fn declaration_value_range_excludes_important() { + let src = ".a { color: red !important; }"; + let c = ctx(src); + let r = find_rule_by_selector(&c, ".a").one().unwrap(); + let d = find_declaration(&c, &r, "color").unwrap(); + assert_eq!(&src[d.value_start..d.value_end], "red"); + assert!(d.important); + } + + #[test] + fn declaration_value_range_excludes_a_trailing_comment() { + let src = ".a { color: red; /* why */ }"; + let c = ctx(src); + let r = find_rule_by_selector(&c, ".a").one().unwrap(); + let d = find_declaration(&c, &r, "color").unwrap(); + assert_eq!(&src[d.value_start..d.value_end], "red"); + } + + #[test] + fn multi_token_values_are_captured_whole() { + let src = ".a { margin: 0 auto 10px; }"; + let c = ctx(src); + let r = find_rule_by_selector(&c, ".a").one().unwrap(); + let d = find_declaration(&c, &r, "margin").unwrap(); + assert_eq!(&src[d.value_start..d.value_end], "0 auto 10px"); + } + + #[test] + fn custom_properties_are_found() { + let src = ":root { --brand: #4f46e5; }"; + let c = ctx(src); + let r = find_rule_by_selector(&c, ":root").one().unwrap(); + let d = find_declaration(&c, &r, "--brand").unwrap(); + assert_eq!(&src[d.value_start..d.value_end], "#4f46e5"); + } + + #[test] + fn a_repeated_property_resolves_to_the_last_one() { + let src = ".a { color: red; color: blue; }"; + let c = ctx(src); + let r = find_rule_by_selector(&c, ".a").one().unwrap(); + let d = find_declaration(&c, &r, "color").unwrap(); + assert_eq!(&src[d.value_start..d.value_end], "blue"); + assert_eq!(find_declarations(&c, &r, "color").len(), 2); + } + + #[test] + fn declaration_lookup_is_case_insensitive_for_standard_properties() { + let c = ctx(".a { COLOR: red; }"); + let r = find_rule_by_selector(&c, ".a").one().unwrap(); + assert!(find_declaration(&c, &r, "color").is_some()); + } + + #[test] + fn nested_at_rules_are_not_declarations() { + let c = ctx(".a { @apply px-2; color: red; }"); + let r = find_rule_by_selector(&c, ".a").one().unwrap(); + let decls = declarations_in(&c, &r); + assert_eq!(decls.len(), 1); + assert_eq!(decls[0].property, "color"); + } + + // -- at-rules ----------------------------------------------------------- + + #[test] + fn reads_at_rule_names_and_preludes() { + let c = ctx("@import \"tailwindcss\";\n@plugin \"../vendor/daisyui\";\n"); + let rules = find_top_level_at_rules(&c); + assert_eq!(rules.len(), 2); + assert_eq!(rules[0].name, "import"); + assert_eq!(rules[0].prelude, "\"tailwindcss\""); + assert_eq!(rules[1].name, "plugin"); + assert_eq!(rules[1].prelude, "\"../vendor/daisyui\""); + } + + #[test] + fn reads_block_at_rules() { + let src = "@theme {\n --a: 1;\n}\n"; + let c = ctx(src); + let r = &find_at_rules_named(&c, "theme")[0]; + assert!(r.has_block); + assert_eq!(r.prelude, ""); + assert_eq!( + &src[r.body_open.unwrap()..r.body_close.unwrap()], + "\n --a: 1;\n" + ); + } + + #[test] + fn reads_block_at_rules_with_a_prelude() { + let c = ctx("@plugin \"../vendor/daisyui\" {\n themes: false;\n}\n"); + let r = &find_at_rules_named(&c, "plugin")[0]; + assert!(r.has_block); + assert_eq!(r.prelude, "\"../vendor/daisyui\""); + } + + #[test] + fn at_rule_name_lookup_tolerates_the_at_sign_and_case() { + let c = ctx("@IMPORT \"x\";\n"); + assert_eq!(find_at_rules_named(&c, "@import").len(), 1); + } + + #[test] + fn finds_declarations_inside_a_theme_block() { + let c = ctx("@theme {\n --color-brand: red;\n --font-x: sans;\n}\n"); + let at = &find_at_rules_named(&c, "theme")[0]; + let decls = declarations_in_block(&c, &at.node.children().next().unwrap()); + assert_eq!(decls.len(), 2); + assert_eq!(decls[0].property, "--color-brand"); + } + + #[test] + fn anchors_after_the_last_at_rule_of_a_family() { + let src = "@import \"a\";\n@import \"b\";\n.x { color: red; }\n"; + let c = ctx(src); + let end = last_top_level_at_rule_end(&c, &["import"]).unwrap(); + assert_eq!(&src[..end], "@import \"a\";\n@import \"b\";"); + } + + #[test] + fn no_anchor_when_the_family_is_absent() { + let c = ctx(".x { color: red; }\n"); + assert!(last_top_level_at_rule_end(&c, &["import"]).is_none()); + } + + // -- anchors ------------------------------------------------------------ + + #[test] + fn top_of_file_anchor_is_zero_for_a_bare_rule() { + let c = ctx(".a { color: red; }\n"); + assert_eq!(top_of_file_anchor(&c), 0); + } + + #[test] + fn top_of_file_anchor_skips_a_header_comment_block() { + let src = "/* Header line one.\n Header line two. */\n\n.a { color: red; }\n"; + let c = ctx(src); + let a = top_of_file_anchor(&c); + assert_eq!(&src[..a], "/* Header line one.\n Header line two. */\n"); + } + + #[test] + fn top_of_file_anchor_keeps_a_comment_attached_to_the_first_rule() { + // No blank line: the comment belongs to `.a`, so we insert above it. + let src = "/* about .a */\n.a { color: red; }\n"; + let c = ctx(src); + assert_eq!(top_of_file_anchor(&c), 14 + 1); + } + + #[test] + fn top_of_file_anchor_lands_after_charset() { + let src = "@charset \"utf-8\";\n.a { color: red; }\n"; + let c = ctx(src); + let a = top_of_file_anchor(&c); + assert_eq!(&src[..a], "@charset \"utf-8\";\n"); + } + + #[test] + fn top_of_file_anchor_is_bom_relative() { + // The BOM is not part of `ctx.source()`, so the anchor is 0 and the + // caller re-attaches the BOM afterwards. + let c = ctx("\u{feff}.a { color: red; }\n"); + assert_eq!(top_of_file_anchor(&c), 0); + assert!(c.has_bom()); + } + + // -- comments ----------------------------------------------------------- + + #[test] + fn collects_every_comment_exactly_once() { + let src = "/* a */\n.x { /* b */ color: red; /* c */ }\n/* d */\n"; + let c = ctx(src); + let comments = all_comments(&c); + let texts: Vec<&str> = comments.iter().map(|(_, _, t)| t.as_str()).collect(); + assert_eq!(texts, vec!["/* a */", "/* b */", "/* c */", "/* d */"]); + } +} diff --git a/native/igniter_css/src/nif.rs b/native/igniter_css/src/nif.rs new file mode 100644 index 0000000..99de99e --- /dev/null +++ b/native/igniter_css/src/nif.rs @@ -0,0 +1,533 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Phase 4: the NIF boundary. Deliberately thin. +//! +//! **Elixir sends intent; Rust returns text.** The CST is never exported across +//! the boundary in any form -- marshalling trees between languages is what made +//! the previous implementation painful, and it buys nothing here (ROADMAP §8 +//! Phase 4). +//! +//! Two other rules hold throughout this module: +//! +//! * no `unwrap()`/`expect()` on anything reachable from a call -- a panic +//! takes down a BEAM scheduler, so every fallible path returns +//! `{:error, _, reason}`; +//! * every NIF is `DirtyCpu`. Input size is unbounded (a vendored `bootstrap.css` +//! is 200 kB) and the work is pure CPU, so the few microseconds of dirty +//! scheduling overhead are much cheaper than the risk of blocking a normal +//! scheduler. See `tests/bench.rs` for the measurements behind that call. + +use crate::analyze; +use crate::atoms; +use crate::ctx::ParseOptions; +use crate::error::CssError; +use crate::helpers::encode_response; +use crate::ops::declaration::{Important, SetOptions}; +use crate::ops::tidy::DedupeOptions; +use crate::ops::{at_rule, declaration, rule, tidy, Outcome}; +use crate::transform; +use rustler::{Env, NifResult, NifStruct, Term}; + +// --------------------------------------------------------------------------- +// Boundary types +// --------------------------------------------------------------------------- + +#[derive(NifStruct, Debug, Clone)] +#[module = "IgniterCss.ParseOpts"] +pub struct ExParseOpts { + pub allow_wrong_line_comments: bool, + pub css_modules: bool, +} + +impl From for ParseOptions { + fn from(o: ExParseOpts) -> Self { + ParseOptions { + allow_wrong_line_comments: o.allow_wrong_line_comments, + css_modules: o.css_modules, + } + } +} + +#[derive(NifStruct, Debug, Clone)] +#[module = "IgniterCss.Outcome"] +pub struct ExOutcome { + pub source: String, + pub changed: bool, + pub diagnostics: Vec, +} + +impl From for ExOutcome { + fn from(o: Outcome) -> Self { + ExOutcome { + source: o.source, + changed: o.changed, + diagnostics: o.diagnostics, + } + } +} + +#[derive(NifStruct, Debug, Clone)] +#[module = "IgniterCss.Analysis"] +pub struct ExAnalysis { + pub rules_count: usize, + pub top_level_rules_count: usize, + pub selectors_count: usize, + pub unique_selectors: usize, + pub declarations_count: usize, + pub unique_properties: usize, + pub at_rules_count: usize, + pub media_queries_count: usize, + pub keyframes_count: usize, + pub imports_count: usize, + pub comments_count: usize, + pub colors_count: usize, + pub important_count: usize, + pub custom_properties_count: usize, + pub property_frequency: Vec<(String, usize)>, + pub selectors: Vec, + pub at_rule_names: Vec, +} + +impl From for ExAnalysis { + fn from(a: analyze::Analysis) -> Self { + ExAnalysis { + rules_count: a.rules_count, + top_level_rules_count: a.top_level_rules_count, + selectors_count: a.selectors_count, + unique_selectors: a.unique_selectors, + declarations_count: a.declarations_count, + unique_properties: a.unique_properties, + at_rules_count: a.at_rules_count, + media_queries_count: a.media_queries_count, + keyframes_count: a.keyframes_count, + imports_count: a.imports_count, + comments_count: a.comments_count, + colors_count: a.colors_count, + important_count: a.important_count, + custom_properties_count: a.custom_properties_count, + property_frequency: a.property_frequency, + selectors: a.selectors, + at_rule_names: a.at_rule_names, + } + } +} + +#[derive(NifStruct, Debug, Clone)] +#[module = "IgniterCss.Validation"] +pub struct ExValidation { + pub valid: bool, + pub diagnostics: usize, + pub round_trips: bool, + pub message: String, +} + +#[derive(NifStruct, Debug, Clone)] +#[module = "IgniterCss.Animation"] +pub struct ExAnimation { + pub name: String, + pub keyframes: Vec<(String, Vec<(String, String)>)>, + pub used_by: Vec, +} + +// --------------------------------------------------------------------------- +// Plumbing +// --------------------------------------------------------------------------- + +fn describe(e: CssError) -> String { + e.to_string() +} + +/// Encode a mutating op's result. +macro_rules! respond { + ($env:expr, $atom:expr, $call:expr) => {{ + match $call { + Ok(value) => encode_response($env, atoms::ok(), $atom, value), + Err(e) => encode_response($env, atoms::error(), $atom, describe(e)), + } + }}; +} + +// --------------------------------------------------------------------------- +// At-rule ops +// --------------------------------------------------------------------------- + +#[rustler::nif(schedule = "DirtyCpu")] +fn ensure_at_rule_nif( + env: Env, + source: String, + line: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::ensure_at_rule_nif(), + at_rule::ensure_at_rule_line(&source, &line, opts.into()).map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn remove_at_rule_nif( + env: Env, + source: String, + name: String, + matching: Option, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::remove_at_rule_nif(), + at_rule::remove_at_rule(&source, &name, matching.as_deref(), opts.into()) + .map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn has_at_rule_nif(env: Env, source: String, line: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::has_at_rule_nif(), + at_rule::has_at_rule(&source, &line, opts.into()) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn add_import_nif( + env: Env, + source: String, + url: String, + media: Option, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::add_import_nif(), + at_rule::add_import(&source, &url, media.as_deref(), opts.into()).map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn remove_import_nif(env: Env, source: String, url: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::remove_import_nif(), + at_rule::remove_import(&source, &url, opts.into()).map(ExOutcome::from) + ) +} + +// --------------------------------------------------------------------------- +// Rule ops +// --------------------------------------------------------------------------- + +#[rustler::nif(schedule = "DirtyCpu")] +fn ensure_rule_nif( + env: Env, + source: String, + selector: String, + declarations: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::ensure_rule_nif(), + rule::ensure_rule_with(&source, &selector, &declarations, opts.into()).map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn remove_rule_nif( + env: Env, + source: String, + selector: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::remove_rule_nif(), + rule::remove_rule(&source, &selector, opts.into()).map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn replace_rule_body_nif( + env: Env, + source: String, + selector: String, + declarations: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::replace_rule_body_nif(), + rule::replace_rule_body(&source, &selector, &declarations, opts.into()) + .map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn append_raw_to_rule_nif( + env: Env, + source: String, + selector: String, + raw: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::append_raw_to_rule_nif(), + rule::append_raw_to_rule(&source, &selector, &raw, opts.into()).map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn has_rule_nif(env: Env, source: String, selector: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::has_rule_nif(), + rule::has_rule(&source, &selector, opts.into()) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn list_selectors_nif(env: Env, source: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::list_selectors_nif(), + rule::list_selectors(&source, opts.into()) + ) +} + +// --------------------------------------------------------------------------- +// Declaration ops +// --------------------------------------------------------------------------- + +#[rustler::nif(schedule = "DirtyCpu")] +#[allow(clippy::too_many_arguments)] +fn set_declaration_nif( + env: Env, + source: String, + selector: String, + property: String, + value: String, + important: Option, + create_rule: bool, + opts: ExParseOpts, +) -> NifResult { + let set = SetOptions { + important: Important::from_option(important), + create_rule, + }; + respond!( + env, + atoms::set_declaration_nif(), + declaration::set_declaration(&source, &selector, &property, &value, set, opts.into()) + .map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn remove_declaration_nif( + env: Env, + source: String, + selector: String, + property: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::remove_declaration_nif(), + declaration::remove_declaration(&source, &selector, &property, opts.into()) + .map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn get_declaration_nif( + env: Env, + source: String, + selector: String, + property: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::get_declaration_nif(), + declaration::get_declaration(&source, &selector, &property, opts.into()) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn has_declaration_nif( + env: Env, + source: String, + selector: String, + property: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::has_declaration_nif(), + declaration::has_declaration(&source, &selector, &property, opts.into()) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn get_rule_declarations_nif( + env: Env, + source: String, + selector: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::get_rule_declarations_nif(), + declaration::get_rule_declarations(&source, &selector, opts.into()) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn add_vendor_prefixes_nif( + env: Env, + source: String, + property: String, + prefixes: Vec, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::add_vendor_prefixes_nif(), + declaration::add_vendor_prefixes(&source, &property, &prefixes, opts.into()) + .map(ExOutcome::from) + ) +} + +// --------------------------------------------------------------------------- +// Tidy ops +// --------------------------------------------------------------------------- + +#[rustler::nif(schedule = "DirtyCpu")] +fn sort_properties_nif(env: Env, source: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::sort_properties_nif(), + tidy::sort_properties(&source, opts.into()).map(ExOutcome::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn remove_duplicates_nif( + env: Env, + source: String, + declarations: bool, + rules: bool, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::remove_duplicates_nif(), + tidy::remove_duplicates( + &source, + DedupeOptions { + declarations, + rules + }, + opts.into() + ) + .map(ExOutcome::from) + ) +} + +// --------------------------------------------------------------------------- +// Analysis +// --------------------------------------------------------------------------- + +#[rustler::nif(schedule = "DirtyCpu")] +fn analyze_nif(env: Env, source: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::analyze_nif(), + analyze::analyze(&source, opts.into()).map(ExAnalysis::from) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn validate_nif(env: Env, source: String, opts: ExParseOpts) -> NifResult { + let v = analyze::validate(&source, opts.into()); + let status = if v.valid { atoms::ok() } else { atoms::error() }; + let payload = ExValidation { + valid: v.valid, + diagnostics: v.diagnostics, + round_trips: v.round_trips, + message: v.message, + }; + encode_response(env, status, atoms::validate_nif(), payload) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn extract_colors_nif(env: Env, source: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::extract_colors_nif(), + analyze::extract_colors(&source, opts.into()) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn extract_media_queries_nif(env: Env, source: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::extract_media_queries_nif(), + analyze::extract_media_queries(&source, opts.into()) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn extract_animations_nif(env: Env, source: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::extract_animations_nif(), + analyze::extract_animations(&source, opts.into()).map(|list| { + list.into_iter() + .map(|a| ExAnimation { + name: a.name, + keyframes: a.keyframes, + used_by: a.used_by, + }) + .collect::>() + }) + ) +} + +// --------------------------------------------------------------------------- +// Whole-file transforms +// --------------------------------------------------------------------------- + +#[rustler::nif(schedule = "DirtyCpu")] +fn minify_nif(env: Env, source: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::minify_nif(), + transform::minify(&source, opts.into()) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn beautify_nif(env: Env, source: String, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::beautify_nif(), + transform::beautify(&source, opts.into()) + ) +} + +#[rustler::nif(schedule = "DirtyCpu")] +fn merge_stylesheets_nif(env: Env, sources: Vec, opts: ExParseOpts) -> NifResult { + respond!( + env, + atoms::merge_stylesheets_nif(), + transform::merge_stylesheets(&sources, opts.into()) + ) +} + +rustler::init!("Elixir.IgniterCss.Native"); diff --git a/native/igniter_css/src/ops/at_rule.rs b/native/igniter_css/src/ops/at_rule.rs new file mode 100644 index 0000000..0ceb302 --- /dev/null +++ b/native/igniter_css/src/ops/at_rule.rs @@ -0,0 +1,625 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Top-level at-rule codemods: `@import`, `@plugin`, `@source`, `@layer`, +//! `@custom-variant` and friends. +//! +//! For these, the grammar barely matters: what we need is an *anchor offset*, +//! and even a bogus node provides one (R1, mitigation 3). + +use crate::ctx::{ParseCtx, ParseOptions}; +use crate::edit::Edit; +use crate::error::{CssError, Result}; +use crate::locate::{ + find_at_rules_named, find_top_level_at_rules, top_level_nodes, top_of_file_anchor, AtRuleRef, +}; +use crate::ops::{run, validate_snippet, Outcome}; +use crate::trivia::{absorb_surrounding_blank_line, comment_ranges, deletion_span}; +use biome_css_syntax::CssSyntaxKind; + +/// At-rules that must appear before any style rule, in this order. +const PROLOGUE_FIRST: &[&str] = &["charset", "import", "use", "namespace"]; + +/// The parsed shape of a caller-supplied at-rule line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtRuleSpec { + /// Lowercase name without `@`. + pub name: String, + /// Normalised prelude (whitespace collapsed). + pub prelude: String, + /// The quoted/`url()` target, unquoted, when the prelude has one. + pub target: Option, + /// The exact text to insert, `;`-terminated when it has no block. + pub text: String, +} + +/// Collapse whitespace runs outside of strings. +fn collapse_ws(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut pending = false; + while let Some(c) = chars.next() { + match c { + '"' | '\'' => { + if pending && !out.is_empty() { + out.push(' '); + } + pending = false; + out.push(c); + let quote = c; + let mut escaped = false; + for q in chars.by_ref() { + out.push(q); + if escaped { + escaped = false; + } else if q == '\\' { + escaped = true; + } else if q == quote { + break; + } + } + } + c if c.is_whitespace() => pending = true, + _ => { + if pending && !out.is_empty() { + out.push(' '); + } + pending = false; + out.push(c); + } + } + } + out.trim().to_string() +} + +/// The first quoted string or `url(...)` in a prelude, unquoted. +pub fn at_rule_target(prelude: &str) -> Option { + let p = prelude.trim(); + let bytes = p.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + match bytes[i] { + b'"' | b'\'' => { + let quote = bytes[i]; + let start = i + 1; + let mut j = start; + while j < bytes.len() { + if bytes[j] == b'\\' { + j += 2; + continue; + } + if bytes[j] == quote { + return p.get(start..j).map(|s| s.to_string()); + } + j += 1; + } + return None; + } + _ => { + if p[i..].starts_with("url(") { + let start = i + 4; + let rest = &p[start..]; + let end = rest.find(')')?; + let inner = rest[..end].trim(); + let inner = inner + .trim_start_matches(['"', '\'']) + .trim_end_matches(['"', '\'']); + return Some(inner.to_string()); + } + i += 1; + } + } + } + None +} + +/// Parse a caller-supplied at-rule line such as `@plugin "daisyui";`. +pub fn parse_at_rule_spec(line: &str) -> Result { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + return Err(CssError::InvalidInput(format!( + "at-rule line must start with `@`, got {trimmed:?}" + ))); + } + validate_snippet(trimmed, "at-rule line")?; + + let has_block = trimmed.contains('{'); + let text = if has_block || trimmed.ends_with(';') { + trimmed.to_string() + } else { + format!("{trimmed};") + }; + + let ctx = ParseCtx::new(&text, ParseOptions::default()); + if !ctx.round_trips() { + return Err(CssError::InvalidInput(format!( + "cannot understand at-rule line {trimmed:?}" + ))); + } + let rules = find_top_level_at_rules(&ctx); + let Some(rule) = rules.first() else { + return Err(CssError::InvalidInput(format!( + "{trimmed:?} is not an at-rule" + ))); + }; + if rules.len() > 1 { + return Err(CssError::InvalidInput( + "expected exactly one at-rule".to_string(), + )); + } + + let prelude = collapse_ws(&rule.prelude); + Ok(AtRuleSpec { + name: rule.name.clone(), + target: at_rule_target(&prelude), + prelude, + text, + }) +} + +/// Is `existing` the same at-rule as `spec`, for idempotency purposes? +/// +/// Two at-rules of the same name that name the same target are the same rule -- +/// `@import "tailwindcss";` and `@import "tailwindcss" source(none);` are not +/// two imports of two different things, they are one import written twice. +fn is_equivalent(spec: &AtRuleSpec, existing: &AtRuleRef) -> bool { + if existing.name != spec.name { + return false; + } + let existing_prelude = collapse_ws(&existing.prelude); + match (&spec.target, at_rule_target(&existing_prelude)) { + (Some(a), Some(b)) => a == &b, + _ => existing_prelude == spec.prelude, + } +} + +/// Where a new at-rule of this family should be inserted. +fn insertion_offset(ctx: &ParseCtx, spec: &AtRuleSpec) -> usize { + let comments = comment_ranges(ctx); + + // 1. After the last at-rule with the same name. + if let Some(last) = find_at_rules_named(ctx, &spec.name).last() { + return crate::ops::past_trailing_comment(ctx, &comments, last.end); + } + + // 2. `@charset`/`@import`/`@namespace` must precede style rules, so they go + // after the last at-rule that also belongs at the top, and never after a + // style rule. + let is_prologue_first = PROLOGUE_FIRST.contains(&spec.name.as_str()); + + let mut anchor: Option = None; + for node in top_level_nodes(ctx) { + match node.kind() { + CssSyntaxKind::CSS_AT_RULE => { + let Some(at) = find_top_level_at_rules(ctx) + .into_iter() + .find(|r| r.node == node) + else { + continue; + }; + if is_prologue_first && !PROLOGUE_FIRST.contains(&at.name.as_str()) { + break; + } + anchor = Some(at.end); + } + // A style rule ends the prologue. + CssSyntaxKind::CSS_QUALIFIED_RULE => break, + _ => {} + } + } + + match anchor { + Some(end) => crate::ops::past_trailing_comment(ctx, &comments, end), + None => top_of_file_anchor(ctx), + } +} + +/// Insert `line` at the top level unless an equivalent at-rule is already +/// present (ROADMAP §8, `ensure_at_rule_line`). +pub fn ensure_at_rule_line(source: &str, line: &str, options: ParseOptions) -> Result { + let spec = parse_at_rule_spec(line)?; + run(source, options, |ctx| { + if find_top_level_at_rules(ctx) + .iter() + .any(|r| is_equivalent(&spec, r)) + { + return Ok(vec![]); + } + + let at = insertion_offset(ctx, &spec); + let nl = ctx.nl(); + let indent = ctx.indent_at(at); + + // Insert on its own line, keeping the surrounding line structure. + let text = if at == 0 { + format!("{}{nl}", spec.text) + } else if ctx.source()[..at].ends_with('\n') { + format!("{indent}{}{nl}", spec.text) + } else { + format!("{nl}{indent}{}", spec.text) + }; + Ok(vec![Edit::insert(at, text)]) + }) +} + +/// Remove every top-level at-rule of `name` whose target (or, failing that, +/// whose whole prelude) matches `matching`. `matching` of `None` removes all of +/// them. +pub fn remove_at_rule( + source: &str, + name: &str, + matching: Option<&str>, + options: ParseOptions, +) -> Result { + let want_name = name.trim_start_matches('@').to_lowercase(); + let want = matching.map(collapse_ws); + + run(source, options, |ctx| { + let comments = comment_ranges(ctx); + let mut edits = Vec::new(); + for at in find_top_level_at_rules(ctx) { + if at.name != want_name { + continue; + } + if let Some(w) = &want { + let prelude = collapse_ws(&at.prelude); + let hit = match (at_rule_target(&prelude), at_rule_target(w)) { + (Some(a), Some(b)) => a == b, + (Some(a), None) => a == *w, + _ => prelude == *w, + }; + if !hit { + continue; + } + } + let span = deletion_span(ctx, &comments, at.start, at.end); + let span = absorb_surrounding_blank_line(ctx, span); + edits.push(Edit::delete(span.start, span.end)); + } + Ok(edits) + }) +} + +/// `@import` convenience wrapper: builds the line and delegates. +pub fn add_import( + source: &str, + url: &str, + media: Option<&str>, + options: ParseOptions, +) -> Result { + let url = url.trim(); + if url.is_empty() { + return Err(CssError::InvalidInput("import url is empty".to_string())); + } + if url.contains('"') || url.contains('\n') { + return Err(CssError::InvalidInput(format!( + "import url {url:?} contains characters that cannot be quoted safely" + ))); + } + // Absolute URLs read better as `url(...)`; relative paths as a plain string. + let target = + if url.starts_with("http://") || url.starts_with("https://") || url.starts_with('/') { + format!("url(\"{url}\")") + } else { + format!("\"{url}\"") + }; + let line = match media.map(str::trim).filter(|m| !m.is_empty()) { + Some(m) => format!("@import {target} {m};"), + None => format!("@import {target};"), + }; + ensure_at_rule_line(source, &line, options) +} + +pub fn remove_import(source: &str, url: &str, options: ParseOptions) -> Result { + remove_at_rule(source, "import", Some(url), options) +} + +/// Read-only: is an equivalent at-rule already present? +pub fn has_at_rule(source: &str, line: &str, options: ParseOptions) -> Result { + let spec = parse_at_rule_spec(line)?; + crate::ops::query(source, options, |ctx| { + Ok(find_top_level_at_rules(ctx) + .iter() + .any(|r| is_equivalent(&spec, r))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ensure(src: &str, line: &str) -> Outcome { + ensure_at_rule_line(src, line, ParseOptions::default()).unwrap() + } + + fn remove(src: &str, name: &str, matching: Option<&str>) -> Outcome { + remove_at_rule(src, name, matching, ParseOptions::default()).unwrap() + } + + // -- spec parsing ------------------------------------------------------- + + #[test] + fn parses_a_simple_at_rule_line() { + let s = parse_at_rule_spec("@plugin \"daisyui\";").unwrap(); + assert_eq!(s.name, "plugin"); + assert_eq!(s.target.as_deref(), Some("daisyui")); + assert_eq!(s.text, "@plugin \"daisyui\";"); + } + + #[test] + fn adds_a_missing_semicolon() { + let s = parse_at_rule_spec("@source \"../js\"").unwrap(); + assert_eq!(s.text, "@source \"../js\";"); + } + + #[test] + fn parses_a_url_target() { + let s = parse_at_rule_spec("@import url(\"/a/b.css\");").unwrap(); + assert_eq!(s.target.as_deref(), Some("/a/b.css")); + } + + #[test] + fn parses_a_block_at_rule() { + let s = parse_at_rule_spec("@plugin \"x\" { themes: false; }").unwrap(); + assert_eq!(s.name, "plugin"); + assert!(s.text.ends_with('}')); + } + + #[test] + fn rejects_text_that_is_not_an_at_rule() { + assert!(parse_at_rule_spec(".a { color: red; }").is_err()); + assert!(parse_at_rule_spec("").is_err()); + } + + #[test] + fn rejects_an_unbalanced_at_rule_line() { + assert!(parse_at_rule_spec("@plugin \"x\" {").is_err()); + } + + // -- insertion ---------------------------------------------------------- + + #[test] + fn inserts_into_an_empty_file() { + let o = ensure("", "@import \"tailwindcss\";"); + assert!(o.changed); + assert_eq!(o.source, "@import \"tailwindcss\";\n"); + } + + #[test] + fn inserts_after_the_last_at_rule_of_the_same_name() { + let src = "@import \"a\";\n@import \"b\";\n\n.x { color: red; }\n"; + let o = ensure(src, "@import \"c\";"); + assert_eq!( + o.source, + "@import \"a\";\n@import \"b\";\n@import \"c\";\n\n.x { color: red; }\n" + ); + } + + #[test] + fn inserts_at_the_end_of_the_prologue_when_the_family_is_new() { + let src = "@import \"tailwindcss\";\n@source \"../js\";\n\n.x { color: red; }\n"; + let o = ensure(src, "@plugin \"daisyui\";"); + assert_eq!( + o.source, + "@import \"tailwindcss\";\n@source \"../js\";\n@plugin \"daisyui\";\n\n.x { color: red; }\n" + ); + } + + #[test] + fn an_import_never_lands_after_a_style_rule() { + let src = ".x { color: red; }\n@plugin \"a\";\n"; + let o = ensure(src, "@import \"b\";"); + assert!(o.source.starts_with("@import \"b\";\n.x")); + } + + #[test] + fn a_plugin_lands_after_the_prologue_not_before_it() { + let src = "@charset \"utf-8\";\n@import \"a\";\n.x { color: red; }\n"; + let o = ensure(src, "@plugin \"p\";"); + assert_eq!( + o.source, + "@charset \"utf-8\";\n@import \"a\";\n@plugin \"p\";\n.x { color: red; }\n" + ); + } + + #[test] + fn inserts_below_a_file_header_comment() { + let src = "/* App styles.\n Two lines. */\n\n.x { color: red; }\n"; + let o = ensure(src, "@import \"a\";"); + assert_eq!( + o.source, + "/* App styles.\n Two lines. */\n@import \"a\";\n\n.x { color: red; }\n" + ); + } + + #[test] + fn inserts_above_a_comment_that_documents_the_first_rule() { + let src = "/* about .x */\n.x { color: red; }\n"; + let o = ensure(src, "@import \"a\";"); + assert_eq!( + o.source, + "/* about .x */\n@import \"a\";\n.x { color: red; }\n" + ); + } + + #[test] + fn uses_the_files_newline_style() { + let src = "@import \"a\";\r\n.x { color: red; }\r\n"; + let o = ensure(src, "@import \"b\";"); + assert_eq!( + o.source, + "@import \"a\";\r\n@import \"b\";\r\n.x { color: red; }\r\n" + ); + } + + #[test] + fn handles_a_file_with_no_trailing_newline() { + let o = ensure(".x { color: red; }", "@import \"a\";"); + assert_eq!(o.source, "@import \"a\";\n.x { color: red; }"); + } + + #[test] + fn preserves_a_bom() { + let o = ensure("\u{feff}.x { color: red; }\n", "@import \"a\";"); + assert_eq!(o.source, "\u{feff}@import \"a\";\n.x { color: red; }\n"); + } + + // -- idempotency -------------------------------------------------------- + + #[test] + fn is_idempotent() { + let src = "@import \"a\";\n.x { color: red; }\n"; + let once = ensure(src, "@plugin \"p\";"); + let twice = ensure(&once.source, "@plugin \"p\";"); + assert!(once.changed); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn an_existing_rule_with_the_same_target_counts_as_present() { + let src = "@import \"tailwindcss\" source(none);\n"; + let o = ensure(src, "@import \"tailwindcss\";"); + assert!(!o.changed); + assert_eq!(o.source, src); + } + + #[test] + fn quoting_style_does_not_create_a_duplicate() { + let src = "@plugin '../vendor/daisyui';\n"; + let o = ensure(src, "@plugin \"../vendor/daisyui\";"); + assert!(!o.changed); + } + + #[test] + fn a_different_target_is_added() { + let src = "@plugin \"a\";\n"; + let o = ensure(src, "@plugin \"b\";"); + assert!(o.changed); + assert_eq!(o.source, "@plugin \"a\";\n@plugin \"b\";\n"); + } + + #[test] + fn has_at_rule_agrees_with_ensure() { + let src = "@plugin \"a\";\n"; + assert!(has_at_rule(src, "@plugin \"a\";", ParseOptions::default()).unwrap()); + assert!(!has_at_rule(src, "@plugin \"b\";", ParseOptions::default()).unwrap()); + } + + // -- comment preservation ---------------------------------------------- + + #[test] + fn insertion_keeps_every_comment() { + let src = "/* one */\n@import \"a\"; /* two */\n/* three */\n.x { color: red; }\n"; + let o = ensure(src, "@import \"b\";"); + for c in ["/* one */", "/* two */", "/* three */"] { + assert!(o.source.contains(c), "lost {c}"); + } + assert_eq!( + o.source, + "/* one */\n@import \"a\"; /* two */\n@import \"b\";\n/* three */\n.x { color: red; }\n" + ); + } + + // -- removal ------------------------------------------------------------ + + #[test] + fn removes_a_matching_at_rule() { + let src = "@import \"a\";\n@import \"b\";\n.x { color: red; }\n"; + let o = remove(src, "import", Some("a")); + assert!(o.changed); + assert_eq!(o.source, "@import \"b\";\n.x { color: red; }\n"); + } + + #[test] + fn removes_every_at_rule_of_a_name_when_unfiltered() { + let src = "@import \"a\";\n@import \"b\";\n.x { color: red; }\n"; + let o = remove(src, "import", None); + assert_eq!(o.source, ".x { color: red; }\n"); + } + + #[test] + fn removing_something_absent_is_a_no_op() { + let src = ".x { color: red; }\n"; + let o = remove(src, "import", Some("a")); + assert!(!o.changed); + assert_eq!(o.source, src); + } + + #[test] + fn removal_takes_the_adjacent_comment_but_not_the_header() { + let src = "/* ===== Imports ===== */\n/* the app css */\n@import \"a\";\n@import \"b\";\n"; + let o = remove(src, "import", Some("a")); + assert_eq!(o.source, "/* ===== Imports ===== */\n@import \"b\";\n"); + } + + #[test] + fn removal_is_idempotent() { + let src = "@import \"a\";\n@import \"b\";\n"; + let once = remove(src, "import", Some("a")); + let twice = remove(&once.source, "import", Some("a")); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn remove_import_matches_a_url_written_either_way() { + let src = "@import url(\"/a.css\");\n@import \"b\";\n"; + let o = remove_import(src, "/a.css", ParseOptions::default()).unwrap(); + assert_eq!(o.source, "@import \"b\";\n"); + } + + // -- add_import --------------------------------------------------------- + + #[test] + fn add_import_quotes_a_relative_path() { + let o = add_import("", "styles.css", None, ParseOptions::default()).unwrap(); + assert_eq!(o.source, "@import \"styles.css\";\n"); + } + + #[test] + fn add_import_wraps_an_absolute_url() { + let o = add_import("", "https://x/y.css", None, ParseOptions::default()).unwrap(); + assert_eq!(o.source, "@import url(\"https://x/y.css\");\n"); + } + + #[test] + fn add_import_carries_a_media_query() { + let o = add_import( + "", + "m.css", + Some("screen and (max-width: 768px)"), + ParseOptions::default(), + ) + .unwrap(); + assert_eq!( + o.source, + "@import \"m.css\" screen and (max-width: 768px);\n" + ); + } + + #[test] + fn add_import_is_idempotent_across_media_queries() { + let src = "@import \"m.css\" screen;\n"; + let o = add_import(src, "m.css", None, ParseOptions::default()).unwrap(); + assert!(!o.changed); + } + + #[test] + fn add_import_rejects_an_unquotable_url() { + assert!(add_import("", "a\"b", None, ParseOptions::default()).is_err()); + assert!(add_import("", " ", None, ParseOptions::default()).is_err()); + } + + // -- targets ------------------------------------------------------------ + + #[test] + fn extracts_targets_from_preludes() { + assert_eq!(at_rule_target("\"a/b.css\""), Some("a/b.css".into())); + assert_eq!(at_rule_target("'a'"), Some("a".into())); + assert_eq!(at_rule_target("url(\"x\")"), Some("x".into())); + assert_eq!(at_rule_target("url(x)"), Some("x".into())); + assert_eq!(at_rule_target("base, components"), None); + } +} diff --git a/native/igniter_css/src/ops/declaration.rs b/native/igniter_css/src/ops/declaration.rs new file mode 100644 index 0000000..3cff39c --- /dev/null +++ b/native/igniter_css/src/ops/declaration.rs @@ -0,0 +1,827 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Declaration-level codemods: the ops Igniter installers reach for most. + +use crate::ctx::ParseOptions; +use crate::edit::Edit; +use crate::error::{CssError, Result}; +use crate::locate::{ + declaration_lists, declarations_in, find_declaration, find_declarations, normalize_property, +}; +use crate::ops::rule::{append_rule_edits, append_to_body, resolve_rule}; +use crate::ops::{query, run, validate_snippet, Outcome}; +use crate::trivia::{comment_ranges, deletion_span}; + +/// What to do with the `!important` flag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Important { + /// Leave whatever the declaration already has. + #[default] + Keep, + Set, + Unset, +} + +impl Important { + pub fn from_option(value: Option) -> Self { + match value { + None => Self::Keep, + Some(true) => Self::Set, + Some(false) => Self::Unset, + } + } + + fn resolve(self, current: bool) -> bool { + match self { + Self::Keep => current, + Self::Set => true, + Self::Unset => false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SetOptions { + pub important: Important, + /// Create `selector { property: value; }` when the rule does not exist. + /// Without it, a missing rule is a `NotFound` error. + pub create_rule: bool, +} + +fn check_property_and_value(property: &str, value: &str) -> Result<(String, String)> { + let property = property.trim(); + let value = value.trim(); + if property.is_empty() { + return Err(CssError::InvalidInput("property name is empty".to_string())); + } + if value.is_empty() { + return Err(CssError::InvalidInput("value is empty".to_string())); + } + if property.contains([':', ';', '{', '}']) { + return Err(CssError::InvalidInput(format!( + "property name {property:?} contains a delimiter" + ))); + } + validate_snippet(value, "value")?; + if value.contains('{') || value.contains('}') { + return Err(CssError::InvalidInput(format!( + "value {value:?} must not contain braces" + ))); + } + // A `;` outside of a string, comment or `url(...)` would silently turn one + // declaration into two. + if crate::ops::split_declarations(value).len() > 1 { + return Err(CssError::InvalidInput(format!( + "value {value:?} contains a `;`; pass one declaration at a time" + ))); + } + // The flag is controlled by `SetOptions::important`, not by the value text. + if value.to_lowercase().contains("!important") { + return Err(CssError::InvalidInput( + "put `!important` in the options, not in the value".to_string(), + )); + } + Ok((property.to_string(), value.to_string())) +} + +/// Update `property` inside the rule matching `selector`, or append it. +/// +/// When the property is already present only its **value** bytes are replaced +/// (rule E), so an inline comment on that line and any `!important` the caller +/// did not ask to change both survive untouched. +pub fn set_declaration( + source: &str, + selector: &str, + property: &str, + value: &str, + set: SetOptions, + options: ParseOptions, +) -> Result { + let (property, value) = check_property_and_value(property, value)?; + + run(source, options, |ctx| { + let Some(rule) = resolve_rule(ctx, selector)? else { + if !set.create_rule { + return Err(CssError::NotFound(format!( + "no top-level rule with selector {selector:?}" + ))); + } + let suffix = if set.important == Important::Set { + " !important" + } else { + "" + }; + let decl = format!("{property}: {value}{suffix};"); + return Ok(append_rule_edits(ctx, selector.trim(), &[decl])); + }; + + match find_declaration(ctx, &rule, &property) { + Some(d) => { + let want_important = set.important.resolve(d.important); + if want_important == d.important { + // Value bytes only -- the minimal possible diff. + Ok(vec![Edit::replace(d.value_start, d.value_end, value)]) + } else if want_important { + Ok(vec![Edit::replace( + d.value_start, + d.value_end, + format!("{value} !important"), + )]) + } else { + let (_, imp_end) = d + .important_range + .expect("important flag present when d.important"); + Ok(vec![Edit::replace(d.value_start, imp_end, value)]) + } + } + None => { + let suffix = if set.important == Important::Set { + " !important" + } else { + "" + }; + let decl = format!("{property}: {value}{suffix};"); + Ok(append_to_body(ctx, &rule, &decl)) + } + } + }) +} + +/// Remove every declaration of `property` from the rule matching `selector`, +/// together with the comments those declarations own (rule D). +pub fn remove_declaration( + source: &str, + selector: &str, + property: &str, + options: ParseOptions, +) -> Result { + let property = property.trim().to_string(); + if property.is_empty() { + return Err(CssError::InvalidInput("property name is empty".to_string())); + } + + run(source, options, |ctx| { + let Some(rule) = resolve_rule(ctx, selector)? else { + // Nothing to remove from a rule that isn't there. + return Ok(vec![]); + }; + let comments = comment_ranges(ctx); + Ok(find_declarations(ctx, &rule, &property) + .into_iter() + .map(|d| { + let span = deletion_span(ctx, &comments, d.start, d.end); + Edit::delete(span.start, span.end) + }) + .collect()) + }) +} + +/// Add vendor-prefixed copies of `property` next to every occurrence of it, +/// anywhere in the file. +/// +/// Each prefixed declaration is inserted immediately **before** the unprefixed +/// one, which is the ordering browsers expect: the standard property wins. +/// Prefixes already present in the same block are skipped, so re-running the op +/// is a no-op (rule A). +pub fn add_vendor_prefixes( + source: &str, + property: &str, + prefixes: &[String], + options: ParseOptions, +) -> Result { + let property = normalize_property(property); + if property.is_empty() { + return Err(CssError::InvalidInput("property name is empty".to_string())); + } + for p in prefixes { + if p.trim().is_empty() || p.contains([':', ';', '{', '}']) { + return Err(CssError::InvalidInput(format!("invalid prefix {p:?}"))); + } + } + if prefixes.is_empty() { + return Ok(Outcome::unchanged(source)); + } + + run(source, options, |ctx| { + let nl = ctx.nl(); + let mut edits = Vec::new(); + + for (_, decls) in declaration_lists(ctx) { + let present: Vec = decls + .iter() + .map(|d| normalize_property(&d.property)) + .collect(); + + for d in &decls { + if normalize_property(&d.property) != property { + continue; + } + let mut lines = Vec::new(); + for prefix in prefixes { + let prefixed = normalize_property(&format!("{}{property}", prefix.trim())); + if present.contains(&prefixed) { + continue; + } + let flag = if d.important { " !important" } else { "" }; + lines.push(format!( + "{prefixed}: {}{flag};", + ctx.source()[d.value_start..d.value_end].trim() + )); + } + if lines.is_empty() { + continue; + } + + // Insert at the start of the declaration's own line so the new + // lines inherit its indentation exactly. + if ctx.is_at_line_start(d.start) { + let indent = ctx.indent_at(d.start); + let text = lines + .iter() + .map(|l| format!("{indent}{l}{nl}")) + .collect::(); + edits.push(Edit::insert(ctx.line_start(d.start), text)); + } else { + let text = format!("{} ", lines.join(" ")); + edits.push(Edit::insert(d.start, text)); + } + } + } + Ok(edits) + }) +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +/// The value of `property` in the rule matching `selector`, as written. +pub fn get_declaration( + source: &str, + selector: &str, + property: &str, + options: ParseOptions, +) -> Result> { + query(source, options, |ctx| { + let Some(rule) = resolve_rule(ctx, selector)? else { + return Ok(None); + }; + Ok(find_declaration(ctx, &rule, property).map(|d| { + if d.important { + format!("{} !important", d.value_raw.trim()) + } else { + d.value_raw.trim().to_string() + } + })) + }) +} + +pub fn has_declaration( + source: &str, + selector: &str, + property: &str, + options: ParseOptions, +) -> Result { + Ok(get_declaration(source, selector, property, options)?.is_some()) +} + +/// Every declaration in the rule matching `selector`, as `(property, value)` +/// pairs in source order. +pub fn get_rule_declarations( + source: &str, + selector: &str, + options: ParseOptions, +) -> Result>> { + query(source, options, |ctx| { + let Some(rule) = resolve_rule(ctx, selector)? else { + return Ok(None); + }; + Ok(Some( + declarations_in(ctx, &rule) + .into_iter() + .map(|d| { + let value = if d.important { + format!("{} !important", d.value_raw.trim()) + } else { + d.value_raw.trim().to_string() + }; + (d.property, value) + }) + .collect(), + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts() -> ParseOptions { + ParseOptions::default() + } + + fn set(src: &str, sel: &str, prop: &str, val: &str) -> Outcome { + set_declaration(src, sel, prop, val, SetOptions::default(), opts()).unwrap() + } + + fn set_with(src: &str, sel: &str, prop: &str, val: &str, o: SetOptions) -> Outcome { + set_declaration(src, sel, prop, val, o, opts()).unwrap() + } + + fn remove(src: &str, sel: &str, prop: &str) -> Outcome { + remove_declaration(src, sel, prop, opts()).unwrap() + } + + // -- set: updating an existing declaration ------------------------------ + + #[test] + fn updates_an_existing_value() { + let o = set(".a {\n color: red;\n}\n", ".a", "color", "blue"); + assert!(o.changed); + assert_eq!(o.source, ".a {\n color: blue;\n}\n"); + } + + #[test] + fn updating_touches_only_the_value_bytes() { + let src = ".a {\n color: red; /* the brand */\n margin: 0;\n}\n"; + let o = set(src, ".a", "color", "blue"); + assert_eq!( + o.source, + ".a {\n color: blue; /* the brand */\n margin: 0;\n}\n" + ); + } + + #[test] + fn updating_preserves_an_existing_important_flag() { + let src = ".a { color: red !important; }\n"; + let o = set(src, ".a", "color", "blue"); + assert_eq!(o.source, ".a { color: blue !important; }\n"); + } + + #[test] + fn can_add_an_important_flag() { + let src = ".a { color: red; }\n"; + let o = set_with( + src, + ".a", + "color", + "blue", + SetOptions { + important: Important::Set, + ..Default::default() + }, + ); + assert_eq!(o.source, ".a { color: blue !important; }\n"); + } + + #[test] + fn can_remove_an_important_flag() { + let src = ".a { color: red !important; }\n"; + let o = set_with( + src, + ".a", + "color", + "blue", + SetOptions { + important: Important::Unset, + ..Default::default() + }, + ); + assert_eq!(o.source, ".a { color: blue; }\n"); + } + + #[test] + fn updates_a_custom_property() { + let src = ":root {\n --brand: #fff;\n}\n"; + let o = set(src, ":root", "--brand", "#000"); + assert_eq!(o.source, ":root {\n --brand: #000;\n}\n"); + } + + #[test] + fn updates_the_last_of_a_repeated_property() { + let src = ".a {\n color: red;\n color: green;\n}\n"; + let o = set(src, ".a", "color", "blue"); + assert_eq!(o.source, ".a {\n color: red;\n color: blue;\n}\n"); + } + + #[test] + fn a_multi_token_value_is_replaced_whole() { + let src = ".a { margin: 0 auto 10px; }\n"; + let o = set(src, ".a", "margin", "1rem"); + assert_eq!(o.source, ".a { margin: 1rem; }\n"); + } + + #[test] + fn accepts_a_function_value() { + let o = set(".a { color: red; }\n", ".a", "color", "var(--brand, #fff)"); + assert_eq!(o.source, ".a { color: var(--brand, #fff); }\n"); + } + + #[test] + fn accepts_a_url_value_containing_a_semicolon() { + let o = set( + ".a { background: none; }\n", + ".a", + "background", + "url(data:image/svg+xml;base64,AA==)", + ); + assert_eq!( + o.source, + ".a { background: url(data:image/svg+xml;base64,AA==); }\n" + ); + } + + // -- set: appending a new declaration ----------------------------------- + + #[test] + fn appends_a_missing_property() { + let src = ".a {\n color: red;\n}\n"; + let o = set(src, ".a", "margin", "0"); + assert_eq!(o.source, ".a {\n color: red;\n margin: 0;\n}\n"); + } + + #[test] + fn appends_into_an_empty_rule() { + let o = set(".a {\n}\n", ".a", "color", "red"); + assert_eq!(o.source, ".a {\n color: red;\n}\n"); + } + + #[test] + fn appends_inline_for_a_single_line_rule() { + let o = set(".a { color: red; }\n", ".a", "margin", "0"); + assert_eq!(o.source, ".a { color: red; margin: 0; }\n"); + } + + #[test] + fn appending_matches_the_files_indentation() { + let src = ".a {\n color: red;\n}\n"; + let o = set(src, ".a", "margin", "0"); + assert_eq!(o.source, ".a {\n color: red;\n margin: 0;\n}\n"); + } + + #[test] + fn appending_uses_tabs_when_the_file_does() { + let src = ".a {\n\tcolor: red;\n}\n"; + let o = set(src, ".a", "margin", "0"); + assert_eq!(o.source, ".a {\n\tcolor: red;\n\tmargin: 0;\n}\n"); + } + + #[test] + fn appending_uses_crlf_when_the_file_does() { + let src = ".a {\r\n color: red;\r\n}\r\n"; + let o = set(src, ".a", "margin", "0"); + assert_eq!(o.source, ".a {\r\n color: red;\r\n margin: 0;\r\n}\r\n"); + } + + #[test] + fn appending_lands_after_a_trailing_comment() { + let src = ".a {\n color: red; /* note */\n}\n"; + let o = set(src, ".a", "margin", "0"); + assert_eq!( + o.source, + ".a {\n color: red; /* note */\n margin: 0;\n}\n" + ); + } + + #[test] + fn appending_terminates_the_previous_declaration() { + let src = ".a {\n color: red\n}\n"; + let o = set(src, ".a", "margin", "0"); + assert_eq!(o.source, ".a {\n color: red;\n margin: 0;\n}\n"); + } + + #[test] + fn appending_keeps_a_dangling_comment_at_the_end_of_the_body() { + let src = ".a {\n color: red;\n /* end of block */\n}\n"; + let o = set(src, ".a", "margin", "0"); + assert!(o.source.contains("/* end of block */")); + } + + // -- set: missing rules ------------------------------------------------- + + #[test] + fn a_missing_rule_is_an_error_by_default() { + let e = set_declaration( + ".a {}\n", + ".zz", + "color", + "red", + SetOptions::default(), + opts(), + ) + .unwrap_err(); + assert!(matches!(e, CssError::NotFound(_))); + } + + #[test] + fn a_missing_rule_can_be_created_on_request() { + let o = set_with( + ".a {}\n", + ".hide-scrollbar", + "display", + "none", + SetOptions { + create_rule: true, + ..Default::default() + }, + ); + assert_eq!( + o.source, + ".a {}\n\n.hide-scrollbar {\n display: none;\n}\n" + ); + } + + #[test] + fn an_ambiguous_selector_is_an_error() { + let e = set_declaration( + ".a {}\n.a {}\n", + ".a", + "color", + "red", + SetOptions::default(), + opts(), + ) + .unwrap_err(); + assert!(matches!(e, CssError::AmbiguousSelector { count: 2, .. })); + } + + // -- set: idempotency and validation ------------------------------------ + + #[test] + fn set_is_idempotent() { + let src = ".a {\n color: red;\n}\n"; + let once = set(src, ".a", "margin", "0"); + let twice = set(&once.source, ".a", "margin", "0"); + assert!(once.changed); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn writing_back_the_same_value_reports_no_change() { + let src = ".a {\n color: red;\n}\n"; + let o = set(src, ".a", "color", "red"); + assert!(!o.changed); + assert_eq!(o.source, src); + } + + #[test] + fn rejects_a_value_carrying_its_own_delimiters() { + for (prop, value) in [ + ("color", "red; margin: 0"), + ("color", "red !important"), + ("color:x", "red"), + ("", "red"), + ("color", " "), + ("color", "red } .b {"), + ] { + assert!( + set_declaration(".a{}", ".a", prop, value, SetOptions::default(), opts()).is_err(), + "should have rejected {prop:?}: {value:?}" + ); + } + } + + // -- remove ------------------------------------------------------------- + + #[test] + fn removes_a_declaration_and_its_line() { + let src = ".a {\n color: red;\n margin: 0;\n}\n"; + let o = remove(src, ".a", "color"); + assert!(o.changed); + assert_eq!(o.source, ".a {\n margin: 0;\n}\n"); + } + + #[test] + fn removing_takes_the_trailing_comment_with_it() { + let src = ".a {\n color: red; /* legacy */\n margin: 0;\n}\n"; + let o = remove(src, ".a", "color"); + assert_eq!(o.source, ".a {\n margin: 0;\n}\n"); + } + + #[test] + fn removing_takes_an_adjacent_comment_above() { + let src = ".a {\n /* brand */\n color: red;\n margin: 0;\n}\n"; + let o = remove(src, ".a", "color"); + assert_eq!(o.source, ".a {\n margin: 0;\n}\n"); + } + + #[test] + fn removing_keeps_a_comment_separated_by_a_blank_line() { + let src = ".a {\n /* about the block */\n\n color: red;\n margin: 0;\n}\n"; + let o = remove(src, ".a", "color"); + assert_eq!( + o.source, + ".a {\n /* about the block */\n\n margin: 0;\n}\n" + ); + } + + #[test] + fn removing_keeps_a_section_header() { + let src = ".a {\n /* ===== colours ===== */\n color: red;\n margin: 0;\n}\n"; + let o = remove(src, ".a", "color"); + assert_eq!( + o.source, + ".a {\n /* ===== colours ===== */\n margin: 0;\n}\n" + ); + } + + #[test] + fn removes_every_copy_of_a_repeated_property() { + let src = ".a {\n color: red;\n margin: 0;\n color: blue;\n}\n"; + let o = remove(src, ".a", "color"); + assert_eq!(o.source, ".a {\n margin: 0;\n}\n"); + } + + #[test] + fn removing_an_absent_property_is_a_no_op() { + let src = ".a {\n color: red;\n}\n"; + let o = remove(src, ".a", "margin"); + assert!(!o.changed); + assert_eq!(o.source, src); + } + + #[test] + fn removing_from_an_absent_rule_is_a_no_op() { + let src = ".a {\n color: red;\n}\n"; + let o = remove(src, ".zz", "color"); + assert!(!o.changed); + } + + #[test] + fn remove_is_idempotent() { + let src = ".a {\n color: red;\n margin: 0;\n}\n"; + let once = remove(src, ".a", "color"); + let twice = remove(&once.source, ".a", "color"); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn removing_the_only_declaration_leaves_an_empty_rule() { + let src = ".a {\n color: red;\n}\n"; + let o = remove(src, ".a", "color"); + assert_eq!(o.source, ".a {\n}\n"); + } + + // -- vendor prefixes ---------------------------------------------------- + + fn prefixes(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn adds_vendor_prefixes_above_the_standard_property() { + let src = ".a {\n user-select: none;\n}\n"; + let o = add_vendor_prefixes( + src, + "user-select", + &prefixes(&["-webkit-", "-moz-"]), + opts(), + ) + .unwrap(); + assert_eq!( + o.source, + ".a {\n -webkit-user-select: none;\n -moz-user-select: none;\n user-select: none;\n}\n" + ); + } + + #[test] + fn prefixes_every_occurrence_in_the_file() { + let src = + ".a { user-select: none; }\n@media print {\n .b {\n user-select: text;\n }\n}\n"; + let o = add_vendor_prefixes(src, "user-select", &prefixes(&["-webkit-"]), opts()).unwrap(); + assert!(o.source.contains("-webkit-user-select: none;")); + assert!(o.source.contains("-webkit-user-select: text;")); + } + + #[test] + fn does_nothing_when_the_property_is_absent() { + let src = ".a {\n color: red;\n}\n"; + let o = add_vendor_prefixes(src, "user-select", &prefixes(&["-webkit-"]), opts()).unwrap(); + assert!(!o.changed); + assert_eq!(o.source, src); + } + + #[test] + fn an_empty_prefix_list_is_a_no_op() { + let src = ".a {\n user-select: none;\n}\n"; + let o = add_vendor_prefixes(src, "user-select", &[], opts()).unwrap(); + assert!(!o.changed); + } + + #[test] + fn vendor_prefixes_are_idempotent() { + let src = ".a {\n user-select: none;\n}\n"; + let once = add_vendor_prefixes( + src, + "user-select", + &prefixes(&["-webkit-", "-moz-"]), + opts(), + ) + .unwrap(); + let twice = add_vendor_prefixes( + &once.source, + "user-select", + &prefixes(&["-webkit-", "-moz-"]), + opts(), + ) + .unwrap(); + assert!(once.changed); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn an_already_present_prefix_is_skipped() { + let src = ".a {\n -webkit-user-select: none;\n user-select: none;\n}\n"; + let o = add_vendor_prefixes( + src, + "user-select", + &prefixes(&["-webkit-", "-moz-"]), + opts(), + ) + .unwrap(); + assert_eq!( + o.source, + ".a {\n -webkit-user-select: none;\n -moz-user-select: none;\n user-select: none;\n}\n" + ); + } + + #[test] + fn prefixed_copies_carry_the_important_flag() { + let src = ".a {\n user-select: none !important;\n}\n"; + let o = add_vendor_prefixes(src, "user-select", &prefixes(&["-webkit-"]), opts()).unwrap(); + assert!(o.source.contains("-webkit-user-select: none !important;")); + } + + #[test] + fn prefixing_preserves_comments() { + let src = ".a {\n /* no text selection */\n user-select: none; /* everywhere */\n}\n"; + let o = add_vendor_prefixes(src, "user-select", &prefixes(&["-webkit-"]), opts()).unwrap(); + assert_eq!( + o.source, + ".a {\n /* no text selection */\n -webkit-user-select: none;\n user-select: none; /* everywhere */\n}\n" + ); + } + + #[test] + fn prefixing_a_single_line_rule_stays_inline() { + let src = ".a { user-select: none; }\n"; + let o = add_vendor_prefixes(src, "user-select", &prefixes(&["-webkit-"]), opts()).unwrap(); + assert_eq!( + o.source, + ".a { -webkit-user-select: none; user-select: none; }\n" + ); + } + + #[test] + fn rejects_a_malformed_prefix() { + assert!(add_vendor_prefixes(".a{}", "x", &prefixes(&["a;b"]), opts()).is_err()); + assert!(add_vendor_prefixes(".a{}", "", &prefixes(&["-webkit-"]), opts()).is_err()); + } + + // -- queries ------------------------------------------------------------ + + #[test] + fn reads_a_declaration_value() { + let src = ".a {\n color: red;\n}\n"; + assert_eq!( + get_declaration(src, ".a", "color", opts()).unwrap(), + Some("red".to_string()) + ); + assert_eq!(get_declaration(src, ".a", "margin", opts()).unwrap(), None); + assert_eq!(get_declaration(src, ".zz", "color", opts()).unwrap(), None); + } + + #[test] + fn reads_a_value_with_its_important_flag() { + let src = ".a { color: red !important; }\n"; + assert_eq!( + get_declaration(src, ".a", "color", opts()).unwrap(), + Some("red !important".to_string()) + ); + } + + #[test] + fn has_declaration_agrees_with_get() { + let src = ".a { color: red; }\n"; + assert!(has_declaration(src, ".a", "color", opts()).unwrap()); + assert!(!has_declaration(src, ".a", "margin", opts()).unwrap()); + } + + #[test] + fn reads_all_declarations_of_a_rule() { + let src = ".a {\n color: red;\n margin: 0 auto;\n}\n"; + assert_eq!( + get_rule_declarations(src, ".a", opts()).unwrap(), + Some(vec![ + ("color".into(), "red".into()), + ("margin".into(), "0 auto".into()), + ]) + ); + assert_eq!(get_rule_declarations(src, ".zz", opts()).unwrap(), None); + } +} diff --git a/native/igniter_css/src/ops/mod.rs b/native/igniter_css/src/ops/mod.rs new file mode 100644 index 0000000..1ccc960 --- /dev/null +++ b/native/igniter_css/src/ops/mod.rs @@ -0,0 +1,377 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Phase 3 codemods. Every op in here obeys the shared rules from ROADMAP §8: +//! +//! * **A. Idempotent** -- check then edit; if the desired state already holds, +//! produce zero edits and report `changed: false`. +//! * **B. Indentation** -- inserted lines copy the indentation of the sibling +//! they land next to; empty bodies use the file's inferred indent unit. +//! * **C. Newlines** -- always `ctx.nl()`, never a hardcoded `\n`. +//! * **D. Comment ownership on delete** -- see [`crate::trivia`]. +//! * **E. Value-only replacement** -- changing a value edits the value range +//! alone, so inline comments and `!important` survive. + +pub mod at_rule; +pub mod declaration; +pub mod rule; +pub mod tidy; + +use crate::ctx::{ParseCtx, ParseOptions}; +use crate::edit::{apply_edits, prune_noop_edits, Edit}; +use crate::error::{CssError, Result}; + +/// What every mutating op returns. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + pub source: String, + pub changed: bool, + pub diagnostics: Vec, +} + +impl Outcome { + pub fn unchanged(source: impl Into) -> Self { + Self { + source: source.into(), + changed: false, + diagnostics: Vec::new(), + } + } + + pub fn with_diagnostic(mut self, msg: impl Into) -> Self { + self.diagnostics.push(msg.into()); + self + } +} + +/// Run a codemod end to end. +/// +/// The round-trip assertion here is the enforcement point for hard constraint +/// #4: if the parse does not reproduce the source byte for byte we cannot trust +/// any offset it gives us, so we refuse to patch and leave the file untouched +/// rather than risk a wrong edit. +pub fn run(source: &str, options: ParseOptions, build: F) -> Result +where + F: FnOnce(&ParseCtx) -> Result>, +{ + let ctx = ParseCtx::new(source, options); + if !ctx.round_trips() { + return Err(CssError::Unparseable( + "the parser did not reproduce the input byte for byte".to_string(), + )); + } + // Offsets in an unbalanced file do not mean what they appear to mean: a + // "top-level" insertion would land inside an unterminated block. Refuse. + if !ctx.braces_are_balanced() { + return Err(CssError::Unparseable( + "braces are unbalanced; refusing to patch".to_string(), + )); + } + + let edits = prune_noop_edits(ctx.source(), build(&ctx)?); + if edits.is_empty() { + return Ok(Outcome::unchanged(source)); + } + + let patched = apply_edits(ctx.source(), edits)?; + let patched = ctx.restore_bom(patched); + let changed = patched != source; + Ok(Outcome { + source: patched, + changed, + diagnostics: Vec::new(), + }) +} + +/// Read-only equivalent of [`run`], for the query ops. +pub fn query(source: &str, options: ParseOptions, f: F) -> Result +where + F: FnOnce(&ParseCtx) -> Result, +{ + let ctx = ParseCtx::new(source, options); + if !ctx.round_trips() { + return Err(CssError::Unparseable( + "the parser did not reproduce the input byte for byte".to_string(), + )); + } + f(&ctx) +} + +// --------------------------------------------------------------------------- +// Shared insertion helpers +// --------------------------------------------------------------------------- + +/// Re-indent a caller-supplied block of text to `indent`, preserving its own +/// relative nesting. Blank lines stay blank rather than becoming trailing +/// whitespace. +pub fn reindent(text: &str, indent: &str, nl: &str) -> String { + let lines: Vec<&str> = text.trim_matches(['\r', '\n']).split('\n').collect(); + + // The smallest indentation across non-blank lines is the block's own base. + let base = lines + .iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.len() - l.trim_start_matches([' ', '\t']).len()) + .min() + .unwrap_or(0); + + lines + .iter() + .map(|line| { + let line = line.trim_end_matches('\r'); + if line.trim().is_empty() { + String::new() + } else { + format!("{indent}{}", &line[base.min(line.len())..]) + } + }) + .collect::>() + .join(nl) +} + +/// Split caller-supplied declaration text (`"color: red; margin: 0"`) into +/// individual `"color: red;"` statements, respecting strings, comments and +/// nested parentheses so a `;` inside `url(...)` or a comment does not split. +pub fn split_declarations(text: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut chars = text.chars().peekable(); + let mut depth = 0usize; + + while let Some(c) = chars.next() { + match c { + '"' | '\'' => { + current.push(c); + let quote = c; + let mut escaped = false; + for q in chars.by_ref() { + current.push(q); + if escaped { + escaped = false; + } else if q == '\\' { + escaped = true; + } else if q == quote { + break; + } + } + } + '/' if chars.peek() == Some(&'*') => { + current.push(c); + current.push(chars.next().unwrap()); + let mut prev = '\0'; + for q in chars.by_ref() { + current.push(q); + if prev == '*' && q == '/' { + break; + } + prev = q; + } + } + '(' => { + depth += 1; + current.push(c); + } + ')' => { + depth = depth.saturating_sub(1); + current.push(c); + } + ';' if depth == 0 => { + if !current.trim().is_empty() { + out.push(format!("{};", current.trim())); + } + current.clear(); + } + _ => current.push(c), + } + } + if !current.trim().is_empty() { + out.push(format!("{};", current.trim())); + } + out +} + +/// Does this text already end in a `;` outside of strings and comments? +pub fn ends_with_semicolon(text: &str) -> bool { + text.trim_end().ends_with(';') +} + +/// Reject caller text that would make the file unparseable if spliced in. +/// +/// Cheap structural guard, not a full validation: we re-parse the result +/// anyway, but catching an unbalanced brace here gives a far better error. +pub fn validate_snippet(text: &str, what: &str) -> Result<()> { + let mut depth = 0i32; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' | '\'' => { + let quote = c; + let mut escaped = false; + let mut closed = false; + for q in chars.by_ref() { + if escaped { + escaped = false; + } else if q == '\\' { + escaped = true; + } else if q == quote { + closed = true; + break; + } + } + if !closed { + return Err(CssError::InvalidInput(format!( + "{what} has an unterminated string" + ))); + } + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + let mut prev = '\0'; + let mut closed = false; + for q in chars.by_ref() { + if prev == '*' && q == '/' { + closed = true; + break; + } + prev = q; + } + if !closed { + return Err(CssError::InvalidInput(format!( + "{what} has an unterminated comment" + ))); + } + } + '{' => depth += 1, + '}' => { + depth -= 1; + if depth < 0 { + return Err(CssError::InvalidInput(format!( + "{what} has an unbalanced closing brace" + ))); + } + } + _ => {} + } + } + if depth != 0 { + return Err(CssError::InvalidInput(format!( + "{what} has an unbalanced opening brace" + ))); + } + Ok(()) +} + +/// Extend `offset` past a same-line trailing comment, so an insertion lands +/// after `color: red; /* note */` rather than between them. +pub fn past_trailing_comment(ctx: &ParseCtx, comments: &[(usize, usize)], offset: usize) -> usize { + let src = ctx.source(); + let mut e = offset; + loop { + let mut probe = e; + while matches!(src.as_bytes().get(probe), Some(b' ') | Some(b'\t')) { + probe += 1; + } + match comments.iter().copied().find(|(s, _)| *s == probe) { + Some((_, c_end)) if !src[e..probe].contains('\n') => e = c_end, + _ => return e, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reindent_moves_a_block_to_a_new_base() { + let out = reindent("color: red;\nmargin: 0;", " ", "\n"); + assert_eq!(out, " color: red;\n margin: 0;"); + } + + #[test] + fn reindent_preserves_relative_nesting() { + let out = reindent("a {\n b: c;\n}", " ", "\n"); + assert_eq!(out, " a {\n b: c;\n }"); + } + + #[test] + fn reindent_strips_a_common_base_first() { + let out = reindent(" a: 1;\n b: 2;", " ", "\n"); + assert_eq!(out, " a: 1;\n b: 2;"); + } + + #[test] + fn reindent_leaves_blank_lines_empty() { + let out = reindent("a: 1;\n\nb: 2;", " ", "\n"); + assert_eq!(out, " a: 1;\n\n b: 2;"); + } + + #[test] + fn reindent_uses_the_requested_newline() { + let out = reindent("a: 1;\nb: 2;", " ", "\r\n"); + assert_eq!(out, " a: 1;\r\n b: 2;"); + } + + #[test] + fn splits_simple_declarations() { + assert_eq!( + split_declarations("color: red; margin: 0"), + vec!["color: red;", "margin: 0;"] + ); + } + + #[test] + fn does_not_split_inside_a_url() { + assert_eq!( + split_declarations("background: url(data:image/svg+xml;base64,AA==)"), + vec!["background: url(data:image/svg+xml;base64,AA==);"] + ); + } + + #[test] + fn does_not_split_inside_a_string() { + assert_eq!( + split_declarations(r#"content: "a;b"; color: red"#), + vec![r#"content: "a;b";"#, "color: red;"] + ); + } + + #[test] + fn does_not_split_inside_a_comment() { + assert_eq!( + split_declarations("color: red /* a;b */; margin: 0"), + vec!["color: red /* a;b */;", "margin: 0;"] + ); + } + + #[test] + fn ignores_empty_statements() { + assert_eq!(split_declarations(";;color: red;;"), vec!["color: red;"]); + assert!(split_declarations(" ").is_empty()); + } + + #[test] + fn validate_snippet_accepts_balanced_text() { + assert!(validate_snippet("color: red;", "value").is_ok()); + assert!(validate_snippet("a { b: c; }", "block").is_ok()); + assert!(validate_snippet(r#"content: "}"#.to_owned().as_str(), "v").is_err()); + } + + #[test] + fn validate_snippet_rejects_unbalanced_braces() { + assert!(validate_snippet("a { b: c;", "block").is_err()); + assert!(validate_snippet("a } b", "block").is_err()); + } + + #[test] + fn validate_snippet_ignores_braces_inside_strings_and_comments() { + assert!(validate_snippet(r#"content: "{";"#, "v").is_ok()); + assert!(validate_snippet("/* { */ color: red;", "v").is_ok()); + } + + #[test] + fn validate_snippet_rejects_unterminated_comment() { + assert!(validate_snippet("color: red; /* oops", "v").is_err()); + } +} diff --git a/native/igniter_css/src/ops/rule.rs b/native/igniter_css/src/ops/rule.rs new file mode 100644 index 0000000..d742aaf --- /dev/null +++ b/native/igniter_css/src/ops/rule.rs @@ -0,0 +1,597 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Rule-level codemods and the shared "put a line inside this body" machinery +//! that the declaration ops build on. + +use crate::ctx::{ParseCtx, ParseOptions}; +use crate::edit::Edit; +use crate::error::{CssError, Result}; +use crate::locate::{ + find_rule_by_selector, find_top_level_rules, normalize_selector, MatchResult, RuleRef, +}; +use crate::ops::{ + past_trailing_comment, reindent, run, split_declarations, validate_snippet, Outcome, +}; +use crate::trivia::{absorb_surrounding_blank_line, comment_ranges, deletion_span}; +use biome_css_syntax::CssSyntaxKind; + +/// Resolve a selector to exactly one top-level rule, or explain why not. +pub fn resolve_rule(ctx: &ParseCtx, selector: &str) -> Result> { + match find_rule_by_selector(ctx, selector) { + MatchResult::One(r) => Ok(Some(*r)), + MatchResult::None => Ok(None), + MatchResult::Ambiguous(hits) => Err(CssError::AmbiguousSelector { + selector: selector.to_string(), + count: hits.len(), + }), + } +} + +/// Items directly inside a rule body, in source order: declarations, nested +/// rules and nested at-rules alike. +fn body_items(rule: &RuleRef) -> Vec { + rule.node + .children() + .find(|c| { + c.first_token() + .is_some_and(|t| t.kind() == CssSyntaxKind::L_CURLY) + }) + .into_iter() + .flat_map(|block| block.children()) + .filter(|c| { + matches!( + c.kind(), + CssSyntaxKind::CSS_DECLARATION_OR_RULE_LIST + | CssSyntaxKind::CSS_DECLARATION_LIST + | CssSyntaxKind::CSS_DECLARATION_OR_AT_RULE_LIST + | CssSyntaxKind::CSS_RULE_LIST + ) + }) + .flat_map(|list| list.children()) + .collect() +} + +/// Build the edits that append `text` (already `;`-terminated where relevant) +/// as the last item of `rule`'s body. +/// +/// Honours rules B and C: the new line copies the indentation of the sibling it +/// lands next to, and a rule written entirely on one line stays on one line. +pub fn append_to_body(ctx: &ParseCtx, rule: &RuleRef, text: &str) -> Vec { + let nl = ctx.nl(); + let rule_indent = ctx.indent_at(rule.start).to_string(); + let single_line = !ctx.source()[rule.start..rule.end].contains('\n'); + let items = body_items(rule); + + if items.is_empty() { + let replacement = if single_line { + format!(" {text} ") + } else { + format!("{nl}{rule_indent}{}{text}{nl}{rule_indent}", ctx.indent()) + }; + return vec![Edit::replace(rule.body_open, rule.body_close, replacement)]; + } + + let comments = comment_ranges(ctx); + let last = items.last().expect("non-empty"); + let last_start = usize::from(last.text_trimmed_range().start()); + let last_end = usize::from(last.text_trimmed_range().end()); + + // A declaration that is not `;`-terminated needs one before we add a sibling. + let is_declaration = matches!( + last.kind(), + CssSyntaxKind::CSS_DECLARATION | CssSyntaxKind::CSS_DECLARATION_WITH_SEMICOLON + ); + let semi = if is_declaration && !ctx.source()[last_start..last_end].trim_end().ends_with(';') { + ";" + } else { + "" + }; + + // Land after any comment trailing the last item, not between them. + let after = past_trailing_comment(ctx, &comments, last_end); + + let indent = if ctx.is_at_line_start(last_start) { + ctx.indent_at(last_start).to_string() + } else { + format!("{rule_indent}{}", ctx.indent()) + }; + let tail = if single_line { + format!(" {text}") + } else { + format!("{nl}{indent}{text}") + }; + + if after == last_end { + vec![Edit::insert(last_end, format!("{semi}{tail}"))] + } else if semi.is_empty() { + vec![Edit::insert(after, tail)] + } else { + vec![Edit::insert(last_end, semi), Edit::insert(after, tail)] + } +} + +/// Text of a brand new top-level rule, indented at column zero. +fn new_rule_text(ctx: &ParseCtx, selector: &str, body: &[String]) -> String { + let nl = ctx.nl(); + if body.is_empty() { + return format!("{selector} {{{nl}}}"); + } + let inner = body + .iter() + .map(|d| format!("{}{d}", ctx.indent())) + .collect::>() + .join(nl); + format!("{selector} {{{nl}{inner}{nl}}}") +} + +/// Append a new top-level rule at the end of the file. +pub fn append_rule_edits(ctx: &ParseCtx, selector: &str, body: &[String]) -> Vec { + let nl = ctx.nl(); + let src = ctx.source(); + let text = new_rule_text(ctx, selector, body); + + if src.trim().is_empty() { + // Preserve whatever leading trivia (a header comment) already exists. + let sep = if src.is_empty() || src.ends_with('\n') { + "" + } else { + nl + }; + return vec![Edit::insert(src.len(), format!("{sep}{text}{nl}"))]; + } + + // One blank line before the new rule, matching the file's own habit of + // separating top-level rules. + let trimmed_end = src.trim_end_matches(['\n', '\r', ' ', '\t']).len(); + let tail = if ctx.has_final_newline() { + format!("{nl}{nl}{text}{nl}") + } else { + format!("{nl}{nl}{text}") + }; + vec![Edit::replace(trimmed_end, src.len(), tail)] +} + +// --------------------------------------------------------------------------- +// Public ops +// --------------------------------------------------------------------------- + +/// Create `selector { }` at the end of the file when no top-level rule with +/// that selector exists. +pub fn ensure_rule(source: &str, selector: &str, options: ParseOptions) -> Result { + ensure_rule_with(source, selector, "", options) +} + +/// Same, but seeding the new rule with `declarations` when it has to be created. +pub fn ensure_rule_with( + source: &str, + selector: &str, + declarations: &str, + options: ParseOptions, +) -> Result { + let selector = selector.trim(); + if selector.is_empty() { + return Err(CssError::InvalidInput("selector is empty".to_string())); + } + validate_snippet(selector, "selector")?; + validate_snippet(declarations, "declarations")?; + if selector.contains('{') || selector.contains('}') { + return Err(CssError::InvalidInput(format!( + "selector {selector:?} must not contain braces" + ))); + } + let body = split_declarations(declarations); + + run(source, options, |ctx| { + match find_rule_by_selector(ctx, selector) { + MatchResult::One(_) | MatchResult::Ambiguous(_) => Ok(vec![]), + MatchResult::None => Ok(append_rule_edits(ctx, selector, &body)), + } + }) +} + +/// Remove a top-level rule and the comments it owns (rule D). +pub fn remove_rule(source: &str, selector: &str, options: ParseOptions) -> Result { + let want = normalize_selector(selector); + if want.is_empty() { + return Err(CssError::InvalidInput("selector is empty".to_string())); + } + run(source, options, |ctx| { + let comments = comment_ranges(ctx); + let mut edits = Vec::new(); + // Removal is the one place ambiguity is harmless: "delete this rule" + // means all of them, and deleting each one is itself unambiguous. + for rule in find_top_level_rules(ctx) { + if rule.selector_norm != want { + continue; + } + let span = deletion_span(ctx, &comments, rule.start, rule.end); + let span = absorb_surrounding_blank_line(ctx, span); + edits.push(Edit::delete(span.start, span.end)); + } + Ok(edits) + }) +} + +/// Replace everything between a rule's braces with `declarations`. +pub fn replace_rule_body( + source: &str, + selector: &str, + declarations: &str, + options: ParseOptions, +) -> Result { + validate_snippet(declarations, "declarations")?; + let body = split_declarations(declarations); + + run(source, options, |ctx| { + let Some(rule) = resolve_rule(ctx, selector)? else { + return Err(CssError::NotFound(format!( + "no top-level rule with selector {selector:?}" + ))); + }; + let nl = ctx.nl(); + let rule_indent = ctx.indent_at(rule.start).to_string(); + let single_line = !ctx.source()[rule.start..rule.end].contains('\n'); + + let replacement = if body.is_empty() { + if single_line { + String::new() + } else { + format!("{nl}{rule_indent}") + } + } else if single_line { + format!(" {} ", body.join(" ")) + } else { + let inner = body + .iter() + .map(|d| format!("{rule_indent}{}{d}", ctx.indent())) + .collect::>() + .join(nl); + format!("{nl}{inner}{nl}{rule_indent}") + }; + Ok(vec![Edit::replace( + rule.body_open, + rule.body_close, + replacement, + )]) + }) +} + +/// Insert caller-provided raw text at the end of a rule body, re-indented to +/// match the surrounding code. +pub fn append_raw_to_rule( + source: &str, + selector: &str, + raw: &str, + options: ParseOptions, +) -> Result { + validate_snippet(raw, "raw block")?; + if raw.trim().is_empty() { + return Err(CssError::InvalidInput("raw block is empty".to_string())); + } + + run(source, options, |ctx| { + let Some(rule) = resolve_rule(ctx, selector)? else { + return Err(CssError::NotFound(format!( + "no top-level rule with selector {selector:?}" + ))); + }; + // Already present verbatim? Then this is a no-op (rule A). + let body = &ctx.source()[rule.body_open..rule.body_close]; + let needle = raw.trim(); + if body.contains(needle) { + return Ok(vec![]); + } + + let rule_indent = ctx.indent_at(rule.start).to_string(); + let inner_indent = format!("{rule_indent}{}", ctx.indent()); + // `append_to_body` supplies the indentation of the first line itself, so + // hand it a block whose first line is already flush. + let text = reindent(needle, &inner_indent, ctx.nl()) + .trim_start() + .to_string(); + Ok(append_to_body(ctx, &rule, &text)) + }) +} + +/// Read-only: does a top-level rule with this selector exist? +pub fn has_rule(source: &str, selector: &str, options: ParseOptions) -> Result { + let want = normalize_selector(selector); + crate::ops::query(source, options, |ctx| { + Ok(find_top_level_rules(ctx) + .iter() + .any(|r| r.selector_norm == want)) + }) +} + +/// Read-only: every top-level selector, as written. +pub fn list_selectors(source: &str, options: ParseOptions) -> Result> { + crate::ops::query(source, options, |ctx| { + Ok(find_top_level_rules(ctx) + .into_iter() + .map(|r| r.selector_raw) + .collect()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts() -> ParseOptions { + ParseOptions::default() + } + + // -- ensure_rule -------------------------------------------------------- + + #[test] + fn creates_a_missing_rule_at_the_end() { + let o = ensure_rule(".a { color: red; }\n", ".b", opts()).unwrap(); + assert!(o.changed); + assert_eq!(o.source, ".a { color: red; }\n\n.b {\n}\n"); + } + + #[test] + fn creates_a_rule_in_an_empty_file() { + let o = ensure_rule("", ".b", opts()).unwrap(); + assert_eq!(o.source, ".b {\n}\n"); + } + + #[test] + fn does_not_recreate_an_existing_rule() { + let src = ".b {\n color: red;\n}\n"; + let o = ensure_rule(src, ".b", opts()).unwrap(); + assert!(!o.changed); + assert_eq!(o.source, src); + } + + #[test] + fn matches_an_existing_rule_written_with_different_spacing() { + let src = ".a > .b { color: red; }\n"; + let o = ensure_rule(src, ".a>.b", opts()).unwrap(); + assert!(!o.changed); + } + + #[test] + fn ensure_rule_is_idempotent() { + let once = ensure_rule(".a {}\n", ".b", opts()).unwrap(); + let twice = ensure_rule(&once.source, ".b", opts()).unwrap(); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn seeds_a_new_rule_with_declarations() { + let o = ensure_rule_with("", ".b", "color: red; margin: 0", opts()).unwrap(); + assert_eq!(o.source, ".b {\n color: red;\n margin: 0;\n}\n"); + } + + #[test] + fn new_rules_follow_the_files_indent_and_newline_style() { + let src = ".a {\r\n\tcolor: red;\r\n}\r\n"; + let o = ensure_rule_with(src, ".b", "margin: 0", opts()).unwrap(); + assert_eq!( + o.source, + ".a {\r\n\tcolor: red;\r\n}\r\n\r\n.b {\r\n\tmargin: 0;\r\n}\r\n" + ); + } + + #[test] + fn a_file_without_a_trailing_newline_keeps_not_having_one() { + let o = ensure_rule(".a {}", ".b", opts()).unwrap(); + assert_eq!(o.source, ".a {}\n\n.b {\n}"); + } + + #[test] + fn preserves_a_trailing_comment_when_appending() { + let src = ".a {}\n\n/* the end */\n"; + let o = ensure_rule(src, ".b", opts()).unwrap(); + assert!(o.source.contains("/* the end */")); + assert_eq!(o.source, ".a {}\n\n/* the end */\n\n.b {\n}\n"); + } + + #[test] + fn rejects_a_selector_containing_braces() { + assert!(ensure_rule("", ".a { }", opts()).is_err()); + assert!(ensure_rule("", " ", opts()).is_err()); + } + + // -- remove_rule -------------------------------------------------------- + + #[test] + fn removes_a_rule_and_its_line() { + let src = ".a {}\n.b {}\n.c {}\n"; + let o = remove_rule(src, ".b", opts()).unwrap(); + assert!(o.changed); + assert_eq!(o.source, ".a {}\n.c {}\n"); + } + + #[test] + fn removes_the_comment_directly_above() { + let src = ".a {}\n\n/* about b */\n.b {}\n\n.c {}\n"; + let o = remove_rule(src, ".b", opts()).unwrap(); + assert_eq!(o.source, ".a {}\n\n.c {}\n"); + } + + #[test] + fn keeps_a_section_header_above_a_removed_rule() { + let src = "/* ===== Utilities ===== */\n.b {}\n.c {}\n"; + let o = remove_rule(src, ".b", opts()).unwrap(); + assert_eq!(o.source, "/* ===== Utilities ===== */\n.c {}\n"); + } + + #[test] + fn removing_an_absent_rule_is_a_no_op() { + let src = ".a {}\n"; + let o = remove_rule(src, ".zz", opts()).unwrap(); + assert!(!o.changed); + assert_eq!(o.source, src); + } + + #[test] + fn removes_every_copy_of_a_duplicated_rule() { + let src = ".a {}\n.b { color: red; }\n.b { color: blue; }\n"; + let o = remove_rule(src, ".b", opts()).unwrap(); + assert_eq!(o.source, ".a {}\n"); + } + + #[test] + fn remove_rule_is_idempotent() { + let src = ".a {}\n.b {}\n"; + let once = remove_rule(src, ".b", opts()).unwrap(); + let twice = remove_rule(&once.source, ".b", opts()).unwrap(); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn does_not_remove_a_nested_rule() { + let src = "@media print {\n .b { color: red; }\n}\n"; + let o = remove_rule(src, ".b", opts()).unwrap(); + assert!(!o.changed); + } + + // -- replace_rule_body -------------------------------------------------- + + #[test] + fn replaces_a_multi_line_body() { + let src = ".a {\n color: red;\n margin: 0;\n}\n"; + let o = replace_rule_body(src, ".a", "padding: 1px; color: blue", opts()).unwrap(); + assert_eq!(o.source, ".a {\n padding: 1px;\n color: blue;\n}\n"); + } + + #[test] + fn replaces_a_single_line_body_in_place() { + let src = ".a { color: red; }\n"; + let o = replace_rule_body(src, ".a", "color: blue", opts()).unwrap(); + assert_eq!(o.source, ".a { color: blue; }\n"); + } + + #[test] + fn empties_a_body() { + let src = ".a {\n color: red;\n}\n"; + let o = replace_rule_body(src, ".a", "", opts()).unwrap(); + assert_eq!(o.source, ".a {\n}\n"); + } + + #[test] + fn replacing_the_body_leaves_the_rest_of_the_file_alone() { + let src = "/* head */\n.a {\n color: red;\n}\n/* tail */\n.b {}\n"; + let o = replace_rule_body(src, ".a", "color: blue", opts()).unwrap(); + assert_eq!( + o.source, + "/* head */\n.a {\n color: blue;\n}\n/* tail */\n.b {}\n" + ); + } + + #[test] + fn replace_body_errors_on_a_missing_rule() { + let e = replace_rule_body(".a {}\n", ".zz", "color: red", opts()).unwrap_err(); + assert!(matches!(e, CssError::NotFound(_))); + } + + #[test] + fn replace_body_errors_on_an_ambiguous_selector() { + let e = replace_rule_body(".a {}\n.a {}\n", ".a", "color: red", opts()).unwrap_err(); + assert!(matches!(e, CssError::AmbiguousSelector { count: 2, .. })); + } + + #[test] + fn replace_body_is_idempotent() { + let src = ".a {\n color: red;\n}\n"; + let once = replace_rule_body(src, ".a", "color: blue", opts()).unwrap(); + let twice = replace_rule_body(&once.source, ".a", "color: blue", opts()).unwrap(); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + // -- append_raw_to_rule ------------------------------------------------- + + #[test] + fn appends_raw_text_to_a_body() { + let src = ".a {\n color: red;\n}\n"; + let o = append_raw_to_rule(src, ".a", "margin: 0;", opts()).unwrap(); + assert_eq!(o.source, ".a {\n color: red;\n margin: 0;\n}\n"); + } + + #[test] + fn appends_into_an_empty_body() { + let src = ".a {\n}\n"; + let o = append_raw_to_rule(src, ".a", "margin: 0;", opts()).unwrap(); + assert_eq!(o.source, ".a {\n margin: 0;\n}\n"); + } + + #[test] + fn appends_into_an_inline_empty_body() { + let src = ".a {}\n"; + let o = append_raw_to_rule(src, ".a", "margin: 0;", opts()).unwrap(); + assert_eq!(o.source, ".a { margin: 0; }\n"); + } + + #[test] + fn reindents_a_multi_line_raw_block() { + let src = ".a {\n color: red;\n}\n"; + let o = append_raw_to_rule(src, ".a", "&:hover {\n color: blue;\n}", opts()).unwrap(); + assert_eq!( + o.source, + ".a {\n color: red;\n &:hover {\n color: blue;\n }\n}\n" + ); + } + + #[test] + fn adds_a_missing_semicolon_to_the_previous_declaration() { + let src = ".a {\n color: red\n}\n"; + let o = append_raw_to_rule(src, ".a", "margin: 0;", opts()).unwrap(); + assert_eq!(o.source, ".a {\n color: red;\n margin: 0;\n}\n"); + } + + #[test] + fn lands_after_a_trailing_comment_not_before_it() { + let src = ".a {\n color: red; /* note */\n}\n"; + let o = append_raw_to_rule(src, ".a", "margin: 0;", opts()).unwrap(); + assert_eq!( + o.source, + ".a {\n color: red; /* note */\n margin: 0;\n}\n" + ); + } + + #[test] + fn append_raw_is_idempotent() { + let src = ".a {\n color: red;\n}\n"; + let once = append_raw_to_rule(src, ".a", "margin: 0;", opts()).unwrap(); + let twice = append_raw_to_rule(&once.source, ".a", "margin: 0;", opts()).unwrap(); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn append_raw_rejects_unbalanced_text() { + assert!(append_raw_to_rule(".a {}\n", ".a", "&:hover {", opts()).is_err()); + assert!(append_raw_to_rule(".a {}\n", ".a", " ", opts()).is_err()); + } + + #[test] + fn append_raw_uses_tabs_when_the_file_does() { + let src = ".a {\n\tcolor: red;\n}\n"; + let o = append_raw_to_rule(src, ".a", "margin: 0;", opts()).unwrap(); + assert_eq!(o.source, ".a {\n\tcolor: red;\n\tmargin: 0;\n}\n"); + } + + // -- queries ------------------------------------------------------------ + + #[test] + fn has_rule_is_top_level_and_normalised() { + assert!(has_rule(".a > .b {}\n", ".a>.b", opts()).unwrap()); + assert!(!has_rule("@media print { .b {} }\n", ".b", opts()).unwrap()); + } + + #[test] + fn lists_selectors_as_written() { + let src = ".a,\n.b { color: red; }\n#c {}\n"; + assert_eq!( + list_selectors(src, opts()).unwrap(), + vec![".a,\n.b".to_string(), "#c".to_string()] + ); + } +} diff --git a/native/igniter_css/src/ops/tidy.rs b/native/igniter_css/src/ops/tidy.rs new file mode 100644 index 0000000..bbd897f --- /dev/null +++ b/native/igniter_css/src/ops/tidy.rs @@ -0,0 +1,428 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Whole-file tidying ops that are nevertheless **diff-minimal**. +//! +//! Sorting and de-duplication are usually implemented by reprinting the tree, +//! which violates hard constraint #2. Here they are implemented as permutations +//! and deletions of existing byte ranges instead: lines move or disappear, and +//! every other byte in the file is untouched. A block we cannot rearrange +//! safely is skipped and reported in `diagnostics` rather than reformatted. + +use crate::ctx::{ParseCtx, ParseOptions}; +use crate::edit::Edit; +use crate::error::Result; +use crate::locate::{declaration_lists, find_top_level_rules, normalize_property, DeclRef}; +use crate::ops::{run, Outcome}; +use crate::trivia::{absorb_surrounding_blank_line, comment_ranges, deletion_span}; + +/// The byte range a declaration owns, including the comments attached to it. +fn owned_span(ctx: &ParseCtx, comments: &[(usize, usize)], d: &DeclRef) -> (usize, usize) { + let s = deletion_span(ctx, comments, d.start, d.end); + (s.start, s.end) +} + +/// A block can be rearranged only when every declaration owns a contiguous run +/// of whole lines and nothing but blank space sits between them. Anything else +/// -- a section-header comment between two declarations, a nested rule, two +/// declarations sharing a line -- means a permutation could lose or misplace +/// text, so we decline. +fn spans_are_permutable(ctx: &ParseCtx, spans: &[(usize, usize)]) -> bool { + if spans.len() < 2 { + return false; + } + let src = ctx.source(); + for (i, (s, e)) in spans.iter().enumerate() { + if *s != ctx.line_start(*s) || !src[..*e].ends_with('\n') { + return false; + } + if i > 0 { + let prev_end = spans[i - 1].1; + if prev_end > *s { + return false; + } + if !src[prev_end..*s].trim().is_empty() { + return false; + } + } + } + true +} + +/// Sort declarations alphabetically within each block. +/// +/// Note this is a *semantic* change when a block mixes shorthand and longhand +/// (`margin` then `margin-left` behaves differently from the reverse). The sort +/// is stable, so repeated declarations of one property keep their relative +/// order and the last-wins rule is preserved. +pub fn sort_properties(source: &str, options: ParseOptions) -> Result { + let mut skipped = 0usize; + let outcome = run(source, options, |ctx| { + let comments = comment_ranges(ctx); + let mut edits = Vec::new(); + + for (list, decls) in declaration_lists(ctx) { + // Any non-declaration sibling (a nested rule, an `@apply`) means the + // ordering carries meaning we must not disturb. + if list.children().count() != decls.len() || decls.len() < 2 { + continue; + } + let spans: Vec<(usize, usize)> = decls + .iter() + .map(|d| owned_span(ctx, &comments, d)) + .collect(); + if !spans_are_permutable(ctx, &spans) { + skipped += 1; + continue; + } + + let mut order: Vec = (0..decls.len()).collect(); + order.sort_by(|a, b| { + normalize_property(&decls[*a].property) + .cmp(&normalize_property(&decls[*b].property)) + }); + if order.iter().enumerate().all(|(i, j)| i == *j) { + continue; + } + + let start = spans[0].0; + let end = spans[spans.len() - 1].1; + let text: String = order + .iter() + .map(|i| { + let (s, e) = spans[*i]; + &ctx.source()[s..e] + }) + .collect(); + edits.push(Edit::replace(start, end, text)); + } + Ok(edits) + })?; + + Ok(if skipped > 0 { + outcome.with_diagnostic(format!( + "{skipped} block(s) left unsorted: their declarations are not on separate lines, \ + or a comment between them made the order meaningful" + )) + } else { + outcome + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DedupeOptions { + /// Drop an earlier declaration when a later one in the same block sets the + /// same property. + pub declarations: bool, + /// Drop an earlier top-level rule when a later one has the same selector + /// and an identical body. + pub rules: bool, +} + +impl Default for DedupeOptions { + fn default() -> Self { + Self { + declarations: true, + rules: true, + } + } +} + +/// Remove redundant declarations and rules. +/// +/// Only removals that cannot change rendering are made: +/// +/// * a declaration is dropped only when a **later** declaration in the same +/// block sets the same property and is at least as important -- CSS's +/// last-wins rule already made the earlier one dead; +/// * a rule is dropped only when a later top-level rule has the same selector +/// **and** a byte-identical body. +pub fn remove_duplicates( + source: &str, + dedupe: DedupeOptions, + options: ParseOptions, +) -> Result { + run(source, options, |ctx| { + let comments = comment_ranges(ctx); + let mut edits: Vec = Vec::new(); + + if dedupe.declarations { + for (_, decls) in declaration_lists(ctx) { + for (i, d) in decls.iter().enumerate() { + let shadowed = decls[i + 1..].iter().any(|later| { + normalize_property(&later.property) == normalize_property(&d.property) + // An earlier `!important` beats a later plain one, + // so only a later flag of equal-or-greater weight + // makes this one dead. + && (later.important || !d.important) + }); + if !shadowed { + continue; + } + let (s, e) = owned_span(ctx, &comments, d); + edits.push(Edit::delete(s, e)); + } + } + } + + if dedupe.rules { + let rules = find_top_level_rules(ctx); + for (i, r) in rules.iter().enumerate() { + let body = ctx.source()[r.body_open..r.body_close].trim(); + let duplicated = rules[i + 1..].iter().any(|later| { + later.selector_norm == r.selector_norm + && ctx.source()[later.body_open..later.body_close].trim() == body + }); + if !duplicated { + continue; + } + let span = deletion_span(ctx, &comments, r.start, r.end); + let span = absorb_surrounding_blank_line(ctx, span); + // A rule deletion subsumes any declaration deletions inside it. + edits.retain(|e| !(e.start >= span.start && e.end <= span.end)); + edits.push(Edit::delete(span.start, span.end)); + } + } + + Ok(edits) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts() -> ParseOptions { + ParseOptions::default() + } + + fn sort(src: &str) -> Outcome { + sort_properties(src, opts()).unwrap() + } + + fn dedupe(src: &str) -> Outcome { + remove_duplicates(src, DedupeOptions::default(), opts()).unwrap() + } + + // -- sorting ------------------------------------------------------------ + + #[test] + fn sorts_declarations_alphabetically() { + let src = ".a {\n color: red;\n background: blue;\n z-index: 1;\n}\n"; + let o = sort(src); + assert!(o.changed); + assert_eq!( + o.source, + ".a {\n background: blue;\n color: red;\n z-index: 1;\n}\n" + ); + } + + #[test] + fn sorting_moves_comments_with_their_declaration() { + let src = ".a {\n /* about z */\n z-index: 1;\n color: red; /* about c */\n}\n"; + let o = sort(src); + assert_eq!( + o.source, + ".a {\n color: red; /* about c */\n /* about z */\n z-index: 1;\n}\n" + ); + } + + #[test] + fn already_sorted_input_is_unchanged() { + let src = ".a {\n background: blue;\n color: red;\n}\n"; + let o = sort(src); + assert!(!o.changed); + assert_eq!(o.source, src); + } + + #[test] + fn sorting_is_idempotent() { + let src = ".a {\n z-index: 1;\n color: red;\n background: blue;\n}\n"; + let once = sort(src); + let twice = sort(&once.source); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn sorting_leaves_everything_outside_the_block_alone() { + let src = "/* head */\n.a {\n b: 2;\n a: 1;\n}\n/* tail */\n.z { q: 1; }\n"; + let o = sort(src); + assert_eq!( + o.source, + "/* head */\n.a {\n a: 1;\n b: 2;\n}\n/* tail */\n.z { q: 1; }\n" + ); + } + + #[test] + fn a_single_line_block_is_skipped_and_reported() { + let src = ".a { z-index: 1; color: red; }\n"; + let o = sort(src); + assert!(!o.changed); + assert_eq!(o.source, src); + assert_eq!(o.diagnostics.len(), 1); + } + + #[test] + fn a_block_with_a_nested_rule_is_left_alone() { + let src = ".a {\n z-index: 1;\n color: red;\n &:hover { color: blue; }\n}\n"; + let o = sort(src); + assert!(!o.changed); + } + + #[test] + fn a_block_with_an_apply_is_left_alone() { + let src = ".a {\n z-index: 1;\n @apply px-2;\n color: red;\n}\n"; + let o = sort(src); + assert!(!o.changed); + } + + #[test] + fn a_section_header_between_declarations_blocks_sorting() { + let src = ".a {\n z-index: 1;\n\n /* ===== colours ===== */\n color: red;\n}\n"; + let o = sort(src); + assert!(!o.changed); + assert_eq!(o.diagnostics.len(), 1); + } + + #[test] + fn sorting_is_stable_for_a_repeated_property() { + let src = ".a {\n color: red;\n color: blue;\n background: x;\n}\n"; + let o = sort(src); + assert_eq!( + o.source, + ".a {\n background: x;\n color: red;\n color: blue;\n}\n" + ); + } + + #[test] + fn sorts_inside_media_blocks_too() { + let src = "@media print {\n .a {\n z-index: 1;\n color: red;\n }\n}\n"; + let o = sort(src); + assert_eq!( + o.source, + "@media print {\n .a {\n color: red;\n z-index: 1;\n }\n}\n" + ); + } + + #[test] + fn sorting_preserves_crlf() { + let src = ".a {\r\n z-index: 1;\r\n color: red;\r\n}\r\n"; + let o = sort(src); + assert_eq!(o.source, ".a {\r\n color: red;\r\n z-index: 1;\r\n}\r\n"); + } + + // -- de-duplication ----------------------------------------------------- + + #[test] + fn drops_a_shadowed_declaration() { + let src = ".a {\n color: red;\n margin: 0;\n color: blue;\n}\n"; + let o = dedupe(src); + assert!(o.changed); + assert_eq!(o.source, ".a {\n margin: 0;\n color: blue;\n}\n"); + } + + #[test] + fn keeps_an_important_declaration_a_later_plain_one_cannot_override() { + let src = ".a {\n color: red !important;\n color: blue;\n}\n"; + let o = dedupe(src); + assert!(!o.changed); + assert_eq!(o.source, src); + } + + #[test] + fn a_later_important_declaration_does_shadow_an_earlier_one() { + let src = ".a {\n color: red;\n color: blue !important;\n}\n"; + let o = dedupe(src); + assert_eq!(o.source, ".a {\n color: blue !important;\n}\n"); + } + + #[test] + fn drops_an_identical_duplicated_rule() { + let src = ".a {\n color: red;\n}\n\n.b {}\n\n.a {\n color: red;\n}\n"; + let o = dedupe(src); + assert_eq!(o.source, ".b {}\n\n.a {\n color: red;\n}\n"); + } + + #[test] + fn keeps_two_rules_with_the_same_selector_but_different_bodies() { + let src = ".a { color: red; }\n.a { margin: 0; }\n"; + let o = dedupe(src); + assert!(!o.changed); + } + + #[test] + fn does_not_touch_rules_in_different_scopes() { + let src = ".a { color: red; }\n@media print {\n .a { color: red; }\n}\n"; + let o = dedupe(src); + assert!(!o.changed); + } + + #[test] + fn dedupe_is_idempotent() { + let src = + ".a {\n color: red;\n color: blue;\n}\n.a {\n color: red;\n color: blue;\n}\n"; + let once = dedupe(src); + let twice = dedupe(&once.source); + assert!(!twice.changed); + assert_eq!(once.source, twice.source); + } + + #[test] + fn deduping_takes_the_comment_owned_by_the_dropped_declaration() { + let src = ".a {\n /* old */\n color: red;\n color: blue;\n}\n"; + let o = dedupe(src); + assert_eq!(o.source, ".a {\n color: blue;\n}\n"); + } + + #[test] + fn deduping_keeps_a_section_header() { + let src = ".a {\n /* ==== colours ==== */\n color: red;\n color: blue;\n}\n"; + let o = dedupe(src); + assert_eq!( + o.source, + ".a {\n /* ==== colours ==== */\n color: blue;\n}\n" + ); + } + + #[test] + fn declaration_dedupe_can_be_switched_off() { + let src = ".a {\n color: red;\n color: blue;\n}\n"; + let o = remove_duplicates( + src, + DedupeOptions { + declarations: false, + rules: true, + }, + opts(), + ) + .unwrap(); + assert!(!o.changed); + } + + #[test] + fn rule_dedupe_can_be_switched_off() { + let src = ".a { color: red; }\n.a { color: red; }\n"; + let o = remove_duplicates( + src, + DedupeOptions { + declarations: true, + rules: false, + }, + opts(), + ) + .unwrap(); + assert!(!o.changed); + } + + #[test] + fn dropping_a_whole_rule_does_not_collide_with_dropping_its_declarations() { + // `.a` is duplicated *and* has an internally shadowed declaration; the + // rule deletion must subsume the declaration deletion, not overlap it. + let src = + ".a {\n color: red;\n color: blue;\n}\n.a {\n color: red;\n color: blue;\n}\n"; + let o = dedupe(src); + assert_eq!(o.source, ".a {\n color: blue;\n}\n"); + } +} diff --git a/native/igniter_css/src/transform.rs b/native/igniter_css/src/transform.rs new file mode 100644 index 0000000..950d457 --- /dev/null +++ b/native/igniter_css/src/transform.rs @@ -0,0 +1,503 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! **Whole-file transforms. These are not codemods.** +//! +//! Everything in [`crate::ops`] is diff-minimal by construction. The functions +//! here deliberately are not: minifying or beautifying rewrites every byte, and +//! minifying discards comments because that is what minifying *is*. +//! +//! Keep them out of Igniter installers. They exist for build-time and reporting +//! use, they are never used to patch a user's file in place, and the codemods +//! never route their output through here (ROADMAP §3, §6). + +use crate::ctx::{ParseCtx, ParseOptions}; +use crate::error::Result; +use crate::locate::all_comments; +use biome_css_syntax::CssSyntaxKind; +use biome_rowan::Direction; + +/// Did the author leave a blank line in this run of whitespace? +fn blank_line_in(gap: &str) -> bool { + gap.matches('\n').count() >= 2 +} + +/// Characters that would merge with an adjacent one if the space between them +/// were dropped. +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || matches!(c, '-' | '_' | '%' | '.' | '#' | '\\') || (c as u32) >= 128 +} + +/// Strip comments and collapse whitespace. +/// +/// Driven by the token stream rather than by regex over the text, so a `;` +/// inside `url(...)` or a `/*` inside a string is never mistaken for syntax. A +/// space is preserved only where the source had whitespace **and** removing it +/// would change how the result tokenises -- so `and (min-width: 1px)` keeps its +/// space while `url(x)` never gains one. +pub fn minify(source: &str, options: ParseOptions) -> Result { + let ctx = ParseCtx::new(source, options); + let mut out = String::with_capacity(source.len()); + let mut prev_end: Option = None; + + let tokens: Vec<_> = ctx + .syntax() + .descendants_tokens(Direction::Next) + .filter(|t| t.kind() != CssSyntaxKind::EOF) + .collect(); + + for (i, token) in tokens.iter().enumerate() { + let text = token.text_trimmed(); + if text.is_empty() { + continue; + } + let start = usize::from(token.text_trimmed_range().start()); + let end = usize::from(token.text_trimmed_range().end()); + + // Drop the semicolon that terminates the last declaration in a block. + if token.kind() == CssSyntaxKind::SEMICOLON { + let next_is_close = tokens + .get(i + 1) + .is_some_and(|t| t.kind() == CssSyntaxKind::R_CURLY); + if next_is_close { + prev_end = Some(end); + continue; + } + } + + if let (Some(pe), Some(last)) = (prev_end, out.chars().last()) { + let gap = ctx.source().get(pe..start).unwrap_or(""); + let first = text.chars().next().unwrap_or(' '); + let had_space = !gap.is_empty(); + // `(` matters: `and (` must not become `and(`, which would read as + // a function call, but `url(` must never gain a space. + let would_merge = is_word_char(last) && (is_word_char(first) || first == '('); + if had_space && would_merge { + out.push(' '); + } + } + + out.push_str(text); + prev_end = Some(end); + } + + Ok(ctx.restore_bom(out)) +} + +/// Re-print the stylesheet with one declaration per line and consistent +/// indentation, keeping every comment. +/// +/// A conventional pretty-printer: whole-file output, so never use it to patch. +pub fn beautify(source: &str, options: ParseOptions) -> Result { + let ctx = ParseCtx::new(source, options); + let nl = ctx.nl(); + let unit = ctx.indent(); + let comments = all_comments(&ctx); + + let mut out = String::with_capacity(source.len()); + let mut depth = 0usize; + let mut prev_end: Option = None; + let mut at_line_start = true; + + let tokens: Vec<_> = ctx + .syntax() + .descendants_tokens(Direction::Next) + .filter(|t| t.kind() != CssSyntaxKind::EOF) + .collect(); + + // Emit comments in stream order alongside the tokens they precede. + let mut comment_idx = 0usize; + // Whether the last thing written was a comment, which decides whether a + // repaired `;` would land inside it. + let mut last_was_comment = false; + + let push = |out: &mut String, at_line_start: &mut bool, depth: usize, text: &str| { + if *at_line_start { + for _ in 0..depth { + out.push_str(unit); + } + *at_line_start = false; + } + out.push_str(text); + }; + + for token in &tokens { + let start = usize::from(token.text_trimmed_range().start()); + let text = token.text_trimmed(); + if text.is_empty() { + continue; + } + + // Any comment that sits before this token goes out first. + while comment_idx < comments.len() && comments[comment_idx].0 < start { + let (c_start, c_end, c_text) = &comments[comment_idx]; + comment_idx += 1; + let gap = prev_end + .and_then(|pe| ctx.source().get(pe..*c_start)) + .unwrap_or(""); + let same_line = prev_end.is_some() && !gap.contains('\n'); + + if same_line && !at_line_start { + out.push(' '); + out.push_str(c_text); + // A `//` comment runs to end of line: anything we emit after it + // on the same line would be silently commented out. + if c_text.starts_with("//") { + out.push_str(nl); + at_line_start = true; + } + } else { + if !at_line_start { + out.push_str(nl); + at_line_start = true; + } + if blank_line_in(gap) && !out.is_empty() { + out.push_str(nl); + } + // A comment sitting just before `}` still belongs to the block + // body, so it keeps body indentation rather than dedenting. + push(&mut out, &mut at_line_start, depth, c_text); + out.push_str(nl); + at_line_start = true; + } + last_was_comment = true; + // Anchor the next gap at the comment, not at the token before it, + // or the newline the comment already consumed reads as a blank line. + prev_end = Some(*c_end); + } + + // Preserve a single blank line the author put between constructs. + if at_line_start && !out.is_empty() && token.kind() != CssSyntaxKind::R_CURLY { + let gap = prev_end + .and_then(|pe| ctx.source().get(pe..start)) + .unwrap_or(""); + if blank_line_in(gap) && !out.ends_with(&format!("{nl}{nl}")) { + out.push_str(nl); + } + } + + match token.kind() { + CssSyntaxKind::L_CURLY => { + if !at_line_start && !out.ends_with(' ') { + out.push(' '); + } + push(&mut out, &mut at_line_start, depth, "{"); + depth += 1; + out.push_str(nl); + at_line_start = true; + } + CssSyntaxKind::R_CURLY => { + // Minified input has no `;` on the last declaration of a block; + // a pretty-printer should put one back. + let content = out.trim_end(); + if !(last_was_comment + || content.is_empty() + || content.ends_with('{') + || content.ends_with('}') + || content.ends_with(';')) + { + let at = content.len(); + out.insert(at, ';'); + } + if !at_line_start { + out.push_str(nl); + at_line_start = true; + } + depth = depth.saturating_sub(1); + push(&mut out, &mut at_line_start, depth, "}"); + out.push_str(nl); + at_line_start = true; + } + CssSyntaxKind::SEMICOLON => { + push(&mut out, &mut at_line_start, depth, ";"); + out.push_str(nl); + at_line_start = true; + } + CssSyntaxKind::COMMA => { + push(&mut out, &mut at_line_start, depth, ","); + out.push(' '); + } + CssSyntaxKind::COLON => { + push(&mut out, &mut at_line_start, depth, ":"); + out.push(' '); + } + _ => { + if !at_line_start { + let last = out.chars().last().unwrap_or(' '); + let first = text.chars().next().unwrap_or(' '); + let gap = prev_end + .and_then(|pe| ctx.source().get(pe..start)) + .unwrap_or(""); + let needs_space = !last.is_whitespace() + && ((!gap.is_empty() + && is_word_char(last) + && (is_word_char(first) || first == '(')) + || matches!(first, '{')); + if needs_space { + out.push(' '); + } + } + push(&mut out, &mut at_line_start, depth, text); + } + } + last_was_comment = false; + prev_end = Some(usize::from(token.text_trimmed_range().end())); + } + + // Trailing comments after the last token. + while comment_idx < comments.len() { + let (_, _, c_text) = &comments[comment_idx]; + comment_idx += 1; + if !at_line_start { + out.push_str(nl); + at_line_start = true; + } + push(&mut out, &mut at_line_start, 0, c_text); + out.push_str(nl); + at_line_start = true; + } + + let out = out.trim_end_matches(['\n', '\r', ' ', '\t']).to_string(); + let out = if out.is_empty() { + out + } else { + format!("{out}{nl}") + }; + Ok(ctx.restore_bom(out)) +} + +/// Concatenate stylesheets, then drop rules made redundant by a later copy. +pub fn merge_stylesheets(sheets: &[String], options: ParseOptions) -> Result { + let nl = "\n"; + let joined = sheets + .iter() + .map(|s| s.trim_matches(['\n', '\r']).to_string()) + .filter(|s| !s.trim().is_empty()) + .collect::>() + .join(&format!("{nl}{nl}")); + + if joined.trim().is_empty() { + return Ok(String::new()); + } + let joined = format!("{joined}{nl}"); + + let deduped = crate::ops::tidy::remove_duplicates( + &joined, + crate::ops::tidy::DedupeOptions { + declarations: false, + rules: true, + }, + options, + )?; + Ok(deduped.source) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts() -> ParseOptions { + ParseOptions::default() + } + + fn min(src: &str) -> String { + minify(src, opts()).unwrap() + } + + fn pretty(src: &str) -> String { + beautify(src, opts()).unwrap() + } + + // -- minify ------------------------------------------------------------- + + #[test] + fn minifies_a_simple_sheet() { + let src = + ".header {\n color: #333;\n background: #fff;\n}\n\n.footer {\n color: #000;\n}\n"; + assert_eq!( + min(src), + ".header{color:#333;background:#fff}.footer{color:#000}" + ); + } + + #[test] + fn minifying_drops_comments() { + let src = "/* a */\n.x { /* b */ color: red; /* c */ }\n"; + assert_eq!(min(src), ".x{color:red}"); + } + + #[test] + fn minifying_keeps_a_space_the_grammar_needs() { + let src = "@media screen and (min-width: 40em) {\n .a { margin: 1px -2px; }\n}\n"; + assert_eq!( + min(src), + "@media screen and (min-width:40em){.a{margin:1px -2px}}" + ); + } + + #[test] + fn minifying_never_adds_a_space_before_a_function_paren() { + let src = ".a {\n background: url(a.png);\n transform: translate(1px) rotate(2deg);\n}\n"; + assert_eq!( + min(src), + ".a{background:url(a.png);transform:translate(1px)rotate(2deg)}" + ); + } + + #[test] + fn minifying_preserves_string_contents() { + let src = ".a::after {\n content: \"a b /* not a comment */ ;\";\n}\n"; + assert_eq!( + min(src), + ".a::after{content:\"a b /* not a comment */ ;\"}" + ); + } + + #[test] + fn minifying_preserves_non_ascii() { + let src = ".a::after {\n content: \"日本語 ✓\";\n}\n"; + assert_eq!(min(src), ".a::after{content:\"日本語 ✓\"}"); + } + + #[test] + fn minifying_collapses_selector_combinators() { + let src = ".a > .b,\n.c {\n color: red;\n}\n"; + assert_eq!(min(src), ".a>.b,.c{color:red}"); + } + + #[test] + fn minifying_is_idempotent() { + let src = ".a {\n color: red;\n}\n"; + let once = min(src); + assert_eq!(min(&once), once); + } + + #[test] + fn minifying_an_empty_sheet_yields_an_empty_string() { + assert_eq!(min(""), ""); + assert_eq!(min("/* only a comment */\n"), ""); + } + + #[test] + fn minified_output_still_parses() { + let src = "@media print {\n .a, .b > .c {\n margin: 0 auto !important;\n }\n}\n"; + let out = min(src); + let ctx = ParseCtx::parse_default(&out); + assert!(ctx.round_trips()); + assert!(!ctx.has_errors(), "minified output must still parse: {out}"); + } + + #[test] + fn minifying_preserves_a_bom() { + assert_eq!(min("\u{feff}.a { color: red; }\n"), "\u{feff}.a{color:red}"); + } + + // -- beautify ----------------------------------------------------------- + + #[test] + fn beautifies_a_minified_sheet() { + let src = ".a{color:red;background:#fff}.b{color:#000}"; + assert_eq!( + pretty(src), + ".a {\n color: red;\n background: #fff;\n}\n.b {\n color: #000;\n}\n" + ); + } + + #[test] + fn beautifying_keeps_comments() { + let src = "/* head */.a{color:red}"; + assert_eq!(pretty(src), "/* head */\n.a {\n color: red;\n}\n"); + } + + #[test] + fn beautifying_indents_nested_blocks() { + let src = "@media print{.a{color:red}}"; + assert_eq!( + pretty(src), + "@media print {\n .a {\n color: red;\n }\n}\n" + ); + } + + #[test] + fn beautified_output_still_parses() { + let src = ".a{color:red}@media print{.b{margin:0 auto!important}}"; + let out = pretty(src); + let ctx = ParseCtx::parse_default(&out); + assert!(ctx.round_trips()); + assert!(!ctx.has_errors(), "beautified output must parse: {out}"); + } + + #[test] + fn beautifying_is_idempotent() { + let src = ".a{color:red;margin:0}@media print{.b{color:blue}}"; + let once = pretty(src); + assert_eq!(pretty(&once), once); + } + + #[test] + fn beautifying_an_empty_sheet_yields_an_empty_string() { + assert_eq!(pretty(""), ""); + } + + #[test] + fn beautify_then_minify_round_trips_to_the_same_minified_text() { + let src = ".a{color:red;margin:0 auto}.b,.c>.d{padding:0}"; + assert_eq!(min(&pretty(src)), src); + } + + // -- merge -------------------------------------------------------------- + + #[test] + fn merges_two_sheets() { + let out = merge_stylesheets( + &[".a { color: red; }".into(), ".b { color: blue; }".into()], + opts(), + ) + .unwrap(); + assert_eq!(out, ".a { color: red; }\n\n.b { color: blue; }\n"); + } + + #[test] + fn merging_drops_an_identical_repeated_rule() { + let out = merge_stylesheets( + &[".a { color: red; }".into(), ".a { color: red; }".into()], + opts(), + ) + .unwrap(); + assert_eq!(out, ".a { color: red; }\n"); + } + + #[test] + fn merging_keeps_a_later_rule_that_overrides() { + let out = merge_stylesheets( + &[".a { color: red; }".into(), ".a { color: blue; }".into()], + opts(), + ) + .unwrap(); + assert!(out.contains("color: red")); + assert!(out.contains("color: blue")); + } + + #[test] + fn merging_preserves_comments() { + let out = merge_stylesheets( + &["/* one */\n.a {}".into(), "/* two */\n.b {}".into()], + opts(), + ) + .unwrap(); + assert!(out.contains("/* one */")); + assert!(out.contains("/* two */")); + } + + #[test] + fn merging_skips_empty_sheets() { + let out = merge_stylesheets(&["".into(), ".a {}".into(), " ".into()], opts()).unwrap(); + assert_eq!(out, ".a {}\n"); + } + + #[test] + fn merging_nothing_yields_an_empty_string() { + assert_eq!(merge_stylesheets(&[], opts()).unwrap(), ""); + } +} diff --git a/native/igniter_css/src/trivia.rs b/native/igniter_css/src/trivia.rs new file mode 100644 index 0000000..7565de5 --- /dev/null +++ b/native/igniter_css/src/trivia.rs @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Rule D -- comment ownership on delete (ROADMAP §8). +//! +//! When a codemod removes a node, which of the comments around it go with it? +//! The convention, decided deliberately rather than inferred: +//! +//! | Comment position | Fate | +//! |----------------------------------------------------|-----------| +//! | trailing on the same line as the node | deleted | +//! | own line directly above, no blank line between | deleted | +//! | separated from the node by a blank line | **kept** | +//! | looks like a section header | **kept** | +//! +//! A section header is a comment that spans more than one line, or that +//! contains a rule of three or more repeated `= - * # ~ _` characters. Those +//! read as headings for everything below them, not as documentation of the one +//! node that happens to follow. + +use crate::ctx::ParseCtx; + +/// A byte range to delete, widened from a node's own range to include the +/// comments and line terminator it owns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeleteSpan { + pub start: usize, + pub end: usize, +} + +/// True when a comment reads as a heading for the region below it rather than +/// as documentation of the single node that follows. +pub fn is_section_header(text: &str) -> bool { + if text.contains('\n') { + return true; + } + let mut run_char = '\0'; + let mut run = 0usize; + for c in text.chars() { + if c == run_char { + run += 1; + if run >= 3 && matches!(c, '=' | '-' | '*' | '#' | '~' | '_') { + return true; + } + } else { + run_char = c; + run = 1; + } + } + false +} + +/// Comments in the file as `(start, end)` byte ranges, sorted. Computed once +/// per operation and threaded through, so a codemod touching many nodes does +/// not re-walk the tree for each one. +pub fn comment_ranges(ctx: &ParseCtx) -> Vec<(usize, usize)> { + crate::locate::all_comments(ctx) + .into_iter() + .map(|(s, e, _)| (s, e)) + .collect() +} + +fn is_blank(s: &str) -> bool { + s.chars().all(|c| c == ' ' || c == '\t' || c == '\r') +} + +/// The comment that starts exactly at `offset` after optional spaces/tabs. +fn comment_starting_at(comments: &[(usize, usize)], offset: usize) -> Option<(usize, usize)> { + comments.iter().copied().find(|(s, _)| *s == offset) +} + +/// The comment whose text ends inside `[line_start, line_end)`. +fn comment_ending_in_line( + comments: &[(usize, usize)], + line_start: usize, + line_end: usize, +) -> Option<(usize, usize)> { + comments + .iter() + .copied() + .find(|(_, e)| *e > line_start && *e <= line_end) +} + +/// Widen `[start, end)` to the bytes the node owns under Rule D. +/// +/// `start`/`end` must be the node's *trimmed* range -- its own text, without +/// surrounding trivia. +pub fn deletion_span( + ctx: &ParseCtx, + comments: &[(usize, usize)], + start: usize, + end: usize, +) -> DeleteSpan { + let src = ctx.source(); + let owns_its_line = ctx.is_at_line_start(start); + + // ---- forward: trailing comments on the same line, then the terminator. + let mut e = end; + loop { + let mut probe = e; + while matches!(src.as_bytes().get(probe), Some(b' ') | Some(b'\t')) { + probe += 1; + } + match comment_starting_at(comments, probe) { + // Only same-line: a comment on the next line is not ours. + Some((_, c_end)) if !src[e..probe].contains('\n') => e = c_end, + _ => break, + } + } + if owns_its_line && ctx.is_at_line_end(e) { + e = ctx.line_end_inclusive(e); + } + + // ---- backward: own-line comments directly above. + let mut s = start; + if owns_its_line { + s = ctx.line_start(start); + while s > 0 { + let prev_line_start = ctx.line_start(s - 1); + let prev_line = &src[prev_line_start..s]; + if is_blank(prev_line.trim_end_matches('\n')) { + // A blank line separates the comment from the node: keep it. + break; + } + let Some((c_start, c_end)) = comment_ending_in_line(comments, prev_line_start, s) + else { + break; + }; + // The comment must be alone on its line(s). + if !is_blank(src[c_end..s].trim_end_matches('\n')) { + break; + } + let c_line_start = ctx.line_start(c_start); + if !is_blank(&src[c_line_start..c_start]) { + break; + } + if is_section_header(&src[c_start..c_end]) { + break; + } + s = c_line_start; + } + } + + DeleteSpan { start: s, end: e } +} + +/// Collapse a run of blank lines left behind by a deletion down to one. +/// +/// Deleting `.b` from `.a{}\n\n.b{}\n\n.c{}\n` would otherwise leave two blank +/// lines where the user had one. Returns a widened span, never a narrower one. +pub fn absorb_surrounding_blank_line(ctx: &ParseCtx, span: DeleteSpan) -> DeleteSpan { + let src = ctx.source(); + let mut end = span.end; + + // Only relevant when the deletion consumed whole lines. + if !(span.start == ctx.line_start(span.start) + && (end == src.len() || src[..end].ends_with('\n'))) + { + return span; + } + + let before_is_blank = span.start == 0 || { + let prev_start = ctx.line_start(span.start - 1); + is_blank(src[prev_start..span.start].trim_end_matches('\n')) + }; + + if before_is_blank { + // Drop one following blank line so we don't stack two. + let next_end = ctx.line_end_inclusive(end); + if next_end > end && is_blank(src[end..next_end].trim_end_matches('\n')) { + end = next_end; + } + } + + DeleteSpan { + start: span.start, + end, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::locate::{declarations_in, find_rule_by_selector}; + + fn span_for_decl(src: &str, selector: &str, property: &str) -> (usize, usize) { + let ctx = ParseCtx::parse_default(src); + let comments = comment_ranges(&ctx); + let rule = find_rule_by_selector(&ctx, selector).one().unwrap(); + let d = declarations_in(&ctx, &rule) + .into_iter() + .find(|d| d.property == property) + .unwrap(); + let s = deletion_span(&ctx, &comments, d.start, d.end); + (s.start, s.end) + } + + fn deleted_text(src: &str, selector: &str, property: &str) -> String { + let (s, e) = span_for_decl(src, selector, property); + src[s..e].to_string() + } + + fn remaining(src: &str, selector: &str, property: &str) -> String { + let (s, e) = span_for_decl(src, selector, property); + format!("{}{}", &src[..s], &src[e..]) + } + + // -- section header detection ------------------------------------------- + + #[test] + fn a_plain_comment_is_not_a_section_header() { + assert!(!is_section_header("/* Firefox */")); + assert!(!is_section_header("/* the brand colour */")); + assert!(!is_section_header("// a line comment")); + } + + #[test] + fn a_ruled_comment_is_a_section_header() { + assert!(is_section_header("/* ===== Layout ===== */")); + assert!(is_section_header("/* --- utilities --- */")); + assert!(is_section_header("/* ### Section ### */")); + assert!(is_section_header("/* ~~~ */")); + assert!(is_section_header("/* ___ */")); + } + + #[test] + fn a_multi_line_comment_is_a_section_header() { + assert!(is_section_header("/* line one\n line two */")); + } + + #[test] + fn two_repeated_characters_are_not_a_rule() { + assert!(!is_section_header("/* a--b */")); + assert!(!is_section_header("/* == */")); + } + + // -- Rule D cases ------------------------------------------------------- + + #[test] + fn a_trailing_same_line_comment_is_deleted_with_the_declaration() { + let src = ".a {\n color: red; /* legacy */\n margin: 0;\n}\n"; + assert_eq!( + deleted_text(src, ".a", "color"), + " color: red; /* legacy */\n" + ); + assert_eq!(remaining(src, ".a", "color"), ".a {\n margin: 0;\n}\n"); + } + + #[test] + fn an_adjacent_own_line_comment_above_is_deleted_with_the_declaration() { + let src = ".a {\n /* brand colour */\n color: red;\n margin: 0;\n}\n"; + assert_eq!( + deleted_text(src, ".a", "color"), + " /* brand colour */\n color: red;\n" + ); + } + + #[test] + fn a_comment_separated_by_a_blank_line_is_kept() { + let src = ".a {\n /* used by the sidebar */\n\n color: red;\n margin: 0;\n}\n"; + assert_eq!(deleted_text(src, ".a", "color"), " color: red;\n"); + assert_eq!( + remaining(src, ".a", "color"), + ".a {\n /* used by the sidebar */\n\n margin: 0;\n}\n" + ); + } + + #[test] + fn a_section_header_above_is_kept() { + let src = ".a {\n /* ===== Colours ===== */\n color: red;\n margin: 0;\n}\n"; + assert_eq!(deleted_text(src, ".a", "color"), " color: red;\n"); + } + + #[test] + fn a_multi_line_comment_above_is_kept() { + let src = ".a {\n /* why this exists:\n because reasons */\n color: red;\n}\n"; + assert_eq!(deleted_text(src, ".a", "color"), " color: red;\n"); + } + + #[test] + fn several_adjacent_comment_lines_are_all_deleted() { + let src = ".a {\n /* one */\n /* two */\n color: red;\n margin: 0;\n}\n"; + assert_eq!( + deleted_text(src, ".a", "color"), + " /* one */\n /* two */\n color: red;\n" + ); + } + + #[test] + fn an_adjacent_run_stops_at_a_section_header() { + let src = ".a {\n /* ==== Header ==== */\n /* about colour */\n color: red;\n}\n"; + assert_eq!( + deleted_text(src, ".a", "color"), + " /* about colour */\n color: red;\n" + ); + } + + #[test] + fn both_a_leading_and_a_trailing_comment_are_taken() { + let src = ".a {\n /* above */\n color: red; /* beside */\n margin: 0;\n}\n"; + assert_eq!( + deleted_text(src, ".a", "color"), + " /* above */\n color: red; /* beside */\n" + ); + } + + #[test] + fn a_declaration_sharing_a_line_does_not_eat_the_line() { + let src = ".a { color: red; margin: 0; }\n"; + assert_eq!(deleted_text(src, ".a", "color"), "color: red;"); + } + + #[test] + fn a_comment_on_the_next_line_is_not_a_trailing_comment() { + let src = ".a {\n color: red;\n /* about margin */\n margin: 0;\n}\n"; + assert_eq!(deleted_text(src, ".a", "color"), " color: red;\n"); + } + + #[test] + fn a_line_comment_above_is_deleted_with_the_declaration() { + let src = ".a {\n // brand colour\n color: red;\n margin: 0;\n}\n"; + assert_eq!( + deleted_text(src, ".a", "color"), + " // brand colour\n color: red;\n" + ); + } + + #[test] + fn crlf_line_endings_are_consumed_whole() { + let src = ".a {\r\n color: red;\r\n margin: 0;\r\n}\r\n"; + assert_eq!(deleted_text(src, ".a", "color"), " color: red;\r\n"); + assert_eq!( + remaining(src, ".a", "color"), + ".a {\r\n margin: 0;\r\n}\r\n" + ); + } + + #[test] + fn a_string_that_looks_like_a_comment_is_not_treated_as_one() { + let src = ".a {\n content: \"/* not a comment */\";\n color: red;\n}\n"; + // Deleting `color` must not swallow the `content` line. + assert_eq!(deleted_text(src, ".a", "color"), " color: red;\n"); + } + + // -- blank line collapsing --------------------------------------------- + + #[test] + fn a_doubled_blank_line_is_collapsed() { + let src = ".a {}\n\n.b {}\n\n.c {}\n"; + let ctx = ParseCtx::parse_default(src); + let comments = comment_ranges(&ctx); + let rule = find_rule_by_selector(&ctx, ".b").one().unwrap(); + let span = deletion_span(&ctx, &comments, rule.start, rule.end); + let span = absorb_surrounding_blank_line(&ctx, span); + let out = format!("{}{}", &src[..span.start], &src[span.end..]); + assert_eq!(out, ".a {}\n\n.c {}\n"); + } + + #[test] + fn a_single_blank_line_is_left_alone() { + let src = ".a {}\n.b {}\n.c {}\n"; + let ctx = ParseCtx::parse_default(src); + let comments = comment_ranges(&ctx); + let rule = find_rule_by_selector(&ctx, ".b").one().unwrap(); + let span = deletion_span(&ctx, &comments, rule.start, rule.end); + let span = absorb_surrounding_blank_line(&ctx, span); + let out = format!("{}{}", &src[..span.start], &src[span.end..]); + assert_eq!(out, ".a {}\n.c {}\n"); + } +} diff --git a/native/igniter_css/tests/corpus_invariants.rs b/native/igniter_css/tests/corpus_invariants.rs new file mode 100644 index 0000000..4cad38b --- /dev/null +++ b/native/igniter_css/tests/corpus_invariants.rs @@ -0,0 +1,421 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! ROADMAP §9: the invariants that must hold for **every op** against **every +//! fixture**, not just for the cases somebody remembered to write a unit test +//! for. +//! +//! 1. idempotency -- applying twice equals applying once, and the second run +//! reports `changed: false` (§9.3) +//! 2. comment preservation -- no comment is ever lost (§2 constraint 1) +//! 3. diff minimality -- the changed-line count stays within budget (§9.4) +//! 4. output validity -- the result still round-trips and gains no new parse +//! errors +//! 5. malformed input -- an error is returned and the source is untouched (§9.5) + +mod support; + +use igniter_css::analyze; +use igniter_css::ctx::{ParseCtx, ParseOptions}; +use igniter_css::ops::at_rule::{ensure_at_rule_line, remove_at_rule}; +use igniter_css::ops::declaration::{ + add_vendor_prefixes, remove_declaration, set_declaration, SetOptions, +}; +use igniter_css::ops::rule::{append_raw_to_rule, ensure_rule, remove_rule, replace_rule_body}; +use igniter_css::ops::tidy::{remove_duplicates, sort_properties, DedupeOptions}; +use igniter_css::ops::Outcome; +use support::{changed_line_count, fixtures}; + +type Op = (&'static str, fn(&str) -> Option); + +fn opts() -> ParseOptions { + ParseOptions::default() +} + +/// Every mutating op, wrapped so an expected error (an ambiguous selector, a +/// missing rule) becomes `None` rather than a panic. `None` means "this op does +/// not apply to this fixture", which is itself valid behaviour. +fn ops() -> Vec { + vec![ + ("ensure_at_rule_import", |s| { + ensure_at_rule_line(s, "@import \"igniter-probe.css\";", opts()).ok() + }), + ("ensure_at_rule_plugin", |s| { + ensure_at_rule_line(s, "@plugin \"igniter-probe\";", opts()).ok() + }), + ("ensure_at_rule_source", |s| { + ensure_at_rule_line(s, "@source \"../igniter-probe\";", opts()).ok() + }), + ("remove_at_rule_import", |s| { + remove_at_rule(s, "import", None, opts()).ok() + }), + ("remove_at_rule_plugin", |s| { + remove_at_rule(s, "plugin", None, opts()).ok() + }), + ("ensure_rule", |s| { + ensure_rule(s, ".igniter-probe", opts()).ok() + }), + ("remove_rule", |s| { + remove_rule(s, ".hide-scrollbar", opts()).ok() + }), + ("set_declaration_new_rule", |s| { + set_declaration( + s, + ".igniter-probe", + "display", + "none", + SetOptions { + create_rule: true, + ..Default::default() + }, + opts(), + ) + .ok() + }), + ("set_declaration_existing", |s| { + set_declaration( + s, + ".page", + "color", + "rebeccapurple", + SetOptions::default(), + opts(), + ) + .ok() + }), + ("remove_declaration", |s| { + remove_declaration(s, ".page", "display", opts()).ok() + }), + ("append_raw_to_rule", |s| { + append_raw_to_rule(s, ".page", "outline: 1px solid red;", opts()).ok() + }), + ("replace_rule_body", |s| { + replace_rule_body(s, ".sr-only", "position: fixed;", opts()).ok() + }), + ("add_vendor_prefixes", |s| { + add_vendor_prefixes( + s, + "user-select", + &["-webkit-".to_string(), "-moz-".to_string()], + opts(), + ) + .ok() + }), + ("add_vendor_prefixes_display", |s| { + add_vendor_prefixes(s, "display", &["-webkit-".to_string()], opts()).ok() + }), + ("sort_properties", |s| sort_properties(s, opts()).ok()), + ("remove_duplicates", |s| { + remove_duplicates(s, DedupeOptions::default(), opts()).ok() + }), + ] +} + +/// Comment texts present in a source, as a multiset. +fn comment_texts(source: &str) -> Vec { + let ctx = ParseCtx::parse_default(source); + let mut v: Vec = igniter_css::locate::all_comments(&ctx) + .into_iter() + .map(|(_, _, t)| t) + .collect(); + v.sort(); + v +} + +// --------------------------------------------------------------------------- + +#[test] +fn every_op_is_idempotent_on_every_fixture() { + for (name, source) in fixtures() { + for (op_name, op) in ops() { + let Some(once) = op(&source) else { continue }; + let Some(twice) = op(&once.source) else { + panic!("{op_name} succeeded on {name} but failed on its own output"); + }; + assert_eq!( + once.source, twice.source, + "{op_name} is not idempotent on {name}" + ); + assert!( + !twice.changed, + "{op_name} reported changed=true on the second run over {name}" + ); + } + } +} + +#[test] +fn no_op_ever_loses_a_comment() { + for (name, source) in fixtures() { + let before = comment_texts(&source); + if before.is_empty() { + continue; + } + for (op_name, op) in ops() { + // Removal ops delete comments on purpose (rule D); they are covered + // by their own targeted tests. + if op_name.starts_with("remove_") { + continue; + } + let Some(out) = op(&source) else { continue }; + let after = comment_texts(&out.source); + for c in &before { + assert!( + after.contains(c), + "{op_name} lost comment {c:?} from {name}" + ); + } + } + } +} + +#[test] +fn every_op_leaves_the_output_parseable_and_lossless() { + for (name, source) in fixtures() { + let before = ParseCtx::parse_default(&source); + let before_errors = before.diagnostics_count(); + + for (op_name, op) in ops() { + let Some(out) = op(&source) else { continue }; + let after = ParseCtx::parse_default(&out.source); + assert!( + after.round_trips(), + "{op_name} produced output that does not round-trip, from {name}" + ); + assert!( + after.diagnostics_count() <= before_errors, + "{op_name} introduced {} new parse diagnostic(s) into {name}", + after.diagnostics_count() - before_errors + ); + } + } +} + +#[test] +fn every_op_preserves_the_files_newline_style() { + for (name, source) in fixtures() { + if !source.contains("\r\n") { + continue; + } + let lf_only_before = source.matches('\n').count() - source.matches("\r\n").count(); + for (op_name, op) in ops() { + let Some(out) = op(&source) else { continue }; + let lf_only_after = + out.source.matches('\n').count() - out.source.matches("\r\n").count(); + assert_eq!( + lf_only_before, lf_only_after, + "{op_name} introduced a bare LF into the CRLF file {name}" + ); + } + } +} + +#[test] +fn every_op_preserves_a_bom() { + for (name, source) in fixtures() { + let had_bom = source.starts_with('\u{feff}'); + for (op_name, op) in ops() { + let Some(out) = op(&source) else { continue }; + assert_eq!( + out.source.starts_with('\u{feff}'), + had_bom, + "{op_name} changed the BOM state of {name}" + ); + } + } +} + +#[test] +fn an_unchanged_outcome_returns_the_source_byte_for_byte() { + for (name, source) in fixtures() { + for (op_name, op) in ops() { + let Some(out) = op(&source) else { continue }; + if !out.changed { + assert_eq!( + out.source, source, + "{op_name} reported changed=false but altered {name}" + ); + } + } + } +} + +/// §9.4: a codemod touching one thing must not reformat the file around it. +#[test] +fn single_target_ops_change_only_a_handful_of_lines() { + // (op, budget in changed lines). Generous, but far below "whole file". + type BudgetedOp = (&'static str, fn(&str) -> Option, usize); + let cases: Vec = vec![ + ( + "ensure_at_rule_line", + |s| ensure_at_rule_line(s, "@plugin \"igniter-probe\";", opts()).ok(), + 1, + ), + ( + "ensure_rule", + |s| ensure_rule(s, ".igniter-probe", opts()).ok(), + 4, + ), + ( + "set_declaration", + |s| { + set_declaration( + s, + ".page", + "color", + "rebeccapurple", + SetOptions::default(), + opts(), + ) + .ok() + }, + 2, + ), + ( + // A declaration plus the comments Rule D says it owns. + "remove_declaration", + |s| remove_declaration(s, ".page", "display", opts()).ok(), + 3, + ), + ]; + + for (name, source) in fixtures() { + for (op_name, op, budget) in &cases { + let Some(out) = op(&source) else { continue }; + if !out.changed { + continue; + } + let changed = changed_line_count(&source, &out.source); + assert!( + changed <= *budget, + "{op_name} changed {changed} lines in {name} (budget {budget})" + ); + } + } +} + +/// §9.5: input we cannot understand well enough to patch must come back +/// untouched, with an error, never half-edited. +#[test] +fn malformed_input_is_never_half_edited() { + let malformed = [ + ".broken {\n color: red;\n", + ".a { color: red; }\n}\n.b { color: blue; }\n", + "{{{{", + "@media (\n", + "}", + ".a { color: ", + ]; + + for source in malformed { + for (op_name, op) in ops() { + // An op that fails cannot have written anything -- guaranteed by the + // API shape, since an error carries no source. One that succeeds + // must still have produced valid output. + if let Some(out) = op(source) { + let ctx = ParseCtx::parse_default(&out.source); + assert!( + ctx.round_trips(), + "{op_name} produced non-round-tripping output from {source:?}" + ); + if !out.changed { + assert_eq!(out.source, source); + } + } + } + } +} + +/// The read-only ops must never panic, whatever we hand them. +#[test] +fn analysis_ops_survive_the_whole_corpus() { + for (name, source) in fixtures() { + analyze::analyze(&source, opts()).unwrap_or_else(|e| panic!("analyze {name}: {e}")); + analyze::extract_colors(&source, opts()) + .unwrap_or_else(|e| panic!("extract_colors {name}: {e}")); + analyze::extract_media_queries(&source, opts()) + .unwrap_or_else(|e| panic!("extract_media_queries {name}: {e}")); + analyze::extract_animations(&source, opts()) + .unwrap_or_else(|e| panic!("extract_animations {name}: {e}")); + let _ = analyze::validate(&source, opts()); + } +} + +#[test] +fn transforms_survive_the_whole_corpus_and_stay_parseable() { + for (name, source) in fixtures() { + let minified = igniter_css::transform::minify(&source, opts()) + .unwrap_or_else(|e| panic!("minify {name}: {e}")); + let ctx = ParseCtx::parse_default(&minified); + assert!(ctx.round_trips(), "minified {name} does not round-trip"); + + let pretty = igniter_css::transform::beautify(&source, opts()) + .unwrap_or_else(|e| panic!("beautify {name}: {e}")); + let ctx = ParseCtx::parse_default(&pretty); + assert!(ctx.round_trips(), "beautified {name} does not round-trip"); + + // Beautifying keeps every comment; minifying deliberately does not. + let before = comment_texts(&source); + let after = comment_texts(&pretty); + for c in &before { + assert!(after.contains(c), "beautify lost comment {c:?} from {name}"); + } + } +} + +#[test] +fn minifying_never_grows_a_file() { + for (name, source) in fixtures() { + let minified = igniter_css::transform::minify(&source, opts()).unwrap(); + assert!( + minified.len() <= source.len(), + "minifying grew {name} from {} to {} bytes", + source.len(), + minified.len() + ); + } +} + +/// The end-to-end shape a real Igniter installer takes: patch a fresh Phoenix +/// `app.css` and check the diff contains only the lines we meant to add. +#[test] +fn a_phoenix_app_css_can_be_patched_with_a_minimal_diff() { + let source = support::fixture("phoenix_app.css"); + + let step1 = + ensure_at_rule_line(&source, "@plugin \"@tailwindcss/typography\";", opts()).unwrap(); + let step2 = ensure_at_rule_line(&step1.source, "@source \"../vendor\";", opts()).unwrap(); + let step3 = ensure_rule(&step2.source, ".hide-scrollbar", opts()).unwrap(); + let step4 = set_declaration( + &step3.source, + ".hide-scrollbar", + "scrollbar-width", + "none", + SetOptions::default(), + opts(), + ) + .unwrap(); + + assert!(step1.changed && step2.changed && step3.changed && step4.changed); + + // 2 new at-rule lines + a blank line + 3 lines of new rule. + assert_eq!(changed_line_count(&source, &step4.source), 6); + + // Everything the file already said is still there, verbatim. + for line in source.lines() { + assert!( + step4.source.contains(line), + "patching dropped the line {line:?}" + ); + } + + // And re-running the whole installer is a no-op. + let again = ensure_at_rule_line( + &step4.source, + "@plugin \"@tailwindcss/typography\";", + opts(), + ) + .unwrap(); + assert!(!again.changed); +} diff --git a/native/igniter_css/tests/phase0_roundtrip.rs b/native/igniter_css/tests/phase0_roundtrip.rs new file mode 100644 index 0000000..8926292 --- /dev/null +++ b/native/igniter_css/tests/phase0_roundtrip.rs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! Phase 0 GATE (ROADMAP §8). +//! +//! `parse.syntax().to_string() == source` must hold byte-for-byte across the +//! entire fixture corpus. Nothing else in this crate was allowed to exist until +//! this passed, and it stays in CI forever afterwards (§9.1). + +mod support; + +use igniter_css::ctx::{ParseCtx, ParseOptions}; +use support::fixtures; + +#[test] +fn round_trip_is_byte_identical_with_default_options() { + for (name, source) in fixtures() { + let ctx = ParseCtx::parse_default(&source); + // `restore_bom` puts back the U+FEFF that `ParseCtx` strips before + // parsing; everything between is Biome's own lossless guarantee. + assert_eq!( + ctx.restore_bom(ctx.syntax().to_string()), + source, + "lossless round-trip failed for fixture {name}" + ); + assert!(ctx.round_trips(), "round_trips() disagrees for {name}"); + } +} + +#[test] +fn round_trip_is_byte_identical_in_strict_mode() { + for (name, source) in fixtures() { + let ctx = ParseCtx::new(&source, ParseOptions::strict()); + assert_eq!( + ctx.restore_bom(ctx.syntax().to_string()), + source, + "lossless round-trip (strict) failed for fixture {name}" + ); + } +} + +#[test] +fn round_trip_is_byte_identical_with_css_modules_enabled() { + let opts = ParseOptions { + allow_wrong_line_comments: true, + css_modules: true, + }; + for (name, source) in fixtures() { + let ctx = ParseCtx::new(&source, opts); + assert_eq!( + ctx.restore_bom(ctx.syntax().to_string()), + source, + "lossless round-trip (css modules) failed for fixture {name}" + ); + } +} + +/// Error tolerance: fixtures that produce parse diagnostics must still +/// round-trip. This is the property the whole byte-range design rests on -- +/// unparseable regions become bogus nodes that still carry their source text. +#[test] +fn round_trip_holds_even_when_the_parse_has_errors() { + let mut saw_an_error = false; + for (name, source) in fixtures() { + let ctx = ParseCtx::new(&source, ParseOptions::strict()); + if ctx.has_errors() { + saw_an_error = true; + assert_eq!( + ctx.restore_bom(ctx.syntax().to_string()), + source, + "round-trip failed for erroring fixture {name}" + ); + } + } + assert!( + saw_an_error, + "corpus must contain at least one fixture that fails to parse cleanly, \ + otherwise this test proves nothing" + ); +} + +/// R1: Tailwind v4 at-rules must not merely survive -- they must parse without +/// diagnostics, so the location layer can find them as real nodes. +#[test] +fn tailwind_v4_at_rules_parse_without_diagnostics() { + let source = support::fixture("tailwind_v4.css"); + let ctx = ParseCtx::parse_default(&source); + assert_eq!( + ctx.diagnostics_count(), + 0, + "Tailwind v4 fixture produced parse diagnostics; re-evaluate R1" + ); + assert!(ctx.round_trips()); +} diff --git a/native/igniter_css/tests/property.rs b/native/igniter_css/tests/property.rs new file mode 100644 index 0000000..7111065 --- /dev/null +++ b/native/igniter_css/tests/property.rs @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! ROADMAP §9.6: property tests over generated CSS. +//! +//! Unit tests check the cases we thought of. These check the invariants against +//! input nobody wrote by hand -- including input that is not valid CSS at all, +//! since a codemod must never panic on a user's file however broken it is. + +use igniter_css::ctx::{ParseCtx, ParseOptions}; +use igniter_css::edit::{apply_edits, Edit}; +use igniter_css::ops::at_rule::ensure_at_rule_line; +use igniter_css::ops::declaration::{set_declaration, SetOptions}; +use igniter_css::ops::rule::{ensure_rule, remove_rule}; +use igniter_css::ops::tidy::{remove_duplicates, sort_properties, DedupeOptions}; +use proptest::prelude::*; + +fn opts() -> ParseOptions { + ParseOptions::default() +} + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +fn ident() -> impl Strategy { + prop::sample::select(vec![ + "a", + "b", + "header", + "footer", + "btn", + "card", + "x-1", + "hide-scrollbar", + ]) + .prop_map(String::from) +} + +fn property() -> impl Strategy { + prop::sample::select(vec![ + "color", + "margin", + "padding", + "display", + "--brand", + "user-select", + "z-index", + ]) + .prop_map(String::from) +} + +fn value() -> impl Strategy { + prop::sample::select(vec![ + "red", + "0", + "1px solid #333", + "none", + "var(--brand)", + "0 auto 10px", + "\"quoted ; value\"", + ]) + .prop_map(String::from) +} + +fn declaration() -> impl Strategy { + (property(), value(), any::(), 0u8..3).prop_map(|(p, v, important, comment)| { + let imp = if important { " !important" } else { "" }; + match comment { + 1 => format!(" /* about {p} */\n {p}: {v}{imp};"), + 2 => format!(" {p}: {v}{imp}; /* trailing */"), + _ => format!(" {p}: {v}{imp};"), + } + }) +} + +fn rule() -> impl Strategy { + (ident(), prop::collection::vec(declaration(), 0..4), 0u8..3).prop_map( + |(name, decls, shape)| { + let selector = match shape { + 1 => format!(".{name} > .inner"), + 2 => format!("#{name}, .{name}"), + _ => format!(".{name}"), + }; + format!("{selector} {{\n{}\n}}", decls.join("\n")) + }, + ) +} + +fn at_rule() -> impl Strategy { + prop::sample::select(vec![ + "@import \"tailwindcss\";", + "@plugin \"../vendor/daisyui\";", + "@source \"../js\";", + "@theme {\n --color-x: red;\n}", + "@media print {\n .p {\n display: none;\n }\n}", + "@layer base, components;", + ]) + .prop_map(String::from) +} + +fn stylesheet() -> impl Strategy { + prop::collection::vec( + prop_oneof![ + rule(), + at_rule(), + Just("/* a comment */".to_string()), + Just("/* ===== Section ===== */".to_string()), + ], + 0..7, + ) + .prop_map(|parts| { + let body = parts.join("\n\n"); + if body.is_empty() { + body + } else { + format!("{body}\n") + } + }) +} + +/// Arbitrary bytes that are *probably* not valid CSS. +fn junk() -> impl Strategy { + prop::collection::vec( + prop::sample::select(vec![ + "{", "}", ";", ":", "/*", "*/", "//", "\"", "'", "@", ".x", "url(", ")", "\n", " ", + "\r\n", "é", "\u{feff}", + ]), + 0..24, + ) + .prop_map(|v| v.concat()) +} + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +proptest! { + /// The Phase 0 gate, generalised: the parse must reproduce any input. + #[test] + fn round_trip_holds_for_generated_stylesheets(src in stylesheet()) { + let ctx = ParseCtx::parse_default(&src); + prop_assert_eq!(ctx.restore_bom(ctx.syntax().to_string()), src); + } + + #[test] + fn round_trip_holds_for_junk(src in junk()) { + let ctx = ParseCtx::parse_default(&src); + prop_assert_eq!(ctx.restore_bom(ctx.syntax().to_string()), src); + } + + /// Non-overlapping edits splice cleanly and the result still parses. + #[test] + fn applying_non_overlapping_edits_yields_a_parseable_file( + src in stylesheet(), + cuts in prop::collection::vec(0usize..400, 0..6), + ) { + // Snap each offset to a char boundary, sort, and pair them up so no two + // ranges overlap. + let mut offsets: Vec = cuts + .into_iter() + .map(|c| { + let mut c = c.min(src.len()); + while c > 0 && !src.is_char_boundary(c) { + c -= 1; + } + c + }) + .collect(); + offsets.sort_unstable(); + offsets.dedup(); + + let edits: Vec = offsets + .chunks(2) + .filter(|w| w.len() == 2 && w[0] < w[1]) + .map(|w| Edit::replace(w[0], w[1], "/*x*/")) + .collect(); + + let out = apply_edits(&src, edits).expect("non-overlapping edits must apply"); + let ctx = ParseCtx::parse_default(&out); + prop_assert!(ctx.round_trips()); + } + + /// Overlapping edits are always rejected, never silently merged. + #[test] + fn overlapping_edits_are_always_rejected(src in stylesheet(), a in 0usize..50, len in 1usize..20) { + prop_assume!(src.len() > 80); + let mut a = a.min(src.len()); + while a > 0 && !src.is_char_boundary(a) { a -= 1; } + let mut b = (a + len).min(src.len()); + while b > a && !src.is_char_boundary(b) { b -= 1; } + let mut c = (a + len / 2).min(src.len()); + while c > a && !src.is_char_boundary(c) { c -= 1; } + let mut d = (b + len).min(src.len()); + while d > c && !src.is_char_boundary(d) { d -= 1; } + prop_assume!(a < c && c < b && b < d); + + let result = apply_edits(&src, vec![Edit::replace(a, b, "X"), Edit::replace(c, d, "Y")]); + prop_assert!(result.is_err()); + } + + /// No op may panic, whatever it is handed. + #[test] + fn ops_never_panic_on_junk(src in junk()) { + let _ = ensure_at_rule_line(&src, "@plugin \"p\";", opts()); + let _ = ensure_rule(&src, ".probe", opts()); + let _ = remove_rule(&src, ".probe", opts()); + let _ = set_declaration(&src, ".probe", "color", "red", SetOptions { create_rule: true, ..Default::default() }, opts()); + let _ = sort_properties(&src, opts()); + let _ = remove_duplicates(&src, DedupeOptions::default(), opts()); + let _ = igniter_css::transform::minify(&src, opts()); + let _ = igniter_css::transform::beautify(&src, opts()); + let _ = igniter_css::analyze::analyze(&src, opts()); + let _ = igniter_css::analyze::extract_colors(&src, opts()); + let _ = igniter_css::analyze::extract_animations(&src, opts()); + let _ = igniter_css::analyze::validate(&src, opts()); + } + + /// Idempotency, over generated input rather than a fixed corpus. + #[test] + fn ensure_at_rule_is_idempotent(src in stylesheet()) { + let Ok(once) = ensure_at_rule_line(&src, "@plugin \"probe\";", opts()) else { return Ok(()) }; + let twice = ensure_at_rule_line(&once.source, "@plugin \"probe\";", opts()).unwrap(); + prop_assert_eq!(&once.source, &twice.source); + prop_assert!(!twice.changed); + } + + #[test] + fn ensure_rule_is_idempotent(src in stylesheet()) { + let Ok(once) = ensure_rule(&src, ".igniter-probe", opts()) else { return Ok(()) }; + let twice = ensure_rule(&once.source, ".igniter-probe", opts()).unwrap(); + prop_assert_eq!(&once.source, &twice.source); + prop_assert!(!twice.changed); + } + + #[test] + fn sorting_is_idempotent(src in stylesheet()) { + let Ok(once) = sort_properties(&src, opts()) else { return Ok(()) }; + let twice = sort_properties(&once.source, opts()).unwrap(); + prop_assert_eq!(&once.source, &twice.source); + } + + /// Insertion ops never lose a comment. + #[test] + fn insertion_ops_preserve_every_comment(src in stylesheet()) { + let count_comments = |s: &str| { + let ctx = ParseCtx::parse_default(s); + igniter_css::locate::all_comments(&ctx).len() + }; + let before = count_comments(&src); + if let Ok(o) = ensure_at_rule_line(&src, "@plugin \"probe\";", opts()) { + prop_assert_eq!(count_comments(&o.source), before); + } + if let Ok(o) = ensure_rule(&src, ".igniter-probe", opts()) { + prop_assert_eq!(count_comments(&o.source), before); + } + } + + /// Minified output is never larger and always still parses. + #[test] + fn minifying_shrinks_and_stays_valid(src in stylesheet()) { + let out = igniter_css::transform::minify(&src, opts()).unwrap(); + prop_assert!(out.len() <= src.len()); + let ctx = ParseCtx::parse_default(&out); + prop_assert!(ctx.round_trips()); + } + + /// Beautify then minify lands on the same text as minify alone. + #[test] + fn beautify_does_not_change_what_a_sheet_means(src in stylesheet()) { + let direct = igniter_css::transform::minify(&src, opts()).unwrap(); + let pretty = igniter_css::transform::beautify(&src, opts()).unwrap(); + let via_pretty = igniter_css::transform::minify(&pretty, opts()).unwrap(); + prop_assert_eq!(direct, via_pretty); + } +} diff --git a/native/igniter_css/tests/support/mod.rs b/native/igniter_css/tests/support/mod.rs new file mode 100644 index 0000000..2a600b9 --- /dev/null +++ b/native/igniter_css/tests/support/mod.rs @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; + +pub fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures") + .canonicalize() + .expect("test/fixtures must exist") +} + +/// Every `.css` file in the corpus, sorted by name. +pub fn fixtures() -> Vec<(String, String)> { + let mut out = Vec::new(); + for entry in std::fs::read_dir(fixture_dir()).expect("readable fixture dir") { + let path = entry.expect("readable entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("css") { + continue; + } + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("fixture {name} must be valid utf-8: {e}")); + out.push((name, source)); + } + out.sort(); + assert!(!out.is_empty(), "fixture corpus must not be empty"); + out +} + +pub fn fixture(name: &str) -> String { + std::fs::read_to_string(fixture_dir().join(name)) + .unwrap_or_else(|e| panic!("fixture {name}: {e}")) +} + +/// Number of lines that differ between `before` and `after` -- added plus +/// removed -- via a proper LCS diff. +/// +/// A common-prefix/suffix approximation would count everything between two +/// separate hunks as changed, which is exactly the failure mode these +/// diff-size assertions exist to catch, so it has to be a real diff. +pub fn changed_line_count(before: &str, after: &str) -> usize { + let a: Vec<&str> = before.lines().collect(); + let b: Vec<&str> = after.lines().collect(); + + // lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..] + let mut lcs = vec![vec![0usize; b.len() + 1]; a.len() + 1]; + for i in (0..a.len()).rev() { + for j in (0..b.len()).rev() { + lcs[i][j] = if a[i] == b[j] { + lcs[i + 1][j + 1] + 1 + } else { + lcs[i + 1][j].max(lcs[i][j + 1]) + }; + } + } + let common = lcs[0][0]; + (a.len() - common) + (b.len() - common) +} + +#[test] +fn changed_line_count_counts_a_single_edited_line_as_two() { + assert_eq!(changed_line_count("a\nb\nc\n", "a\nX\nc\n"), 2); +} + +#[test] +fn changed_line_count_is_zero_for_identical_input() { + assert_eq!(changed_line_count("a\nb\n", "a\nb\n"), 0); +} + +#[test] +fn changed_line_count_counts_a_pure_insertion_as_one() { + assert_eq!(changed_line_count("a\nc\n", "a\nb\nc\n"), 1); +} + +#[test] +fn changed_line_count_handles_two_separate_hunks() { + // A prefix/suffix approximation would say 8 here. + assert_eq!(changed_line_count("a\nb\nc\nd\ne\n", "X\nb\nc\nd\nY\n"), 4); +} + +#[test] +fn changed_line_count_counts_a_pure_deletion() { + assert_eq!(changed_line_count("a\nb\nc\n", "a\nc\n"), 1); +} diff --git a/plibs/css_tools/dist/css_tools-0.1.2-py3-none-any.whl b/plibs/css_tools/dist/css_tools-0.1.2-py3-none-any.whl deleted file mode 100644 index 80a9c34bf4abb11ae76342e3b5ee5553f3e5f6ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16528 zcmaKz1CTAzvZmX%ZQD3)+qP}nwr$(CecHBd`}CaqCT1??&D#|#Dk`G(w^r54$jJIL zQ(g)f1O)&900Lm!0ZU8OVQP@?@6|sB>fd4H<&loyZlB% zMfoYoJ0ze{DgvZDC2ryYB(RR?zU6bMs>@SE(uq*%EVLZ9cXu+@+3^#tr`&i*ptm$g z+|0~O%}z)5MHQZ&FIT0vlu6|(o5wYDN;EV}1RFI=-$APuytBmLPn#$rWn~whODU+m z7PZq3j5W}#RBhNfM0dZ*WGWFqc~kCwqP)2b?Ji`spM6X)Pm)@FS z=}RbJ0Xj1$EVCdxkbwHN!!5}~$Pz0l%uw@dY*SpwOraBuyN@8Wa+aCcnfW{IW<%Q2n%akdb67}Ad=SBfQ>3qa;wwZAqhx1&Gc#=h=;(> zJ{B4}6^xl_wa`WRbDbxa5YRUsco4#i1XQy&5oQ;cKUyN}jtafZJN?Y)Tuo~cWyH1%90j18Xe3i0 zJLM9dK(mF3pfNRye+M@siTXnv60~Y%NP31VhLNz2piHzK_UT3(C5|exvld~+u_J;_ zLJl}`00&Uhd8niSdovj_TGIkLKX?oH%-&`4_{|ssVRRMfkkCh%!Us6HLVu2i^`R*~ zKqC&#>OD+#x6i1|y1SdD7q}uKczcHTYsHOg7EhH>i0E3(2YFfP zQZ}j`n6?H~h-3g)Gx6d|^Wy%*#HG=X)|IyTb$rJ8y|3OqJa!pWyZbT1`}aR@4rskk zY>qI$`7PaF)?iO$3BBhwyg|=%WPQY|GC>0jxM0tW?aSHG*umZO9~{2SBEYvXFt_yp z?hQ{0Ny|CtoN{+`Qy7HNjNa%f&bsR(;Y`89_f!JJtFjh!q=t8zuJDE)Ie4$*>fv8;v7*Rht@nNXn_b>oNhG+_3?R4pXpm5|9sj;SIPBNuAWFCv5d6Fi3i2tuc4%dJ+ueWTg-~%WtD^RRjQb z&!`5=jm4&P4&xsV2;$j($shRIFXBo0dw$K;WJE(VL7HWrlWU)ICUS1`qU{NQucx)89w2?+oM3(S%GAL zlZ<7^u6+08!|YdG69FMsq|*7@l%O_$a>Al+`{@*y(Idmw^r*)FG(P;a&YdizH--zr zCAV`Ovc3T#R%cra7(E2yjFEE9KALzTHl4M&ZUlYIc9m~S>*mi5?#jVhFRq!1OhiB% zAh0srTIiUOD0su?Ay?mZ^fS(ruN%^)zoxubdiY+^Z|SV}i!O*;eiKpF3hlMzUz)1J~FNtEr&;n8O$NUt&B0!d_)P#or-b zHAAdK%M-Q%0l@n&@h|wmvFFAgDKB!S^*CKyV(oA`!iQ_(yG?|&wK%^@aSB6TK4Nc3 zEzejuV>oJm+*rWxel{x}YdGM5(>w{O)*u1>8JKtM3?hWWHsx9vk&Nf02Nat?8{+o! z_a-FYb)>R{A6zm--+JjZaWwtiAErhF%xk{kQM*7KdL$bU0{kNN-hO2H0~BnHU(^D+V^y2fvNqus z7YC+)7kM4cqpHy_G$F#+`l;6Mg+8M9o8+;Kd zK@%)HM1a-bHIoHN9|32YjQ7ef<^-rmV0UsF&#sw~P(UTjoGQgzbldIU7}Q#9 z7l@kg_?kzu9!lwRMGa+_4c;uC>ulLa>l0JimCvHZB-^|V8`n@g4~4>P?Xu=kEX+(&;F)9(c<hwSWYC#)Woz&1#1WIO#t>do^%$KmtlBju9Jj1w-UTPGz2rcgMf>OU(ZoSkIGP-?>Nc ze9E;uiTzOQxAEF@I!7-NcbeQ<63JQ`#$EGxZ?}J&hQvDUfb*<-ZmkN2Vv*|-qel6i zu7H@Q7)WA15Wb48`Pk0l9&Yu?6$lMEl)!R9O$x+}%H+A`Sc*EhIy(ilWFCxc>ufs; z<`}!LF5ygn3W#n%mGC}=CR(({5Ts|N8_^~)mIwM#y%-%ZYE0e87gNos!0XENOTEjz zmXWl5nUTNsOIlYadd!YU6Z3P<&X3Q8?6&KyVwP9(dUN6I5X*!t?LnZdQo#Z#+IHs& z1S$stSn(yS{k95V9KlzYx(W?C!HgBmT)(Vf8g>;k1);o|N+j?$*-du8g3#0X-AFLq zp2=$u_&Z`1SVHP3ooSF_brki0Zw<6GwIPEdRK(4Q0Yor6i(BEF&C)9sp$=QXS(`B| z?7STU7A`qqJMJ--7g#1vYr?&}9y=JjwsM7Rj6eva$>R5IT=(i2tjvo}F7U&?r8w&z1t;oiEdHX!))dra$?;PV8W5 zJW~!Zr@-9FmWB2gC_TEc&>1aFr~wFDQxTBfzz8yKuE48M^9zrh@odmw*R9sAlKlfi zRP%njM$OP_xVhV#aOPx~vJ8vTt!^uA2}fuX0&Lv2y8K2+SdQ|{*Ie5-Z=Z?oZ>a(@ z-Y+2LK}nID)X>x{155sH^*ITFvc7pOAwyqFw!DrqHp&anXAluWshwn=XOlD>!R>HI|^7Hv7@MZ^mclwOCD| zAe*txA8-=8%j zsNp-H%goFfHM`at@}3srJTu%ELmoLN6>y-^`k5y)pNqC6)@1Tw*P~l*dZHAw{Kr*C zgl;-?wXmF z_rzZ%d~3b>&Mpji!~aBpeY9}OtQusexAXdKH}!{O%YCA=5XWZKQJw+*7+Pi6`gbG)2e z&Zfg!;-3UM9uTj0?8uatX>s)DrZ_ygzyvPcez#}dARZwzQgsk6r4K$A98mtQkb@6p z$|&;jL;Rm`VC!!)wCF*TH-^|qcai5=Ji$FxXxC|#2VlM>!q@3ZG*63b;r&{6n;bnW zFYR?d)saQo6S#e4<09FOvc*>k5g`XR@0~R$-@#|d5n}cH;VB{4Q>xt5fY-uJ5(|8X zV(^Lr2IVH>*%K|VGikfP1?PPJ6%z`bua&&g;j@~A(qN242Qqddz%t^3xxv)c0EGGfQ(&?AtFM?^nEXTS*w(s?+iXYtx#C;KFlVft2!;? zqq8t?lVK;`LK`o-{hctfoc)#K@b>A^nv>M#lCZ$}E*3y7a-RK_IRG!wNl}=%sJIwe zg<982dF+-96!zMz>5#e5J~^c#!dIbXlCz*9c*sH(0_4qtMf#*l)uI!Stn~IMOxJRx z!byH8s z5f=d%E+$)k_Obk1>3iv`75fA0OtRyd=&5t=&OB&swHvJkxwi z#2kR9$aiEZLOO9>H5rl2)hZ!@bLkVURRG95Qu*x_)YSLG(I-O0ln&4pg&Tn*R1YYiHHe66Jb zq9`oY_^8{&cRaCg+`4iKH*a6TJ=cQ zP|=1J%U5spET`+*1Hy^ETVr?yyXw4HpR(@fUL`}$rMx%}7Lln%bFGfY3l9izuH`R@ z?va?hCUXxBP9c(TCnd0xH%FqU*)Ni!ta#<4sB9Y+^iO7r`gh6tQ`4gqfYB%<5@b?L zcJtGuknY(;Try1aSMCU-p8ff=@FqNQ|BeWyx{yfiP4NO;&5qDjjZN5x_4JT*CoYxN zfZ_JTGHdRItdqgi9^`0y(QF7%q!rD4dD1cAZ5T-4MviGHdve=2aUe9)c%FD!jt=dR zG!}?%Pn8aIx;||}kPs>I6cRxr8;sTiF|H>uD@<&t(v+OIXj!p~M%1)N+|-FA=i!sq zK_ep5JYB$G){nw->GvoeL6Alk*E@hpOP6D_Ou1#;=9PAxjY@FZH$|iRQQN zh&@x*yq3Y>n-&`{>r6YsOEIru*dOGq@kS;B?9m`%{`i)J0*5Q8K{h3{^ixNS&2NJx zOk9-L>6LsT;K5Mw8fzm*SE85}GQO;e&c{%4C=4)%_I9BP86lw4tRd(=v^ zp+mCZ_Fyk$F(uZBn_?R@K4v`7DuW1r${vJ))We8Zwqj)6QM5fsx9f*DsO(wu`yEa> zSNx1%;i~)M`DcY~iTgpnlj{65fqN`+T!p4!wumwci&Uxjm91_@F>UYnxyGeo({(0n zTc`;WhgucJHWCeeE0;;V`Q?O^L!kd$@X|d?4BI6M(FAg0_`wm)8`Ey}y>nUMuEpfX zc1e)4xYkPY{ejK86i(o;NgS7tbM;4$L=4;)$$sm*79(s{@adv5FStp-4hA#o%G?W^ zBgET+@sk@aM3>7G)OYNX5UNb31cBa=D%uE1JMGF5o?aayp5!nqrkm8PRMix$dpsCN zS)Jmy+`DnZBHekGC7Vk@V`Ac;DE0t(3|_4o3s(7Gq;wIM!-?%X@qmgs5?lygLVk%mz$;0Qli>rGX`zfVtfc~Uy5F5)-=V|a4Pj_^29x+X1i&&EGk?&tE|Cw{Aaxp!kWXxq?iKa6 zgC~LwawWUyWK46_==O*;d)uHL(wpc=|0vo)?wKA&^Wx|ua4hO3w3(pnvKyvvA{A16 zq&F2~6YJh5#76Bsxx(@O&T3p5O|J3Uli7yk;Z>_QQBttwABE@T(g&S*xN^v<^ZYhN z+Zg>oU*=CLSQ@lV75yexxDr97>_tNlk>^@fPnQ<3q###uhOZ4JmX#(X9p0Ki&vw@Slr`$N#_;d;&F-AIe<3tSFo(-xF-# z1k2atgeKL~$Aoc7wqv#R2W!@&AN0~|^yvD~1lrH1UDcnix{BgD?V;?q#K~%~oi^n% z?|L`GLkSVNa#7#d)s@!_oPS&m#7tD@zLrm$TDA(XS@+5_utpaiY0<2s(jGhYMV#a>* zjuYU0ga3QrjEmupJ^VLMxT^dA1kQHG|0i(1*4}p7WJB_M)eB&PPo`@u?J2`H+p?&$ zsmv9<|yBcF%kc-6hQX0fBy!6glf~`~0`)D=akQvCLo(UKv zwL^!cci&KbEV9a&}I>CzWcnZm6wdt=pU~tZ`nQKn#Mf_a+kM zv)b?+rDLPDn=yNmZQ(X}i^Q`KG-*8OJ!O}e&!8WsNo?dXKvz+zS9;;5#PVe^8PhYY zLbePE9RBgkF`+SN2Z$uFqIO@X_K3VJ(8z44G_{ycN^N?Hyga4RYwR}B7|CL?#qzf_k!hUEF%Zo=Zxw`~_t;!6Aq>M8pZ5@L3WSS532dRE zP@ek&J1h1EDopZZYB_?mg8rEi02RniYPU)o`w1^I)K-?YW>_*MVJktKs-$MF$<~y+ zTb6uiM+nNFkHBM{H}Rl6d))#UdNYPI9bcz(es7V2Ds+gRE$S^XoWWUVtcPq(*YuGV zO47jrPTsEeO=LD|U)1fXPSq{wuI2JQV(}X6?Kn7RMQTIM2Y{Yn8-&3s%Ua>S-%0G4 z23=c@E{$$HF=;zs5aDGc=W^*tdmK^ymc;NON=?pimk9-|vpKw1d~%Wvgk<#t^6aBa zRDOfH2y(402l>o+O)hX(g}X_oLu7PnrP%V5W)`66X?84ZmF`mc zhjm9FmUlzcp*2X+W$H4dOWVnl5i>!{eH2|kXq7i>WO{~4ZFc9_If%_<84gx|>7_YK zaivt{<1Na`AU!X@V8`-1VldRqHIMv~mA+}B91V`uh%AN6SBt2}{Q^DPE}K9pFZv-# z!ISR~CcNtr`IU!CU1I8Q;siE{b|8M#@9C`CdRoLg4>tZ*Ju2P&Xu!b9M#7#*NTQ`mi|Ub}o+4#1{5o{vP+9YMX! zJ4!84H#F|`#;&fm;~cK>7n_cdAEKyi(52j5xbauRQ-3B|u&6t`nbm3{2I_tye}y+N zg3^=z>8BWfX+wPKnZBM9nGsxCrb9SB4-<8UFt3_H_Uo*1f&%I3gXs(g?G&PN#Ec; zfgF?*EJES#H&sA0gFikdV%&uBrH+j5K!&t+1AVuCz>@LO<&14IAKTLnAW!?~mWre~ zU?Q&KxFMz%)0(@xLltHF*7Io8y&T?5Upy+@%zN4Cdrm=JsfNC~>E1N(ScD0w*8^nP zVVmS~+U>laIQQjEg=k$M(G9uo8^n~RL&x^;LAc2i{3 zvfQCD=rSdpc!=58p~vu=!U0E>=6v;+PC$e}50-5D3$wX~m_@VYb2c(vowPw|$ zxv+nuPY(|wFd{~S&lxa0lTL5z1MoBH11J57WS4QAFnW8G@z7CiD>28EYwMBEbu*C! z#bUtKRFId<91u|`S%vMI-lT4=og)og1pF;+!FLf&C*q12AKW>2R@xddC)ZE;Y%MIS zs0yA>)nXq;L~T^udue7wArT(U-9DH6LNx#YF@(Pcut3el3zVR1e;&_7-vHd7Q}=l| z7(g(>H`PkZI=@{%E!u70Om7jg@myMI42*}VbLUsJkYw&R*c%4d-*-hHBZ0LNJWGGd z+zJGrS+B>H4r!-tL2`YNX=hvY3z%|l?i*Q-HV|3=@dCglmDUsYj)8WIgsL&qgp`es z6LvLpGI2F3{}Se&s6y^rT3jvo5Xr~Fo>i`^bHdJjUlj)gzh}eKJ87WOO&$6b%ZaVO zt1yp-Q9IFyBF(&^shLxe>9q@0h`p%p=0jx1@FoaN=Av6d7zXDR-Y2sI-I^|6T&L z#hnX|s@ozO7^(+d9c`jyl2(!&)Bl(y#dO|{hjm>muZ>pJT$XioE08SX zWbyF=u91}?XQJs&t{AkN)x$6+P?j{jy{G7FA73z(M#icx+792o1Ptbh#eT(MWo&Up z+dJ-{3B&sv#gD&s1GujJHGMZ(w1diWmB?ssPEDou$zDAdAMqhN&n#Me^pPkXff=aj za$9q##%K~6&~P#rG+vqRrPQ#brON9N8B2->^jEe2+7R>QIwGR%>H^3-19GE z4#6g2j|dL6eo%(TFDCjV!&k}+yxt8!(exCZJr{O+R(>mWv_ps$_;tXeWZ1H;d}XNX zDVP%Z!TUt}WKQzPBp{%OeJ=a+tEh|`G{RANl~1AWep#<64~yhF_yaPU4T7uzI(RO^ zZX=f?S#INrXOANe5nfk?Ii2e6VFX)&bM5eXn$_3F*%hr3ku~_*dX_*fTvZeSVC9(M zC(qu=0t~pkd5kMNG+?n*x#q-Q!#6@x(e983jll17FQ+x3TWv@lyTbAMm*l&#L~S0& zv^lo9Iwr3>C)nS2=_q`kqb0EgAk^tx2TB!OavALM+ZpZkeDF|KKA&00n^3N8pJVNn zVSPYY)xMgve9G@l<8_>$k(GI}vwVW{N^D+nCUWN(764`E>NQOn>c7|Nu#*2oG5!@{ zoa)#mE-%gjRi=heRp2<5mWQBotPLxOM=b@3&7655+XMUDvu1Ss<{nFMCvG*cZQy94 z9zGV7=fI3`8z>|1Rv{CC!@hm_1+77oCRRhL!CsUF*!G?W2!9U`pl55X80ZpN42Au` z@XphaSOc&ubZ2+&f*G`HPF6gdL{c;|ZEKdiOF~6M? z!L9QejI$)HSIX^Sw#?@^$oM$ORdDMRQtRn7gWGpZeR|jAE|i+*VtY0@SEZK-ogMN1ZNvmAyt+#yPkWELOrYb z7=6^Sr~=lm&PH63YSji(G&^N?oH^jnWtk(KV^+TwRvrhOM{ue|KGrm~qQ{#KlW4&?1htsc`ZD^$+P^8MBO zwJ(3_t`PDDEXDk&5WF@-lJJh2BB0I|;OLLTFY&=w8-yRjwm}2|IpzR@X1luwpjlU_ z&b@d&Iz}f6-_YhcYAn~}TpE6ic)2*V8GkVQ0>YdcPvColo&f2QznLxiZEZoUAcBp1 z#nYVQH$vngeua{)G}N$zfq#lP6kv;8jE8a_0T%2E*%H4Wz3yR4h$ka=EOQ?<~JxvO>`%@dgLGlLHJv391ShAeU zm&aj-{NBK1M&7d$DU)$P;)}A7TH={u1-n;q+BO#SMye9xT(e&dX+R%*S5HE2;xpho zB7zl35(>P>W!oI>fzS(d3G7+b+2~xMsf|cLSH`AG6tX$EKr2GYSwrRiL*P4V7{HQk zAqVK<-yW-t4U`@>rrT~2sqZ=$>T4ojAu`yH+rsuSLXPx@wusZWs|(pI`uBaCbyv(4 z?|yb&F~@VJ!R{;Y+QFKkA3zfh?oPwZDJW30VoCRBv6gI@Dpnzy^jo|kmb1hLjDC9NQbO9Jtj z=q};kAkmfp+0XjCDE1Wes@H!n?C%S{&BycXx;5r4JI34T#Q~CfJT#ZQllQ>qhrvQFhH0Ga^w6QG$fs_UZumi-nGaam@|{H zj~5xen9CYs@P2+Zf4p%AG{tu*kp^XN{dxZ5WBH}L+D6CPrO^eix8t{5sbq3-#5_W! zlH{e-Z&_A|ghx*gxEXm=+5%LB>b)q9>gr*KUJe{-nE6wiHA|oRaWq6*D%2Ql#_Xy^ zZq#_Y!y2r(%ZF8l zGVZK=!PQ(ErcKHymjq+q1nloZ{v1_#QulSP_o$?S)+uNPSnliC2dw>2o#s?zj4sZU z)QQG(t@h_X@`bpZ1W>Ji^MyAI007AURvv9{;OO*U*}`co9lOK!`|Z9!6+G+xnKEsW|=Wd;|VbZiSDSc*BKazgk*}b3A)G+!iI|X18=8t5((dq zUQ;i+tin#C8mdV_)ItN=Z&eW&b=GNo`b3fp>B#Ng)Jm;vYvUfHi+VBf$1A%LNs}w3 z`TpJ4WkMtN>s}PhPn){P&I6OtUgbLzri|*5(6z{Jg)l`HEy5+3ab3%Bks{;KRB+PF z&E%Y)5OX^JqwC|~0dUa4pS%Q+Lm0Pxh=xK%*6LLM;S1O#TJH9Agi$Y#RdcL2!dP}K z0T9(^EOS>rqxZ9&!2W$|A*YV;V(5^wn3V@ zi)}a%n}K8WgkRe=hyhp6ifD5=u_5UXB< zykWy~jYp`|tfjJRQ%Z16t~slyK@FN*n1~kDjnLN2vx;YTIppMVnZBlN3F%$Dfo~@7L=kBqj9v+r@5XoF18v+V&AtX0jCv z+;Cy~W&=64ZH9McgjI}POI}@ohnGPutx0OMEn`V-B7H$TK-sfC-*D9JAAm--=gIW( z$Stl`I*MP@robJRBa(7h(>jsnD$|F8t&havMU5le`yuhJ0*<*8MTjA&ms_tAAqkM1 zoIwZWVI^sNL)O4eipcbGJ5SXyyFccH+nWC)MK5RSU_cLIF7h#*RFz z3ZfN*$FD-*V$mp#aE*rC1kmSep0Ize2io##WM}}ZHA6{}DTxNbLX~@u$ zY;Yu}dinDx)>v41E283E5-i5iJoiSa^$9M&yNWtDi|@V;rDRrJ=3G37lPSE1)V%aQ zycGTm7WTw$FjJBkX=;z=Uy%B;g%%oQsoScQZ2JdILqDQ5G)hw zfi@?&%TrEms#7iTC$F?*xkoet+%<5g8YEfEZSzn3RLO5F1qct`LvA7-{ET;Ij+blm zD@I$<1}z*Jp-rd48bZKxZzJoss?2irG$iq`OoH_HN4%9fzZu92t8Rxo_b`k6-fwH- z!Q%z)3!4id!TwZ-42eP@iF0z$eJI?}D=~ZlA{Do#3xCX1KPJkrKl)?QAtF?;>^QvN zX+G9YT6*n@bJsb`Zn1sI4$H)p;QzjEJ|%IpiUK%AEB^3s#5pMA^kOH1u_xwYXatr7 zkyS>c?xo3z&Rq}uLPq5^TnUlecTM1s@WCIQrA`=Az6Tb6|J9}XH_aD~Z%UMt-)1l0 zz{`sn5g4Kq6)s&&Q`O)s9!GtjzR5p)%eId@JKyt!Ll#Ogp&NMS30rwsN)BtZZd>Oe zwSpmqLrxYh+gI4ZNwdVpQCF&S!ciavWjGp#d{)NVVbkEpDcub&R(O<>0AT2c zZO(EIlb~&wiVft>>o?KM0}e1-2Z=X!zJi9=WsI^a5Y5eJgkL3?kz_v%j8UHiUbQo$ zEgFOfQZ`UT7@q01LIgsb{GKonZ8d&}_yBJzg?re55QDnO|N6FfwD-RAYZK849B{GT z|F)=Piw!6EhLIaUQ`~Dp(CHB=WVW|BDkPlWsAIpl8%arwMgascx~yWSU=ttjSqto% zF8Ao(l)!i6MhvBLU^KXGCnN+_10P`@LaEy2{oXYnw2q4Kah~`z-QQI_d80SrZ!p_O zbGh8}C>r^%)SjH*vJ0AJEv>?f&u(PggxfoIG)8JtgCvF&e^c{0kFon(` zSUy5j;2KEJLT%5&KDX6&JrDfMrq_jnL2(-V_<2c^8-^2$2Yax$53Z4ovP{Ii6`GXS09=5*f71I(G>_sPzEhvl?>oh!Ys@aWxfd-M^-#n z?Ltv?)ZTDN-k42*s)3(rNx5l(`MG^!IcfUqOFf0B9MDKOH7iDW~t9H2=WfvSELau@R;)B!Ku;>&f^Ty<$Vloc2x&ORCf& z1vLzW|A6~l`{=8QGU%SB0*n4al5;lg0NBvJBFgToXK}Gy!oyaQ4jf?`6TQ#d5Z1?`d;}+BSZ@oww&7 z9o!Ih2!{iot$ML+-)015oFd|;z3r7vgvkgW@S#9He^^EpjyJI~dO+Q`P6h?jut}t4ex{gXsNF z2X}P0J*6z(T2W9{sb>LGg|_#Ga5L>ORH}SmEwsr9yCOs5{rj9@W1zl?ss{Ir}( z*4e@&@5^ptz@r%&TG>bc@Ca(rdPHcizG(j7R0p$j)_W9_6tfITARw*R5x6c}f7oOm zG;e8nz|mzWaLJN44JSp47h*rFlUw+uVs0&Gpd$-396(Nk@daXSbU8(BqhWzb;(eu2 zITwWjElf*NS40us+CimJ$L-E~K4rT?D&MZob1np6vA*8iHONXUtp>ZUx(d7hl1B15 zw(C^m2~qje#&zc!HwMSV(1)z__EFMVcQGa@B)fP@vX;#nbKMn24!SCbKgF0EGUya( zN+#~!1|I#eCzDR?P}>$^SGLxs9s+9T92`WR7AR%v%6^fPb)<&-F*kox!|tC*Q1?4{ zk)OdeSp9Hkohw_b^Dvw}IeW9>JmFI8Yn@McG%tOqxCkpNsM9IVYOhOag8?lT*0~d5 z{H*#RwdmVz#_=vG_W)Pu(U)J34HOo4r2D(f2A%mS#e>$Ax>Fb+q|!plslYHYHLD2x z(WxwR{4x81lz*SIOiPAmu9b?Pv_no|hzfp3%s%oWoc-GNB-LeV?e_vimVRyBtnRk> zWYEqsY%`jChhQ+6pjx$$Y6ue7UNR#G@vKy@RxPJjr?Fg=TN~!twE(~Ow4E{a!pLz~ zUQbr&@RlGUvfY$vW;PrEqG%ry8YmI?74l-zDPX#sB?J;lZNIlq9Nq}fTfplWe;30U zA&jV4M2%Gdw=b+TaX-LG(3_?IR~q{p6rBrRC|;_Y0_e9W*8O>|lyIpV+?cVKf77$U z9nD&zh7`EIhu*-vDjH%g`48wnvzvi`yC8v)YCV+!0RTvV004;oHO!}Fpkt(CqBFK| za;CMgHMOIc5m6Qp7El&QQJAvbppLG0)OtNtw>-5f_# z!SeB0d(FLPL?5LyV{aPPO*(>0h1N$232tYgkeh{nHZDl~kWUC>$lI^MNsnHNmovno zp}x$@@q{x5$cnu8H-L!W`|PjF_U}>5g3Ir~YJsjG2;){S=ek-5hHQNc^uY%Ut$R@I za;DLVI~K6D@GZR)jzR0YVMntKEDERf36N!ttf@?G!Tw2>u}76u8z$~LSZPx99A}h8 z?Tw+eMrPwF`9Z8uHzr}M0u*^mpwt-ekwtz}|6N98{Wo@*lKHN52Vo2-djt?l@&B^DY^oIa)tv#j*K8gTv2nu?>lPB(sF7 z@I?9s{C8uK$Stpg`MV&fzk~dLT$r<+y`Hs+tBEz8v%9kt9bGK_KYDggfc~Wi_a9Fh zwr`-_{@422|5_ix{|HnR5t36B_L=E&E#wFA-{+lW!oa*4LGu>}0UjqUpT+?Zac<|( z4)*wvIjog1_(b1*Uu+f8(K&pK^Uvf%^-dLL)*O7XN|L{FRawRiZ(xMB)O zDhDmCS73%W)&(~#)M++gyr<-+`fx3QuzsOpY5t`_Bd_t^9iWrCM7AUN|xC%h8(x zD-P)AbYvDwZEo<1jZs0YZ&@X6T@R#NVzL&QiQX+X|D3)#A0fAIPyjo>zh(pogaZ8k z@Am$?_5bxN4*19EzwQ10SHyqUzWu)e006}Sj{k=E*Jkd2W&C$F(f?!^ApYwz{-r|t zKN0^~9r8~^CeFVh{#h&X{~-QDY56B2f%ab!|5tbUSHOSg!~Y3z=lmDI|JS4cC+|P~ z&p&xET>lgAzr4|Z!v51|{1aC4Kf?aagZwA-KmEr4fi?%c{EyK8?M39JK*9d;56Hhq N>|f7w#q*D={{;w&>v{kH diff --git a/plibs/css_tools/dist/css_tools-0.1.2.tar.gz b/plibs/css_tools/dist/css_tools-0.1.2.tar.gz deleted file mode 100644 index a9f9ba646f95fb6c1ae1adff360d086c4957ad42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14307 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 RZ6OeH2`n%P -# -# SPDX-License-Identifier: MIT - -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "css_tools" -version = "0.1.2" -authors = [{ name = "Shahryar Tavakkoli", email = "shahryar@mishka.tools" }] -description = "CSS manipulation tools for Elixir integration" -readme = "README.md" -requires-python = ">=3.10" -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", -] -dependencies = ["tinycss2>=1.4.0"] - -[project.urls] -"Homepage" = "https://github.com/ash-project/igniter_css" -"Bug Tracker" = "https://github.com/ash-project/igniter_css/issues" diff --git a/plibs/css_tools/setup.py b/plibs/css_tools/setup.py deleted file mode 100644 index d0ca6d0..0000000 --- a/plibs/css_tools/setup.py +++ /dev/null @@ -1,32 +0,0 @@ -# SPDX-FileCopyrightText: 2025 igniter_css contributors -# -# SPDX-License-Identifier: MIT - -from setuptools import setup, find_packages -# Create a virtual environment in your project -# python3 -m venv plibs/venv - -# Activate the virtual environment -# source plibs/venv/bin/activate - -# Now install build and any other dependencies -# pip install build -# pip install tinycss2 - -# Navigate to your package directory -# cd plibs/css_tools - -# Build the package -# python -m build - -# Or use the rebuild script from project root: -# ./rebuild_wheel.sh -setup( - name="css_tools", - version="0.1.2", - packages=find_packages(where="src"), - package_dir={"": "src"}, - install_requires=[ - "tinycss2>=1.4.0", - ], -) diff --git a/plibs/css_tools/src/css_tools.egg-info/PKG-INFO b/plibs/css_tools/src/css_tools.egg-info/PKG-INFO deleted file mode 100644 index f9ead36..0000000 --- a/plibs/css_tools/src/css_tools.egg-info/PKG-INFO +++ /dev/null @@ -1,13 +0,0 @@ -Metadata-Version: 2.4 -Name: css_tools -Version: 0.1.2 -Summary: CSS manipulation tools for Elixir integration -Author-email: Shahryar Tavakkoli -Project-URL: Homepage, https://github.com/ash-project/igniter_css -Project-URL: Bug Tracker, https://github.com/ash-project/igniter_css/issues -Classifier: Programming Language :: Python :: 3 -Classifier: License :: OSI Approved :: MIT License -Classifier: Operating System :: OS Independent -Requires-Python: >=3.10 -Description-Content-Type: text/markdown -Requires-Dist: tinycss2>=1.4.0 diff --git a/plibs/css_tools/src/css_tools.egg-info/SOURCES.txt b/plibs/css_tools/src/css_tools.egg-info/SOURCES.txt deleted file mode 100644 index 7c77bda..0000000 --- a/plibs/css_tools/src/css_tools.egg-info/SOURCES.txt +++ /dev/null @@ -1,12 +0,0 @@ -pyproject.toml -setup.py -src/css_tools/__init__.py -src/css_tools/extractor.py -src/css_tools/minifier.py -src/css_tools/modifier.py -src/css_tools/parser.py -src/css_tools.egg-info/PKG-INFO -src/css_tools.egg-info/SOURCES.txt -src/css_tools.egg-info/dependency_links.txt -src/css_tools.egg-info/requires.txt -src/css_tools.egg-info/top_level.txt \ No newline at end of file diff --git a/plibs/css_tools/src/css_tools.egg-info/dependency_links.txt b/plibs/css_tools/src/css_tools.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/plibs/css_tools/src/css_tools.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plibs/css_tools/src/css_tools.egg-info/requires.txt b/plibs/css_tools/src/css_tools.egg-info/requires.txt deleted file mode 100644 index 04fd98b..0000000 --- a/plibs/css_tools/src/css_tools.egg-info/requires.txt +++ /dev/null @@ -1 +0,0 @@ -tinycss2>=1.4.0 diff --git a/plibs/css_tools/src/css_tools.egg-info/top_level.txt b/plibs/css_tools/src/css_tools.egg-info/top_level.txt deleted file mode 100644 index 807c3d8..0000000 --- a/plibs/css_tools/src/css_tools.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -css_tools diff --git a/plibs/css_tools/src/css_tools/extractor.py b/plibs/css_tools/src/css_tools/extractor.py deleted file mode 100644 index c7fe300..0000000 --- a/plibs/css_tools/src/css_tools/extractor.py +++ /dev/null @@ -1,606 +0,0 @@ -# SPDX-FileCopyrightText: 2025 igniter_css contributors -# -# SPDX-License-Identifier: MIT - -"""CSS extraction utilities using tinycss2.""" - -import tinycss2 -import re -from typing import Dict, List, Any, Tuple, Optional, Union, Set -from .parser import parse_stylesheet, get_selector_text, get_rule_declarations - - -def extract_colors(css: Union[str, bytes]) -> Dict[str, List[str]]: - """ - Extract all color values from CSS, including those in nested selectors. - - Args: - css: The CSS code as string or bytes - - Returns: - Dictionary mapping selectors to their color properties - - Raises: - Exception: If the CSS cannot be properly parsed - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - # Validate CSS syntax before proceeding - if css.count('{') != css.count('}'): - raise Exception("CSS syntax error: Unbalanced braces") - - # Parse CSS for analysis - rules = parse_stylesheet(css) - - # Check for parse errors - for rule in rules: - if hasattr(rule, 'type') and rule.type == 'error': - raise Exception(f"CSS parse error: {getattr(rule, 'message', 'Unknown error')}") - - colors = {} - - # Regular expressions for different color formats - hex_pattern = r'#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})' - rgb_pattern = r'rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)' - rgba_pattern = r'rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[0-9.]+\s*\)' - hsl_pattern = r'hsl\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*\)' - hsla_pattern = r'hsla\(\s*\d+\s*,\s*\d+%\s*,\s*\d+%\s*,\s*[0-9.]+\s*\)' - - color_properties = [ - 'color', 'background-color', 'border-color', 'border-top-color', - 'border-right-color', 'border-bottom-color', 'border-left-color', - 'outline-color', 'text-decoration-color', 'box-shadow', 'text-shadow' - ] - - # Recursive function to process rules - def process_rules(rule_list): - for rule in rule_list: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - for decl in declarations: - if decl.type == "declaration": - value = tinycss2.serialize(decl.value).strip() - # Check if it's a color property or has a color value - is_color_property = decl.name in color_properties - has_color_value = ( - re.search(hex_pattern, value) or - re.search(rgb_pattern, value) or - re.search(rgba_pattern, value) or - re.search(hsl_pattern, value) or - re.search(hsla_pattern, value) or - value in ['black', 'white', 'red', 'green', 'blue', 'yellow', - 'purple', 'orange', 'brown', 'gray', 'transparent'] - ) - if is_color_property or has_color_value: - if selector not in colors: - colors[selector] = [] - colors[selector].append(f"{decl.name}: {value}") - - # Process media queries and other at-rules with nested content - elif rule.type == "at-rule" and rule.content is not None: - # Parse nested rules - nested_rules = parse_stylesheet(tinycss2.serialize(rule.content)) - # Recursively process nested rules - process_rules(nested_rules) - - # Start processing rules - process_rules(rules) - - return colors - -def extract_media_queries(css: Union[str, bytes]) -> Dict[str, List[Dict[str, Any]]]: - """ - Extract all media queries and their contents. - - Args: - css: The CSS code as string or bytes - - Returns: - Dictionary mapping media query conditions to their rules - - Raises: - Exception: If the CSS cannot be properly parsed - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - # Validate CSS syntax before proceeding - # Check for unbalanced braces - a common CSS error - if css.count('{') != css.count('}'): - raise Exception("CSS syntax error: Unbalanced braces") - - rules = parse_stylesheet(css) - - # Check for parse errors - for rule in rules: - if hasattr(rule, 'type') and rule.type == 'error': - raise Exception(f"CSS parse error: {getattr(rule, 'message', 'Unknown error')}") - - media_queries = {} - - for rule in rules: - if rule.type == "at-rule" and rule.lower_at_keyword == "media": - condition = tinycss2.serialize(rule.prelude).strip() - - if condition not in media_queries: - media_queries[condition] = [] - - # Parse the content of the media query - if hasattr(rule, 'content') and rule.content: - try: - inner_rules = tinycss2.parse_stylesheet( - rule.content, skip_whitespace=False, skip_comments=False - ) - - # Check for parse errors in inner rules - for inner_rule in inner_rules: - if hasattr(inner_rule, 'type') and inner_rule.type == 'error': - raise Exception(f"CSS parse error in media query: {getattr(inner_rule, 'message', 'Unknown error')}") - - for inner_rule in inner_rules: - if inner_rule.type == "qualified-rule": - selector = get_selector_text(inner_rule) - declarations = get_rule_declarations(inner_rule) - - props = {} - for decl in declarations: - if decl.type == "declaration": - props[decl.name] = tinycss2.serialize(decl.value).strip() - - media_queries[condition].append({ - "selector": selector, - "properties": props - }) - except Exception as e: - raise Exception(f"Error parsing media query content: {str(e)}") - - return media_queries - - -def validate_css(css: Union[str, bytes]) -> str: - """ - Validates CSS syntax and returns decoded string. - - Args: - css: The CSS code as string or bytes - - Returns: - Decoded CSS string - - Raises: - Exception: If the CSS cannot be properly parsed - """ - - if isinstance(css, bytes): - css = css.decode('utf-8') - - # Check for unbalanced braces - a common CSS error - if css.count('{') != css.count('}'): - raise Exception("CSS syntax error: Unbalanced braces") - - # Parse the CSS to detect syntax errors - rules = tinycss2.parse_stylesheet(css, skip_whitespace=False, skip_comments=False) - check_parse_errors(rules) - - # Check each rule for proper declaration syntax - for rule in rules: - if rule.type == 'qualified-rule' and rule.content: - # Parse declarations, keeping comments to avoid issues - declarations = tinycss2.parse_declaration_list(rule.content, skip_whitespace=False, skip_comments=False) - - # Check for missing semicolons between properties - # Look for patterns like "property: value property:" which indicate missing semicolon - for i, decl in enumerate(declarations): - if hasattr(decl, 'type') and decl.type == 'declaration': - # Check if the value contains what looks like another property - value_str = tinycss2.serialize(decl.value).strip() - # Look for patterns like "value property-name:" indicating missing semicolon - if value_str and ':' in value_str: - # But ignore if it's a valid CSS value with colons (like calc() or url()) - if not any(func in value_str.lower() for func in ['calc(', 'url(', 'var(', 'rgb(', 'rgba(', 'hsl(', 'hsla(']): - # Check if there's a property-like pattern - parts = value_str.split() - for part in parts[1:]: # Skip the first value part - if part.endswith(':') or (len(parts) > parts.index(part) + 1 and parts[parts.index(part) + 1].startswith(':')): - raise Exception(f"CSS syntax error: Missing semicolon after '{decl.name}: {parts[0]}'") - - return css - -def check_parse_errors(rules, context=""): - """ - Check for parse errors in a list of CSS rules. - - Args: - rules: List of CSS rules - context: Optional context description for error messages - - Raises: - Exception: If any parse errors are found - """ - for rule in rules: - if hasattr(rule, 'type') and rule.type == 'error': - prefix = f"CSS parse error {context}: " if context else "CSS parse error: " - raise Exception(f"{prefix}{getattr(rule, 'message', 'Unknown error')}") - - -def extract_keyframes(rule): - """ - Extract keyframes from a @keyframes rule. - - Args: - rule: The @keyframes at-rule - - Returns: - Dictionary mapping percentages to property dictionaries - """ - keyframes = {} - - if not (hasattr(rule, 'content') and rule.content): - return keyframes - - try: - keyframe_rules = tinycss2.parse_stylesheet( - rule.content, skip_whitespace=False, skip_comments=False - ) - - # Check for parse errors in keyframe rules - check_parse_errors(keyframe_rules, "in @keyframes") - - for keyframe_rule in keyframe_rules: - if keyframe_rule.type == "qualified-rule": - # The "selector" for keyframes is the percentage or keywords (from/to) - percentage = get_selector_text(keyframe_rule) - declarations = get_rule_declarations(keyframe_rule) - - props = {} - for decl in declarations: - if decl.type == "declaration": - props[decl.name] = tinycss2.serialize(decl.value).strip() - - keyframes[percentage] = props - except Exception as e: - raise Exception(f"Error parsing @keyframes content: {str(e)}") - - return keyframes - - -def find_animation_usage(rules): - """ - Find all elements using animations. - - Args: - rules: List of CSS rules - - Returns: - Dictionary mapping animation names to lists of selectors using them - """ - animation_usage = {} - - # List of animation-related properties (including vendor prefixes) - animation_properties = [ - "animation", "animation-name", - "-webkit-animation", "-webkit-animation-name", - "-moz-animation", "-moz-animation-name", - "-ms-animation", "-ms-animation-name", - "-o-animation", "-o-animation-name" - ] - - for rule in rules: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - - for decl in declarations: - if decl.type == "declaration" and decl.name in animation_properties: - value = tinycss2.serialize(decl.value).strip() - # Simple extraction, might need more complex parsing for multiple animations - animation_name = value.split()[0] - - # Normalize animation name (remove quotes if present) - animation_name = animation_name.strip("'\"") - - if animation_name not in animation_usage: - animation_usage[animation_name] = [] - animation_usage[animation_name].append(selector) - - return animation_usage - - -def extract_animations(css: Union[str, bytes]) -> Dict[str, Dict[str, Any]]: - """ - Extract all CSS animations and keyframes, including vendor-prefixed ones. - - Args: - css: The CSS code as string or bytes - - Returns: - Dictionary mapping animation names to their keyframes - - Raises: - Exception: If the CSS cannot be properly parsed - """ - # Validate and decode CSS - if isinstance(css, bytes): - css = css.decode('utf-8') - - # Validate CSS syntax - if css.count('{') != css.count('}'): - raise Exception("CSS syntax error: Unbalanced braces") - - # Parse CSS - rules = parse_stylesheet(css) - - # Check for parse errors - for rule in rules: - if hasattr(rule, 'type') and rule.type == 'error': - raise Exception(f"CSS parse error: {getattr(rule, 'message', 'Unknown error')}") - - animations = {} - - # List of possible keyframes at-keywords (standard and vendor prefixed) - keyframes_keywords = [ - "keyframes", - "-webkit-keyframes", - "-moz-keyframes", - "-ms-keyframes", - "-o-keyframes" - ] - - # First pass: Find all @keyframes rules (including vendor prefixed) - for rule in rules: - if rule.type == "at-rule": - # Check if this is a keyframes rule (standard or vendor prefixed) - is_keyframes = False - animation_name = "" - - # Check against all possible keyframes at-keywords - for keyword in keyframes_keywords: - if rule.lower_at_keyword == keyword or rule.at_keyword.lower() == keyword: - is_keyframes = True - break - - if is_keyframes: - # Extract animation name - animation_name = tinycss2.serialize(rule.prelude).strip() - # Normalize animation name (remove quotes if present) - animation_name = animation_name.strip("'\"") - - # Extract keyframes - keyframes = {} - if hasattr(rule, 'content') and rule.content: - try: - keyframe_rules = tinycss2.parse_stylesheet( - rule.content, skip_whitespace=False, skip_comments=False - ) - - # Check for parse errors in keyframe rules - for keyframe_rule in keyframe_rules: - if hasattr(keyframe_rule, 'type') and keyframe_rule.type == 'error': - raise Exception(f"CSS parse error in @keyframes: {getattr(keyframe_rule, 'message', 'Unknown error')}") - - for keyframe_rule in keyframe_rules: - if keyframe_rule.type == "qualified-rule": - # The "selector" for keyframes is the percentage or keywords (from/to) - percentage = get_selector_text(keyframe_rule) - declarations = get_rule_declarations(keyframe_rule) - - props = {} - for decl in declarations: - if decl.type == "declaration": - props[decl.name] = tinycss2.serialize(decl.value).strip() - - keyframes[percentage] = props - except Exception as e: - raise Exception(f"Error parsing @keyframes content: {str(e)}") - - animations[animation_name] = keyframes - - # Second pass: Find all elements using animations - animation_usage = find_animation_usage(rules) - - # Combine the results - result = {} - for name, keyframes in animations.items(): - result[name] = { - "keyframes": keyframes, - "used_by": animation_usage.get(name, []) - } - - return result - -def extract_unused_selectors(css: Union[str, bytes], html_content: str) -> List[str]: - """ - Extract CSS selectors that are not used in the given HTML content. - - Args: - css: The CSS code as string or bytes - html_content: The HTML content to check against - - Returns: - List of unused selectors - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - rules = parse_stylesheet(css) - all_selectors = [] - unused_selectors = [] - - for rule in rules: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - # Skip pseudo-elements and pseudo-classes for simplicity - base_selector = re.sub(r'::?[a-zA-Z-]+(\([^)]*\))?', '', selector) - - # Process complex selectors - parts = re.split(r'\s*[,>+~]\s*', base_selector) - for part in parts: - part = part.strip() - if part and part not in all_selectors: - all_selectors.append(part) - - # Basic check for unused selectors - for selector in all_selectors: - # Extract class and ID selectors - if selector.startswith('.'): - # Class selector - class_name = selector[1:] - if f'class="{class_name}"' not in html_content and f"class='{class_name}'" not in html_content: - unused_selectors.append(selector) - elif selector.startswith('#'): - # ID selector - id_name = selector[1:] - if f'id="{id_name}"' not in html_content and f"id='{id_name}'" not in html_content: - unused_selectors.append(selector) - else: - # Element selector - more complex, would need proper HTML parsing - pass - - return unused_selectors - - -def extract_fonts(css: Union[str, bytes]) -> Dict[str, List[Dict[str, Any]]]: - """ - Extract all font-related properties, including those in nested rules and media queries. - - Args: - css: The CSS code as string or bytes - - Returns: - Dictionary mapping selectors to their font properties - - Raises: - Exception: If the CSS cannot be properly parsed or has invalid syntax - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - # Parse CSS for analysis - rules = parse_stylesheet(css) - - # Check for parse errors - for rule in rules: - if hasattr(rule, 'type') and rule.type == 'error': - raise Exception(f"CSS parse error: {getattr(rule, 'message', 'Unknown error')}") - - # Validate declarations - for rule in rules: - if rule.type == "qualified-rule": - declarations = get_rule_declarations(rule) - for decl in declarations: - if decl.type == "error": - raise Exception(f"CSS parse error in declaration: {getattr(decl, 'message', 'Unknown error')}") - - fonts = {} - - # List of font-related properties - font_properties = [ - 'font', 'font-family', 'font-size', 'font-weight', 'font-style', - 'font-variant', 'line-height', 'text-transform', 'letter-spacing' - ] - - # Recursive function to process rules - def process_rules(rule_list, parent_selector=""): - for rule in rule_list: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - # Handle nested selectors by combining with parent selector - full_selector = f"{parent_selector} {selector}".strip() if parent_selector else selector - - declarations = get_rule_declarations(rule) - font_decls = [] - - for decl in declarations: - if decl.type == "declaration" and decl.name in font_properties: - value = tinycss2.serialize(decl.value).strip() - font_decls.append({ - "property": decl.name, - "value": value - }) - - if font_decls: - if full_selector not in fonts: - fonts[full_selector] = [] - fonts[full_selector].extend(font_decls) - - # Process nested rules within this rule - if hasattr(rule, 'content') and rule.content: - nested_rules = parse_stylesheet(tinycss2.serialize(rule.content)) - process_rules(nested_rules, full_selector) - - # Process media queries and other at-rules with nested content - elif rule.type == "at-rule" and rule.content is not None: - # For media queries, we want to keep the selector as is - if rule.lower_at_keyword == "media": - # Parse nested rules - nested_rules = parse_stylesheet(tinycss2.serialize(rule.content)) - # Process nested rules with the same parent selector - process_rules(nested_rules, parent_selector) - else: - # For other at-rules, we want to combine the selectors - nested_rules = parse_stylesheet(tinycss2.serialize(rule.content)) - process_rules(nested_rules, parent_selector) - - # Start processing rules - process_rules(rules) - - return fonts - -def extract_selectors_by_property(css: Union[str, bytes], property_name: str) -> Dict[str, str]: - """ - Extract all selectors that use a specific CSS property and their values. - - Args: - css: The CSS code as string or bytes - property_name: The name of the property to extract (case-insensitive) - - Returns: - Dictionary mapping selectors to their property values - - Raises: - Exception: If the CSS cannot be properly parsed - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - # Validate CSS syntax before proceeding - if css.count('{') != css.count('}'): - raise Exception("CSS syntax error: Unbalanced braces") - - # Parse CSS for analysis - rules = parse_stylesheet(css) - - # Check for parse errors - for rule in rules: - if hasattr(rule, 'type') and rule.type == 'error': - raise Exception(f"CSS parse error: {getattr(rule, 'message', 'Unknown error')}") - - selectors = {} - - # Recursive function to process rules - def process_rules(rule_list): - for rule in rule_list: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - - for decl in declarations: - if decl.type == "declaration" and decl.name.lower() == property_name.lower(): - value = tinycss2.serialize(decl.value).strip() - if decl.important: - value += " !important" - selectors[selector] = value - - # Process media queries and other at-rules with nested content - elif rule.type == "at-rule" and rule.content is not None: - # Parse nested rules - nested_rules = parse_stylesheet(tinycss2.serialize(rule.content)) - # Recursively process nested rules - process_rules(nested_rules) - - # Start processing rules - process_rules(rules) - - return selectors diff --git a/plibs/css_tools/src/css_tools/minifier.py b/plibs/css_tools/src/css_tools/minifier.py deleted file mode 100644 index 5e83183..0000000 --- a/plibs/css_tools/src/css_tools/minifier.py +++ /dev/null @@ -1,406 +0,0 @@ -# SPDX-FileCopyrightText: 2025 igniter_css contributors -# -# SPDX-License-Identifier: MIT - -"""CSS minification utilities using tinycss2.""" - -import tinycss2 -import re -from typing import Dict, List, Any, Tuple, Optional, Union -from .parser import parse_stylesheet, get_selector_text, get_rule_declarations - - -def minify_css(css: Union[str, bytes]) -> str: - """ - Minify CSS by removing comments, whitespace, and unnecessary characters. - - Args: - css: The CSS code as string or bytes - - Returns: - Minified CSS as a string - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - rules = parse_stylesheet(css) - minified_css = "" - - for rule in rules: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - # Remove whitespace in selectors - selector = re.sub(r'\s*([,>+~])\s*', r'\1', selector) - - declarations = get_rule_declarations(rule) - # Filter out comments and whitespace - declarations = [decl for decl in declarations if decl.type == "declaration"] - - # Serialize declarations without whitespace - content = "" - for decl in declarations: - value = tinycss2.serialize(decl.value).strip() - # Minimize color values - if value.startswith('#'): - # Convert #RRGGBB to #RGB when possible - if len(value) == 7 and value[1] == value[2] and value[3] == value[4] and value[5] == value[6]: - value = '#' + value[1] + value[3] + value[5] - - important = "!important" if decl.important else "" - content += f"{decl.name}:{value}{important};" - - if content: - minified_css += f"{selector}{{{content}}}" - - elif rule.type == "at-rule": - if rule.lower_at_keyword == "media" or rule.lower_at_keyword == "keyframes": - prelude = tinycss2.serialize(rule.prelude).strip() - - # Recursively minify the content of the at-rule - inner_css = tinycss2.serialize(rule.content) - minified_inner = minify_css(inner_css) - - minified_css += f"@{rule.lower_at_keyword} {prelude}{{{minified_inner}}}" - else: - # For other at-rules like @charset, @import, etc. - prelude = tinycss2.serialize(rule.prelude).strip() - minified_css += f"@{rule.lower_at_keyword} {prelude};" - - return minified_css - - -def beautify_css(css: Union[str, bytes]) -> str: - """ - Beautify CSS by adding proper indentation and formatting. - - Args: - css: The CSS code as string or bytes - - Returns: - Beautified CSS as a string - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - # First parse the CSS - rules = parse_stylesheet(css) - beautified_css = "" - - for rule in rules: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - # Format selector nicely (one selector per line for multiple selectors) - if ',' in selector: - selector = ',\n'.join([s.strip() for s in selector.split(',')]) - - declarations = get_rule_declarations(rule) - formatted_content = "" - - for decl in declarations: - if decl.type == "declaration": - value = tinycss2.serialize(decl.value).strip() - important = " !important" if decl.important else "" - formatted_content += f" {decl.name}: {value}{important};\n" - elif decl.type == "comment": - formatted_content += f" /* {decl.value} */\n" - - beautified_css += f"{selector} {{\n{formatted_content}}}\n\n" - - elif rule.type == "at-rule": - if rule.lower_at_keyword == "media" or rule.lower_at_keyword == "keyframes": - prelude = tinycss2.serialize(rule.prelude).strip() - - # Recursively beautify the content of the at-rule - inner_css = tinycss2.serialize(rule.content) - inner_rules = parse_stylesheet(inner_css) - - formatted_inner = "" - for inner_rule in inner_rules: - if inner_rule.type == "qualified-rule": - inner_selector = get_selector_text(inner_rule) - inner_declarations = get_rule_declarations(inner_rule) - - inner_content = "" - for inner_decl in inner_declarations: - if inner_decl.type == "declaration": - inner_value = tinycss2.serialize(inner_decl.value).strip() - inner_important = " !important" if inner_decl.important else "" - inner_content += f" {inner_decl.name}: {inner_value}{inner_important};\n" - elif inner_decl.type == "comment": - inner_content += f" /* {inner_decl.value} */\n" - - formatted_inner += f" {inner_selector} {{\n{inner_content} }}\n\n" - - beautified_css += f"@{rule.lower_at_keyword} {prelude} {{\n{formatted_inner}}}\n\n" - else: - # For other at-rules like @charset, @import, etc. - prelude = tinycss2.serialize(rule.prelude).strip() - beautified_css += f"@{rule.lower_at_keyword} {prelude};\n\n" - - elif rule.type == "comment": - beautified_css += f"/* {rule.value} */\n\n" - - return beautified_css - - -def sort_properties(css: Union[str, bytes]) -> str: - """ - Sort CSS properties alphabetically within each rule. - - Args: - css: The CSS code as string or bytes - - Returns: - CSS with properties sorted alphabetically - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - rules = parse_stylesheet(css) - sorted_css = "" - - for rule in rules: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - - # Separate declarations and comments - decls = [] - comments = [] - - for item in declarations: - if item.type == "declaration": - decls.append(item) - elif item.type == "comment": - comments.append(item) - - # Sort declarations by property name - sorted_decls = sorted(decls, key=lambda d: d.name) - - # Combine sorted declarations with comments - sorted_content = "" - for decl in sorted_decls: - value = tinycss2.serialize(decl.value).strip() - important = " !important" if decl.important else "" - sorted_content += f" {decl.name}: {value}{important};\n" - - # Add comments at the end - for comment in comments: - sorted_content += f" /* {comment.value} */\n" - - sorted_css += f"{selector} {{\n{sorted_content}}}\n\n" - - else: - # Keep other rules as they are - sorted_css += tinycss2.serialize([rule]) + "\n" - - return sorted_css - - -def remove_duplicates(css: Union[str, bytes]) -> str: - """ - Remove duplicate selectors and properties from CSS. - - Args: - css: The CSS code as string or bytes - - Returns: - CSS with duplicates removed - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - rules = parse_stylesheet(css) - selectors_map = {} # Maps selectors to rule index - media_queries_map = {} # Maps media query conditions to their rules - - # First pass: identify duplicates - for i, rule in enumerate(rules): - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - - if selector in selectors_map: - # Duplicate selector found - existing_idx = selectors_map[selector] - existing_rule = rules[existing_idx] - - # Merge declarations - existing_decls = get_rule_declarations(existing_rule) - new_decls = get_rule_declarations(rule) - - # Track existing properties to avoid duplicates - existing_props = {} - for j, decl in enumerate(existing_decls): - if decl.type == "declaration": - existing_props[decl.name] = j - - # Add non-duplicate declarations - for decl in new_decls: - if decl.type == "declaration": - if decl.name in existing_props: - # Replace the existing declaration (newer takes precedence) - existing_decls[existing_props[decl.name]] = decl - else: - # Add new declaration - existing_decls.append(decl) - - # Mark as deleted by setting to None - rules[i] = None - else: - # First occurrence of this selector - selectors_map[selector] = i - - elif rule.type == "at-rule" and rule.at_keyword.lower() == "media": - # Handle media queries - media_condition = tinycss2.serialize(rule.prelude).strip() - - if media_condition in media_queries_map: - # Duplicate media query found - existing_media_rule = media_queries_map[media_condition] - - # Parse the content of both media queries - existing_content = parse_stylesheet(tinycss2.serialize(existing_media_rule.content)) - new_content = parse_stylesheet(tinycss2.serialize(rule.content)) - - # Create a map of selectors to their rules in existing content - existing_selectors = {} - for existing_rule in existing_content: - if existing_rule.type == "qualified-rule": - selector = get_selector_text(existing_rule) - existing_selectors[selector] = existing_rule - - # Merge the content rules - for content_rule in new_content: - if content_rule.type == "qualified-rule": - selector = get_selector_text(content_rule) - - if selector in existing_selectors: - # Merge declarations with existing rule - existing_rule = existing_selectors[selector] - existing_decls = get_rule_declarations(existing_rule) - new_decls = get_rule_declarations(content_rule) - - # Track existing properties - existing_props = {} - for j, decl in enumerate(existing_decls): - if decl.type == "declaration": - existing_props[decl.name] = j - - # Add non-duplicate declarations - for decl in new_decls: - if decl.type == "declaration": - if decl.name in existing_props: - # Replace existing declaration - existing_decls[existing_props[decl.name]] = decl - else: - # Add new declaration - existing_decls.append(decl) - else: - # Add new selector rule - existing_content.append(content_rule) - - # Update the existing media rule's content - existing_media_rule.content = existing_content - # Mark current rule as deleted - rules[i] = None - else: - # First occurrence of this media query - media_queries_map[media_condition] = rule - - # Second pass: build result with duplicates removed - cleaned_css = "" - for rule in rules: - if rule is None: - continue # Skip deleted rules - - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - - # Remove duplicate properties - unique_props = {} - unique_decls = [] - - for decl in declarations: - if decl.type == "declaration": - # Newer declarations override older ones - unique_props[decl.name] = decl - else: - # Keep non-declaration nodes (like comments) - unique_decls.append(decl) - - # Add unique declarations - for decl in unique_props.values(): - unique_decls.append(decl) - - # Sort declarations for consistency - declaration_nodes = [d for d in unique_decls if d.type == "declaration"] - sorted_decls = sorted(declaration_nodes, key=lambda d: d.name) - comment_nodes = [d for d in unique_decls if d.type == "comment"] - - # Format the content - content = "" - for decl in sorted_decls: - value = tinycss2.serialize(decl.value).strip() - important = " !important" if decl.important else "" - content += f" {decl.name}: {value}{important};\n" - - # Add comments at the end - for comment in comment_nodes: - content += f" /* {comment.value} */\n" - - cleaned_css += f"{selector} {{\n{content}}}\n\n" - - elif rule.type == "at-rule" and rule.at_keyword.lower() == "media": - # Format media query - media_condition = tinycss2.serialize(rule.prelude).strip() - - # Format the content - content = "" - for content_rule in rule.content: - if content_rule.type == "qualified-rule": - selector = get_selector_text(content_rule) - declarations = get_rule_declarations(content_rule) - - # Remove duplicate properties - unique_props = {} - unique_decls = [] - - for decl in declarations: - if decl.type == "declaration": - # Newer declarations override older ones - unique_props[decl.name] = decl - else: - # Keep non-declaration nodes (like comments) - unique_decls.append(decl) - - # Add unique declarations - for decl in unique_props.values(): - unique_decls.append(decl) - - # Sort declarations for consistency - declaration_nodes = [d for d in unique_decls if d.type == "declaration"] - sorted_decls = sorted(declaration_nodes, key=lambda d: d.name) - comment_nodes = [d for d in unique_decls if d.type == "comment"] - - # Format declarations - rule_content = "" - for decl in sorted_decls: - value = tinycss2.serialize(decl.value).strip() - important = " !important" if decl.important else "" - rule_content += f" {decl.name}: {value}{important};\n" - - # Add comments at the end - for comment in comment_nodes: - rule_content += f" /* {comment.value} */\n" - - content += f" {selector} {{\n{rule_content} }}\n\n" - elif content_rule.type == "comment": - content += f" /* {content_rule.value} */\n" - - cleaned_css += f"@media {media_condition} {{\n{content}}}\n\n" - else: - # Keep other rules as they are - cleaned_css += tinycss2.serialize([rule]) + "\n" - - return cleaned_css diff --git a/plibs/css_tools/src/css_tools/modifier.py b/plibs/css_tools/src/css_tools/modifier.py deleted file mode 100644 index a080f22..0000000 --- a/plibs/css_tools/src/css_tools/modifier.py +++ /dev/null @@ -1,607 +0,0 @@ -# SPDX-FileCopyrightText: 2025 igniter_css contributors -# -# SPDX-License-Identifier: MIT - -"""CSS modification utilities using tinycss2.""" - -import tinycss2 -from typing import Dict, List, Any, Tuple, Optional, Union -from .parser import parse_stylesheet, get_selector_text, get_rule_declarations - - -def add_property_to_selector( - css: Union[str, bytes], - selector: str, - property_name: str, - property_value: str, - important: bool = False -) -> str: - """ - Add a CSS property to a specific selector, or create the selector if it doesn't exist. - - Args: - css: The CSS code as string or bytes - selector: The CSS selector to modify - property_name: The property name to add - property_value: The property value to add - important: Whether to mark the property as !important - - Returns: - Modified CSS as a string - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - rules = parse_stylesheet(css) - found_selector = False - modified_css = "" - - for rule in rules: - if rule.type == "qualified-rule": - rule_selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - - if rule_selector == selector: - found_selector = True - - # Check if the property already exists - property_exists = False - for i, decl in enumerate(declarations): - if decl.type == "declaration" and decl.name == property_name: - property_exists = True - # Replace the existing property - declarations[i] = tinycss2.ast.Declaration( - name=property_name, - value=[ - tinycss2.ast.WhitespaceToken(value=" ", line=0, column=0), - tinycss2.ast.IdentToken(value=property_value, line=0, column=0) - ], - important=important, - line=0, - column=0, - lower_name=property_name.lower(), - ) - break - - # Add the property if it doesn't exist - if not property_exists: - if declarations and declarations[-1].type != "whitespace": - declarations.append(tinycss2.ast.WhitespaceToken(line=0, column=0, value='\n ')) - declarations.append( - tinycss2.ast.Declaration( - name=property_name, - value=[ - tinycss2.ast.WhitespaceToken(value=" ", line=0, column=0), - tinycss2.ast.IdentToken(value=property_value, line=0, column=0) - ], - important=important, - line=0, - column=0, - lower_name=property_name.lower(), - ) - ) - - # Format and add the rule to the result - serialized_content = tinycss2.serialize(declarations).strip() - serialized_content = "\n".join(" " + line.strip() for line in serialized_content.splitlines() if line.strip()) - formatted_rule = f"{rule_selector} {{\n{serialized_content}\n}}\n" - modified_css += formatted_rule - - else: - # Keep other rules as they are - modified_css += tinycss2.serialize([rule]) - - # Create the selector if it doesn't exist - if not found_selector: - new_rule = f"\n{selector} {{\n {property_name}: {property_value}{' !important' if important else ''};\n}}\n" - modified_css += new_rule - - return modified_css.strip() - - -def remove_property_from_selector( - css: Union[str, bytes], - selector: str, - property_name: str -) -> str: - """ - Remove a CSS property from a specific selector. - - Args: - css: The CSS code as string or bytes - selector: The CSS selector to modify - property_name: The property name to remove - - Returns: - Modified CSS as a string - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - rules = parse_stylesheet(css) - modified_css = "" - - for rule in rules: - if rule.type == "qualified-rule": - rule_selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - - if rule_selector == selector: - # Filter out the property to remove - filtered_declarations = [] - for decl in declarations: - if not (decl.type == "declaration" and decl.name == property_name): - filtered_declarations.append(decl) - - declarations = filtered_declarations - - # Only add the rule if it has declarations - if declarations: - serialized_content = tinycss2.serialize(declarations).strip() - if serialized_content: # Only include if there are actual declarations - serialized_content = "\n".join(" " + line.strip() for line in serialized_content.splitlines() if line.strip()) - formatted_rule = f"{rule_selector} {{\n{serialized_content}\n}}\n" - modified_css += formatted_rule - - else: - # Keep other rules as they are - modified_css += tinycss2.serialize([rule]) - - return modified_css.strip() - - -def remove_selector(css: Union[str, bytes], selector: Union[str, bytes]) -> str: - """ - Remove a CSS selector and all its properties. - - Args: - css: The CSS code as string or bytes - selector: The CSS selector to remove - - Returns: - Modified CSS as a string - - Raises: - Exception: If the CSS cannot be properly parsed - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - # Ensure selector is also a string, not bytes - if isinstance(selector, bytes): - selector = selector.decode('utf-8') - - # Validate CSS syntax before proceeding - # Check for unbalanced braces - a common CSS error - if css.count('{') != css.count('}'): - raise Exception("CSS syntax error: Unbalanced braces") - - # Parse CSS for further analysis - rules = parse_stylesheet(css) - - # Check for parse errors - for rule in rules: - if hasattr(rule, 'type') and rule.type == 'error': - raise Exception(f"CSS parse error: {getattr(rule, 'message', 'Unknown error')}") - - # Function to process rule blocks with potential nesting - def process_rule_block(rules): - result = "" - for rule in rules: - if rule.type == "qualified-rule": - rule_selector = get_selector_text(rule) - if rule_selector != selector: - # Keep rules that don't match the selector to be removed - declarations = get_rule_declarations(rule) - serialized_content = tinycss2.serialize(declarations).strip() - serialized_content = "\n".join(" " + line.strip() for line in serialized_content.splitlines() if line.strip()) - formatted_rule = f"{rule_selector} {{\n{serialized_content}\n}}\n" - result += formatted_rule - elif rule.type == "at-rule" and rule.content is not None: - # Handle at-rules with blocks (e.g., media queries) - at_keyword = rule.at_keyword - prelude = tinycss2.serialize(rule.prelude).strip() - - # Parse the content of the at-rule - inner_rules = parse_stylesheet(tinycss2.serialize(rule.content)) - - # Process the inner rules recursively - inner_content = process_rule_block(inner_rules) - - # Only include the at-rule if it has content after processing - if inner_content.strip(): - result += f"@{at_keyword} {prelude} {{\n{inner_content}\n}}\n" - else: - # Keep other rules as they are - result += tinycss2.serialize([rule]) - - return result - - # Start processing from the top level - modified_css = process_rule_block(rules) - - return modified_css.strip() - -def modify_property_value( - css: Union[str, bytes], - selector: Union[str, bytes], - property_name: Union[str, bytes], - new_value: Union[str, bytes], - important: Optional[bool] = None -) -> str: - """ - Modify the value of a CSS property for a specific selector. - - Args: - css: The CSS code as string or bytes - selector: The CSS selector to modify - property_name: The property name to modify - new_value: The new property value - important: Whether to mark the property as !important (None = keep current setting) - - Returns: - Modified CSS as a string - """ - # Convert all byte parameters to strings - if isinstance(css, bytes): - css = css.decode('utf-8') - if isinstance(selector, bytes): - selector = selector.decode('utf-8') - if isinstance(property_name, bytes): - property_name = property_name.decode('utf-8') - if isinstance(new_value, bytes): - new_value = new_value.decode('utf-8') - - rules = parse_stylesheet(css) - modified_css = "" - property_found = False - - for rule in rules: - if rule.type == "qualified-rule": - rule_selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - - if rule_selector == selector: - # Modify the property value - for i, decl in enumerate(declarations): - if decl.type == "declaration" and decl.name == property_name: - property_found = True - # Use existing important flag if not specified - is_important = important if important is not None else decl.important - declarations[i] = tinycss2.ast.Declaration( - name=property_name, - value=[ - tinycss2.ast.WhitespaceToken(value=" ", line=0, column=0), - tinycss2.ast.IdentToken(value=new_value, line=0, column=0) - ], - important=is_important, - line=0, - column=0, - lower_name=property_name.lower(), - ) - - # Format and add the rule to the result - serialized_content = tinycss2.serialize(declarations).strip() - serialized_content = "\n".join(" " + line.strip() for line in serialized_content.splitlines() if line.strip()) - formatted_rule = f"{rule_selector} {{\n{serialized_content}\n}}\n" - modified_css += formatted_rule - - else: - # Keep other rules as they are - modified_css += tinycss2.serialize([rule]) - - # If the property wasn't found, add it - if not property_found and selector: - # Check if selector already exists in the result - if selector not in modified_css: - # Add new rule with the property - new_rule = f"\n{selector} {{\n {property_name}: {new_value}{' !important' if important else ''};\n}}\n" - modified_css += new_rule - else: - # Property wasn't found but selector exists, so add_property would be needed - # This is a bit tricky since we've already formatted the CSS - # For simplicity, we'll call add_property on our current result - return add_property_to_selector(modified_css, selector, property_name, new_value, important or False) - - return modified_css.strip() - - -def add_prefix_to_property( - css: Union[str, bytes], - property_name: Union[str, bytes], - prefixes: List[str] -) -> str: - """ - Add vendor prefixes to a CSS property throughout the stylesheet. - - Args: - css: The CSS code as string or bytes - property_name: The property name to prefix - prefixes: List of prefixes to add (e.g., ['-webkit-', '-moz-']) - - Returns: - Modified CSS as a string - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - if isinstance(property_name, bytes): - property_name = property_name.decode('utf-8') - - rules = parse_stylesheet(css) - modified_css = "" - - def process_declarations(declarations): - """Helper function to process declarations and add prefixes""" - new_declarations = [] - for decl in declarations: - new_declarations.append(decl) - if decl.type == "declaration" and decl.name == property_name: - # Add prefixed versions before the standard property - for prefix in prefixes: - prefixed_prop = tinycss2.ast.Declaration( - name=f"{prefix}{property_name}", - value=decl.value, # Use the same value as the original property - important=decl.important, - line=0, - column=0, - lower_name=f"{prefix}{property_name}".lower(), - ) - # Insert prefixed property before the original - new_declarations.insert(len(new_declarations) - 1, prefixed_prop) - # Add whitespace between properties - new_declarations.insert( - len(new_declarations) - 1, - tinycss2.ast.WhitespaceToken(line=0, column=0, value='\n ') - ) - return new_declarations - - def process_rules(rules_list, indent_level=0): - """Recursively process rules, handling nested at-rules""" - result = "" - indent = " " * indent_level - - for rule in rules_list: - if rule.type == "qualified-rule": - # Regular CSS rule - rule_selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - - # Process declarations to add prefixes - new_declarations = process_declarations(declarations) - - # Format and add the rule to the result - serialized_content = tinycss2.serialize(new_declarations).strip() - serialized_content = "\n".join(indent + " " + line.strip() - for line in serialized_content.splitlines() if line.strip()) - - formatted_rule = f"{indent}{rule_selector} {{\n{serialized_content}\n{indent}}}\n" - result += formatted_rule - - elif rule.type == "at-rule" and rule.content is not None: - # Handle at-rules with blocks like @media - at_keyword = rule.at_keyword - prelude = tinycss2.serialize(rule.prelude).strip() - - # Parse the content of the at-rule - content_rules = parse_stylesheet(tinycss2.serialize(rule.content)) - - # Process the nested rules - inner_content = process_rules(content_rules, indent_level + 1) - - # Format the at-rule - formatted_at_rule = f"{indent}@{at_keyword} {prelude} {{\n{inner_content}{indent}}}\n" - result += formatted_at_rule - - else: - # Keep other rules as they are (at-rules without blocks, comments, etc.) - result += indent + tinycss2.serialize([rule]) - - return result - - # Start processing from the top level - modified_css = process_rules(rules) - - return modified_css.strip() - -def merge_stylesheets(css_list: List[Union[str, bytes]]) -> str: - """ - Merge multiple CSS stylesheets into one, removing duplicates. - - Args: - css_list: List of CSS stylesheets as strings or bytes - - Returns: - Merged CSS as a string - """ - all_rules = [] - selector_map = {} # Maps selectors to their rule index in all_rules - - for css in css_list: - if isinstance(css, bytes): - css = css.decode('utf-8') - - rules = parse_stylesheet(css) - - for rule in rules: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - - if selector in selector_map: - # Merge declarations with existing rule - existing_rule_idx = selector_map[selector] - existing_rule = all_rules[existing_rule_idx] - - # Get declarations from both rules - existing_decls = get_rule_declarations(existing_rule) - new_decls = get_rule_declarations(rule) - - # Create a map of existing declarations to avoid duplicates - existing_props = { - decl.name: i - for i, decl in enumerate(existing_decls) - if decl.type == "declaration" - } - - # Add new declarations if they don't exist - for decl in new_decls: - if decl.type == "declaration": - if decl.name in existing_props: - # Replace existing declaration (newer takes precedence) - existing_decls[existing_props[decl.name]] = decl - else: - # Add new declaration - existing_decls.append(decl) - - # Update the rule content - existing_rule.content = tinycss2.serialize(existing_decls) - else: - # Add new rule - all_rules.append(rule) - selector_map[selector] = len(all_rules) - 1 - else: - # For at-rules and comments, just add them - all_rules.append(rule) - - # Serialize the merged rules - merged_css = "" - for rule in all_rules: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - declarations = get_rule_declarations(rule) - - serialized_content = tinycss2.serialize(declarations).strip() - serialized_content = "\n".join(" " + line.strip() for line in serialized_content.splitlines() if line.strip()) - formatted_rule = f"{selector} {{\n{serialized_content}\n}}\n" - merged_css += formatted_rule - else: - merged_css += tinycss2.serialize([rule]) - - return merged_css.strip() - -def replace_selector_rule(css: Union[str, bytes], selector: Union[str, bytes], new_declarations: Union[str, bytes]) -> str: - """ - Replace an entire CSS rule for a specific selector with new declarations. - - Args: - css: The CSS code as string or bytes - selector: The CSS selector to replace - new_declarations: The new CSS declarations as a string (without curly braces) - - Returns: - Modified CSS as a string - - Raises: - Exception: If the CSS cannot be properly parsed or new declarations are invalid - """ - # Ensure input types are correct - if isinstance(css, bytes): - css = css.decode('utf-8') - if isinstance(selector, bytes): - selector = selector.decode('utf-8') - if isinstance(new_declarations, bytes): - new_declarations = new_declarations.decode('utf-8') - - # Validate CSS syntax before proceeding - if css.count('{') != css.count('}'): - raise Exception("CSS syntax error: Unbalanced braces") - - # Basic validation of the original CSS by parsing it - try: - rules = parse_stylesheet(css) - - # Check for parse errors in the original CSS - for rule in rules: - if hasattr(rule, 'type') and rule.type == 'error': - raise Exception(f"CSS parse error: {getattr(rule, 'message', 'Unknown error')}") - except Exception as e: - raise Exception(f"Failed to parse CSS: {str(e)}") - - # Validate new declarations syntax - ensure each declaration ends with a semicolon - declarations_list = [d.strip() for d in new_declarations.split(';') if d.strip()] - for decl in declarations_list: - if ':' not in decl: - raise Exception(f"Invalid declaration syntax: Missing colon in '{decl}'") - - # Reconstruct new_declarations with proper formatting and ensure semicolons - new_declarations = '; '.join(declarations_list) + ';' - - try: - # Simple validation by trying to parse a test rule - test_css = f".test{{ {new_declarations} }}" - test_rules = parse_stylesheet(test_css) - for rule in test_rules: - if hasattr(rule, 'type') and rule.type == 'error': - raise Exception(f"Invalid declaration syntax: {getattr(rule, 'message', 'Unknown error')}") - except Exception as e: - raise Exception(f"Invalid declaration syntax: {str(e)}") - - # Flatten nested selectors if present in the CSS - flattened_css = "" - selector_found = False - - def flatten_nested_css(rules, parent_selector=None): - nonlocal flattened_css, selector_found - - for rule in rules: - if rule.type == "qualified-rule": - current_selector = get_selector_text(rule) - combined_selector = current_selector - - if parent_selector: - combined_selector = f"{parent_selector} {current_selector}" - - if combined_selector == selector: - # Found the selector to replace - selector_found = True - formatted_declarations = "\n".join(f" {decl};" for decl in new_declarations.split(';') if decl.strip()) - flattened_css += f"{selector} {{\n{formatted_declarations}\n}}\n" - else: - # Keep other rules - declarations = get_rule_declarations(rule) - - # Check if this rule contains more nested rules - nested_rules = [] - for item in declarations: - if item.type == "qualified-rule": - nested_rules.append(item) - - if nested_rules: - # Process nested rules - flatten_nested_css(nested_rules, combined_selector) - else: - # Regular rule - add it to output - serialized_content = tinycss2.serialize(declarations).strip() - if serialized_content: # Only add if there's content - serialized_content = "\n".join(f" {line.strip()}" for line in serialized_content.splitlines() if line.strip()) - flattened_css += f"{combined_selector} {{\n{serialized_content}\n}}\n" - - elif rule.type == "at-rule" and rule.content: - # Handle at-rules like media queries - at_keyword = rule.at_keyword - prelude = tinycss2.serialize(rule.prelude).strip() - - # Store the current CSS position - current_css_length = len(flattened_css) - - # Process nested rules in the at-rule - inner_rules = parse_stylesheet(tinycss2.serialize(rule.content)) - flatten_nested_css(inner_rules) - - # If content was added, wrap it in the at-rule - if len(flattened_css) > current_css_length: - at_rule_content = flattened_css[current_css_length:] - flattened_css = flattened_css[:current_css_length] - flattened_css += f"@{at_keyword} {prelude} {{\n{at_rule_content}}}\n" - else: - # Other rules like comments - flattened_css += tinycss2.serialize([rule]) - - # Process the CSS - flatten_nested_css(rules) - - # Add the selector if not found - if not selector_found: - formatted_declarations = "\n".join(f" {decl};" for decl in new_declarations.split(';') if decl.strip()) - flattened_css += f"\n{selector} {{\n{formatted_declarations}\n}}\n" - - return flattened_css.strip() diff --git a/plibs/css_tools/src/css_tools/parser.py b/plibs/css_tools/src/css_tools/parser.py deleted file mode 100644 index 6c77789..0000000 --- a/plibs/css_tools/src/css_tools/parser.py +++ /dev/null @@ -1,394 +0,0 @@ -# SPDX-FileCopyrightText: 2025 igniter_css contributors -# -# SPDX-License-Identifier: MIT - -"""CSS parsing utilities using tinycss2.""" - -import tinycss2 -from typing import Dict, List, Any, Tuple, Optional, Union - - -def parse_stylesheet(css: Union[str, bytes]) -> List[Any]: - """ - Parse a CSS stylesheet into a list of rules. - - Args: - css: The CSS code as string or bytes - - Returns: - List of tinycss2 nodes representing the stylesheet - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - return tinycss2.parse_stylesheet(css, skip_whitespace=False, skip_comments=False) - - -def parse_declarations(declarations_str: str) -> List[Any]: - """ - Parse a CSS declaration list into a list of declarations. - - Args: - declarations_str: The CSS declarations as a string - - Returns: - List of tinycss2 declarations - """ - return tinycss2.parse_declaration_list( - declarations_str, skip_whitespace=False, skip_comments=False - ) - - -def serialize_stylesheet(rules: List[Any]) -> str: - """ - Serialize a list of CSS rules back to a CSS string. - - Args: - rules: List of tinycss2 nodes - - Returns: - CSS code as string - """ - return tinycss2.serialize(rules) - - -def serialize_declarations(declarations: List[Any]) -> str: - """ - Serialize a list of CSS declarations back to a CSS string. - - Args: - declarations: List of tinycss2 declarations - - Returns: - CSS declarations as string - """ - return tinycss2.serialize(declarations) - - -def get_rule_declarations(rule: Any) -> List[Any]: - """ - Extract the declarations from a CSS rule. - - Args: - rule: A tinycss2.ast.QualifiedRule object - - Returns: - List of declarations - """ - if not hasattr(rule, 'content'): - return [] - - # Ensure content is properly serialized before parsing - content = rule.content - if isinstance(content, str): - return tinycss2.parse_declaration_list(content, skip_whitespace=False, skip_comments=False) - else: - # Serialize the content if it's not a string - serialized_content = tinycss2.serialize(content) - # Remove any surrounding braces if present - serialized_content = serialized_content.strip('{}') - return tinycss2.parse_declaration_list(serialized_content, skip_whitespace=False, skip_comments=False) - - -def get_selector_text(rule: Any) -> str: - """ - Extract the selector text from a CSS rule. - - Args: - rule: A tinycss2.ast.QualifiedRule object - - Returns: - Selector text as string - """ - if not hasattr(rule, 'prelude'): - return "" - return tinycss2.serialize(rule.prelude).strip() - - -def extract_rules_by_selector(css: Union[str, bytes], selector_pattern: str) -> List[Any]: - """ - Extract all rules that match a given selector pattern. - - Args: - css: The CSS code as string or bytes - selector_pattern: The selector pattern to match (can be partial) - - Returns: - List of matching rules - """ - rules = parse_stylesheet(css) - matching_rules = [] - - for rule in rules: - if rule.type == "qualified-rule": - selector = get_selector_text(rule) - if selector_pattern in selector: - matching_rules.append(rule) - - return matching_rules - - -def extract_comments(css: Union[str, bytes]) -> List[str]: - """Extract all comments from CSS code.""" - if isinstance(css, bytes): - css = css.decode('utf-8') - - all_comments = [] - # Use the correct tinycss2 API for tokenization - tokens = tinycss2.parse_component_value_list(css) - for token in tokens: - if token.type == 'comment': - all_comments.append(token.value) - return all_comments - - -def extract_colors_and_fonts(value: str, property_name: str) -> Tuple[List[str], List[str]]: - """Extract colors and fonts from a CSS property value.""" - colors = [] - fonts = [] - - # Check for color properties - if property_name in ["color", "background-color", "border-color"] or "#" in value: - colors.append(value) - - # Check for font properties - if property_name in ["font-family", "font"]: - fonts.append(value) - - return colors, fonts - - -def process_declaration(declaration, properties, colors, fonts, - selector_properties, selector, parent_media, media_query_details): - """Process a single CSS declaration.""" - if declaration.type != "declaration": - return [], [] - - property_name = declaration.name - value = tinycss2.serialize(declaration.value).strip() - - # Track property usage - if property_name not in properties: - properties[property_name] = 0 - properties[property_name] += 1 - - # Track property in media query if applicable - if parent_media: - if property_name not in media_query_details[parent_media]["properties"]: - media_query_details[parent_media]["properties"][property_name] = 0 - media_query_details[parent_media]["properties"][property_name] += 1 - - # Store the property value for this selector - selector_properties[selector][property_name] = value - - # Extract colors and fonts - new_colors, new_fonts = extract_colors_and_fonts(value, property_name) - - return new_colors, new_fonts - - -def process_qualified_rule(rule, parent_media, selectors, properties, colors, fonts, - selector_properties, media_query_details): - """Process a qualified CSS rule (selector with declarations).""" - selector = get_selector_text(rule) - - # Track media query relationship - if parent_media: - if parent_media not in media_query_details: - media_query_details[parent_media] = { - "selectors": [], - "properties": {} - } - media_query_details[parent_media]["selectors"].append(selector) - - selectors.append(selector) - - # Initialize selector_properties entry - if selector not in selector_properties: - selector_properties[selector] = {} - - declarations = get_rule_declarations(rule) - for decl in declarations: - new_colors, new_fonts = process_declaration( - decl, properties, colors, fonts, - selector_properties, selector, parent_media, media_query_details - ) - colors.extend(new_colors) - fonts.extend(new_fonts) - - -def process_media_rule(rule, media_query_list, media_query_details, - selectors, properties, colors, fonts, selector_properties): - """Process a media query rule.""" - if not rule.content: - return - - media_query = tinycss2.serialize(rule.prelude).strip() - media_query_list.append(media_query) - - # Process rules inside the media query - inner_rules = parse_stylesheet(tinycss2.serialize(rule.content)) - process_rule_block( - inner_rules, media_query, selectors, properties, - colors, fonts, media_query_list, media_query_details, selector_properties - ) - - -def process_rule_block(rule_block, parent_media, selectors, properties, - colors, fonts, media_query_list, media_query_details, selector_properties): - """Process a block of CSS rules, handling nested structures.""" - for rule in rule_block: - if rule.type == "qualified-rule": - process_qualified_rule( - rule, parent_media, selectors, properties, colors, fonts, - selector_properties, media_query_details - ) - elif rule.type == "at-rule" and rule.at_keyword.lower() == "media": - process_media_rule( - rule, media_query_list, media_query_details, - selectors, properties, colors, fonts, selector_properties - ) - - -def extract_imports(rules): - """ - Extract @import rules from CSS using AST approach. - - Args: - rules: List of CSS rules from tinycss2 parser - - Returns: - Tuple of (imports list, import_media_queries dict) - """ - imports = [] - import_media_queries = {} - - for rule in rules: - if rule.type == "at-rule" and rule.lower_at_keyword == "import": - # Parse the import URL - import_url = None - media_tokens = [] - - # Process prelude tokens to extract URL and media query - prelude_tokens = rule.prelude - - # Process each token to find URL (in either format) and media query - i = 0 - while i < len(prelude_tokens): - token = prelude_tokens[i] - - # Handle url token - if token.type == "function" and token.lower_name == "url": - # Extract URL from inside the url() function - url_content = tinycss2.parse_component_value_list(tinycss2.serialize(token.arguments)) - for arg in url_content: - if arg.type in ["string", "ident"]: - import_url = arg.value - break - - # Media query would be remaining tokens - media_tokens = prelude_tokens[i+1:] - break - - # Handle string token - elif token.type == "string": - import_url = token.value - # Media query would be remaining tokens - media_tokens = prelude_tokens[i+1:] - break - - i += 1 - - # If we found a URL, process it - if import_url: - imports.append(import_url) - - # Extract media query if present - if media_tokens: - media_query_text = tinycss2.serialize(media_tokens).strip() - if media_query_text: - import_media_queries[import_url] = media_query_text - - return imports, import_media_queries - - -def analyze_stylesheet(css: Union[str, bytes]) -> Dict[str, Any]: - """ - Analyze a CSS stylesheet and return various statistics. - - Args: - css: The CSS code as string or bytes - - Returns: - Dictionary with statistics and detailed information about the stylesheet - - Raises: - Exception: If the CSS cannot be properly parsed - """ - if isinstance(css, bytes): - css = css.decode('utf-8') - - # Validate CSS syntax before proceeding - # Check for unbalanced braces - a common CSS error - if css.count('{') != css.count('}'): - raise Exception("CSS syntax error: Unbalanced braces") - - # Extract all comments using the existing function - all_comments = extract_comments(css) - - try: - # Parse CSS for further analysis - rules = parse_stylesheet(css) - - # Check for parse errors - for rule in rules: - if hasattr(rule, 'type') and rule.type == 'error': - raise Exception(f"CSS parse error: {getattr(rule, 'message', 'Unknown error')}") - - # Extract imports using AST approach - imports, import_media_queries = extract_imports(rules) - - except Exception as e: - # Re-raise any parsing exceptions with a clear message - raise Exception(f"Failed to parse CSS: {str(e)}") - - # Initialize data structures - selectors = [] - properties = {} - colors = [] - fonts = [] - media_query_list = [] - media_query_details = {} - selector_properties = {} - - # Process CSS rules - try: - process_rule_block( - rules, None, selectors, properties, colors, fonts, - media_query_list, media_query_details, selector_properties - ) - except Exception as e: - raise Exception(f"Error analyzing CSS structure: {str(e)}") - - # Return the analysis results - return { - "selectors": selectors, - "selectors_count": len(selectors), - "unique_selectors": len(set(selectors)), - "properties_count": sum(properties.values()), - "unique_properties": len(properties), - "most_used_properties": sorted(properties.items(), key=lambda x: x[1], reverse=True)[:10], - "colors_used": len(set(colors)), - "colors": list(set(colors)), - "fonts_used": len(set(fonts)), - "fonts": list(set(fonts)), - "media_queries_count": len(media_query_list), - "media_queries": media_query_list, - "media_query_details": media_query_details, - "comments_count": len(all_comments), - "comments": all_comments, - "file_size_bytes": len(css.encode('utf-8')), - "selector_properties": selector_properties, - "imports": imports, - "imports_count": len(imports), - "import_media_queries": import_media_queries - } diff --git a/priv/python/css_tools-0.1.2-py3-none-any.whl b/priv/python/css_tools-0.1.2-py3-none-any.whl deleted file mode 100644 index 80a9c34bf4abb11ae76342e3b5ee5553f3e5f6ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16528 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 N>|f7w#q*D={{;w&>v{kH diff --git a/rebuild_wheel.sh b/rebuild_wheel.sh deleted file mode 100755 index 15901dc..0000000 --- a/rebuild_wheel.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash - -# SPDX-FileCopyrightText: 2025 igniter_css contributors -# -# SPDX-License-Identifier: MIT - -# Script to rebuild the Python wheel and ensure the latest version is used - -echo "🔧 Rebuilding CSS tools wheel..." - -# Remove old wheel from priv/python -echo "📦 Removing old wheel..." -rm -f priv/python/css_tools-0.1.0-py3-none-any.whl - -# Navigate to css_tools directory -cd plibs/css_tools - -# Activate virtual environment if it exists -if [ -d "../venv" ]; then - echo "🐍 Activating virtual environment..." - source ../venv/bin/activate -fi - -# Clean previous builds -echo "🧹 Cleaning previous builds..." -rm -rf build/ dist/ src/css_tools.egg-info/ - -# Build the wheel -echo "🏗️ Building new wheel..." -python -m build - -# Copy the new wheel to priv/python -echo "📋 Copying wheel to priv/python..." -cp dist/css_tools-0.1.0-py3-none-any.whl ../../priv/python/ - -# Verify the wheel was copied -if [ -f "../../priv/python/css_tools-0.1.0-py3-none-any.whl" ]; then - echo "✅ Wheel successfully rebuilt and deployed!" - ls -lh ../../priv/python/css_tools-0.1.0-py3-none-any.whl -else - echo "❌ Failed to copy wheel to priv/python" - exit 1 -fi - -echo "🎉 Done!" diff --git a/test/codemods_test.exs b/test/codemods_test.exs new file mode 100644 index 0000000..114667a --- /dev/null +++ b/test/codemods_test.exs @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.CodemodsTest do + @moduledoc """ + The Igniter-facing wrappers, exercised against a real `Igniter` struct so the + installer flow — including "re-running changes nothing" — is covered rather + than assumed. + """ + + use IgniterCss.CssCase, async: true + + import Igniter.Test + + alias IgniterCss.Codemods + + @path "assets/css/app.css" + + defp igniter_with(content) do + test_project(files: %{@path => content}) + end + + defp content(igniter) do + igniter.rewrite + |> Rewrite.source!(@path) + |> Rewrite.Source.get(:content) + end + + describe "ensure_at_rule/4" do + test "adds the at-rule to the file" do + result = + ~s|@import "tailwindcss";\n| + |> igniter_with() + |> Codemods.ensure_at_rule(@path, ~s|@plugin "daisyui";|) + |> content() + + assert result == ~s|@import "tailwindcss";\n@plugin "daisyui";\n| + end + + test "re-running leaves the file untouched" do + source = ~s|@import "tailwindcss";\n@plugin "daisyui";\n| + + result = + source + |> igniter_with() + |> Codemods.ensure_at_rule(@path, ~s|@plugin "daisyui";|) + |> content() + + assert result == source + end + end + + describe "ensure_rule/5 and set_declaration/6" do + test "a full installer run produces the expected file" do + result = + fixture("phoenix_app.css") + |> igniter_with() + |> Codemods.ensure_at_rule(@path, ~s|@plugin "@tailwindcss/typography";|) + |> Codemods.ensure_rule(@path, ".hide-scrollbar") + |> Codemods.set_declaration(@path, ".hide-scrollbar", "scrollbar-width", "none") + |> content() + + assert String.contains?(result, ~s|@plugin "@tailwindcss/typography";|) + assert String.contains?(result, ".hide-scrollbar {\n scrollbar-width: none;\n}") + assert_comments_preserved(fixture("phoenix_app.css"), result) + assert_changed_lines(fixture("phoenix_app.css"), result, 5) + end + + test "running the same installer twice is a no-op" do + install = fn igniter -> + igniter + |> Codemods.ensure_at_rule(@path, ~s|@plugin "daisyui";|) + |> Codemods.ensure_rule(@path, ".hide-scrollbar") + |> Codemods.set_declaration(@path, ".hide-scrollbar", "scrollbar-width", "none") + end + + once = fixture("phoenix_app.css") |> igniter_with() |> install.() |> content() + twice = once |> igniter_with() |> install.() |> content() + + assert once == twice + end + + test "seeds a new rule with declarations" do + result = + ".a {}\n" + |> igniter_with() + |> Codemods.ensure_rule(@path, ".b", "color: red") + |> content() + + assert result == ".a {}\n\n.b {\n color: red;\n}\n" + end + end + + describe "remove_declaration/5 and remove_rule/4" do + test "removes a declaration with the comment it owns" do + result = + ".a {\n /* legacy */\n color: red;\n margin: 0;\n}\n" + |> igniter_with() + |> Codemods.remove_declaration(@path, ".a", "color") + |> content() + + assert result == ".a {\n margin: 0;\n}\n" + end + + test "removes a rule but keeps a section header" do + result = + "/* ===== Utils ===== */\n.b {}\n.c {}\n" + |> igniter_with() + |> Codemods.remove_rule(@path, ".b") + |> content() + + assert result == "/* ===== Utils ===== */\n.c {}\n" + end + end + + describe "append_raw_to_rule/5 and add_vendor_prefixes/5" do + test "appends a re-indented block" do + result = + ".a {\n color: red;\n}\n" + |> igniter_with() + |> Codemods.append_raw_to_rule(@path, ".a", "&:hover {\n color: blue;\n}") + |> content() + + assert result == ".a {\n color: red;\n &:hover {\n color: blue;\n }\n}\n" + end + + test "adds vendor prefixes" do + result = + ".a {\n user-select: none;\n}\n" + |> igniter_with() + |> Codemods.add_vendor_prefixes(@path, "user-select", ["-webkit-"]) + |> content() + + assert result == ".a {\n -webkit-user-select: none;\n user-select: none;\n}\n" + end + end + + describe "failure handling" do + test "an ambiguous selector stops the installer instead of guessing" do + igniter = igniter_with(".a {}\n.a {}\n") + + assert_raise RuntimeError, ~r/refusing to guess/, fn -> + Codemods.set_declaration(igniter, @path, ".a", "color", "red") + end + end + + test "a file we refuse to patch stops the installer" do + igniter = igniter_with(".broken {\n color: red;\n") + + assert_raise RuntimeError, ~r/unbalanced/, fn -> + Codemods.ensure_rule(igniter, @path, ".probe") + end + end + end +end diff --git a/test/corpus_invariants_test.exs b/test/corpus_invariants_test.exs new file mode 100644 index 0000000..c47cb25 --- /dev/null +++ b/test/corpus_invariants_test.exs @@ -0,0 +1,223 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.CorpusInvariantsTest do + @moduledoc """ + The four guarantees in `IgniterCss`'s moduledoc, checked for every operation + against every fixture rather than only the cases somebody wrote a test for. + + The fixture corpus is real-world shaped on purpose: a Phoenix `app.css`, + Tailwind v4 syntax, comments in awkward places, CRLF, a BOM, no trailing + newline, minified vendor CSS, non-ASCII content, and files that are simply + broken. + """ + + use IgniterCss.CssCase, async: true + + # {label, function, is_a_removal?} + # A function, not a module attribute: closures cannot be escaped into one. + defp ops do + [ + {"ensure_at_rule/import", &IgniterCss.ensure_at_rule(&1, ~s|@import "probe.css";|), false}, + {"ensure_at_rule/plugin", &IgniterCss.ensure_at_rule(&1, ~s|@plugin "probe";|), false}, + {"ensure_at_rule/source", &IgniterCss.ensure_at_rule(&1, ~s|@source "../probe";|), false}, + {"remove_at_rule/import", &IgniterCss.remove_at_rule(&1, "import"), true}, + {"remove_at_rule/plugin", &IgniterCss.remove_at_rule(&1, "plugin"), true}, + {"ensure_rule", &IgniterCss.ensure_rule(&1, ".igniter-probe"), false}, + {"remove_rule", &IgniterCss.remove_rule(&1, ".hide-scrollbar"), true}, + {"set_declaration/create", + &IgniterCss.set_declaration(&1, ".igniter-probe", "display", "none", create_rule: true), + false}, + {"set_declaration/existing", + &IgniterCss.set_declaration(&1, ".page", "color", "rebeccapurple"), false}, + {"remove_declaration", &IgniterCss.remove_declaration(&1, ".page", "display"), true}, + {"append_raw_to_rule", &IgniterCss.append_raw_to_rule(&1, ".page", "outline: 1px solid;"), + false}, + {"replace_rule_body", &IgniterCss.replace_rule_body(&1, ".sr-only", "position: fixed;"), + false}, + {"add_vendor_prefixes", + &IgniterCss.add_vendor_prefixes(&1, "user-select", ["-webkit-", "-moz-"]), false}, + {"add_vendor_prefixes/display", + &IgniterCss.add_vendor_prefixes(&1, "display", ["-webkit-"]), false}, + {"sort_properties", &IgniterCss.sort_properties/1, false}, + {"remove_duplicates", &IgniterCss.remove_duplicates/1, false} + ] + end + + # An op that returns `{:error, _}` for a given fixture has not applied to it; + # that is valid behaviour (a missing rule, an ambiguous selector, a file we + # refuse to patch), and it means the fixture is skipped for that op. + defp applicable(fun, source) do + case fun.(source) do + {:ok, outcome} -> {:ok, outcome} + {:error, _reason} -> :skip + end + end + + test "guarantee 3: every op is idempotent on every fixture" do + for {name, source} <- fixtures(), {label, fun, _} <- ops() do + with {:ok, once} <- applicable(fun, source) do + assert {:ok, twice} = fun.(once.source), + "#{label} succeeded on #{name} but failed on its own output" + + assert once.source == twice.source, "#{label} is not idempotent on #{name}" + refute twice.changed, "#{label} reported changed: true on a second run over #{name}" + end + end + end + + test "guarantee 1: no non-removal op ever loses a comment" do + for {name, source} <- fixtures(), + {label, fun, removal?} <- ops(), + not removal?, + comments(source) != [] do + with {:ok, out} <- applicable(fun, source) do + missing = comments(source) -- comments(out.source) + assert missing == [], "#{label} lost #{inspect(missing)} from #{name}" + end + end + end + + test "guarantee 4: output always still round-trips, with no new parse errors" do + for {name, source} <- fixtures(), {label, fun, _} <- ops() do + with {:ok, out} <- applicable(fun, source) do + {_, before} = IgniterCss.validate(source) + {_, after_} = IgniterCss.validate(out.source) + + assert after_.round_trips, "#{label} broke the round-trip of #{name}" + + assert after_.diagnostics <= before.diagnostics, + "#{label} added parse diagnostics to #{name}" + end + end + end + + test "an unchanged outcome returns the source byte for byte" do + for {name, source} <- fixtures(), {label, fun, _} <- ops() do + with {:ok, %{changed: false} = out} <- applicable(fun, source) do + assert out.source == source, "#{label} reported changed: false but altered #{name}" + end + end + end + + test "every op preserves the file's newline style" do + for {name, source} <- fixtures(), + String.contains?(source, "\r\n"), + {label, fun, _} <- ops() do + with {:ok, out} <- applicable(fun, source) do + bare_lf_before = count_bare_lf(source) + bare_lf_after = count_bare_lf(out.source) + + assert bare_lf_before == bare_lf_after, + "#{label} introduced a bare LF into the CRLF file #{name}" + end + end + end + + test "every op preserves a BOM" do + for {name, source} <- fixtures(), {label, fun, _} <- ops() do + with {:ok, out} <- applicable(fun, source) do + assert String.starts_with?(out.source, "") == + String.starts_with?(source, ""), + "#{label} changed the BOM state of #{name}" + end + end + end + + test "guarantee 2: single-target ops change only a handful of lines" do + budgets = [ + {"ensure_at_rule", &IgniterCss.ensure_at_rule(&1, ~s|@plugin "probe";|), 1}, + {"ensure_rule", &IgniterCss.ensure_rule(&1, ".igniter-probe"), 4}, + {"set_declaration", &IgniterCss.set_declaration(&1, ".page", "color", "rebeccapurple"), 2}, + # A declaration plus the comments Rule D says it owns. + {"remove_declaration", &IgniterCss.remove_declaration(&1, ".page", "display"), 3} + ] + + for {name, source} <- fixtures(), {label, fun, budget} <- budgets do + with {:ok, %{changed: true} = out} <- applicable(fun, source) do + actual = changed_lines(source, out.source) + + assert actual <= budget, + "#{label} changed #{actual} lines in #{name} (budget #{budget})" + end + end + end + + test "the read-only ops survive the whole corpus" do + for {name, source} <- fixtures() do + assert {:ok, _} = IgniterCss.analyze(source), "analyze failed on #{name}" + assert {:ok, _} = IgniterCss.extract_colors(source), "extract_colors failed on #{name}" + + assert {:ok, _} = IgniterCss.extract_media_queries(source), + "extract_media_queries failed on #{name}" + + assert {:ok, _} = IgniterCss.extract_animations(source), + "extract_animations failed on #{name}" + + assert {_, %IgniterCss.Validation{}} = IgniterCss.validate(source) + assert {:ok, _} = IgniterCss.list_selectors(source) + end + end + + test "malformed input is never half-edited" do + malformed = [ + ".broken {\n color: red;\n", + ".a { color: red; }\n}\n.b { color: blue; }\n", + "{{{{", + "@media (\n", + "}", + ".a { color: " + ] + + for source <- malformed, {label, fun, _} <- ops() do + case fun.(source) do + {:ok, %{changed: false, source: unchanged}} -> + assert unchanged == source, + "#{label} altered #{inspect(source)} while reporting no change" + + {:ok, %{source: patched}} -> + {_, validation} = IgniterCss.validate(patched) + + assert validation.round_trips, + "#{label} produced non-round-tripping output from #{inspect(source)}" + + {:error, _reason} -> + # An error carries no source, so nothing could have been written. + :ok + end + end + end + + test "a fresh Phoenix app.css can be patched end to end with a minimal diff" do + source = fixture("phoenix_app.css") + + {:ok, s1} = IgniterCss.ensure_at_rule(source, ~s|@plugin "@tailwindcss/typography";|) + {:ok, s2} = IgniterCss.ensure_at_rule(s1.source, ~s|@source "../vendor";|) + {:ok, s3} = IgniterCss.ensure_rule(s2.source, ".hide-scrollbar") + + {:ok, s4} = + IgniterCss.set_declaration(s3.source, ".hide-scrollbar", "scrollbar-width", "none") + + assert s1.changed and s2.changed and s3.changed and s4.changed + + # Two at-rule lines, a blank line, and three lines of new rule. + assert_changed_lines(source, s4.source, 6) + assert_comments_preserved(source, s4.source) + + # Every line the file already had is still there, verbatim. + for line <- String.split(source, "\n") do + assert String.contains?(s4.source, line), "patching dropped #{inspect(line)}" + end + + # Re-running the installer produces no diff at all. + {:ok, again} = IgniterCss.ensure_at_rule(s4.source, ~s|@plugin "@tailwindcss/typography";|) + refute again.changed + end + + defp count_bare_lf(string) do + total = length(String.split(string, "\n")) - 1 + crlf = length(String.split(string, "\r\n")) - 1 + total - crlf + end +end diff --git a/test/fixtures/bom.css b/test/fixtures/bom.css new file mode 100644 index 0000000..2bef45d --- /dev/null +++ b/test/fixtures/bom.css @@ -0,0 +1,4 @@ +/* file starts with a BOM */ +.bom { + color: teal; +} diff --git a/plibs/css_tools/dist/css_tools-0.1.2.tar.gz.license b/test/fixtures/bom.css.license similarity index 100% rename from plibs/css_tools/dist/css_tools-0.1.2.tar.gz.license rename to test/fixtures/bom.css.license diff --git a/test/fixtures/comments_everywhere.css b/test/fixtures/comments_everywhere.css new file mode 100644 index 0000000..29887c0 --- /dev/null +++ b/test/fixtures/comments_everywhere.css @@ -0,0 +1,45 @@ +/* ============================================================ + Section: Layout + ============================================================ */ + +/* The page shell. */ +.page { + /* leading comment inside the body */ + display: flex; /* trailing on a declaration */ + /* between declarations */ + flex-direction: column; + min-height: 100vh; + /* dangling comment at the end of the body */ +} + +/* A selector list with comments between the selectors. */ +.a, /* first */ +.b, /* second */ +.c { + color: red; +} + +@media (min-width: 768px) { + /* comment inside a media block */ + .page { + flex-direction: row; /* side by side on desktop */ + } + /* trailing comment inside the media block */ +} + +/* ============================================================ + Section: Utilities + ============================================================ */ + +.sr-only { + position: absolute; + width: 1px; +} + +/* A comment separated from the rule below by a blank line. */ + +.hide-scrollbar { + scrollbar-width: none; /* Firefox */ +} + +/* Trailing comment at end of file, no newline after it */ diff --git a/plibs/css_tools/src/css_tools.egg-info/PKG-INFO.license b/test/fixtures/comments_everywhere.css.license similarity index 100% rename from plibs/css_tools/src/css_tools.egg-info/PKG-INFO.license rename to test/fixtures/comments_everywhere.css.license diff --git a/test/fixtures/crlf.css b/test/fixtures/crlf.css new file mode 100644 index 0000000..766a280 --- /dev/null +++ b/test/fixtures/crlf.css @@ -0,0 +1,8 @@ +.header { + color: blue; +} + +/* a comment */ +.footer { + color: green; +} diff --git a/plibs/css_tools/src/css_tools.egg-info/SOURCES.txt.license b/test/fixtures/crlf.css.license similarity index 100% rename from plibs/css_tools/src/css_tools.egg-info/SOURCES.txt.license rename to test/fixtures/crlf.css.license diff --git a/test/fixtures/empty.css b/test/fixtures/empty.css new file mode 100644 index 0000000..e69de29 diff --git a/plibs/css_tools/src/css_tools.egg-info/dependency_links.txt.license b/test/fixtures/empty.css.license similarity index 100% rename from plibs/css_tools/src/css_tools.egg-info/dependency_links.txt.license rename to test/fixtures/empty.css.license diff --git a/test/fixtures/kitchen_sink.css b/test/fixtures/kitchen_sink.css new file mode 100644 index 0000000..4e0e668 --- /dev/null +++ b/test/fixtures/kitchen_sink.css @@ -0,0 +1,97 @@ +@charset "utf-8"; +@import url("https://fonts.googleapis.com/css2?family=Inter") screen and (min-width: 0); +@namespace svg url(http://www.w3.org/2000/svg); + +:root { + --brand: #4f46e5; + --spacing: 0.25rem; + --shadow: 0 1px 2px rgb(0 0 0 / 0.05); +} + +html:where(:not(.no-js)) { + color-scheme: light dark; +} + +.grid { + display: grid; + grid-template-areas: + "header header" + "side main"; + grid-template-columns: minmax(12rem, 1fr) 3fr; +} + +a[href^="https://"]:not(.internal)::after { + content: " \2197"; +} + +.quote::before { + content: "\201C"; +} + +.emoji::after { + content: "→ ✨ naïve café"; +} + +@supports (display: grid) and (not (display: inline-grid)) { + .fallback { + display: grid; + } +} + +@media screen and (min-width: 40em), print { + .responsive { + font-size: clamp(1rem, 2.5vw, 1.5rem); + } +} + +@font-face { + font-family: "Inter"; + src: url("/fonts/inter.woff2") format("woff2"); + font-display: swap; +} + +@keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slide { + 0% { + transform: translateX(0); + } + + 50%, + 75% { + transform: translateX(10px); + } + + 100% { + transform: translateX(0); + } +} + +.animated { + animation: fade-in 0.3s ease-in-out both, slide 1s linear infinite; +} + +@page :first { + margin: 1in; +} + +.important { + color: red !important; + background: url(data:image/svg+xml;base64,PHN2Zy8+) no-repeat; +} + +@layer base, components, utilities; + +@container card (min-width: 400px) { + .card-title { + font-size: 1.5rem; + } +} diff --git a/plibs/css_tools/src/css_tools.egg-info/requires.txt.license b/test/fixtures/kitchen_sink.css.license similarity index 100% rename from plibs/css_tools/src/css_tools.egg-info/requires.txt.license rename to test/fixtures/kitchen_sink.css.license diff --git a/test/fixtures/line_comments.css b/test/fixtures/line_comments.css new file mode 100644 index 0000000..2f40cd7 --- /dev/null +++ b/test/fixtures/line_comments.css @@ -0,0 +1,11 @@ +// css-in-js habit: line comments at the top level +.button { + // the brand colour + color: #4f46e5; + padding: 8px 16px; // horizontal breathing room +} + +// A whole section commented out below +.legacy { + border: 1px solid #ccc; +} diff --git a/plibs/css_tools/src/css_tools.egg-info/top_level.txt.license b/test/fixtures/line_comments.css.license similarity index 100% rename from plibs/css_tools/src/css_tools.egg-info/top_level.txt.license rename to test/fixtures/line_comments.css.license diff --git a/test/fixtures/minified.css b/test/fixtures/minified.css new file mode 100644 index 0000000..26ab694 --- /dev/null +++ b/test/fixtures/minified.css @@ -0,0 +1 @@ +.a{color:#333;margin:0}.b,.c{padding:0;border:0}@media(max-width:640px){.a{display:none}} diff --git a/priv/python/css_tools-0.1.2-py3-none-any.whl.license b/test/fixtures/minified.css.license similarity index 100% rename from priv/python/css_tools-0.1.2-py3-none-any.whl.license rename to test/fixtures/minified.css.license diff --git a/test/fixtures/no_trailing_newline.css b/test/fixtures/no_trailing_newline.css new file mode 100644 index 0000000..7b31923 --- /dev/null +++ b/test/fixtures/no_trailing_newline.css @@ -0,0 +1,3 @@ +.no-trailing-newline { + color: red; +} \ No newline at end of file diff --git a/test/fixtures/no_trailing_newline.css.license b/test/fixtures/no_trailing_newline.css.license new file mode 100644 index 0000000..e84618c --- /dev/null +++ b/test/fixtures/no_trailing_newline.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT diff --git a/test/fixtures/non_ascii.css b/test/fixtures/non_ascii.css new file mode 100644 index 0000000..f38f41c --- /dev/null +++ b/test/fixtures/non_ascii.css @@ -0,0 +1,8 @@ +/* Ünïcödé cömment — with em dash and ellipsis… */ +.non-ascii::after { + content: "日本語 ✓"; +} + +.rtl { + content: "سلام"; +} diff --git a/test/fixtures/non_ascii.css.license b/test/fixtures/non_ascii.css.license new file mode 100644 index 0000000..e84618c --- /dev/null +++ b/test/fixtures/non_ascii.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT diff --git a/test/fixtures/only_comment.css b/test/fixtures/only_comment.css new file mode 100644 index 0000000..f20efeb --- /dev/null +++ b/test/fixtures/only_comment.css @@ -0,0 +1 @@ +/* nothing but a comment */ diff --git a/test/fixtures/only_comment.css.license b/test/fixtures/only_comment.css.license new file mode 100644 index 0000000..e84618c --- /dev/null +++ b/test/fixtures/only_comment.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT diff --git a/test/fixtures/phoenix_app.css b/test/fixtures/phoenix_app.css new file mode 100644 index 0000000..bdbd067 --- /dev/null +++ b/test/fixtures/phoenix_app.css @@ -0,0 +1,32 @@ +@import "tailwindcss" source(none); +@source "../css"; +@source "../js"; +@source "../../lib/my_app_web"; + +/* A Tailwind plugin for phoenix. */ +@plugin "../vendor/heroicons"; +@plugin "@tailwindcss/forms"; + +/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with: + curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js */ +@plugin "../vendor/daisyui" { + themes: false; +} + +/* daisyUI theme plugin */ +@plugin "../vendor/daisyui-theme" { + name: "dark"; + default: false; + prefersdark: true; + color-scheme: "dark"; +} + +/* Use the data attribute for dark mode */ +@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); + +/* Make LiveView wrapper divs transparent for layout */ +[data-phx-session] { + display: contents; +} + +/* This file is for your main application CSS */ diff --git a/test/fixtures/phoenix_app.css.license b/test/fixtures/phoenix_app.css.license new file mode 100644 index 0000000..e84618c --- /dev/null +++ b/test/fixtures/phoenix_app.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT diff --git a/test/fixtures/stray_brace.css b/test/fixtures/stray_brace.css new file mode 100644 index 0000000..012c37e --- /dev/null +++ b/test/fixtures/stray_brace.css @@ -0,0 +1,3 @@ +.a { color: red; } +} +.b { color: blue; } diff --git a/test/fixtures/stray_brace.css.license b/test/fixtures/stray_brace.css.license new file mode 100644 index 0000000..e84618c --- /dev/null +++ b/test/fixtures/stray_brace.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT diff --git a/test/fixtures/tabs.css b/test/fixtures/tabs.css new file mode 100644 index 0000000..a697225 --- /dev/null +++ b/test/fixtures/tabs.css @@ -0,0 +1,4 @@ +.tabbed { + color: red; + background: blue; +} diff --git a/test/fixtures/tabs.css.license b/test/fixtures/tabs.css.license new file mode 100644 index 0000000..e84618c --- /dev/null +++ b/test/fixtures/tabs.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT diff --git a/test/fixtures/tailwind_v4.css b/test/fixtures/tailwind_v4.css new file mode 100644 index 0000000..ce848c3 --- /dev/null +++ b/test/fixtures/tailwind_v4.css @@ -0,0 +1,58 @@ +@import "tailwindcss"; +@import "./typography.css" layer(components); + +@theme { + --font-display: "Satoshi", "sans-serif"; + --breakpoint-3xl: 1920px; + --color-avocado-100: oklch(0.99 0 0); + --color-avocado-200: oklch(0.98 0.04 113.22); + --ease-fluid: cubic-bezier(0.3, 0, 0, 1); +} + +@theme inline { + --color-brand: var(--brand); +} + +@custom-variant pointer-coarse (@media (pointer: coarse)); +@custom-variant theme-midnight (&:where([data-theme="midnight"] *)); + +@utility tab-4 { + tab-size: 4; +} + +@utility scrollbar-hidden { + &::-webkit-scrollbar { + display: none; + } +} + +@layer base { + /* Sensible defaults for headings. */ + h1 { + font-size: var(--text-2xl); + } + + h2 { + font-size: var(--text-xl); + } +} + +@layer components { + .btn-primary { + @apply rounded-full bg-violet-500 px-5 py-2 font-semibold text-white; + } +} + +@variant dark { + .card { + background-color: var(--color-gray-900); + } +} + +@reference "../../app.css"; + +.typography { + h1 { + @apply text-2xl; + } +} diff --git a/test/fixtures/tailwind_v4.css.license b/test/fixtures/tailwind_v4.css.license new file mode 100644 index 0000000..e84618c --- /dev/null +++ b/test/fixtures/tailwind_v4.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT diff --git a/test/fixtures/truncated.css b/test/fixtures/truncated.css new file mode 100644 index 0000000..cfe7652 --- /dev/null +++ b/test/fixtures/truncated.css @@ -0,0 +1,2 @@ +.broken { + color: red; diff --git a/test/fixtures/truncated.css.license b/test/fixtures/truncated.css.license new file mode 100644 index 0000000..e84618c --- /dev/null +++ b/test/fixtures/truncated.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT diff --git a/test/igniter_css_test.exs b/test/igniter_css_test.exs index 3682965..dd0c3f7 100644 --- a/test/igniter_css_test.exs +++ b/test/igniter_css_test.exs @@ -3,5 +3,668 @@ # SPDX-License-Identifier: MIT defmodule IgniterCssTest do - use ExUnit.Case + use IgniterCss.CssCase, async: true + + doctest IgniterCss + + alias IgniterCss.Outcome + + describe "ensure_at_rule/3" do + test "inserts into an empty file" do + assert {:ok, %Outcome{source: ~s|@plugin "daisyui";\n|, changed: true}} = + IgniterCss.ensure_at_rule("", ~s|@plugin "daisyui";|) + end + + test "inserts after the last at-rule of the same name" do + css = ~s|@import "a";\n@import "b";\n\n.x { color: red; }\n| + + assert {:ok, %Outcome{source: out}} = IgniterCss.ensure_at_rule(css, ~s|@import "c";|) + assert out == ~s|@import "a";\n@import "b";\n@import "c";\n\n.x { color: red; }\n| + end + + test "inserts at the end of the prologue when the family is new" do + css = ~s|@import "tailwindcss";\n@source "../js";\n\n.x { color: red; }\n| + + assert {:ok, %Outcome{source: out}} = IgniterCss.ensure_at_rule(css, ~s|@plugin "d";|) + + assert out == + ~s|@import "tailwindcss";\n@source "../js";\n@plugin "d";\n\n.x { color: red; }\n| + end + + test "never places an @import after a style rule" do + assert {:ok, %Outcome{source: out}} = + IgniterCss.ensure_at_rule(".x { color: red; }\n", ~s|@import "b";|) + + assert String.starts_with?(out, ~s|@import "b";\n.x|) + end + + test "an equivalent rule with the same target is not duplicated" do + css = ~s|@import "tailwindcss" source(none);\n| + + assert {:ok, %Outcome{changed: false, source: ^css}} = + IgniterCss.ensure_at_rule(css, ~s|@import "tailwindcss";|) + end + + test "quoting style does not create a duplicate" do + assert {:ok, %Outcome{changed: false}} = + IgniterCss.ensure_at_rule(~s|@plugin '../vendor/x';\n|, ~s|@plugin "../vendor/x";|) + end + + test "is idempotent" do + assert_idempotent( + ~s|@import "a";\n.x {}\n|, + &IgniterCss.ensure_at_rule(&1, ~s|@plugin "p";|) + ) + end + + test "keeps every comment" do + css = ~s|/* one */\n@import "a"; /* two */\n/* three */\n.x {}\n| + {:ok, out} = IgniterCss.ensure_at_rule(css, ~s|@import "b";|) + assert_comments_preserved(css, out.source) + end + + test "adds only one line" do + css = fixture("phoenix_app.css") + {:ok, out} = IgniterCss.ensure_at_rule(css, ~s|@plugin "probe";|) + assert_changed_lines(css, out.source, 1) + end + + test "rejects text that is not an at-rule" do + assert {:error, reason} = IgniterCss.ensure_at_rule("", ".a { color: red; }") + assert reason =~ "at-rule" + end + + test "rejects an unbalanced at-rule line" do + assert {:error, _} = IgniterCss.ensure_at_rule("", ~s|@plugin "x" {|) + end + end + + describe "remove_at_rule/4" do + test "removes only the matching rule" do + css = ~s|@import "a";\n@import "b";\n.x {}\n| + + assert {:ok, %Outcome{source: ~s|@import "b";\n.x {}\n|}} = + IgniterCss.remove_at_rule(css, "import", "a") + end + + test "removes every rule of the name when unfiltered" do + css = ~s|@import "a";\n@import "b";\n.x {}\n| + assert {:ok, %Outcome{source: ".x {}\n"}} = IgniterCss.remove_at_rule(css, "import") + end + + test "takes the adjacent comment but keeps a section header" do + css = ~s|/* ===== Imports ===== */\n/* the app css */\n@import "a";\n@import "b";\n| + + assert {:ok, %Outcome{source: out}} = IgniterCss.remove_at_rule(css, "import", "a") + assert out == ~s|/* ===== Imports ===== */\n@import "b";\n| + end + + test "removing something absent is a no-op" do + assert {:ok, %Outcome{changed: false}} = IgniterCss.remove_at_rule(".x {}\n", "import", "a") + end + end + + describe "has_at_rule?/3" do + test "agrees with ensure_at_rule" do + css = ~s|@plugin "a";\n| + assert {:ok, true} = IgniterCss.has_at_rule?(css, ~s|@plugin "a";|) + assert {:ok, false} = IgniterCss.has_at_rule?(css, ~s|@plugin "b";|) + end + end + + describe "add_import/4 and remove_import/3" do + test "quotes a relative path and wraps an absolute url" do + assert {:ok, %Outcome{source: ~s|@import "styles.css";\n|}} = + IgniterCss.add_import("", "styles.css") + + assert {:ok, %Outcome{source: ~s|@import url("https://x/y.css");\n|}} = + IgniterCss.add_import("", "https://x/y.css") + end + + test "carries a media query" do + assert {:ok, %Outcome{source: out}} = + IgniterCss.add_import("", "m.css", "screen and (max-width: 768px)") + + assert out == ~s|@import "m.css" screen and (max-width: 768px);\n| + end + + test "matches a url written either way when removing" do + css = ~s|@import url("/a.css");\n@import "b";\n| + assert {:ok, %Outcome{source: ~s|@import "b";\n|}} = IgniterCss.remove_import(css, "/a.css") + end + + test "rejects an unquotable url" do + assert {:error, _} = IgniterCss.add_import("", ~s|a"b|) + assert {:error, _} = IgniterCss.add_import("", " ") + end + end + + describe "ensure_rule/4" do + test "creates a missing rule at the end" do + assert {:ok, %Outcome{source: ".a { color: red; }\n\n.b {\n}\n"}} = + IgniterCss.ensure_rule(".a { color: red; }\n", ".b") + end + + test "seeds the new rule with declarations" do + assert {:ok, %Outcome{source: ".b {\n color: red;\n margin: 0;\n}\n"}} = + IgniterCss.ensure_rule("", ".b", "color: red; margin: 0") + end + + test "does not recreate an existing rule" do + css = ".b {\n color: red;\n}\n" + assert {:ok, %Outcome{changed: false, source: ^css}} = IgniterCss.ensure_rule(css, ".b") + end + + test "matches a rule written with different spacing" do + assert {:ok, %Outcome{changed: false}} = + IgniterCss.ensure_rule(".a > .b { color: red; }\n", ".a>.b") + end + + test "follows the file's indent and newline style" do + css = ".a {\r\n\tcolor: red;\r\n}\r\n" + assert {:ok, %Outcome{source: out}} = IgniterCss.ensure_rule(css, ".b", "margin: 0") + assert out == ".a {\r\n\tcolor: red;\r\n}\r\n\r\n.b {\r\n\tmargin: 0;\r\n}\r\n" + end + + test "a file without a trailing newline keeps not having one" do + assert {:ok, %Outcome{source: ".a {}\n\n.b {\n}"}} = IgniterCss.ensure_rule(".a {}", ".b") + end + + test "is idempotent" do + assert_idempotent(".a {}\n", &IgniterCss.ensure_rule(&1, ".b")) + end + + test "rejects a selector containing braces" do + assert {:error, _} = IgniterCss.ensure_rule("", ".a { }") + assert {:error, _} = IgniterCss.ensure_rule("", " ") + end + end + + describe "remove_rule/3" do + test "removes the rule and its line" do + assert {:ok, %Outcome{source: ".a {}\n.c {}\n"}} = + IgniterCss.remove_rule(".a {}\n.b {}\n.c {}\n", ".b") + end + + test "removes the comment directly above" do + assert {:ok, %Outcome{source: ".a {}\n\n.c {}\n"}} = + IgniterCss.remove_rule(".a {}\n\n/* about b */\n.b {}\n\n.c {}\n", ".b") + end + + test "keeps a section header" do + css = "/* ===== Utilities ===== */\n.b {}\n.c {}\n" + + assert {:ok, %Outcome{source: "/* ===== Utilities ===== */\n.c {}\n"}} = + IgniterCss.remove_rule(css, ".b") + end + + test "does not reach into a media block" do + css = "@media print {\n .b { color: red; }\n}\n" + assert {:ok, %Outcome{changed: false}} = IgniterCss.remove_rule(css, ".b") + end + + test "removing an absent rule is a no-op" do + assert {:ok, %Outcome{changed: false}} = IgniterCss.remove_rule(".a {}\n", ".zz") + end + end + + describe "set_declaration/5" do + test "updates an existing value" do + assert {:ok, %Outcome{source: ".a {\n color: blue;\n}\n"}} = + IgniterCss.set_declaration(".a {\n color: red;\n}\n", ".a", "color", "blue") + end + + test "touches only the value bytes, preserving an inline comment" do + css = ".a {\n color: red; /* the brand */\n margin: 0;\n}\n" + assert {:ok, %Outcome{source: out}} = IgniterCss.set_declaration(css, ".a", "color", "blue") + assert out == ".a {\n color: blue; /* the brand */\n margin: 0;\n}\n" + assert_changed_lines(css, out, 2) + end + + test "preserves an existing !important by default" do + assert {:ok, %Outcome{source: ".a { color: blue !important; }"}} = + IgniterCss.set_declaration(".a { color: red !important; }", ".a", "color", "blue") + end + + test "can add and remove the !important flag" do + assert {:ok, %Outcome{source: ".a { color: blue !important; }"}} = + IgniterCss.set_declaration(".a { color: red; }", ".a", "color", "blue", + important: true + ) + + assert {:ok, %Outcome{source: ".a { color: blue; }"}} = + IgniterCss.set_declaration( + ".a { color: red !important; }", + ".a", + "color", + "blue", + important: false + ) + end + + test "appends a missing property in the file's own style" do + assert {:ok, %Outcome{source: ".a {\n\tcolor: red;\n\tmargin: 0;\n}\n"}} = + IgniterCss.set_declaration(".a {\n\tcolor: red;\n}\n", ".a", "margin", "0") + end + + test "keeps a single-line rule on one line" do + assert {:ok, %Outcome{source: ".a { color: red; margin: 0; }\n"}} = + IgniterCss.set_declaration(".a { color: red; }\n", ".a", "margin", "0") + end + + test "updates a custom property" do + assert {:ok, %Outcome{source: ":root {\n --brand: #000;\n}\n"}} = + IgniterCss.set_declaration( + ":root {\n --brand: #fff;\n}\n", + ":root", + "--brand", + "#000" + ) + end + + test "a missing rule errors by default and can be created on request" do + assert {:error, reason} = IgniterCss.set_declaration(".a {}\n", ".zz", "color", "red") + assert reason =~ "not found" + + assert {:ok, %Outcome{source: ".a {}\n\n.zz {\n color: red;\n}\n"}} = + IgniterCss.set_declaration(".a {}\n", ".zz", "color", "red", create_rule: true) + end + + test "an ambiguous selector errors rather than guessing" do + assert {:error, reason} = IgniterCss.set_declaration(".a {}\n.a {}\n", ".a", "color", "red") + assert reason =~ "matches 2 top-level rules" + assert reason =~ "refusing to guess" + end + + test "writing back the same value reports no change" do + css = ".a {\n color: red;\n}\n" + + assert {:ok, %Outcome{changed: false, source: ^css}} = + IgniterCss.set_declaration(css, ".a", "color", "red") + end + + test "accepts a url value containing a semicolon" do + assert {:ok, %Outcome{source: out}} = + IgniterCss.set_declaration( + ".a { background: none; }", + ".a", + "background", + "url(data:image/svg+xml;base64,AA==)" + ) + + assert out == ".a { background: url(data:image/svg+xml;base64,AA==); }" + end + + test "rejects a value carrying its own delimiters" do + for {property, value} <- [ + {"color", "red; margin: 0"}, + {"color", "red !important"}, + {"color:x", "red"}, + {"", "red"}, + {"color", " "} + ] do + assert {:error, _} = IgniterCss.set_declaration(".a{}", ".a", property, value) + end + end + + test "is idempotent" do + assert_idempotent( + ".a {\n color: red;\n}\n", + &IgniterCss.set_declaration(&1, ".a", "margin", "0") + ) + end + end + + describe "remove_declaration/4" do + test "removes the declaration and its line" do + assert {:ok, %Outcome{source: ".a {\n margin: 0;\n}\n"}} = + IgniterCss.remove_declaration( + ".a {\n color: red;\n margin: 0;\n}\n", + ".a", + "color" + ) + end + + test "takes the trailing comment with it" do + assert {:ok, %Outcome{source: ".a {\n margin: 0;\n}\n"}} = + IgniterCss.remove_declaration( + ".a {\n color: red; /* legacy */\n margin: 0;\n}\n", + ".a", + "color" + ) + end + + test "keeps a comment separated by a blank line" do + css = ".a {\n /* about the block */\n\n color: red;\n margin: 0;\n}\n" + + assert {:ok, %Outcome{source: ".a {\n /* about the block */\n\n margin: 0;\n}\n"}} = + IgniterCss.remove_declaration(css, ".a", "color") + end + + test "removes every copy of a repeated property" do + assert {:ok, %Outcome{source: ".a {\n margin: 0;\n}\n"}} = + IgniterCss.remove_declaration( + ".a {\n color: red;\n margin: 0;\n color: blue;\n}\n", + ".a", + "color" + ) + end + + test "removing from an absent rule is a no-op, not an error" do + assert {:ok, %Outcome{changed: false}} = + IgniterCss.remove_declaration(".a { color: red; }", ".zz", "color") + end + + test "is idempotent" do + assert_idempotent( + ".a {\n color: red;\n margin: 0;\n}\n", + &IgniterCss.remove_declaration(&1, ".a", "color") + ) + end + end + + describe "append_raw_to_rule/4" do + test "re-indents a multi-line block" do + assert {:ok, %Outcome{source: out}} = + IgniterCss.append_raw_to_rule( + ".a {\n color: red;\n}\n", + ".a", + "&:hover {\n color: blue;\n}" + ) + + assert out == ".a {\n color: red;\n &:hover {\n color: blue;\n }\n}\n" + end + + test "is a no-op when the text is already present" do + assert_idempotent( + ".a {\n color: red;\n}\n", + &IgniterCss.append_raw_to_rule(&1, ".a", "margin: 0;") + ) + end + + test "rejects unbalanced text" do + assert {:error, _} = IgniterCss.append_raw_to_rule(".a {}\n", ".a", "&:hover {") + assert {:error, _} = IgniterCss.append_raw_to_rule(".a {}\n", ".a", " ") + end + end + + describe "replace_rule_body/4" do + test "replaces a multi-line body" do + assert {:ok, %Outcome{source: ".a {\n padding: 1px;\n color: blue;\n}\n"}} = + IgniterCss.replace_rule_body( + ".a {\n color: red;\n margin: 0;\n}\n", + ".a", + "padding: 1px; color: blue" + ) + end + + test "errors on a missing or ambiguous selector" do + assert {:error, _} = IgniterCss.replace_rule_body(".a {}\n", ".zz", "color: red") + assert {:error, _} = IgniterCss.replace_rule_body(".a {}\n.a {}\n", ".a", "color: red") + end + end + + describe "add_vendor_prefixes/4" do + test "adds prefixes above the standard property" do + assert {:ok, %Outcome{source: out}} = + IgniterCss.add_vendor_prefixes( + ".a {\n user-select: none;\n}\n", + "user-select", + ["-webkit-", "-moz-"] + ) + + assert out == + ".a {\n -webkit-user-select: none;\n -moz-user-select: none;\n user-select: none;\n}\n" + end + + test "skips a prefix that is already present" do + css = ".a {\n -webkit-user-select: none;\n user-select: none;\n}\n" + + assert {:ok, %Outcome{source: out}} = + IgniterCss.add_vendor_prefixes(css, "user-select", ["-webkit-", "-moz-"]) + + assert out == + ".a {\n -webkit-user-select: none;\n -moz-user-select: none;\n user-select: none;\n}\n" + end + + test "carries the !important flag onto the copies" do + assert {:ok, %Outcome{source: out}} = + IgniterCss.add_vendor_prefixes( + ".a {\n user-select: none !important;\n}\n", + "user-select", + ["-webkit-"] + ) + + assert out =~ "-webkit-user-select: none !important;" + end + + test "an empty prefix list is a no-op" do + assert {:ok, %Outcome{changed: false}} = + IgniterCss.add_vendor_prefixes(".a { user-select: none; }", "user-select", []) + end + + test "is idempotent" do + assert_idempotent( + ".a {\n user-select: none;\n}\n", + &IgniterCss.add_vendor_prefixes(&1, "user-select", ["-webkit-", "-moz-"]) + ) + end + end + + describe "sort_properties/2" do + test "sorts declarations and moves their comments with them" do + css = ".a {\n /* about z */\n z-index: 1;\n color: red; /* about c */\n}\n" + assert {:ok, %Outcome{source: out}} = IgniterCss.sort_properties(css) + assert out == ".a {\n color: red; /* about c */\n /* about z */\n z-index: 1;\n}\n" + assert_comments_preserved(css, out) + end + + test "leaves a block it cannot rearrange safely alone, and says so" do + css = ".a { z-index: 1; color: red; }\n" + + assert {:ok, %Outcome{changed: false, diagnostics: [message]}} = + IgniterCss.sort_properties(css) + + assert message =~ "unsorted" + end + + test "is idempotent" do + assert_idempotent( + ".a {\n z-index: 1;\n color: red;\n background: blue;\n}\n", + &IgniterCss.sort_properties/1 + ) + end + end + + describe "remove_duplicates/2" do + test "drops a shadowed declaration" do + assert {:ok, %Outcome{source: ".a {\n margin: 0;\n color: blue;\n}\n"}} = + IgniterCss.remove_duplicates( + ".a {\n color: red;\n margin: 0;\n color: blue;\n}\n" + ) + end + + test "keeps an !important a later plain declaration cannot override" do + css = ".a {\n color: red !important;\n color: blue;\n}\n" + assert {:ok, %Outcome{changed: false}} = IgniterCss.remove_duplicates(css) + end + + test "drops an identical duplicated rule but keeps a differing one" do + assert {:ok, %Outcome{source: ".b {}\n\n.a {\n color: red;\n}\n"}} = + IgniterCss.remove_duplicates( + ".a {\n color: red;\n}\n\n.b {}\n\n.a {\n color: red;\n}\n" + ) + + assert {:ok, %Outcome{changed: false}} = + IgniterCss.remove_duplicates(".a { color: red; }\n.a { margin: 0; }\n") + end + + test "each half can be switched off" do + assert {:ok, %Outcome{changed: false}} = + IgniterCss.remove_duplicates(".a {\n color: red;\n color: blue;\n}\n", + declarations: false + ) + + assert {:ok, %Outcome{changed: false}} = + IgniterCss.remove_duplicates(".a { color: red; }\n.a { color: red; }\n", + rules: false + ) + end + end + + describe "queries" do + test "has_rule? is top-level and normalised" do + assert {:ok, true} = IgniterCss.has_rule?(".a > .b {}\n", ".a>.b") + assert {:ok, false} = IgniterCss.has_rule?("@media print { .b {} }\n", ".b") + end + + test "has_rule? does not match one member of a selector list" do + assert {:ok, false} = IgniterCss.has_rule?(".a, .b { color: red; }", ".a") + assert {:ok, true} = IgniterCss.has_rule?(".a, .b { color: red; }", ".a, .b") + end + + test "has_rule? does not match a substring" do + assert {:ok, false} = IgniterCss.has_rule?(".header-inner { color: red; }", ".header") + end + + test "list_selectors returns selectors as written" do + assert {:ok, [".a,\n.b", "#c"]} = + IgniterCss.list_selectors(".a,\n.b { color: red; }\n#c {}\n") + end + + test "get_declaration and has_declaration? agree" do + css = ".a { color: red; }" + assert {:ok, "red"} = IgniterCss.get_declaration(css, ".a", "color") + assert {:ok, nil} = IgniterCss.get_declaration(css, ".a", "margin") + assert {:ok, nil} = IgniterCss.get_declaration(css, ".zz", "color") + assert {:ok, true} = IgniterCss.has_declaration?(css, ".a", "color") + assert {:ok, false} = IgniterCss.has_declaration?(css, ".a", "margin") + end + + test "get_rule_declarations returns pairs in source order" do + assert {:ok, [{"color", "red"}, {"margin", "0 auto"}]} = + IgniterCss.get_rule_declarations( + ".a {\n color: red;\n margin: 0 auto;\n}\n", + ".a" + ) + + assert {:ok, nil} = IgniterCss.get_rule_declarations(".a {}", ".zz") + end + end + + describe "analyze/2" do + test "counts the basics" do + css = """ + /* c */ + @import "x"; + .a, .b { + color: red; + margin: 0 !important; + } + #c { + --x: 1; + } + @media print { + .d { color: blue; } + } + """ + + assert {:ok, a} = IgniterCss.analyze(css) + assert a.rules_count == 3 + assert a.top_level_rules_count == 2 + assert a.selectors_count == 4 + assert a.declarations_count == 4 + assert a.imports_count == 1 + assert a.media_queries_count == 1 + assert a.comments_count == 1 + assert a.important_count == 1 + assert a.custom_properties_count == 1 + end + + test "ranks properties by frequency" do + assert {:ok, a} = IgniterCss.analyze(".a { color: red; }\n.b { color: blue; margin: 0; }\n") + assert a.property_frequency == [{"color", 2}, {"margin", 1}] + end + + test "analyses an empty sheet" do + assert {:ok, %IgniterCss.Analysis{rules_count: 0, declarations_count: 0}} = + IgniterCss.analyze("") + end + end + + describe "validate/2" do + test "accepts valid css and Tailwind v4" do + assert {:ok, %{valid: true}} = IgniterCss.validate(".a { color: red; }\n") + assert {:ok, %{valid: true}} = IgniterCss.validate(fixture("tailwind_v4.css")) + end + + test "reports malformed css but confirms it still round-trips" do + assert {:error, %{valid: false, round_trips: true, diagnostics: n}} = + IgniterCss.validate(".a { color: red;\n") + + assert n > 0 + end + end + + describe "extract_*" do + test "extracts colours by selector" do + assert {:ok, [{".a", ["color: #333"]}, {".b", ["background: rgba(0,0,0,.5)"]}]} = + IgniterCss.extract_colors( + ".a {\n color: #333;\n margin: 0;\n}\n.b {\n background: rgba(0,0,0,.5);\n}\n" + ) + end + + test "does not mistake a url path for a colour" do + assert {:ok, []} = IgniterCss.extract_colors(".a { background: url(/red.png); }") + end + + test "extracts media queries with their rules" do + assert {:ok, [{"(max-width: 768px)", [{".a", [{"font-size", "14px"}]}]}]} = + IgniterCss.extract_media_queries( + "@media (max-width: 768px) {\n .a {\n font-size: 14px;\n }\n}\n" + ) + end + + test "extracts animations and their users" do + css = """ + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + .a { animation: fade-in 1s; } + """ + + assert {:ok, [animation]} = IgniterCss.extract_animations(css) + assert animation.name == "fade-in" + assert animation.used_by == [".a"] + assert animation.keyframes == [{"from", [{"opacity", "0"}]}, {"to", [{"opacity", "1"}]}] + end + end + + describe "refusing to patch" do + test "unbalanced braces are rejected rather than half-edited" do + for source <- [".broken {\n color: red;\n", ".a {}\n}\n", "}"] do + assert {:error, reason} = IgniterCss.ensure_rule(source, ".probe") + assert reason =~ "unbalanced" + end + end + + test "analysis still works on a file we would refuse to patch" do + assert {:ok, _} = IgniterCss.analyze(".broken {\n color: red;\n") + assert {:error, %{round_trips: true}} = IgniterCss.validate(".broken {\n color: red;\n") + end + end + + describe "option handling" do + test "line comments are tolerated by default" do + assert {:ok, %{valid: true}} = IgniterCss.validate("// hi\n.a { color: red; }\n") + end + + test "line comments can be rejected explicitly" do + assert {:error, %{valid: false}} = + IgniterCss.validate("// hi\n.a { color: red; }\n", + allow_wrong_line_comments: false + ) + end + end end diff --git a/test/parsers/css/formatter_test.exs b/test/parsers/css/formatter_test.exs new file mode 100644 index 0000000..d7dc2d5 --- /dev/null +++ b/test/parsers/css/formatter_test.exs @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCssTest.Parsers.Css.FormatterTest do + use IgniterCss.CssCase, async: true + + doctest IgniterCss.Parsers.Formatter + + alias IgniterCss.Parsers.Formatter + + test "formats a minified stylesheet" do + assert {:ok, :format, ".a {\n color: red;\n margin: 0;\n}\n"} = + Formatter.format(".a{color:red;margin:0}") + end + + test "formatting keeps comments" do + css = "/* head */.a{color:red}" + assert {:ok, _, formatted} = Formatter.format(css) + assert_comments_preserved(css, formatted) + end + + test "reports an already formatted stylesheet" do + assert {:ok, :is_formatted, true} = Formatter.is_formatted(".a {\n color: red;\n}\n") + end + + test "reports an unformatted stylesheet" do + assert {:error, :is_formatted, false} = Formatter.is_formatted(".a{color:red}") + end + + test "formatting is idempotent" do + assert {:ok, _, once} = Formatter.format(".a{color:red}@media print{.b{margin:0}}") + assert {:ok, _, ^once} = Formatter.format(once) + assert {:ok, _, true} = Formatter.is_formatted(once) + end + + test "formats from a file path" do + path = + Path.join(System.tmp_dir!(), "igniter_css_fmt_#{System.unique_integer([:positive])}.css") + + File.write!(path, ".a{color:red}") + + try do + assert {:ok, _, ".a {\n color: red;\n}\n"} = Formatter.format(path, :path) + after + File.rm(path) + end + end +end diff --git a/test/parsers/css/parser_test.exs b/test/parsers/css/parser_test.exs index 53dfbc1..792d23b 100644 --- a/test/parsers/css/parser_test.exs +++ b/test/parsers/css/parser_test.exs @@ -3,13 +3,21 @@ # SPDX-License-Identifier: MIT defmodule IgniterCssTest.Parsers.Css.ParserTest do - use ExUnit.Case + @moduledoc """ + The compatibility surface: same function names and the same + `{:ok, :function_name, result}` shape the Python implementation used, now + backed by the Rust parser. + """ + + use IgniterCss.CssCase, async: true + + doctest IgniterCss.Parsers.Parser + alias IgniterCss.Parsers.Parser - describe "add_hide_scrollbar_property/1" do - test "adds display: none to existing .hide-scrollbar class" do - # Given: CSS with .hide-scrollbar class but without display: none - css_code = """ + describe "add_hide_scrollbar_property/2" do + test "adds display: none to an existing .hide-scrollbar class" do + css = """ .header { color: blue; } @@ -19,3496 +27,469 @@ defmodule IgniterCssTest.Parsers.Css.ParserTest do } """ - # When: Adding hide-scrollbar property - {:ok, _, result} = Parser.add_hide_scrollbar_property(css_code) - - # Then: The .hide-scrollbar class should have display: none added - assert String.contains?(result, ".hide-scrollbar") - assert String.contains?(result, "display: none") - # Original property preserved - assert String.contains?(result, "scrollbar-width: none") - # Other selectors preserved - assert String.contains?(result, ".header") - end - - test "creates .hide-scrollbar class when it doesn't exist" do - # Given: CSS without .hide-scrollbar class - css_code = """ - .header { - color: blue; - } + assert {:ok, :add_hide_scrollbar_property, result} = + Parser.add_hide_scrollbar_property(css) - .content { - padding: 20px; - } - """ + assert result == """ + .header { + color: blue; + } - # When: Adding hide-scrollbar property - {:ok, _, result} = Parser.add_hide_scrollbar_property(css_code) + .hide-scrollbar { + scrollbar-width: none; /* Firefox */ + display: none; + } + """ - # Then: The .hide-scrollbar class should be created with display: none - assert String.contains?(result, ".hide-scrollbar") - assert String.contains?(result, "display: none") - # Original content preserved - assert String.contains?(result, ".header") - # Original content preserved - assert String.contains?(result, ".content") + assert_comments_preserved(css, result) end - test "updates existing display property in .hide-scrollbar class" do - # Given: CSS with .hide-scrollbar class that has a different display value - css_code = """ - .hide-scrollbar { - display: flex; - } - """ - - # When: Adding hide-scrollbar property - {:ok, _, result} = Parser.add_hide_scrollbar_property(css_code) + test "creates the class when it does not exist" do + css = ".header {\n color: blue;\n}\n" + assert {:ok, _, result} = Parser.add_hide_scrollbar_property(css) + assert result == ".header {\n color: blue;\n}\n\n.hide-scrollbar {\n display: none;\n}\n" + end - # Then: The display property should be updated to none - assert String.contains?(result, ".hide-scrollbar") - assert String.contains?(result, "display: none") - # Old value removed - refute String.contains?(result, "display: flex") + test "updates an existing display property" do + css = ".hide-scrollbar {\n display: block;\n}\n" + assert {:ok, _, result} = Parser.add_hide_scrollbar_property(css) + assert result == ".hide-scrollbar {\n display: none;\n}\n" end test "works with empty CSS" do - # Given: Empty CSS - css_code = "" - - # When: Adding hide-scrollbar property - {:ok, _, result} = Parser.add_hide_scrollbar_property(css_code) - - # Then: The .hide-scrollbar class should be created with display: none - assert String.contains?(result, ".hide-scrollbar") - assert String.contains?(result, "display: none") + assert {:ok, _, ".hide-scrollbar {\n display: none;\n}\n"} = + Parser.add_hide_scrollbar_property("") end - test "handles CSS with comments" do - # Given: CSS with comments - css_code = """ - /* Header styles */ - .header { - color: blue; - } - - /* This class hides scrollbars */ - .hide-scrollbar { - /* Firefox */ - scrollbar-width: none; - } - """ + test "is idempotent" do + css = ".hide-scrollbar {\n scrollbar-width: none;\n}\n" + assert {:ok, _, once} = Parser.add_hide_scrollbar_property(css) + assert {:ok, _, twice} = Parser.add_hide_scrollbar_property(once) + assert once == twice + end - # When: Adding hide-scrollbar property - {:ok, _, result} = Parser.add_hide_scrollbar_property(css_code) + test "reads from a file path" do + path = Path.join(System.tmp_dir!(), "igniter_css_#{System.unique_integer([:positive])}.css") + File.write!(path, ".hide-scrollbar {\n scrollbar-width: none;\n}\n") - # Then: Comments should be preserved and display: none added - assert String.contains?(result, "/* Header styles */") - assert String.contains?(result, "/* This class hides scrollbars */") - assert String.contains?(result, "/* Firefox */") - assert String.contains?(result, ".hide-scrollbar") - assert String.contains?(result, "display: none") + try do + assert {:ok, _, result} = Parser.add_hide_scrollbar_property(path, :path) + assert result =~ "display: none;" + after + File.rm(path) + end + end - {:error, _, "Failed to parse CSS: Can not serialize "} = - assert Parser.add_hide_scrollbar_property("1") + test "rejects a path that is not a stylesheet" do + assert {:error, _, "Invalid file path or format."} = + Parser.add_hide_scrollbar_property("/nope/nothing.txt", :path) end end - describe "add_vendor_prefixes/3" do - test "adds vendor prefixes to existing property" do - # Given: CSS with user-select property - css_code = """ - .selectable { - user-select: none; - color: blue; - } - """ + describe "add_vendor_prefixes/4" do + test "adds prefixes to an existing property" do + css = ".a {\n user-select: none;\n}\n" - # When: Adding vendor prefixes - prefixes = ["-webkit-", "-moz-", "-ms-"] - {:ok, _, result} = Parser.add_vendor_prefixes(css_code, "user-select", prefixes) + assert {:ok, _, result} = + Parser.add_vendor_prefixes(css, "user-select", ["-webkit-", "-ms-"]) - # Then: Prefixed properties should be added - assert String.contains?(result, "-webkit-user-select: none") - assert String.contains?(result, "-moz-user-select: none") - assert String.contains?(result, "-ms-user-select: none") - # Original property should be preserved - assert String.contains?(result, "user-select: none") - # Other properties should be preserved - assert String.contains?(result, "color: blue") + assert result == + ".a {\n -webkit-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n" end - test "does nothing when property doesn't exist" do - # Given: CSS without the target property - css_code = """ - .header { - color: blue; - font-size: 16px; - } - """ - - # When: Adding vendor prefixes for a non-existent property - prefixes = ["-webkit-", "-moz-"] - {:ok, _, result} = Parser.add_vendor_prefixes(css_code, "user-select", prefixes) - - # Then: CSS should remain unchanged - assert elem(Parser.beautify(result), 2) == elem(Parser.beautify(css_code), 2) + test "does nothing when the property is absent" do + css = ".a {\n color: red;\n}\n" + assert {:ok, _, ^css} = Parser.add_vendor_prefixes(css, "user-select", ["-webkit-"]) end - test "handles multiple occurrences of the property" do - # Given: CSS with multiple elements having the same property - css_code = """ - .one { - user-select: none; - } - .two { - user-select: text; - } - """ - - # When: Adding vendor prefixes - prefixes = ["-webkit-", "-moz-"] - {:ok, _, result} = Parser.add_vendor_prefixes(css_code, "user-select", prefixes) - - # Then: All occurrences should be prefixed - assert String.contains?(result, ".one {") - assert String.contains?(result, "-webkit-user-select: none") - assert String.contains?(result, "-moz-user-select: none") - assert String.contains?(result, "user-select: none") - - assert String.contains?(result, ".two {") - assert String.contains?(result, "-webkit-user-select: text") - assert String.contains?(result, "-moz-user-select: text") - assert String.contains?(result, "user-select: text") + test "handles every occurrence, including inside media queries" do + css = ".a { user-select: none; }\n@media print {\n .b {\n user-select: text;\n }\n}\n" + assert {:ok, _, result} = Parser.add_vendor_prefixes(css, "user-select", ["-webkit-"]) + assert result =~ "-webkit-user-select: none;" + assert result =~ "-webkit-user-select: text;" end - test "works with empty prefixes list" do - # Given: CSS with a property - css_code = """ - .box { - user-select: none; - } - """ - - # When: Adding an empty list of prefixes - prefixes = [] - {:ok, _, result} = Parser.add_vendor_prefixes(css_code, "user-select", prefixes) - - # Then: CSS should remain unchanged - assert elem(Parser.beautify(result), 2) == elem(Parser.beautify(css_code), 2) + test "works with an empty prefix list" do + css = ".a {\n user-select: none;\n}\n" + assert {:ok, _, ^css} = Parser.add_vendor_prefixes(css, "user-select", []) end test "preserves !important flags" do - # Given: CSS with !important - css_code = """ - .important { - user-select: none !important; - } - """ - - # When: Adding vendor prefixes - prefixes = ["-webkit-", "-moz-"] - {:ok, _, result} = Parser.add_vendor_prefixes(css_code, "user-select", prefixes) - - # Then: !important should be preserved in all versions - assert String.contains?(result, "-webkit-user-select: none !important") - assert String.contains?(result, "-moz-user-select: none !important") - assert String.contains?(result, "user-select: none !important") + css = ".a {\n user-select: none !important;\n}\n" + assert {:ok, _, result} = Parser.add_vendor_prefixes(css, "user-select", ["-webkit-"]) + assert result =~ "-webkit-user-select: none !important;" + assert result =~ "user-select: none !important;" end - test "handles CSS with comments" do - # Given: CSS with comments - css_code = """ - /* Header styles */ - .header { - /* Prevent selection */ - user-select: none; - } - """ + test "preserves comments" do + css = ".a {\n /* no selection */\n user-select: none; /* anywhere */\n}\n" + assert {:ok, _, result} = Parser.add_vendor_prefixes(css, "user-select", ["-webkit-"]) + assert_comments_preserved(css, result) + end - # When: Adding vendor prefixes - prefixes = ["-webkit-", "-moz-"] - {:ok, _, result} = Parser.add_vendor_prefixes(css_code, "user-select", prefixes) + test "refuses invalid CSS rather than half-editing it" do + assert {:error, _, reason} = + Parser.add_vendor_prefixes(".a { user-select: none;", "user-select", ["-webkit-"]) - # Then: Comments should be preserved - assert String.contains?(result, "/* Header styles */") - assert String.contains?(result, "/* Prevent selection */") - assert String.contains?(result, "-webkit-user-select: none") - assert String.contains?(result, "-moz-user-select: none") + assert reason =~ "unbalanced" end + end - test "handles properties with multiple values" do - # Given: CSS with property having multiple values - css_code = """ - .complex { - transform: translateX(10px) rotate(45deg); - } - """ - - # When: Adding vendor prefixes - prefixes = ["-webkit-", "-moz-"] - {:ok, _, result} = Parser.add_vendor_prefixes(css_code, "transform", prefixes) + describe "modify_property/6" do + test "changes a property value" do + assert {:ok, :modify_property, ".a { color: blue; }"} = + Parser.modify_property(".a { color: red; }", ".a", "color", "blue", false) + end - # Then: Complex values should be preserved - assert String.contains?(result, "-webkit-transform: translateX(10px) rotate(45deg)") - assert String.contains?(result, "-moz-transform: translateX(10px) rotate(45deg)") - assert String.contains?(result, "transform: translateX(10px) rotate(45deg)") + test "marks a property important" do + assert {:ok, _, ".a { color: blue !important; }"} = + Parser.modify_property(".a { color: red; }", ".a", "color", "blue", true) end - test "handles media queries" do - # Given: CSS with media queries - css_code = """ - @media (max-width: 768px) { - .mobile { - user-select: none; - } - } - """ + test "adds the property when the rule lacks it" do + assert {:ok, _, ".a {\n color: red;\n margin: 0;\n}\n"} = + Parser.modify_property(".a {\n color: red;\n}\n", ".a", "margin", "0", false) + end - # When: Adding vendor prefixes - prefixes = ["-webkit-", "-moz-"] - {:ok, _, result} = Parser.add_vendor_prefixes(css_code, "user-select", prefixes) + test "creates the rule when the selector is absent" do + assert {:ok, _, ".a {}\n\n.b {\n color: red;\n}\n"} = + Parser.modify_property(".a {}\n", ".b", "color", "red", false) + end - # Then: Properties inside media queries should be prefixed - assert String.contains?(result, "@media (max-width: 768px)") - assert String.contains?(result, "-webkit-user-select: none") - assert String.contains?(result, "-moz-user-select: none") + test "preserves a trailing comment on the modified line" do + css = ".a {\n color: red; /* brand */\n}\n" + assert {:ok, _, result} = Parser.modify_property(css, ".a", "color", "blue", false) + assert result == ".a {\n color: blue; /* brand */\n}\n" end - test "handles invalid CSS" do - # Given: Invalid CSS - css_code = "invalid { css syntax" - # When: Adding vendor prefixes - prefixes = ["-webkit-", "-moz-"] - {:error, _, error_message} = Parser.add_vendor_prefixes(css_code, "user-select", prefixes) + test "refuses an ambiguous selector" do + assert {:error, _, reason} = + Parser.modify_property(".a {}\n.a {}\n", ".a", "color", "red", false) - # Then: Should return an error - assert String.contains?(error_message, "Failed to parse CSS") + assert reason =~ "refusing to guess" end end - describe "analyze_css/1" do - test "returns analysis for valid CSS with multiple selectors" do - # Given: CSS with multiple selectors, properties, and values - css_code = """ - .header { - color: #333; - font-size: 16px; - } - - #main-content { - margin: 0 auto; - width: 100%; - max-width: 1200px; - } - - .footer { - background-color: #f5f5f5; - padding: 20px; - } - """ - - # When: Analyzing the CSS - {:ok, _, result} = Parser.analyze_css(css_code) - - # Then: Result should include comprehensive analysis - assert is_map(result) - assert result["selectors_count"] == 3 - assert result["properties_count"] >= 7 - assert is_list(result["selectors"]) - assert ".header" in result["selectors"] - assert "#main-content" in result["selectors"] - assert ".footer" in result["selectors"] - - # Check for specific property values - assert ".header" in Map.keys(result["selector_properties"]) - assert "#333" in Map.values(result["selector_properties"][".header"]) - end - - test "handles CSS with media queries" do - # Given: CSS with media queries - css_code = """ - @media (max-width: 768px) { - .mobile { - display: block; - font-size: 14px; - } - } - - @media print { - .no-print { - display: none; - } - } - """ - - # When: Analyzing the CSS - {:ok, _, result} = Parser.analyze_css(css_code) - - # Then: Media queries should be properly analyzed - assert is_map(result) - - assert result["media_queries_count"] == 2 + describe "remove_selector/3" do + test "removes a selector and its block" do + assert {:ok, :remove_selector, ".a {}\n"} = + Parser.remove_selector(".a {}\n.unused {\n color: red;\n}\n", ".unused") end - test "analyzes CSS with complex selectors" do - # Given: CSS with complex selectors - css_code = """ - .parent > .child { - color: blue; - } - - .sibling + .adjacent { - margin-left: 10px; - } - - ul li:hover { - background-color: #f0f0f0; - } - - input[type="text"] { - border: 1px solid #ccc; - } - """ - - # When: Analyzing the CSS - {:ok, _, result} = Parser.analyze_css(css_code) - - # Then: Complex selectors should be analyzed correctly - assert is_map(result) - assert result["selectors_count"] == 4 - assert ".parent > .child" in result["selectors"] - assert ".sibling + .adjacent" in result["selectors"] - assert "ul li:hover" in result["selectors"] - - assert "input[type=\"text\"]" in result["selectors"] + test "leaves other selectors alone" do + css = ".a { color: red; }\n.b { color: blue; }\n.c { color: green; }\n" + assert {:ok, _, result} = Parser.remove_selector(css, ".b") + assert result == ".a { color: red; }\n.c { color: green; }\n" end - test "analyzes CSS for color usage" do - # Given: CSS with various color formats - css_code = """ - .hex-color { - color: #ff0000; - } - - .rgb-color { - color: rgb(0, 128, 255); - } - - .rgba-color { - background-color: rgba(255, 255, 255, 0.8); - } - - .named-color { - border-color: blue; - } - """ - - # When: Analyzing the CSS - {:ok, _, result} = Parser.analyze_css(css_code) - - # Then: Color analysis should be included - - assert result["colors_used"] >= 4 + test "removing an absent selector changes nothing" do + css = ".a {}\n" + assert {:ok, _, ^css} = Parser.remove_selector(css, ".zz") end - test "analyzes empty CSS" do - # Given: Empty CSS - css_code = "" - - # When: Analyzing the CSS - {:ok, _, result} = Parser.analyze_css(css_code) - - # Then: Analysis should handle empty CSS gracefully - assert is_map(result) - assert result["selectors_count"] == 0 - assert result["properties_count"] == 0 - assert Enum.empty?(result["selectors"]) + test "keeps a section header above the removed rule" do + css = "/* ===== Utils ===== */\n.b {}\n.c {}\n" + assert {:ok, _, "/* ===== Utils ===== */\n.c {}\n"} = Parser.remove_selector(css, ".b") end + end - test "analyzes CSS with comments" do - css_code = """ - /* Header styles */ - .header { - color: black; - } - - /* Main content area */ - .content { - /* Inner padding */ - padding: 20px; - } - """ - - # When: Analyzing the CSS - {:ok, _, result} = Parser.analyze_css(css_code) - - # Then: Comments should be properly analyzed - assert result["comments_count"] >= 2 + describe "replace_selector_rule/4" do + test "replaces the declarations of a rule" do + assert {:ok, :replace_selector_rule, ".a { color: blue; font-size: 20px; }"} = + Parser.replace_selector_rule( + ".a { color: red; }", + ".a", + "color: blue; font-size: 20px;" + ) end - test "handles invalid CSS" do - # Given: Invalid CSS - css_code = ".invalid { color: red; missing-closing-brace;" - - # When: Analyzing the CSS - {:error, _, error_message} = Parser.analyze_css(css_code) - - # Then: Should return an error - assert is_binary(error_message) - assert String.contains?(error_message, "Failed to parse CSS") + test "leaves the rest of the file untouched" do + css = "/* head */\n.a {\n color: red;\n}\n/* tail */\n.b {}\n" + assert {:ok, _, result} = Parser.replace_selector_rule(css, ".a", "color: blue") + assert result == "/* head */\n.a {\n color: blue;\n}\n/* tail */\n.b {}\n" + assert_comments_preserved(css, result) end - test "analyzes CSS with imports" do - # Given: CSS with import statements - css_code = """ - @import url('fonts.css'); - @import 'typography.css' screen and (min-width: 800px); - - .content { - font-family: 'Open Sans', sans-serif; - } - """ - - # When: Analyzing the CSS - {:ok, _, result} = Parser.analyze_css(css_code) - - # Then: Import statements should be analyzed - assert result["imports_count"] == 2 - assert "fonts.css" in result["imports"] - assert "typography.css" in result["imports"] - - # Check media queries for imports - assert is_map(result["import_media_queries"]) - assert "typography.css" in Map.keys(result["import_media_queries"]) - assert "screen and (min-width: 800px)" in Map.values(result["import_media_queries"]) + test "errors on a missing selector" do + assert {:error, _, reason} = Parser.replace_selector_rule(".a {}", ".zz", "color: red") + assert reason =~ "not found" end end - describe "extract_colors/1" do - test "extracts hex color values" do - # Given: CSS with hex color values - css_code = """ - .header { - color: #333; - background-color: #f5f5f5; - } - .button { - color: #fff; - background-color: #007bff; - } - """ - - # When: Extracting colors - {:ok, _, result} = Parser.extract_colors(css_code) - - # Then: Colors should be properly extracted and organized by selector - assert is_map(result) - assert Map.has_key?(result, ".header") - assert Map.has_key?(result, ".button") - - assert "color: #333" in result[".header"] - assert "background-color: #f5f5f5" in result[".header"] - assert "color: #fff" in result[".button"] - assert "background-color: #007bff" in result[".button"] + describe "add_import/4 and remove_import/3" do + test "adds an import with no media query" do + assert {:ok, :add_import, ~s|@import "styles.css";\n|} = + Parser.add_import("", "styles.css", false) end - test "extracts rgb and rgba color values" do - # Given: CSS with RGB and RGBA color values - css_code = """ - .container { - color: rgb(51, 51, 51); - background-color: rgba(255, 255, 255, 0.8); - } - .overlay { - background-color: rgba(0, 0, 0, 0.5); - } - """ - - # When: Extracting colors - {:ok, _, result} = Parser.extract_colors(css_code) + test "adds an import with a media query" do + assert {:ok, _, ~s|@import "mobile.css" screen and (max-width: 768px);\n|} = + Parser.add_import("", "mobile.css", "screen and (max-width: 768px)") + end - # Then: RGB and RGBA colors should be properly extracted - assert is_map(result) - assert Map.has_key?(result, ".container") - assert Map.has_key?(result, ".overlay") + test "does not add a duplicate import" do + css = ~s|@import "styles.css";\n| + assert {:ok, _, ^css} = Parser.add_import(css, "styles.css", false) + end - assert "color: rgb(51, 51, 51)" in result[".container"] - assert "background-color: rgba(255, 255, 255, 0.8)" in result[".container"] - assert "background-color: rgba(0, 0, 0, 0.5)" in result[".overlay"] + test "places the import before existing rules" do + assert {:ok, _, result} = Parser.add_import(".a { color: red; }\n", "x.css", false) + assert result == ~s|@import "x.css";\n.a { color: red; }\n| end - test "extracts named color values" do - # Given: CSS with named color values - css_code = """ - .success { - color: green; - } - .error { - color: red; - } - .info { - color: blue; - background-color: white; - } - """ + test "removes a matching import" do + css = ~s|@import "a.css";\n@import "b.css";\n.x {}\n| - # When: Extracting colors - {:ok, _, result} = Parser.extract_colors(css_code) + assert {:ok, :remove_import, ~s|@import "b.css";\n.x {}\n|} = + Parser.remove_import(css, "a.css") + end - # Then: Named colors should be properly extracted - assert is_map(result) - assert "color: green" in result[".success"] - assert "color: red" in result[".error"] - assert "color: blue" in result[".info"] - assert "background-color: white" in result[".info"] + test "removing an absent import changes nothing" do + css = ~s|@import "b.css";\n| + assert {:ok, _, ^css} = Parser.remove_import(css, "a.css") end + end - test "extracts hsl and hsla color values" do - # Given: CSS with HSL and HSLA color values - css_code = """ - .hsl-colors { - color: hsl(120, 100%, 50%); - background-color: hsla(240, 100%, 50%, 0.5); - } - """ + describe "sort_properties/2" do + test "sorts properties alphabetically" do + css = ".a {\n font-size: 16px;\n background: #fff;\n color: #333;\n}\n" + assert {:ok, :sort_properties, result} = Parser.sort_properties(css) + assert result == ".a {\n background: #fff;\n color: #333;\n font-size: 16px;\n}\n" + end - # When: Extracting colors - {:ok, _, result} = Parser.extract_colors(css_code) + test "leaves an already sorted sheet alone" do + css = ".a {\n background: #fff;\n color: #333;\n}\n" + assert {:ok, _, ^css} = Parser.sort_properties(css) + end - # Then: HSL and HSLA colors should be properly extracted - assert is_map(result) - assert "color: hsl(120, 100%, 50%)" in result[".hsl-colors"] - assert "background-color: hsla(240, 100%, 50%, 0.5)" in result[".hsl-colors"] + test "preserves comments" do + css = ".a {\n /* z */\n z-index: 1;\n color: red;\n}\n" + assert {:ok, _, result} = Parser.sort_properties(css) + assert_comments_preserved(css, result) end + end - test "extracts colors from shorthand properties" do - # Given: CSS with shorthand properties containing colors - css_code = """ - .shorthand { - border: 1px solid #ccc; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); - } - """ + describe "remove_duplicates/2" do + test "drops declarations a later one shadows" do + assert {:ok, :remove_duplicates, ".a {\n color: blue;\n}\n"} = + Parser.remove_duplicates(".a {\n color: red;\n color: blue;\n}\n") + end - # When: Extracting colors - {:ok, _, result} = Parser.extract_colors(css_code) + test "drops an identical duplicated rule" do + assert {:ok, _, ".b {}\n\n.a { color: red; }\n"} = + Parser.remove_duplicates(".a { color: red; }\n\n.b {}\n\n.a { color: red; }\n") + end - # Then: Colors in shorthand properties should be extracted - assert is_map(result) - assert Map.has_key?(result, ".shorthand") + test "keeps rules that differ" do + css = ".a { color: red; }\n.a { margin: 0; }\n" + assert {:ok, _, ^css} = Parser.remove_duplicates(css) + end + end - assert Enum.any?(result[".shorthand"], fn color -> - String.contains?(color, "#ccc") and String.contains?(color, "border") - end) + describe "minify/2 and beautify/2" do + test "minifies a stylesheet" do + css = ".header {\n color: #333;\n background: #fff;\n}\n\n.footer {\n color: #000;\n}\n" - assert Enum.any?(result[".shorthand"], fn color -> - String.contains?(color, "rgba(0, 0, 0, 0.3)") and - String.contains?(color, "box-shadow") - end) + assert {:ok, :minify, ".header{color:#333;background:#fff}.footer{color:#000}"} = + Parser.minify(css) end - test "extracts colors from nested selectors" do - # Given: CSS with nested selectors (e.g., media queries) - css_code = """ - @media (max-width: 768px) { - .mobile { - color: #555; - background-color: #eee; - } - } - """ + test "minifying removes comments" do + assert {:ok, _, ".a{color:red}"} = Parser.minify("/* c */\n.a { color: red; /* d */ }\n") + end - # When: Extracting colors - {:ok, _, result} = Parser.extract_colors(css_code) + test "beautifies a minified stylesheet" do + assert {:ok, :beautify, ".a {\n color: red;\n background: #fff;\n}\n"} = + Parser.beautify(".a{color:red;background:#fff}") + end - # Then: Colors from nested selectors should be properly extracted - assert is_map(result) - assert Map.has_key?(result, ".mobile") - assert "color: #555" in result[".mobile"] - assert "background-color: #eee" in result[".mobile"] + test "beautifying keeps comments" do + css = "/* head */.a{color:red}" + assert {:ok, _, result} = Parser.beautify(css) + assert_comments_preserved(css, result) end - test "handles CSS with no colors" do - # Given: CSS without any color properties - css_code = """ - .no-colors { - display: block; - margin: 10px; - padding: 20px; - } - """ + test "minify and beautify are inverse enough to round-trip meaning" do + css = ".a{color:red;margin:0 auto}.b,.c>.d{padding:0}" + assert {:ok, _, pretty} = Parser.beautify(css) + assert {:ok, _, back} = Parser.minify(pretty) + assert back == css + end + end - # When: Extracting colors - {:ok, _, result} = Parser.extract_colors(css_code) + describe "merge_stylesheets/1" do + test "merges two stylesheets" do + assert {:ok, :merge_stylesheets, ".a { color: red; }\n\n.b { color: blue; }\n"} = + Parser.merge_stylesheets([".a { color: red; }", ".b { color: blue; }"]) + end - # Then: Result should be an empty map - assert is_map(result) - assert Enum.empty?(result) + test "drops an identical repeated rule" do + assert {:ok, _, ".a { color: red; }\n"} = + Parser.merge_stylesheets([".a { color: red; }", ".a { color: red; }"]) end - test "handles invalid CSS" do - # Given: Invalid CSS - css_code = ".invalid { color: red; missing-closing-brace;" + test "keeps a later rule that overrides" do + assert {:ok, _, result} = + Parser.merge_stylesheets([".a { color: red; }", ".a { color: blue; }"]) - # When: Extracting colors - {:error, _, error_message} = Parser.extract_colors(css_code) + assert result =~ "color: red" + assert result =~ "color: blue" + end - # Then: Should return an error - assert is_binary(error_message) - assert String.contains?(error_message, "Failed to parse CSS") + test "merging nothing gives an empty string" do + assert {:ok, _, ""} = Parser.merge_stylesheets([]) end end - describe "minify/1" do - test "minifies CSS by removing whitespace and comments" do - # Given: CSS with whitespace and comments - css_code = """ - /* Header styles */ + describe "analyze_css/2" do + test "returns analysis for a multi-selector stylesheet" do + css = """ .header { - color: blue; - font-size: 16px; - } - - /* Content area */ - .content { - padding: 20px; - margin: 10px; - } - """ - - # When: Minifying the CSS - {:ok, _, result} = Parser.minify(css_code) - - # Then: Result should be minified without whitespace and comments - assert !String.contains?(result, "/* Header styles */") - assert !String.contains?(result, "\n") - assert String.contains?(result, ".header{color:blue;font-size:16px;}") - assert String.contains?(result, ".content{padding:20px;margin:10px;}") - end - - test "preserves functionality while minifying" do - # Given: CSS with various properties - css_code = """ - .button { - display: inline-block; - background-color: #007bff; - color: white; - padding: 10px 15px; - border-radius: 4px; + color: #333; + background: #fff; } - """ - # When: Minifying the CSS - {:ok, _, result} = Parser.minify(css_code) - - # Then: All properties should be preserved in minified form - assert String.contains?(result, "display:inline-block") - assert String.contains?(result, "background-color:#007bff") - assert String.contains?(result, "color:white") - assert String.contains?(result, "padding:10px 15px") - assert String.contains?(result, "border-radius:4px") - end - - test "handles CSS with media queries" do - # Given: CSS with media queries - css_code = """ - @media (max-width: 768px) { - .mobile { - display: block; - width: 100%; - } + .footer { + color: #000; } """ - # When: Minifying the CSS - {:ok, _, result} = Parser.minify(css_code) - - # Then: Media queries should be properly minified - assert String.contains?( - result, - "@media (max-width: 768px){.mobile{display:block;width:100%;}}" - ) - - assert String.contains?(result, ".mobile{display:block;width:100%;}") + assert {:ok, :analyze_css, stats} = Parser.analyze_css(css) + assert stats["rules_count"] == 2 + assert stats["declarations_count"] == 3 + # `color` twice plus `background` once. + assert stats["unique_properties"] == 2 + assert stats["colors_count"] == 3 + assert stats["property_frequency"]["color"] == 2 end - test "handles CSS with vendor prefixes" do - # Given: CSS with vendor prefixes - css_code = """ - .box { - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - } - """ - - # When: Minifying the CSS - {:ok, _, result} = Parser.minify(css_code) - - # Then: Vendor prefixes should be preserved - assert String.contains?(result, "-webkit-border-radius:4px") - assert String.contains?(result, "-moz-border-radius:4px") - assert String.contains?(result, "border-radius:4px") + test "counts media queries and imports" do + css = ~s|@import "a";\n@media print {\n .a { color: red; }\n}\n| + assert {:ok, _, stats} = Parser.analyze_css(css) + assert stats["imports_count"] == 1 + assert stats["media_queries_count"] == 1 end - test "handles @import and other at-rules" do - # Given: CSS with at-rules - css_code = """ - @import url('fonts.css'); - @charset "UTF-8"; - @keyframes fade { - from { opacity: 0; } - to { opacity: 1; } - } - """ - - # When: Minifying the CSS - {:ok, _, result} = Parser.minify(css_code) - - # Then: At-rules should be properly minified - assert String.contains?(result, "@import url(\"fonts.css\")") - assert String.contains?(result, "@charset \"UTF-8\"") - assert String.contains?(result, "@keyframes fade{from{opacity:0;}to{opacity:1;}}") + test "analyses an empty stylesheet" do + assert {:ok, _, stats} = Parser.analyze_css("") + assert stats["rules_count"] == 0 + assert stats["declarations_count"] == 0 end - test "handles empty CSS" do - # Given: Empty CSS - css_code = "" - - # When: Minifying the CSS - {:ok, _, result} = Parser.minify(css_code) - - # Then: Result should be empty - assert result == "" + test "counts comments" do + assert {:ok, _, stats} = Parser.analyze_css("/* a */\n.x { /* b */ color: red; }\n") + assert stats["comments_count"] == 2 end end - describe "modify_property/5" do - test "modifies existing property value for selector" do - # Given: CSS with a selector and property - css_code = """ - .header { - color: red; - font-size: 16px; - } - """ + describe "extract_colors/2" do + test "groups colours by selector" do + css = + ".header {\n color: #333;\n background-color: white;\n}\n.footer {\n color: rgba(0, 0, 0, 0.8);\n}\n" - # When: Modifying the color property - {:ok, _, result} = Parser.modify_property(css_code, ".header", "color", "blue", false) + assert {:ok, :extract_colors, colors} = Parser.extract_colors(css) - # Then: The property value should be updated - assert String.contains?(result, "color: blue") - assert !String.contains?(result, "color: red") - # Other properties should remain unchanged - assert String.contains?(result, "font-size: 16px") + assert colors == %{ + ".header" => ["color: #333", "background-color: white"], + ".footer" => ["color: rgba(0, 0, 0, 0.8)"] + } end - test "adds property if it doesn't exist for the selector" do - # Given: CSS with a selector but without the target property - css_code = """ - .header { - font-size: 16px; - } - """ - - # When: Modifying a non-existent property - {:ok, _, result} = Parser.modify_property(css_code, ".header", "color", "blue", false) - - # Then: The new property should be added - assert String.contains?(result, "color: blue") - # Existing properties should be preserved - assert String.contains?(result, "font-size: 16px") + test "ignores declarations that carry no colour" do + assert {:ok, _, %{}} = Parser.extract_colors(".a { margin: 0; display: flex; }") end + end - test "adds selector and property if selector doesn't exist" do - # Given: CSS without the target selector - css_code = """ - .content { - padding: 20px; - } - """ + describe "extract_media_queries/2" do + test "returns rules keyed by query" do + css = "@media (max-width: 768px) {\n .header {\n font-size: 14px;\n }\n}\n" - # When: Modifying a property for a non-existent selector - {:ok, _, result} = Parser.modify_property(css_code, ".header", "color", "blue", false) + assert {:ok, :extract_media_queries, queries} = Parser.extract_media_queries(css) - # Then: The new selector and property should be added - assert String.contains?(result, ".header") - assert String.contains?(result, "color: blue") - # Existing content should be preserved - assert String.contains?(result, ".content") - assert String.contains?(result, "padding: 20px") + assert queries == %{ + "(max-width: 768px)" => [ + %{"selector" => ".header", "properties" => %{"font-size" => "14px"}} + ] + } end - test "adds !important flag when specified" do - # Given: CSS with a selector and property - css_code = """ - .header { - color: red; - } - """ - - # When: Modifying property with important flag - {:ok, _, result} = Parser.modify_property(css_code, ".header", "color", "blue", true) - - # Then: The property should be updated with !important - assert String.contains?(result, "color: blue!important;") + test "a stylesheet without media queries yields an empty map" do + assert {:ok, _, %{}} = Parser.extract_media_queries(".a {}\n") end + end - test "removes !important flag when not specified" do - # Given: CSS with a property having !important flag - css_code = """ - .header { - color: red !important; + describe "extract_animations/2" do + test "returns keyframes and users" do + css = """ + @keyframes fade-in { + 0% { opacity: 0; } + 100% { opacity: 1; } } - """ - # When: Modifying property without important flag - {:ok, _, result} = Parser.modify_property(css_code, ".header", "color", "blue", false) - - # Then: The property should be updated without !important - assert String.contains?(result, "color: blue") - assert !String.contains?(result, "!important") - end - - test "handles selectors with pseudo-classes" do - # Given: CSS with pseudo-class selectors - css_code = """ - .button:hover { - background-color: red; - } + .header { animation: fade-in 1s; } + .modal { animation-name: fade-in; } """ - # When: Modifying property for a selector with pseudo-class - {:ok, _, result} = - Parser.modify_property(css_code, ".button:hover", "background-color", "blue", false) + assert {:ok, :extract_animations, animations} = Parser.extract_animations(css) - # Then: The property should be updated for the correct selector - assert String.contains?(result, ".button:hover") - assert String.contains?(result, "background-color: blue") + assert animations == %{ + "fade-in" => %{ + "keyframes" => %{"0%" => %{"opacity" => "0"}, "100%" => %{"opacity" => "1"}}, + "used_by" => [".header", ".modal"] + } + } end - test "handles complex selectors" do - # Given: CSS with complex selectors - css_code = """ - .parent > .child { - color: red; - } - """ - - # When: Modifying property for a complex selector - {:ok, _, result} = - Parser.modify_property(css_code, ".parent > .child", "color", "blue", false) - - # Then: The property should be updated for the correct selector - assert String.contains?(result, ".parent > .child") - assert String.contains?(result, "color: blue") + test "an unused animation lists no users" do + assert {:ok, _, %{"x" => %{"used_by" => []}}} = + Parser.extract_animations("@keyframes x {\n 0% { left: 0; }\n}\n") end + end - test "preserves media queries when modifying properties inside them" do - # Given: CSS with media queries - css_code = """ - @media (max-width: 768px) { - .mobile { - color: red; - } - } - """ - - # When: Modifying property inside media query - {:ok, _, result} = Parser.modify_property(css_code, ".mobile", "color", "blue", false) - - # Then: The media query should be preserved and property updated - assert String.contains?(result, "@media (max-width: 768px)") - assert String.contains?(result, ".mobile") - assert String.contains?(result, "color: blue") + describe "validate_css/2" do + test "accepts valid CSS" do + assert {:ok, :validate_css, true} = Parser.validate_css(".a { color: red; }") end - test "handles empty CSS" do - # Given: Empty CSS - css_code = "" - - # When: Modifying property - {:ok, _, result} = Parser.modify_property(css_code, ".header", "color", "blue", false) - - # Then: A new rule should be created - assert String.contains?(result, ".header") - assert String.contains?(result, "color: blue") + test "accepts Tailwind v4 syntax" do + assert {:ok, _, true} = + Parser.validate_css(~s|@import "tailwindcss";\n@theme {\n --c: red;\n}\n|) end - test "handles invalid CSS" do - # Given: Invalid CSS - css_code = ".invalid { color: red; missing-closing-brace;" - - # When: Modifying property - {:error, _, error_message} = - Parser.modify_property(css_code, ".invalid", "color", "blue", false) - - # Then: Should return an error - assert is_binary(error_message) - assert String.contains?(error_message, "Failed to parse CSS") + test "rejects malformed CSS with a message" do + assert {:error, :validate_css, message} = Parser.validate_css("invalid { css") + assert is_binary(message) + assert message =~ "diagnostic" end end - describe "merge_stylesheets/1" do - test "merges multiple CSS stylesheets" do - # Given: Multiple CSS stylesheets - css_code1 = """ - .header { - color: blue; - font-size: 16px; - } - """ - - css_code2 = """ - .content { - padding: 20px; - margin: 10px; - } - """ - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets([css_code1, css_code2]) - - # Then: The result should contain all selectors and properties - assert String.contains?(result, ".header") - assert String.contains?(result, "color: blue") - assert String.contains?(result, "font-size: 16px") - assert String.contains?(result, ".content") - assert String.contains?(result, "padding: 20px") - assert String.contains?(result, "margin: 10px") + describe "selector_exists?/3" do + test "finds an existing selector" do + assert {:ok, :selector_exists?, true} = Parser.selector_exists?(".header {}", ".header") end - test "removes duplicate selectors when merging" do - # Given: CSS stylesheets with duplicate selectors - css_code1 = """ - .header { - color: blue; - } - """ - - css_code2 = """ - .header { - font-size: 16px; - } - """ - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets([css_code1, css_code2]) - - # Then: The duplicate selectors should be merged - assert String.contains?(result, ".header") - assert String.contains?(result, "color: blue") - assert String.contains?(result, "font-size: 16px") + test "reports a missing selector" do + assert {:error, :selector_exists?, false} = Parser.selector_exists?(".header {}", "#nope") + end - # Count occurrences of .header - should only appear once - assert Regex.scan(~r/\.header\s*\{/, result) |> length() == 1 + test "normalises whitespace before comparing" do + assert {:ok, _, true} = Parser.selector_exists?(".a > .b {}", ".a>.b") end - - test "handles duplicate properties by keeping the last one" do - # Given: CSS stylesheets with duplicate properties - css_code1 = """ - .header { - color: blue; - } - """ - - css_code2 = """ - .header { - color: red; - } - """ - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets([css_code1, css_code2]) - - # Then: The last property value should be kept - assert String.contains?(result, ".header") - assert String.contains?(result, "color: red") - refute String.contains?(result, "color: blue") - end - - test "preserves media queries when merging" do - # Given: CSS stylesheets with media queries - css_code1 = """ - @media (max-width: 768px) { - .mobile { - color: blue; - } - } - """ - - css_code2 = """ - @media (max-width: 768px) { - .mobile { - font-size: 14px; - } - } - """ - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets([css_code1, css_code2]) - - # Then: Media queries should be preserved and properties merged - assert String.contains?(result, "@media (max-width: 768px)") - assert String.contains?(result, ".mobile") - assert String.contains?(result, "color: blue") - assert String.contains?(result, "font-size: 14px") - end - - test "preserves @import rules" do - # Given: CSS stylesheets with @import rules - css_code1 = """ - @import url('fonts.css'); - .header { - font-family: 'Open Sans', sans-serif; - } - """ - - css_code2 = """ - @import url('layout.css'); - .content { - padding: 20px; - } - """ - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets([css_code1, css_code2]) - - # Then: @import rules should be preserved - assert String.contains?(result, "@import url(\"fonts.css\");") - assert String.contains?(result, "@import url(\"layout.css\");") - assert String.contains?(result, ".header") - assert String.contains?(result, ".content") - end - - test "preserves !important declarations" do - # Given: CSS stylesheets with !important declarations - css_code1 = """ - .header { - color: blue !important; - } - """ - - css_code2 = """ - .content { - padding: 20px !important; - } - """ - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets([css_code1, css_code2]) - - # Then: !important declarations should be preserved - assert String.contains?(result, "color: blue !important") - assert String.contains?(result, "padding: 20px !important") - end - - test "handles CSS with comments" do - # Given: CSS stylesheets with comments - css_code1 = """ - /* Header styles */ - .header { - color: blue; - } - """ - - css_code2 = """ - /* Content styles */ - .content { - padding: 20px; - } - """ - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets([css_code1, css_code2]) - - # Then: Comments should be preserved - assert String.contains?(result, "/* Header styles */") - assert String.contains?(result, "/* Content styles */") - assert String.contains?(result, ".header") - assert String.contains?(result, ".content") - end - - test "handles empty CSS list" do - # Given: Empty CSS list - css_list = [] - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets(css_list) - - # Then: Result should be empty - assert result == "" or result == nil - end - - test "handles list with a single stylesheet" do - # Given: CSS list with a single stylesheet - css_code = """ - .header { - color: blue; - } - """ - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets([css_code]) - - # Then: Result should be the same as the input - assert String.contains?(result, ".header") - assert String.contains?(result, "color: blue") - end - - test "handles lists with empty stylesheets" do - # Given: CSS list with some empty stylesheets - css_code1 = "" - - css_code2 = """ - .header { - color: blue; - } - """ - - css_code3 = "" - - # When: Merging the stylesheets - {:ok, _, result} = Parser.merge_stylesheets([css_code1, css_code2, css_code3]) - - # Then: Empty stylesheets should be ignored - assert String.contains?(result, ".header") - assert String.contains?(result, "color: blue") - end - - test "handles invalid CSS" do - # Given: CSS list with some invalid CSS - css_code1 = """ - .header { - color: blue; - } - """ - - css_code2 = ".invalid { color: red; missing-closing-brace;" - - # When: Merging the stylesheets - result = Parser.merge_stylesheets([css_code1, css_code2]) - - # Then: Should either return an error or handle it gracefully - case result do - {:error, _, error_message} -> - assert is_binary(error_message) - assert String.contains?(error_message, "Failed to parse CSS") - - {:ok, _, merged_css} -> - # If the function tries to handle invalid CSS gracefully, verify the valid parts are there - assert String.contains?(merged_css, ".header") - assert String.contains?(merged_css, "color: blue") - end - end - end - - describe "remove_selector/2" do - test "removes a basic selector from CSS" do - # Given: CSS with multiple selectors - css_code = """ - .header { - color: blue; - font-size: 16px; - } - - .content { - padding: 20px; - margin: 10px; - } - """ - - # When: Removing one selector - {:ok, _, result} = Parser.remove_selector(css_code, ".header") - - # Then: The specified selector should be removed - refute String.contains?(result, ".header") - refute String.contains?(result, "color: blue") - refute String.contains?(result, "font-size: 16px") - - # Other selectors should be preserved - assert String.contains?(result, ".content") - assert String.contains?(result, "padding: 20px") - assert String.contains?(result, "margin: 10px") - end - - test "handles complex selectors" do - # Given: CSS with complex selectors - css_code = """ - .parent > .child { - color: red; - } - - .sibling + .adjacent { - margin-left: 10px; - } - """ - - # When: Removing a complex selector - {:ok, _, result} = Parser.remove_selector(css_code, ".parent > .child") - - # Then: The complex selector should be removed - refute String.contains?(result, ".parent > .child") - refute String.contains?(result, "color: red") - - # Other selectors should be preserved - assert String.contains?(result, ".sibling + .adjacent") - assert String.contains?(result, "margin-left: 10px") - end - - test "handles selectors with pseudo-classes" do - # Given: CSS with pseudo-class selectors - css_code = """ - .button:hover { - background-color: blue; - } - - .link:visited { - color: purple; - } - """ - - # When: Removing a selector with pseudo-class - {:ok, _, result} = Parser.remove_selector(css_code, ".button:hover") - - # Then: The selector with pseudo-class should be removed - refute String.contains?(result, ".button:hover") - refute String.contains?(result, "background-color: blue") - - # Other selectors should be preserved - assert String.contains?(result, ".link:visited") - assert String.contains?(result, "color: purple") - end - - test "handles removing a selector from a media query" do - # Given: CSS with selectors inside media queries - css_code = """ - @media (max-width: 768px) { - .mobile { - display: block; - } - - .tablet { - display: none; - } - } - """ - - # When: Removing a selector from a media query - {:ok, _, result} = Parser.remove_selector(css_code, ".mobile") - # Then: The selector should be removed from the media query - refute String.contains?(result, ".mobile") - refute String.contains?(result, "display: block") - - # Media query and other selectors should be preserved - assert String.contains?(result, "@media (max-width: 768px)") - assert String.contains?(result, ".tablet") - assert String.contains?(result, "display: none") - end - - test "maintains empty media queries after removing all selectors" do - # Given: CSS with a single selector in a media query - css_code = """ - @media (max-width: 768px) { - .mobile { - display: block; - } - } - """ - - # When: Removing the only selector from the media query - # If there is no child selector, the media query should be removed - {:ok, _, result} = Parser.remove_selector(css_code, ".mobile") - - # Then: The media query should still exist but be empty - assert String.contains?(result, "") - refute String.contains?(result, ".mobile") - refute String.contains?(result, "display: block") - end - - test "handles removing selector with multiple declarations" do - # Given: CSS with a selector having multiple declarations - css_code = """ - .multiline { - color: red; - font-size: 16px; - margin: 10px; - padding: 5px; - border: 1px solid black; - } - """ - - # When: Removing the selector - {:ok, _, result} = Parser.remove_selector(css_code, ".multiline") - - # Then: The entire selector block should be removed - refute String.contains?(result, ".multiline") - refute String.contains?(result, "color: red") - refute String.contains?(result, "font-size: 16px") - refute String.contains?(result, "margin: 10px") - refute String.contains?(result, "padding: 5px") - refute String.contains?(result, "border: 1px solid black") - end - - test "handles non-existent selector" do - # Given: CSS without the target selector - css_code = """ - .header { - color: blue; - } - """ - - # When: Removing a non-existent selector - {:ok, _, result} = Parser.remove_selector(css_code, ".non-existent") - - # Then: The CSS should remain unchanged - assert elem(Parser.beautify(result), 2) == elem(Parser.beautify(css_code), 2) - assert String.contains?(result, ".header") - assert String.contains?(result, "color: blue") - end - - test "handles multiple occurrences of the same selector" do - # Given: CSS with multiple occurrences of the same selector - css_code = """ - .duplicate { - color: red; - } - - .other { - margin: 10px; - } - - .duplicate { - font-size: 16px; - } - """ - - # When: Removing the duplicate selector - {:ok, _, result} = Parser.remove_selector(css_code, ".duplicate") - - # Then: All occurrences of the selector should be removed - refute String.contains?(result, ".duplicate") - refute String.contains?(result, "color: red") - refute String.contains?(result, "font-size: 16px") - - # Other selectors should be preserved - assert String.contains?(result, ".other") - assert String.contains?(result, "margin: 10px") - end - - test "handles empty CSS" do - # Given: Empty CSS - css_code = "" - - # When: Removing a selector - {:ok, _, result} = Parser.remove_selector(css_code, ".header") - - # Then: The result should still be empty - assert result == "" - end - - test "handles CSS with comments" do - # Given: CSS with comments - css_code = """ - /* Header styles */ - .header { - color: blue; - } - - /* Content styles */ - .content { - padding: 20px; - } - """ - - # When: Removing a selector - {:ok, _, result} = Parser.remove_selector(css_code, ".header") - - # Then: The selector should be removed - refute String.contains?(result, ".header") - refute String.contains?(result, "color: blue") - - # Comments for other selectors should be preserved - assert String.contains?(result, "/* Content styles */") - assert String.contains?(result, ".content") - assert String.contains?(result, "padding: 20px") - end - - test "handles invalid CSS" do - # Given: Invalid CSS - css_code = ".invalid { color: red; missing-closing-brace;" - - # When: Removing a selector - {:error, _, error_message} = Parser.remove_selector(css_code, ".invalid") - - # Then: Should return an error - assert is_binary(error_message) - assert String.contains?(error_message, "Failed to parse CSS") - end - end - - describe "extract_media_queries/1" do - test "extracts basic media queries and their contents" do - # Given: CSS with simple media queries - css_code = """ - @media (max-width: 768px) { - .header { - font-size: 14px; - } - .content { - padding: 10px; - } - } - """ - - # When: Extracting media queries - {:ok, _, result} = Parser.extract_media_queries(css_code) - - # Then: The media query and its contents should be extracted - assert is_map(result) - assert Map.has_key?(result, "(max-width: 768px)") - - mobile_rules = result["(max-width: 768px)"] - assert is_list(mobile_rules) - assert length(mobile_rules) == 2 - - # Verify first selector properties - header_rule = Enum.find(mobile_rules, fn rule -> rule["selector"] == ".header" end) - assert header_rule != nil - assert header_rule["properties"]["font-size"] == "14px" - - # Verify second selector properties - content_rule = Enum.find(mobile_rules, fn rule -> rule["selector"] == ".content" end) - assert content_rule != nil - assert content_rule["properties"]["padding"] == "10px" - end - - test "extracts multiple media queries" do - # Given: CSS with multiple media queries - css_code = """ - @media (max-width: 768px) { - .mobile { - display: block; - } - } - - @media (min-width: 1200px) { - .desktop { - margin: 0 auto; - } - } - - @media print { - .no-print { - display: none; - } - } - """ - - # When: Extracting media queries - {:ok, _, result} = Parser.extract_media_queries(css_code) - - # Then: All media queries should be extracted - assert Map.has_key?(result, "(max-width: 768px)") - assert Map.has_key?(result, "(min-width: 1200px)") - assert Map.has_key?(result, "print") - - # Verify contents of each media query - assert Enum.find(result["(max-width: 768px)"], fn rule -> rule["selector"] == ".mobile" end)[ - "properties" - ]["display"] == "block" - - assert Enum.find(result["(min-width: 1200px)"], fn rule -> - rule["selector"] == ".desktop" - end)["properties"]["margin"] == "0 auto" - - assert Enum.find(result["print"], fn rule -> rule["selector"] == ".no-print" end)[ - "properties" - ]["display"] == "none" - end - - test "extracts media queries with multiple properties" do - # Given: CSS with media queries containing selectors with multiple properties - css_code = """ - @media (max-width: 768px) { - .header { - font-size: 14px; - color: #333; - padding: 5px; - margin: 10px; - } - } - """ - - # When: Extracting media queries - {:ok, _, result} = Parser.extract_media_queries(css_code) - - # Then: All properties should be extracted - mobile_header = - Enum.find(result["(max-width: 768px)"], fn rule -> rule["selector"] == ".header" end) - - assert mobile_header["properties"]["font-size"] == "14px" - assert mobile_header["properties"]["color"] == "#333" - assert mobile_header["properties"]["padding"] == "5px" - assert mobile_header["properties"]["margin"] == "10px" - end - - test "extracts media queries with complex selectors" do - # Given: CSS with media queries containing complex selectors - css_code = """ - @media (max-width: 768px) { - .parent > .child { - color: red; - } - - .sibling + .adjacent { - margin-left: 10px; - } - - ul li:hover { - background-color: #f0f0f0; - } - } - """ - - # When: Extracting media queries - {:ok, _, result} = Parser.extract_media_queries(css_code) - - # Then: Complex selectors should be correctly extracted - mobile_rules = result["(max-width: 768px)"] - - assert Enum.find(mobile_rules, fn rule -> rule["selector"] == ".parent > .child" end)[ - "properties" - ]["color"] == "red" - - assert Enum.find(mobile_rules, fn rule -> rule["selector"] == ".sibling + .adjacent" end)[ - "properties" - ]["margin-left"] == "10px" - - assert Enum.find(mobile_rules, fn rule -> rule["selector"] == "ul li:hover" end)[ - "properties" - ]["background-color"] == "#f0f0f0" - end - - test "extracts media queries with complex conditions" do - # Given: CSS with media queries having complex conditions - css_code = """ - @media (min-width: 768px) and (max-width: 1200px) { - .tablet { - display: block; - } - } - - @media screen and (orientation: landscape) { - .landscape { - width: 100%; - } - } - - @media (max-width: 768px), (min-width: 1400px) { - .extremes { - font-size: 18px; - } - } - """ - - # When: Extracting media queries - {:ok, _, result} = Parser.extract_media_queries(css_code) - - # Then: Complex media query conditions should be correctly extracted - assert Map.has_key?(result, "(min-width: 768px) and (max-width: 1200px)") - assert Map.has_key?(result, "screen and (orientation: landscape)") - assert Map.has_key?(result, "(max-width: 768px), (min-width: 1400px)") - - assert Enum.find(result["(min-width: 768px) and (max-width: 1200px)"], fn rule -> - rule["selector"] == ".tablet" - end)["properties"]["display"] == "block" - - assert Enum.find(result["screen and (orientation: landscape)"], fn rule -> - rule["selector"] == ".landscape" - end)["properties"]["width"] == "100%" - - assert Enum.find(result["(max-width: 768px), (min-width: 1400px)"], fn rule -> - rule["selector"] == ".extremes" - end)["properties"]["font-size"] == "18px" - end - - test "handles CSS with no media queries" do - # Given: CSS without any media queries - css_code = """ - .header { - color: blue; - } - - .content { - padding: 20px; - } - """ - - # When: Extracting media queries - {:ok, _, result} = Parser.extract_media_queries(css_code) - - # Then: Result should be an empty map - assert result == %{} - end - - test "handles nested media queries" do - # Given: CSS with nested media queries (if supported) - css_code = """ - @media print { - .document { - color: black; - } - - @media (max-width: 768px) { - .document { - font-size: 12px; - } - } - } - """ - - # When: Extracting media queries - {:ok, _, result} = Parser.extract_media_queries(css_code) - - # Then: Either nested media queries are extracted separately or combined - # Note: How nested media queries are handled depends on the implementation - assert Map.has_key?(result, "print") - # Depending on implementation, might have nested query as: - # - A separate entry - # - Combined with parent (e.g., "print and (max-width: 768px)") - # - Ignored (only parent is extracted) - - # Test for the guaranteed parent media query content - assert Enum.find(result["print"], fn rule -> rule["selector"] == ".document" end)[ - "properties" - ]["color"] == "black" - end - - test "extracts empty media queries" do - # Given: CSS with empty media queries - css_code = """ - @media (max-width: 768px) { - /* Empty media query */ - } - """ - - # When: Extracting media queries - {:ok, _, result} = Parser.extract_media_queries(css_code) - - # Then: Empty media queries should be extracted with empty content - assert Map.has_key?(result, "(max-width: 768px)") - assert result["(max-width: 768px)"] == [] - end - - test "handles CSS with comments in media queries" do - # Given: CSS with comments inside media queries - css_code = """ - @media (max-width: 768px) { - /* Mobile styles */ - .header { - /* Smaller font on mobile */ - font-size: 14px; - } - } - """ - - # When: Extracting media queries - {:ok, _, result} = Parser.extract_media_queries(css_code) - - # Then: Comments should be ignored and content correctly extracted - assert Map.has_key?(result, "(max-width: 768px)") - - assert Enum.find(result["(max-width: 768px)"], fn rule -> rule["selector"] == ".header" end)[ - "properties" - ]["font-size"] == "14px" - end - - test "handles invalid CSS" do - # Given: Invalid CSS - css_code = "@media (max-width: 768px) { .invalid { color: red; missing-closing-brace; }" - - # When: Extracting media queries - {:error, _, error_message} = Parser.extract_media_queries(css_code) - - # Then: Should return an error - assert is_binary(error_message) - assert String.contains?(error_message, "Failed to parse CSS") - end - end - - describe "extract_animations/1" do - test "extracts basic animation and keyframes" do - # Given: CSS with basic animation and keyframes - css_code = """ - @keyframes fade-in { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } - } - - .animated { - animation: fade-in 2s ease-in-out; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: Animation and keyframes should be extracted correctly - assert is_map(result) - assert Map.has_key?(result, "fade-in") - - # Check keyframes - assert is_map(result["fade-in"]["keyframes"]) - assert result["fade-in"]["keyframes"]["0%"]["opacity"] == "0" - assert result["fade-in"]["keyframes"]["100%"]["opacity"] == "1" - - # Check usage - assert ".animated" in result["fade-in"]["used_by"] - end - - test "extracts multiple animations" do - # Given: CSS with multiple animations - css_code = """ - @keyframes fade-in { - 0% { opacity: 0; } - 100% { opacity: 1; } - } - - @keyframes slide-up { - 0% { transform: translateY(20px); } - 100% { transform: translateY(0); } - } - - .header { - animation: fade-in 1s ease-out; - } - - .content { - animation: slide-up 0.5s ease-in; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: Both animations should be extracted correctly - assert Map.has_key?(result, "fade-in") - assert Map.has_key?(result, "slide-up") - - # Check fade-in keyframes - assert result["fade-in"]["keyframes"]["0%"]["opacity"] == "0" - assert result["fade-in"]["keyframes"]["100%"]["opacity"] == "1" - - # Check slide-up keyframes - assert result["fade-in"]["keyframes"]["0%"]["opacity"] == "0" - assert result["slide-up"]["keyframes"]["0%"]["transform"] == "translateY(20px)" - assert result["slide-up"]["keyframes"]["100%"]["transform"] == "translateY(0)" - - # Check usage - assert ".header" in result["fade-in"]["used_by"] - assert ".content" in result["slide-up"]["used_by"] - end - - test "extracts animations with multiple keyframes" do - # Given: CSS with animation having multiple keyframe steps - css_code = """ - @keyframes pulse { - 0% { - transform: scale(1); - } - 50% { - transform: scale(1.1); - } - 100% { - transform: scale(1); - } - } - - .button { - animation: pulse 2s infinite; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: All keyframes should be extracted - assert Map.has_key?(result, "pulse") - assert Map.has_key?(result["pulse"]["keyframes"], "0%") - assert Map.has_key?(result["pulse"]["keyframes"], "50%") - assert Map.has_key?(result["pulse"]["keyframes"], "100%") - - assert result["pulse"]["keyframes"]["0%"]["transform"] == "scale(1)" - assert result["pulse"]["keyframes"]["50%"]["transform"] == "scale(1.1)" - assert result["pulse"]["keyframes"]["100%"]["transform"] == "scale(1)" - - assert ".button" in result["pulse"]["used_by"] - end - - test "extracts animations with from/to notation" do - # Given: CSS with animation using from/to notation - css_code = """ - @keyframes slide-left { - from { - transform: translateX(100%); - } - to { - transform: translateX(0); - } - } - - .sidebar { - animation: slide-left 0.3s ease-out; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: from/to keyframes should be correctly extracted - assert Map.has_key?(result, "slide-left") - assert Map.has_key?(result["slide-left"]["keyframes"], "from") - assert Map.has_key?(result["slide-left"]["keyframes"], "to") - - assert result["slide-left"]["keyframes"]["from"]["transform"] == "translateX(100%)" - assert result["slide-left"]["keyframes"]["to"]["transform"] == "translateX(0)" - - assert ".sidebar" in result["slide-left"]["used_by"] - end - - test "extracts animations with multiple properties per keyframe" do - # Given: CSS with animation having multiple properties per keyframe - css_code = """ - @keyframes complex-animation { - 0% { - opacity: 0; - transform: scale(0.8); - background-color: red; - } - 100% { - opacity: 1; - transform: scale(1); - background-color: blue; - } - } - - .card { - animation: complex-animation 1s; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: All properties should be extracted - assert Map.has_key?(result, "complex-animation") - - assert result["complex-animation"]["keyframes"]["0%"]["opacity"] == "0" - assert result["complex-animation"]["keyframes"]["0%"]["transform"] == "scale(0.8)" - assert result["complex-animation"]["keyframes"]["0%"]["background-color"] == "red" - - assert result["complex-animation"]["keyframes"]["100%"]["opacity"] == "1" - assert result["complex-animation"]["keyframes"]["100%"]["transform"] == "scale(1)" - assert result["complex-animation"]["keyframes"]["100%"]["background-color"] == "blue" - - assert ".card" in result["complex-animation"]["used_by"] - end - - test "extracts animations used by multiple selectors" do - # Given: CSS with animation used by multiple selectors - css_code = """ - @keyframes fade-in { - 0% { opacity: 0; } - 100% { opacity: 1; } - } - - .header { - animation: fade-in 1s; - } - - .modal { - animation: fade-in 0.5s; - } - - .tooltip { - animation: fade-in 0.3s; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: All selectors using the animation should be listed - assert Map.has_key?(result, "fade-in") - assert ".header" in result["fade-in"]["used_by"] - assert ".modal" in result["fade-in"]["used_by"] - assert ".tooltip" in result["fade-in"]["used_by"] - end - - test "extracts animations with vendor prefixes" do - # Given: CSS with vendor prefixed animations - css_code = """ - @-webkit-keyframes bounce { - 0% { transform: translateY(0); } - 50% { transform: translateY(-20px); } - 100% { transform: translateY(0); } - } - - .ball { - -webkit-animation: bounce 1s infinite; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: Prefixed animations should be extracted - # Note: Exact behavior depends on how the Python function handles prefixes - assert Map.has_key?(result, "bounce") or Map.has_key?(result, "-webkit-bounce") - - # Access the correct key (depending on implementation) - animation_key = if Map.has_key?(result, "bounce"), do: "bounce", else: "-webkit-bounce" - - assert result[animation_key]["keyframes"]["0%"]["transform"] == "translateY(0)" - assert result[animation_key]["keyframes"]["50%"]["transform"] == "translateY(-20px)" - assert result[animation_key]["keyframes"]["100%"]["transform"] == "translateY(0)" - end - - test "handles animation-name property" do - # Given: CSS using animation-name property instead of shorthand - css_code = """ - @keyframes rotate { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } - - .spinner { - animation-name: rotate; - animation-duration: 2s; - animation-iteration-count: infinite; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: Animation should be extracted and associated with selector - assert Map.has_key?(result, "rotate") - assert ".spinner" in result["rotate"]["used_by"] - end - - test "handles keyframes without usage" do - # Given: CSS with keyframes that aren't used - css_code = """ - @keyframes unused-animation { - 0% { opacity: 0; } - 100% { opacity: 1; } - } - - .static { - color: blue; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: Keyframes should be extracted with empty used_by - assert Map.has_key?(result, "unused-animation") - assert Enum.empty?(result["unused-animation"]["used_by"]) - end - - test "handles animations without keyframes" do - # Given: CSS with animation reference but no keyframes - css_code = """ - .element { - animation: non-existent-animation 1s; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: No animations should be extracted (or empty map) - assert result == %{} or Enum.empty?(result) - end - - test "handles CSS with no animations" do - # Given: CSS without any animations - css_code = """ - .header { - color: blue; - } - - .content { - padding: 20px; - } - """ - - # When: Extracting animations - {:ok, _, result} = Parser.extract_animations(css_code) - - # Then: Result should be an empty map - assert result == %{} - end - - test "handles invalid CSS" do - # Given: Invalid CSS - css_code = "@keyframes broken { 0% { opacity: 0; missing-closing-brace;" - - # When: Extracting animations - {:error, _, error_message} = Parser.extract_animations(css_code) - - # Then: Should return an error - assert is_binary(error_message) - assert String.contains?(error_message, "Failed to parse CSS") - end - end - - describe "sort_properties/1" do - test "sorts properties alphabetically within each rule" do - # Given: CSS with unsorted properties - css_code = """ - .header { - color: blue; - background: white; - font-size: 16px; - } - - .content { - padding: 20px; - margin: 10px; - border: 1px solid black; - } - """ - - # When: Sorting properties - {:ok, _, result} = Parser.sort_properties(css_code) - - # Then: Properties should be sorted alphabetically - assert String.contains?( - result, - ".header {\n background: white;\n color: blue;\n font-size: 16px;\n}" - ) - - assert String.contains?( - result, - ".content {\n border: 1px solid black;\n margin: 10px;\n padding: 20px;\n}" - ) - end - - test "preserves comments within rules" do - # Given: CSS with comments - css_code = """ - .header { - /* Header styles */ - color: blue; - background: white; - /* Font settings */ - font-size: 16px; - } - """ - - # When: Sorting properties - {:ok, _, result} = Parser.sort_properties(css_code) - # Then: Comments should be preserved - assert String.contains?(result, "/* Header styles */") - assert String.contains?(result, "/* Font settings */") - end - - test "preserves !important flags" do - # Given: CSS with !important properties - css_code = """ - .important { - color: blue !important; - background: white; - font-size: 16px !important; - } - """ - - # When: Sorting properties - {:ok, _, result} = Parser.sort_properties(css_code) - - # Then: !important flags should be preserved - assert String.contains?( - result, - ".important {\n background: white;\n color: blue !important;\n font-size: 16px !important;\n}" - ) - end - - test "handles media queries" do - # Given: CSS with media queries - css_code = """ - @media (max-width: 768px) { - .responsive { - color: blue; - background: white; - font-size: 16px; - } - } - """ - - # When: Sorting properties - {:ok, _, result} = Parser.sort_properties(css_code) - - # Then: Media query structure should be preserved and properties sorted - assert String.contains?(result, "@media (max-width: 768px) {") - - assert String.contains?( - result, - ".responsive {\n color: blue;\n background: white;\n font-size: 16px;" - ) - end - - test "handles empty CSS" do - # Given: Empty CSS - css_code = "" - - # When: Sorting properties - {:ok, _, result} = Parser.sort_properties(css_code) - - # Then: Should return empty string - assert result == "" - end - - test "handles invalid CSS" do - # Given: Invalid CSS - css_code = "invalid css" - - # When: Sorting properties - result = Parser.sort_properties(css_code) - - # Then: Should return error - assert {:error, _, _} = result - end - end - - describe "remove_duplicates/2" do - test "removes duplicate properties within a selector" do - # Given: CSS with duplicate properties - css_code = """ - .header { - color: blue; - color: red; - font-size: 16px; - font-size: 18px; - } - """ - - # When: Removing duplicates - {:ok, _, result} = Parser.remove_duplicates(css_code) - - # Then: Only the last occurrence of each property should remain - assert String.contains?(result, ".header") - assert String.contains?(result, "color: red") - assert String.contains?(result, "font-size: 18px") - refute String.contains?(result, "color: blue") - refute String.contains?(result, "font-size: 16px") - end - - test "removes duplicate selectors" do - # Given: CSS with duplicate selectors - css_code = """ - .header { - color: blue; - height: 10px; - } - - .content { - padding: 20px; - } - - .header { - color: red; - } - """ - - # When: Removing duplicates - {:ok, _, result} = Parser.remove_duplicates(css_code) - - # Then: Only the last occurrence of each selector should remain - assert String.contains?(result, ".header") - assert String.contains?(result, "color: blue") - assert String.contains?(result, ".content") - assert String.contains?(result, "padding: 20px") - # Count occurrences of .header - should only appear once - assert Regex.scan(~r/\.header\s*\{/, result) |> length() == 1 - end - - test "preserves !important flags when removing duplicates" do - # Given: CSS with duplicate properties, one with !important - css_code = """ - .important { - color: blue !important; - color: red; - font-size: 16px; - font-size: 18px !important; - } - """ - - # When: Removing duplicates - {:ok, _, result} = Parser.remove_duplicates(css_code) - - # Then: !important flags should be preserved - assert String.contains?(result, "color: red") - assert String.contains?(result, "font-size: 18px !important") - refute String.contains?(result, "color: blue") - refute String.contains?(result, "font-size: 16px") - end - - test "handles media queries" do - # Given: CSS with duplicate properties in media queries - css_code = """ - @media (max-width: 768px) { - .mobile { - color: blue; - color: red; - } - } - - @media (max-width: 768px) { - .mobile { - font-size: 16px; - font-size: 18px; - } - } - """ - - # When: Removing duplicates - {:ok, _, result} = Parser.remove_duplicates(css_code) - - # Then: Media query structure should be preserved and duplicates removed - assert String.contains?(result, "@media (max-width: 768px)") - assert String.contains?(result, ".mobile") - assert String.contains?(result, "color: red") - refute String.contains?(result, "color: blue") - refute String.contains?(result, "font-size: 16px") - # Count occurrences of media query - should only appear once - assert Regex.scan(~r/@media\s*\(max-width:\s*768px\)\s*\{/, result) |> length() == 1 - end - - test "preserves comments" do - # Given: CSS with comments and duplicate properties - css_code = """ - /* Header styles */ - .header { - /* Color settings */ - color: blue; - color: red; - /* Font settings */ - font-size: 16px; - font-size: 18px; - } - """ - - # When: Removing duplicates - {:ok, _, result} = Parser.remove_duplicates(css_code) - - # Then: Comments should be preserved - assert String.contains?(result, "/* Header styles */") - assert String.contains?(result, "/* Color settings */") - assert String.contains?(result, "/* Font settings */") - assert String.contains?(result, "color: red") - assert String.contains?(result, "font-size: 18px") - end - - test "handles empty CSS" do - # Given: Empty CSS - css_code = "" - - # When: Removing duplicates - {:ok, _, result} = Parser.remove_duplicates(css_code) - - # Then: Should return empty string - assert result == "" - end - - test "handles invalid CSS" do - # Given: Invalid CSS - css_code = "invalid css" - - # When: Removing duplicates - result = Parser.remove_duplicates(css_code) - - # Then: Should return error - assert {:error, _, _} = result - end - - test "handles complex selectors" do - # Given: CSS with complex selectors and duplicate properties - css_code = """ - .parent > .child { - color: blue; - color: red; - } - - .sibling + .adjacent { - margin: 10px; - margin: 20px; - } - """ - - # When: Removing duplicates - {:ok, _, result} = Parser.remove_duplicates(css_code) - - # Then: Complex selectors should be preserved and duplicates removed - assert String.contains?(result, ".parent > .child") - assert String.contains?(result, "color: red") - assert String.contains?(result, ".sibling + .adjacent") - assert String.contains?(result, "margin: 20px") - refute String.contains?(result, "color: blue") - refute String.contains?(result, "margin: 10px") - end - - test "handles pseudo-classes and pseudo-elements" do - # Given: CSS with pseudo-classes and duplicate properties - css_code = """ - .button:hover { - background: blue; - background: red; - } - - .content::before { - content: "old"; - content: "new"; - } - """ - - # When: Removing duplicates - {:ok, _, result} = Parser.remove_duplicates(css_code) - - # Then: Pseudo-classes and pseudo-elements should be preserved - assert String.contains?(result, ".button:hover") - assert String.contains?(result, "background: red") - assert String.contains?(result, ".content::before") - assert String.contains?(result, "content: \"new\"") - refute String.contains?(result, "background: blue") - refute String.contains?(result, "content: \"old\"") - end - end - - describe "validate_css/1" do - test "validates valid CSS string" do - # Given: Valid CSS string - css = """ - .header { - color: blue; - font-size: 16px; - } - """ - - # When: Validating CSS - {:ok, _, true} = assert Parser.validate_css(css) - end - - test "validates valid CSS with media queries" do - # Given: Valid CSS with media queries - css = """ - @media (max-width: 600px) { - .header { - font-size: 14px; - } - } - """ - - # When: Validating CSS - {:ok, _, true} = assert Parser.validate_css(css) - end - - test "handles CSS with comments" do - # Given: CSS with comments - css = """ - /* Header styles */ - .header { - color: blue; /* Main color */ - } - """ - - # When: Validating CSS - {:ok, _, true} = Parser.validate_css(css) - end - - test "validates CSS with inline comments after semicolons" do - # Given: CSS with various inline comment styles - css = """ - .element { - color: red; /* Basic comment */ - background: #fff; /* Hex color comment */ - padding: 10px; /* Number with unit */ - margin: 0; /* Zero value */ - border: 1px solid #ccc; /* Multiple values */ - font-family: "Arial", sans-serif; /* String value */ - } - """ - - # When: Validating CSS - {:ok, _, true} = Parser.validate_css(css) - end - - test "validates CSS with multi-line comments" do - # Given: CSS with multi-line comments - css = """ - /* - * This is a multi-line comment - * that spans several lines - * and describes the following rules - */ - .container { - width: 100%; /* Full width */ - max-width: 1200px; /* - Maximum width for larger screens - Prevents content from being too wide - */ - } - """ - - # When: Validating CSS - {:ok, _, true} = Parser.validate_css(css) - end - - test "validates CSS with comments in various positions" do - # Given: CSS with comments in different positions - css = """ - /* Comment at the beginning */ - .class1 /* comment after selector */ { - /* comment before property */ - color /* comment after property name */: /* comment before value */ blue /* comment after value */; - /* comment between properties */ - margin: 10px; /* standard inline comment */ - } /* comment after closing brace */ - - /* Comment between rules */ - - .class2 { - padding: 5px; /* Another property */ - } - /* Comment at the end */ - """ - - # When: Validating CSS - {:ok, _, true} = Parser.validate_css(css) - end - - test "validates CSS with browser-specific comments" do - # Given: CSS with browser-specific inline comments - css = """ - .hide-scrollbar { - -ms-overflow-style: none; /* Internet Explorer 10+ */ - scrollbar-width: none; /* Firefox */ - -webkit-overflow-scrolling: touch; /* iOS Safari */ - } - - .hide-scrollbar::-webkit-scrollbar { - display: none; /* Safari and Chrome */ - width: 0; /* Alternative method */ - height: 0; /* For horizontal scrollbar */ - } - - @supports (-ms-overflow-style: none) { - .hide-scrollbar { - overflow: -moz-scrollbars-none; /* Old Firefox */ - } - } - """ - - # When: Validating CSS - {:ok, _, true} = Parser.validate_css(css) - end - - test "validates CSS with nested comments in media queries" do - # Given: CSS with comments inside media queries - css = """ - /* Mobile-first responsive design */ - @media screen and (min-width: 768px) { - /* Tablet styles */ - .container { - width: 750px; /* Fixed width for tablets */ - margin: 0 auto; /* Center alignment */ - } - } - - @media screen and (min-width: 1024px) { - /* Desktop styles */ - .container { - width: 970px; /* Fixed width for desktop */ - } - } - """ - - # When: Validating CSS - {:ok, _, true} = Parser.validate_css(css) - end - - test "validates CSS with special characters in comments" do - # Given: CSS with special characters in comments - css = """ - .element { - content: "→"; /* Arrow symbol: → */ - font-size: 16px; /* Size in px (pixels) */ - width: calc(100% - 20px); /* 100% minus padding */ - color: #ff0000; /* RGB: 255, 0, 0 */ - opacity: 0.5; /* 50% transparency */ - z-index: 999; /* Layer order: higher = on top */ - } - """ - - # When: Validating CSS - {:ok, _, true} = Parser.validate_css(css) - end - - test "handles empty CSS" do - # Given: Empty CSS - css = "" - - # When: Validating CSS - {:ok, _, true} = assert Parser.validate_css(css) - end - - test "handles CSS with only whitespace" do - # Given: CSS with only whitespace - css = " \n \t " - - # When: Validating CSS - {:ok, _, true} = assert Parser.validate_css(css) - end - - test "rejects CSS with unbalanced braces" do - # Given: CSS with unbalanced braces - css = """ - .header { - color: blue; - } - .content { - margin: 10px; - """ - - # When: Validating CSS - result = Parser.validate_css(css) - - # Then: Should return error - assert {:error, _, "CSS syntax error: Unbalanced braces"} = result - end - - test "rejects CSS with invalid syntax" do - # Given: CSS with invalid syntax - css = """ - {}} - """ - - # When: Validating CSS - result = Parser.validate_css(css) - - # Then: Should return error - assert {:error, _, _} = result - end - - test "handles binary input" do - # Given: CSS as binary - css = - """ - .header { - color: blue; - } - """ - |> String.to_charlist() - |> :erlang.iolist_to_binary() - - # When: Validating CSS - {:ok, _, true} = assert Parser.validate_css(css) - end - - test "handles CSS with special characters" do - # Given: CSS with special characters - css = """ - .header[data-test="test-value"] { - content: "✓"; - } - """ - - # When: Validating CSS - {:ok, _, true} = assert Parser.validate_css(css) - end - end - - describe "replace_selector_rule/4" do - test "replaces existing selector with new declarations" do - # Given: CSS with existing selector - css = """ - .header { - color: red; - font-size: 16px; - } - .footer { - color: blue; - } - """ - - # When: Replacing selector with new declarations - {:ok, _, result} = - Parser.replace_selector_rule(css, ".header", "color: green; font-weight: bold;") - - # Then: Should replace the selector's declarations - assert String.contains?(result, ".header {") - assert String.contains?(result, "color: green") - assert String.contains?(result, "font-weight: bold") - refute String.contains?(result, "color: red") - refute String.contains?(result, "font-size: 16px") - # Original selectors preserved - assert String.contains?(result, ".footer {") - assert String.contains?(result, "color: blue") - end - - test "adds new selector if it doesn't exist" do - # Given: CSS without the target selector - css = """ - .footer { - color: blue; - } - """ - - # When: Adding new selector with declarations - {:ok, _, result} = - Parser.replace_selector_rule(css, ".header", "color: green; font-weight: bold;") - - # Then: Should add the new selector - assert String.contains?(result, ".header {") - assert String.contains?(result, "color: green") - assert String.contains?(result, "font-weight: bold") - # Original selectors preserved - assert String.contains?(result, ".footer {") - assert String.contains?(result, "color: blue") - end - - test "handles CSS with invalid syntax" do - # Given: CSS with invalid syntax - css = """ - .header { - color: red - font-size: 16px; - } - """ - - # When: Replacing selector with new declarations - {:error, _, _error_message} = - assert Parser.replace_selector_rule(css, ".header", "color: green;") - end - - test "preserves media queries and other at-rules" do - # Given: CSS with media queries and other rules - css = """ - @media (max-width: 768px) { - .header { - color: red; - } - } - .footer { - color: blue; - } - """ - - # When: Replacing selector with new declarations - {:ok, _, result} = - Parser.replace_selector_rule(css, ".header", "color: green; font-weight: bold;") - - # Then: Should preserve media queries and other rules - assert String.contains?(result, "@media (max-width: 768px)") - assert String.contains?(result, ".header {") - assert String.contains?(result, "color: green") - assert String.contains?(result, "font-weight: bold") - assert String.contains?(result, ".footer {") - assert String.contains?(result, "color: blue") - end - - test "handles multiple occurrences of the same selector" do - # Given: CSS with multiple occurrences of the same selector - css = """ - .header { - color: red; - } - .content { - padding: 20px; - } - .header { - font-size: 16px; - } - """ - - # When: Replacing selector with new declarations - {:ok, _, result} = - Parser.replace_selector_rule(css, ".header", "color: green; font-weight: bold;") - - # Then: Should replace all occurrences - assert String.contains?(result, ".header {") - assert String.contains?(result, "color: green") - assert String.contains?(result, "font-weight: bold") - refute String.contains?(result, "color: red") - refute String.contains?(result, "font-size: 16px") - # Original selectors preserved - assert String.contains?(result, ".content {") - assert String.contains?(result, "padding: 20px") - - # Count occurrences of .header - should appear only once after replacement - header_count = - result - |> String.split(".header {") - |> length - |> Kernel.-(1) - - assert header_count == 2 - end - - test "handles complex selectors" do - # Given: CSS with complex selectors - css = """ - .parent > .child { - color: red; - } - .sibling + .adjacent { - color: blue; - } - ul li:hover { - color: green; - } - """ - - # When: Replacing a complex selector with new declarations - {:ok, _, result} = - Parser.replace_selector_rule(css, ".parent > .child", "color: purple; font-weight: bold;") - - # Then: Should replace the complex selector's declarations - assert String.contains?(result, ".parent > .child {") - assert String.contains?(result, "color: purple") - assert String.contains?(result, "font-weight: bold") - refute String.contains?(result, "color: red") - # Other selectors preserved - assert String.contains?(result, ".sibling + .adjacent {") - assert String.contains?(result, "color: blue") - assert String.contains?(result, "ul li:hover {") - assert String.contains?(result, "color: green") - end - - test "handles empty CSS" do - # Given: Empty CSS - css = "" - # When: Adding new selector with declarations - {:ok, _, result} = - Parser.replace_selector_rule(css, ".header", "color: green; font-weight: bold;") - - # Then: Should add the new selector - assert String.contains?(result, ".header {") - assert String.contains?(result, "color: green") - assert String.contains?(result, "font-weight: bold") - end - - test "handles declarations with !important" do - # Given: CSS with selector - css = """ - .header { - color: red; - } - """ - - # When: Replacing with declarations containing !important - {:ok, _, result} = - Parser.replace_selector_rule( - css, - ".header", - "color: green !important; font-weight: bold;" - ) - - # Then: Should preserve !important flag - assert String.contains?(result, ".header {") - assert String.contains?(result, "color: green !important") - assert String.contains?(result, "font-weight: bold") - end - - test "handles declarations with comments" do - # Given: CSS with selector - css = """ - .header { - color: red; - } - """ - - # When: Replacing with declarations containing comments - {:ok, _, result} = - Parser.replace_selector_rule( - css, - ".header", - "color: green; /* Green color */ font-weight: bold;" - ) - - # Then: Should preserve comments - assert String.contains?(result, ".header {") - assert String.contains?(result, "color: green") - assert String.contains?(result, "/* Green color */") - assert String.contains?(result, "font-weight: bold") - end - - test "validates input declarations" do - # Given: CSS with selector - css = """ - .header { - color: red; - } - """ - - # When: Replacing with invalid declarations - result = Parser.replace_selector_rule(css, ".header", "invalid declaration") - - # Then: Should return error - case result do - {:error, _, error_message} -> - assert String.contains?(error_message, "Failed to parse CSS") - - {:ok, _, _} -> - flunk("Expected error for invalid declarations") - end - end - end - - describe "add_import/4" do - test "adds import to empty CSS" do - # Given: Empty CSS - css = "" - - # When: Adding an import - {:ok, :add_import, result} = Parser.add_import(css, "styles.css", false) - - # Then: Import should be added correctly - assert result == "@import 'styles.css';" - end - - test "adds import with URL" do - # Given: Empty CSS - css = "" - - # When: Adding an import with absolute URL - {:ok, :add_import, result} = Parser.add_import(css, "https://example.com/styles.css", false) - - # Then: Import should be added with url() syntax - assert result == "@import url('https://example.com/styles.css');" - end - - test "adds import with media query" do - # Given: Empty CSS - css = "" - - # When: Adding an import with a media query - {:ok, :add_import, result} = - Parser.add_import(css, "mobile.css", "screen and (max-width: 768px)") - - # Then: Import should include the media query - assert result == "@import 'mobile.css' screen and (max-width: 768px);" - end - - test "adds import to CSS with existing rules" do - # Given: CSS with existing rules - css = """ - body { - font-size: 16px; - } - """ - - # When: Adding an import - {:ok, :add_import, result} = Parser.add_import(css, "styles.css", false) - - # Then: Import should be added at the beginning - assert String.starts_with?(result, "@import 'styles.css';") - assert String.contains?(result, "body {") - end - - test "adds import after existing imports" do - # Given: CSS with an existing import - css = """ - @import 'base.css'; - - body { - font-size: 16px; - } - """ - - # When: Adding a new import - {:ok, :add_import, result} = Parser.add_import(css, "styles.css", false) - - # Then: New import should be after the existing import - assert String.contains?(result, "@import \"base.css\"") - assert String.contains?(result, "@import 'styles.css';") - end - - test "doesn't add duplicate imports" do - # Given: CSS with an existing import - css = """ - @import 'styles.css'; - - body { - font-size: 16px; - } - """ - - # When: Trying to add the same import again - {:ok, :add_import, result} = Parser.add_import(css, "styles.css", false) - - # Then: The CSS should remain unchanged - - assert elem(Parser.beautify(result), 2) == elem(Parser.beautify(css), 2) - - # And there should only be one occurrence of the import - assert result |> String.split("@import 'styles.css';") |> length() == 2 - end - - test "validates CSS before adding import" do - # Given: Invalid CSS with missing semicolon - css = """ - body { - color: red - font-size: 16px; - } - """ - - # When: Trying to add an import to invalid CSS - {:error, :add_import, error_message} = Parser.add_import(css, "styles.css", false) - - # Then: Should return a validation error - assert error_message =~ "Missing semicolon" - end - - test "validates CSS with unbalanced braces" do - # Given: Invalid CSS with unbalanced braces - css = """ - body { - color: red; - font-size: 16px; - - """ - - # When: Trying to add an import to invalid CSS - {:error, :add_import, error_message} = Parser.add_import(css, "styles.css", false) - - # Then: Should return a validation error about braces - assert error_message =~ "Unbalanced braces" - end - end - - describe "remove_import/3" do - test "removes import from CSS" do - # Given: CSS with an import - css = """ - @import 'styles.css'; - body { - font-size: 16px; - } - """ - - # When: Removing the import - {:ok, :remove_import, result} = Parser.remove_import(css, "styles.css") - - # Then: The import should be removed - refute String.contains?(result, "@import 'styles.css'") - assert String.contains?(result, "body {") - end - - test "removes URL import from CSS" do - # Given: CSS with an import using url() - css = """ - @import url('https://example.com/styles.css'); - body { - font-size: 16px; - } - """ - - # When: Removing the import - {:ok, :remove_import, result} = Parser.remove_import(css, "example.com/styles.css") - - # Then: The import should be removed - refute String.contains?(result, "@import url('https://example.com/styles.css')") - assert String.contains?(result, "body {") - end - - test "removes import with media query" do - # Given: CSS with an import with media query - css = """ - @import 'mobile.css' screen and (max-width: 768px); - body { - font-size: 16px; - } - """ - - # When: Removing the import - {:ok, :remove_import, result} = Parser.remove_import(css, "mobile.css") - - # Then: The import should be removed - refute String.contains?(result, "@import 'mobile.css'") - assert String.contains?(result, "body {") - end - - test "removes only matching import and keeps others" do - # Given: CSS with multiple imports - css = """ - @import 'base.css'; - @import 'styles.css'; - @import 'mobile.css' screen and (max-width: 768px); - body { - font-size: 16px; - } - """ - - # When: Removing just one import - {:ok, :remove_import, result} = Parser.remove_import(css, "styles.css") - - # Then: Only the matching import should be removed - assert String.contains?(result, "@import \"base.css\";") - refute String.contains?(result, "@import 'styles.css'") - assert String.contains?(result, "@import \"mobile.css\"") - end - - test "handles CSS with no matching import" do - # Given: CSS with no matching import - css = """ - @import 'base.css'; - body { - font-size: 16px; - } - """ - - # When: Trying to remove a non-existent import - {:ok, :remove_import, result} = Parser.remove_import(css, "styles.css") - - # Then: The CSS should remain unchanged - assert elem(Parser.beautify(result), 2) == elem(Parser.beautify(css), 2) - end - - test "handles empty CSS" do - # Given: Empty CSS - css = "" - - # When: Trying to remove an import - {:ok, :remove_import, result} = Parser.remove_import(css, "styles.css") - - # Then: The result should be empty too - assert result == "" - end - - test "validates CSS before removing import" do - # Given: Invalid CSS with missing semicolon - css = """ - body { - color: red - font-size: 16px; - } - """ - - # When: Trying to remove an import from invalid CSS - {:error, :remove_import, error_message} = Parser.remove_import(css, "styles.css") - - # Then: Should return a validation error - assert error_message =~ "Missing semicolon" - end - - test "validates CSS with unbalanced braces" do - # Given: Invalid CSS with unbalanced braces - css = """ - body { - color: red; - font-size: 16px; - """ - - # When: Trying to remove an import from invalid CSS - {:error, :remove_import, error_message} = Parser.remove_import(css, "styles.css") - - # Then: Should return a validation error about braces - assert error_message =~ "Unbalanced braces" - end - - test "partial URL matching works correctly" do - # Given: CSS with various import URLs - css = """ - @import url('https://example.com/styles.css'); - @import url('https://other-domain.com/styles.css'); - body { - font-size: 16px; - } - """ - - # When: Removing import with partial URL match - {:ok, :remove_import, result} = Parser.remove_import(css, "example.com") - - # Then: Only the matching import should be removed - refute String.contains?(result, "example.com") - assert String.contains?(result, "other-domain.com") - end - end - - describe "selector_exists?/3" do - test "returns true when selector exists" do - # Given: CSS with a specific selector - css = """ - .header { - color: blue; - font-size: 20px; - } - """ - - # When: Checking if the selector exists - {:ok, :selector_exists?, result} = Parser.selector_exists?(css, ".header") - - # Then: Should return true - assert result == true - end - - test "returns false when selector doesn't exist" do - # Given: CSS without the specific selector - css = """ - .header { - color: blue; - font-size: 20px; - } - """ - - # When: Checking if a non-existent selector exists - {:error, :selector_exists?, result} = Parser.selector_exists?(css, "#nonexistent") - - # Then: Should return false - assert result == false - end - - test "returns true for ID selectors" do - # Given: CSS with an ID selector - css = """ - #main { - width: 80%; - margin: 0 auto; - } - """ - - # When: Checking if the ID selector exists - {:ok, :selector_exists?, result} = Parser.selector_exists?(css, "#main") - - # Then: Should return true - assert result == true - end - - test "returns true for element selectors" do - # Given: CSS with an element selector - css = """ - body { - font-family: Arial, sans-serif; - line-height: 1.6; - } - """ - - # When: Checking if the element selector exists - {:ok, :selector_exists?, result} = Parser.selector_exists?(css, "body") - - # Then: Should return true - assert result == true - end - - test "returns true for attribute selectors" do - # Given: CSS with an attribute selector - css = """ - [type="text"] { - border: 1px solid #ccc; - padding: 5px; - } - """ - - # When: Checking if the attribute selector exists - {:ok, :selector_exists?, result} = Parser.selector_exists?(css, "[type=\"text\"]") - - # Then: Should return true - assert result == true - end - - test "returns true for pseudo-class selectors" do - # Given: CSS with a pseudo-class selector - css = """ - a:hover { - text-decoration: underline; - color: red; - } - """ - - # When: Checking if the pseudo-class selector exists - {:ok, :selector_exists?, result} = Parser.selector_exists?(css, "a:hover") - - # Then: Should return true - assert result == true - end - - test "returns true for compound selectors" do - # Given: CSS with a compound selector - css = """ - .container .box { - background: #f5f5f5; - padding: 10px; - } - """ - - # When: Checking if the compound selector exists - {:ok, :selector_exists?, result} = Parser.selector_exists?(css, ".container .box") - - # Then: Should return true - assert result == true - end - - test "returns false for partial selector match" do - # Given: CSS with a specific selector - css = """ - .header-container { - display: flex; - justify-content: space-between; - } - """ - - # When: Checking if a partial selector exists - {:error, :selector_exists?, result} = Parser.selector_exists?(css, ".header") - - # Then: Should return false (no partial matching) - assert result == false - end - - test "handles empty CSS" do - # Given: Empty CSS - css = "" - - # When: Checking if a selector exists in empty CSS - {:error, :selector_exists?, result} = Parser.selector_exists?(css, ".header") - - # Then: Should return false - assert result == false - end - - test "handles CSS with comments" do - # Given: CSS with comments and a selector - css = """ - /* Header styling */ - .header { - /* Primary color */ - color: blue; - font-size: 20px; - } - """ - - # When: Checking if the selector exists - {:ok, :selector_exists?, result} = Parser.selector_exists?(css, ".header") - - # Then: Should return true (ignores comments) - assert result == true - end - - test "handles multiple selectors separated by commas" do - # Given: CSS with multiple selectors for a rule - css = """ - .header, .footer { - background-color: #333; - color: white; - } - """ - - # When: Checking each individual selector - {:error, :selector_exists?, header_result} = Parser.selector_exists?(css, ".header") - {:error, :selector_exists?, footer_result} = Parser.selector_exists?(css, ".footer") - {:ok, :selector_exists?, combined_result} = Parser.selector_exists?(css, ".header, .footer") - - # Then: The individual selectors should return false, but the combined one returns true - # Note: This may need adjustment depending on how your parser handles comma-separated selectors - assert header_result == false - assert footer_result == false - assert combined_result == true - end - end + end describe "get_selector_properties/3" do - test "returns properties for an existing selector" do - # Given: CSS with a specific selector and properties - css = """ - .header { - color: blue; - font-size: 16px; - margin-top: 20px; - } - """ - - # When: Getting properties for the selector - - {:ok, :get_selector_properties, properties} = Parser.get_selector_properties(css, ".header") - - # Then: Should return a map of properties - assert properties != nil - assert properties["color"] == "blue" - assert properties["font-size"] == "16px" - assert properties["margin-top"] == "20px" - end - - test "returns nil for a non-existent selector" do - # Given: CSS without the specific selector - css = """ - .header { - color: blue; - font-size: 16px; - } - """ - - # When: Getting properties for a non-existent selector - {:ok, :get_selector_properties, properties} = - Parser.get_selector_properties(css, "#nonexistent") - - # Then: Should return nil - assert properties == nil - end - - test "handles properties with multiple values" do - # Given: CSS with properties that have multiple values - css = """ - .box { - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - font-family: Arial, Helvetica, sans-serif; - border: 1px solid #ccc; - } - """ - - # When: Getting properties for the selector - {:ok, :get_selector_properties, properties} = Parser.get_selector_properties(css, ".box") - - # Then: Should correctly capture multi-value properties - assert properties["box-shadow"] == "0 2px 4px rgba(0, 0, 0, 0.2)" - assert properties["font-family"] == "Arial, Helvetica, sans-serif" - assert properties["border"] == "1px solid #ccc" - end - - test "handles CSS variables and functions" do - # Given: CSS with variables and functions - css = """ - .modern { - --primary-color: #3498db; - color: var(--primary-color); - transform: translateY(-10px); - background: linear-gradient(to right, #f5f5f5, #e5e5e5); - } - """ - - # When: Getting properties for the selector - {:ok, :get_selector_properties, properties} = Parser.get_selector_properties(css, ".modern") - - # Then: Should correctly handle variables and functions - assert properties["--primary-color"] == "#3498db" - assert properties["color"] == "var(--primary-color)" - assert properties["transform"] == "translateY(-10px)" - assert String.contains?(properties["background"], "linear-gradient") - end - - test "handles comments within declarations" do - # Given: CSS with comments inside declarations - css = """ - .comment-test { - color: blue; /* This is a blue color */ - font-size: 16px; /* Standard size */ - } - """ - - # When: Getting properties for the selector - {:ok, :get_selector_properties, properties} = - Parser.get_selector_properties(css, ".comment-test") - - # Then: Should ignore comments in the values - assert properties["color"] == "blue" - assert properties["font-size"] == "16px" - end - - test "handles empty declarations" do - # Given: CSS with an empty declaration block - css = """ - .empty-block { - } - """ - - # When: Getting properties for the selector - {:ok, :get_selector_properties, properties} = - Parser.get_selector_properties(css, ".empty-block") - - # Then: Should return an empty map, not nil - assert properties == %{} - end - - test "handles invalid CSS gracefully" do - # Given: Invalid CSS with syntax errors - css = """ - .invalid { - color: red - font-size: 16px; - } - """ - - # When: Trying to get properties from invalid CSS - result = Parser.get_selector_properties(css, ".invalid") - - # Then: Should return an error or handle it gracefully - case result do - {:ok, :get_selector_properties, _} -> - # If your parser is robust enough to handle this error - :ok - - {:error, :get_selector_properties, error_message} -> - # If your parser properly reports the error - assert error_message =~ "parse" or error_message =~ "syntax" - end + test "returns a property map" do + assert {:ok, :get_selector_properties, %{"color" => "blue", "font-size" => "16px"}} = + Parser.get_selector_properties(".a { color: blue; font-size: 16px; }", ".a") end - test "gets only the first matching selector's properties" do - # Given: CSS with duplicate selectors - css = """ - .duplicate { - color: red; - } - - .other { - font-size: 20px; - } - - .duplicate { - font-weight: bold; - } - """ - - # When: Getting properties for the duplicate selector - {:ok, :get_selector_properties, properties} = - Parser.get_selector_properties(css, ".duplicate") - - # Then: Should return properties from the first occurrence only - assert properties["color"] == "red" - refute Map.has_key?(properties, "font-weight") + test "returns nil for a missing selector" do + assert {:ok, _, nil} = Parser.get_selector_properties(".a {}", "#nope") end - test "handles specific complex selectors" do - # Given: CSS with complex selectors - css = """ - .parent > .child { - color: green; - } - - #main input[type="text"] { - border: 1px solid gray; - } - """ - - # When: Getting properties for the complex selectors - {:ok, :get_selector_properties, parent_child} = - Parser.get_selector_properties(css, ".parent > .child") - - {:ok, :get_selector_properties, input_type} = - Parser.get_selector_properties(css, "#main input[type=\"text\"]") - - # Then: Should correctly identify and return properties - assert parent_child["color"] == "green" - assert input_type["border"] == "1px solid gray" + test "includes the !important flag in the value" do + assert {:ok, _, %{"color" => "red !important"}} = + Parser.get_selector_properties(".a { color: red !important; }", ".a") end end end diff --git a/test/support/css_case.ex b/test/support/css_case.ex new file mode 100644 index 0000000..26d8f53 --- /dev/null +++ b/test/support/css_case.ex @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.CssCase do + @moduledoc """ + Shared assertions for the CSS test suites. + + The interesting ones are `assert_idempotent/2`, `assert_comments_preserved/2` + and `assert_changed_lines/3` — the three properties that, together, are what + "diff-minimal codemod" actually means. + """ + + use ExUnit.CaseTemplate + + using do + quote do + import IgniterCss.CssCase + end + end + + @fixture_dir Path.expand("../fixtures", __DIR__) + + @doc "Read a fixture from `test/fixtures`." + def fixture(name), do: File.read!(Path.join(@fixture_dir, name)) + + @doc "Every fixture as `{name, contents}`, sorted." + def fixtures do + @fixture_dir + |> File.ls!() + |> Enum.filter(&String.ends_with?(&1, ".css")) + |> Enum.sort() + |> Enum.map(&{&1, fixture(&1)}) + end + + @doc """ + Assert that applying `fun` twice equals applying it once, and that the second + run reports `changed: false`. + """ + def assert_idempotent(source, fun) do + {:ok, once} = fun.(source) + {:ok, twice} = fun.(once.source) + + ExUnit.Assertions.assert( + once.source == twice.source, + "operation is not idempotent\n\nfirst:\n#{once.source}\nsecond:\n#{twice.source}" + ) + + ExUnit.Assertions.refute( + twice.changed, + "operation reported changed: true on the second run" + ) + + once + end + + @doc "Comment texts found in a stylesheet, sorted." + def comments(source) do + ~r{/\*.*?\*/|//[^\n]*}s + |> Regex.scan(strip_strings(source)) + |> List.flatten() + |> Enum.sort() + end + + # Blank out string literals so `content: "/* not a comment */"` is not + # counted, which would make the assertion below lie in both directions. + defp strip_strings(source) do + Regex.replace(~r/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/s, source, ~s|""|) + end + + @doc "Assert every comment in `before` still appears in `after_source`." + def assert_comments_preserved(before, after_source) do + missing = comments(before) -- comments(after_source) + + ExUnit.Assertions.assert( + missing == [], + "lost #{length(missing)} comment(s): #{inspect(missing)}" + ) + end + + @doc "Added plus removed lines between two versions, via an LCS diff." + def changed_lines(before, after_source) do + a = String.split(before, "\n") + b = String.split(after_source, "\n") + common = lcs_length(a, b) + length(a) - common + (length(b) - common) + end + + @doc "Assert a codemod changed no more than `budget` lines." + def assert_changed_lines(before, after_source, budget) do + actual = changed_lines(before, after_source) + + ExUnit.Assertions.assert( + actual <= budget, + "changed #{actual} lines, budget was #{budget}\n\nbefore:\n#{before}\nafter:\n#{after_source}" + ) + end + + # Standard LCS, right to left, one row at a time. Tuples rather than lists so + # the inner reads are O(1) -- the fixtures are small but this runs per op. + defp lcs_length(a, b) do + b_tuple = List.to_tuple(b) + width = tuple_size(b_tuple) + empty_row = Tuple.duplicate(0, width + 1) + + a + |> Enum.reverse() + |> Enum.reduce(empty_row, fn a_item, next_row -> + Enum.reduce(row_indices(width), next_row, fn j, current_row -> + value = + if a_item == elem(b_tuple, j) do + elem(next_row, j + 1) + 1 + else + max(elem(next_row, j), elem(current_row, j + 1)) + end + + put_elem(current_row, j, value) + end) + end) + |> elem(0) + end + + defp row_indices(0), do: [] + defp row_indices(width), do: (width - 1)..0//-1 +end diff --git a/test/transform_test.exs b/test/transform_test.exs new file mode 100644 index 0000000..d91a3e0 --- /dev/null +++ b/test/transform_test.exs @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.TransformTest do + use IgniterCss.CssCase, async: true + + doctest IgniterCss.Transform + + alias IgniterCss.Transform + + describe "minify/2" do + test "keeps a space the grammar needs" do + assert {:ok, "@media screen and (min-width:40em){.a{margin:1px -2px}}"} = + Transform.minify( + "@media screen and (min-width: 40em) {\n .a { margin: 1px -2px; }\n}\n" + ) + end + + test "never adds a space before a function paren" do + assert {:ok, ".a{background:url(a.png);transform:translate(1px)rotate(2deg)}"} = + Transform.minify( + ".a {\n background: url(a.png);\n transform: translate(1px) rotate(2deg);\n}\n" + ) + end + + test "preserves string contents exactly" do + css = ~s|.a::after {\n content: "a b /* not a comment */ ;";\n}\n| + assert {:ok, ~s|.a::after{content:"a b /* not a comment */ ;"}|} = Transform.minify(css) + end + + test "preserves non-ascii" do + assert {:ok, ~s|.a::after{content:"日本語 ✓"}|} = + Transform.minify(~s|.a::after {\n content: "日本語 ✓";\n}\n|) + end + + test "is idempotent" do + {:ok, once} = Transform.minify(".a {\n color: red;\n}\n") + assert {:ok, ^once} = Transform.minify(once) + end + + test "output still parses" do + css = "@media print {\n .a, .b > .c {\n margin: 0 auto !important;\n }\n}\n" + assert {:ok, minified} = Transform.minify(css) + assert {:ok, %{valid: true}} = IgniterCss.validate(minified) + end + + test "never grows a file, across the whole corpus" do + for {name, source} <- fixtures() do + assert {:ok, minified} = Transform.minify(source) + + assert byte_size(minified) <= byte_size(source), + "minifying grew #{name}" + end + end + + test "preserves a BOM" do + assert {:ok, ".a{color:red}"} = Transform.minify(".a { color: red; }\n") + end + end + + describe "beautify/2" do + test "indents nested blocks" do + assert {:ok, "@media print {\n .a {\n color: red;\n }\n}\n"} = + Transform.beautify("@media print{.a{color:red}}") + end + + test "keeps every comment, across the whole corpus" do + for {name, source} <- fixtures() do + assert {:ok, pretty} = Transform.beautify(source) + + missing = comments(source) -- comments(pretty) + assert missing == [], "beautify lost #{inspect(missing)} from #{name}" + end + end + + test "does not comment out code after a line comment" do + css = ".a {\n padding: 0; // breathing room\n}\n.b { color: red; }\n" + assert {:ok, pretty} = Transform.beautify(css) + assert pretty =~ ".b {" + assert {:ok, %{valid: true}} = IgniterCss.validate(pretty) + end + + test "is idempotent" do + {:ok, once} = Transform.beautify(".a{color:red;margin:0}@media print{.b{color:blue}}") + assert {:ok, ^once} = Transform.beautify(once) + end + + test "does not change what a stylesheet means" do + for {name, source} <- fixtures() do + assert {:ok, direct} = Transform.minify(source) + assert {:ok, pretty} = Transform.beautify(source) + assert {:ok, via_pretty} = Transform.minify(pretty) + assert direct == via_pretty, "beautify changed the meaning of #{name}" + end + end + + test "an empty sheet stays empty" do + assert {:ok, ""} = Transform.beautify("") + end + end + + describe "merge_stylesheets/2" do + test "preserves comments from every sheet" do + assert {:ok, merged} = Transform.merge_stylesheets(["/* one */\n.a {}", "/* two */\n.b {}"]) + assert merged =~ "/* one */" + assert merged =~ "/* two */" + end + + test "skips empty sheets" do + assert {:ok, ".a {}\n"} = Transform.merge_stylesheets(["", ".a {}", " "]) + end + + test "merged output still round-trips" do + assert {:ok, merged} = + Transform.merge_stylesheets([ + fixture("phoenix_app.css"), + fixture("kitchen_sink.css") + ]) + + # Round-tripping is the property that matters: the Phoenix fixture carries + # one parse diagnostic of its own (`@custom-variant ... *`), and merging + # must not add any, but it does not have to remove it either. + {_status, validation} = IgniterCss.validate(merged) + assert validation.round_trips + assert validation.diagnostics == 1 + end + end +end From 7f27d8541ff96bd92d5e6b4221665dcadcf53957 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 17:35:05 +0200 Subject: [PATCH 02/11] test: cover updating a declaration that already carries a trailing comment Found by mutation-testing the comment guarantees: the corpus sweep's `set_declaration_existing` op targets `.page`/`color`, but no fixture rule has that property, so the op always took the *append* branch. The *update* branch -- the one rule E governs, where only the value bytes may be replaced -- was never exercised against a declaration carrying a trailing comment anywhere in the sweep. Adds an op that sets `.page`/`display`, which in comments_everywhere.css is `display: flex; /* trailing on a declaration */`, so the sweep now asserts comment survival across that path on every fixture. Co-Authored-By: Claude Opus 5 (1M context) --- native/igniter_css/tests/corpus_invariants.rs | 15 +++++++++++++++ test/corpus_invariants_test.exs | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/native/igniter_css/tests/corpus_invariants.rs b/native/igniter_css/tests/corpus_invariants.rs index 4cad38b..55430e2 100644 --- a/native/igniter_css/tests/corpus_invariants.rs +++ b/native/igniter_css/tests/corpus_invariants.rs @@ -84,6 +84,21 @@ fn ops() -> Vec { ) .ok() }), + // `.page { display: flex; /* trailing on a declaration */ }` in the + // comments fixture: this is the only op that takes the *update* branch + // on a declaration that already carries a trailing comment, so without + // it the sweep never exercises rule E against a real comment. + ("set_declaration_over_a_commented_line", |s| { + set_declaration( + s, + ".page", + "display", + "block", + SetOptions::default(), + opts(), + ) + .ok() + }), ("remove_declaration", |s| { remove_declaration(s, ".page", "display", opts()).ok() }), diff --git a/test/corpus_invariants_test.exs b/test/corpus_invariants_test.exs index c47cb25..2f4a83a 100644 --- a/test/corpus_invariants_test.exs +++ b/test/corpus_invariants_test.exs @@ -31,6 +31,11 @@ defmodule IgniterCss.CorpusInvariantsTest do false}, {"set_declaration/existing", &IgniterCss.set_declaration(&1, ".page", "color", "rebeccapurple"), false}, + # `.page { display: flex; /* trailing on a declaration */ }` in the + # comments fixture: the only op here that takes the *update* branch over a + # declaration that already carries a trailing comment. + {"set_declaration/over-a-commented-line", + &IgniterCss.set_declaration(&1, ".page", "display", "block"), false}, {"remove_declaration", &IgniterCss.remove_declaration(&1, ".page", "display"), true}, {"append_raw_to_rule", &IgniterCss.append_raw_to_rule(&1, ".page", "outline: 1px solid;"), false}, From c1e37dee71dbcc88946cf46f010b814acc05cd73 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 17:52:04 +0200 Subject: [PATCH 03/11] feat: derive at-rule targets from the CST, and cover imports exhaustively Import de-duplication was deciding equivalence by scanning the prelude *text* for a quoted string or `url(...)`. That worked, but it was the one correctness- critical path in the codebase still reading characters instead of tokens, and it could in principle be fooled by a quote appearing somewhere unexpected. `AtRuleRef` now carries a `target` read from the CST: the first `CSS_STRING_LITERAL` or `CSS_URL_VALUE_RAW_LITERAL` token appearing before any block. Equivalence and removal both compare that. Text scanning survives in exactly one place -- `normalize_target_needle`, which unquotes the caller's `matching` argument -- because that argument is a bare path from Elixir, not CSS. It is documented as such. Consequences, all now asserted: - `@import "a.css"`, `'a.css'`, `url("a.css")`, `url(a.css)`, and the same with `screen` / `layer(base)` / `supports(...)` trailing, are one import - a quote inside a comment is not a target - a string inside a block is not a target, but one before the block is, so `@plugin "p" { ... }` dedupes against `@plugin "p";` - `@layer base, components;` has no target and falls back to prelude comparison Adds test/imports_test.exs (38 tests): the full quoting/wrapping matrix in both directions, near-miss targets that must stay distinct, ordering against @charset / prologue / style rules, media queries and modifiers, url quoting rules, CRLF/BOM/no-trailing-newline shape preservation, removal and Rule D, the other at-rule families, and an installer-shaped end-to-end run asserting one copy of each target after two passes. Rust: 335 tests. Elixir: 39 doctests + 237 tests. clippy, fmt, credo clean. Co-Authored-By: Claude Opus 5 (1M context) --- native/igniter_css/src/locate.rs | 37 +++ native/igniter_css/src/ops/at_rule.rs | 119 ++++---- native/igniter_css/tests/probe.rs | 15 + test/imports_test.exs | 381 ++++++++++++++++++++++++++ 4 files changed, 498 insertions(+), 54 deletions(-) create mode 100644 native/igniter_css/tests/probe.rs create mode 100644 test/imports_test.exs diff --git a/native/igniter_css/src/locate.rs b/native/igniter_css/src/locate.rs index 12a28cc..93030bb 100644 --- a/native/igniter_css/src/locate.rs +++ b/native/igniter_css/src/locate.rs @@ -56,6 +56,12 @@ pub struct AtRuleRef { pub name: String, /// Everything between the name and the `;` or `{`, trimmed. pub prelude: String, + /// The at-rule's subject, read from the CST: the first string literal or + /// `url()` value appearing before any block, unquoted. + /// + /// Token-derived rather than scanned out of `prelude`, so a quote inside a + /// comment or a later argument cannot be mistaken for the target. + pub target: Option, pub start: usize, pub end: usize, pub has_block: bool, @@ -307,6 +313,36 @@ fn rule_ref_from(node: &CssSyntaxNode, ctx: &ParseCtx) -> Option { }) } +/// Strip one layer of matching quotes from a string literal's text. +pub fn unquote(text: &str) -> String { + let t = text.trim(); + let bytes = t.as_bytes(); + if bytes.len() >= 2 { + let first = bytes[0]; + if (first == b'"' || first == b'\'') && bytes[bytes.len() - 1] == first { + return t[1..t.len() - 1].to_string(); + } + } + t.to_string() +} + +/// The first string-literal or raw-url token inside an at-rule, ignoring +/// anything inside its block. +fn at_rule_target_token(node: &CssSyntaxNode) -> Option { + for token in node.descendants_tokens(Direction::Next) { + match token.kind() { + // A block starts here; its contents are not the at-rule's subject. + CssSyntaxKind::L_CURLY => return None, + CssSyntaxKind::CSS_STRING_LITERAL => return Some(unquote(token.text_trimmed())), + CssSyntaxKind::CSS_URL_VALUE_RAW_LITERAL => { + return Some(token.text_trimmed().trim().to_string()) + } + _ => {} + } + } + None +} + fn at_rule_ref_from(node: &CssSyntaxNode, ctx: &ParseCtx) -> Option { if node.kind() != CssSyntaxKind::CSS_AT_RULE { return None; @@ -352,6 +388,7 @@ fn at_rule_ref_from(node: &CssSyntaxNode, ctx: &ParseCtx) -> Option { .to_string(); Some(AtRuleRef { + target: at_rule_target_token(node), node: node.clone(), name, prelude, diff --git a/native/igniter_css/src/ops/at_rule.rs b/native/igniter_css/src/ops/at_rule.rs index 0ceb302..5910303 100644 --- a/native/igniter_css/src/ops/at_rule.rs +++ b/native/igniter_css/src/ops/at_rule.rs @@ -73,45 +73,21 @@ fn collapse_ws(input: &str) -> String { out.trim().to_string() } -/// The first quoted string or `url(...)` in a prelude, unquoted. -pub fn at_rule_target(prelude: &str) -> Option { - let p = prelude.trim(); - let bytes = p.as_bytes(); - let mut i = 0usize; - while i < bytes.len() { - match bytes[i] { - b'"' | b'\'' => { - let quote = bytes[i]; - let start = i + 1; - let mut j = start; - while j < bytes.len() { - if bytes[j] == b'\\' { - j += 2; - continue; - } - if bytes[j] == quote { - return p.get(start..j).map(|s| s.to_string()); - } - j += 1; - } - return None; - } - _ => { - if p[i..].starts_with("url(") { - let start = i + 4; - let rest = &p[start..]; - let end = rest.find(')')?; - let inner = rest[..end].trim(); - let inner = inner - .trim_start_matches(['"', '\'']) - .trim_end_matches(['"', '\'']); - return Some(inner.to_string()); - } - i += 1; - } +/// Normalise a caller-supplied needle (e.g. the `matching` argument of +/// [`remove_at_rule`]) into the same shape as an AST-derived target. +/// +/// This is the one place we touch text rather than tokens, and deliberately so: +/// the argument is a bare path from Elixir, not CSS. Targets read *out of a +/// stylesheet* always come from `AtRuleRef::target`, which is token-derived. +pub fn normalize_target_needle(needle: &str) -> String { + let n = needle.trim(); + // Tolerate a caller writing `url("x")` or `"x"` instead of just `x`. + if let Some(rest) = n.strip_prefix("url(") { + if let Some(inner) = rest.strip_suffix(')') { + return crate::locate::unquote(inner); } } - None + crate::locate::unquote(n) } /// Parse a caller-supplied at-rule line such as `@plugin "daisyui";`. @@ -152,7 +128,8 @@ pub fn parse_at_rule_spec(line: &str) -> Result { let prelude = collapse_ws(&rule.prelude); Ok(AtRuleSpec { name: rule.name.clone(), - target: at_rule_target(&prelude), + // Read from the parsed line's own CST, not scanned out of the text. + target: rule.target.clone(), prelude, text, }) @@ -167,10 +144,15 @@ fn is_equivalent(spec: &AtRuleSpec, existing: &AtRuleRef) -> bool { if existing.name != spec.name { return false; } - let existing_prelude = collapse_ws(&existing.prelude); - match (&spec.target, at_rule_target(&existing_prelude)) { - (Some(a), Some(b)) => a == &b, - _ => existing_prelude == spec.prelude, + match (&spec.target, &existing.target) { + // Both name a subject: same subject means same at-rule, however each + // was quoted and whatever extra arguments follow. + (Some(a), Some(b)) => a == b, + // Neither has one (`@layer base, components;`): fall back to the + // whitespace-normalised prelude. + (None, None) => collapse_ws(&existing.prelude) == spec.prelude, + // One names a subject and the other does not: different rules. + _ => false, } } @@ -253,7 +235,7 @@ pub fn remove_at_rule( options: ParseOptions, ) -> Result { let want_name = name.trim_start_matches('@').to_lowercase(); - let want = matching.map(collapse_ws); + let want = matching.map(normalize_target_needle); run(source, options, |ctx| { let comments = comment_ranges(ctx); @@ -263,11 +245,10 @@ pub fn remove_at_rule( continue; } if let Some(w) = &want { - let prelude = collapse_ws(&at.prelude); - let hit = match (at_rule_target(&prelude), at_rule_target(w)) { - (Some(a), Some(b)) => a == b, - (Some(a), None) => a == *w, - _ => prelude == *w, + let hit = match &at.target { + Some(target) => target == w, + // No subject to match on (`@layer base;`): compare preludes. + None => collapse_ws(&at.prelude) == *w, }; if !hit { continue; @@ -615,11 +596,41 @@ mod tests { // -- targets ------------------------------------------------------------ #[test] - fn extracts_targets_from_preludes() { - assert_eq!(at_rule_target("\"a/b.css\""), Some("a/b.css".into())); - assert_eq!(at_rule_target("'a'"), Some("a".into())); - assert_eq!(at_rule_target("url(\"x\")"), Some("x".into())); - assert_eq!(at_rule_target("url(x)"), Some("x".into())); - assert_eq!(at_rule_target("base, components"), None); + fn targets_are_read_from_the_cst_not_scanned_from_text() { + use crate::ctx::ParseCtx; + use crate::locate::find_top_level_at_rules; + + let cases = [ + (r#"@import "a/b.css";"#, Some("a/b.css")), + (r#"@import 'a';"#, Some("a")), + (r#"@import url("x");"#, Some("x")), + ("@import url(x);", Some("x")), + ("@layer base, components;", None), + // A quote inside a comment must not be mistaken for the target. + (r#"@import /* "decoy" */ "real.css";"#, Some("real.css")), + // A block at-rule still has a subject when one precedes the `{`, + // so `@plugin "p" { ... }` dedupes against `@plugin "p";`. + (r#"@plugin "p" { name: "decoy"; }"#, Some("p")), + // But a string that only appears *inside* the block is not it. + (r#"@theme { --font: "decoy"; }"#, None), + ]; + + for (src, expected) in cases { + let ctx = ParseCtx::parse_default(src); + let rules = find_top_level_at_rules(&ctx); + assert_eq!( + rules[0].target.as_deref(), + expected, + "wrong target for {src}" + ); + } + } + + #[test] + fn a_caller_supplied_needle_is_unquoted() { + assert_eq!(normalize_target_needle("a.css"), "a.css"); + assert_eq!(normalize_target_needle("\"a.css\""), "a.css"); + assert_eq!(normalize_target_needle("url(\"a.css\")"), "a.css"); + assert_eq!(normalize_target_needle("url(a.css)"), "a.css"); } } diff --git a/native/igniter_css/tests/probe.rs b/native/igniter_css/tests/probe.rs new file mode 100644 index 0000000..b836782 --- /dev/null +++ b/native/igniter_css/tests/probe.rs @@ -0,0 +1,15 @@ +use igniter_css::ctx::ParseCtx; +#[test] +fn p() { + for src in [ + "@import \"a.css\";", + "@import url(\"/a.css\");", + "@import url(/a.css);", + "@import 'a.css' screen;", + "@plugin \"../vendor/x\";", + "@source \"../js\";", + ] { + println!("===== {src}"); + println!("{:#?}", ParseCtx::parse_default(src).syntax()); + } +} diff --git a/test/imports_test.exs b/test/imports_test.exs new file mode 100644 index 0000000..b0a9012 --- /dev/null +++ b/test/imports_test.exs @@ -0,0 +1,381 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.ImportsTest do + @moduledoc """ + Exhaustive coverage of at-rule insertion, de-duplication, ordering and + removal — the operations an Igniter installer leans on hardest, and the ones + where a duplicate or a misplaced `@import` silently breaks a user's build. + + De-duplication is decided on the at-rule's **subject**, read from the CST as a + string-literal or `url()` token, never scanned out of the text. That is why + every quoting and wrapping variant below collapses to the same import. + """ + + use IgniterCss.CssCase, async: true + + alias IgniterCss.Outcome + + describe "duplicate detection: the same target written differently" do + # Each of these denotes the same import as `@import "a.css";`. + @same_target [ + ~s|@import "a.css";|, + ~s|@import 'a.css';|, + ~s|@import url("a.css");|, + ~s|@import url('a.css');|, + ~s|@import url(a.css);|, + ~s|@import "a.css" screen;|, + ~s|@import "a.css" layer(base);|, + ~s|@import "a.css" supports(display: grid);|, + ~s|@import "a.css" screen and (min-width: 40em);|, + ~s|@import "a.css" ;| + ] + + test "no variant is added to a file that already has any other variant" do + for existing <- @same_target, candidate <- @same_target do + source = existing <> "\n" + + assert {:ok, %Outcome{changed: false, source: ^source}} = + IgniterCss.ensure_at_rule(source, candidate), + "adding #{candidate} to #{inspect(source)} created a duplicate" + end + end + + test "has_at_rule? agrees with ensure_at_rule for every variant" do + for existing <- @same_target, candidate <- @same_target do + assert {:ok, true} = IgniterCss.has_at_rule?(existing <> "\n", candidate) + end + end + + test "add_import/4 sees them all as the same import too" do + for existing <- @same_target do + assert {:ok, %Outcome{changed: false}} = + IgniterCss.add_import(existing <> "\n", "a.css"), + "add_import duplicated against #{existing}" + end + end + + test "remove_import/3 finds them all" do + for existing <- @same_target do + assert {:ok, %Outcome{changed: true, source: ""}} = + IgniterCss.remove_import(existing <> "\n", "a.css"), + "remove_import did not match #{existing}" + end + end + + test "a caller may write the needle quoted or wrapped" do + source = ~s|@import "a.css";\n| + + for needle <- [~s|a.css|, ~s|"a.css"|, ~s|url(a.css)|, ~s|url("a.css")|] do + assert {:ok, %Outcome{changed: true, source: ""}} = + IgniterCss.remove_import(source, needle), + "needle #{needle} did not match" + end + end + end + + describe "duplicate detection: targets that only look alike" do + test "different paths are different imports" do + for {a, b} <- [ + {"a.css", "b.css"}, + {"a.css", "./a.css"}, + {"a.css", "a.css "}, + {"dir/a.css", "a.css"}, + {"a.css", "a.CSS"} + ] do + source = ~s|@import "#{a}";\n| + {:ok, out} = IgniterCss.add_import(source, String.trim(b)) + + if String.trim(a) == String.trim(b) do + refute out.changed + else + assert out.changed, "#{inspect(a)} and #{inspect(b)} were wrongly merged" + end + end + end + + test "the same target under a different at-rule is not a duplicate" do + source = ~s|@import "x";\n| + assert {:ok, %Outcome{changed: true}} = IgniterCss.ensure_at_rule(source, ~s|@plugin "x";|) + end + + test "a string inside a comment is not mistaken for a target" do + source = ~s|/* @import "a.css"; */\n@import "b.css";\n| + assert {:ok, %Outcome{changed: true}} = IgniterCss.add_import(source, "a.css") + end + + test "a string inside a block is not mistaken for a target" do + source = ~s|@plugin "real" {\n name: "decoy";\n}\n| + + assert {:ok, %Outcome{changed: false}} = + IgniterCss.ensure_at_rule(source, ~s|@plugin "real";|) + + assert {:ok, %Outcome{changed: true}} = + IgniterCss.ensure_at_rule(source, ~s|@plugin "decoy";|) + end + + test "a target inside a nested at-rule does not count as top level" do + source = ~s|@media print {\n @import "a.css";\n}\n| + assert {:ok, %Outcome{changed: true}} = IgniterCss.add_import(source, "a.css") + end + end + + describe "de-duplicating a file that already has duplicates" do + test "two identical imports both match, and removal clears both" do + source = ~s|@import "a.css";\n@import "a.css";\n.x {}\n| + assert {:ok, %Outcome{source: ".x {}\n"}} = IgniterCss.remove_import(source, "a.css") + end + + test "ensure_at_rule does not add a third" do + source = ~s|@import "a.css";\n@import url(a.css);\n| + + assert {:ok, %Outcome{changed: false}} = + IgniterCss.ensure_at_rule(source, ~s|@import "a.css";|) + end + + test "remove_at_rule with no filter clears every import of that name" do + source = ~s|@import "a";\n@import "b";\n@plugin "c";\n.x {}\n| + + assert {:ok, %Outcome{source: ~s|@plugin "c";\n.x {}\n|}} = + IgniterCss.remove_at_rule(source, "import") + end + end + + describe "ordering" do + test "an import lands after the last existing import" do + source = ~s|@import "a";\n@import "b";\n@plugin "p";\n.x {}\n| + {:ok, out} = IgniterCss.add_import(source, "c") + assert out.source == ~s|@import "a";\n@import "b";\n@import "c";\n@plugin "p";\n.x {}\n| + end + + test "an import never lands after a style rule" do + {:ok, out} = IgniterCss.add_import(".x { color: red; }\n", "a.css") + assert out.source == ~s|@import "a.css";\n.x { color: red; }\n| + end + + test "an import lands after @charset, never before it" do + source = ~s|@charset "utf-8";\n.x {}\n| + {:ok, out} = IgniterCss.add_import(source, "a.css") + assert out.source == ~s|@charset "utf-8";\n@import "a.css";\n.x {}\n| + end + + test "a non-prologue at-rule lands at the end of the prologue" do + source = ~s|@import "tailwindcss";\n@source "../js";\n\n.x {}\n| + {:ok, out} = IgniterCss.ensure_at_rule(source, ~s|@plugin "daisyui";|) + + assert out.source == + ~s|@import "tailwindcss";\n@source "../js";\n@plugin "daisyui";\n\n.x {}\n| + end + + test "an import goes above a plugin even when the plugin came first" do + source = ~s|@plugin "p";\n.x {}\n| + {:ok, out} = IgniterCss.add_import(source, "a.css") + assert out.source == ~s|@import "a.css";\n@plugin "p";\n.x {}\n| + end + + test "ordering holds on a real Phoenix app.css" do + source = fixture("phoenix_app.css") + {:ok, out} = IgniterCss.add_import(source, "./extra.css") + + lines = String.split(out.source, "\n") + import_at = Enum.find_index(lines, &String.starts_with?(&1, ~s|@import "./extra.css"|)) + first_rule_at = Enum.find_index(lines, &String.starts_with?(&1, "[")) + + assert import_at < first_rule_at + assert_changed_lines(source, out.source, 1) + assert_comments_preserved(source, out.source) + end + end + + describe "media queries and modifiers" do + test "a media query is carried onto the generated line" do + {:ok, out} = IgniterCss.add_import("", "m.css", "screen and (max-width: 768px)") + assert out.source == ~s|@import "m.css" screen and (max-width: 768px);\n| + end + + test "adding the same target with a different media query does not duplicate" do + source = ~s|@import "m.css" print;\n| + assert {:ok, %Outcome{changed: false}} = IgniterCss.add_import(source, "m.css", "screen") + end + + test "an empty or nil media query is omitted" do + for media <- [nil, "", " "] do + {:ok, out} = IgniterCss.add_import("", "m.css", media) + assert out.source == ~s|@import "m.css";\n| + end + end + + test "layer() and supports() modifiers survive untouched when adding a sibling" do + source = ~s|@import "a.css" layer(base) supports(display: grid);\n| + {:ok, out} = IgniterCss.add_import(source, "b.css") + assert out.source == source <> ~s|@import "b.css";\n| + end + end + + describe "url quoting" do + test "relative paths are quoted, absolute ones wrapped in url()" do + for {url, expected} <- [ + {"styles.css", ~s|@import "styles.css";\n|}, + {"./a/b.css", ~s|@import "./a/b.css";\n|}, + {"../vendor/x.css", ~s|@import "../vendor/x.css";\n|}, + {"/absolute.css", ~s|@import url("/absolute.css");\n|}, + {"https://x/y.css", ~s|@import url("https://x/y.css");\n|}, + {"http://x/y.css", ~s|@import url("http://x/y.css");\n|} + ] do + assert {:ok, %Outcome{source: ^expected}} = IgniterCss.add_import("", url) + end + end + + test "a url that cannot be quoted safely is rejected" do + for bad <- [~s|a"b|, "a\nb", "", " "] do + assert {:error, _} = IgniterCss.add_import("", bad) + end + end + + test "a non-ascii path round-trips" do + {:ok, out} = IgniterCss.add_import("", "./styles/café.css") + assert out.source == ~s|@import "./styles/café.css";\n| + + assert {:ok, %Outcome{changed: false}} = + IgniterCss.add_import(out.source, "./styles/café.css") + end + end + + describe "file shape is preserved" do + test "CRLF files stay CRLF" do + source = ~s|@import "a";\r\n.x {}\r\n| + {:ok, out} = IgniterCss.add_import(source, "b") + assert out.source == ~s|@import "a";\r\n@import "b";\r\n.x {}\r\n| + end + + test "a BOM survives" do + {:ok, out} = IgniterCss.add_import(".x {}\n", "a.css") + assert out.source == ~s|@import "a.css";\n.x {}\n| + end + + test "a missing trailing newline stays missing" do + {:ok, out} = IgniterCss.add_import(".x {}", "a.css") + assert out.source == ~s|@import "a.css";\n.x {}| + end + + test "an import lands below a file header comment but above a rule's comment" do + header = "/* App styles.\n Two lines. */\n\n.x {}\n" + {:ok, out} = IgniterCss.add_import(header, "a.css") + assert out.source == ~s|/* App styles.\n Two lines. */\n@import "a.css";\n\n.x {}\n| + + attached = "/* about .x */\n.x {}\n" + {:ok, out2} = IgniterCss.add_import(attached, "a.css") + assert out2.source == ~s|/* about .x */\n@import "a.css";\n.x {}\n| + end + end + + describe "removal keeps the file coherent" do + test "removing an import takes the comment it owns but not a section header" do + source = ~s|/* ===== Imports ===== */\n/* the base sheet */\n@import "a";\n@import "b";\n| + {:ok, out} = IgniterCss.remove_import(source, "a") + assert out.source == ~s|/* ===== Imports ===== */\n@import "b";\n| + end + + test "removing an absent import is a no-op" do + source = ~s|@import "b";\n| + + assert {:ok, %Outcome{changed: false, source: ^source}} = + IgniterCss.remove_import(source, "a") + end + + test "removal is idempotent" do + assert_idempotent(~s|@import "a";\n@import "b";\n|, &IgniterCss.remove_import(&1, "a")) + end + + test "removing every import leaves valid CSS" do + source = fixture("kitchen_sink.css") + {:ok, out} = IgniterCss.remove_at_rule(source, "import") + {_, validation} = IgniterCss.validate(out.source) + assert validation.round_trips + + assert {:ok, %{imports_count: 0}} = IgniterCss.analyze(out.source) + end + end + + describe "other at-rule families dedupe the same way" do + test "@plugin, @source, @use and @reference" do + for {name, target} <- [ + {"plugin", "../vendor/daisyui"}, + {"source", "../js"}, + {"reference", "../../app.css"} + ] do + line = ~s|@#{name} "#{target}";| + {:ok, first} = IgniterCss.ensure_at_rule("", line) + assert first.changed + + # Same target, single quotes: still a duplicate. + alt = ~s|@#{name} '#{target}';| + assert {:ok, %Outcome{changed: false}} = IgniterCss.ensure_at_rule(first.source, alt) + + # Different target: added. + assert {:ok, %Outcome{changed: true}} = + IgniterCss.ensure_at_rule(first.source, ~s|@#{name} "other";|) + end + end + + test "@layer has no target, so it dedupes on the whole prelude" do + source = "@layer base, components;\n" + + assert {:ok, %Outcome{changed: false}} = + IgniterCss.ensure_at_rule(source, "@layer base, components;") + + assert {:ok, %Outcome{changed: false}} = + IgniterCss.ensure_at_rule(source, "@layer base, components;") + + assert {:ok, %Outcome{changed: true}} = + IgniterCss.ensure_at_rule(source, "@layer utilities;") + end + + test "a block at-rule dedupes against its non-block form" do + source = ~s|@plugin "../vendor/daisyui" {\n themes: false;\n}\n| + + assert {:ok, %Outcome{changed: false}} = + IgniterCss.ensure_at_rule(source, ~s|@plugin "../vendor/daisyui";|) + end + end + + describe "installer-shaped end to end" do + test "adding a full Tailwind v4 plugin set twice yields one copy of each" do + # Targets, not whole lines: the fixture already carries + # `@import "tailwindcss" source(none);`, so the literal line + # `@import "tailwindcss";` is correctly never written — dedup is decided + # on the subject, and asserting on the subject is what proves it. + lines = [ + {~s|@import "tailwindcss";|, ~s|"tailwindcss"|}, + {~s|@plugin "@tailwindcss/forms";|, ~s|"@tailwindcss/forms"|}, + {~s|@plugin "@tailwindcss/typography";|, ~s|"@tailwindcss/typography"|}, + {~s|@source "../js";|, ~s|"../js"|}, + {~s|@source "../../lib/my_app_web";|, ~s|"../../lib/my_app_web"|} + ] + + install = fn source -> + Enum.reduce(lines, source, fn {line, _target}, acc -> + {:ok, out} = IgniterCss.ensure_at_rule(acc, line) + out.source + end) + end + + once = install.(fixture("phoenix_app.css")) + twice = install.(once) + + assert once == twice + + for {line, target} <- lines do + assert {:ok, true} = IgniterCss.has_at_rule?(once, line) + + occurrences = length(String.split(once, target)) - 1 + assert occurrences == 1, "target #{target} appears #{occurrences} times" + end + + assert_comments_preserved(fixture("phoenix_app.css"), once) + {_, validation} = IgniterCss.validate(once) + assert validation.round_trips + end + end +end From 7f528a61622b5d5dc6e5b12bc7173e78d3e81c45 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 17:55:58 +0200 Subject: [PATCH 04/11] test: prove every selector and property shape on both sides of the NIF Answers "can it change any line, in any class, id or tag?" with a table rather than an assurance. 22 selector shapes -- class, id, tag, universal, attribute (quoted and bare), pseudo-class, pseudo-element, functional pseudo, :not(), :where(), :root, descendant, child, adjacent and general sibling, selector list, tag.class, compound chain, escaped `\/`, non-ASCII, double class -- each run through the full lifecycle: update a value, append a declaration, query, remove a declaration, remove the rule. The same table exists in native/igniter_css/tests/selectors.rs and test/selectors_test.exs, so the behaviour is proven directly and through the NIF boundary rather than assumed to survive marshalling. Each shape additionally asserts: - an equivalent spelling matches and can drive a real edit (`.a>.b` == `.a > .b`, `nav ul li` == `nav ul li`) - a near miss never matches (`.btn-primary` for `.btn`, `.a .b` for `.a > .b`, `:nth-child(2n)` for `:nth-child(2n+1)`) and never removes anything - comments in every awkward position survive, and an update touches 2 lines - all three mutating ops are idempotent Property shapes get the same treatment: standard, hyphenated, custom property, vendor prefixed, shorthand, function value, nested functions, a `url()` containing a semicolon, a non-ASCII string value, grid-template, and the `!important` lifecycle (set, preserved on update, cleared on request). Scoping is pinned down too: a rule inside `@media` is unreachable at the top level, the same selector at two scopes only edits the top-level one, and two top-level rules with one selector is an error rather than a guess. Rust: 340 tests. Elixir: 39 doctests + 268 tests. clippy, fmt, credo clean. Co-Authored-By: Claude Opus 5 (1M context) --- native/igniter_css/tests/selectors.rs | 290 ++++++++++++++++++++++++++ test/selectors_test.exs | 222 ++++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 native/igniter_css/tests/selectors.rs create mode 100644 test/selectors_test.exs diff --git a/native/igniter_css/tests/selectors.rs b/native/igniter_css/tests/selectors.rs new file mode 100644 index 0000000..0f5af3e --- /dev/null +++ b/native/igniter_css/tests/selectors.rs @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: 2025 igniter_css contributors +// +// SPDX-License-Identifier: MIT + +//! "Can it change any line, in any class, id or tag?" +//! +//! Table-driven coverage of every selector shape a real stylesheet uses, run +//! through the full lifecycle: update a value, append a declaration, query, +//! remove a declaration, remove the rule. The Elixir suite mirrors this table +//! through the NIF, so both sides of the boundary are covered. + +use igniter_css::ctx::ParseOptions; +use igniter_css::ops::declaration::{ + get_declaration, has_declaration, remove_declaration, set_declaration, SetOptions, +}; +use igniter_css::ops::rule::{has_rule, remove_rule}; + +fn opts() -> ParseOptions { + ParseOptions::default() +} + +/// `(label, selector, an equivalent spelling, a near miss that must NOT match)` +fn selectors() -> Vec<(&'static str, &'static str, &'static str, &'static str)> { + vec![ + ("class", ".btn", ".btn", ".btn-primary"), + ("id", "#main", "#main", "#mai"), + ("tag", "div", "div", "div span"), + ("universal", "*", "*", "*.x"), + ( + "attribute", + r#"a[href^="https://"]"#, + r#"a[href^="https://"]"#, + r#"a[href^="http://"]"#, + ), + ( + "attribute bare", + "[data-phx-session]", + "[data-phx-session]", + "[data-phx]", + ), + ("pseudo class", "a:hover", "a:hover", "a:focus"), + ( + "pseudo element", + "p::first-line", + "p::first-line", + "p::first-letter", + ), + ( + "functional pseudo", + "li:nth-child(2n+1)", + "li:nth-child(2n+1)", + "li:nth-child(2n)", + ), + ( + "not()", + "input:not([disabled])", + "input:not([disabled])", + "input:not([readonly])", + ), + ( + "where()", + ":where(h1, h2)", + ":where(h1, h2)", + ":where(h1, h3)", + ), + ("root", ":root", ":root", ":host"), + ("descendant", "nav ul li", "nav ul li", "nav ul"), + ("child", ".a > .b", ".a>.b", ".a .b"), + ("adjacent sibling", ".a + .b", ".a+.b", ".a ~ .b"), + ("general sibling", ".a ~ .b", ".a~.b", ".a + .b"), + ("selector list", ".a, .b", ".a,.b", ".a"), + ( + "tag with class", + "button.primary", + "button.primary", + "button", + ), + ( + "compound chain", + "#app .card > h2:first-child", + "#app .card>h2:first-child", + "#app .card h2", + ), + ("escaped slash", r".w-1\/2", r".w-1\/2", ".w-1"), + ("non ascii", ".café", ".café", ".cafe"), + ("double class", ".a.b", ".a.b", ".a .b"), + ] +} + +/// A rule carrying a comment in every awkward position, so each lifecycle step +/// also proves comment survival for that selector shape. +fn rule_for(selector: &str) -> String { + format!( + "/* above {selector} */\n{selector} {{\n color: red; /* trailing */\n margin: 0;\n}}\n" + ) +} + +#[test] +fn every_selector_shape_supports_the_full_lifecycle() { + for (label, selector, _, _) in selectors() { + let src = rule_for(selector); + + // 1. update an existing value -- comment on that line must survive + let updated = set_declaration( + &src, + selector, + "color", + "blue", + SetOptions::default(), + opts(), + ) + .unwrap_or_else(|e| panic!("{label}: set failed: {e}")); + assert!(updated.changed, "{label}: set reported no change"); + assert!( + updated.source.contains("color: blue; /* trailing */"), + "{label}: value-only replacement failed\n{}", + updated.source + ); + assert!( + updated.source.contains(&format!("/* above {selector} */")), + "{label}: lost the leading comment" + ); + + // 2. append a new declaration + let appended = set_declaration( + &updated.source, + selector, + "padding", + "1rem", + SetOptions::default(), + opts(), + ) + .unwrap_or_else(|e| panic!("{label}: append failed: {e}")); + assert!( + appended.source.contains("padding: 1rem;"), + "{label}: append missing" + ); + + // 3. queries + assert!( + has_rule(&appended.source, selector, opts()).unwrap(), + "{label}: has_rule false" + ); + assert!( + has_declaration(&appended.source, selector, "padding", opts()).unwrap(), + "{label}: has_declaration false" + ); + assert_eq!( + get_declaration(&appended.source, selector, "color", opts()).unwrap(), + Some("blue".to_string()), + "{label}: get_declaration wrong" + ); + + // 4. remove a declaration + let removed = remove_declaration(&appended.source, selector, "padding", opts()) + .unwrap_or_else(|e| panic!("{label}: remove_declaration failed: {e}")); + assert!( + !removed.source.contains("padding: 1rem;"), + "{label}: not removed" + ); + + // 5. remove the rule entirely + let gone = remove_rule(&removed.source, selector, opts()) + .unwrap_or_else(|e| panic!("{label}: remove_rule failed: {e}")); + assert!(gone.changed, "{label}: remove_rule reported no change"); + assert!( + !has_rule(&gone.source, selector, opts()).unwrap(), + "{label}: rule survived removal" + ); + } +} + +#[test] +fn every_selector_shape_is_idempotent() { + for (label, selector, _, _) in selectors() { + let src = rule_for(selector); + + let once = set_declaration( + &src, + selector, + "padding", + "1rem", + SetOptions::default(), + opts(), + ) + .unwrap(); + let twice = set_declaration( + &once.source, + selector, + "padding", + "1rem", + SetOptions::default(), + opts(), + ) + .unwrap(); + assert_eq!(once.source, twice.source, "{label}: set not idempotent"); + assert!(!twice.changed, "{label}: set changed on second run"); + + let once = remove_rule(&src, selector, opts()).unwrap(); + let twice = remove_rule(&once.source, selector, opts()).unwrap(); + assert_eq!(once.source, twice.source, "{label}: remove not idempotent"); + assert!(!twice.changed, "{label}: remove changed on second run"); + } +} + +#[test] +fn an_equivalent_spelling_matches_and_a_near_miss_does_not() { + for (label, selector, equivalent, near_miss) in selectors() { + let src = rule_for(selector); + + assert!( + has_rule(&src, equivalent, opts()).unwrap(), + "{label}: {equivalent:?} should match {selector:?}" + ); + assert!( + !has_rule(&src, near_miss, opts()).unwrap(), + "{label}: {near_miss:?} must NOT match {selector:?}" + ); + + // And the equivalent spelling can drive a real edit. + let out = set_declaration( + &src, + equivalent, + "color", + "green", + SetOptions::default(), + opts(), + ) + .unwrap_or_else(|e| panic!("{label}: edit via {equivalent:?} failed: {e}")); + assert!(out.changed, "{label}: edit via {equivalent:?} did nothing"); + } +} + +#[test] +fn every_selector_shape_survives_a_round_trip_unchanged_when_nothing_applies() { + for (label, selector, _, near_miss) in selectors() { + let src = rule_for(selector); + + // Removing a rule that isn't there must not touch the file. + let out = remove_rule(&src, near_miss, opts()).unwrap(); + assert!( + !out.changed, + "{label}: near miss {near_miss:?} removed something" + ); + assert_eq!(out.source, src, "{label}: file changed for a no-op"); + } +} + +/// Declarations are edited by property, so every property shape must work too. +#[test] +fn every_property_shape_can_be_set_and_removed() { + let properties = [ + ("standard", "color", "red"), + ("hyphenated", "background-color", "#fff"), + ("custom property", "--brand", "#4f46e5"), + ("vendor prefixed", "-webkit-user-select", "none"), + ("shorthand", "margin", "0 auto 10px"), + ("function value", "background", "var(--x, #fff)"), + ("multi function", "transform", "translate(1px) rotate(2deg)"), + ( + "url value", + "background-image", + "url(data:image/svg+xml;base64,AA==)", + ), + ("string value", "content", "\"→ ✨\""), + ( + "grid template", + "grid-template-columns", + "minmax(12rem, 1fr) 3fr", + ), + ]; + + for (label, property, value) in properties { + let src = ".x {\n z-index: 1;\n}\n"; + let out = set_declaration(src, ".x", property, value, SetOptions::default(), opts()) + .unwrap_or_else(|e| panic!("{label}: set failed: {e}")); + assert!(out.changed, "{label}: nothing changed"); + assert_eq!( + get_declaration(&out.source, ".x", property, opts()).unwrap(), + Some(value.to_string()), + "{label}: value did not round-trip" + ); + + let gone = remove_declaration(&out.source, ".x", property, opts()).unwrap(); + assert_eq!( + gone.source, src, + "{label}: removal did not restore the original" + ); + } +} diff --git a/test/selectors_test.exs b/test/selectors_test.exs new file mode 100644 index 0000000..136d2b4 --- /dev/null +++ b/test/selectors_test.exs @@ -0,0 +1,222 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.SelectorsTest do + @moduledoc """ + "Can it change any line, in any class, id or tag?" + + Mirrors `native/igniter_css/tests/selectors.rs` through the NIF, so the same + table is proven on both sides of the boundary. Each selector shape goes + through the full lifecycle — update, append, query, remove a declaration, + remove the rule — with comments in awkward positions throughout. + """ + + use IgniterCss.CssCase, async: true + + # {label, selector, an equivalent spelling, a near miss that must NOT match} + @selectors [ + {"class", ".btn", ".btn", ".btn-primary"}, + {"id", "#main", "#main", "#mai"}, + {"tag", "div", "div", "div span"}, + {"universal", "*", "*", "*.x"}, + {"attribute", ~s|a[href^="https://"]|, ~s|a[href^="https://"]|, ~s|a[href^="http://"]|}, + {"attribute bare", "[data-phx-session]", "[data-phx-session]", "[data-phx]"}, + {"pseudo class", "a:hover", "a:hover", "a:focus"}, + {"pseudo element", "p::first-line", "p::first-line", "p::first-letter"}, + {"functional pseudo", "li:nth-child(2n+1)", "li:nth-child(2n+1)", "li:nth-child(2n)"}, + {"not()", "input:not([disabled])", "input:not([disabled])", "input:not([readonly])"}, + {"where()", ":where(h1, h2)", ":where(h1, h2)", ":where(h1, h3)"}, + {"root", ":root", ":root", ":host"}, + {"descendant", "nav ul li", "nav ul li", "nav ul"}, + {"child", ".a > .b", ".a>.b", ".a .b"}, + {"adjacent sibling", ".a + .b", ".a+.b", ".a ~ .b"}, + {"general sibling", ".a ~ .b", ".a~.b", ".a + .b"}, + {"selector list", ".a, .b", ".a,.b", ".a"}, + {"tag with class", "button.primary", "button.primary", "button"}, + {"compound chain", "#app .card > h2:first-child", "#app .card>h2:first-child", + "#app .card h2"}, + {"escaped slash", ~S|.w-1\/2|, ~S|.w-1\/2|, ".w-1"}, + {"non ascii", ".café", ".café", ".cafe"}, + {"double class", ".a.b", ".a.b", ".a .b"} + ] + + defp rule_for(selector) do + """ + /* above #{selector} */ + #{selector} { + color: red; /* trailing */ + margin: 0; + } + """ + end + + describe "full lifecycle" do + for {label, selector, _equivalent, _near_miss} <- @selectors do + test "#{label}: #{selector} can be updated, extended, queried and removed" do + selector = unquote(selector) + src = rule_for(selector) + + # update a value -- only the value bytes may change + assert {:ok, %{changed: true} = updated} = + IgniterCss.set_declaration(src, selector, "color", "blue") + + assert updated.source =~ "color: blue; /* trailing */" + assert updated.source =~ "/* above #{selector} */" + assert_comments_preserved(src, updated.source) + assert_changed_lines(src, updated.source, 2) + + # append a declaration + assert {:ok, %{changed: true} = appended} = + IgniterCss.set_declaration(updated.source, selector, "padding", "1rem") + + assert appended.source =~ "padding: 1rem;" + assert_comments_preserved(src, appended.source) + + # queries + assert {:ok, true} = IgniterCss.has_rule?(appended.source, selector) + assert {:ok, true} = IgniterCss.has_declaration?(appended.source, selector, "padding") + assert {:ok, "blue"} = IgniterCss.get_declaration(appended.source, selector, "color") + + assert {:ok, [{"color", "blue"}, {"margin", "0"}, {"padding", "1rem"}]} = + IgniterCss.get_rule_declarations(appended.source, selector) + + # remove a declaration + assert {:ok, %{changed: true} = removed} = + IgniterCss.remove_declaration(appended.source, selector, "padding") + + refute removed.source =~ "padding: 1rem;" + + # remove the whole rule + assert {:ok, %{changed: true} = gone} = IgniterCss.remove_rule(removed.source, selector) + assert {:ok, false} = IgniterCss.has_rule?(gone.source, selector) + end + end + end + + describe "matching" do + test "an equivalent spelling matches and can drive an edit" do + for {label, selector, equivalent, _} <- @selectors do + src = rule_for(selector) + + assert {:ok, true} = IgniterCss.has_rule?(src, equivalent), + "#{label}: #{equivalent} should match #{selector}" + + assert {:ok, %{changed: true}} = + IgniterCss.set_declaration(src, equivalent, "color", "green"), + "#{label}: editing via #{equivalent} did nothing" + end + end + + test "a near miss never matches" do + for {label, selector, _, near_miss} <- @selectors do + src = rule_for(selector) + + assert {:ok, false} = IgniterCss.has_rule?(src, near_miss), + "#{label}: #{near_miss} must not match #{selector}" + + assert {:ok, %{changed: false, source: ^src}} = IgniterCss.remove_rule(src, near_miss), + "#{label}: #{near_miss} removed something" + end + end + + test "every shape is idempotent" do + for {label, selector, _, _} <- @selectors do + src = rule_for(selector) + + assert_idempotent(src, &IgniterCss.set_declaration(&1, selector, "padding", "1rem")) + assert_idempotent(src, &IgniterCss.remove_rule(&1, selector)) + assert_idempotent(src, &IgniterCss.remove_declaration(&1, selector, "color")) + _ = label + end + end + end + + describe "property shapes" do + @properties [ + {"standard", "color", "red"}, + {"hyphenated", "background-color", "#fff"}, + {"custom property", "--brand", "#4f46e5"}, + {"vendor prefixed", "-webkit-user-select", "none"}, + {"shorthand", "margin", "0 auto 10px"}, + {"function value", "background", "var(--x, #fff)"}, + {"multi function", "transform", "translate(1px) rotate(2deg)"}, + {"url with a semicolon", "background-image", "url(data:image/svg+xml;base64,AA==)"}, + {"string value", "content", ~s|"→ ✨"|}, + {"grid template", "grid-template-columns", "minmax(12rem, 1fr) 3fr"}, + # Deliberately not `z-index`: the base rule already sets it, which would + # make this an update rather than the append the assertion expects. + {"unitless number", "order", "10"} + ] + + test "every property shape sets, reads back and removes cleanly" do + for {label, property, value} <- @properties do + src = ".x {\n z-index: 1;\n}\n" + + assert {:ok, %{changed: true} = out} = + IgniterCss.set_declaration(src, ".x", property, value), + "#{label}: set failed" + + assert {:ok, ^value} = IgniterCss.get_declaration(out.source, ".x", property), + "#{label}: value did not round-trip" + + assert {:ok, %{source: ^src}} = + IgniterCss.remove_declaration(out.source, ".x", property), + "#{label}: removal did not restore the original" + end + end + + test "the important flag is set, read back and cleared" do + src = ".x {\n z-index: 1;\n}\n" + + assert {:ok, out} = IgniterCss.set_declaration(src, ".x", "color", "red", important: true) + assert out.source =~ "color: red !important;" + assert {:ok, "red !important"} = IgniterCss.get_declaration(out.source, ".x", "color") + + # An update with no opinion keeps the flag. + assert {:ok, kept} = IgniterCss.set_declaration(out.source, ".x", "color", "blue") + assert kept.source =~ "color: blue !important;" + + # An explicit false clears it. + assert {:ok, cleared} = + IgniterCss.set_declaration(kept.source, ".x", "color", "blue", important: false) + + assert cleared.source =~ "color: blue;" + refute cleared.source =~ "!important" + end + + test "a property is matched case-insensitively but a custom property is not" do + assert {:ok, "red"} = IgniterCss.get_declaration(".a { COLOR: red; }", ".a", "color") + + assert {:ok, nil} = + IgniterCss.get_declaration(":root { --Brand: red; }", ":root", "--brand") + + assert {:ok, "red"} = + IgniterCss.get_declaration(":root { --Brand: red; }", ":root", "--Brand") + end + end + + describe "scoping" do + test "a nested rule is not reachable at the top level" do + src = "@media print {\n .b { color: red; }\n}\n" + + assert {:ok, false} = IgniterCss.has_rule?(src, ".b") + assert {:ok, %{changed: false}} = IgniterCss.remove_rule(src, ".b") + assert {:error, reason} = IgniterCss.set_declaration(src, ".b", "color", "blue") + assert reason =~ "not found" + end + + test "the same selector at two scopes only touches the top-level one" do + src = ".b { color: red; }\n@media print {\n .b { color: green; }\n}\n" + + assert {:ok, out} = IgniterCss.set_declaration(src, ".b", "color", "blue") + assert out.source == ".b { color: blue; }\n@media print {\n .b { color: green; }\n}\n" + end + + test "duplicate top-level rules are refused rather than guessed" do + src = ".b { color: red; }\n.b { margin: 0; }\n" + assert {:error, reason} = IgniterCss.set_declaration(src, ".b", "color", "blue") + assert reason =~ "matches 2 top-level rules" + end + end +end From 23af43863cb32f0026244df6a7b6de8e025f99f1 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 18:45:47 +0200 Subject: [PATCH 05/11] ci: build precompiled NIFs from our own release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ash-ci's `build-release` job cannot ship this package. It hardcodes igniter_js: project-name: igniter_js project-dir: "native/igniter_js" workspaces: native/igniter_js and the `rust-crate-dir` input that would fix it is only consumed by `rust-check`. Worse, `build-release` is gated on the tag rather than on `inputs.release`, so pushing `v0.2.0` would have made ash-ci try to build a crate this repository does not contain — a failed release, not a skipped job. So elixir.yml no longer triggers on tags; it covers branches and PRs, where ash-ci is exactly right. It now also passes `rust-crate-dir: native/igniter_css` so the `rust-check` job (cargo fmt --check, clippy -D warnings, cargo test) targets our crate instead of discovering every Cargo.toml in the tree. release.yml handles `v*` tags: it re-runs the full check suite against the tag, then runs the same ten-target precompiled matrix ash-ci uses — same actions, same pinned SHAs, same NIF version — pointed at native/igniter_css. It then generates checksum-Elixir.IgniterCss.Native.exs from the attached artifacts, publishes to Hex, and cuts the GitHub release from the CHANGELOG. The release matrix and `targets:` in lib/igniter_css/native.ex are verified in sync: a target built but not declared is never downloaded, and one declared but not built is a hard failure for those users. Both lists are the same ten. The proper fix is upstream: give ash-ci's build-release `rust-project-name` and `rust-project-dir` inputs, at which point this workflow can go away. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/elixir.yml | 3 +- .github/workflows/release.yml | 190 ++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index e337f9a..f6f4f54 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -5,8 +5,6 @@ name: CI on: push: - tags: - - "v*" branches: [main] pull_request: branches: [main] @@ -32,6 +30,7 @@ jobs: erlang-version: ${{ matrix.erlang-version }} igniter-upgrade: false rustler-precompiled-module: IgniterCss.Native + rust-crate-dir: native/igniter_css publish-docs: ${{ matrix.primary }} release: false reuse: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0f40500 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +name: Release +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + ci: + uses: ./.github/workflows/elixir.yml + secrets: inherit + + build-release: + name: NIF ${{ matrix.nif }} - ${{ matrix.job.target }} (${{ matrix.job.os }}) + runs-on: ${{ matrix.job.os }} + needs: ci + permissions: + contents: write + strategy: + fail-fast: false + matrix: + nif: ["2.15"] + job: + - { target: aarch64-apple-darwin, os: macos-15 } + - { + target: aarch64-unknown-linux-gnu, + os: ubuntu-22.04, + use-cross: true, + } + - { + target: aarch64-unknown-linux-musl, + os: ubuntu-22.04, + use-cross: true, + } + - { + target: riscv64gc-unknown-linux-gnu, + os: ubuntu-22.04, + use-cross: true, + cargo-args: "--no-default-features", + } + - { target: x86_64-apple-darwin, os: macos-15 } + - { target: x86_64-pc-windows-gnu, os: windows-2022 } + - { target: x86_64-pc-windows-msvc, os: windows-2022 } + - { + target: x86_64-unknown-freebsd, + os: ubuntu-22.04, + use-cross: true, + cross-version: v0.2.5, + } + - { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04 } + - { + target: x86_64-unknown-linux-musl, + os: ubuntu-22.04, + use-cross: true, + } + + steps: + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Extract crate information + shell: bash + run: | + echo "PROJECT_VERSION=$(sed -n 's/^ @version "\(.*\)"/\1/p' mix.exs | head -n1)" >> $GITHUB_ENV + + - name: Add target + shell: bash + run: rustup target add ${{ matrix.job.target }} + + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + prefix-key: v0-precomp + shared-key: ${{ matrix.job.target }}-${{ matrix.nif }} + workspaces: | + native/igniter_css + + - name: Build the project + id: build-crate + uses: philss/rustler-precompiled-action@67ed0cc2d6a423e4c4cd9ad651dcc6c90f802cdb # v1.1.5 + with: + project-name: igniter_css + project-version: ${{ env.PROJECT_VERSION }} + target: ${{ matrix.job.target }} + nif-version: ${{ matrix.nif }} + use-cross: ${{ matrix.job.use-cross }} + cross-version: ${{ matrix.job.cross-version || 'v0.2.4' }} + project-dir: "native/igniter_css" + cargo-args: ${{ matrix.job.cargo-args }} + + - name: Artifact upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.build-crate.outputs.file-name }} + path: ${{ steps.build-crate.outputs.file-path }} + + - name: Publish archives and packages + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + files: | + ${{ steps.build-crate.outputs.file-path }} + if: startsWith(github.ref, 'refs/tags/v') + + hex-publish: + name: Hex Publish + needs: build-release + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: team-alembic/staple-actions/actions/mix-task@325af91762b51931c459503300c22c057251b3cf # main + with: + task: rustler_precompiled.download IgniterCss.Native --all --print + + - uses: team-alembic/staple-actions/actions/mix-hex-publish@325af91762b51931c459503300c22c057251b3cf # main + with: + mix-env: dev + hex-api-key: ${{ secrets.HEX_API_KEY }} + + github-release: + name: GitHub Release + needs: hex-publish + if: ${{ always() && !cancelled() && startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: team-alembic/staple-actions/actions/install-elixir@325af91762b51931c459503300c22c057251b3cf # main + + - name: Check if version exists on Hex + id: hex-check + env: + PACKAGE: ${{ github.event.repository.name }} + HEX_HOME: ${{ runner.temp }}/.hex + MIX_HOME: ${{ runner.temp }}/.mix + run: | + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#v} + + mix local.hex --if-missing --force + if mix hex.info "$PACKAGE" "$VERSION" > /dev/null 2>&1; then + echo "on_hex=true" >> $GITHUB_OUTPUT + else + echo "on_hex=false" >> $GITHUB_OUTPUT + fi + + - name: Extract release notes from CHANGELOG.md + id: extract-notes + if: ${{ steps.hex-check.outputs.on_hex == 'true' }} + run: | + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#v} + + awk -v version="$VERSION" ' + /^# Changelog for IgniterCss / { + if (found) exit + if (index($0, "IgniterCss " version)) { found = 1; next } + } + found { print } + ' CHANGELOG.md > release_notes.md + + if [ -s release_notes.md ]; then + echo "has_notes=true" >> $GITHUB_OUTPUT + else + echo "has_notes=false" >> $GITHUB_OUTPUT + fi + + - name: Create or update release with changelog notes + if: ${{ steps.hex-check.outputs.on_hex == 'true' && steps.extract-notes.outputs.has_notes == 'true' }} + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + body_path: release_notes.md + prerelease: ${{ contains(github.ref, '-rc') || contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-pre') }} + + - name: Create or update release with generated notes + if: ${{ steps.hex-check.outputs.on_hex == 'true' && steps.extract-notes.outputs.has_notes != 'true' }} + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + generate_release_notes: true + prerelease: ${{ contains(github.ref, '-rc') || contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-pre') }} From 33de65261c16bbfc69d01ed0a2134c8f227c516c Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 19:05:08 +0200 Subject: [PATCH 06/11] ci: return to the shared ash-ci workflow for releases Reverts the local release.yml now that ash-ci takes the crate name and directory as inputs. igniter_css is back to the igniter_js shape: one workflow, tags included in the triggers, and ash-ci owns build-release, hex publish and the GitHub release. `release: false` is gone, so the input falls back to its default of true and a v* tag builds the ten-target matrix, attaches the artifacts, generates the checksum file and publishes -- the same path igniter_js takes. Requires the matching ash change: passing rust-project-name / rust-project-dir to an ash-ci that does not declare them fails every run, not just tagged ones. Merge that first. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/elixir.yml | 5 +- .github/workflows/release.yml | 190 ---------------------------------- 2 files changed, 4 insertions(+), 191 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index f6f4f54..f131c07 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -5,6 +5,8 @@ name: CI on: push: + tags: + - "v*" branches: [main] pull_request: branches: [main] @@ -31,8 +33,9 @@ jobs: igniter-upgrade: false rustler-precompiled-module: IgniterCss.Native rust-crate-dir: native/igniter_css + rust-project-name: igniter_css + rust-project-dir: native/igniter_css publish-docs: ${{ matrix.primary }} - release: false reuse: true secrets: HEX_API_KEY: ${{ secrets.HEX_API_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 0f40500..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,190 +0,0 @@ -# SPDX-FileCopyrightText: 2025 igniter_css contributors -# -# SPDX-License-Identifier: MIT - -name: Release -on: - push: - tags: - - "v*" - workflow_dispatch: - -permissions: - contents: read - -jobs: - ci: - uses: ./.github/workflows/elixir.yml - secrets: inherit - - build-release: - name: NIF ${{ matrix.nif }} - ${{ matrix.job.target }} (${{ matrix.job.os }}) - runs-on: ${{ matrix.job.os }} - needs: ci - permissions: - contents: write - strategy: - fail-fast: false - matrix: - nif: ["2.15"] - job: - - { target: aarch64-apple-darwin, os: macos-15 } - - { - target: aarch64-unknown-linux-gnu, - os: ubuntu-22.04, - use-cross: true, - } - - { - target: aarch64-unknown-linux-musl, - os: ubuntu-22.04, - use-cross: true, - } - - { - target: riscv64gc-unknown-linux-gnu, - os: ubuntu-22.04, - use-cross: true, - cargo-args: "--no-default-features", - } - - { target: x86_64-apple-darwin, os: macos-15 } - - { target: x86_64-pc-windows-gnu, os: windows-2022 } - - { target: x86_64-pc-windows-msvc, os: windows-2022 } - - { - target: x86_64-unknown-freebsd, - os: ubuntu-22.04, - use-cross: true, - cross-version: v0.2.5, - } - - { target: x86_64-unknown-linux-gnu, os: ubuntu-22.04 } - - { - target: x86_64-unknown-linux-musl, - os: ubuntu-22.04, - use-cross: true, - } - - steps: - - name: Checkout source code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Extract crate information - shell: bash - run: | - echo "PROJECT_VERSION=$(sed -n 's/^ @version "\(.*\)"/\1/p' mix.exs | head -n1)" >> $GITHUB_ENV - - - name: Add target - shell: bash - run: rustup target add ${{ matrix.job.target }} - - - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - with: - prefix-key: v0-precomp - shared-key: ${{ matrix.job.target }}-${{ matrix.nif }} - workspaces: | - native/igniter_css - - - name: Build the project - id: build-crate - uses: philss/rustler-precompiled-action@67ed0cc2d6a423e4c4cd9ad651dcc6c90f802cdb # v1.1.5 - with: - project-name: igniter_css - project-version: ${{ env.PROJECT_VERSION }} - target: ${{ matrix.job.target }} - nif-version: ${{ matrix.nif }} - use-cross: ${{ matrix.job.use-cross }} - cross-version: ${{ matrix.job.cross-version || 'v0.2.4' }} - project-dir: "native/igniter_css" - cargo-args: ${{ matrix.job.cargo-args }} - - - name: Artifact upload - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ steps.build-crate.outputs.file-name }} - path: ${{ steps.build-crate.outputs.file-path }} - - - name: Publish archives and packages - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - files: | - ${{ steps.build-crate.outputs.file-path }} - if: startsWith(github.ref, 'refs/tags/v') - - hex-publish: - name: Hex Publish - needs: build-release - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - uses: team-alembic/staple-actions/actions/mix-task@325af91762b51931c459503300c22c057251b3cf # main - with: - task: rustler_precompiled.download IgniterCss.Native --all --print - - - uses: team-alembic/staple-actions/actions/mix-hex-publish@325af91762b51931c459503300c22c057251b3cf # main - with: - mix-env: dev - hex-api-key: ${{ secrets.HEX_API_KEY }} - - github-release: - name: GitHub Release - needs: hex-publish - if: ${{ always() && !cancelled() && startsWith(github.ref, 'refs/tags/v') }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: team-alembic/staple-actions/actions/install-elixir@325af91762b51931c459503300c22c057251b3cf # main - - - name: Check if version exists on Hex - id: hex-check - env: - PACKAGE: ${{ github.event.repository.name }} - HEX_HOME: ${{ runner.temp }}/.hex - MIX_HOME: ${{ runner.temp }}/.mix - run: | - TAG_NAME=${GITHUB_REF#refs/tags/} - VERSION=${TAG_NAME#v} - - mix local.hex --if-missing --force - if mix hex.info "$PACKAGE" "$VERSION" > /dev/null 2>&1; then - echo "on_hex=true" >> $GITHUB_OUTPUT - else - echo "on_hex=false" >> $GITHUB_OUTPUT - fi - - - name: Extract release notes from CHANGELOG.md - id: extract-notes - if: ${{ steps.hex-check.outputs.on_hex == 'true' }} - run: | - TAG_NAME=${GITHUB_REF#refs/tags/} - VERSION=${TAG_NAME#v} - - awk -v version="$VERSION" ' - /^# Changelog for IgniterCss / { - if (found) exit - if (index($0, "IgniterCss " version)) { found = 1; next } - } - found { print } - ' CHANGELOG.md > release_notes.md - - if [ -s release_notes.md ]; then - echo "has_notes=true" >> $GITHUB_OUTPUT - else - echo "has_notes=false" >> $GITHUB_OUTPUT - fi - - - name: Create or update release with changelog notes - if: ${{ steps.hex-check.outputs.on_hex == 'true' && steps.extract-notes.outputs.has_notes == 'true' }} - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - body_path: release_notes.md - prerelease: ${{ contains(github.ref, '-rc') || contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-pre') }} - - - name: Create or update release with generated notes - if: ${{ steps.hex-check.outputs.on_hex == 'true' && steps.extract-notes.outputs.has_notes != 'true' }} - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - generate_release_notes: true - prerelease: ${{ contains(github.ref, '-rc') || contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-pre') }} From e1b41b8af11b83dd0bfe94e855b9542b9ddc28db Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 19:11:11 +0200 Subject: [PATCH 07/11] ci: drop the rust-project inputs now that ash-ci derives them ash-ci resolves the precompiled crate from the calling repository's name, so igniter_css gets `igniter_css` and `native/igniter_css` without configuring anything. Passing them explicitly only duplicated the convention. This also removes the ordering hazard from the previous commit: the workflow no longer passes inputs that current ash-ci@main does not declare, so it is safe on either version. Releases still need the ash change merged to build the right crate, but CI will not fail in the meantime. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/elixir.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index f131c07..0ea9f1f 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -33,8 +33,6 @@ jobs: igniter-upgrade: false rustler-precompiled-module: IgniterCss.Native rust-crate-dir: native/igniter_css - rust-project-name: igniter_css - rust-project-dir: native/igniter_css publish-docs: ${{ matrix.primary }} reuse: true secrets: From 700db1e1b7a2114a9df9e1ba7b9b44923a2a5f94 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 20:04:20 +0200 Subject: [PATCH 08/11] fix: remove a stray AST-probe file and grant ash-ci's permissions Two findings from the first real CI run. REUSE compliance failed on native/igniter_css/tests/probe.rs -- a scratch file used to inspect the CST shape of `@import` variants. It was meant to be deleted; the `rm` sat behind a `cd` that had already succeeded, so `&&` short-circuited and it was committed instead. It carried no SPDX header, which is what caught it. Removing it drops the Rust suite from 340 to 339 tests: the probe was counted as one. The run before that never started at all. ash-ci's jobs request security-events: write for the credo SARIF upload, pages and id-token for docs, and contents: write for releases; a reusable workflow cannot request more than its caller grants, so on any repository whose default workflow token is read-only the run fails at startup. igniter_js already declares these. Adding the same block. Verified against the real workflow: `cargo test, fmt and clippy` passes on both matrix legs. The remaining job failures in that run are a runner-side TLS error reaching builds.hex.pm, unrelated to this repository. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/elixir.yml | 5 +++++ native/igniter_css/tests/probe.rs | 15 --------------- 2 files changed, 5 insertions(+), 15 deletions(-) delete mode 100644 native/igniter_css/tests/probe.rs diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 0ea9f1f..ded0a4a 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -24,6 +24,11 @@ jobs: erlang-version: "default" primary: true uses: ash-project/ash/.github/workflows/ash-ci.yml@main + permissions: + contents: write + pages: write + id-token: write + security-events: write with: spark-formatter: false spark-cheat-sheets: false diff --git a/native/igniter_css/tests/probe.rs b/native/igniter_css/tests/probe.rs deleted file mode 100644 index b836782..0000000 --- a/native/igniter_css/tests/probe.rs +++ /dev/null @@ -1,15 +0,0 @@ -use igniter_css::ctx::ParseCtx; -#[test] -fn p() { - for src in [ - "@import \"a.css\";", - "@import url(\"/a.css\");", - "@import url(/a.css);", - "@import 'a.css' screen;", - "@plugin \"../vendor/x\";", - "@source \"../js\";", - ] { - println!("===== {src}"); - println!("{:#?}", ParseCtx::parse_default(src).syntax()); - } -} From 9acb49763c44e4140d1d5248d965860aea8e8e89 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 20:23:11 +0200 Subject: [PATCH 09/11] ci: move to OTP 28 and drop the OTP 26 matrix leg The `Could not mix rebar from any hex.pm mirror` failures are not flaky. OTP 27.1.3 cannot complete the TLS handshake to builds.hex.pm -- it rejects the certificate chain with `key_usage_mismatch` -- and `.tool-versions` pinned exactly that, so `default` resolved to a toolchain that cannot install rebar. Compared on the same runner the same day, igniter_js on OTP 28.0.2 compiles and tests cleanly while igniter_css on OTP 27.1.3 fails every job that needs hex. Moves `.tool-versions` to erlang 28.0.2 / elixir 1.18.4-otp-28, matching igniter_js, and drops the 1.17.0-otp-26 matrix leg, which fails the same way and harder. igniter_css now calls ash-ci as a single job, as igniter_js does. Tradeoff worth stating: mix.exs still declares `elixir: "~> 1.17"`, and that floor is no longer exercised in CI. Testing it would need an Elixir 1.17 build on OTP 28, which does not exist -- 1.17 tops out at OTP 27. igniter_js makes the same tradeoff, declaring `~> 1.14` while testing only the default toolchain. `publish-docs` is dropped rather than passed, since its default is already true. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/elixir.yml | 13 ------------- .tool-versions | 4 ++-- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index ded0a4a..d2752ca 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -13,16 +13,6 @@ on: workflow_call: jobs: ash-ci: - strategy: - fail-fast: false - matrix: - include: - - elixir-version: "1.17.0-otp-26" - erlang-version: "26.0" - primary: false - - elixir-version: "default" - erlang-version: "default" - primary: true uses: ash-project/ash/.github/workflows/ash-ci.yml@main permissions: contents: write @@ -33,12 +23,9 @@ jobs: spark-formatter: false spark-cheat-sheets: false sobelow: false - elixir-version: ${{ matrix.elixir-version }} - erlang-version: ${{ matrix.erlang-version }} igniter-upgrade: false rustler-precompiled-module: IgniterCss.Native rust-crate-dir: native/igniter_css - publish-docs: ${{ matrix.primary }} reuse: true secrets: HEX_API_KEY: ${{ secrets.HEX_API_KEY }} diff --git a/.tool-versions b/.tool-versions index 3dbcf4c..b7e96e6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,4 @@ -erlang 27.1.3 -elixir 1.18.3-otp-27 +erlang 28.0.2 +elixir 1.18.4-otp-28 rust 1.97.1 pipx 1.8.0 From d0a7e48df9bd6dd9a3836c3633d4639bb8afc292 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 20:47:18 +0200 Subject: [PATCH 10/11] refactor: decide from the CST, not from the text Review of the Rust for hand-rolled parsing that Biome already models. Four places were reading characters where the tree had the answer. Selector normalisation was a character scanner tracking quote state and bracket depth. The CST models combinators as tokens -- `>` `+` `~`, and a CSS_SPACE_LITERAL for descendant -- and selector lists as element children, so canonical spacing now falls out of a structural walk. Quoted attribute values and `:not(...)` arguments are copied token by token and need no tracking at all. A caller's selector string is parsed and rendered through the same function, so a hand-written selector and one read from a stylesheet cannot disagree. This fixed a real bug: `:nth-child(2n + 1)` and `:nth-child(2n+1)` are one selector, and the text scanner kept them distinct, so one could not match the other. Colour detection scanned for 148 names in raw text and needed a `strip_opaque_runs` hack so `url(/red.png)` was not read as `red`. Biome distinguishes all three cases already: a hex colour is CSS_COLOR, `rgb(...)` is CSS_FUNCTION with an identifier name, a bare `red` is CSS_IDENTIFIER, and a url payload is CSS_URL_VALUE_RAW -- structurally not an identifier. Matching node kinds deletes the hack outright. The name list stays because it is spec data, but it is now compared against identifier tokens rather than substrings. Hex literals are still length-checked, since Biome parses `#notahex` as a colour node too. At-rule preludes were compared by collapsing whitespace in raw text. AtRuleRef now carries `prelude_norm`, built by joining the tokens between the name and the `;`/`{`; trivia is excluded by construction, so a comment inside a prelude cannot change the comparison. `collapse_ws` is gone. `split_declarations` hand-parsed caller text with bracket counting. It now wraps the text in a throwaway rule and reads the declarations back off the CST, so a `;` inside `url(...)`, a string or a comment does not split -- because the parser knows what those are, not because of counting. Also removes the last two `expect()` calls reachable from a NIF. Both were provably unreachable, but the rule is that nothing reachable from a NIF may unwind, and proving it per call site is worse than not needing to. README rewritten: what it does, how to use it, how to contribute and test. No migration history. 339 Rust tests, 268 Elixir + 39 doctests, clippy -D warnings clean. Co-Authored-By: Claude Opus 5 (1M context) --- native/igniter_css/README.md | 172 ++++++++++++++++------ native/igniter_css/src/analyze.rs | 107 ++++++-------- native/igniter_css/src/locate.rs | 172 +++++++++++++--------- native/igniter_css/src/ops/at_rule.rs | 45 +----- native/igniter_css/src/ops/declaration.rs | 15 +- native/igniter_css/src/ops/mod.rs | 86 ++++------- native/igniter_css/src/ops/rule.rs | 10 +- 7 files changed, 328 insertions(+), 279 deletions(-) diff --git a/native/igniter_css/README.md b/native/igniter_css/README.md index fb526a2..910b46b 100644 --- a/native/igniter_css/README.md +++ b/native/igniter_css/README.md @@ -1,71 +1,153 @@ -# NIF for Elixir.IgniterCss.Native +Logo Light +Logo Dark -CSS codemods over Biome's lossless CSS CST. +[![CI](https://github.com/ash-project/igniter_css/actions/workflows/elixir.yml/badge.svg)](https://github.com/ash-project/igniter_css/actions/workflows/elixir.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Hex version badge](https://img.shields.io/hexpm/v/igniter_css.svg)](https://hex.pm/packages/igniter_css) +[![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/igniter_css) +[![REUSE status](https://api.reuse.software/badge/github.com/ash-project/igniter_css)](https://api.reuse.software/info/github.com/ash-project/igniter_css) -## Architecture +# IgniterCss -Parse losslessly → locate byte ranges → splice text. **The tree is never -reprinted.** That is the single most important decision in this crate: it is why -comments, indentation and property order outside an edit are preserved by -construction rather than by effort. +CSS codemods for [Igniter](https://hexdocs.pm/igniter), powered by a Rust parser +integrated via NIFs. Changes the lines you meant to change, and nothing else. +```elixir +{:igniter_css, "~> 0.2.0", only: [:dev, :test]} ``` -source (String) - → parse_css() // lossless CST, error tolerant - → locate target nodes // typed queries - → node.text_trimmed_range() // exact byte offsets - → Vec // { start, end, replacement } - → splice into the ORIGINAL source - → new source + +Precompiled NIFs ship for the standard target matrix — no Rust toolchain needed. + +- **Comments are never lost** — operations splice byte ranges, the tree is never reprinted +- **Minimal diffs** — no whole-file reformatting +- **Idempotent** — re-running reports `changed: false` +- **Safe** — a file that can't be patched cleanly comes back untouched with `{:error, _}` + +## Usage + +```elixir +css = """ +@import "tailwindcss"; + +.btn { + color: red; /* brand */ +} +""" + +{:ok, out} = IgniterCss.ensure_at_rule(css, ~s|@plugin "daisyui";|) +{:ok, out} = IgniterCss.set_declaration(out.source, ".btn", "color", "var(--brand)") ``` -| Module | Responsibility | -|---|---| -| `ctx` | source, parse, newline style, indent unit, BOM, brace balance | -| `locate` | typed CST queries returning byte ranges | -| `trivia` | which comments a deleted node owns (rule D) | -| `edit` | overlap-checked splicing | -| `ops/` | the codemods — diff-minimal and idempotent | -| `analyze` | read-only queries | -| `transform` | whole-file minify/beautify/merge — **not** codemods | -| `nif` | the Elixir boundary | +```css +@import "tailwindcss"; +@plugin "daisyui"; -`ctx` and `locate` are the only modules that name Biome types. Keeping them -contained means a Biome upgrade touches two files rather than twenty. +.btn { + color: var(--brand); /* brand */ +} +``` -## Building +Everything returns `{:ok, %IgniterCss.Outcome{source:, changed:, diagnostics:}}` +or `{:error, reason}`, plus an optional trailing keyword list. -The NIF builds along with the Elixir project. To force a local build instead of -downloading a precompiled artifact: +In an installer, for Igniter's diff preview and confirmation: +```elixir +igniter +|> IgniterCss.Codemods.ensure_at_rule(path, ~s|@plugin "daisyui";|) +|> IgniterCss.Codemods.ensure_rule(path, ".hide-scrollbar") +|> IgniterCss.Codemods.set_declaration(path, ".hide-scrollbar", "scrollbar-width", "none") ``` -IGNITERCSS_BUILD=1 mix compile + +## Operations + +| `IgniterCss` | | +|---|---| +| `ensure_at_rule/3` · `remove_at_rule/4` | `@import`, `@plugin`, `@source`, `@layer`, … | +| `add_import/4` · `remove_import/3` | `@import` convenience | +| `ensure_rule/4` · `remove_rule/3` | whole rules | +| `replace_rule_body/4` · `append_raw_to_rule/4` | rule bodies | +| `set_declaration/5` · `remove_declaration/4` | declarations | +| `add_vendor_prefixes/4` | prefixed copies of a property | +| `sort_properties/2` · `remove_duplicates/2` | tidying, by moving whole lines | + +Read-only: `has_rule?/3` · `has_declaration?/4` · `has_at_rule?/3` · +`get_declaration/4` · `get_rule_declarations/3` · `list_selectors/2` · +`analyze/2` · `validate/2` · `extract_colors/2` · `extract_media_queries/2` · +`extract_animations/2` + +`IgniterCss.Transform` — `minify/2`, `beautify/2`, `merge_stylesheets/2`. These +rewrite every byte by design; don't point them at a file a user maintains. + +`IgniterCss.Parsers.Parser` — the same surface on the +`{:ok, :function_name, result}` convention shared with `igniter_js`, and accepts +a file path as well as content. + +## Matching + +Top-level rules only, compared on a normalised form (`.a>.b` matches `.a > .b`), +never substring or fuzzy. `.a` does not match `.a, .b`. **Ambiguity is an +error**, not a guess. All selector kinds are supported — class, id, tag, +attribute, pseudo, combinators, escaped and non-ASCII. + +Tailwind v4 parses cleanly: `@theme`, `@plugin`, `@source`, `@custom-variant`, +`@variant`, `@utility`, `@apply`, `@layer`, `@reference`. + +When a codemod deletes a node, it takes the comments that node owns: + +```css +/* ===== Layout ===== */ ← kept (section header) + +/* used by the sidebar */ ← kept (blank line between) + +/* brand color */ ← deleted (adjacent, own line) +color: red; /* legacy */ ← deleted (target + trailing) ``` -## Testing +## Contributing +```bash +mix deps.get +IGNITERCSS_BUILD=1 mix compile # build the NIF locally; required until a release exists + +mix test # Elixir +cd native/igniter_css && cargo test # Rust +mix check # format, credo, dialyzer, reuse ``` -cargo test # unit, corpus-invariant and property suites -cargo clippy --all-targets + +CI runs the Rust checks as `-D warnings`: + +```bash +cd native/igniter_css cargo fmt --check +cargo clippy --all-targets -- -D warnings ``` -`tests/phase0_roundtrip.rs` is the gate everything else rests on: -`parse.syntax().to_string() == source` must hold byte-for-byte across the whole -fixture corpus in `test/fixtures`. If it ever fails, byte-range editing is no -longer safe and the codemods must not run. +| module | | +|---|---| +| `ctx` | source, parse, newline style, indent unit, BOM, brace balance | +| `locate` | CST queries returning byte ranges | +| `trivia` | which comments a deleted node owns | +| `edit` | overlap-checked splicing | +| `ops/` | the codemods | +| `analyze` · `transform` | read-only queries · whole-file rewrites | +| `nif` | the Elixir boundary | + +Four rules for changes: -## Dependency pinning +- Never reprint the tree — locate byte ranges and splice. +- No `unwrap`/`expect` reachable from a NIF; a panic takes down a scheduler. +- Every codemod ships with a golden, an idempotency and a comment-placement test. +- `biome_*` crates are pinned with `=` and churn between patch releases — check + [docs.rs](https://docs.rs/biome_css_syntax) rather than writing a call from memory. -The `biome_*` crates are Biome-internal, published at 0.5.x with no API -stability guarantee, and they churn between patch releases. They are pinned with -`=` on purpose. Upgrading is a deliberate, tested activity — never a -`cargo update` — and any API call must be checked against - for the pinned version rather than written -from memory. +`ctx` and `locate` are the only modules naming Biome types, so an upgrade touches +two files. `tests/phase0_roundtrip.rs` is the gate everything rests on: +`parse.syntax().to_string() == source`, byte for byte, across the whole corpus. diff --git a/native/igniter_css/src/analyze.rs b/native/igniter_css/src/analyze.rs index 9d4c8a6..6bd7a83 100644 --- a/native/igniter_css/src/analyze.rs +++ b/native/igniter_css/src/analyze.rs @@ -14,6 +14,7 @@ use crate::locate::{ }; use crate::ops::query; use biome_css_syntax::{CssSyntaxKind, CssSyntaxNode}; +use biome_rowan::Direction; use std::collections::{BTreeMap, BTreeSet}; /// A block of declarations together with the text that introduced it -- a @@ -95,6 +96,8 @@ pub fn blocks(ctx: &ParseCtx) -> Vec { // Colours // --------------------------------------------------------------------------- +/// Functions that produce a colour. Names, because the CST models them all as +/// `CSS_FUNCTION` -- the spec list is data, not a parsing shortcut. const COLOR_FUNCTIONS: &[&str] = &[ "rgb", "rgba", @@ -110,6 +113,10 @@ const COLOR_FUNCTIONS: &[&str] = &[ "light-dark", ]; +/// CSS named colours. Matched against `CSS_IDENTIFIER` tokens only, never +/// against arbitrary text, so a path like `url(/red.png)` cannot be mistaken +/// for one -- Biome parses that as `CSS_URL_FUNCTION` with a raw value, which +/// is structurally not an identifier. const NAMED_COLORS: &[&str] = &[ "aliceblue", "antiquewhite", @@ -131,6 +138,7 @@ const NAMED_COLORS: &[&str] = &[ "cornflowerblue", "cornsilk", "crimson", + "currentcolor", "cyan", "darkblue", "darkcyan", @@ -262,72 +270,51 @@ const NAMED_COLORS: &[&str] = &[ "yellowgreen", ]; -fn is_hex_color(token: &str) -> bool { - let Some(rest) = token.strip_prefix('#') else { - return false; - }; - matches!(rest.len(), 3 | 4 | 6 | 8) && rest.chars().all(|c| c.is_ascii_hexdigit()) +/// A `CSS_COLOR_LITERAL` carries the digits without the `#`. Biome parses +/// `#notahex` as a colour node too, so the literal still has to be checked +/// against the spec's permitted lengths. +fn is_hex_literal(text: &str) -> bool { + matches!(text.len(), 3 | 4 | 6 | 8) && text.chars().all(|c| c.is_ascii_hexdigit()) } -/// Blank out `url(...)` payloads and quoted strings so a path like -/// `url(/red.png)` is not read as the colour `red`. -fn strip_opaque_runs(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - let mut chars = value.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '"' | '\'' => { - let quote = c; - let mut escaped = false; - for q in chars.by_ref() { - if escaped { - escaped = false; - } else if q == '\\' { - escaped = true; - } else if q == quote { - break; - } - } - out.push(' '); - } - _ => { - out.push(c); - if out.to_lowercase().ends_with("url(") { - let mut depth = 1usize; - for q in chars.by_ref() { - match q { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - break; - } - } - _ => {} - } - } - out.push(')'); - } - } - } - } - out +/// Does this declaration's value contain a colour? +/// +/// Decided from the CST, not from the text. Biome already distinguishes the +/// three cases: a hex colour is a `CSS_COLOR` node, `rgb(...)` and friends are +/// `CSS_FUNCTION` nodes carrying an identifier name, and a bare `red` is a +/// `CSS_IDENTIFIER`. A `url(...)` payload is a `CSS_URL_FUNCTION` with a raw +/// value node, so its contents are never identifiers and cannot be misread. +pub fn value_node_has_color(value: &CssSyntaxNode) -> bool { + value.descendants().any(|node| match node.kind() { + // `#fff`, `#ffffffcc` -- shape from the CST, digits from the spec. + CssSyntaxKind::CSS_COLOR => node.descendants_tokens(Direction::Next).any(|t| { + t.kind() == CssSyntaxKind::CSS_COLOR_LITERAL && is_hex_literal(t.text_trimmed()) + }), + // `rgb(...)`, `oklch(...)`, `color-mix(...)` + CssSyntaxKind::CSS_FUNCTION => node + .first_token() + .is_some_and(|t| COLOR_FUNCTIONS.contains(&t.text_trimmed().to_lowercase().as_str())), + // `red`, `transparent`, `currentColor` + CssSyntaxKind::CSS_IDENTIFIER => node + .first_token() + .is_some_and(|t| NAMED_COLORS.contains(&t.text_trimmed().to_lowercase().as_str())), + _ => false, + }) } -/// Does this value contain a colour? +/// String-level convenience for callers that only have the value text. Parses +/// it so the answer comes from the same CST walk as everything else. pub fn value_has_color(value: &str) -> bool { - let lower = strip_opaque_runs(value).to_lowercase(); - if lower.contains("currentcolor") { - return true; - } - for func in COLOR_FUNCTIONS { - if lower.contains(&format!("{func}(")) { - return true; - } + if value.trim().is_empty() { + return false; } - lower - .split(|c: char| !(c.is_alphanumeric() || c == '#' || c == '-')) - .any(|token| is_hex_color(token) || (!token.is_empty() && NAMED_COLORS.contains(&token))) + let probe = format!("a{{b:{value}}}"); + let parse = biome_css_parser::parse_css(&probe, biome_css_parser::CssParserOptions::default()); + parse + .syntax() + .descendants() + .find(|n| n.kind() == CssSyntaxKind::CSS_GENERIC_COMPONENT_VALUE_LIST) + .is_some_and(|list| value_node_has_color(&list)) } /// Colour-carrying declarations, grouped by the selector they belong to. diff --git a/native/igniter_css/src/locate.rs b/native/igniter_css/src/locate.rs index 93030bb..47cacdd 100644 --- a/native/igniter_css/src/locate.rs +++ b/native/igniter_css/src/locate.rs @@ -56,6 +56,11 @@ pub struct AtRuleRef { pub name: String, /// Everything between the name and the `;` or `{`, trimmed. pub prelude: String, + /// The prelude rendered token by token with single-space separation, for + /// comparisons. Built from the CST rather than by collapsing whitespace in + /// the raw text, so a comment or an odd line break inside the prelude + /// cannot change the answer. + pub prelude_norm: String, /// The at-rule's subject, read from the CST: the first string literal or /// `url()` value appearing before any block, unquoted. /// @@ -194,87 +199,94 @@ fn is_declaration_item(kind: CssSyntaxKind) -> bool { // Selector normalisation // --------------------------------------------------------------------------- -/// Canonical form of a selector for comparison purposes. +/// Render a selector subtree in canonical form. /// -/// Collapses whitespace runs, puts exactly one space around the `>`, `+`, `~` -/// combinators, and exactly one space after each `,`. Text inside quotes is -/// copied verbatim; text inside `[...]` keeps its own spacing rules so that -/// `[a~="b"]` is not mangled into something unrecognisable. -/// -/// This does not need to be semantically perfect -- it needs to be -/// *deterministic*, so that the same selector written two ways lands on the -/// same string and two different selectors do not collide. -pub fn normalize_selector(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - let mut bracket_depth = 0usize; - let mut paren_depth = 0usize; - let mut pending_space = false; - - while let Some(c) = chars.next() { - match c { - '"' | '\'' => { - if pending_space && !out.is_empty() { - out.push(' '); - } - pending_space = false; - let quote = c; - out.push(quote); - let mut escaped = false; - for q in chars.by_ref() { - out.push(q); - if escaped { - escaped = false; - } else if q == '\\' { - escaped = true; - } else if q == quote { - break; - } +/// Structural, not textual: the CST already models combinators as tokens +/// (`>` `+` `~`, and a `CSS_SPACE_LITERAL` for descendant) and selector lists +/// as element children, so canonical spacing falls out of walking the tree. +/// Everything below a combinator is concatenated token by token, which is +/// correct because whitespace is not legal inside a compound selector -- and it +/// means quoted attribute values and `:not(...)` arguments are copied verbatim +/// without any quote or bracket tracking. +fn render_selector(node: &CssSyntaxNode, out: &mut String) { + match node.kind() { + // `.a, .b` -- join the elements, drop the source's own commas. + CssSyntaxKind::CSS_SELECTOR_LIST + | CssSyntaxKind::CSS_COMPOUND_SELECTOR_LIST + | CssSyntaxKind::CSS_ANY_SELECTOR_LIST + | CssSyntaxKind::CSS_RELATIVE_SELECTOR_LIST => { + for (i, child) in node.children().enumerate() { + if i > 0 { + out.push_str(", "); } + render_selector(&child, out); } - c if c.is_whitespace() => { - pending_space = true; - } - '>' | '+' | '~' if bracket_depth == 0 && paren_depth == 0 => { - // Combinator: exactly one space on each side. - while out.ends_with(' ') { - out.pop(); - } - if !out.is_empty() { - out.push(' '); - } - out.push(c); - out.push(' '); - pending_space = false; - } - ',' => { - while out.ends_with(' ') { - out.pop(); + } + // `left right`, always exactly one space either side. + CssSyntaxKind::CSS_COMPLEX_SELECTOR => { + for element in node.children_with_tokens() { + match element { + biome_rowan::SyntaxElement::Node(child) => render_selector(&child, out), + biome_rowan::SyntaxElement::Token(token) => { + // The descendant combinator is a space literal; every + // other combinator carries its own glyph. + if token.kind() == CssSyntaxKind::CSS_SPACE_LITERAL { + out.push(' '); + } else { + out.push(' '); + out.push_str(token.text_trimmed()); + out.push(' '); + } + } } - out.push(','); - out.push(' '); - pending_space = false; } - _ => { - if pending_space && !out.is_empty() && !out.ends_with(' ') { - out.push(' '); - } - pending_space = false; - match c { - '[' => bracket_depth += 1, - ']' => bracket_depth = bracket_depth.saturating_sub(1), - '(' => paren_depth += 1, - ')' => paren_depth = paren_depth.saturating_sub(1), - _ => {} + } + _ => { + for element in node.children_with_tokens() { + match element { + biome_rowan::SyntaxElement::Node(child) => render_selector(&child, out), + biome_rowan::SyntaxElement::Token(token) => out.push_str(token.text_trimmed()), } - out.push(c); } } } +} +/// Canonical form of a selector node, for comparison purposes. +pub fn normalize_selector_node(node: &CssSyntaxNode) -> String { + let mut out = String::new(); + render_selector(node, &mut out); + // No post-processing: collapsing whitespace here would reach inside string + // literals such as `a[href^="a b"]`. The walk emits exactly one space per + // combinator, so there is nothing to collapse. out.trim().to_string() } +/// Canonical form of a caller-supplied selector string. +/// +/// Parsed with Biome and rendered through [`normalize_selector_node`], so a +/// selector written by hand in Elixir and one read out of a stylesheet go +/// through exactly the same code path and cannot disagree. +pub fn normalize_selector(input: &str) -> String { + let trimmed = input.trim(); + if trimmed.is_empty() { + return String::new(); + } + let probe = format!("{trimmed} {{}}"); + let parse = biome_css_parser::parse_css(&probe, biome_css_parser::CssParserOptions::default()); + let selector_list = parse + .syntax() + .descendants() + .find(|n| n.kind() == CssSyntaxKind::CSS_SELECTOR_LIST); + + match selector_list { + Some(list) if !list.text_trimmed().to_string().is_empty() => normalize_selector_node(&list), + // Unparseable as a selector: fall back to whitespace collapsing so the + // caller still gets a deterministic key rather than an empty one. + _ => trimmed.split_whitespace().collect::>().join(" "), + } +} + /// Canonical form of a property name: lowercased, trimmed. Custom properties /// (`--foo`) are case-sensitive per spec, so those keep their case. pub fn normalize_property(input: &str) -> String { @@ -303,7 +315,7 @@ fn rule_ref_from(node: &CssSyntaxNode, ctx: &ParseCtx) -> Option { let selector_raw = ctx.text(selector_list.text_trimmed_range()).to_string(); Some(RuleRef { - selector_norm: normalize_selector(&selector_raw), + selector_norm: normalize_selector_node(&selector_list), selector_raw, node: node.clone(), start, @@ -387,7 +399,21 @@ fn at_rule_ref_from(node: &CssSyntaxNode, ctx: &ParseCtx) -> Option { .trim() .to_string(); + // Canonical prelude: the tokens between the name and the `;`/`{`, joined by + // single spaces. Trivia (whitespace and comments) is excluded by + // construction because we read `text_trimmed` of each token. + let prelude_norm = inner + .descendants_tokens(Direction::Next) + .filter(|t| { + let s = usize::from(t.text_trimmed_range().start()); + s >= name_end && s < prelude_end + }) + .map(|t| t.text_trimmed().to_string()) + .collect::>() + .join(" "); + Some(AtRuleRef { + prelude_norm, target: at_rule_target_token(node), node: node.clone(), name, @@ -769,10 +795,14 @@ mod tests { } #[test] - fn does_not_treat_plus_inside_parens_as_a_combinator() { + fn a_plus_inside_a_functional_pseudo_is_not_a_combinator() { + // Both spellings denote the same selector, and reading the CST rather + // than the text makes them agree -- the old text scanner kept them + // distinct, which meant `:nth-child(2n + 1)` could not be matched by + // `:nth-child(2n+1)`. assert_eq!( normalize_selector("li:nth-child(2n + 1)"), - "li:nth-child(2n + 1)" + "li:nth-child(2n+1)" ); assert_eq!( normalize_selector("li:nth-child(2n+1)"), diff --git a/native/igniter_css/src/ops/at_rule.rs b/native/igniter_css/src/ops/at_rule.rs index 5910303..9567f90 100644 --- a/native/igniter_css/src/ops/at_rule.rs +++ b/native/igniter_css/src/ops/at_rule.rs @@ -34,45 +34,6 @@ pub struct AtRuleSpec { pub text: String, } -/// Collapse whitespace runs outside of strings. -fn collapse_ws(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - let mut pending = false; - while let Some(c) = chars.next() { - match c { - '"' | '\'' => { - if pending && !out.is_empty() { - out.push(' '); - } - pending = false; - out.push(c); - let quote = c; - let mut escaped = false; - for q in chars.by_ref() { - out.push(q); - if escaped { - escaped = false; - } else if q == '\\' { - escaped = true; - } else if q == quote { - break; - } - } - } - c if c.is_whitespace() => pending = true, - _ => { - if pending && !out.is_empty() { - out.push(' '); - } - pending = false; - out.push(c); - } - } - } - out.trim().to_string() -} - /// Normalise a caller-supplied needle (e.g. the `matching` argument of /// [`remove_at_rule`]) into the same shape as an AST-derived target. /// @@ -125,7 +86,7 @@ pub fn parse_at_rule_spec(line: &str) -> Result { )); } - let prelude = collapse_ws(&rule.prelude); + let prelude = rule.prelude_norm.clone(); Ok(AtRuleSpec { name: rule.name.clone(), // Read from the parsed line's own CST, not scanned out of the text. @@ -150,7 +111,7 @@ fn is_equivalent(spec: &AtRuleSpec, existing: &AtRuleRef) -> bool { (Some(a), Some(b)) => a == b, // Neither has one (`@layer base, components;`): fall back to the // whitespace-normalised prelude. - (None, None) => collapse_ws(&existing.prelude) == spec.prelude, + (None, None) => existing.prelude_norm == spec.prelude, // One names a subject and the other does not: different rules. _ => false, } @@ -248,7 +209,7 @@ pub fn remove_at_rule( let hit = match &at.target { Some(target) => target == w, // No subject to match on (`@layer base;`): compare preludes. - None => collapse_ws(&at.prelude) == *w, + None => at.prelude_norm == *w, }; if !hit { continue; diff --git a/native/igniter_css/src/ops/declaration.rs b/native/igniter_css/src/ops/declaration.rs index 3cff39c..33b98b3 100644 --- a/native/igniter_css/src/ops/declaration.rs +++ b/native/igniter_css/src/ops/declaration.rs @@ -71,8 +71,9 @@ fn check_property_and_value(property: &str, value: &str) -> Result<(String, Stri ))); } // A `;` outside of a string, comment or `url(...)` would silently turn one - // declaration into two. - if crate::ops::split_declarations(value).len() > 1 { + // declaration into two. Probed as a complete declaration, so the parser -- + // not a bracket counter -- decides whether the value terminates early. + if crate::ops::split_declarations(&format!("{property}: {value}")).len() > 1 { return Err(CssError::InvalidInput(format!( "value {value:?} contains a `;`; pass one declaration at a time" ))); @@ -130,10 +131,12 @@ pub fn set_declaration( format!("{value} !important"), )]) } else { - let (_, imp_end) = d - .important_range - .expect("important flag present when d.important"); - Ok(vec![Edit::replace(d.value_start, imp_end, value)]) + // Clearing the flag: replace through the end of the + // `!important` range. If the range is somehow absent the + // value range is still correct, so there is no case where a + // panic would be better than an edit. + let end = d.important_range.map_or(d.value_end, |(_, e)| e); + Ok(vec![Edit::replace(d.value_start, end, value)]) } } None => { diff --git a/native/igniter_css/src/ops/mod.rs b/native/igniter_css/src/ops/mod.rs index 1ccc960..e4f3ae4 100644 --- a/native/igniter_css/src/ops/mod.rs +++ b/native/igniter_css/src/ops/mod.rs @@ -131,64 +131,42 @@ pub fn reindent(text: &str, indent: &str, nl: &str) -> String { } /// Split caller-supplied declaration text (`"color: red; margin: 0"`) into -/// individual `"color: red;"` statements, respecting strings, comments and -/// nested parentheses so a `;` inside `url(...)` or a comment does not split. +/// individual `"color: red;"` statements. +/// +/// Parsed with Biome rather than scanned: the text is wrapped in a throwaway +/// rule and the declarations are read back off the CST. That is why a `;` +/// inside `url(...)`, inside a string, or inside a comment does not split -- +/// not because of bracket counting, but because the parser knows what those +/// are. Falls back to the raw text as a single declaration if it will not +/// parse, so a caller always gets something rather than silent loss. pub fn split_declarations(text: &str) -> Vec { - let mut out = Vec::new(); - let mut current = String::new(); - let mut chars = text.chars().peekable(); - let mut depth = 0usize; + if text.trim().is_empty() { + return Vec::new(); + } - while let Some(c) = chars.next() { - match c { - '"' | '\'' => { - current.push(c); - let quote = c; - let mut escaped = false; - for q in chars.by_ref() { - current.push(q); - if escaped { - escaped = false; - } else if q == '\\' { - escaped = true; - } else if q == quote { - break; - } - } - } - '/' if chars.peek() == Some(&'*') => { - current.push(c); - current.push(chars.next().unwrap()); - let mut prev = '\0'; - for q in chars.by_ref() { - current.push(q); - if prev == '*' && q == '/' { - break; - } - prev = q; - } - } - '(' => { - depth += 1; - current.push(c); - } - ')' => { - depth = depth.saturating_sub(1); - current.push(c); - } - ';' if depth == 0 => { - if !current.trim().is_empty() { - out.push(format!("{};", current.trim())); - } - current.clear(); - } - _ => current.push(c), - } + let probe = format!("a{{{text}}}"); + let ctx = ParseCtx::parse_default(&probe); + let Some(rule) = crate::locate::find_top_level_rules(&ctx).into_iter().next() else { + return vec![ensure_semicolon(text.trim())]; + }; + + let declarations = crate::locate::declarations_in(&ctx, &rule); + if declarations.is_empty() { + return vec![ensure_semicolon(text.trim())]; } - if !current.trim().is_empty() { - out.push(format!("{};", current.trim())); + + declarations + .into_iter() + .map(|d| ensure_semicolon(ctx.source()[d.start..d.end].trim())) + .collect() +} + +fn ensure_semicolon(text: &str) -> String { + if text.ends_with(';') { + text.to_string() + } else { + format!("{text};") } - out } /// Does this text already end in a `;` outside of strings and comments? diff --git a/native/igniter_css/src/ops/rule.rs b/native/igniter_css/src/ops/rule.rs index d742aaf..9ac75c8 100644 --- a/native/igniter_css/src/ops/rule.rs +++ b/native/igniter_css/src/ops/rule.rs @@ -74,7 +74,15 @@ pub fn append_to_body(ctx: &ParseCtx, rule: &RuleRef, text: &str) -> Vec { } let comments = comment_ranges(ctx); - let last = items.last().expect("non-empty"); + // `items` is non-empty here, but expressing that with `?` rather than a + // panic keeps this path total -- nothing reachable from a NIF may unwind. + let Some(last) = items.last() else { + return vec![Edit::replace( + rule.body_open, + rule.body_close, + text.to_string(), + )]; + }; let last_start = usize::from(last.text_trimmed_range().start()); let last_end = usize::from(last.text_trimmed_range().end()); From 2eee96f9272e662e0e554762e234a45c10ddc18b Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Mon, 3 Aug 2026 20:52:43 +0200 Subject: [PATCH 11/11] docs: attribute every file to contributors, and restore the NIF readme Two problems, both mine. A `cat > README.md` ran while the shell was inside native/igniter_css and overwrote the NIF readme with the root one. That dragged the root's personal copyright line onto a file that never carried it. The NIF readme is restored, covering the architecture, build, test and dependency-pinning rules for the crate. Attribution is now uniform. Every tracked file carries exactly one line: SPDX-FileCopyrightText: 2025 igniter_css contributors That normalises three inconsistencies at once: the personal line, the `graphs.contributors` spelling that predated this branch in the .license sidecars, and the duplicate lines in the two readmes. 24 files updated; the comment prefix of each file type is preserved. Co-Authored-By: Claude Opus 5 (1M context) --- .tool-versions.license | 2 +- CHANGELOG.md | 2 +- README.md | 3 +- logos/igniter-logo-medium.png.license | 2 +- logos/igniter-logo-small.png.license | 2 +- logos/igniter-logo-tiny.png.license | 2 +- logos/igniter-logo.png.license | 2 +- mix.lock.license | 2 +- native/igniter_css/Cargo.lock.license | 2 +- native/igniter_css/README.md | 172 +++++------------- test/fixtures/bom.css.license | 2 +- test/fixtures/comments_everywhere.css.license | 2 +- test/fixtures/crlf.css.license | 2 +- test/fixtures/empty.css.license | 2 +- test/fixtures/kitchen_sink.css.license | 2 +- test/fixtures/line_comments.css.license | 2 +- test/fixtures/minified.css.license | 2 +- test/fixtures/no_trailing_newline.css.license | 2 +- test/fixtures/non_ascii.css.license | 2 +- test/fixtures/only_comment.css.license | 2 +- test/fixtures/phoenix_app.css.license | 2 +- test/fixtures/stray_brace.css.license | 2 +- test/fixtures/tabs.css.license | 2 +- test/fixtures/tailwind_v4.css.license | 2 +- test/fixtures/truncated.css.license | 2 +- 25 files changed, 72 insertions(+), 149 deletions(-) diff --git a/.tool-versions.license b/.tool-versions.license index e84618c..afd70dd 100644 --- a/.tool-versions.license +++ b/.tool-versions.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e985c..c3eef9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ diff --git a/README.md b/README.md index a32de62..610e5b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ diff --git a/logos/igniter-logo-medium.png.license b/logos/igniter-logo-medium.png.license index e84618c..afd70dd 100644 --- a/logos/igniter-logo-medium.png.license +++ b/logos/igniter-logo-medium.png.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/logos/igniter-logo-small.png.license b/logos/igniter-logo-small.png.license index e84618c..afd70dd 100644 --- a/logos/igniter-logo-small.png.license +++ b/logos/igniter-logo-small.png.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/logos/igniter-logo-tiny.png.license b/logos/igniter-logo-tiny.png.license index e84618c..afd70dd 100644 --- a/logos/igniter-logo-tiny.png.license +++ b/logos/igniter-logo-tiny.png.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/logos/igniter-logo.png.license b/logos/igniter-logo.png.license index e84618c..afd70dd 100644 --- a/logos/igniter-logo.png.license +++ b/logos/igniter-logo.png.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/mix.lock.license b/mix.lock.license index e84618c..afd70dd 100644 --- a/mix.lock.license +++ b/mix.lock.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/native/igniter_css/Cargo.lock.license b/native/igniter_css/Cargo.lock.license index e84618c..afd70dd 100644 --- a/native/igniter_css/Cargo.lock.license +++ b/native/igniter_css/Cargo.lock.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/native/igniter_css/README.md b/native/igniter_css/README.md index 910b46b..fdcd32f 100644 --- a/native/igniter_css/README.md +++ b/native/igniter_css/README.md @@ -1,153 +1,77 @@ -Logo Light -Logo Dark +# NIF for Elixir.IgniterCss.Native -[![CI](https://github.com/ash-project/igniter_css/actions/workflows/elixir.yml/badge.svg)](https://github.com/ash-project/igniter_css/actions/workflows/elixir.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Hex version badge](https://img.shields.io/hexpm/v/igniter_css.svg)](https://hex.pm/packages/igniter_css) -[![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/igniter_css) -[![REUSE status](https://api.reuse.software/badge/github.com/ash-project/igniter_css)](https://api.reuse.software/info/github.com/ash-project/igniter_css) +CSS codemods over Biome's lossless CSS CST. -# IgniterCss +## Architecture -CSS codemods for [Igniter](https://hexdocs.pm/igniter), powered by a Rust parser -integrated via NIFs. Changes the lines you meant to change, and nothing else. +Parse losslessly → locate byte ranges → splice text. **The tree is never +reprinted.** That is why comments, indentation and property order outside an +edit are preserved by construction rather than by effort. -```elixir -{:igniter_css, "~> 0.2.0", only: [:dev, :test]} ``` - -Precompiled NIFs ship for the standard target matrix — no Rust toolchain needed. - -- **Comments are never lost** — operations splice byte ranges, the tree is never reprinted -- **Minimal diffs** — no whole-file reformatting -- **Idempotent** — re-running reports `changed: false` -- **Safe** — a file that can't be patched cleanly comes back untouched with `{:error, _}` - -## Usage - -```elixir -css = """ -@import "tailwindcss"; - -.btn { - color: red; /* brand */ -} -""" - -{:ok, out} = IgniterCss.ensure_at_rule(css, ~s|@plugin "daisyui";|) -{:ok, out} = IgniterCss.set_declaration(out.source, ".btn", "color", "var(--brand)") -``` - -```css -@import "tailwindcss"; -@plugin "daisyui"; - -.btn { - color: var(--brand); /* brand */ -} +source (String) + → parse_css() // lossless CST, error tolerant + → locate target nodes // typed CST queries + → node.text_trimmed_range() // exact byte offsets + → Vec // { start, end, replacement } + → splice into the ORIGINAL source + → new source ``` -Everything returns `{:ok, %IgniterCss.Outcome{source:, changed:, diagnostics:}}` -or `{:error, reason}`, plus an optional trailing keyword list. - -In an installer, for Igniter's diff preview and confirmation: - -```elixir -igniter -|> IgniterCss.Codemods.ensure_at_rule(path, ~s|@plugin "daisyui";|) -|> IgniterCss.Codemods.ensure_rule(path, ".hide-scrollbar") -|> IgniterCss.Codemods.set_declaration(path, ".hide-scrollbar", "scrollbar-width", "none") -``` - -## Operations - -| `IgniterCss` | | +| module | responsibility | |---|---| -| `ensure_at_rule/3` · `remove_at_rule/4` | `@import`, `@plugin`, `@source`, `@layer`, … | -| `add_import/4` · `remove_import/3` | `@import` convenience | -| `ensure_rule/4` · `remove_rule/3` | whole rules | -| `replace_rule_body/4` · `append_raw_to_rule/4` | rule bodies | -| `set_declaration/5` · `remove_declaration/4` | declarations | -| `add_vendor_prefixes/4` | prefixed copies of a property | -| `sort_properties/2` · `remove_duplicates/2` | tidying, by moving whole lines | - -Read-only: `has_rule?/3` · `has_declaration?/4` · `has_at_rule?/3` · -`get_declaration/4` · `get_rule_declarations/3` · `list_selectors/2` · -`analyze/2` · `validate/2` · `extract_colors/2` · `extract_media_queries/2` · -`extract_animations/2` - -`IgniterCss.Transform` — `minify/2`, `beautify/2`, `merge_stylesheets/2`. These -rewrite every byte by design; don't point them at a file a user maintains. - -`IgniterCss.Parsers.Parser` — the same surface on the -`{:ok, :function_name, result}` convention shared with `igniter_js`, and accepts -a file path as well as content. - -## Matching - -Top-level rules only, compared on a normalised form (`.a>.b` matches `.a > .b`), -never substring or fuzzy. `.a` does not match `.a, .b`. **Ambiguity is an -error**, not a guess. All selector kinds are supported — class, id, tag, -attribute, pseudo, combinators, escaped and non-ASCII. - -Tailwind v4 parses cleanly: `@theme`, `@plugin`, `@source`, `@custom-variant`, -`@variant`, `@utility`, `@apply`, `@layer`, `@reference`. - -When a codemod deletes a node, it takes the comments that node owns: - -```css -/* ===== Layout ===== */ ← kept (section header) +| `ctx` | source, parse, newline style, indent unit, BOM, brace balance | +| `locate` | CST queries returning byte ranges | +| `trivia` | which comments a deleted node owns | +| `edit` | overlap-checked splicing | +| `ops/` | the codemods — diff-minimal and idempotent | +| `analyze` | read-only queries | +| `transform` | whole-file minify/beautify/merge — **not** codemods | +| `nif` | the Elixir boundary | -/* used by the sidebar */ ← kept (blank line between) +`ctx` and `locate` are the only modules that name Biome types, so an upgrade +touches two files rather than twenty. -/* brand color */ ← deleted (adjacent, own line) -color: red; /* legacy */ ← deleted (target + trailing) -``` +## Building -## Contributing +The NIF builds along with the Elixir project. To force a local build instead of +downloading a precompiled artifact: ```bash -mix deps.get -IGNITERCSS_BUILD=1 mix compile # build the NIF locally; required until a release exists - -mix test # Elixir -cd native/igniter_css && cargo test # Rust -mix check # format, credo, dialyzer, reuse +IGNITERCSS_BUILD=1 mix compile ``` -CI runs the Rust checks as `-D warnings`: +## Testing ```bash -cd native/igniter_css +cargo test cargo fmt --check cargo clippy --all-targets -- -D warnings ``` -| module | | -|---|---| -| `ctx` | source, parse, newline style, indent unit, BOM, brace balance | -| `locate` | CST queries returning byte ranges | -| `trivia` | which comments a deleted node owns | -| `edit` | overlap-checked splicing | -| `ops/` | the codemods | -| `analyze` · `transform` | read-only queries · whole-file rewrites | -| `nif` | the Elixir boundary | +`tests/phase0_roundtrip.rs` is the gate everything else rests on: +`parse.syntax().to_string() == source` must hold byte for byte across the whole +fixture corpus in `test/fixtures`. If it ever fails, byte-range editing is no +longer safe and the codemods must not run. + +## Conventions -Four rules for changes: +- Decide from the CST, never by scanning text — combinators, colours, at-rule + preludes and declaration boundaries are all modelled by Biome already. +- No `unwrap`/`expect` reachable from a NIF; a panic takes down a BEAM scheduler. +- Never export the tree to Elixir. Elixir sends intent, Rust returns text. -- Never reprint the tree — locate byte ranges and splice. -- No `unwrap`/`expect` reachable from a NIF; a panic takes down a scheduler. -- Every codemod ships with a golden, an idempotency and a comment-placement test. -- `biome_*` crates are pinned with `=` and churn between patch releases — check - [docs.rs](https://docs.rs/biome_css_syntax) rather than writing a call from memory. +## Dependency pinning -`ctx` and `locate` are the only modules naming Biome types, so an upgrade touches -two files. `tests/phase0_roundtrip.rs` is the gate everything rests on: -`parse.syntax().to_string() == source`, byte for byte, across the whole corpus. +The `biome_*` crates are Biome-internal, published at 0.5.x with no API +stability guarantee, and they churn between patch releases. They are pinned with +`=` on purpose. Upgrading is a deliberate, tested activity — never a +`cargo update` — and any API call must be checked against + for the pinned version rather than written +from memory. diff --git a/test/fixtures/bom.css.license b/test/fixtures/bom.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/bom.css.license +++ b/test/fixtures/bom.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/comments_everywhere.css.license b/test/fixtures/comments_everywhere.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/comments_everywhere.css.license +++ b/test/fixtures/comments_everywhere.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/crlf.css.license b/test/fixtures/crlf.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/crlf.css.license +++ b/test/fixtures/crlf.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/empty.css.license b/test/fixtures/empty.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/empty.css.license +++ b/test/fixtures/empty.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/kitchen_sink.css.license b/test/fixtures/kitchen_sink.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/kitchen_sink.css.license +++ b/test/fixtures/kitchen_sink.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/line_comments.css.license b/test/fixtures/line_comments.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/line_comments.css.license +++ b/test/fixtures/line_comments.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/minified.css.license b/test/fixtures/minified.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/minified.css.license +++ b/test/fixtures/minified.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/no_trailing_newline.css.license b/test/fixtures/no_trailing_newline.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/no_trailing_newline.css.license +++ b/test/fixtures/no_trailing_newline.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/non_ascii.css.license b/test/fixtures/non_ascii.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/non_ascii.css.license +++ b/test/fixtures/non_ascii.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/only_comment.css.license b/test/fixtures/only_comment.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/only_comment.css.license +++ b/test/fixtures/only_comment.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/phoenix_app.css.license b/test/fixtures/phoenix_app.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/phoenix_app.css.license +++ b/test/fixtures/phoenix_app.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/stray_brace.css.license b/test/fixtures/stray_brace.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/stray_brace.css.license +++ b/test/fixtures/stray_brace.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/tabs.css.license b/test/fixtures/tabs.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/tabs.css.license +++ b/test/fixtures/tabs.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/tailwind_v4.css.license b/test/fixtures/tailwind_v4.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/tailwind_v4.css.license +++ b/test/fixtures/tailwind_v4.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT diff --git a/test/fixtures/truncated.css.license b/test/fixtures/truncated.css.license index e84618c..afd70dd 100644 --- a/test/fixtures/truncated.css.license +++ b/test/fixtures/truncated.css.license @@ -1,3 +1,3 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors +SPDX-FileCopyrightText: 2025 igniter_css contributors SPDX-License-Identifier: MIT