lib/css/src/value/parse.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2
3 const color = @import("color.zig");
4 const scalar = @import("scalar.zig");
5 const token = @import("../token/root.zig");
6
7 const Grammar = scalar.Grammar;
8 const Keyword = scalar.Keyword;
9 const Unit = scalar.Unit;
10 const Value = scalar.Value;
11
12 /// Which shapes a grammar admits, plus the keywords it names. One generic
13 /// reader consumes this so a property table entry stays a single enum tag.
14 pub const Accept = struct {
15 length: bool = false,
16 percent: bool = false,
17 number: bool = false,
18 integer: bool = false,
19 color: bool = false,
20 string: bool = false,
21 url: bool = false,
22 raw: bool = false,
23 keywords: []const Keyword = &.{},
24 };
25
26 /// The four keywords every property accepts.
27 pub const wide = [_]Keyword{ .inherit, .initial, .unset, .revert };
28
29 /// Reads `source[start..end]` under `grammar`. String offsets are absolute, so
30 /// a computed string value indexes the same source the sheet borrows.
31 /// Returns an invalid value when the text is not a complete match.
32 pub fn parse(grammar: Grammar, source: []const u8, start: u32, end: u32) Value {
33 std.debug.assert(start <= end);
34 std.debug.assert(end <= source.len);
35 const rules = accepted(grammar);
36 var tokenizer = token.Tokenizer{ .source = source[0..end], .index = start };
37 const first = nextMeaningful(&tokenizer);
38 if (first.kind == .eof) return .{};
39 if (first.kind == .ident) {
40 if (Keyword.parse(first.value(source))) |word| {
41 if (contains(&wide, word)) return sealed(&tokenizer, Value.keyword(word));
42 }
43 }
44 if (rules.raw) {
45 const body = std.mem.trim(u8, source[start..end], " \t\r\n\x0C");
46 if (body.len == 0) return .{};
47 const offset = @intFromPtr(body.ptr) - @intFromPtr(source.ptr);
48 return Value.string(@intCast(offset), @intCast(body.len));
49 }
50 const value = single(rules, &tokenizer, first, source);
51 if (!value.present()) return .{};
52 return sealed(&tokenizer, value);
53 }
54
55 /// Whether `source[start..end]` carries a `var()` reference, which defers the
56 /// value to computed value time.
57 pub fn hasVariable(source: []const u8, start: u32, end: u32) bool {
58 std.debug.assert(start <= end);
59 std.debug.assert(end <= source.len);
60 var tokenizer = token.Tokenizer{ .source = source[0..end], .index = start };
61 var guard: usize = 0;
62 while (guard <= source.len + 1) : (guard += 1) {
63 const next = tokenizer.next();
64 if (next.kind == .eof) return false;
65 if (next.kind != .function) continue;
66 if (std.ascii.eqlIgnoreCase(next.value(source), "var")) return true;
67 }
68 return false;
69 }
70
71 /// The admitted shapes of one grammar.
72 pub fn accepted(grammar: Grammar) Accept {
73 return switch (grammar) {
74 .length => .{ .length = true },
75 .length_percentage => .{ .length = true, .percent = true },
76 .length_percentage_auto => .{ .length = true, .percent = true, .keywords = &.{.auto} },
77 .size => .{ .length = true, .percent = true, .keywords = &.{
78 .auto, .none, .min_content,
79 .max_content, .fit_content,
80 } },
81 .number => .{ .number = true },
82 .integer => .{ .integer = true },
83 .opacity => .{ .number = true, .percent = true },
84 .color => .{ .color = true },
85 .string => .{ .string = true },
86 .url => .{ .url = true, .keywords = &.{.none} },
87 .display => .{ .keywords = &.{
88 .block, .@"inline", .inline_block, .flex,
89 .inline_flex, .none, .contents,
90 } },
91 .position => .{ .keywords = &.{ .static, .relative, .absolute, .fixed, .sticky } },
92 .overflow => .{ .keywords = &.{ .visible, .hidden, .scroll, .auto, .clip } },
93 .visibility => .{ .keywords = &.{ .visible, .hidden, .collapse } },
94 .box_sizing => .{ .keywords = &.{ .content_box, .border_box } },
95 .flex_direction => .{ .keywords = &.{ .row, .row_reverse, .column, .column_reverse } },
96 .flex_wrap => .{ .keywords = &.{ .nowrap, .wrap, .wrap_reverse } },
97 .justify => .{ .keywords = &.{
98 .flex_start, .flex_end, .center, .space_between, .space_around,
99 .space_evenly, .start, .end, .stretch, .normal,
100 .left, .right,
101 } },
102 .@"align" => .{ .keywords = &.{
103 .flex_start, .flex_end, .center, .baseline,
104 .stretch, .start, .end, .normal,
105 } },
106 .align_self => .{ .keywords = &.{
107 .auto, .flex_start, .flex_end, .center,
108 .baseline, .stretch, .start, .end,
109 } },
110 .text_align => .{ .keywords = &.{ .left, .right, .center, .justify, .start, .end } },
111 .text_transform => .{ .keywords = &.{ .none, .capitalize, .uppercase, .lowercase } },
112 .white_space => .{ .keywords = &.{
113 .normal, .nowrap, .pre, .pre_wrap, .pre_line, .break_spaces,
114 } },
115 .font_style => .{ .keywords = &.{ .normal, .italic, .oblique } },
116 .font_weight => .{ .number = true, .keywords = &.{ .normal, .bold, .bolder, .lighter } },
117 .font_family => .{ .raw = true },
118 .line_height => .{
119 .number = true,
120 .length = true,
121 .percent = true,
122 .keywords = &.{.normal},
123 },
124 .border_style => .{ .keywords = &.{
125 .none, .hidden, .solid, .dashed, .dotted,
126 .double, .groove, .ridge, .inset, .outset,
127 } },
128 .text_decoration_line => .{ .keywords = &.{
129 .none, .underline, .overline, .line_through,
130 } },
131 .cursor => .{ .keywords = &.{
132 .auto, .default, .pointer, .text, .move, .not_allowed,
133 .grab, .grabbing, .crosshair, .wait, .help, .progress,
134 } },
135 .list_style_type => .{ .keywords = &.{
136 .none, .disc, .circle, .square, .decimal,
137 .lower_alpha, .upper_alpha, .lower_roman, .upper_roman,
138 } },
139 .vertical_align => .{ .length = true, .percent = true, .keywords = &.{
140 .baseline, .top, .middle, .bottom, .sub, .super, .text_top, .text_bottom,
141 } },
142 .content => .{ .string = true, .keywords = &.{ .none, .normal } },
143 .z_index => .{ .integer = true, .keywords = &.{.auto} },
144 .gap => .{ .length = true, .percent = true, .keywords = &.{.normal} },
145 .flex_basis => .{ .length = true, .percent = true, .keywords = &.{ .auto, .content } },
146 .length_normal => .{ .length = true, .keywords = &.{.normal} },
147 .direction => .{ .keywords = &.{ .ltr, .rtl } },
148 .text_overflow => .{ .keywords = &.{ .clip, .ellipsis } },
149 .word_break => .{ .keywords = &.{ .normal, .break_all, .keep_all } },
150 .overflow_wrap => .{ .keywords = &.{ .normal, .break_word, .anywhere } },
151 .object_fit => .{ .keywords = &.{ .fill, .contain, .cover, .none, .scale_down } },
152 .pointer_events => .{ .keywords = &.{ .auto, .none } },
153 .user_select => .{ .keywords = &.{ .auto, .none, .text, .all } },
154 .list_style_position => .{ .keywords = &.{ .inside, .outside } },
155 .background_repeat => .{ .keywords = &.{
156 .repeat, .repeat_x, .repeat_y, .no_repeat, .space, .round,
157 } },
158 .background_size => .{ .length = true, .percent = true, .keywords = &.{
159 .auto, .cover, .contain,
160 } },
161 .background_box => .{ .keywords = &.{ .border_box, .padding_box, .content_box } },
162 .background_attachment => .{ .keywords = &.{ .scroll, .fixed, .local } },
163 };
164 }
165
166 fn single(rules: Accept, tokenizer: *token.Tokenizer, first: token.Token, source: []const u8) Value {
167 switch (first.kind) {
168 .ident => {
169 const word = Keyword.parse(first.value(source)) orelse return identColor(rules, first, source);
170 if (contains(rules.keywords, word)) return Value.keyword(word);
171 return identColor(rules, first, source);
172 },
173 .dimension => return dimension(rules, first, source),
174 .number => return numeric(rules, first),
175 .percentage => {
176 if (!rules.percent) return .{};
177 return Value.percent(@floatCast(first.number));
178 },
179 .hash => {
180 if (!rules.color) return .{};
181 const packed_value = color.hex(first.value(source)) orelse return .{};
182 return Value.color(packed_value);
183 },
184 .string => {
185 if (!rules.string) return .{};
186 return Value.string(first.value_start, first.value_end - first.value_start);
187 },
188 .url => {
189 if (!rules.url) return .{};
190 return Value.string(first.value_start, first.value_end - first.value_start);
191 },
192 .function => return functionValue(rules, tokenizer, first, source),
193 else => return .{},
194 }
195 }
196
197 fn identColor(rules: Accept, first: token.Token, source: []const u8) Value {
198 if (!rules.color) return .{};
199 const name = first.value(source);
200 if (std.ascii.eqlIgnoreCase(name, "transparent")) return Value.color(color.transparent);
201 if (std.ascii.eqlIgnoreCase(name, "currentcolor")) return Value.keyword(.currentcolor);
202 return Value.color(color.named(name) orelse return .{});
203 }
204
205 fn dimension(rules: Accept, first: token.Token, source: []const u8) Value {
206 if (!rules.length) return .{};
207 const unit = Unit.parse(first.unit(source)) orelse return .{};
208 return Value.length(@floatCast(first.number), unit);
209 }
210
211 fn numeric(rules: Accept, first: token.Token) Value {
212 if (rules.integer) {
213 if (first.numeric != .integer) return .{};
214 return Value.number(@floatCast(first.number));
215 }
216 if (rules.number) return Value.number(@floatCast(first.number));
217 if (rules.length and first.number == 0) return Value.length(0, .px);
218 return .{};
219 }
220
221 fn functionValue(
222 rules: Accept,
223 tokenizer: *token.Tokenizer,
224 first: token.Token,
225 source: []const u8,
226 ) Value {
227 const body = functionBody(tokenizer, first, source) orelse return .{};
228 if (rules.color) {
229 const packed_value = color.function(first.value(source), body) orelse return .{};
230 return Value.color(packed_value);
231 }
232 if (rules.url and std.ascii.eqlIgnoreCase(first.value(source), "url")) {
233 const trimmed = std.mem.trim(u8, body, " \t\r\n\"'");
234 if (trimmed.len == 0) return .{};
235 const offset = @intFromPtr(trimmed.ptr) - @intFromPtr(source.ptr);
236 return Value.string(@intCast(offset), @intCast(trimmed.len));
237 }
238 return .{};
239 }
240
241 fn functionBody(tokenizer: *token.Tokenizer, first: token.Token, source: []const u8) ?[]const u8 {
242 var depth: u32 = 1;
243 var guard: usize = 0;
244 const limit = source.len + 1;
245 while (guard <= limit) : (guard += 1) {
246 const next = tokenizer.next();
247 switch (next.kind) {
248 .eof => return null,
249 .function, .left_paren => depth += 1,
250 .right_paren => {
251 depth -= 1;
252 if (depth == 0) return source[first.end..next.start];
253 },
254 else => {},
255 }
256 }
257 unreachable;
258 }
259
260 fn sealed(tokenizer: *token.Tokenizer, value: Value) Value {
261 const trailing = nextMeaningful(tokenizer);
262 if (trailing.kind != .eof) return .{};
263 return value;
264 }
265
266 fn nextMeaningful(tokenizer: *token.Tokenizer) token.Token {
267 var guard: usize = 0;
268 const limit = tokenizer.source.len + 1;
269 while (guard <= limit) : (guard += 1) {
270 const next = tokenizer.next();
271 if (next.kind != .whitespace) return next;
272 }
273 unreachable;
274 }
275
276 fn contains(set: []const Keyword, word: Keyword) bool {
277 for (set) |candidate| {
278 if (candidate == word) return true;
279 }
280 return false;
281 }
282
283 fn expectValue(grammar: Grammar, source: []const u8) Value {
284 return parse(grammar, source, 0, @intCast(source.len));
285 }
286
287 test "a variable reference is detected without parsing the value" {
288 const source = "1px var(--gap)";
289 try std.testing.expect(hasVariable(source, 0, @intCast(source.len)));
290 const plain = "1px solid red";
291 try std.testing.expect(!hasVariable(plain, 0, @intCast(plain.len)));
292 const quoted = "\"var(--x)\"";
293 try std.testing.expect(!hasVariable(quoted, 0, @intCast(quoted.len)));
294 }
295
296 test "a css wide keyword parses under every grammar" {
297 for ([_]Grammar{ .display, .color, .length, .z_index }) |grammar| {
298 try std.testing.expectEqual(Keyword.inherit, expectValue(grammar, "inherit").asKeyword());
299 try std.testing.expectEqual(Keyword.unset, expectValue(grammar, " unset ").asKeyword());
300 }
301 try std.testing.expectEqual(Keyword.revert, expectValue(.display, "REVERT").asKeyword());
302 }
303
304 test "lengths keep their unit and a bare zero becomes zero pixels" {
305 const length = expectValue(.length, "12.5rem");
306 try std.testing.expectEqual(scalar.Unit.rem, length.valueUnit());
307 try std.testing.expectEqual(@as(f32, 12.5), length.asNumber());
308 try std.testing.expectEqual(scalar.Unit.px, expectValue(.length, "0").valueUnit());
309 try std.testing.expect(!expectValue(.length, "5").present());
310 try std.testing.expect(!expectValue(.length, "5parsec").present());
311 }
312
313 test "a percentage needs a grammar that admits one" {
314 try std.testing.expectEqual(@as(f32, 50), expectValue(.length_percentage, "50%").asNumber());
315 try std.testing.expectEqual(scalar.Kind.percent, expectValue(.length_percentage, "50%").valueKind());
316 try std.testing.expect(!expectValue(.length, "50%").present());
317 }
318
319 test "colours arrive as names, hashes, and functions" {
320 try std.testing.expectEqual(color.pack(255, 0, 0, 255), expectValue(.color, "red").a);
321 try std.testing.expectEqual(color.pack(0xAA, 0xBB, 0xCC, 255), expectValue(.color, "#abc").a);
322 try std.testing.expectEqual(color.pack(1, 2, 3, 255), expectValue(.color, "rgb(1, 2, 3)").a);
323 try std.testing.expectEqual(@as(u32, 0), expectValue(.color, "transparent").a);
324 try std.testing.expectEqual(Keyword.currentcolor, expectValue(.color, "currentColor").asKeyword());
325 try std.testing.expect(!expectValue(.color, "notacolour").present());
326 }
327
328 test "a keyword grammar rejects a keyword outside its set" {
329 try std.testing.expectEqual(Keyword.flex, expectValue(.display, "flex").asKeyword());
330 try std.testing.expect(!expectValue(.display, "italic").present());
331 try std.testing.expect(!expectValue(.display, "block extra").present());
332 }
333
334 test "z index takes integers only and width takes sizing keywords" {
335 try std.testing.expectEqual(@as(f32, 3), expectValue(.z_index, "3").asNumber());
336 try std.testing.expect(!expectValue(.z_index, "3.5").present());
337 try std.testing.expectEqual(Keyword.auto, expectValue(.z_index, "auto").asKeyword());
338 try std.testing.expectEqual(Keyword.max_content, expectValue(.size, "max-content").asKeyword());
339 }
340
341 test "a url value borrows the resource span and none stays a keyword" {
342 const source = "url(icons/save.png)";
343 const value = parse(.url, source, 0, @intCast(source.len));
344 try std.testing.expectEqualStrings("icons/save.png", value.asString(source));
345 const quoted = "url(\"a b.png\")";
346 try std.testing.expectEqualStrings("a b.png", parse(.url, quoted, 0, @intCast(quoted.len)).asString(quoted));
347 try std.testing.expectEqual(Keyword.none, expectValue(.url, "none").asKeyword());
348 }
349
350 test "a raw grammar keeps the whole trimmed value as a string" {
351 const source = "font-family: Inter, system-ui ";
352 const value = parse(.font_family, source, 12, @intCast(source.len));
353 try std.testing.expectEqualStrings("Inter, system-ui", value.asString(source));
354 }
355
356 test "a string grammar keeps the quoted interior only" {
357 const source = "\"alpha\"";
358 try std.testing.expectEqualStrings("alpha", parse(.string, source, 0, 7).asString(source));
359 try std.testing.expect(!expectValue(.string, "alpha").present());
360 }
361
362 test "line height takes a number, a length, and the normal keyword" {
363 try std.testing.expectEqual(@as(f32, 1.5), expectValue(.line_height, "1.5").asNumber());
364 try std.testing.expectEqual(scalar.Unit.px, expectValue(.line_height, "20px").valueUnit());
365 try std.testing.expectEqual(Keyword.normal, expectValue(.line_height, "normal").asKeyword());
366 try std.testing.expect(!expectValue(.line_height, "bold").present());
367 }