From fbe4d84b5e65cde0ba47fb65d28603a4acf8e0bd Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Sun, 9 Aug 2026 23:04:41 +0200 Subject: [PATCH 1/7] feat: read an at-rule back whole with get_at_rules/4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `analyze/2` reports that a `@plugin` exists; nothing reported *which* plugin or how it was configured. That is the difference between an installer knowing daisyUI is present and knowing it was loaded as `@plugin "daisyui" { prefix: "d-" }` — and an installer that guesses wrong generates `@apply btn` into a project where the class is `d-btn`, which simply fails to build. `get_at_rules(source, name, matching \\ nil, opts \\ [])` returns `IgniterCss.AtRule` structs carrying the name, prelude, target, whether there is a block, the block's declarations in source order, and the at-rule's own bytes verbatim. Top-level only, for the same reason the codemods are: a `@plugin` nested in a `@layer` is a different thing, and guessing between them is how a caller gets a surprising answer. Absence is an empty list, not an error. Also adds the two Igniter wrappers the pure module already had but `Codemods` did not — `add_import/5` and `remove_import/4`. Without them an installer wanting an import had to reach past `Codemods` and lose the diff preview. And `force_build:` now falls through to `config :rustler_precompiled, :force_build` instead of only reading `IGNITERCSS_BUILD`. A consuming project has no reason to know this library's private variable name, so hardcoding it made rustler_precompiled's own documented switch a no-op — which matters for anyone depending on this from git before a release carries NIF artifacts. `@plugin` is not standard CSS, so Biome parses its body as `CSS_DECLARATION_OR_RULE_BLOCK`; `locate::at_rule_body/1` hands `declarations_in_block` the block's owner, which is the shape it expects. Co-Authored-By: Claude Opus 5 --- README.md | 3 +- lib/igniter_css.ex | 23 +++++ lib/igniter_css/codemods.ex | 16 ++++ lib/igniter_css/native.ex | 7 +- lib/igniter_css/parsers/parser.ex | 34 +++++++ lib/igniter_css/structs.ex | 31 +++++++ native/igniter_css/src/analyze.rs | 145 +++++++++++++++++++++++++++++- native/igniter_css/src/atoms.rs | 1 + native/igniter_css/src/locate.rs | 10 +++ native/igniter_css/src/nif.rs | 37 ++++++++ test/at_rules_test.exs | 129 ++++++++++++++++++++++++++ test/codemods_test.exs | 63 +++++++++++++ 12 files changed, 495 insertions(+), 4 deletions(-) create mode 100644 test/at_rules_test.exs diff --git a/README.md b/README.md index 610e5b6..e01524d 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ Callers get Igniter's normal diff preview and confirmation flow. | `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`, +`get_declaration/4`, `get_rule_declarations/3`, `get_at_rules/4`, +`list_selectors/2`, `analyze/2`, `validate/2`, `extract_colors/2`, `extract_media_queries/2`, `extract_animations/2`. diff --git a/lib/igniter_css.ex b/lib/igniter_css.ex index 4f460aa..cdf897a 100644 --- a/lib/igniter_css.ex +++ b/lib/igniter_css.ex @@ -128,6 +128,29 @@ defmodule IgniterCss do Native.has_at_rule_nif(source, line, ParseOpts.new(opts)) |> unwrap() end + @doc """ + Every top-level at-rule named `name`, as `IgniterCss.AtRule` structs. + + Pass `matching` to narrow to a single target — the string or `url()` before + the block. Returns `[]` when nothing matches; absence is an answer, not an + error. + + Unlike `has_at_rule?/3`, this hands back the at-rule's block, so a caller can + read a decision out of it rather than only confirm the line exists: + + iex> css = ~s|@plugin "daisyui" { prefix: "d-"; }| + iex> {:ok, [rule]} = IgniterCss.get_at_rules(css, "plugin", "daisyui") + iex> rule.declarations + [{"prefix", ~s|"d-"|}] + + The leading `@` in `name` is optional. + """ + @spec get_at_rules(String.t(), String.t(), String.t() | nil, opts()) :: + {:ok, [IgniterCss.AtRule.t()]} | {:error, String.t()} + def get_at_rules(source, name, matching \\ nil, opts \\ []) do + Native.get_at_rules_nif(source, name, matching, ParseOpts.new(opts)) |> unwrap() + end + @doc """ Add an `@import`, building the line for you. diff --git a/lib/igniter_css/codemods.ex b/lib/igniter_css/codemods.ex index 1f0f638..71b1f5c 100644 --- a/lib/igniter_css/codemods.ex +++ b/lib/igniter_css/codemods.ex @@ -55,6 +55,20 @@ defmodule IgniterCss.Codemods do end) end + @doc "See `IgniterCss.add_import/4`." + def add_import(igniter, path, url, media \\ nil, opts \\ []) do + update(igniter, path, "add_import #{inspect(url)}", fn source -> + IgniterCss.add_import(source, url, media, opts) + end) + end + + @doc "See `IgniterCss.remove_import/3`." + def remove_import(igniter, path, url, opts \\ []) do + update(igniter, path, "remove_import #{inspect(url)}", fn source -> + IgniterCss.remove_import(source, url, 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 -> @@ -124,6 +138,8 @@ defmodule IgniterCss.Codemods do for {name, arity} <- [ ensure_at_rule: 4, remove_at_rule: 5, + add_import: 5, + remove_import: 4, ensure_rule: 5, remove_rule: 4, set_declaration: 6, diff --git a/lib/igniter_css/native.ex b/lib/igniter_css/native.ex index f8db6f4..8740dce 100644 --- a/lib/igniter_css/native.ex +++ b/lib/igniter_css/native.ex @@ -28,9 +28,13 @@ defmodule IgniterCss.Native do x86_64-unknown-linux-gnu x86_64-unknown-linux-musl ), + # `rustler_precompiled`'s own documented switch. Without this the env var above is the + # only way in, and a consuming project — which has no reason to know this library's + # private variable name — cannot force a source build the standard way. force_build: System.get_env("IGNITERCSS_BUILD") in ["1", "true"] || - System.get_env("ASH_CI_BUILD") in ["1", "true"] + System.get_env("ASH_CI_BUILD") in ["1", "true"] || + Application.compile_env(:rustler_precompiled, [:force_build, :igniter_css], false) # -- at-rules --------------------------------------------------------------- @@ -76,6 +80,7 @@ defmodule IgniterCss.Native do # -- analysis --------------------------------------------------------------- def analyze_nif(_source, _opts), do: error() + def get_at_rules_nif(_source, _name, _matching, _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() diff --git a/lib/igniter_css/parsers/parser.ex b/lib/igniter_css/parsers/parser.ex index 7fae507..658fb4a 100644 --- a/lib/igniter_css/parsers/parser.ex +++ b/lib/igniter_css/parsers/parser.ex @@ -337,6 +337,40 @@ defmodule IgniterCss.Parsers.Parser do # Analysis # --------------------------------------------------------------------------- + @doc """ + Every top-level at-rule named `name`, as string-keyed maps. + + ## Examples + + iex> css = ~s|@plugin "daisyui" { prefix: "d-"; }| + iex> {:ok, _, [rule]} = IgniterCss.Parsers.Parser.get_at_rules(css, "plugin", "daisyui") + iex> rule["declarations"] + [{"prefix", ~s|"d-"|}] + """ + @spec get_at_rules(String.t(), String.t(), String.t() | nil, type()) :: + {:ok, atom(), [map()]} | {:error, atom(), String.t()} + def get_at_rules(file_path_or_content, name, matching \\ nil, type \\ :content) do + call_nif_fn( + file_path_or_content, + __ENV__.function, + fn content -> + case Native.get_at_rules_nif(content, name, matching, ParseOpts.new([])) do + {:ok, fun, rules} -> + {:ok, fun, + Enum.map(rules, fn rule -> + rule + |> Map.from_struct() + |> Map.new(fn {key, value} -> {Atom.to_string(key), value} end) + end)} + + other -> + other + end + end, + type + ) + end + @doc """ Statistics about a stylesheet, as a string-keyed map. diff --git a/lib/igniter_css/structs.ex b/lib/igniter_css/structs.ex index 32d955b..99953a8 100644 --- a/lib/igniter_css/structs.ex +++ b/lib/igniter_css/structs.ex @@ -123,6 +123,37 @@ defmodule IgniterCss.Validation do defstruct valid: false, diagnostics: 0, round_trips: false, message: "" end +defmodule IgniterCss.AtRule do + @moduledoc """ + One at-rule, read back whole. + + `IgniterCss.analyze/2` reports only that an at-rule of some name exists. This + reports which one and how it was configured — the difference between knowing a + `@plugin` is present and knowing it was loaded as + `@plugin "daisyui" { prefix: "d-"; }`, which is what an installer needs before + it can generate code that agrees with the user's setup. + + `declarations` is empty for an at-rule with no block, or one whose block holds + rules rather than declarations. + """ + + @type t :: %__MODULE__{ + name: String.t(), + prelude: String.t(), + target: String.t() | nil, + has_block: boolean(), + declarations: [{String.t(), String.t()}], + text: String.t() + } + + defstruct name: "", + prelude: "", + target: nil, + has_block: false, + declarations: [], + text: "" +end + defmodule IgniterCss.Animation do @moduledoc """ A `@keyframes` animation and the selectors that use it. diff --git a/native/igniter_css/src/analyze.rs b/native/igniter_css/src/analyze.rs index 13812f6..430a419 100644 --- a/native/igniter_css/src/analyze.rs +++ b/native/igniter_css/src/analyze.rs @@ -9,8 +9,8 @@ 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, + all_comments, at_rule_body, declaration_lists, declarations_in_block, find_all_at_rules, + find_all_rules, find_at_rules_named, find_top_level_rules, DeclRef, }; use crate::ops::query; use biome_css_syntax::{CssSyntaxKind, CssSyntaxNode}; @@ -448,6 +448,72 @@ pub fn extract_animations(source: &str, options: ParseOptions) -> Result, + pub has_block: bool, + /// Declarations inside the block, in source order. Empty when the at-rule + /// has no block, or a block that holds rules rather than declarations. + pub declarations: Vec<(String, String)>, + /// The at-rule's own bytes, exactly as written. + pub text: String, +} + +/// Every **top-level** at-rule named `name`, optionally narrowed to those whose +/// target matches `matching`. +/// +/// Top-level only, for the same reason the codemods are: a `@plugin` nested +/// inside a `@layer` is a different thing from one at the file's root, and +/// guessing between them is how a caller gets a surprising answer. +pub fn get_at_rules( + source: &str, + name: &str, + matching: Option<&str>, + options: ParseOptions, +) -> Result> { + let wanted = name.trim().trim_start_matches('@').to_lowercase(); + + query(source, options, |ctx| { + Ok(find_at_rules_named(ctx, &wanted) + .iter() + .filter(|at| match matching { + None => true, + Some(m) => at.target.as_deref() == Some(m), + }) + .map(|at| AtRule { + name: at.name.clone(), + prelude: at.prelude.clone(), + target: at.target.clone(), + has_block: at.has_block, + declarations: at_rule_body(at) + .map(|block| { + declarations_in_block(ctx, &block) + .iter() + .map(|d| (d.property.clone(), d.value_raw.trim().to_string())) + .collect() + }) + .unwrap_or_default(), + text: ctx.source()[at.start..at.end].to_string(), + }) + .collect()) + }) +} + // --------------------------------------------------------------------------- // Stylesheet statistics // --------------------------------------------------------------------------- @@ -620,6 +686,81 @@ mod tests { ParseOptions::default() } + // -- at-rules ----------------------------------------------------------- + + const TAILWIND: &str = r#"@import "tailwindcss"; +@plugin "../vendor/heroicons"; +@plugin "daisyui" { + prefix: "d-"; /* keeps daisyUI off our own .btn */ + exclude: rootcolor; + logs: false; +} +@source "../js"; + +.btn { color: red; } +"#; + + #[test] + fn reads_an_at_rule_block_as_declarations() { + let found = get_at_rules(TAILWIND, "plugin", Some("daisyui"), opts()).unwrap(); + assert_eq!(found.len(), 1); + + let at = &found[0]; + assert_eq!(at.name, "plugin"); + assert_eq!(at.target.as_deref(), Some("daisyui")); + assert!(at.has_block); + assert_eq!( + at.declarations, + vec![ + ("prefix".to_string(), "\"d-\"".to_string()), + ("exclude".to_string(), "rootcolor".to_string()), + ("logs".to_string(), "false".to_string()), + ] + ); + } + + #[test] + fn narrows_by_target_and_returns_every_match_without_one() { + assert_eq!( + get_at_rules(TAILWIND, "plugin", None, opts()).unwrap().len(), + 2 + ); + assert!(get_at_rules(TAILWIND, "plugin", Some("nope"), opts()) + .unwrap() + .is_empty()); + } + + #[test] + fn a_blockless_at_rule_reports_no_declarations() { + let found = get_at_rules(TAILWIND, "import", None, opts()).unwrap(); + assert_eq!(found.len(), 1); + assert!(!found[0].has_block); + assert!(found[0].declarations.is_empty()); + assert_eq!(found[0].target.as_deref(), Some("tailwindcss")); + } + + #[test] + fn the_leading_at_is_optional_in_the_name() { + assert_eq!( + get_at_rules(TAILWIND, "@source", None, opts()).unwrap(), + get_at_rules(TAILWIND, "source", None, opts()).unwrap() + ); + } + + #[test] + fn reports_the_at_rule_text_verbatim_comments_included() { + let found = get_at_rules(TAILWIND, "plugin", Some("daisyui"), opts()).unwrap(); + assert!(found[0].text.starts_with("@plugin \"daisyui\" {")); + assert!(found[0].text.contains("/* keeps daisyUI off our own .btn */")); + } + + #[test] + fn an_absent_at_rule_is_an_empty_list_not_an_error() { + assert!(get_at_rules(".a { color: red; }\n", "plugin", None, opts()) + .unwrap() + .is_empty()); + } + // -- colours ------------------------------------------------------------ #[test] diff --git a/native/igniter_css/src/atoms.rs b/native/igniter_css/src/atoms.rs index adb9c5f..269d5c4 100644 --- a/native/igniter_css/src/atoms.rs +++ b/native/igniter_css/src/atoms.rs @@ -33,6 +33,7 @@ rustler::atoms! { remove_duplicates_nif, analyze_nif, + get_at_rules_nif, validate_nif, extract_colors_nif, extract_media_queries_nif, diff --git a/native/igniter_css/src/locate.rs b/native/igniter_css/src/locate.rs index 0602e5f..cad3ffa 100644 --- a/native/igniter_css/src/locate.rs +++ b/native/igniter_css/src/locate.rs @@ -139,6 +139,16 @@ fn trimmed(node: &CssSyntaxNode) -> (usize, usize) { /// A child node that opens with `{`. Kind-agnostic on purpose: CSS has a dozen /// block kinds and new ones appear between Biome releases. +/// The at-rule's inner node -- the one that carries the name, the prelude and +/// the block. This is what [`declarations_in_block`] wants: it takes the block's +/// *owner*, not the block, so it can be handed a rule or an at-rule alike. +/// +/// `None` when the at-rule has no block. +pub fn at_rule_body(at: &AtRuleRef) -> Option { + let inner = at.node.children().next()?; + block_child(&inner).map(|_| inner) +} + fn block_child(node: &CssSyntaxNode) -> Option { node.children().find(|c| { c.first_token() diff --git a/native/igniter_css/src/nif.rs b/native/igniter_css/src/nif.rs index c2b9878..90ad201 100644 --- a/native/igniter_css/src/nif.rs +++ b/native/igniter_css/src/nif.rs @@ -122,6 +122,17 @@ pub struct ExValidation { pub message: String, } +#[derive(NifStruct, Debug, Clone)] +#[module = "IgniterCss.AtRule"] +pub struct ExAtRule { + pub name: String, + pub prelude: String, + pub target: Option, + pub has_block: bool, + pub declarations: Vec<(String, String)>, + pub text: String, +} + #[derive(NifStruct, Debug, Clone)] #[module = "IgniterCss.Animation"] pub struct ExAnimation { @@ -529,4 +540,30 @@ fn merge_stylesheets_nif(env: Env, sources: Vec, opts: ExParseOpts) -> N ) } +#[rustler::nif(schedule = "DirtyCpu")] +fn get_at_rules_nif( + env: Env, + source: String, + name: String, + matching: Option, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::get_at_rules_nif(), + analyze::get_at_rules(&source, &name, matching.as_deref(), opts.into()).map(|list| { + list.into_iter() + .map(|a| ExAtRule { + name: a.name, + prelude: a.prelude, + target: a.target, + has_block: a.has_block, + declarations: a.declarations, + text: a.text, + }) + .collect::>() + }) + ) +} + rustler::init!("Elixir.IgniterCss.Native"); diff --git a/test/at_rules_test.exs b/test/at_rules_test.exs new file mode 100644 index 0000000..a662a23 --- /dev/null +++ b/test/at_rules_test.exs @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2025 igniter_css contributors +# +# SPDX-License-Identifier: MIT + +defmodule IgniterCss.AtRulesTest do + @moduledoc """ + Reading at-rules back whole — the query an installer needs when the *shape* of + the user's setup decides what it should generate. + + `has_at_rule?/3` answers "is it there"; `get_at_rules/4` answers "and how was + it configured". A generator that emits `@apply btn` into a project whose + daisyUI is loaded as `@plugin "daisyui" { prefix: "d-" }` produces CSS that + will not build, and the only way to know is to read the block. + """ + + use IgniterCss.CssCase, async: true + + @tailwind """ + /* the app's own stylesheet */ + @import "tailwindcss" source(none); + @import "../vendor/app.css"; + @plugin "../vendor/heroicons"; + @plugin "daisyui" { + prefix: "d-"; /* keeps daisyUI off our own .btn */ + exclude: rootcolor; + logs: false; + } + @source "../js"; + + .btn { + color: red; + } + """ + + describe "get_at_rules/4" do + test "reads a block as declarations, in source order" do + assert {:ok, [rule]} = IgniterCss.get_at_rules(@tailwind, "plugin", "daisyui") + + assert rule.name == "plugin" + assert rule.target == "daisyui" + assert rule.has_block + + assert rule.declarations == [ + {"prefix", ~s|"d-"|}, + {"exclude", "rootcolor"}, + {"logs", "false"} + ] + end + + test "without a target, every at-rule of that name comes back" do + assert {:ok, plugins} = IgniterCss.get_at_rules(@tailwind, "plugin") + assert Enum.map(plugins, & &1.target) == ["../vendor/heroicons", "daisyui"] + + assert {:ok, imports} = IgniterCss.get_at_rules(@tailwind, "import") + assert Enum.map(imports, & &1.target) == ["tailwindcss", "../vendor/app.css"] + end + + test "a blockless at-rule reports no declarations" do + assert {:ok, [heroicons]} = + IgniterCss.get_at_rules(@tailwind, "plugin", "../vendor/heroicons") + + refute heroicons.has_block + assert heroicons.declarations == [] + end + + test "the prelude keeps what the target drops" do + assert {:ok, [tailwind | _]} = IgniterCss.get_at_rules(@tailwind, "import") + assert tailwind.prelude =~ "source(none)" + assert tailwind.target == "tailwindcss" + end + + test "the text is verbatim, comments included" do + assert {:ok, [rule]} = IgniterCss.get_at_rules(@tailwind, "plugin", "daisyui") + assert String.starts_with?(rule.text, ~s|@plugin "daisyui" {|) + assert rule.text =~ "/* keeps daisyUI off our own .btn */" + assert String.ends_with?(rule.text, "}") + end + + test "the leading @ is optional" do + assert IgniterCss.get_at_rules(@tailwind, "@source") == + IgniterCss.get_at_rules(@tailwind, "source") + end + + test "absence is an empty list, not an error" do + assert {:ok, []} = IgniterCss.get_at_rules(@tailwind, "plugin", "not-installed") + assert {:ok, []} = IgniterCss.get_at_rules(@tailwind, "container") + assert {:ok, []} = IgniterCss.get_at_rules(".a { color: red; }\n", "plugin") + end + + test "reading never edits — the source comes back byte for byte" do + for {name, css} <- fixtures() do + before = css + assert {:ok, _} = IgniterCss.get_at_rules(css, "media") + assert css == before, "#{name}: the source was mutated by a read" + end + end + + test "every fixture answers rather than raising" do + for {name, css} <- fixtures() do + assert {:ok, list} = IgniterCss.get_at_rules(css, "import"), + "#{name}: get_at_rules/4 failed" + + assert is_list(list) + end + end + + test "agrees with has_at_rule? on presence" do + for target <- ["daisyui", "../vendor/heroicons"] do + assert {:ok, [_]} = IgniterCss.get_at_rules(@tailwind, "plugin", target) + assert {:ok, true} = IgniterCss.has_at_rule?(@tailwind, ~s|@plugin "#{target}";|) + end + + assert {:ok, []} = IgniterCss.get_at_rules(@tailwind, "plugin", "missing") + assert {:ok, false} = IgniterCss.has_at_rule?(@tailwind, ~s|@plugin "missing";|) + end + + test "reads the prefix an installer would have to guess otherwise" do + prefix = + with {:ok, [rule]} <- IgniterCss.get_at_rules(@tailwind, "plugin", "daisyui"), + {_, raw} <- List.keyfind(rule.declarations, "prefix", 0) do + String.trim(raw, ~s|"|) + else + _ -> "" + end + + assert prefix == "d-" + end + end +end diff --git a/test/codemods_test.exs b/test/codemods_test.exs index 114667a..9c695e2 100644 --- a/test/codemods_test.exs +++ b/test/codemods_test.exs @@ -27,6 +27,69 @@ defmodule IgniterCss.CodemodsTest do |> Rewrite.Source.get(:content) end + describe "add_import/5 and remove_import/4" do + test "adds an import through Igniter, so the change is in the diff" do + result = + ~s|@import "tailwindcss";\n| + |> igniter_with() + |> Codemods.add_import(@path, "../vendor/app.css") + |> content() + + assert result == ~s|@import "tailwindcss";\n@import "../vendor/app.css";\n| + end + + test "re-adding the same import leaves the file untouched" do + source = ~s|@import "tailwindcss";\n@import "../vendor/app.css";\n| + + result = + source + |> igniter_with() + |> Codemods.add_import(@path, "../vendor/app.css") + |> content() + + assert result == source + end + + test "an import lands after the prologue and keeps surrounding comments" do + result = + ~s|/* app styles */\n@import "tailwindcss";\n\n.btn {\n color: red;\n}\n| + |> igniter_with() + |> Codemods.add_import(@path, "../vendor/app.css") + |> content() + + assert result =~ "/* app styles */" + assert result =~ ".btn {\n color: red;\n}" + + {tailwind, _} = :binary.match(result, "tailwindcss") + {vendor, _} = :binary.match(result, "../vendor/app.css") + assert tailwind < vendor + end + + test "removing an import takes only that line" do + result = + ~s|@import "tailwindcss";\n@import "../vendor/app.css";\n\n.btn {\n color: red;\n}\n| + |> igniter_with() + |> Codemods.remove_import(@path, "../vendor/app.css") + |> content() + + refute result =~ "../vendor/app.css" + assert result =~ "tailwindcss" + assert result =~ ".btn {\n color: red;\n}" + end + + test "removing an import that is not there changes nothing" do + source = ~s|@import "tailwindcss";\n| + + result = + source + |> igniter_with() + |> Codemods.remove_import(@path, "../vendor/nope.css") + |> content() + + assert result == source + end + end + describe "ensure_at_rule/4" do test "adds the at-rule to the file" do result = From f2e7db1b499300f4e30f0f96ef8bdefb8c2ab6d3 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Sun, 9 Aug 2026 23:43:54 +0200 Subject: [PATCH 2/7] style: rustfmt the get_at_rules tests Two assertions in the new at-rule tests were written past the width rustfmt wraps at, so `cargo fmt --check` failed in CI. Formatting only. Co-Authored-By: Claude Opus 5 --- native/igniter_css/src/analyze.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/native/igniter_css/src/analyze.rs b/native/igniter_css/src/analyze.rs index 430a419..9a00867 100644 --- a/native/igniter_css/src/analyze.rs +++ b/native/igniter_css/src/analyze.rs @@ -722,7 +722,9 @@ mod tests { #[test] fn narrows_by_target_and_returns_every_match_without_one() { assert_eq!( - get_at_rules(TAILWIND, "plugin", None, opts()).unwrap().len(), + get_at_rules(TAILWIND, "plugin", None, opts()) + .unwrap() + .len(), 2 ); assert!(get_at_rules(TAILWIND, "plugin", Some("nope"), opts()) @@ -751,7 +753,9 @@ mod tests { fn reports_the_at_rule_text_verbatim_comments_included() { let found = get_at_rules(TAILWIND, "plugin", Some("daisyui"), opts()).unwrap(); assert!(found[0].text.starts_with("@plugin \"daisyui\" {")); - assert!(found[0].text.contains("/* keeps daisyUI off our own .btn */")); + assert!(found[0] + .text + .contains("/* keeps daisyUI off our own .btn */")); } #[test] From b734e22aea89d3bb229e4a686e4bb2f371b94789 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Sun, 6 Sep 2026 17:25:18 +0200 Subject: [PATCH 3/7] feat: ensure_at_rule_block, and read an at-rule's block back verbatim `ensure_at_rule` inserts a statement line and `replace_rule_body` works on style rules, so nothing owned a block at-rule: giving `@theme { ... }` a body meant regex, and re-running an installer appended a second one. `ensure_at_rule_block/5` replaces an existing body or inserts the whole rule when there is none, so it is idempotent. `matching` narrows to one target the way `remove_at_rule/4` does and is carried into the inserted prelude. The body is spliced verbatim through `reindent` rather than rebuilt from parsed declarations. Rebuilding drops comments and blank-line grouping, which a caller moving a block between files would not expect to lose. `reindent` needs the block's own common base to strip, so the first line keeps its indentation. `IgniterCss.AtRule` gains `body`: the bytes between the braces, `None` for an at-rule without a block. Reading a block out of one file and writing it into another is what makes the pair useful together. Co-Authored-By: Claude Opus 5 --- lib/igniter_css.ex | 20 +++ lib/igniter_css/codemods.ex | 8 + lib/igniter_css/native.ex | 2 + lib/igniter_css/structs.ex | 6 +- native/igniter_css/src/analyze.rs | 7 + native/igniter_css/src/atoms.rs | 1 + native/igniter_css/src/nif.rs | 25 ++++ native/igniter_css/src/ops/at_rule.rs | 206 +++++++++++++++++++++++++- test/at_rules_test.exs | 78 ++++++++++ 9 files changed, 350 insertions(+), 3 deletions(-) diff --git a/lib/igniter_css.ex b/lib/igniter_css.ex index cdf897a..4e99724 100644 --- a/lib/igniter_css.ex +++ b/lib/igniter_css.ex @@ -105,6 +105,26 @@ defmodule IgniterCss do Native.ensure_at_rule_nif(source, line, ParseOpts.new(opts)) |> unwrap() end + @doc """ + Give the top-level at-rule `name` this block, replacing an existing body or + inserting the whole rule when there is none. + + `declarations` is spliced in verbatim, re-indented to the file. `matching` + narrows to one target the way `remove_at_rule/4` does, and is carried into the + prelude. + + iex> css = ~s|@import "tailwindcss";\\n| + iex> {:ok, out} = IgniterCss.ensure_at_rule_block(css, "theme", nil, "--color-a: red;") + iex> out.source + ~s|@import "tailwindcss";\\n@theme {\\n --color-a: red;\\n}\\n| + """ + @spec ensure_at_rule_block(String.t(), String.t(), String.t() | nil, String.t(), opts()) :: + result() + def ensure_at_rule_block(source, name, matching \\ nil, declarations, opts \\ []) do + Native.ensure_at_rule_block_nif(source, name, matching, declarations, ParseOpts.new(opts)) + |> unwrap() + end + @doc """ Remove top-level at-rules of `name`. diff --git a/lib/igniter_css/codemods.ex b/lib/igniter_css/codemods.ex index 71b1f5c..3dedb35 100644 --- a/lib/igniter_css/codemods.ex +++ b/lib/igniter_css/codemods.ex @@ -48,6 +48,13 @@ defmodule IgniterCss.Codemods do end) end + @doc "See `IgniterCss.ensure_at_rule_block/5`." + def ensure_at_rule_block(igniter, path, name, matching \\ nil, declarations, opts \\ []) do + update(igniter, path, "ensure_at_rule_block #{inspect(name)}", fn source -> + IgniterCss.ensure_at_rule_block(source, name, matching, declarations, 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 -> @@ -137,6 +144,7 @@ defmodule IgniterCss.Codemods do else for {name, arity} <- [ ensure_at_rule: 4, + ensure_at_rule_block: 6, remove_at_rule: 5, add_import: 5, remove_import: 4, diff --git a/lib/igniter_css/native.ex b/lib/igniter_css/native.ex index 8740dce..d982f35 100644 --- a/lib/igniter_css/native.ex +++ b/lib/igniter_css/native.ex @@ -39,6 +39,8 @@ defmodule IgniterCss.Native do # -- at-rules --------------------------------------------------------------- def ensure_at_rule_nif(_source, _line, _opts), do: error() + + def ensure_at_rule_block_nif(_source, _name, _matching, _declarations, _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() diff --git a/lib/igniter_css/structs.ex b/lib/igniter_css/structs.ex index 99953a8..3bb1424 100644 --- a/lib/igniter_css/structs.ex +++ b/lib/igniter_css/structs.ex @@ -143,7 +143,8 @@ defmodule IgniterCss.AtRule do target: String.t() | nil, has_block: boolean(), declarations: [{String.t(), String.t()}], - text: String.t() + text: String.t(), + body: String.t() | nil } defstruct name: "", @@ -151,7 +152,8 @@ defmodule IgniterCss.AtRule do target: nil, has_block: false, declarations: [], - text: "" + text: "", + body: nil end defmodule IgniterCss.Animation do diff --git a/native/igniter_css/src/analyze.rs b/native/igniter_css/src/analyze.rs index 9a00867..508dd7b 100644 --- a/native/igniter_css/src/analyze.rs +++ b/native/igniter_css/src/analyze.rs @@ -472,6 +472,9 @@ pub struct AtRule { pub declarations: Vec<(String, String)>, /// The at-rule's own bytes, exactly as written. pub text: String, + /// The bytes between the block's braces, exactly as written. `None` for an + /// at-rule with no block. + pub body: Option, } /// Every **top-level** at-rule named `name`, optionally narrowed to those whose @@ -509,6 +512,10 @@ pub fn get_at_rules( }) .unwrap_or_default(), text: ctx.source()[at.start..at.end].to_string(), + body: match (at.body_open, at.body_close) { + (Some(open), Some(close)) => Some(ctx.source()[open..close].to_string()), + _ => None, + }, }) .collect()) }) diff --git a/native/igniter_css/src/atoms.rs b/native/igniter_css/src/atoms.rs index 269d5c4..70985d4 100644 --- a/native/igniter_css/src/atoms.rs +++ b/native/igniter_css/src/atoms.rs @@ -10,6 +10,7 @@ rustler::atoms! { // One atom per NIF, so `IgniterCss.Helpers.normalize_output/2` can label // the result with the operation that produced it. ensure_at_rule_nif, + ensure_at_rule_block_nif, remove_at_rule_nif, has_at_rule_nif, add_import_nif, diff --git a/native/igniter_css/src/nif.rs b/native/igniter_css/src/nif.rs index 90ad201..afdef40 100644 --- a/native/igniter_css/src/nif.rs +++ b/native/igniter_css/src/nif.rs @@ -131,6 +131,7 @@ pub struct ExAtRule { pub has_block: bool, pub declarations: Vec<(String, String)>, pub text: String, + pub body: Option, } #[derive(NifStruct, Debug, Clone)] @@ -193,6 +194,29 @@ fn remove_at_rule_nif( ) } +#[rustler::nif(schedule = "DirtyCpu")] +fn ensure_at_rule_block_nif( + env: Env, + source: String, + name: String, + matching: Option, + declarations: String, + opts: ExParseOpts, +) -> NifResult { + respond!( + env, + atoms::ensure_at_rule_block_nif(), + at_rule::ensure_at_rule_block( + &source, + &name, + matching.as_deref(), + &declarations, + opts.into() + ) + .map(ExOutcome::from) + ) +} + #[rustler::nif(schedule = "DirtyCpu")] fn has_at_rule_nif(env: Env, source: String, line: String, opts: ExParseOpts) -> NifResult { respond!( @@ -560,6 +584,7 @@ fn get_at_rules_nif( has_block: a.has_block, declarations: a.declarations, text: a.text, + body: a.body, }) .collect::>() }) diff --git a/native/igniter_css/src/ops/at_rule.rs b/native/igniter_css/src/ops/at_rule.rs index bc94323..e796c85 100644 --- a/native/igniter_css/src/ops/at_rule.rs +++ b/native/igniter_css/src/ops/at_rule.rs @@ -14,7 +14,7 @@ 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::ops::{reindent, run, validate_snippet, Outcome}; use crate::trivia::{absorb_surrounding_blank_line, comment_ranges, deletion_span}; use biome_css_syntax::CssSyntaxKind; @@ -255,6 +255,92 @@ pub fn remove_import(source: &str, url: &str, options: ParseOptions) -> Result String { + let nl = ctx.nl(); + let content = body.trim_end().trim_start_matches(['\n', '\r']); + if content.trim().is_empty() { + return if single_line { + String::new() + } else { + format!("{nl}{indent}") + }; + } + if single_line && !content.contains('\n') { + return format!(" {} ", content.trim()); + } + let inner_indent = format!("{indent}{}", ctx.indent()); + let inner = reindent(content, &inner_indent, nl); + format!("{nl}{inner}{nl}{indent}") +} + +/// Give the top-level at-rule `name` this block, replacing an existing body or +/// inserting the whole rule when there is none. +/// +/// `matching` narrows to one target the way [`remove_at_rule`] does, and is +/// carried into the inserted prelude. +pub fn ensure_at_rule_block( + source: &str, + name: &str, + matching: Option<&str>, + declarations: &str, + options: ParseOptions, +) -> Result { + let want_name = name.trim().trim_start_matches('@').trim().to_lowercase(); + if want_name.is_empty() { + return Err(CssError::InvalidInput("at-rule name is empty".to_string())); + } + validate_snippet(declarations, "declarations")?; + let want = matching.map(normalize_target_needle); + + let header = match matching.map(str::trim).filter(|m| !m.is_empty()) { + Some(m) => format!("@{want_name} {m}"), + None => format!("@{want_name}"), + }; + + run(source, options, |ctx| { + let existing = find_at_rules_named(ctx, &want_name) + .into_iter() + .find(|at| match &want { + None => true, + Some(w) => match &at.target { + Some(target) => target == w, + None => at.prelude_norm == *w, + }, + }); + + if let Some(at) = existing { + let (Some(open), Some(close)) = (at.body_open, at.body_close) else { + return Err(CssError::InvalidInput(format!( + "@{want_name} is present without a block; refusing to give it one" + ))); + }; + let indent = ctx.indent_at(at.start).to_string(); + let single_line = !ctx.source()[at.start..at.end].contains('\n'); + let replacement = render_block_body(ctx, declarations, &indent, single_line); + return Ok(vec![Edit::replace(open, close, replacement)]); + } + + let spec = parse_at_rule_spec(&format!("{header} {{}}"))?; + let at = insertion_offset(ctx, &spec); + let nl = ctx.nl(); + let indent = ctx.indent_at(at).to_string(); + let block = format!( + "{header} {{{}}}", + render_block_body(ctx, declarations, &indent, false) + ); + + let text = if at == 0 { + format!("{block}{nl}") + } else if ctx.source()[..at].ends_with('\n') { + format!("{indent}{block}{nl}") + } else { + format!("{nl}{indent}{block}") + }; + Ok(vec![Edit::insert(at, text)]) + }) +} + /// 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)?; @@ -277,6 +363,124 @@ mod tests { remove_at_rule(src, name, matching, ParseOptions::default()).unwrap() } + fn ensure_block(src: &str, name: &str, matching: Option<&str>, decls: &str) -> Outcome { + ensure_at_rule_block(src, name, matching, decls, ParseOptions::default()).unwrap() + } + + // -- block at-rules ----------------------------------------------------- + + #[test] + fn inserts_a_block_at_rule_when_absent() { + let out = ensure_block( + "@import \"tailwindcss\";\n", + "theme", + None, + "--color-a: red;", + ); + assert!(out.changed); + assert_eq!( + out.source, + "@import \"tailwindcss\";\n@theme {\n --color-a: red;\n}\n" + ); + } + + #[test] + fn replaces_the_body_of_an_existing_block_at_rule() { + let src = "@theme {\n --color-a: red;\n}\n"; + let out = ensure_block(src, "theme", None, "--color-b: blue;"); + assert!(out.changed); + assert_eq!(out.source, "@theme {\n --color-b: blue;\n}\n"); + } + + #[test] + fn replacing_a_block_at_rule_with_the_same_body_is_a_no_op() { + let src = "@theme {\n --color-a: red;\n}\n"; + let out = ensure_block(src, "theme", None, "--color-a: red;"); + assert!(!out.changed); + assert_eq!(out.source, src); + } + + #[test] + fn keeps_a_single_line_block_on_one_line() { + let out = ensure_block( + "@theme { --color-a: red; }\n", + "theme", + None, + "--color-b: blue;", + ); + assert_eq!(out.source, "@theme { --color-b: blue; }\n"); + } + + #[test] + fn accepts_the_name_with_or_without_the_at_sign() { + let a = ensure_block("", "theme", None, "--a: 1;"); + let b = ensure_block("", "@theme", None, "--a: 1;"); + assert_eq!(a.source, b.source); + } + + #[test] + fn narrows_to_a_matching_target() { + let src = "@plugin \"a\" {\n x: 1;\n}\n@plugin \"b\" {\n y: 2;\n}\n"; + let out = ensure_block(src, "plugin", Some("\"b\""), "y: 3;"); + assert_eq!( + out.source, + "@plugin \"a\" {\n x: 1;\n}\n@plugin \"b\" {\n y: 3;\n}\n" + ); + } + + #[test] + fn carries_matching_into_an_inserted_prelude() { + let out = ensure_block("", "plugin", Some("\"daisyui\""), "prefix: \"d-\";"); + assert_eq!(out.source, "@plugin \"daisyui\" {\n prefix: \"d-\";\n}\n"); + } + + #[test] + fn empties_a_block_given_no_declarations() { + let out = ensure_block("@theme {\n --a: 1;\n}\n", "theme", None, ""); + assert_eq!(out.source, "@theme {\n}\n"); + } + + #[test] + fn refuses_to_give_a_block_to_a_statement_at_rule() { + let err = ensure_at_rule_block( + "@import \"a.css\";\n", + "import", + None, + "x: 1;", + ParseOptions::default(), + ); + assert!(err.is_err()); + } + + #[test] + fn keeps_comments_and_grouping_in_a_spliced_body() { + let body = " --a: 1;\n\n /* group */\n --b: 2;"; + let out = ensure_block("", "theme", None, body); + assert_eq!( + out.source, + "@theme {\n --a: 1;\n\n /* group */\n --b: 2;\n}\n" + ); + } + + #[test] + fn reindents_a_body_taken_from_another_file() { + let body = " --a: 1;\n --b: 2;"; + let out = ensure_block("", "theme", None, body); + assert_eq!(out.source, "@theme {\n --a: 1;\n --b: 2;\n}\n"); + } + + #[test] + fn rejects_an_empty_name() { + let err = ensure_at_rule_block("", "@", None, "x: 1;", ParseOptions::default()); + assert!(err.is_err()); + } + + #[test] + fn rejects_declarations_that_would_unbalance_the_file() { + let err = ensure_at_rule_block("", "theme", None, "x: 1; }", ParseOptions::default()); + assert!(err.is_err()); + } + // -- spec parsing ------------------------------------------------------- #[test] diff --git a/test/at_rules_test.exs b/test/at_rules_test.exs index a662a23..e70ba42 100644 --- a/test/at_rules_test.exs +++ b/test/at_rules_test.exs @@ -32,6 +32,84 @@ defmodule IgniterCss.AtRulesTest do } """ + describe "AtRule.body" do + test "hands back the block verbatim, comments and all" do + assert {:ok, [rule]} = IgniterCss.get_at_rules(@tailwind, "plugin", "daisyui") + + assert rule.body =~ ~s|prefix: "d-"; /* keeps daisyUI off our own .btn */| + assert rule.body =~ "exclude: rootcolor;" + end + + test "is nil for an at-rule that carries no block" do + assert {:ok, [rule]} = IgniterCss.get_at_rules(@tailwind, "source") + refute rule.has_block + assert rule.body == nil + end + + test "round-trips through ensure_at_rule_block/5" do + assert {:ok, [rule]} = IgniterCss.get_at_rules(@tailwind, "plugin", "daisyui") + assert {:ok, out} = IgniterCss.ensure_at_rule_block("", "plugin", ~s|"daisyui"|, rule.body) + assert {:ok, [copied]} = IgniterCss.get_at_rules(out.source, "plugin", "daisyui") + assert copied.declarations == rule.declarations + end + end + + describe "ensure_at_rule_block/5" do + test "inserts the block into the prologue when the at-rule is absent" do + assert {:ok, out} = + IgniterCss.ensure_at_rule_block(@tailwind, "theme", nil, "--color-brand: red;") + + assert out.changed + assert out.source =~ "@theme {\n --color-brand: red;\n}" + assert {:ok, [rule]} = IgniterCss.get_at_rules(out.source, "theme") + assert rule.declarations == [{"--color-brand", "red"}] + end + + test "replaces the body of the at-rule it already has, rather than adding a second" do + assert {:ok, first} = + IgniterCss.ensure_at_rule_block(@tailwind, "theme", nil, "--color-brand: red;") + + assert {:ok, second} = + IgniterCss.ensure_at_rule_block(first.source, "theme", nil, "--color-brand: blue;") + + assert {:ok, [rule]} = IgniterCss.get_at_rules(second.source, "theme") + assert rule.declarations == [{"--color-brand", "blue"}] + end + + test "is idempotent, so a re-run produces no diff" do + assert {:ok, first} = + IgniterCss.ensure_at_rule_block(@tailwind, "theme", nil, "--color-brand: red;") + + assert {:ok, again} = + IgniterCss.ensure_at_rule_block(first.source, "theme", nil, "--color-brand: red;") + + refute again.changed + assert again.source == first.source + end + + test "narrows to one target and leaves its siblings alone" do + assert {:ok, out} = + IgniterCss.ensure_at_rule_block(@tailwind, "plugin", "daisyui", ~s|prefix: "x-";|) + + assert {:ok, [rule]} = IgniterCss.get_at_rules(out.source, "plugin", "daisyui") + assert rule.declarations == [{"prefix", ~s|"x-"|}] + assert out.source =~ ~s|@plugin "../vendor/heroicons";| + end + + test "refuses an at-rule that carries no block" do + assert {:error, _} = IgniterCss.ensure_at_rule_block(@tailwind, "source", nil, "x: 1;") + end + + test "leaves the rest of the stylesheet untouched" do + assert {:ok, out} = + IgniterCss.ensure_at_rule_block(@tailwind, "theme", nil, "--color-brand: red;") + + assert out.source =~ "/* the app's own stylesheet */" + assert out.source =~ ".btn {\n color: red;\n}" + assert out.source =~ ~s|@import "tailwindcss" source(none);| + end + end + describe "get_at_rules/4" do test "reads a block as declarations, in source order" do assert {:ok, [rule]} = IgniterCss.get_at_rules(@tailwind, "plugin", "daisyui") From 51bc6d22814f7e54967cffb85efac8a9653611de Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Sun, 6 Sep 2026 17:40:48 +0200 Subject: [PATCH 4/7] fix: detect the indent width by frequency, as the docs already said `detect_indent` documented "the most common non-empty prefix" but returned the smallest width present. One stray shallow line then decided the file's indent for every codemod that re-indents: a stylesheet indented with four spaces throughout, carrying a couple of two-space lines, reported two, and inserted blocks came out half-indented. Now it counts widths and takes the most frequent, preferring the shallower on a tie. Tabs still win outright, and a file with nothing indented still falls back to two spaces. Co-Authored-By: Claude Opus 5 --- native/igniter_css/src/ctx.rs | 39 +++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/native/igniter_css/src/ctx.rs b/native/igniter_css/src/ctx.rs index 0239512..0acd76e 100644 --- a/native/igniter_css/src/ctx.rs +++ b/native/igniter_css/src/ctx.rs @@ -12,6 +12,7 @@ use biome_css_parser::{parse_css, CssParse, CssParserOptions}; use biome_css_syntax::{CssSyntaxKind, CssSyntaxNode}; use biome_rowan::TextRange; +use std::collections::BTreeMap; pub const BOM: &str = "\u{feff}"; @@ -365,11 +366,16 @@ fn detect_indent(source: &str) -> String { 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(), + let mut counts: BTreeMap = BTreeMap::new(); + for w in widths { + *counts.entry(w).or_default() += 1; } + + counts + .into_iter() + .max_by_key(|&(width, count)| (count, std::cmp::Reverse(width))) + .map(|(width, _)| " ".repeat(width)) + .unwrap_or_else(|| " ".to_string()) } /// True for the kinds that make up a comment trivia piece. @@ -451,6 +457,31 @@ mod tests { assert_eq!(detect_indent(src), " "); } + #[test] + fn a_stray_shallow_line_does_not_decide_the_width() { + let src = concat!( + ".a {\n", + " color: red;\n", + " margin: 0;\n", + " padding: 0;\n", + "}\n", + "@media (min-width: 1px) {\n", + " .b {\n", + " color: blue;\n", + " }\n", + "}\n" + ); + assert_eq!(detect_indent(src), " "); + } + + #[test] + fn a_tie_prefers_the_shallower_width() { + assert_eq!( + detect_indent(".a {\n x: 1;\n}\n.b {\n y: 2;\n}\n"), + " " + ); + } + #[test] fn balanced_braces_are_recognised() { assert!(ParseCtx::parse_default(".a { color: red; }\n").braces_are_balanced()); From fe7d1afd148503f8c380402906fd8c87ad180809 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Sun, 6 Sep 2026 19:34:18 +0200 Subject: [PATCH 5/7] fix: keep a pseudo-class colon attached to its selector when beautifying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `beautify/2` emitted `": "` for every COLON token, so `.a:not([data-x])` came back as `.a: not([data-x])` — invalid CSS, from a function whose whole job is to reprint a stylesheet unchanged but tidier. Any caller formatting a file with a pseudo-class was corrupting it. The distinction is grammatical, not textual, so it is read off the node the token hangs from: `CSS_PSEUDO_CLASS_SELECTOR` and its function and pseudo-element variants take a bare colon, while `CSS_GENERIC_PROPERTY` and `CSS_QUERY_FEATURE_PLAIN` — declarations and media features — keep the space. Covered for `:not`, `::before`, a nested `:not(:hover)`, and both colons that must keep their space. Co-Authored-By: Claude Opus 5 --- native/igniter_css/src/transform.rs | 61 ++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/native/igniter_css/src/transform.rs b/native/igniter_css/src/transform.rs index 31a98cf..a18de41 100644 --- a/native/igniter_css/src/transform.rs +++ b/native/igniter_css/src/transform.rs @@ -86,6 +86,32 @@ pub fn minify(source: &str, options: ParseOptions) -> Result { Ok(ctx.restore_bom(out)) } +/// Is this colon the one introducing a pseudo-class or pseudo-element, rather +/// than a declaration or a media feature? +/// +/// Read from the node the token hangs off, so `:not(...)` in a selector and +/// `color:` in a body are told apart by the grammar rather than by their +/// surroundings. +fn is_selector_colon(token: &biome_css_syntax::CssSyntaxToken) -> bool { + token.parent().is_some_and(|parent| { + matches!( + parent.kind(), + CssSyntaxKind::CSS_PSEUDO_CLASS_SELECTOR + | CssSyntaxKind::CSS_PSEUDO_ELEMENT_SELECTOR + | CssSyntaxKind::CSS_PSEUDO_CLASS_FUNCTION_SELECTOR + | CssSyntaxKind::CSS_PSEUDO_CLASS_FUNCTION_SELECTOR_LIST + | CssSyntaxKind::CSS_PSEUDO_CLASS_FUNCTION_IDENTIFIER + | CssSyntaxKind::CSS_PSEUDO_CLASS_FUNCTION_COMPOUND_SELECTOR + | CssSyntaxKind::CSS_PSEUDO_CLASS_FUNCTION_COMPOUND_SELECTOR_LIST + | CssSyntaxKind::CSS_PSEUDO_CLASS_FUNCTION_RELATIVE_SELECTOR_LIST + | CssSyntaxKind::CSS_PSEUDO_CLASS_FUNCTION_VALUE_LIST + | CssSyntaxKind::CSS_PSEUDO_CLASS_FUNCTION_NTH + | CssSyntaxKind::CSS_PSEUDO_ELEMENT_FUNCTION_SELECTOR + | CssSyntaxKind::CSS_PSEUDO_ELEMENT_FUNCTION_IDENTIFIER + ) + }) +} + /// Re-print the stylesheet with one declaration per line and consistent /// indentation, keeping every comment. /// @@ -222,7 +248,9 @@ pub fn beautify(source: &str, options: ParseOptions) -> Result { } CssSyntaxKind::COLON => { push(&mut out, &mut at_line_start, depth, ":"); - out.push(' '); + if !is_selector_colon(token) { + out.push(' '); + } } _ => { if !at_line_start { @@ -297,6 +325,37 @@ pub fn merge_stylesheets(sheets: &[String], options: ParseOptions) -> Result ParseOptions { From 040d929bcbf7b6520e6b2530f70437456f35e9c5 Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Sun, 6 Sep 2026 20:07:45 +0200 Subject: [PATCH 6/7] fix: stop the transforms damaging values, and hold them to the corpus invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `corpus_invariants` covers the mutating ops, but `beautify` and `minify` reprint the whole file and were never in it. That is how a colon bug reached a released formatter, and running the same style of invariants over the transforms found three more, all silent: * `needs_space` only fired after a word character, so anything following `)`, `"` or `*/` lost its separator: `url(x) no-repeat` became `url(x)no-repeat`, `minmax(12rem, 1fr) 3fr` became `minmax(12rem, 1fr)3fr`, and the two strings of a `grid-template-areas` ran together. It now keeps a space wherever the source had whitespace, which is both simpler and faithful. * `minify` dropped the semicolon before every `}`, including an at-rule's. `@apply text-2xl;` became `@apply text-2xl}` and the enclosing rule stopped parsing as a rule — `.typography` was no longer findable by `list_selectors`. Only a `CSS_DECLARATION_WITH_SEMICOLON` terminator is dropped now. * a comment inside a selector list lost the space after it. `tests/transform_invariants.rs` runs both transforms over every fixture and asserts the output parses, is idempotent, and keeps every selector, declaration, at-rule and comment — plus that no pseudo colon is ever split. Comparison is by meaning, so reprinting may write `.a, .b` for `.a,.b` and minifying may drop a comment, but neither may change what matches. `pathological_selectors.css` adds the corpus's adversarial case: `:not()` with a selector list, `:nth-child(2n + 1)`, `:has`/`:is`/`:where`, attribute selectors whose values contain braces and colons, escaped selectors, a data-URI, nested at-rules and `@layer`. Co-Authored-By: Claude Opus 5 --- native/igniter_css/src/transform.rs | 82 +++++- .../igniter_css/tests/transform_invariants.rs | 255 ++++++++++++++++++ test/fixtures/pathological_selectors.css | 74 +++++ .../pathological_selectors.css.license | 3 + test/transform_test.exs | 132 +++++++++ 5 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 native/igniter_css/tests/transform_invariants.rs create mode 100644 test/fixtures/pathological_selectors.css create mode 100644 test/fixtures/pathological_selectors.css.license diff --git a/native/igniter_css/src/transform.rs b/native/igniter_css/src/transform.rs index a18de41..c2545a1 100644 --- a/native/igniter_css/src/transform.rs +++ b/native/igniter_css/src/transform.rs @@ -57,7 +57,10 @@ pub fn minify(source: &str, options: ParseOptions) -> Result { 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 { + // Only a declaration's: an at-rule statement such as `@apply x;` needs + // its terminator, or `}` has to end it and the rule stops parsing as + // one. + if token.kind() == CssSyntaxKind::SEMICOLON && terminates_declaration(token) { let next_is_close = tokens .get(i + 1) .is_some_and(|t| t.kind() == CssSyntaxKind::R_CURLY); @@ -86,6 +89,14 @@ pub fn minify(source: &str, options: ParseOptions) -> Result { Ok(ctx.restore_bom(out)) } +/// Is this semicolon the one ending a declaration, rather than an at-rule +/// statement? +fn terminates_declaration(token: &biome_css_syntax::CssSyntaxToken) -> bool { + token.parent().is_some_and(|parent| { + matches!(parent.kind(), CssSyntaxKind::CSS_DECLARATION_WITH_SEMICOLON) + }) +} + /// Is this colon the one introducing a pseudo-class or pseudo-element, rather /// than a declaration or a media feature? /// @@ -259,11 +270,8 @@ pub fn beautify(source: &str, options: ParseOptions) -> Result { 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, '{')); + let separated = gap.chars().any(char::is_whitespace); + let needs_space = !last.is_whitespace() && (separated || matches!(first, '{')); if needs_space { out.push(' '); } @@ -326,6 +334,68 @@ pub fn merge_stylesheets(sheets: &[String], options: ParseOptions) -> Result +// +// SPDX-License-Identifier: MIT + +//! What must hold for the whole-file transforms against every fixture. +//! +//! `corpus_invariants` covers the mutating ops; `beautify` and `minify` reprint +//! the entire file, so they can damage a construct the ops would never touch. +//! A reprint that changes what the stylesheet *means* is the failure to catch: +//! the selector set, the declarations under each selector, and the at-rules +//! must survive, and the result must still parse. + +mod support; + +use igniter_css::ctx::{ParseCtx, ParseOptions}; +use igniter_css::transform::{beautify, minify}; +use support::fixtures; + +fn opts() -> ParseOptions { + ParseOptions::default() +} + +fn parses(source: &str) -> bool { + ParseCtx::try_new(source, opts()).is_ok_and(|ctx| ctx.round_trips()) +} + +/// A selector reduced to what it *means*: comments gone, whitespace collapsed, +/// and no space left around a separator. Reprinting is allowed to write `.a, .b` +/// where the source said `.a,.b`, and minifying is allowed to drop a comment — +/// neither changes which elements match. +fn canonical_selector(selector: &str) -> String { + let chars: Vec = selector.chars().collect(); + let mut stripped = String::with_capacity(selector.len()); + let mut i = 0; + let mut in_comment = false; + while i < chars.len() { + if !in_comment && chars[i] == '/' && chars.get(i + 1) == Some(&'*') { + in_comment = true; + i += 2; + continue; + } + if in_comment && chars[i] == '*' && chars.get(i + 1) == Some(&'/') { + in_comment = false; + i += 2; + continue; + } + if !in_comment { + stripped.push(chars[i]); + } + i += 1; + } + + let collapsed = stripped.split_whitespace().collect::>().join(" "); + let collapsed: Vec = collapsed.chars().collect(); + let separator = |c: Option| matches!(c, Some(',' | '>' | '+' | '~')); + + let mut out = String::with_capacity(collapsed.len()); + for (k, &c) in collapsed.iter().enumerate() { + if c == ' ' && (separator(out.chars().last()) || separator(collapsed.get(k + 1).copied())) { + continue; + } + out.push(c); + } + out +} + +fn selectors(source: &str) -> Vec { + let mut out: Vec = igniter_css::ops::rule::list_selectors(source, opts()) + .unwrap_or_default() + .iter() + .map(|s| canonical_selector(s)) + .collect(); + out.sort(); + out +} + +/// Declarations with every space gone. Minifying is *defined* as removing +/// whitespace, so it can only be judged on what survives without it. +fn declarations_squeezed(source: &str) -> Vec<(String, String)> { + declarations(source) + .into_iter() + .map(|(p, v)| (p, v.chars().filter(|c| !c.is_whitespace()).collect())) + .collect() +} + +fn declarations(source: &str) -> Vec<(String, String)> { + let Ok(ctx) = ParseCtx::try_new(source, opts()) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for rule in igniter_css::locate::find_top_level_rules(&ctx) { + for d in igniter_css::locate::declarations_in(&ctx, &rule) { + let value = d.value_raw.split_whitespace().collect::>().join(" "); + out.push((d.property.clone(), value)); + } + } + out.sort(); + out +} + +fn at_rule_names(source: &str) -> Vec { + let Ok(ctx) = ParseCtx::try_new(source, opts()) else { + return Vec::new(); + }; + let mut out: Vec = igniter_css::locate::find_top_level_at_rules(&ctx) + .iter() + .map(|a| a.name.clone()) + .collect(); + out.sort(); + out +} + +fn comment_count(source: &str) -> usize { + let Ok(ctx) = ParseCtx::try_new(source, opts()) else { + return 0; + }; + igniter_css::locate::all_comments(&ctx).len() +} + +#[test] +fn beautify_output_still_parses() { + for (name, source) in fixtures() { + let Ok(out) = beautify(&source, opts()) else { + continue; + }; + assert!( + parses(&out), + "{name}: beautified output does not round-trip" + ); + } +} + +#[test] +fn beautify_is_idempotent() { + for (name, source) in fixtures() { + let Ok(once) = beautify(&source, opts()) else { + continue; + }; + let twice = beautify(&once, opts()).expect("beautified output must beautify"); + assert_eq!(once, twice, "{name}: beautify is not idempotent"); + } +} + +#[test] +fn beautify_keeps_every_selector() { + for (name, source) in fixtures() { + let Ok(out) = beautify(&source, opts()) else { + continue; + }; + assert_eq!( + selectors(&source), + selectors(&out), + "{name}: beautify changed the selector set" + ); + } +} + +#[test] +fn beautify_keeps_every_declaration() { + for (name, source) in fixtures() { + let Ok(out) = beautify(&source, opts()) else { + continue; + }; + assert_eq!( + declarations(&source), + declarations(&out), + "{name}: beautify changed a declaration" + ); + } +} + +#[test] +fn beautify_keeps_every_at_rule() { + for (name, source) in fixtures() { + let Ok(out) = beautify(&source, opts()) else { + continue; + }; + assert_eq!( + at_rule_names(&source), + at_rule_names(&out), + "{name}: beautify changed the at-rules" + ); + } +} + +#[test] +fn beautify_keeps_every_comment() { + for (name, source) in fixtures() { + let Ok(out) = beautify(&source, opts()) else { + continue; + }; + assert_eq!( + comment_count(&source), + comment_count(&out), + "{name}: beautify lost a comment" + ); + } +} + +#[test] +fn beautify_never_splits_a_pseudo_selector() { + for (name, source) in fixtures() { + let Ok(out) = beautify(&source, opts()) else { + continue; + }; + for selector in selectors(&out) { + assert!( + !selector.contains(": "), + "{name}: beautify split a pseudo colon in {selector:?}" + ); + } + } +} + +#[test] +fn minify_keeps_every_selector_and_declaration() { + for (name, source) in fixtures() { + let Ok(out) = minify(&source, opts()) else { + continue; + }; + assert!(parses(&out), "{name}: minified output does not round-trip"); + assert_eq!( + selectors(&source), + selectors(&out), + "{name}: minify changed the selector set" + ); + assert_eq!( + declarations_squeezed(&source), + declarations_squeezed(&out), + "{name}: minify changed a declaration" + ); + } +} + +#[test] +fn beautify_then_minify_preserves_meaning() { + for (name, source) in fixtures() { + let Ok(pretty) = beautify(&source, opts()) else { + continue; + }; + let Ok(small) = minify(&pretty, opts()) else { + continue; + }; + assert_eq!( + selectors(&source), + selectors(&small), + "{name}: round trip changed the selector set" + ); + assert_eq!( + declarations_squeezed(&source), + declarations_squeezed(&small), + "{name}: round trip changed a declaration" + ); + } +} diff --git a/test/fixtures/pathological_selectors.css b/test/fixtures/pathological_selectors.css new file mode 100644 index 0000000..f05a0e4 --- /dev/null +++ b/test/fixtures/pathological_selectors.css @@ -0,0 +1,74 @@ +/* SPDX-FileCopyrightText: 2025 igniter_css contributors + * + * SPDX-License-Identifier: MIT + */ + +a:hover, +a:focus-visible::before, +a::after { + content: "a:b"; + color: red; +} + +.card:not([data-open]) .body, +.card:not(:hover, :focus-within) > .footer { + display: none; +} + +li:nth-child(2n + 1):not(:last-child) { + margin-block: 0; +} + +input[type="text"]:not([disabled]):focus, +input[placeholder="a:b, c{d}"] { + outline: 2px solid; +} + +:root:has(> .theme-dark) .panel:where(.a, .b):is(:hover, :focus) { + --shadow: 0 1px rgb(0 0 0 / 10%); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3C/svg%3E"); +} + +@media (min-width: 40rem) and (max-width: 80rem) { + .grid:not(.plain) { + grid-template-areas: + "head head" + "side main"; + grid-template-columns: minmax(min-content, 12rem) 1fr; + } +} + +@supports (display: grid) and (not (display: inline-grid)) { + .x::selection { + color: white !important; + } +} + +@layer base, components; + +@layer components { + .btn:active:not(:disabled) { + transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1); + } +} + +@keyframes spin { + from { + rotate: 0deg; + } + + to { + rotate: 360deg; + } +} + +@font-face { + font-family: "X:Y"; + src: url(fonts/x.woff2) format("woff2"); +} + +.escaped\:colon, +.a\/b, +#id\.with\.dots { + padding: calc(1rem + var(--x, 2px) * 2); +} diff --git a/test/fixtures/pathological_selectors.css.license b/test/fixtures/pathological_selectors.css.license new file mode 100644 index 0000000..afd70dd --- /dev/null +++ b/test/fixtures/pathological_selectors.css.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2025 igniter_css contributors + +SPDX-License-Identifier: MIT diff --git a/test/transform_test.exs b/test/transform_test.exs index d91a3e0..f1ef0b2 100644 --- a/test/transform_test.exs +++ b/test/transform_test.exs @@ -9,6 +9,138 @@ defmodule IgniterCss.TransformTest do alias IgniterCss.Transform + @complex """ + /* header */ + @layer base, components; + + @import "./typography.css" layer(components); + + @theme { + --font-display: "Satoshi", sans-serif; + --ease-fluid: cubic-bezier(0.3, 0, 0, 1); + } + + a:hover, + a:focus-visible::before { + content: "a:b"; + } + + .card:not([data-open]) .body, + .card:not(:hover, :focus-within) > .footer { + display: none; + } + + li:nth-child(2n + 1):not(:last-child) { + background: url(data:image/svg+xml;base64,PHN2Zy8+) no-repeat; + } + + .grid { + grid-template-areas: + "head head" + "side main"; + grid-template-columns: minmax(12rem, 1fr) 3fr; + } + + @media (min-width: 40rem) and (max-width: 80rem) { + .x::selection { + color: white !important; + } + } + + @supports (display: grid) and (not (display: inline-grid)) { + .y { + --shadow: 0 1px 2px rgb(0 0 0 / 0.05); + } + } + + .typography { + h1 { + @apply text-2xl; + } + } + """ + + describe "beautify/2 on complex CSS" do + test "keeps every pseudo-class and pseudo-element attached to its selector" do + assert {:ok, out} = Transform.beautify(@complex) + + for selector <- [ + "a:hover", + "a:focus-visible::before", + ".card:not([data-open])", + ":not(:hover, :focus-within)", + "li:nth-child(2n + 1):not(:last-child)", + ".x::selection" + ] do + assert String.contains?(out, selector), "lost #{selector}" + end + + refute out =~ ~r/:\s+(not|hover|nth-child|selection|focus-visible)\b/ + end + + test "keeps the separators a value needs" do + assert {:ok, out} = Transform.beautify(@complex) + + assert out =~ "url(data:image/svg+xml;base64,PHN2Zy8+) no-repeat" + assert out =~ "minmax(12rem, 1fr) 3fr" + assert out =~ ~s|"head head" "side main"| + assert out =~ "rgb(0 0 0 / 0.05)" + assert out =~ "cubic-bezier(0.3, 0, 0, 1)" + end + + test "keeps Tailwind's own at-rules intact" do + assert {:ok, out} = Transform.beautify(@complex) + + assert {:ok, [theme]} = IgniterCss.get_at_rules(out, "theme") + assert length(theme.declarations) == 2 + assert out =~ "@layer base, components;" + assert out =~ ~s|@import "./typography.css" layer(components);| + assert out =~ "@apply text-2xl" + end + + test "preserves every selector and declaration" do + assert {:ok, out} = Transform.beautify(@complex) + assert {:ok, before} = IgniterCss.list_selectors(@complex) + assert {:ok, after_} = IgniterCss.list_selectors(out) + + squeeze = fn list -> + list |> Enum.map(&(&1 |> String.split() |> Enum.join(" "))) |> Enum.sort() + end + + assert squeeze.(before) == squeeze.(after_) + + collapse = fn {:ok, ds} -> + Enum.map(ds, fn {k, v} -> {k, v |> String.split() |> Enum.join(" ")} end) + end + + assert collapse.(IgniterCss.get_rule_declarations(@complex, ".grid")) == + collapse.(IgniterCss.get_rule_declarations(out, ".grid")) + end + + test "is idempotent and still parses" do + assert {:ok, once} = Transform.beautify(@complex) + assert {:ok, twice} = Transform.beautify(once) + assert once == twice + assert {:ok, _} = IgniterCss.validate(once) + end + + test "keeps every comment" do + assert {:ok, out} = Transform.beautify(@complex) + assert out =~ "/* header */" + end + + test "survives a minify then beautify round trip" do + assert {:ok, small} = Transform.minify(@complex) + assert {:ok, pretty} = Transform.beautify(small) + + assert {:ok, before} = IgniterCss.list_selectors(@complex) + assert {:ok, after_} = IgniterCss.list_selectors(pretty) + assert length(before) == length(after_) + assert pretty =~ ".typography" + assert pretty =~ "@apply text-2xl" + end + end + describe "minify/2" do test "keeps a space the grammar needs" do assert {:ok, "@media screen and (min-width:40em){.a{margin:1px -2px}}"} = From ac38821afd3ace6ff1fce2a92eb41c87af9c29cb Mon Sep 17 00:00:00 2001 From: Shahryar Tavakkoli Date: Sun, 6 Sep 2026 20:26:03 +0200 Subject: [PATCH 7/7] chore(deps): update every dependency to its latest compatible version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mint` carried two advisories, one HIGH: unbounded HTTP/1 status-line and chunk-extension buffering (CVE-2026-82728) and quadratic chunk-size parsing (CVE-2026-82729), both memory or CPU exhaustion. 1.10.0 clears them, and `mix deps.audit` and `mix hex.audit` are both clean again. Elixir: mint 1.9.3 -> 1.10.0, req 0.7.2 -> 0.7.4, spitfire 0.3.13 -> 0.4.1, dialyxir 1.4.7 -> 1.4.8, ex_doc 0.40.3 -> 0.40.4. Rust: eight transitive crates moved within semver. The four direct ones are already at the newest published version — biome_css_parser, biome_css_syntax and biome_rowan at 0.5.8, rustler at 0.38.0 — so the `=` pins stand as they are, for the reason the manifest gives. 402 cargo tests, 42 doctests and 300 Elixir tests still pass, with fmt, clippy and credo clean. Co-Authored-By: Claude Opus 5 --- mix.lock | 10 +++++----- native/igniter_css/Cargo.lock | 34 +++++++++++++++++----------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/mix.lock b/mix.lock index 0b380ca..51a9952 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,12 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "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"}, + "dialyxir": {:hex, :dialyxir, "1.4.8", "7ef671a8aff9948b091d8c30f09467fbb16e77305cda451bce48109a0f5e021c", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "cbd5a851571e5dfeb32aaf2e840bfa98b7864cb3071bf2ef5d95d1276b12e072"}, "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"}, + "ex_doc": {:hex, :ex_doc, "0.40.4", "66f2e42bf588594d5a8aab31cad87f2ddad09d0da1b1a2f379340ec2c2e497cb", [:mix], [{:earmark_parser, "~> 1.4.46", [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", "6222b9e423d76584ee34df2c82a5ed72c2d53dc153f7f483ad28b378694186cc"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "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"}, @@ -17,19 +17,19 @@ "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"}, + "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [: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", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, "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"}, "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"}, + "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [: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", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "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.15.0", "b067d7f8522a9d758fa89cb2bfcbab7ad72c45a0993cb958c989c6fd956fdd56", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24a800e2d7fa8c3bd21561b6ad8ad4745ed726a09fd606598981d9048708da98"}, "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, - "spitfire": {:hex, :spitfire, "0.3.13", "edd207b065eaec57acc5484097d0aa3e97fe4246168c54e67a9af040b8dee4c1", [:mix], [], "hexpm", "3601be88ceed4967b584e96444de3e1d12d6555ae0864a7390b9cd5332d134b4"}, + "spitfire": {:hex, :spitfire, "0.4.1", "69e90335d00ca328295e1e1e77cac5d7575aa6d34274e3467ebfc654b8858be3", [:mix], [], "hexpm", "27d86f67681179682b15c6758d64ac2eb2b3637ed8340800c8b885c69754cdcd"}, "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"}, diff --git a/native/igniter_css/Cargo.lock b/native/igniter_css/Cargo.lock index 301e98e..a71b1f0 100644 --- a/native/igniter_css/Cargo.lock +++ b/native/igniter_css/Cargo.lock @@ -252,9 +252,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -275,9 +275,9 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "dashmap" @@ -419,9 +419,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -698,9 +698,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" [[package]] name = "regex-lite" @@ -848,7 +848,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -905,9 +905,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "syn" @@ -933,9 +933,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -1107,18 +1107,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote",