lib/css/src/property/expand.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 
  3 const table = @import("table.zig");
  4 const token = @import("../token/root.zig");
  5 const value = @import("../value/root.zig");
  6 
  7 const Id = table.Id;
  8 const Value = value.Value;
  9 
 10 /// The shorthands this engine expands. A shorthand never reaches a computed
 11 /// style: the sheet lowers it into its longhands at parse time.
 12 pub const Shorthand = enum(u8) {
 13     margin,
 14     padding,
 15     border_width,
 16     border_style,
 17     border_color,
 18     inset,
 19     scroll_margin,
 20     scroll_padding,
 21     border_radius,
 22     gap,
 23     overflow,
 24     background_position,
 25     flex,
 26     flex_flow,
 27     border,
 28     border_top,
 29     border_right,
 30     border_bottom,
 31     border_left,
 32     outline,
 33     background,
 34     text_decoration,
 35 };
 36 
 37 /// One longhand the expansion produced.
 38 pub const Expansion = struct {
 39     id: Id,
 40     value: Value,
 41 };
 42 
 43 /// The most longhands one shorthand produces.
 44 pub const max_expansions: usize = 4;
 45 
 46 /// The most whitespace separated components one shorthand value may carry.
 47 pub const max_components: usize = 4;
 48 
 49 /// Resolves a shorthand name, ASCII case insensitively.
 50 pub fn shorthandOf(name: []const u8) ?Shorthand {
 51     inline for (shorthand_names) |entry| {
 52         if (std.ascii.eqlIgnoreCase(name, entry.name)) return entry.kind;
 53     }
 54     return null;
 55 }
 56 
 57 /// Expands `source[start..end]` under `kind` into `out`, returning the number
 58 /// of longhands written. Zero means the value did not parse and the caller
 59 /// records a diagnostic instead.
 60 pub fn expand(
 61     kind: Shorthand,
 62     source: []const u8,
 63     start: u32,
 64     end: u32,
 65     out: *[max_expansions]Expansion,
 66 ) usize {
 67     var spans: [max_components][2]u32 = undefined;
 68     const components = componentSpans(source, start, end, &spans);
 69     if (components == 0 or components > max_components) return 0;
 70     const targets = longhands(kind);
 71     if (wideKeyword(source, spans[0], components)) |word| {
 72         for (targets, 0..) |id, index| out[index] = .{ .id = id, .value = Value.keyword(word) };
 73         return targets.len;
 74     }
 75     return switch (kind) {
 76         .margin, .padding, .border_width, .border_style, .border_color => box(targets, source, spans[0..components], out),
 77         .inset, .scroll_margin, .scroll_padding => box(targets, source, spans[0..components], out),
 78         .border_radius => corner(targets, source, spans[0..components], out),
 79         .gap, .overflow, .background_position => pair(targets, source, spans[0..components], out),
 80         .flex => flex(source, spans[0..components], out),
 81         .flex_flow => components_any(targets, source, spans[0..components], out),
 82         .border, .border_top, .border_right, .border_bottom, .border_left => components_any(targets, source, spans[0..components], out),
 83         .outline, .background, .text_decoration => components_any(targets, source, spans[0..components], out),
 84     };
 85 }
 86 
 87 /// The longhands a shorthand writes, in the order the expansion fills them.
 88 pub fn longhands(kind: Shorthand) []const Id {
 89     return switch (kind) {
 90         .margin => &.{ .margin_top, .margin_right, .margin_bottom, .margin_left },
 91         .padding => &.{ .padding_top, .padding_right, .padding_bottom, .padding_left },
 92         .border_width => &.{
 93             .border_top_width, .border_right_width, .border_bottom_width, .border_left_width,
 94         },
 95         .border_style => &.{
 96             .border_top_style, .border_right_style, .border_bottom_style, .border_left_style,
 97         },
 98         .border_color => &.{
 99             .border_top_color, .border_right_color, .border_bottom_color, .border_left_color,
100         },
101         .inset => &.{ .top, .right, .bottom, .left },
102         .scroll_margin => &.{
103             .scroll_margin_top,    .scroll_margin_right,
104             .scroll_margin_bottom, .scroll_margin_left,
105         },
106         .scroll_padding => &.{
107             .scroll_padding_top,    .scroll_padding_right,
108             .scroll_padding_bottom, .scroll_padding_left,
109         },
110         .border_radius => &.{
111             .border_top_left_radius,     .border_top_right_radius,
112             .border_bottom_right_radius, .border_bottom_left_radius,
113         },
114         .gap => &.{ .row_gap, .column_gap },
115         .overflow => &.{ .overflow_x, .overflow_y },
116         .background_position => &.{ .background_position_x, .background_position_y },
117         .flex => &.{ .flex_grow, .flex_shrink, .flex_basis },
118         .flex_flow => &.{ .flex_direction, .flex_wrap },
119         .border => &.{ .border_top_width, .border_top_style, .border_top_color },
120         .border_top => &.{ .border_top_width, .border_top_style, .border_top_color },
121         .border_right => &.{ .border_right_width, .border_right_style, .border_right_color },
122         .border_bottom => &.{ .border_bottom_width, .border_bottom_style, .border_bottom_color },
123         .border_left => &.{ .border_left_width, .border_left_style, .border_left_color },
124         .outline => &.{ .outline_width, .outline_style, .outline_color },
125         .background => &.{ .background_color, .background_image },
126         .text_decoration => &.{
127             .text_decoration_line, .text_decoration_color, .text_decoration_style,
128         },
129     };
130 }
131 
132 const NamedShorthand = struct {
133     name: []const u8,
134     kind: Shorthand,
135 };
136 
137 const shorthand_names = [_]NamedShorthand{
138     .{ .name = "margin", .kind = .margin },
139     .{ .name = "padding", .kind = .padding },
140     .{ .name = "border-width", .kind = .border_width },
141     .{ .name = "border-style", .kind = .border_style },
142     .{ .name = "border-color", .kind = .border_color },
143     .{ .name = "inset", .kind = .inset },
144     .{ .name = "scroll-margin", .kind = .scroll_margin },
145     .{ .name = "scroll-padding", .kind = .scroll_padding },
146     .{ .name = "border-radius", .kind = .border_radius },
147     .{ .name = "gap", .kind = .gap },
148     .{ .name = "overflow", .kind = .overflow },
149     .{ .name = "background-position", .kind = .background_position },
150     .{ .name = "flex", .kind = .flex },
151     .{ .name = "flex-flow", .kind = .flex_flow },
152     .{ .name = "border", .kind = .border },
153     .{ .name = "border-top", .kind = .border_top },
154     .{ .name = "border-right", .kind = .border_right },
155     .{ .name = "border-bottom", .kind = .border_bottom },
156     .{ .name = "border-left", .kind = .border_left },
157     .{ .name = "outline", .kind = .outline },
158     .{ .name = "background", .kind = .background },
159     .{ .name = "text-decoration", .kind = .text_decoration },
160 };
161 
162 fn wideKeyword(source: []const u8, span: [2]u32, components: usize) ?value.Keyword {
163     if (components != 1) return null;
164     const word = value.Keyword.parse(source[span[0]..span[1]]) orelse return null;
165     for (value.wide) |candidate| {
166         if (candidate == word) return word;
167     }
168     return null;
169 }
170 
171 fn box(
172     targets: []const Id,
173     source: []const u8,
174     spans: []const [2]u32,
175     out: *[max_expansions]Expansion,
176 ) usize {
177     std.debug.assert(targets.len == 4);
178     var parsed: [max_components]Value = undefined;
179     for (spans, 0..) |span, index| {
180         parsed[index] = read(targets[0], source, span);
181         if (!parsed[index].present()) return 0;
182     }
183     const order = sides(spans.len);
184     for (targets, 0..) |id, index| out[index] = .{ .id = id, .value = parsed[order[index]] };
185     return targets.len;
186 }
187 
188 fn corner(
189     targets: []const Id,
190     source: []const u8,
191     spans: []const [2]u32,
192     out: *[max_expansions]Expansion,
193 ) usize {
194     std.debug.assert(targets.len == 4);
195     var parsed: [max_components]Value = undefined;
196     for (spans, 0..) |span, index| {
197         parsed[index] = read(targets[0], source, span);
198         if (!parsed[index].present()) return 0;
199     }
200     const order: [4]usize = switch (spans.len) {
201         1 => .{ 0, 0, 0, 0 },
202         2 => .{ 0, 1, 0, 1 },
203         3 => .{ 0, 1, 2, 1 },
204         else => .{ 0, 1, 2, 3 },
205     };
206     for (targets, 0..) |id, index| out[index] = .{ .id = id, .value = parsed[order[index]] };
207     return targets.len;
208 }
209 
210 fn pair(
211     targets: []const Id,
212     source: []const u8,
213     spans: []const [2]u32,
214     out: *[max_expansions]Expansion,
215 ) usize {
216     std.debug.assert(targets.len == 2);
217     if (spans.len > 2) return 0;
218     const first = read(targets[0], source, spans[0]);
219     if (!first.present()) return 0;
220     const second = if (spans.len == 2) read(targets[1], source, spans[1]) else first;
221     if (!second.present()) return 0;
222     out[0] = .{ .id = targets[0], .value = first };
223     out[1] = .{ .id = targets[1], .value = second };
224     return 2;
225 }
226 
227 fn flex(source: []const u8, spans: []const [2]u32, out: *[max_expansions]Expansion) usize {
228     if (spans.len > 3) return 0;
229     var grow = Value.number(1);
230     var shrink = Value.number(1);
231     var basis = Value.length(0, .px);
232     var numbers: usize = 0;
233     var bases: usize = 0;
234     for (spans) |span| {
235         const number = read(.flex_grow, source, span);
236         if (number.present() and numbers < 2) {
237             if (numbers == 0) grow = number else shrink = number;
238             numbers += 1;
239             continue;
240         }
241         const candidate = read(.flex_basis, source, span);
242         if (!candidate.present() or bases == 1) return 0;
243         basis = candidate;
244         bases = 1;
245     }
246     if (numbers == 0 and bases == 0) return 0;
247     if (numbers == 0) grow = Value.number(1);
248     out[0] = .{ .id = .flex_grow, .value = grow };
249     out[1] = .{ .id = .flex_shrink, .value = shrink };
250     out[2] = .{ .id = .flex_basis, .value = basis };
251     return 3;
252 }
253 
254 fn components_any(
255     targets: []const Id,
256     source: []const u8,
257     spans: []const [2]u32,
258     out: *[max_expansions]Expansion,
259 ) usize {
260     if (spans.len > targets.len) return 0;
261     var filled: [max_expansions]bool = @splat(false);
262     for (targets, 0..) |id, index| {
263         out[index] = .{ .id = id, .value = table.metadata(id).initial };
264     }
265     for (spans) |span| {
266         var placed = false;
267         for (targets, 0..) |id, index| {
268             if (filled[index]) continue;
269             const parsed = read(id, source, span);
270             if (!parsed.present()) continue;
271             out[index] = .{ .id = id, .value = parsed };
272             filled[index] = true;
273             placed = true;
274             break;
275         }
276         if (!placed) return 0;
277     }
278     return targets.len;
279 }
280 
281 fn read(id: Id, source: []const u8, span: [2]u32) Value {
282     return value.parse(table.metadata(id).grammar, source, span[0], span[1]);
283 }
284 
285 fn sides(count: usize) [4]usize {
286     return switch (count) {
287         1 => .{ 0, 0, 0, 0 },
288         2 => .{ 0, 1, 0, 1 },
289         3 => .{ 0, 1, 2, 1 },
290         else => .{ 0, 1, 2, 3 },
291     };
292 }
293 
294 /// Splits a value into top level whitespace separated components. Returns a
295 /// count above `max_components` when the value carries more than fit.
296 pub fn componentSpans(
297     source: []const u8,
298     start: u32,
299     end: u32,
300     out: *[max_components][2]u32,
301 ) usize {
302     std.debug.assert(start <= end);
303     std.debug.assert(end <= source.len);
304     var tokenizer = token.Tokenizer{ .source = source[0..end], .index = start };
305     var count: usize = 0;
306     var open: ?u32 = null;
307     var last: u32 = start;
308     var depth: u32 = 0;
309     var guard: usize = 0;
310     while (guard <= source.len + 1) : (guard += 1) {
311         const next = tokenizer.next();
312         if (next.kind == .eof) break;
313         switch (next.kind) {
314             .function, .left_paren => depth += 1,
315             .right_paren => depth -|= 1,
316             else => {},
317         }
318         if (next.kind == .whitespace and depth == 0) {
319             if (open) |begin| {
320                 if (count < max_components) out[count] = .{ begin, last };
321                 count += 1;
322                 open = null;
323             }
324             continue;
325         }
326         if (open == null) open = next.start;
327         last = next.end;
328     }
329     if (open) |begin| {
330         if (count < max_components) out[count] = .{ begin, last };
331         count += 1;
332     }
333     return count;
334 }
335 
336 fn run(kind: Shorthand, source: []const u8, out: *[max_expansions]Expansion) usize {
337     return expand(kind, source, 0, @intCast(source.len), out);
338 }
339 
340 test "a box shorthand fills four sides from one, two, three, and four values" {
341     var out: [max_expansions]Expansion = undefined;
342     try std.testing.expectEqual(@as(usize, 4), run(.margin, "1px", &out));
343     for (out) |item| try std.testing.expectEqual(@as(f32, 1), item.value.asNumber());
344     _ = run(.margin, "1px 2px", &out);
345     try std.testing.expectEqual(@as(f32, 2), out[1].value.asNumber());
346     try std.testing.expectEqual(@as(f32, 1), out[2].value.asNumber());
347     _ = run(.padding, "1px 2px 3px", &out);
348     try std.testing.expectEqual(Id.padding_bottom, out[2].id);
349     try std.testing.expectEqual(@as(f32, 3), out[2].value.asNumber());
350     try std.testing.expectEqual(@as(f32, 2), out[3].value.asNumber());
351     _ = run(.inset, "1px 2px 3px 4px", &out);
352     try std.testing.expectEqual(@as(f32, 4), out[3].value.asNumber());
353 }
354 
355 test "border radius spreads corners rather than sides" {
356     var out: [max_expansions]Expansion = undefined;
357     try std.testing.expectEqual(@as(usize, 4), run(.border_radius, "1px 2px 3px", &out));
358     try std.testing.expectEqual(Id.border_top_left_radius, out[0].id);
359     try std.testing.expectEqual(Id.border_bottom_right_radius, out[2].id);
360     try std.testing.expectEqual(@as(f32, 3), out[2].value.asNumber());
361     try std.testing.expectEqual(@as(f32, 2), out[3].value.asNumber());
362 }
363 
364 test "a pair shorthand copies its single value into both longhands" {
365     var out: [max_expansions]Expansion = undefined;
366     try std.testing.expectEqual(@as(usize, 2), run(.gap, "8px", &out));
367     try std.testing.expectEqual(Id.row_gap, out[0].id);
368     try std.testing.expectEqual(Id.column_gap, out[1].id);
369     try std.testing.expectEqual(@as(f32, 8), out[1].value.asNumber());
370     _ = run(.overflow, "hidden auto", &out);
371     try std.testing.expectEqual(value.Keyword.hidden, out[0].value.asKeyword());
372     try std.testing.expectEqual(value.Keyword.auto, out[1].value.asKeyword());
373     try std.testing.expectEqual(@as(usize, 0), run(.gap, "1px 2px 3px", &out));
374 }
375 
376 test "border takes its three components in any order" {
377     var out: [max_expansions]Expansion = undefined;
378     try std.testing.expectEqual(@as(usize, 3), run(.border, "1px solid red", &out));
379     try std.testing.expectEqual(@as(f32, 1), out[0].value.asNumber());
380     try std.testing.expectEqual(value.Keyword.solid, out[1].value.asKeyword());
381     try std.testing.expectEqual(value.color.pack(255, 0, 0, 255), out[2].value.a);
382     _ = run(.border_left, "red solid", &out);
383     try std.testing.expectEqual(Id.border_left_width, out[0].id);
384     try std.testing.expectEqual(@as(f32, 3), out[0].value.asNumber());
385     try std.testing.expectEqual(@as(usize, 0), run(.border, "1px solid red extra", &out));
386 }
387 
388 test "flex places numbers before a basis and defaults the rest" {
389     var out: [max_expansions]Expansion = undefined;
390     try std.testing.expectEqual(@as(usize, 3), run(.flex, "2", &out));
391     try std.testing.expectEqual(@as(f32, 2), out[0].value.asNumber());
392     try std.testing.expectEqual(@as(f32, 1), out[1].value.asNumber());
393     try std.testing.expectEqual(@as(f32, 0), out[2].value.asNumber());
394     _ = run(.flex, "2 3 40px", &out);
395     try std.testing.expectEqual(@as(f32, 3), out[1].value.asNumber());
396     try std.testing.expectEqual(@as(f32, 40), out[2].value.asNumber());
397     _ = run(.flex, "auto", &out);
398     try std.testing.expectEqual(value.Keyword.auto, out[2].value.asKeyword());
399     try std.testing.expectEqual(@as(f32, 1), out[0].value.asNumber());
400 }
401 
402 test "a css wide keyword on a shorthand reaches every longhand" {
403     var out: [max_expansions]Expansion = undefined;
404     try std.testing.expectEqual(@as(usize, 4), run(.margin, "inherit", &out));
405     for (out) |item| try std.testing.expectEqual(value.Keyword.inherit, item.value.asKeyword());
406     try std.testing.expectEqual(@as(usize, 3), run(.border, "unset", &out));
407     try std.testing.expectEqual(value.Keyword.unset, out[2].value.asKeyword());
408 }
409 
410 test "background takes a colour or an image and rejects both at once" {
411     var out: [max_expansions]Expansion = undefined;
412     try std.testing.expectEqual(@as(usize, 2), run(.background, "red", &out));
413     try std.testing.expectEqual(value.color.pack(255, 0, 0, 255), out[0].value.a);
414     try std.testing.expectEqual(value.Keyword.none, out[1].value.asKeyword());
415     _ = run(.background, "url(a.png)", &out);
416     try std.testing.expectEqual(value.Kind.string, out[1].value.valueKind());
417     try std.testing.expectEqual(@as(usize, 0), run(.background, "notacolour", &out));
418 }
419 
420 test "components split at top level whitespace and keep function bodies whole" {
421     var spans: [max_components][2]u32 = undefined;
422     const source = "rgb(1, 2, 3) solid 1px";
423     const count = componentSpans(source, 0, @intCast(source.len), &spans);
424     try std.testing.expectEqual(@as(usize, 3), count);
425     try std.testing.expectEqualStrings("rgb(1, 2, 3)", source[spans[0][0]..spans[0][1]]);
426     try std.testing.expectEqualStrings("solid", source[spans[1][0]..spans[1][1]]);
427     const crowded = "1px 2px 3px 4px 5px";
428     try std.testing.expectEqual(@as(usize, 5), componentSpans(crowded, 0, 19, &spans));
429 }
430 
431 test "every shorthand name resolves and an unknown one does not" {
432     for (shorthand_names) |entry| {
433         try std.testing.expectEqual(entry.kind, shorthandOf(entry.name).?);
434     }
435     try std.testing.expectEqual(Shorthand.margin, shorthandOf("MARGIN").?);
436     try std.testing.expect(shorthandOf("margin-top") == null);
437     try std.testing.expect(shorthandOf("font") == null);
438 }