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 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 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;