Skip to content

Increate the CSS spec coverage#5

Open
samoht wants to merge 690 commits into
mainfrom
spec-parser
Open

Increate the CSS spec coverage#5
samoht wants to merge 690 commits into
mainfrom
spec-parser

Conversation

@samoht
Copy link
Copy Markdown
Owner

@samoht samoht commented May 4, 2026

No description provided.

samoht added 29 commits May 6, 2026 23:51
CSS Masking 1 §3.6 [clip-path = none | <clip-source> | [<basic-shape>
|| <geometry-box>] | <geometry-box>] adds gaps cascade did not cover:

- [<geometry-box>]-only values (e.g. [clip-path: margin-box]) and the
  shape+box juxtaposition in either order ([padding-box circle(...)]
  / [circle(...) padding-box]) - add [Clip_path_box] /
  [Clip_path_with_box].
- CSS Shapes 1 §3 [<shape-radius>] for [circle()] / [ellipse()] is a
  [<length-percentage>] or one of [closest-side] / [farthest-side],
  and an optional [at <position>] tail. Replace [Clip_path_circle of
  length] / [Clip_path_ellipse of length * length] with records
  carrying [clip_path_extent] options plus a [position] field.
- CSS Shapes 1 §3.6 [polygon()] takes an optional leading
  [<fill-rule>] before the points. Add [clip_path_fill_rule] and
  collapse the two [Clip_path_polygon{,_spaced}] arms into a single
  record with a [spaced] flag.

Walk the new shapes in [vars_of_clip_path] and update the printer to
emit the new arms.
CSS Shapes 1 §3.6 [polygon()] tweaks under minify:

- [nonzero] is the [<fill-rule>] default, so [polygon(nonzero,
  ...)] minifies to [polygon(...)] without changing semantics.
- [<percentage>]-suffixed coordinates ([50% 0%]) self-delimit, so
  the space between [50%] and the next length can drop, matching
  csso's [50%0] form.

Also accept the bare [<geometry-box>] value at the end of the
declaration ([clip-path: margin-box;]) by treating [;] as a
value-end delimiter alongside true [is_done].
Five interop fixtures use math or basic-shape forms whose typed reader
refuses the input but lightningcss / esbuild / csso preserve verbatim:

- [rotate: asin(45deg)] - [asin()] takes [<number>], not [<angle>]
- [rotate: acos(45deg)] / [rotate: atan(45deg)] - same shape
- [width: asin(sin(45deg))] - [width] takes [<length>], not [<angle>]
- [clip-path: ellipse(50px 60px at 0 10% 20%)] - 3-value [<position>]

Cascade's typed grammar is intentionally stricter than what these tools
accept, but authoring round-trip wins here: extend [allows_opaque_fallback]
to include the inverse-trig and basic-shape function names so the
declaration is preserved as raw text when the typed reader fails. A
future spec-based optimizer step is what eliminates these.
The lightning interop bench and runner mixed [Printf.sprintf /
Fmt.epr / Printf.eprintf] etc.; consolidate on [Fmt] (already a
transitive dependency) so the test target uses one formatting library
and the dune file declares it explicitly.
[Css.Parser.according_to_grammar] was renamed to [matches_grammar] in
the public parser API; update the two test call sites that still used
the old name.
…_feature]

The public [Properties] API renamed the [font_variant_east_asian_feature]
reader and printer to drop the redundant [font_variant] prefix; track
the rename in [check_font_variant_east_asian_feature].
Add a [Test_inline] module exercising the [Css.inline_vars] entry
point: substitution of theme [var(--name)] references, keeping the
[var()] when the [--name] is undefined, and preserving the value when
the user explicitly opts out via [Css.inline_vars] options. Wire the
new suite into the [test] runner.
Cascade had drifted between [foo_from_bar] (English-style) and
[foo_of_bar] (the standard OCaml convention) for "build a [foo] out of
a [bar]" helpers. Standardise on [_of_] for the four affected entry
points:

- [Css.rules_of_statements] (was [rules_from_statements])
- [Css.custom_props_of_rules] (was [custom_props_from_rules])
- [Parser.declaration_of_buffer] (was [declaration_from_buffer])
- [Supports.declaration_of_components] (was [declaration_from_components])
Drop the redundant [_from_] suffix in
[exclude_selector_changes_from_modified] - the function filters
[other_modified] entries, not "from" the [sel_changes] argument.
Fold long descriptive names into shorter, equivalent forms across the
fuzz and test runners:

- [test_eval_full_context_computes_claimed_observables] ->
  [test_full_context_observables]
- [test_invalid_css_wide_keyword_mixes] -> [test_invalid_css_wide]
- [test_shared_invalid_css_wide_inventory] -> [test_css_wide_inventory]
- [test_custom_property_token_stream_vectors] -> [test_custom_tokens]
- and similar across the other [fuzz_*] / [test_*] modules.

Also break the [generated_stylesheet] generator in [fuzz_optimize] into
named statement helpers ([generated_import], [generated_namespace],
...) so individual at-rules are easier to share.
[assert_document_fields] / [assert_query_fields] / [assert_branch_inventory]
all destructure records whose constructor name collides with another
type's field. Make the parameter type explicit so OCaml's
disambiguation picks the right field set.
The stylesheet fuzz suite had a 60+ line inline test-case list. Split
it into [parser_cases] / [roundtrip_cases] / [invariant_cases] / etc.
named bindings so individual clusters are easy to skip when iterating
on a single area.
[fuzz_values] had several inline lists of CSS literals nested inside
test bodies. Lift them out as named bindings ([valid_length_vectors],
[valid_color_vectors], [valid_number_vectors], ...) so the corpus is
easier to scan and reuse.
Track the [@namespace] / [@import] minify-spacing change committed in
[5ec2e8b] and the surrounding pretty-printer cleanups: when the URI is
a quoted string the leading [\"] already delimits the at-keyword from
the URI, so the inter-token whitespace can be elided. Update the four
[@import] / [@namespace] oracles in [inline_imports_*] to match the
new shorter output.
The optimizer now collapses two consecutive [.x { color: ... }] rules
into the last-wins survivor when both target the same selector with no
intervening at-rule frame: [.x{color:red}] is shadowed by
[.x{color:#00f}] in source order, so under [--minify --inline-imports]
the dead rule is dropped. Update the oracle to match.
CSS Custom Properties L1 §2 allows commas inside a [var()] fallback,
but if the resulting token stream is invalid for the destination
property the declaration is invalid at computed-value time and the
minifier drops it. Two oracles caught up to the new behaviour:

- [.b { color: var(--undef, red, blue) }] - the [red, blue] sequence
  isn't a valid [<color>], so [.b] disappears entirely.
- [.a { color: var(--undef,) }] - empty fallback - the wrapper-removed
  value is invalid CSS for [<color>] so the rule produces no output;
  the surrounding pipeline now wraps [grep -v "warning"] in [|| true]
  so the empty result doesn't break the pipeline exit code.
[print_output] always emits a trailing newline when the body is
non-empty, so an output that is just a single [\n] (the result of
inlining everything away from a stylesheet that originally held only
custom-property scaffolding) printed as a blank line. Treat the
single-newline payload as empty so the CLI prints nothing in that
case.
…handling

[Inline.vars] previously walked declarations through [Context.eval]
which substitutes typed values one var at a time. That path was OK
for simple [color: var(--brand)] forms but missed three classes of
input the cli oracle exercises:

- Multi-comma fallback lists ([font-family: var(--font, "A", B)]
  substitute to a token list).
- Cycle reachability: when [--a -> var(--b) -> var(--a)] both
  customs are dead - reachable only from a cycle - so they should
  drop with the consuming declaration falling back.
- Empty / invalid fallbacks ([color: var(--undef,)] and
  [color: var(--undef, red, blue)] are invalid for [<color>] and the
  declaration drops under minify).

Add a component-level [substitute_components] / [substitute_var] pass
that walks the [Component.t list] tree, tracks visited names for
cycle detection, and returns either resolved [Components] or [Cycle]
when both the visited path and the fallback are exhausted.
[declaration_with_components] reprints the substituted value through
the typed reader, falling back to opaque preservation for shapes
([font-family] with strings, comma-bearing values) that don't survive
typed reparse but are still valid CSS. Detect dead cycles with
[cyclic_live_customs] so the dead-code pass actually drops them after
substitution.

Cover the new behaviour with a [Test_inline] suite that exercises
nested fallbacks, missing-var preservation, calc fallback canonical-
isation, multi-comma fallback substitution, empty fallback drops, and
cycle fallback paths of length 1, 2, and 3.
[simplify_var_record] previously discarded the active visited-name
list when recursing into a [var()]'s [default] / [fallback], so a
cycle that passes through a var-typed default could re-enter the same
name and either loop or silently substitute the wrong value.

Pass [visited] through and reuse it when simplifying the fallback /
default sub-values; both callers ([Var_residual] and [Calc_residual])
now hand it down explicitly.
CSS Fonts 4 §11.2.1 says [@font-face] is invalid without both
[font-family] and [src], but the descriptors that ARE present are
still well-formed CSS. Upstream tooling (lightningcss / esbuild /
csso) preserves the partial rule verbatim, and cascade's "preserve
for authoring fidelity, eliminate when doing spec-based optimization"
philosophy applies here too.

Drop the hard-reject in [read_font_face]; a later optimizer pass is
free to reduce / drop incomplete [@font-face] rules.
…[local("")]

Two follow-up gaps in the [@font-face] / [<font-family>] readers:

- [.foo { font-family: inherit test; }] mixes a CSS-wide keyword with
  a custom-ident; cascade rejected the whole declaration, but
  lightningcss / esbuild / csso preserve the source verbatim so
  minifier output round-trips. Drop the [css-wide-mix] check for
  [font-family] / [font] (both are [<custom-ident>#] lists where the
  CSS-wide keyword was already invalid as a multi-token value, so
  preserving the invalid declaration is the most authoring-friendly
  choice).
- [@font-face { src: local("") url(...) }] is technically invalid
  (empty [local()]), but again upstream tools preserve. Drop the
  empty-arg rejection in [Font_face.read_function_arg].
Replace the declaration-level opaque-fallback path for [asin] / [acos]
/ [atan] / [atan2] with a typed [Opaque of Component.t list] arm in
the [angle] AST. The trig readers now snapshot the function call before
attempting the typed reduction; on failure they restore the cursor and
emit [Angle.Opaque [<original-call>]] instead of raising.

This keeps the parser typed end-to-end (no raw-text fallback at the
declaration boundary) and gives a future spec-based [Optimize.drop_invalid]
pass a single arm to look for. The pretty-printer just emits the
captured tokens verbatim, which round-trips byte-for-byte.

The trig contains-check in [allows_opaque_fallback] is no longer
needed and removed; basic-shape preservation still uses that path
until the equivalent typed arms land for [<basic-shape>] /
[<position>].
…apes

Replace the substring-matching opaque-fallback path
([raw_value_has_preservable_function] testing for [\"circle(\"] etc.)
with a typed [Invalid of Component.t list] arm in the [clip_path] AST.
The basic-shape readers ([circle] / [ellipse] / [polygon]) snapshot
the function call before attempting their typed reduction; on failure
they restore the cursor and return [Clip_path.Invalid [<original-call>]]
instead of raising.

This drops the substring search entirely - cascade now matches on
[Component.Func _] structurally - and gives the future
[Optimize.drop_invalid] pass a single arm to look for. The pretty-printer
emits the captured tokens verbatim.
The trig readers in [Values] and the basic-shape readers in [Properties]
duplicated the same five-line pattern: snapshot the cursor, peek the
[Func] component, run the typed reader inside [try], and on [Parse_error]
restore + skip + return the original call so the caller can wrap it in
their type's [Invalid] arm. Lift it to
[Cursor.try_typed_call : (t -> 'a) -> t -> ('a, Component.t) result]
and have all four call sites ([read_angle_trig], [read_angle_atan2],
[with_basic_shape_fallback], and the new [read_length_percentage] path)
go through it.

Add the [Invalid of Component.t list] arm to [<length-percentage>] so
[width: asin(sin(45deg))] - inverse trig produces an angle which is
type-incompatible with [<length-percentage>] - parses as
[Length_percentage.Invalid] instead of routing through the
declaration-level opaque fallback. The [length_invalid_function_name]
helper enumerates the trig functions that produce non-length results.
CSS Color 5 §13 [<hue-interpolation-method>] grammar admits a
[specified] keyword alongside [shorter] / [longer] / [increasing] /
[decreasing]. Cascade was missing the variant, and the [color-mix]
fallback path in [Declaration] used a substring scan for [\"specified
hue\"] to route the call through opaque preservation.

Add [Specified] to the [hue_interpolation] enum, register it in both
hue-interpolation readers, and drop the substring scan: with a typed
arm, [color-mix] no longer needs the special-case fallback so its
entry in [color_fallback_function]'s allow-list is sufficient. Also
drop the now-orphan [contains_substring] helper.
Implement the minify-time validation step from the
"preserve-then-eliminate" architecture: declarations whose typed value
contains an [Invalid] arm (set by the typed readers when CSS Color 5,
Shapes 1, or Values 5 detect a spec-violation cascade still parsed for
authoring fidelity) are dropped under [Optimize.stylesheet] - and so
under [Css.to_string ~minify:true ~optimize:true].

- [Properties.is_invalid_value : 'a property -> 'a -> bool] dispatches
  on the typed property and walks the value-type-specific [Invalid]
  arms ([angle], [length_percentage], [clip_path], [text_indent_value]).
- [Declaration.is_invalid : declaration -> bool] hands [(property,
  value)] off to [is_invalid_value], threading through
  [Theme_guarded].
- [Optimize.drop_invalid] walks every declaration container in the
  stylesheet and filters declarations failing the predicate.

[Optimize.stylesheet] now ends with [drop_invalid] so the existing
[--optimize] / minify-with-optimize CLI flag picks up the new
behaviour.
…rd mix + verbatim rendering

CSS Cascade 5 §7.3: a CSS-wide keyword ([inherit] / [initial] / [unset]
/ [revert] / [revert-layer]) must stand alone; mixed inside a
[<custom-ident>#] list it makes the whole declaration invalid
([font-family: Arial, inherit]). Cascade was happily accepting the
mixed list and emitting it verbatim under [--minify].

Add an [Invalid of Component.t list] arm to the [font_family] type;
[read_font_family] detects a list with a CSS-wide member and rebuilds
the original token slice as the [Invalid] payload. [is_invalid_value]
covers the new arm so [Optimize.drop_invalid] removes the declaration
under minify.

Also fix the printer for the existing [Invalid] arms in [<angle>],
[<length-percentage>], and [<clip-path>]: they were rendering the
captured components through [Component.pp], which emits the
debug-format [<ident name>@[start-end]] suffix. Use [Parser.to_string]
(or [Parser.to_string_minified] under [--minify]) so [Invalid] tokens
round-trip as their original source text.
[Optimize.drop_invalid] removes declarations whose typed value carries
an [Invalid] arm. Until now it was wired only through [Optimize.stylesheet]
([--optimize]); plain [--minify] kept the spec-violating declarations.

Run the same pass plus [drop_empty_rules] in the [--minify] path of
[Css.to_string] so spec-based eliminations apply at the lighter pass
too. Reorder [Optimize.stylesheet] so [drop_invalid] runs before
[statements_top_level], which lets [drop_empty_rules] (already part of
that pipeline) remove the empty rules left behind. Expose
[drop_empty_rules] in [Optimize]'s mli for the [--minify] caller.
samoht added 30 commits May 14, 2026 09:01
[Color_percentage] in [pp_gradient_stop] used [Pp.space_if_pretty]
unconditionally, which produced [red0%] / [#00f50%] under minify.
That tokenizes wrong: CSS Syntax 3 §4 absorbs the trailing digits
into the ident ([red0]) or hash ([#00f50]) token, so the position
never starts.

Render the colour to a sub-buffer, peek the last byte, and elide
the separator only when the colour ends with [)] (function-shaped:
[var(...)] / [rgb(...)] / etc.). Ident- and hash-shaped colours
keep the mandatory space; function-shaped colours stay compact.

Drive-by rename in test/interop/lightning of
[conic_gradient_has_bare_from_zero] (5 underscores, blocked merlint)
to [conic_gradient_bare_zero_angle] (4).
Add [conic_gradient_bare_zero_angle] regex check that recognises
the bare-zero angle in [conic-gradient(from 0, ...)] / [from 0 at]
and marks it as an upstream minifier optimization the lightning
harness should treat as a cached oracle bug. CSS Values 4 §6.5
restricts the unitless-zero shortcut to [<length>] - angle zeros
keep [deg] (or another angle unit) outside [calc()].
… minify

Counter symbols are quoted strings; the [""] delimiters terminate
each token unambiguously, so the inter-symbol whitespace is purely
cosmetic. Switch [Pp.list ~sep:Pp.space] to
[Pp.list ~sep:Pp.space_if_pretty] so [symbols: "(" ")"] minifies
to [symbols:"("")"].
When a rule's declarations carry all four sides of [margin-*] (or all
four of [padding-*]) with matching importance and no [var()] / [env()]
/ [attr()] leaves, fold them into the [margin] / [padding] shorthand.
The pretty-printer's existing 1-to-4-value collapse then picks the
shortest spelling. Existing [merge_box_shorthand_longhands] runs after
this pass and handles absorption when an explicit shorthand was
already present.

Also drop empty [@page { }] in [drop_empty_rules]: the [Page_with_margins]
arm matches [(_, [], [])] alongside the existing empty-block at-rules.

Keithamus interop: 185 -> 181 failures (margin / padding composition
plus the empty-page drop). Lightning interop: 16 -> 5 failures (the
same composition is what most lightning tests expected). All other
suites green.
CSS Syntax 3 sec. 8.3 treats [@charset] as a byte-pattern
encoding-declaration that the parser consumes before tokenization, not
a stylesheet at-rule whose count is meant to round-trip. The cascade
emits UTF-8 unconditionally, so [@charset "UTF-8"] is purely
redundant; per the spec only the first non-UTF-8 declaration is
honored, so subsequent ones can also be dropped.

[Optimize.normalize_charset] runs in [statements_top_level]: drop
every [@charset "UTF-8"] (case-insensitive), keep the first
non-UTF-8 declaration, drop later ones.

The fuzz [boundary_shapes] and [atrule_counts_stable] invariants
previously treated [Charset _] as a structural at-rule whose presence
must round-trip; that's the right invariant for layers / media /
keyframes but wrong for charset. Drop [Charset _] from the boundary
shape and the at-rule-count check list so the invariants reflect the
spec.

Keithamus interop: 181 -> 180 failures (charset/0001 now passes).
All other suites unchanged. Fuzz green.
[Min_width_rem] / [Min_width_px] always emitted the legacy
[(min-width: NNN)] spelling. Per Cascade's README minify policy
(media queries: legacy -> range), the shorter [(width>=NNN)] form
applies to [@container] queries too. Branch on [minify] in
[to_string_with]: minified output emits [(width>=...)]; pretty
mode keeps [min-width:] for source-shape preservation.
CSS Grid Layout 2 §7.3
(https://drafts.csswg.org/css-grid/#named-areas) defines a null cell
token as one or more sequential periods, all denoting the same single
empty cell. Padding spellings like [....] (used to align row strings
in source) collapse to the canonical single [.] under minify.
…aren

[Animation.ends_with_paren] tracks whether the timing-function token
ends with [)] so the shorthand printer can elide the inter-token
space ([cubic-bezier(...)X] -> [cubic-bezier(...)X], no space). It
already handled [steps(1, jump-start|jump-end)] which fold to
[step-start]/[step-end] keywords; the [start] / [end] aliases of the
same shorthand do too but were misclassified as paren-shaped.

Result: [animation: fade steps(1, start)] minified to
[animation:step-startfade] (one ident token, broken). Now emits
[animation:step-start fade] as expected.
Two more 2-/4-longhand composition passes alongside margin / padding:

- [row-gap] + [column-gap] -> [gap], emitting the typed
  [Gap (Lengths { row_gap; column_gap })] value so the printer's
  collapse-to-single-value rule fires when both lengths match.
- 4 [border-*-radius] corners -> [border-radius], reusing the [box_side]
  4-tuple convention and the typed [Radius { horizontal; vertical = None }]
  value so [length] corners lift to [length_percentage].

Both passes are conservative: contiguous occurrences in the next 2 / 4
declarations, matching importance, no [var()] / [env()] / [attr()]
leaves on any side.

Keithamus interop: 180 -> 176 failures.
CSS Backgrounds 3 §3.6.1 makes the two-value [<repeat-style>{2}] forms
equivalent to shorter spellings under minify:

- [X X] -> [X] for [repeat] / [space] / [round] / [no-repeat]
- [repeat no-repeat] -> [repeat-x]
- [no-repeat repeat] -> [repeat-y]

Drive-by: drop the spurious [Cursor.expect_eof] from the value-side
[read_repeats] helper. The outer [validate_no_extra_tokens] already
gates the trailing-tokens check at the declaration boundary, so
calling [expect_eof] inside the value reader rejected
[background-repeat: no-repeat no-repeat;] just because of the [;]
that was about to be consumed by the declaration parser.
Two related changes:

- [cmd_fmt] no longer passes [~flatten_nesting:true] on [--minify].
  Modern browsers parse the CSS Nesting Module natively, the nested
  form is usually shorter than the flattened expansion, and the
  keithamus interop oracle treats the nested form as the canonical
  output. A new [--flatten-nesting] CLI flag opt-ins to the
  compatibility transform for old browsers; the library API
  [Css.optimize ~flatten_nesting] still controls the underlying pass.

- [Optimize.compose_outline_shorthand] folds three contiguous
  [outline-width / -style / -color] longhands into the [outline]
  shorthand. Matching importance, no [var()] / [env()] / [attr()] on
  the width side; the pretty-printer's existing shortest-form pick
  drops [medium] / [solid] / [currentcolor] default components.

Keithamus interop: 176 -> 172 failures.
CSS Images 4 §5.1 ties the cardinal [to <side>] keywords to fixed
angles ([to top]=0deg, [to right]=90deg, [to bottom]=180deg, [to
left]=270deg). The angle spelling is shorter for every cardinal,
so [pp_gradient_direction] picks the angle form under minify. The
diagonal [to <corner>] keywords are box-aspect-dependent and have
no fixed angle, so they stay as keywords.

[pp_linear_gradient_named] also recognises [Angle (Deg 180.)] (not
just [To_bottom]) as the default linear-gradient direction now and
drops it entirely, including under [With_interpolation].
CSS Overflow 3 §3.1: when both axes of the [overflow] shorthand match,
the single-value spelling is canonical. The longhand-merger already
emits the single-value form when [overflow-x] = [overflow-y]; this
extends the printer so an explicit [overflow: hidden hidden] in
source also collapses.
CSS Backgrounds 3 §4.4: [<border-width>] defaults to [medium]. When
the user wrote it explicitly and another slot ([border-style] or
[border-color]) is non-default, the keyword is redundant. Drop it
under minify so [border: medium solid red] -> [border: solid red].

Standalone [border: medium] keeps the keyword - dropping every slot
would leave [border:] which is a parse error.
CSS Align 3 §6.1: the [place-*] shorthands accept either a single
value applied to both axes or two values for align/justify
respectively; when both axes render to the same token the
single-value spelling is canonical. Render align and justify into
sub-buffers, compare, and emit one value when they match.

Also folds [Stretch_stretch] -> [stretch] under minify (the AST
constructor preserved the explicit two-value form for source-shape
fidelity).
Same shape as the Color 4 cross-tier fallback: when a same-property
declaration is followed by one whose typed value contains a [var()]
/ [env()] / [attr()] / anchor query on a length (possibly through a
[calc()]), the earlier declaration is a cascade fallback for
browsers that can't resolve the substitution and must survive
deduplication.

[Declaration.value_uses_runtime_subst] dispatches on the property
GADT for the length / length-list properties most commonly used in
this pattern: margin / padding / inset, the four corner radii, the
margin and padding sides, and [outline-width]. Length-percentage
properties ([width] et al.) and compound shorthands are not yet
covered.

Used by [Optimize.legacy_runtime_subst_fallback] next to
[legacy_vendor_fallback] and [legacy_color_fallback]; only fires when
the later value introduces the substitution.

Keithamus interop: 172 -> 167 failures (duplicates / 0005 and the
related length-fallback patterns).
Match the [place-items] collapse for [place-content] (sub-buffer
render + compare) and drop the [Stretch, Stretch -> true] branch
in the [place-self] case so the existing equal-pair shortcut
applies uniformly.
CSS Position 3 §3.1: [inset] is the [top right bottom left]
shorthand. Add [extract_inset_side] (1-element [length list] payload)
and a [build_inset] case to [compose_box_shorthands]. Reuses the
existing [try_compose_box] framework so the runtime-substitution
guard, importance match, and four-distinct-side checks all apply.
…longhands

Extend [compose_pair_shorthands] to fold the matching [-start] /
[-end] longhands into the logical-property shorthand:

- [margin-inline-start] + [margin-inline-end] -> [margin-inline]
- [margin-block-start] + [margin-block-end] -> [margin-block]
- [padding-inline-start] + [padding-inline-end] -> [padding-inline]
- [padding-block-start] + [padding-block-end] -> [padding-block]
- [inset-inline-start] + [inset-inline-end] -> [inset-inline]
- [inset-block-start] + [inset-block-end] -> [inset-block]

Add [try_compose_axis_pair] to keep the per-property duplication
minimal; it shares the importance / runtime-subst / distinct-side
guards with the existing gap composer. Equal sides emit the
single-value form; divergent sides emit [v_start v_end].

Drive-by:

- replace the nested [match ... | Some _ -> r | None -> ...] ladders
  in [compose_box_shorthands] and [compose_pair_shorthands] with a
  list of composer closures + [List.find_map]; same control flow,
  fewer braces.
- rename [FLineHeight] -> [FLine_height] (snake_case per the
  constructor convention).
Public-API simplification:

- [~optimize] is gone. [Css.to_string ~minify:true] now runs the
  full optimizer pipeline (declaration dedup, longhand -> shorthand
  merge, rule combine, ...). [~minify:false] does only the spec-
  recovery drops (invalid declarations, unknown at-rules, empty
  rules) the same as before. The orthogonal "optimise without
  minify" combo had no real use case and surprised callers who set
  [~minify:true] but forgot [~optimize:true] (e.g. the keithamus
  interop harness).

- [~newline] is gone. Output never ends with a trailing newline now;
  callers who want one append it themselves. The CLI's
  [print_output] already does this, so [bin/cmd_fmt] is unaffected.

- [?indent : int] joins [Css.to_string] / [Stylesheet.to_string] /
  [Pp.to_string]. Defaults: [None] under [minify] (no per-level
  indent), [Some 2] otherwise. Pass [Some n] to set a per-level
  width or [None] to suppress indentation in pretty mode.

- [Pp.ctx]'s [indent : int] (which actually meant "current nesting
  level") is renamed [level : int] and a new [indent : int option]
  field carries the per-level width.

- [Stylesheet.config] / [pp_config] / [read_config] are gone. CSS
  rendering config is not a CSS construct; the round-trip serialiser
  only existed to satisfy a generic test harness, and the four-field
  record shrunk to just [{minify; mode}] after [optimize] / [newline]
  left. Inline the two args directly through [pp_decl_inline] /
  [pp_important], and drop the round-trip test (test_stylesheet
  [config] case + fuzz [read_config crash safety] case).

- The arbitrary "prepend [header] only when [@layer] statements exist"
  branch in [Stylesheet.to_string] is gone with [?header]; callers
  wanting a header concatenate it themselves.

- Bump [(lang dune)] to [3.21] and add [(env (dev (flags :standard
  %{dune-warnings})))] so standalone opam builds fail on warnings.
… longhands

Add three composers that fold contiguous [align-*] + [justify-*]
longhand pairs (in either order, matching importance) into the
corresponding [place-*] shorthand:

- [align-items] + [justify-items] -> [place-items]
- [align-content] + [justify-content] -> [place-content]
- [align-self] + [justify-self] -> [place-self]

The per-property printers already collapse equal-axis [Align_justify]
pairs to a single value (CSS Align 3 §6.1), so the composed
shorthand prints in canonical form.
The [Css.to_string] refactor stopped appending a trailing newline to
the output, so the [c62_origin_wrapper_api] pin needs the same
update applied across the test suite by the previous commit.
CSS Lists 3 sec. 1.2: [list-style] shorthand combines
[list-style-position], [list-style-image], and [list-style-type] in
any order. Cascade stores [List_style] as a string; the composition
renders each longhand via its pretty-printer and drops the
default-valued components ([outside] / [none] / [disc]). If all
three drop, leave the [outside] keyword so the shorthand is not
emitted with an empty value.

Conservative: requires all three longhands contiguous with matching
importance.

Keithamus shorthands/0028.
CSS Media Queries 4 §3: a [<media-type>] of [all] without an explicit
[not] / [only] prefix is the default; [@media all and X] is
equivalent to [@media X]. Match [Type_query { prefix = None;
type_ = All; trailing = Some cond }] in [Media.pp] and emit just
the trailing condition under minify.

Drive-by: rename [LSType] / [LSPosition] / [LSImage] -> snake_case
to match the constructor convention.
[pp_var_without_fallback] / [pp_stylesheet_var]'s [Fallback] branch
both gated theme-default resolution on [is_theme_var v] (true only
for vars declared with [layer = Some "theme"]). That treated the
theme set as an allowlist for theme-layer vars instead of a
denylist for protection. The cleaner rule, matching the user-
facing contract:

- no resolver / no default: never inline (keep [var(--name)])
- resolver returns [Some value]: inline only if the variable is
  not protected by the theme set
- resolver returns [None]: keep [var(--name)] (or the supplied
  fallback) and preserve syntax
- theme set: protection / denylist, not an allowlist for non-theme
  vars

Drop the [is_theme_var] gate (and the unused helper). The print-time
flow is now: in theme set -> keep var; otherwise resolver, then typed
default, then bare [var(--name)] / [var(--name, fallback)].

Drive-by: rename [td_kind] constructors [TDLine] / [TDStyle] /
[TDColor] -> [Line] / [Style] / [Color] (the prefix was dead weight;
type-directed disambiguation handles the collision with the
[Color] / [Style] constructors elsewhere).
The fallback branch of [pp_stylesheet_var] inlined to the supplied
fallback value when the resolver said [None]. Per the user-facing
rule (resolver [None] keeps [var(...)] and preserves fallback
syntax) the fallback is the runtime branch, not a print-time
substitute. Switch the [None] case to re-emit
[var(--name, fallback)] so [.x{color:var(--undef, red)}] minifies
back to [.x{color:var(--undef,red)}] instead of [.x{color:red}].

Also drop the unused [pp_value] parameter warning in
[pp_var_without_fallback] (now always uses [pp_var_ref]).
…ult test

[Css.Length] is a constructor in several types ([kind],
[property_value_kind], [grid_template]); type-directed
disambiguation can't pick the right one in the [Css.var ... Css.Length
(Css.Px _)] form. Annotate the [Css.Zero] default and tag the
[Length] constructor with the [Css.length Css.kind] type so the
declaration builds.
The helpers moved out of [Optimize] into [Stylesheet] are AST -> AST
rewrites applied just before serialization, not pretty-printer code.
The [minified_output_*] prefix advertised "output strings"; rename to
plain [normalise_*]:

- [minified_output_charset]               -> [normalise_charset]
- [minified_output_shadow_color_var_slots] -> [normalise_shadows]
- [minified_output_shadow_color_var_declaration] -> [rewrite_shadow_decl]
- [minified_output_shadow_color_vars]     -> [rewrite_shadow_value]
- [minified_output_canonical_statements]  -> [normalise]

The [pp_] prefix is reserved for actual pretty-printer functions
(those returning [unit] into a [Pp.ctx]). The drive-by also adds a
comment on [color_var_of_length_var] explaining why [default] is
dropped (the var is consumed in colour-slot position; the inline
length default doesn't apply there).
Move out of [Optimize]:

- [color_custom_property_names] (walks for [--*: <color>] declarations)
- [color_fallback_of_length_fallback] / [color_var_of_length_var]
  (typed fallback/var coercion used by the shadow rewrite)
- [normalize_shadow_color_var_*] (rewrites [box-shadow: var(--ring)]
  into [0 0 0 var(--ring)] so the var sits in the colour slot)
- [normalize_charset] (drops redundant [@charset "UTF-8"] and trailing
  duplicates)

These are AST -> AST transforms applied just before serialization, not
cascade-correctness optimizations. They live in [Stylesheet] now under
[normalise_*]; the [Optimize] entry point still calls them via the
[Stylesheet.normalise] umbrella.

Drive-by: extend [shorthand_longhands] with [inset] and the logical-
property pairs ([margin-/padding-/inset-inline/block]) plus the
border family ([border], [border-{width,style,color}], [border-
{top,right,bottom,left}]) so the cascade-merge bookkeeping covers
the new composers.

Drop the explicit [Some Solid -> None] and [Some Current -> None]
defaulting from [try_compose_text_decoration]; the typed
[pp_text_decoration_shorthand] already drops those defaults at print
time.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant