Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,38 @@ to lose a whole rule over one bad piece. Both are gone.

### Canonical diff

- `--diff=canonical` drops an `@supports` block whose guard is false however a
browser answers it, reading a parenthesised `<general-enclosed>` term as the
false CSS Conditional 3 sec. 6.1 makes it: Tailwind's `@supports
(@media(width>=1px): var(--tw))` against nothing compares equal, under
`--enforce-spec` too, since no reading answers the guard yes. `--minify`
keeps the guard, and `Supports.never_holds` is the reading (#1270)
- `cascade diff` no longer exits 2 over a declaration its reader refused: the
reader is held to the browser's accept set, so a browser drops that
declaration from whichever file holds it and renders the same, and the
verdict is the one over what remains, the parse warning kept. Tailwind's
`filter: blur(<value>)` placeholder against nothing exits 0. A rule the
reader dropped still withholds the verdict, and `Css_compare.unread_separates`
is the library's answer (#1270)
- A `color-mix()` or a relative colour over a `light-dark()` argument is the
`light-dark()` of that operation over each branch, which CSS Color 5 sec. 6
picks by the element's colour scheme, so `color-mix(in oklab,
light-dark(red, #00f) 50%, #0000)` and `light-dark(#ff000080, #0000ff80)`
are one colour under `--diff=canonical` and `--minify` writes the second.
A mix a branch cannot fold stays as written, and `--lossless` keeps the mix
(#1270)
- An angle compares as the degrees it names under `--diff=canonical`, which
CSS Values 4 sec. 6.1 makes one dimension under `deg`, `grad`, `rad` and
`turn`: `.5turn` and `180deg` are one angle in `rotate`, in a `transform`
function and in an unregistered custom property's stream, and `1.5rad` is
the `85.9437deg` lightningcss writes, under the six-significant-figure
budget a quotient already takes; `--lossless` keeps the unit as written
(#1270)
- A colour function in a custom property's stream reads a missing axis as
the zero CSS Color 4 sec. 4.4 makes it, as a colour longhand already did, so
`--tw-mask-top-from-color:oklab(0% none none/.5)` and its `#00000080` twin
are one colour under `--diff=canonical`; a rule transitioning the custom
property keeps them apart, as it does for a longhand (#1270)
- `--diff=canonical` is several times faster on a large stylesheet, for
byte-identical output: tw's site sheet against Tailwind's compiled one
drops from 22s to 5.5s of CPU. Unwrapping a `@supports` guard every target
Expand Down
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,24 @@ the evergreen browsers `--minify` targets: a `@supports` guard every one of
them satisfies is unwrapped, a vendor prefix a target needs is written on both
sides and one no target needs is dropped from both, and a colour fallback every
target parses past is dead, so two sheets that disagree only on those compare
equal. `--enforce-spec` holds those rewrites off and keeps the two apart.
equal. `--enforce-spec` holds those rewrites off and keeps the two apart. An
`@supports` guard no reading answers yes, one over a `<general-enclosed>` term
such as `(@media(width>=1px): var(--tw))` that CSS Conditional 3 sec. 6.1 makes
false, selects nothing under either, and its block is dropped under both.

A difference cascade can see exits 1, whether or not it read everything. When it
finds no difference but could not read part of a file, it compares the text each
side dropped. Text that matches byte for byte cannot hide a difference, so
finds no difference but could not read a rule in a file, it compares the text
each side dropped. Text that matches byte for byte cannot hide a difference, so
exit 0 stands. Text that differs might hide one, so cascade exits 2 rather than
claim an equivalence it has not established. Adding or removing an unreadable
declaration exits 2 for that reason: one side dropped text the other did not. A
gate that treated 2 as 0 would pass while the files differ. The report and the
`--json` document count the unreadable declarations and rules on each side.
claim an equivalence it has not established: one side dropped a rule the other
did not, and a gate that treated 2 as 0 would pass while the files differ. A
declaration cascade could not read is another matter. Its reader is held to the
browser's accept set (`test/spec/browser/accept_set`), so a declaration it
refuses is one the browser drops from whichever file holds it, and the two
render the same; the declaration is dropped as the browser drops it, the parse
warning stays as information, and the verdict is the one over what remains.
The report and the `--json` document count the unreadable declarations and
rules on each side.

`--diff=MODE` controls what counts as "no difference":

Expand Down
46 changes: 12 additions & 34 deletions bin/cmd_diff.ml
Original file line number Diff line number Diff line change
Expand Up @@ -79,33 +79,10 @@ let dropped construct (w : Cascade.Error.t) =
| Dropped d -> Cascade.Error.Recovery.equal_construct d.construct construct
| Recovered -> false

(* What one side lost, in the order its reader reported it. *)
let losses ws =
List.filter_map
(fun (w : Cascade.Error.t) ->
match w.recovery with
| Cascade.Error.Recovery.Dropped { construct; text } ->
Some (construct, text)
| Recovered -> None)
ws

(* One loss accounts for another when the two readers threw away the same
construct spelled the same way. A loss the reader could not name accounts for
nothing: nothing shows the other side lost those same bytes. *)
let accounts_for (c1, t1) (c2, t2) =
Cascade.Error.Recovery.equal_construct c1 c2
&&
match (t1, t2) with
| Some a, Some b -> String.equal a b
| None, _ | _, None -> false

(* Two sides that lost the same run of text saw the same thing twice, so what
they hid cannot separate them and the comparison's verdict stands. *)
let losses_cancel (result : Cascade_diff.Css_compare.t) =
let expected = losses result.expected_warnings in
let actual = losses result.actual_warnings in
List.compare_lengths expected actual = 0
&& List.for_all2 accounts_for expected actual
(* The verdict is proven when nothing the readers dropped can separate the two
sides; {!Cascade_diff.Css_compare.unread_separates} owns that question. *)
let losses_cancel result =
not (Cascade_diff.Css_compare.unread_separates result)

(* Counted per side, because the question is whether either input hid something
from the comparison, not how many the pair hid between them. *)
Expand Down Expand Up @@ -521,9 +498,9 @@ let json_document ~file1 ~file2 ~mode ~css1 ~css2 ~unread result =
Cascade_diff.Css_compare.stats ~expected_str:css1 ~actual_str:css2 result
in
let outcome = result.Cascade_diff.Css_compare.result in
(* A declaration or a rule the reader refuses is dropped from both sides, so a
comparison that found no difference has not shown the two files to be
identical - unless the two sides lost the same text. *)
(* A rule the reader refuses is dropped from both sides, so a comparison that
found no difference has not shown the two files to be identical - unless
the two sides lost the same text. *)
let identical =
match outcome with
| No_diff -> losses_cancel result
Expand Down Expand Up @@ -845,10 +822,11 @@ let cmd =
Cmd.Exit.info
~doc:
"if the comparison found no difference and cascade could not read \
a declaration or a rule one of the files holds. The reader drops \
it from both sides, so the comparison never sees it and cannot \
call the two files identical. The report and the $(b,--json) \
document count declarations and rules per side. Under \
a rule one of the files holds. The reader drops it from both \
sides, so the comparison never sees it and cannot call the two \
files identical. A declaration it could not read is one a browser \
drops too, so it withholds nothing; the report and the \
$(b,--json) document count declarations and rules per side. Under \
$(b,--browser), if no browser or node was found, the driver \
failed, or the document gave nothing to sample"
Cli_exit.cannot_determine;
Expand Down
27 changes: 19 additions & 8 deletions lib/css.mli
Original file line number Diff line number Diff line change
Expand Up @@ -9822,17 +9822,28 @@ val canonicalize_rule_order :
channels all land on a whole byte is keyed as the [rgb()] spelling of the
same colour, which emission cannot do either because [color()] needs a
browser that parses it. A [none] channel of a Lab-family colour standing
as a whole colour-longhand value is keyed as the zero CSS Color 4 sec. 4.4
as a whole colour-longhand value, or as a colour function of a
custom-property token stream, is keyed as the zero CSS Color 4 sec. 4.4
says a missing component behaves as, so a converted achromatic [oklab()]
meets the hex a minifier writes for it; sec. 13.3 keeps that off the
positions the sheet interpolates, so a gradient stop, a [color-mix()]
operand, a custom-property token stream, [@keyframes], [@starting-style]
and a colour whose own rule transitions the property it writes keep their
[none], and [lossless] bounds how far the resolved colour respells. An
identical [-webkit-text-decoration-color] compatibility declaration is
dropped when its unprefixed twin is present; a differing or prefixed-only
declaration is retained. These are comparison-side normalisations; this
function does not change {!val-optimize}'s configured emission policy. *)
operand, [@keyframes], [@starting-style] and a colour whose own rule
transitions the property it writes, a custom property named by its own
name included, keep their [none], and [lossless] bounds how far the
resolved colour respells. An angle is keyed as the degrees it names, which
CSS Values 4 sec. 6.1 makes one dimension under [deg], [grad], [rad] and
[turn], in the rotate property, in a transform function and in an
unregistered custom property's stream, a converted unit rounded to the
six-significant-figure budget a quotient takes; [lossless] keeps the unit
as written. An [@supports] guard false however a user agent answers its
feature tests, reading a parenthesised [<general-enclosed>] term as the
false CSS Conditional 3 sec. 6.1 makes it ({!Supports.never_holds}), is
dropped with its block under either reading, since no reading answers it
yes. An identical [-webkit-text-decoration-color] compatibility
declaration is dropped when its unprefixed twin is present; a differing or
prefixed-only declaration is retained. These are comparison-side
normalisations; this function does not change {!val-optimize}'s configured
emission policy. *)

val optimize :
?scope:Optimize.scope ->
Expand Down
20 changes: 20 additions & 0 deletions lib/declaration.ml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,26 @@ let canonicalize_custom_time = function
})
| decl -> decl

(* Equivalence-only normalisation for structural diffing: an angle token in a
custom stream is the same angle under [deg], [rad], [turn] and [grad], and
emission keeps the author's unit. *)
let canonicalize_custom_angle = function
| Declaration
{
property = Custom_property _ as property;
value = Custom_value ({ value = Tokens components; _ } as cv);
important;
_;
} ->
v ~important property
(Custom_value
{
cv with
value =
Tokens (Properties.canonicalize_angle_components components);
})
| decl -> decl

(* Equivalence-only normalisation for structural diffing: drop the whitespace of
a custom-property stream that CSS reads as nothing. Cascade keeps that
whitespace verbatim on output, since the stream is opaque and the author's
Expand Down
9 changes: 9 additions & 0 deletions lib/declaration.mli
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ val canonicalize_custom_time : declaration -> declaration
tokens as written, so the caller keeps a property such a query names out of
this fold. *)

val canonicalize_custom_angle : declaration -> declaration
(** [canonicalize_custom_angle d] spells every [<angle>] token of a
custom-property token stream as the degrees it names, a converted unit
rounded to the six-significant-figure budget a computed value takes, which
CSS Values 4 sec. 6.1 makes one dimension. Equivalence-only normalisation
for structural diffing; any other declaration passes through unchanged. As
for {!canonicalize_custom_time}, the caller keeps a property a container
[style()] query names out of this fold. *)

val canonicalize_custom_whitespace : declaration -> declaration
(** [canonicalize_custom_whitespace d] drops from a custom-property token stream
the whitespace CSS reads as nothing: around the [*] and [/] of CSS Values 4
Expand Down
34 changes: 34 additions & 0 deletions lib/diff/css_compare.ml
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,40 @@ let pp_warnings ?(expected = "Expected") ?(actual = "Actual") ?max buf t =

let has_warnings t = t.expected_warnings <> [] || t.actual_warnings <> []

(* ===== What the parse dropped ===== *)

(* What one side lost that the comparison has to answer for, in the order its
reader reported it. A declaration the reader refuses is one the browser
refuses: test/spec/browser/accept_set holds the reader to the browser's
accept set, so the browser drops it from whichever sheet holds it and paints
the same, and it is not a loss the verdict answers for. The warning still
says where the reader may have lagged. A rule is: nothing holds the rule
reader to a browser, and the rule takes everything it held with it. *)
let losses ws =
List.filter_map
(fun (w : Error.t) ->
match w.recovery with
| Error.Recovery.Dropped { construct = Rule; text } -> Some text
| Dropped { construct = Declaration; _ } | Recovered -> None)
ws

(* One loss accounts for another when the two readers threw away the same text.
A loss the reader could not name accounts for nothing: nothing shows the
other side lost those same bytes. *)
let accounts_for t1 t2 =
match (t1, t2) with
| Some a, Some b -> String.equal a b
| None, _ | _, None -> false

(* Two sides that lost the same run of text saw the same thing twice, so what
they hid cannot separate them and the comparison's verdict stands. *)
let unread_separates t =
let expected = losses t.expected_warnings in
let actual = losses t.actual_warnings in
not
(List.compare_lengths expected actual = 0
&& List.for_all2 accounts_for expected actual)

let pp_diff ?(expected = "Expected") ?(actual = "Actual") ?(color = false)
?depth ?entries buf t =
pp_result ~expected ~actual ~color ?depth ?entries buf t.result
Expand Down
11 changes: 11 additions & 0 deletions lib/diff/css_compare.mli
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ val has_warnings : t -> bool
(** [has_warnings result] is [true] when either side accumulated a parse
warning. *)

val unread_separates : t -> bool
(** [unread_separates result] is [true] when a rule one side's reader dropped
may hold a difference the comparison never saw. A dropped rule reaches
neither side of the comparison, and one loss accounts for another only when
both readers dropped the same text; a loss the reader could not name
accounts for nothing. A declaration the reader refused separates nothing:
the reader is held to the browser's accept set, so a browser drops that
declaration from whichever input holds it and renders the same, and its
warning stays as information. A caller that found no difference reads this
before calling the two inputs identical. *)

(** {1:stats Statistics} *)

type stats = {
Expand Down
24 changes: 13 additions & 11 deletions lib/prop_transform.ml
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,9 @@ and read_transforms t : transform list =
then Cursor.err_invalid t "transform none cannot be combined"
else transforms

let normalize_rotate : rotate_value -> rotate_value =
let na = Values.normalize_angle in
let normalize_rotate ?(ctx = Values.default_calc_ctx) :
rotate_value -> rotate_value =
let na = Values.normalize_angle ~ctx in
fun value ->
match value with
| Angle a -> preserve_if_equal value (Angle (na a))
Expand Down Expand Up @@ -435,14 +436,14 @@ let canonicalise_transform : transform -> transform = function
angle and number-percentage operands still fold through their own printers.
Running this before [canonicalise_transform] lets the zero-checks see a folded
[calc()]. *)
let normalize_transform_leaves : transform -> transform =
let normalize_transform_leaves ~ctx : transform -> transform =
(* The translate / perspective operands are inside a function, so they keep a
zero's unit ([translate(0px)] stays). The rotate / skew operands are
angles, converted to the shortest unit. The scale operands are
[<number-percentage>]: pick the shorter spelling so pp does not have to
fold the [Pct]/[Num] node distinction. *)
angles, converted to the shortest unit, or to degrees under the budget. The
scale operands are [<number-percentage>]: pick the shorter spelling so pp
does not have to fold the [Pct]/[Num] node distinction. *)
let nl = Values.normalize_length ~strip:false in
let na = Values.normalize_angle in
let na = Values.normalize_angle ~ctx in
let np = Values.normalize_number_percentage in
function
| Translate (x, y) -> Translate (nl x, Option.map nl y)
Expand All @@ -468,13 +469,14 @@ let normalize_transform_leaves : transform -> transform =
| Scale_3d (x, y, z) -> Scale_3d (np x, np y, np z)
| other -> other

let rec normalize_transform (t : transform) : transform =
let rec normalize_transform ?(ctx = Values.default_calc_ctx) (t : transform) :
transform =
match t with
| List ts -> List (List.map normalize_transform ts)
| List ts -> List (List.map (normalize_transform ~ctx) ts)
| _ ->
let t = normalize_transform_leaves t in
let t = normalize_transform_leaves ~ctx t in
let t' = canonicalise_transform t in
if t' == t then t else normalize_transform t'
if t' == t then t else normalize_transform ~ctx t'

let rec pp_transform : transform Pp.t =
fun ctx t ->
Expand Down
Loading
Loading