diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 8a407af..d2752ca 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -13,26 +13,19 @@ 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 + pages: write + id-token: write + security-events: write with: spark-formatter: false spark-cheat-sheets: false sobelow: false - elixir-version: ${{ matrix.elixir-version }} - erlang-version: ${{ matrix.erlang-version }} igniter-upgrade: false - publish-docs: ${{ matrix.primary }} - release: false + rustler-precompiled-module: IgniterCss.Native + rust-crate-dir: native/igniter_css reuse: true secrets: HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 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..b7e96e6 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +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 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 1db19fc..c3eef9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,53 @@ +# 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..610e5b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ @@ -16,21 +15,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/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.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/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/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.tar.gz.license b/native/igniter_css/Cargo.lock.license similarity index 63% rename from plibs/css_tools/dist/css_tools-0.1.2.tar.gz.license rename to native/igniter_css/Cargo.lock.license index e84618c..afd70dd 100644 --- a/plibs/css_tools/dist/css_tools-0.1.2.tar.gz.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/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..fdcd32f --- /dev/null +++ b/native/igniter_css/README.md @@ -0,0 +1,77 @@ + + +# 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 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 CST 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` | 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 | + +`ctx` and `locate` are the only modules that name Biome types, so an 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: + +```bash +IGNITERCSS_BUILD=1 mix compile +``` + +## Testing + +```bash +cargo test +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. + +## Conventions + +- 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. + +## 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..6bd7a83 --- /dev/null +++ b/native/igniter_css/src/analyze.rs @@ -0,0 +1,814 @@ +// 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 biome_rowan::Direction; +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 +// --------------------------------------------------------------------------- + +/// 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", + "hsl", + "hsla", + "hwb", + "lab", + "lch", + "oklab", + "oklch", + "color", + "color-mix", + "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", + "aqua", + "aquamarine", + "azure", + "beige", + "bisque", + "black", + "blanchedalmond", + "blue", + "blueviolet", + "brown", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "currentcolor", + "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", +]; + +/// 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()) +} + +/// 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, + }) +} + +/// 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 { + if value.trim().is_empty() { + return false; + } + 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. +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..47cacdd --- /dev/null +++ b/native/igniter_css/src/locate.rs @@ -0,0 +1,1121 @@ +// 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, + /// 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. + /// + /// 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, + /// 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 +// --------------------------------------------------------------------------- + +/// Render a selector subtree in canonical form. +/// +/// 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); + } + } + // `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(' '); + } + } + } + } + } + _ => { + 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()), + } + } + } + } +} + +/// 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 { + 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_node(&selector_list), + selector_raw, + node: node.clone(), + start, + end, + body_open, + body_close, + }) +} + +/// 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; + } + // `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(); + + // 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, + 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 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)" + ); + 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..9567f90 --- /dev/null +++ b/native/igniter_css/src/ops/at_rule.rs @@ -0,0 +1,597 @@ +// 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, +} + +/// 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); + } + } + crate::locate::unquote(n) +} + +/// 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 = rule.prelude_norm.clone(); + Ok(AtRuleSpec { + name: rule.name.clone(), + // Read from the parsed line's own CST, not scanned out of the text. + target: rule.target.clone(), + 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; + } + 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) => existing.prelude_norm == spec.prelude, + // One names a subject and the other does not: different rules. + _ => false, + } +} + +/// 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(normalize_target_needle); + + 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 hit = match &at.target { + Some(target) => target == w, + // No subject to match on (`@layer base;`): compare preludes. + None => at.prelude_norm == *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 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/src/ops/declaration.rs b/native/igniter_css/src/ops/declaration.rs new file mode 100644 index 0000000..33b98b3 --- /dev/null +++ b/native/igniter_css/src/ops/declaration.rs @@ -0,0 +1,830 @@ +// 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. 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" + ))); + } + // 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 { + // 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 => { + 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..e4f3ae4 --- /dev/null +++ b/native/igniter_css/src/ops/mod.rs @@ -0,0 +1,355 @@ +// 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. +/// +/// 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 { + if text.trim().is_empty() { + return Vec::new(); + } + + 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())]; + } + + 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};") + } +} + +/// 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..9ac75c8 --- /dev/null +++ b/native/igniter_css/src/ops/rule.rs @@ -0,0 +1,605 @@ +// 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); + // `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()); + + // 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..55430e2 --- /dev/null +++ b/native/igniter_css/tests/corpus_invariants.rs @@ -0,0 +1,436 @@ +// 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() + }), + // `.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() + }), + ("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/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/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 80a9c34..0000000 Binary files a/plibs/css_tools/dist/css_tools-0.1.2-py3-none-any.whl and /dev/null differ 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 a9f9ba6..0000000 Binary files a/plibs/css_tools/dist/css_tools-0.1.2.tar.gz and /dev/null differ diff --git a/plibs/css_tools/pyproject.toml b/plibs/css_tools/pyproject.toml deleted file mode 100644 index b8b36f4..0000000 --- a/plibs/css_tools/pyproject.toml +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: 2025 igniter_css contributors -# -# 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/dependency_links.txt.license b/plibs/css_tools/src/css_tools.egg-info/dependency_links.txt.license deleted file mode 100644 index e84618c..0000000 --- a/plibs/css_tools/src/css_tools.egg-info/dependency_links.txt.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors - -SPDX-License-Identifier: MIT 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/requires.txt.license b/plibs/css_tools/src/css_tools.egg-info/requires.txt.license deleted file mode 100644 index e84618c..0000000 --- a/plibs/css_tools/src/css_tools.egg-info/requires.txt.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors - -SPDX-License-Identifier: MIT 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.egg-info/top_level.txt.license b/plibs/css_tools/src/css_tools.egg-info/top_level.txt.license deleted file mode 100644 index e84618c..0000000 --- a/plibs/css_tools/src/css_tools.egg-info/top_level.txt.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors - -SPDX-License-Identifier: MIT 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 80a9c34..0000000 Binary files a/priv/python/css_tools-0.1.2-py3-none-any.whl and /dev/null differ diff --git a/priv/python/css_tools-0.1.2-py3-none-any.whl.license b/priv/python/css_tools-0.1.2-py3-none-any.whl.license deleted file mode 100644 index e84618c..0000000 --- a/priv/python/css_tools-0.1.2-py3-none-any.whl.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: 2025 igniter_css contributors - -SPDX-License-Identifier: MIT 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..2f4a83a --- /dev/null +++ b/test/corpus_invariants_test.exs @@ -0,0 +1,228 @@ +# 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}, + # `.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}, + {"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/src/css_tools.egg-info/PKG-INFO.license b/test/fixtures/bom.css.license similarity index 63% rename from plibs/css_tools/src/css_tools.egg-info/PKG-INFO.license rename to test/fixtures/bom.css.license index e84618c..afd70dd 100644 --- a/plibs/css_tools/src/css_tools.egg-info/PKG-INFO.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 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/dist/css_tools-0.1.2-py3-none-any.whl.license b/test/fixtures/comments_everywhere.css.license similarity index 63% rename from plibs/css_tools/dist/css_tools-0.1.2-py3-none-any.whl.license rename to test/fixtures/comments_everywhere.css.license index e84618c..afd70dd 100644 --- a/plibs/css_tools/dist/css_tools-0.1.2-py3-none-any.whl.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 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 63% rename from plibs/css_tools/src/css_tools.egg-info/SOURCES.txt.license rename to test/fixtures/crlf.css.license index e84618c..afd70dd 100644 --- a/plibs/css_tools/src/css_tools.egg-info/SOURCES.txt.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 b/test/fixtures/empty.css new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/empty.css.license b/test/fixtures/empty.css.license new file mode 100644 index 0000000..afd70dd --- /dev/null +++ b/test/fixtures/empty.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT 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/test/fixtures/kitchen_sink.css.license b/test/fixtures/kitchen_sink.css.license new file mode 100644 index 0000000..afd70dd --- /dev/null +++ b/test/fixtures/kitchen_sink.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT 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/test/fixtures/line_comments.css.license b/test/fixtures/line_comments.css.license new file mode 100644 index 0000000..afd70dd --- /dev/null +++ b/test/fixtures/line_comments.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT 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/test/fixtures/minified.css.license b/test/fixtures/minified.css.license new file mode 100644 index 0000000..afd70dd --- /dev/null +++ b/test/fixtures/minified.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT 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..afd70dd --- /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..afd70dd --- /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..afd70dd --- /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..afd70dd --- /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..afd70dd --- /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..afd70dd --- /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..afd70dd --- /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..afd70dd --- /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/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 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/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 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