From ab7cae49dfed4f5c9e679c9cfdfabb2d95200542 Mon Sep 17 00:00:00 2001 From: Thomas Gazagnaire Date: Sun, 13 Sep 2026 21:24:41 +0200 Subject: [PATCH 1/3] test: pin the modifier boundary Tailwind's extractor draws A candidate whose /modifier opens on - or _ is no candidate, and the whole candidate is refused rather than truncated. Written as an assertion it fails on the parent commit, where tw scanned one Tailwind does not. --- test/test_source_scan.ml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/test_source_scan.ml b/test/test_source_scan.ml index 46115abfe..00f6d1e3e 100644 --- a/test/test_source_scan.ml +++ b/test/test_source_scan.ml @@ -82,8 +82,44 @@ const f = fn(arg)|} Alcotest.(check bool) "a call is not a candidate" false (List.mem "fn(arg)" found) +(* Tailwind's extractor refuses a candidate whose [/modifier] opens on [-] or + [_], and refuses the whole candidate rather than truncating it: measured + 2026-09-13, the CLI emits nothing at all for [bg-red-500/-2], not the + truncated [bg-red-500]. tw's scanner read one where Tailwind reads none, so a + sheet tw built carried rules the author's own toolchain never writes. Only a + [/] the utility itself carries counts; one inside a bracket or a paren group + belongs to the value. *) +let test_scan_modifier_boundary () = + let source = + {|x|} + in + let found = Tw_tools.Source_scan.candidates source in + let refused cls = + Alcotest.(check bool) + (cls ^ " is not a candidate") + false (List.mem cls found) + in + let scanned cls = + Alcotest.(check bool) (cls ^ " is a candidate") true (List.mem cls found) + in + refused "group-hover/-2a:underline"; + refused "group-hover/_x:underline"; + refused "bg-red-500/-2"; + (* A modifier that opens on anything else is a candidate, and so is every [/] + the value carries. *) + scanned "group-hover/edit:underline"; + scanned "bg-red-500/50"; + scanned "aspect-[16/9]"; + scanned "bg-[url(a/b.png)]"; + scanned "bg-cyan-400/(--alpha)"; + scanned "w-1/2" + let tests = [ + test_case "a modifier opening on - or _ is no candidate" `Quick + test_scan_modifier_boundary; test_case "unbalanced bracket stops at the newline" `Quick test_unbalanced_bracket_stops_at_newline; test_case "split whitespace" `Quick test_split_whitespace; From abd4c79dfd2ba43935a73abd5401e35e8a8eb275 Mon Sep 17 00:00:00 2001 From: Thomas Gazagnaire Date: Sun, 13 Sep 2026 21:24:41 +0200 Subject: [PATCH 2/3] scan: refuse a candidate whose modifier opens on - or _ Tailwind's extractor refuses it, and refuses the whole candidate rather than truncating: the CLI emits nothing for bg-red-500/-2, not the truncated bg-red-500. tw read one where Tailwind reads none, so a sheet tw built carried rules the author's own toolchain never writes. Only a / the utility itself carries counts, so one inside a bracket or a paren group is the value's: aspect-[16/9], bg-[url(a/b.png)] and bg-cyan-400/(--alpha) are unaffected. --- lib/tools/source_scan.ml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/tools/source_scan.ml b/lib/tools/source_scan.ml index feba4b84b..50e802c68 100644 --- a/lib/tools/source_scan.ml +++ b/lib/tools/source_scan.ml @@ -147,6 +147,28 @@ let read_candidate d start = in loop start 0 0 None false +(* Tailwind's extractor refuses a candidate whose [/modifier] opens on [-] or + [_], and refuses the whole candidate rather than truncating it: the CLI emits + nothing at all for [bg-red-500/-2]. Only a [/] the utility itself carries + counts, so one inside a bracket or a paren group - [aspect-[16/9]], + [bg-[url(a/_b)]] - is the value's, not a modifier's. *) +let modifier_opens_badly candidate = + let len = String.length candidate in + let rec loop i bracket paren = + if i >= len then false + else + match candidate.[i] with + | '[' -> loop (i + 1) (bracket + 1) paren + | ']' when bracket > 0 -> loop (i + 1) (bracket - 1) paren + | '(' -> loop (i + 1) bracket (paren + 1) + | ')' when paren > 0 -> loop (i + 1) bracket (paren - 1) + | '/' when bracket = 0 && paren = 0 -> + (i + 1 < len && (candidate.[i + 1] = '-' || candidate.[i + 1] = '_')) + || loop (i + 1) bracket paren + | _ -> loop (i + 1) bracket paren + in + loop 0 0 0 + let candidates source = let d = decoded_utf_8 source in let len = Array.length d.chars in @@ -161,7 +183,10 @@ let candidates source = String.sub source byte_start (byte_stop - byte_start) |> trim_candidate in - let acc = if candidate = "" then acc else candidate :: acc in + let acc = + if candidate = "" || modifier_opens_badly candidate then acc + else candidate :: acc + in loop stop acc else loop (i + 1) acc else loop (i + 1) acc From 1c42b18cb5dcf7b26791bf0dae10a69119a73656 Mon Sep 17 00:00:00 2001 From: Thomas Gazagnaire Date: Sun, 13 Sep 2026 21:24:55 +0200 Subject: [PATCH 3/3] changes: reference the scanner modifier boundary --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index d1fe53b2c..5d41a217a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -196,6 +196,10 @@ which used to drop the rule and leave an element carrying the class with nothing at all. `bg-size-[foo]` no longer writes `background-size: auto`, a value the class never asked for (#761 through #771). +- The source scanner draws the boundary Tailwind's extractor does: a candidate + whose `/modifier` opens on `-` or `_` is not one, so `tw file.html` no longer + writes rules for `group-hover/-2a:underline` that the author's own toolchain + never produces (#772). - A negated arbitrary length is `calc( * -1)` on every family and in every unit, the spelling Tailwind writes. The sign was folded into the number from a unit table that each of margin, the inset sides and `text-indent` kept