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 }