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 }