From 913568aba286e08840c9d1c204f368b236f7eb04 Mon Sep 17 00:00:00 2001 From: Raph Date: Thu, 22 May 2025 01:51:03 +0200 Subject: [PATCH 1/8] Added support for CSSStyleDeclaration API --- src/browser/cssom/css_parser.zig | 308 ++++++++++++++++++ src/browser/cssom/css_style_declaration.zig | 236 ++++++++++++++ src/browser/cssom/css_value_analyzer.zig | 327 ++++++++++++++++++++ src/browser/env.zig | 1 + src/browser/html/elements.zig | 15 +- 5 files changed, 882 insertions(+), 5 deletions(-) create mode 100644 src/browser/cssom/css_parser.zig create mode 100644 src/browser/cssom/css_style_declaration.zig create mode 100644 src/browser/cssom/css_value_analyzer.zig diff --git a/src/browser/cssom/css_parser.zig b/src/browser/cssom/css_parser.zig new file mode 100644 index 00000000..24879de7 --- /dev/null +++ b/src/browser/cssom/css_parser.zig @@ -0,0 +1,308 @@ +const std = @import("std"); + +const CSSConstants = struct { + const IMPORTANT_KEYWORD = "!important"; + const IMPORTANT_LENGTH = IMPORTANT_KEYWORD.len; + const URL_PREFIX = "url("; + const URL_PREFIX_LENGTH = URL_PREFIX.len; +}; + +pub const CSSParserState = enum { + seekName, + inName, + seekColon, + seekValue, + inValue, + inQuotedValue, + inSingleQuotedValue, + inUrl, + inImportant, +}; + +pub const CSSDeclaration = struct { + name: []const u8, + value: []const u8, + is_important: bool, + + pub fn init(name: []const u8, value: []const u8, is_important: bool) CSSDeclaration { + return .{ + .name = name, + .value = value, + .is_important = is_important, + }; + } +}; + +pub const CSSParser = struct { + state: CSSParserState, + name_start: usize, + name_end: usize, + value_start: usize, + position: usize, + paren_depth: usize, + escape_next: bool, + + pub fn init() CSSParser { + return .{ + .state = .seekName, + .name_start = 0, + .name_end = 0, + .value_start = 0, + .position = 0, + .paren_depth = 0, + .escape_next = false, + }; + } + + pub fn parseDeclarations(text: []const u8, allocator: std.mem.Allocator) ![]CSSDeclaration { + var parser = init(); + var declarations = std.ArrayList(CSSDeclaration).init(allocator); + errdefer declarations.deinit(); + + while (parser.position < text.len) { + const c = text[parser.position]; + + switch (parser.state) { + .seekName => { + if (!std.ascii.isWhitespace(c)) { + parser.name_start = parser.position; + parser.state = .inName; + continue; + } + }, + .inName => { + if (c == ':') { + parser.name_end = parser.position; + parser.state = .seekValue; + } else if (std.ascii.isWhitespace(c)) { + parser.name_end = parser.position; + parser.state = .seekColon; + } + }, + .seekColon => { + if (c == ':') { + parser.state = .seekValue; + } else if (!std.ascii.isWhitespace(c)) { + parser.state = .seekName; + continue; + } + }, + .seekValue => { + if (!std.ascii.isWhitespace(c)) { + parser.value_start = parser.position; + if (c == '"') { + parser.state = .inQuotedValue; + } else if (c == '\'') { + parser.state = .inSingleQuotedValue; + } else if (c == 'u' and parser.position + 3 < text.len and + std.mem.eql(u8, text[parser.position .. parser.position + 4], CSSConstants.URL_PREFIX)) + { + parser.state = .inUrl; + parser.paren_depth = 1; + parser.position += 3; + } else { + parser.state = .inValue; + continue; + } + } + }, + .inValue => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '(') { + parser.paren_depth += 1; + } else if (c == ')' and parser.paren_depth > 0) { + parser.paren_depth -= 1; + } else if (c == ';' and parser.paren_depth == 0) { + try parser.finishDeclaration(text, &declarations); + parser.state = .seekName; + } + }, + .inQuotedValue => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '"') { + parser.state = .inValue; + } + }, + .inSingleQuotedValue => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '\'') { + parser.state = .inValue; + } + }, + .inUrl => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '(') { + parser.paren_depth += 1; + } else if (c == ')') { + parser.paren_depth -= 1; + if (parser.paren_depth == 0) { + parser.state = .inValue; + } + } + }, + .inImportant => {}, + } + + parser.position += 1; + } + + try parser.finalize(text, &declarations); + + return declarations.toOwnedSlice(); + } + + fn finishDeclaration(self: *CSSParser, text: []const u8, declarations: *std.ArrayList(CSSDeclaration)) !void { + const name = std.mem.trim(u8, text[self.name_start..self.name_end], &std.ascii.whitespace); + if (name.len == 0) return; + + const raw_value = text[self.value_start..self.position]; + const value = std.mem.trim(u8, raw_value, &std.ascii.whitespace); + + var final_value = value; + var is_important = false; + + if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT_KEYWORD)) { + is_important = true; + final_value = std.mem.trim(u8, value[0 .. value.len - CSSConstants.IMPORTANT_LENGTH], &std.ascii.whitespace); + } + + const declaration = CSSDeclaration.init(name, final_value, is_important); + try declarations.append(declaration); + } + + fn finalize(self: *CSSParser, text: []const u8, declarations: *std.ArrayList(CSSDeclaration)) !void { + if (self.state == .inValue) { + const name = text[self.name_start..self.name_end]; + const trimmed_name = std.mem.trim(u8, name, &std.ascii.whitespace); + + if (trimmed_name.len > 0) { + const raw_value = text[self.value_start..self.position]; + const value = std.mem.trim(u8, raw_value, &std.ascii.whitespace); + + var final_value = value; + var is_important = false; + if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT_KEYWORD)) { + is_important = true; + final_value = std.mem.trim(u8, value[0 .. value.len - CSSConstants.IMPORTANT_LENGTH], &std.ascii.whitespace); + } + + const declaration = CSSDeclaration.init(trimmed_name, final_value, is_important); + try declarations.append(declaration); + } + } + } + + pub fn getState(self: *const CSSParser) CSSParserState { + return self.state; + } + + pub fn getPosition(self: *const CSSParser) usize { + return self.position; + } + + pub fn reset(self: *CSSParser) void { + self.* = init(); + } +}; + +const testing = std.testing; + +test "CSSParser - Simple property" { + const text = "color: red;"; + const allocator = testing.allocator; + + const declarations = try CSSParser.parseDeclarations(text, allocator); + defer allocator.free(declarations); + + try testing.expect(declarations.len == 1); + try testing.expectEqualStrings("color", declarations[0].name); + try testing.expectEqualStrings("red", declarations[0].value); + try testing.expect(!declarations[0].is_important); +} + +test "CSSParser - Property with !important" { + const text = "margin: 10px !important;"; + const allocator = testing.allocator; + + const declarations = try CSSParser.parseDeclarations(text, allocator); + defer allocator.free(declarations); + + try testing.expect(declarations.len == 1); + try testing.expectEqualStrings("margin", declarations[0].name); + try testing.expectEqualStrings("10px", declarations[0].value); + try testing.expect(declarations[0].is_important); +} + +test "CSSParser - Multiple properties" { + const text = "color: red; font-size: 12px; margin: 5px !important;"; + const allocator = testing.allocator; + + const declarations = try CSSParser.parseDeclarations(text, allocator); + defer allocator.free(declarations); + + try testing.expect(declarations.len == 3); + + try testing.expectEqualStrings("color", declarations[0].name); + try testing.expectEqualStrings("red", declarations[0].value); + try testing.expect(!declarations[0].is_important); + + try testing.expectEqualStrings("font-size", declarations[1].name); + try testing.expectEqualStrings("12px", declarations[1].value); + try testing.expect(!declarations[1].is_important); + + try testing.expectEqualStrings("margin", declarations[2].name); + try testing.expectEqualStrings("5px", declarations[2].value); + try testing.expect(declarations[2].is_important); +} + +test "CSSParser - Quoted value with semicolon" { + const text = "content: \"Hello; world!\";"; + const allocator = testing.allocator; + + const declarations = try CSSParser.parseDeclarations(text, allocator); + defer allocator.free(declarations); + + try testing.expect(declarations.len == 1); + try testing.expectEqualStrings("content", declarations[0].name); + try testing.expectEqualStrings("\"Hello; world!\"", declarations[0].value); + try testing.expect(!declarations[0].is_important); +} + +test "CSSParser - URL value" { + const text = "background-image: url(\"test.png\");"; + const allocator = testing.allocator; + + const declarations = try CSSParser.parseDeclarations(text, allocator); + defer allocator.free(declarations); + + try testing.expect(declarations.len == 1); + try testing.expectEqualStrings("background-image", declarations[0].name); + try testing.expectEqualStrings("url(\"test.png\")", declarations[0].value); + try testing.expect(!declarations[0].is_important); +} + +test "CSSParser - Whitespace handling" { + const text = " color : purple ; margin : 10px ; "; + const allocator = testing.allocator; + + const declarations = try CSSParser.parseDeclarations(text, allocator); + defer allocator.free(declarations); + + try testing.expect(declarations.len == 2); + try testing.expectEqualStrings("color", declarations[0].name); + try testing.expectEqualStrings("purple", declarations[0].value); + try testing.expectEqualStrings("margin", declarations[1].name); + try testing.expectEqualStrings("10px", declarations[1].value); +} diff --git a/src/browser/cssom/css_style_declaration.zig b/src/browser/cssom/css_style_declaration.zig new file mode 100644 index 00000000..359d9dc9 --- /dev/null +++ b/src/browser/cssom/css_style_declaration.zig @@ -0,0 +1,236 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +const CSSParser = @import("./css_parser.zig").CSSParser; +const CSSValueAnalyzer = @import("./css_value_analyzer.zig").CSSValueAnalyzer; +const SessionState = @import("../env.zig").SessionState; + +pub const Interfaces = .{ + CSSStyleDeclaration, + CSSRule, +}; + +const CSSRule = struct {}; + +pub const CSSStyleDeclaration = struct { + store: std.StringHashMapUnmanaged(Property), + order: std.ArrayListUnmanaged([]const u8), + + const Property = struct { + value: []const u8, + priority: bool, + }; + + pub fn get_cssFloat(self: *const CSSStyleDeclaration) []const u8 { + return self._getPropertyValue("float"); + } + + pub fn set_cssFloat(self: *CSSStyleDeclaration, value: ?[]const u8, state: *SessionState) !void { + const final_value = value orelse ""; + return self._setProperty("float", final_value, null, state); + } + + pub fn get_cssText(self: *const CSSStyleDeclaration, state: *SessionState) ![]const u8 { + var buffer = std.ArrayList(u8).init(state.arena); + const writer = buffer.writer(); + for (self.order.items) |name| { + const prop = self.store.get(name).?; + const escaped = try CSSValueAnalyzer.escapeCSSValue(state.arena, prop.value); + try writer.print("{s}: {s}", .{ name, escaped }); + if (prop.priority) try writer.writeAll(" !important"); + try writer.writeAll("; "); + } + return buffer.toOwnedSlice(); + } + + // TODO Propagate also upward to parent node + pub fn set_cssText(self: *CSSStyleDeclaration, text: []const u8, state: *SessionState) !void { + var store_it = self.store.iterator(); + while (store_it.next()) |entry| { + state.arena.free(entry.key_ptr.*); + state.arena.free(entry.value_ptr.value); + } + self.store.clearRetainingCapacity(); + for (self.order.items) |name| state.arena.free(name); + self.order.clearRetainingCapacity(); + + const declarations = try CSSParser.parseDeclarations(text, state.arena); + defer state.arena.free(declarations); + + for (declarations) |decl| { + if (!CSSValueAnalyzer.isValidPropertyName(decl.name)) continue; + const priority: ?[]const u8 = if (decl.is_important) "important" else null; + try self._setProperty(decl.name, decl.value, priority, state); + } + } + + pub fn get_length(self: *const CSSStyleDeclaration) usize { + return self.order.items.len; + } + + pub fn get_parentRule() ?CSSRule { + return null; + } + + pub fn _getPropertyPriority(self: *const CSSStyleDeclaration, name: []const u8) []const u8 { + return if (self.store.get(name)) |prop| (if (prop.priority) "important" else "") else ""; + } + + // TODO should handle properly shorthand properties and canonical forms + pub fn _getPropertyValue(self: *const CSSStyleDeclaration, name: []const u8) []const u8 { + return if (self.store.get(name)) |prop| prop.value else ""; + } + + pub fn _item(self: *const CSSStyleDeclaration, index: usize) []const u8 { + return if (index < self.order.items.len) self.order.items[index] else ""; + } + + pub fn _removeProperty(self: *CSSStyleDeclaration, name: []const u8, state: *SessionState) ![]const u8 { + if (self.store.get(name)) |prop| { + const value_copy = try state.arena.dupe(u8, prop.value); + _ = self.store.remove(name); + state.arena.free(prop.value); + var i: usize = 0; + while (i < self.order.items.len) : (i += 1) { + if (std.mem.eql(u8, self.order.items[i], name)) { + state.arena.free(self.order.items[i]); + _ = self.order.orderedRemove(i); + break; + } + } + return value_copy; + } + return ""; + } + + pub fn _setProperty(self: *CSSStyleDeclaration, name: []const u8, value: []const u8, priority: ?[]const u8, state: *SessionState) !void { + const is_important = priority != null and std.ascii.eqlIgnoreCase(priority.?, "important"); + + const value_copy = try state.arena.dupe(u8, value); + + if (!self.store.contains(name)) { + const name_copy = try state.arena.dupe(u8, name); + try self.order.append(state.arena, name_copy); + try self.store.put(state.arena, name_copy, Property{ .value = value_copy, .priority = is_important }); + } else { + const prev = self.store.get(name).?; + state.arena.free(prev.value); + try self.store.put(state.arena, name, Property{ .value = value_copy, .priority = is_important }); + } + } +}; + +const testing = @import("../../testing.zig"); + +test "CSSOM.CSSStyleDeclaration" { + var runner = try testing.jsRunner(testing.tracking_allocator, .{}); + defer runner.deinit(); + + try runner.testCases(&.{ + .{ "let style = document.getElementById('content').style", "undefined" }, + .{ "style.cssText = 'color: red; font-size: 12px; margin: 5px !important;'", "color: red; font-size: 12px; margin: 5px !important;" }, + .{ "style.length", "3" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.getPropertyValue('color')", "red" }, + .{ "style.getPropertyValue('font-size')", "12px" }, + .{ "style.getPropertyValue('unknown-property')", "" }, + + .{ "style.getPropertyPriority('margin')", "important" }, + .{ "style.getPropertyPriority('color')", "" }, + .{ "style.getPropertyPriority('unknown-property')", "" }, + + .{ "style.item(0)", "color" }, + .{ "style.item(1)", "font-size" }, + .{ "style.item(2)", "margin" }, + .{ "style.item(3)", "" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.setProperty('background-color', 'blue')", "undefined" }, + .{ "style.getPropertyValue('background-color')", "blue" }, + .{ "style.length", "4" }, + + .{ "style.setProperty('color', 'green')", "undefined" }, + .{ "style.getPropertyValue('color')", "green" }, + .{ "style.length", "4" }, + + .{ "style.setProperty('padding', '10px', 'important')", "undefined" }, + .{ "style.getPropertyValue('padding')", "10px" }, + .{ "style.getPropertyPriority('padding')", "important" }, + + .{ "style.setProperty('border', '1px solid black', 'IMPORTANT')", "undefined" }, + .{ "style.getPropertyPriority('border')", "important" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.removeProperty('color')", "green" }, + .{ "style.getPropertyValue('color')", "" }, + .{ "style.length", "5" }, + + .{ "style.removeProperty('unknown-property')", "" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.cssText.includes('font-size: 12px;')", "true" }, + .{ "style.cssText.includes('margin: 5px !important;')", "true" }, + .{ "style.cssText.includes('padding: 10px !important;')", "true" }, + .{ "style.cssText.includes('border: 1px solid black !important;')", "true" }, + + .{ "style.cssText = 'color: purple; text-align: center;'", "color: purple; text-align: center;" }, + .{ "style.length", "2" }, + .{ "style.getPropertyValue('color')", "purple" }, + .{ "style.getPropertyValue('text-align')", "center" }, + .{ "style.getPropertyValue('font-size')", "" }, + + .{ "style.setProperty('cont', 'Hello; world!')", "undefined" }, + .{ "style.getPropertyValue('cont')", "Hello; world!" }, + + .{ "style.cssText = 'content: \"Hello; world!\"; background-image: url(\"test.png\");'", "content: \"Hello; world!\"; background-image: url(\"test.png\");" }, + .{ "style.getPropertyValue('content')", "\"Hello; world!\"" }, + .{ "style.getPropertyValue('background-image')", "url(\"test.png\")" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.cssFloat", "" }, + .{ "style.cssFloat = 'left'", "left" }, + .{ "style.cssFloat", "left" }, + .{ "style.getPropertyValue('float')", "left" }, + + .{ "style.cssFloat = 'right'", "right" }, + .{ "style.cssFloat", "right" }, + + .{ "style.cssFloat = null", "null" }, + .{ "style.cssFloat", "" }, + }, .{}); + + try runner.testCases(&.{ + .{ "style.setProperty('display', '')", "undefined" }, + .{ "style.getPropertyValue('display')", "" }, + + .{ "style.cssText = ' color : purple ; margin : 10px ; '", " color : purple ; margin : 10px ; " }, + .{ "style.getPropertyValue('color')", "purple" }, + .{ "style.getPropertyValue('margin')", "10px" }, + + .{ "style.setProperty('border-bottom-left-radius', '5px')", "undefined" }, + .{ "style.getPropertyValue('border-bottom-left-radius')", "5px" }, + }, .{}); +} diff --git a/src/browser/cssom/css_value_analyzer.zig b/src/browser/cssom/css_value_analyzer.zig new file mode 100644 index 00000000..9b098aa8 --- /dev/null +++ b/src/browser/cssom/css_value_analyzer.zig @@ -0,0 +1,327 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); + +pub const CSSValueAnalyzer = struct { + pub fn isNumericWithUnit(value: []const u8) bool { + if (value.len == 0) return false; + + if (!std.ascii.isDigit(value[0]) and + value[0] != '+' and value[0] != '-' and value[0] != '.') + { + return false; + } + + var i: usize = 0; + var has_digit = false; + var decimal_point = false; + + while (i < value.len) : (i += 1) { + const c = value[i]; + if (std.ascii.isDigit(c)) { + has_digit = true; + } else if (c == '.' and !decimal_point) { + decimal_point = true; + } else if ((c == 'e' or c == 'E') and has_digit) { + i += 1; + if (i < value.len and (value[i] == '+' or value[i] == '-')) { + i += 1; + } + var has_exp_digits = false; + while (i < value.len and std.ascii.isDigit(value[i])) : (i += 1) { + has_exp_digits = true; + } + if (!has_exp_digits) return false; + break; + } else if (c != '-' and c != '+') { + break; + } + } + + if (!has_digit) return false; + + if (i == value.len) return true; + + const unit = value[i..]; + return CSSKeywords.isValidUnit(unit); + } + + pub fn isHexColor(value: []const u8) bool { + if (!std.mem.startsWith(u8, value, "#")) return false; + + const hex_part = value[1..]; + if (hex_part.len != 3 and hex_part.len != 6 and hex_part.len != 8) return false; + + for (hex_part) |c| { + if (!std.ascii.isHex(c)) return false; + } + + return true; + } + + pub fn isMultiValueProperty(value: []const u8) bool { + var parts = std.mem.splitAny(u8, value, " "); + var multi_value_parts: usize = 0; + var all_parts_valid = true; + + while (parts.next()) |part| { + if (part.len == 0) continue; + multi_value_parts += 1; + + const is_numeric = isNumericWithUnit(part); + const is_hex_color = isHexColor(part); + const is_known_keyword = CSSKeywords.isKnownKeyword(part); + const is_function = CSSKeywords.startsWithFunction(part); + + if (!is_numeric and !is_hex_color and !is_known_keyword and !is_function) { + all_parts_valid = false; + break; + } + } + + return multi_value_parts >= 2 and all_parts_valid; + } + + pub fn isAlreadyQuoted(value: []const u8) bool { + return value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or + (value[0] == '\'' and value[value.len - 1] == '\'')); + } + + pub fn isValidPropertyName(name: []const u8) bool { + if (name.len == 0) return false; + + const first_char = name[0]; + if (!std.ascii.isAlphabetic(first_char) and first_char != '-') { + return false; + } + + for (name[1..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-') { + return false; + } + } + + return true; + } + + pub fn extractImportant(value: []const u8) struct { value: []const u8, is_important: bool } { + const trimmed = std.mem.trim(u8, value, &std.ascii.whitespace); + + if (std.mem.endsWith(u8, trimmed, "!important")) { + const clean_value = std.mem.trim(u8, trimmed[0 .. trimmed.len - 10], &std.ascii.whitespace); + return .{ .value = clean_value, .is_important = true }; + } + + return .{ .value = trimmed, .is_important = false }; + } + + pub fn needsQuotes(value: []const u8) bool { + if (value.len == 0) return true; + if (isAlreadyQuoted(value)) return false; + + const has_spaces = std.mem.indexOf(u8, value, " ") != null; + const has_special_chars = CSSKeywords.containsSpecialChar(value); + const is_url = std.mem.startsWith(u8, value, "url("); + const is_function = CSSKeywords.startsWithFunction(value); + + const space_requires_quotes = has_spaces and + !isMultiValueProperty(value) and + !is_url and + !is_function; + + return has_special_chars or space_requires_quotes; + } + + pub fn escapeCSSValue(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { + var out = std.ArrayList(u8).init(allocator); + errdefer out.deinit(); + const writer = out.writer(); + + if (isAlreadyQuoted(value)) { + try writer.writeAll(value); + return out.toOwnedSlice(); + } + + const needs_quotes = needsQuotes(value); + + if (needs_quotes) { + try writer.writeByte('"'); + + for (value) |c| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\A "), + '\r' => try writer.writeAll("\\D "), + '\t' => try writer.writeAll("\\9 "), + 0...8, 11, 12, 14...31, 127 => { + try writer.print("\\{x}", .{c}); + if (c + 1 < value.len and std.ascii.isHex(value[c + 1])) { + try writer.writeByte(' '); + } + }, + else => try writer.writeByte(c), + } + } + + try writer.writeByte('"'); + } else { + try writer.writeAll(value); + } + + return out.toOwnedSlice(); + } + + pub fn isKnownKeyword(value: []const u8) bool { + return CSSKeywords.isKnownKeyword(value); + } + + pub fn containsSpecialChar(value: []const u8) bool { + return CSSKeywords.containsSpecialChar(value); + } +}; + +const CSSKeywords = struct { + const BorderStyles = [_][]const u8{ + "none", "solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset", + }; + + const ColorNames = [_][]const u8{ + "black", "white", "red", "green", "blue", "yellow", "purple", "gray", "transparent", + "currentColor", "inherit", + }; + + const PositionKeywords = [_][]const u8{ + "auto", "center", "left", "right", "top", "bottom", + }; + + const BackgroundRepeat = [_][]const u8{ + "repeat", "no-repeat", "repeat-x", "repeat-y", "space", "round", + }; + + const FontStyles = [_][]const u8{ + "normal", "italic", "oblique", "bold", "bolder", "lighter", + }; + + const FontSizes = [_][]const u8{ + "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", + "smaller", "larger", + }; + + const FontFamilies = [_][]const u8{ + "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", + }; + + const CSSGlobal = [_][]const u8{ + "initial", "inherit", "unset", "revert", + }; + + const DisplayValues = [_][]const u8{ + "block", "inline", "inline-block", "flex", "grid", "none", + }; + + const LengthUnits = [_][]const u8{ + "px", "em", "rem", "vw", "vh", "vmin", "vmax", "%", "pt", "pc", "in", "cm", "mm", + "ex", "ch", "fr", + }; + + const AngleUnits = [_][]const u8{ + "deg", "rad", "grad", "turn", + }; + + const TimeUnits = [_][]const u8{ + "s", "ms", + }; + + const FrequencyUnits = [_][]const u8{ + "Hz", "kHz", + }; + + const ResolutionUnits = [_][]const u8{ + "dpi", "dpcm", "dppx", + }; + + const SpecialChars = [_]u8{ + '"', '\'', ';', '{', '}', '\\', '<', '>', '/', + }; + + const Functions = [_][]const u8{ + "rgb", "rgba", "hsl", "hsla", "url", "calc", "var", "attr", + "linear-gradient", "radial-gradient", "conic-gradient", "translate", "rotate", "scale", "skew", "matrix", + }; + + pub fn isKnownKeyword(value: []const u8) bool { + const all_categories = [_][]const []const u8{ + &BorderStyles, &ColorNames, &PositionKeywords, &BackgroundRepeat, + &FontStyles, &FontSizes, &FontFamilies, &CSSGlobal, + &DisplayValues, + }; + + for (all_categories) |category| { + for (category) |keyword| { + if (std.mem.eql(u8, value, keyword)) { + return true; + } + } + } + + return false; + } + + pub fn containsSpecialChar(value: []const u8) bool { + for (value) |c| { + for (SpecialChars) |special| { + if (c == special) { + return true; + } + } + } + return false; + } + + pub fn isValidUnit(unit: []const u8) bool { + const all_units = [_][]const []const u8{ + &LengthUnits, &AngleUnits, &TimeUnits, &FrequencyUnits, &ResolutionUnits, + }; + + for (all_units) |category| { + for (category) |valid_unit| { + if (std.mem.eql(u8, unit, valid_unit)) { + return true; + } + } + } + + return false; + } + + pub fn startsWithFunction(value: []const u8) bool { + for (Functions) |func| { + if (value.len >= func.len + 1 and + std.mem.startsWith(u8, value, func) and + value[func.len] == '(') + { + return true; + } + } + + return std.mem.indexOf(u8, value, "(") != null and + std.mem.indexOf(u8, value, ")") != null; + } +}; diff --git a/src/browser/env.zig b/src/browser/env.zig index e45ff75d..df747ac8 100644 --- a/src/browser/env.zig +++ b/src/browser/env.zig @@ -27,6 +27,7 @@ const WebApis = struct { pub const Interfaces = generate.Tuple(.{ @import("crypto/crypto.zig").Crypto, @import("console/console.zig").Console, + @import("cssom/css_style_declaration.zig").Interfaces, @import("dom/dom.zig").Interfaces, @import("encoding/text_encoder.zig").Interfaces, @import("events/event.zig").Interfaces, diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index b5f62f8a..f34f8330 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -25,6 +25,8 @@ const URL = @import("../url/url.zig").URL; const Node = @import("../dom/node.zig").Node; const Element = @import("../dom/element.zig").Element; +const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration; + // HTMLElement interfaces pub const Interfaces = .{ HTMLElement, @@ -92,7 +94,6 @@ pub const Interfaces = .{ HTMLTrackElement, HTMLUListElement, HTMLVideoElement, - CSSProperties, @import("form.zig").HTMLFormElement, @import("select.zig").HTMLSelectElement, @@ -103,15 +104,19 @@ pub const Union = generate.Union(Interfaces); // Abstract class // -------------- -const CSSProperties = struct {}; - pub const HTMLElement = struct { pub const Self = parser.ElementHTML; pub const prototype = *Element; pub const subtype = .node; - pub fn get_style(_: *parser.ElementHTML) CSSProperties { - return .{}; + style: CSSStyleDeclaration = .{ + .store = .{}, + .order = .{}, + }, + + pub fn get_style(e: *parser.ElementHTML, state: *SessionState) !*CSSStyleDeclaration { + const self = try state.getNodeWrapper(HTMLElement, @ptrCast(e)); + return &self.style; } pub fn get_innerText(e: *parser.ElementHTML) ![]const u8 { From 450e345b28e07822bbf17a3d252f3af650cab514 Mon Sep 17 00:00:00 2001 From: Raph Date: Thu, 22 May 2025 02:01:11 +0200 Subject: [PATCH 2/8] fixed self fetching for HTMLElement --- src/browser/html/elements.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index f34f8330..498f4387 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -115,7 +115,7 @@ pub const HTMLElement = struct { }, pub fn get_style(e: *parser.ElementHTML, state: *SessionState) !*CSSStyleDeclaration { - const self = try state.getNodeWrapper(HTMLElement, @ptrCast(e)); + const self = try state.getOrCreateNodeWrapper(HTMLElement, @ptrCast(e)); return &self.style; } From 09727101c1f7e03d693d05278ca7141241121718 Mon Sep 17 00:00:00 2001 From: Raph Date: Sat, 24 May 2025 01:59:28 +0200 Subject: [PATCH 3/8] various fixes according to PR review --- src/browser/cssom/css_parser.zig | 214 ++++---- src/browser/cssom/css_style_declaration.zig | 23 +- src/browser/cssom/css_value_analyzer.zig | 576 ++++++++++++++++++-- 3 files changed, 651 insertions(+), 162 deletions(-) diff --git a/src/browser/cssom/css_parser.zig b/src/browser/cssom/css_parser.zig index 24879de7..6a4bfdf3 100644 --- a/src/browser/cssom/css_parser.zig +++ b/src/browser/cssom/css_parser.zig @@ -1,36 +1,44 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const std = @import("std"); const CSSConstants = struct { - const IMPORTANT_KEYWORD = "!important"; - const IMPORTANT_LENGTH = IMPORTANT_KEYWORD.len; + const IMPORTANT = "!important"; const URL_PREFIX = "url("; - const URL_PREFIX_LENGTH = URL_PREFIX.len; }; pub const CSSParserState = enum { - seekName, - inName, - seekColon, - seekValue, - inValue, - inQuotedValue, - inSingleQuotedValue, - inUrl, - inImportant, + seek_name, + in_name, + seek_colon, + seek_value, + in_value, + in_quoted_value, + in_single_quoted_value, + in_url, + in_important, }; pub const CSSDeclaration = struct { name: []const u8, value: []const u8, is_important: bool, - - pub fn init(name: []const u8, value: []const u8, is_important: bool) CSSDeclaration { - return .{ - .name = name, - .value = value, - .is_important = is_important, - }; - } }; pub const CSSParser = struct { @@ -44,7 +52,7 @@ pub const CSSParser = struct { pub fn init() CSSParser { return .{ - .state = .seekName, + .state = .seek_name, .name_start = 0, .name_end = 0, .value_start = 0, @@ -54,59 +62,56 @@ pub const CSSParser = struct { }; } - pub fn parseDeclarations(text: []const u8, allocator: std.mem.Allocator) ![]CSSDeclaration { + pub fn parseDeclarations(allocator: std.mem.Allocator, text: []const u8) ![]CSSDeclaration { var parser = init(); - var declarations = std.ArrayList(CSSDeclaration).init(allocator); - errdefer declarations.deinit(); + var declarations = std.ArrayListUnmanaged(CSSDeclaration){}; while (parser.position < text.len) { const c = text[parser.position]; switch (parser.state) { - .seekName => { + .seek_name => { if (!std.ascii.isWhitespace(c)) { parser.name_start = parser.position; - parser.state = .inName; + parser.state = .in_name; continue; } }, - .inName => { + .in_name => { if (c == ':') { parser.name_end = parser.position; - parser.state = .seekValue; + parser.state = .seek_value; } else if (std.ascii.isWhitespace(c)) { parser.name_end = parser.position; - parser.state = .seekColon; + parser.state = .seek_colon; } }, - .seekColon => { + .seek_colon => { if (c == ':') { - parser.state = .seekValue; + parser.state = .seek_value; } else if (!std.ascii.isWhitespace(c)) { - parser.state = .seekName; + parser.state = .seek_name; continue; } }, - .seekValue => { + .seek_value => { if (!std.ascii.isWhitespace(c)) { parser.value_start = parser.position; if (c == '"') { - parser.state = .inQuotedValue; + parser.state = .in_quoted_value; } else if (c == '\'') { - parser.state = .inSingleQuotedValue; - } else if (c == 'u' and parser.position + 3 < text.len and - std.mem.eql(u8, text[parser.position .. parser.position + 4], CSSConstants.URL_PREFIX)) - { - parser.state = .inUrl; + parser.state = .in_single_quoted_value; + } else if (c == 'u' and parser.position + CSSConstants.URL_PREFIX.len <= text.len and std.mem.startsWith(u8, text[parser.position..], CSSConstants.URL_PREFIX)) { + parser.state = .in_url; parser.paren_depth = 1; parser.position += 3; } else { - parser.state = .inValue; + parser.state = .in_value; continue; } } }, - .inValue => { + .in_value => { if (parser.escape_next) { parser.escape_next = false; } else if (c == '\\') { @@ -116,29 +121,29 @@ pub const CSSParser = struct { } else if (c == ')' and parser.paren_depth > 0) { parser.paren_depth -= 1; } else if (c == ';' and parser.paren_depth == 0) { - try parser.finishDeclaration(text, &declarations); - parser.state = .seekName; + try parser.finishDeclaration(allocator, &declarations, text); + parser.state = .seek_name; } }, - .inQuotedValue => { + .in_quoted_value => { if (parser.escape_next) { parser.escape_next = false; } else if (c == '\\') { parser.escape_next = true; } else if (c == '"') { - parser.state = .inValue; + parser.state = .in_value; } }, - .inSingleQuotedValue => { + .in_single_quoted_value => { if (parser.escape_next) { parser.escape_next = false; } else if (c == '\\') { parser.escape_next = true; } else if (c == '\'') { - parser.state = .inValue; + parser.state = .in_value; } }, - .inUrl => { + .in_url => { if (parser.escape_next) { parser.escape_next = false; } else if (c == '\\') { @@ -148,22 +153,22 @@ pub const CSSParser = struct { } else if (c == ')') { parser.paren_depth -= 1; if (parser.paren_depth == 0) { - parser.state = .inValue; + parser.state = .in_value; } } }, - .inImportant => {}, + .in_important => {}, } parser.position += 1; } - try parser.finalize(text, &declarations); + try parser.finalize(allocator, &declarations, text); - return declarations.toOwnedSlice(); + return declarations.items; } - fn finishDeclaration(self: *CSSParser, text: []const u8, declarations: *std.ArrayList(CSSDeclaration)) !void { + fn finishDeclaration(self: *CSSParser, allocator: std.mem.Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { const name = std.mem.trim(u8, text[self.name_start..self.name_end], &std.ascii.whitespace); if (name.len == 0) return; @@ -173,17 +178,20 @@ pub const CSSParser = struct { var final_value = value; var is_important = false; - if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT_KEYWORD)) { + if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT)) { is_important = true; - final_value = std.mem.trim(u8, value[0 .. value.len - CSSConstants.IMPORTANT_LENGTH], &std.ascii.whitespace); + final_value = std.mem.trim(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace); } - const declaration = CSSDeclaration.init(name, final_value, is_important); - try declarations.append(declaration); + try declarations.append(allocator, .{ + .name = name, + .value = final_value, + .is_important = is_important, + }); } - fn finalize(self: *CSSParser, text: []const u8, declarations: *std.ArrayList(CSSDeclaration)) !void { - if (self.state == .inValue) { + fn finalize(self: *CSSParser, allocator: std.mem.Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { + if (self.state == .in_value) { const name = text[self.name_start..self.name_end]; const trimmed_name = std.mem.trim(u8, name, &std.ascii.whitespace); @@ -193,13 +201,16 @@ pub const CSSParser = struct { var final_value = value; var is_important = false; - if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT_KEYWORD)) { + if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT)) { is_important = true; - final_value = std.mem.trim(u8, value[0 .. value.len - CSSConstants.IMPORTANT_LENGTH], &std.ascii.whitespace); + final_value = std.mem.trim(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace); } - const declaration = CSSDeclaration.init(trimmed_name, final_value, is_important); - try declarations.append(declaration); + try declarations.append(allocator, .{ + .name = trimmed_name, + .value = final_value, + .is_important = is_important, + }); } } } @@ -217,92 +228,93 @@ pub const CSSParser = struct { } }; -const testing = std.testing; +const testing = @import("../../testing.zig"); test "CSSParser - Simple property" { - const text = "color: red;"; - const allocator = testing.allocator; + defer testing.reset(); - const declarations = try CSSParser.parseDeclarations(text, allocator); - defer allocator.free(declarations); + const text = "color: red;"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); try testing.expect(declarations.len == 1); - try testing.expectEqualStrings("color", declarations[0].name); - try testing.expectEqualStrings("red", declarations[0].value); + try testing.expectEqual("color", declarations[0].name); + try testing.expectEqual("red", declarations[0].value); try testing.expect(!declarations[0].is_important); } test "CSSParser - Property with !important" { + defer testing.reset(); const text = "margin: 10px !important;"; - const allocator = testing.allocator; + const allocator = testing.arena_allocator; - const declarations = try CSSParser.parseDeclarations(text, allocator); - defer allocator.free(declarations); + const declarations = try CSSParser.parseDeclarations(allocator, text); try testing.expect(declarations.len == 1); - try testing.expectEqualStrings("margin", declarations[0].name); - try testing.expectEqualStrings("10px", declarations[0].value); + try testing.expectEqual("margin", declarations[0].name); + try testing.expectEqual("10px", declarations[0].value); try testing.expect(declarations[0].is_important); } test "CSSParser - Multiple properties" { + defer testing.reset(); const text = "color: red; font-size: 12px; margin: 5px !important;"; - const allocator = testing.allocator; + const allocator = testing.arena_allocator; - const declarations = try CSSParser.parseDeclarations(text, allocator); - defer allocator.free(declarations); + const declarations = try CSSParser.parseDeclarations(allocator, text); try testing.expect(declarations.len == 3); - try testing.expectEqualStrings("color", declarations[0].name); - try testing.expectEqualStrings("red", declarations[0].value); + try testing.expectEqual("color", declarations[0].name); + try testing.expectEqual("red", declarations[0].value); try testing.expect(!declarations[0].is_important); - try testing.expectEqualStrings("font-size", declarations[1].name); - try testing.expectEqualStrings("12px", declarations[1].value); + try testing.expectEqual("font-size", declarations[1].name); + try testing.expectEqual("12px", declarations[1].value); try testing.expect(!declarations[1].is_important); - try testing.expectEqualStrings("margin", declarations[2].name); - try testing.expectEqualStrings("5px", declarations[2].value); + try testing.expectEqual("margin", declarations[2].name); + try testing.expectEqual("5px", declarations[2].value); try testing.expect(declarations[2].is_important); } test "CSSParser - Quoted value with semicolon" { + defer testing.reset(); const text = "content: \"Hello; world!\";"; - const allocator = testing.allocator; + const allocator = testing.arena_allocator; - const declarations = try CSSParser.parseDeclarations(text, allocator); - defer allocator.free(declarations); + const declarations = try CSSParser.parseDeclarations(allocator, text); try testing.expect(declarations.len == 1); - try testing.expectEqualStrings("content", declarations[0].name); - try testing.expectEqualStrings("\"Hello; world!\"", declarations[0].value); + try testing.expectEqual("content", declarations[0].name); + try testing.expectEqual("\"Hello; world!\"", declarations[0].value); try testing.expect(!declarations[0].is_important); } test "CSSParser - URL value" { + defer testing.reset(); const text = "background-image: url(\"test.png\");"; - const allocator = testing.allocator; + const allocator = testing.arena_allocator; - const declarations = try CSSParser.parseDeclarations(text, allocator); - defer allocator.free(declarations); + const declarations = try CSSParser.parseDeclarations(allocator, text); try testing.expect(declarations.len == 1); - try testing.expectEqualStrings("background-image", declarations[0].name); - try testing.expectEqualStrings("url(\"test.png\")", declarations[0].value); + try testing.expectEqual("background-image", declarations[0].name); + try testing.expectEqual("url(\"test.png\")", declarations[0].value); try testing.expect(!declarations[0].is_important); } test "CSSParser - Whitespace handling" { + defer testing.reset(); const text = " color : purple ; margin : 10px ; "; - const allocator = testing.allocator; + const allocator = testing.arena_allocator; - const declarations = try CSSParser.parseDeclarations(text, allocator); - defer allocator.free(declarations); + const declarations = try CSSParser.parseDeclarations(allocator, text); try testing.expect(declarations.len == 2); - try testing.expectEqualStrings("color", declarations[0].name); - try testing.expectEqualStrings("purple", declarations[0].value); - try testing.expectEqualStrings("margin", declarations[1].name); - try testing.expectEqualStrings("10px", declarations[1].value); + try testing.expectEqual("color", declarations[0].name); + try testing.expectEqual("purple", declarations[0].value); + try testing.expectEqual("margin", declarations[1].name); + try testing.expectEqual("10px", declarations[1].value); } diff --git a/src/browser/cssom/css_style_declaration.zig b/src/browser/cssom/css_style_declaration.zig index 359d9dc9..153baa8d 100644 --- a/src/browser/cssom/css_style_declaration.zig +++ b/src/browser/cssom/css_style_declaration.zig @@ -48,31 +48,24 @@ pub const CSSStyleDeclaration = struct { } pub fn get_cssText(self: *const CSSStyleDeclaration, state: *SessionState) ![]const u8 { - var buffer = std.ArrayList(u8).init(state.arena); - const writer = buffer.writer(); + var buffer = std.ArrayListUnmanaged(u8){}; + const writer = buffer.writer(state.call_arena); for (self.order.items) |name| { const prop = self.store.get(name).?; - const escaped = try CSSValueAnalyzer.escapeCSSValue(state.arena, prop.value); + const escaped = try CSSValueAnalyzer.escapeCSSValue(state.call_arena, prop.value); try writer.print("{s}: {s}", .{ name, escaped }); if (prop.priority) try writer.writeAll(" !important"); try writer.writeAll("; "); } - return buffer.toOwnedSlice(); + return buffer.items; } // TODO Propagate also upward to parent node pub fn set_cssText(self: *CSSStyleDeclaration, text: []const u8, state: *SessionState) !void { - var store_it = self.store.iterator(); - while (store_it.next()) |entry| { - state.arena.free(entry.key_ptr.*); - state.arena.free(entry.value_ptr.value); - } self.store.clearRetainingCapacity(); - for (self.order.items) |name| state.arena.free(name); self.order.clearRetainingCapacity(); - const declarations = try CSSParser.parseDeclarations(text, state.arena); - defer state.arena.free(declarations); + const declarations = try CSSParser.parseDeclarations(state.call_arena, text); for (declarations) |decl| { if (!CSSValueAnalyzer.isValidPropertyName(decl.name)) continue; @@ -104,13 +97,11 @@ pub const CSSStyleDeclaration = struct { pub fn _removeProperty(self: *CSSStyleDeclaration, name: []const u8, state: *SessionState) ![]const u8 { if (self.store.get(name)) |prop| { - const value_copy = try state.arena.dupe(u8, prop.value); + const value_copy = try state.call_arena.dupe(u8, prop.value); _ = self.store.remove(name); - state.arena.free(prop.value); var i: usize = 0; while (i < self.order.items.len) : (i += 1) { if (std.mem.eql(u8, self.order.items[i], name)) { - state.arena.free(self.order.items[i]); _ = self.order.orderedRemove(i); break; } @@ -130,8 +121,6 @@ pub const CSSStyleDeclaration = struct { try self.order.append(state.arena, name_copy); try self.store.put(state.arena, name_copy, Property{ .value = value_copy, .priority = is_important }); } else { - const prev = self.store.get(name).?; - state.arena.free(prev.value); try self.store.put(state.arena, name, Property{ .value = value_copy, .priority = is_important }); } } diff --git a/src/browser/cssom/css_value_analyzer.zig b/src/browser/cssom/css_value_analyzer.zig index 9b098aa8..36c4a0f2 100644 --- a/src/browser/cssom/css_value_analyzer.zig +++ b/src/browser/cssom/css_value_analyzer.zig @@ -39,8 +39,10 @@ pub const CSSValueAnalyzer = struct { } else if (c == '.' and !decimal_point) { decimal_point = true; } else if ((c == 'e' or c == 'E') and has_digit) { + if (i + 1 >= value.len) return false; + if (value[i + 1] != '+' and value[i + 1] != '-' and !std.ascii.isDigit(value[i + 1])) break; i += 1; - if (i < value.len and (value[i] == '+' or value[i] == '-')) { + if (value[i] == '+' or value[i] == '-') { i += 1; } var has_exp_digits = false; @@ -106,15 +108,39 @@ pub const CSSValueAnalyzer = struct { pub fn isValidPropertyName(name: []const u8) bool { if (name.len == 0) return false; + if (std.mem.startsWith(u8, name, "--")) { + if (name.len == 2) return false; + for (name[2..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') { + return false; + } + } + return true; + } + const first_char = name[0]; if (!std.ascii.isAlphabetic(first_char) and first_char != '-') { return false; } - for (name[1..]) |c| { - if (!std.ascii.isAlphanumeric(c) and c != '-') { + if (first_char == '-') { + if (name.len < 2) return false; + + if (!std.ascii.isAlphabetic(name[1])) { return false; } + + for (name[2..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-') { + return false; + } + } + } else { + for (name[1..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-') { + return false; + } + } } return true; @@ -149,13 +175,12 @@ pub const CSSValueAnalyzer = struct { } pub fn escapeCSSValue(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { - var out = std.ArrayList(u8).init(allocator); - errdefer out.deinit(); - const writer = out.writer(); + var out = std.ArrayListUnmanaged(u8){}; + const writer = out.writer(allocator); if (isAlreadyQuoted(value)) { try writer.writeAll(value); - return out.toOwnedSlice(); + return out.items; } const needs_quotes = needsQuotes(value); @@ -163,7 +188,7 @@ pub const CSSValueAnalyzer = struct { if (needs_quotes) { try writer.writeByte('"'); - for (value) |c| { + for (value, 0..) |c, i| { switch (c) { '"' => try writer.writeAll("\\\""), '\\' => try writer.writeAll("\\\\"), @@ -172,7 +197,7 @@ pub const CSSValueAnalyzer = struct { '\t' => try writer.writeAll("\\9 "), 0...8, 11, 12, 14...31, 127 => { try writer.print("\\{x}", .{c}); - if (c + 1 < value.len and std.ascii.isHex(value[c + 1])) { + if (i + 1 < value.len and std.ascii.isHex(value[i + 1])) { try writer.writeByte(' '); } }, @@ -185,7 +210,7 @@ pub const CSSValueAnalyzer = struct { try writer.writeAll(value); } - return out.toOwnedSlice(); + return out.items; } pub fn isKnownKeyword(value: []const u8) bool { @@ -198,84 +223,84 @@ pub const CSSValueAnalyzer = struct { }; const CSSKeywords = struct { - const BorderStyles = [_][]const u8{ + const border_styles = [_][]const u8{ "none", "solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset", }; - const ColorNames = [_][]const u8{ + const color_names = [_][]const u8{ "black", "white", "red", "green", "blue", "yellow", "purple", "gray", "transparent", "currentColor", "inherit", }; - const PositionKeywords = [_][]const u8{ + const position_keywords = [_][]const u8{ "auto", "center", "left", "right", "top", "bottom", }; - const BackgroundRepeat = [_][]const u8{ + const background_repeat = [_][]const u8{ "repeat", "no-repeat", "repeat-x", "repeat-y", "space", "round", }; - const FontStyles = [_][]const u8{ + const font_styles = [_][]const u8{ "normal", "italic", "oblique", "bold", "bolder", "lighter", }; - const FontSizes = [_][]const u8{ + const font_sizes = [_][]const u8{ "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "smaller", "larger", }; - const FontFamilies = [_][]const u8{ + const font_families = [_][]const u8{ "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", }; - const CSSGlobal = [_][]const u8{ + const css_global = [_][]const u8{ "initial", "inherit", "unset", "revert", }; - const DisplayValues = [_][]const u8{ + const display_values = [_][]const u8{ "block", "inline", "inline-block", "flex", "grid", "none", }; - const LengthUnits = [_][]const u8{ + const length_units = [_][]const u8{ "px", "em", "rem", "vw", "vh", "vmin", "vmax", "%", "pt", "pc", "in", "cm", "mm", "ex", "ch", "fr", }; - const AngleUnits = [_][]const u8{ + const angle_units = [_][]const u8{ "deg", "rad", "grad", "turn", }; - const TimeUnits = [_][]const u8{ + const time_units = [_][]const u8{ "s", "ms", }; - const FrequencyUnits = [_][]const u8{ + const frequency_units = [_][]const u8{ "Hz", "kHz", }; - const ResolutionUnits = [_][]const u8{ + const resolution_units = [_][]const u8{ "dpi", "dpcm", "dppx", }; - const SpecialChars = [_]u8{ - '"', '\'', ';', '{', '}', '\\', '<', '>', '/', + const special_chars = [_]u8{ + '"', '\'', ';', '{', '}', '\\', '<', '>', '/', '\n', '\t', '\r', '\x00', '\x7F', }; - const Functions = [_][]const u8{ - "rgb", "rgba", "hsl", "hsla", "url", "calc", "var", "attr", - "linear-gradient", "radial-gradient", "conic-gradient", "translate", "rotate", "scale", "skew", "matrix", + const functions = [_][]const u8{ + "rgb(", "rgba(", "hsl(", "hsla(", "url(", "calc(", "var(", "attr(", + "linear-gradient(", "radial-gradient(", "conic-gradient(", "translate(", "rotate(", "scale(", "skew(", "matrix(", }; pub fn isKnownKeyword(value: []const u8) bool { const all_categories = [_][]const []const u8{ - &BorderStyles, &ColorNames, &PositionKeywords, &BackgroundRepeat, - &FontStyles, &FontSizes, &FontFamilies, &CSSGlobal, - &DisplayValues, + &border_styles, &color_names, &position_keywords, &background_repeat, + &font_styles, &font_sizes, &font_families, &css_global, + &display_values, }; for (all_categories) |category| { for (category) |keyword| { - if (std.mem.eql(u8, value, keyword)) { + if (std.ascii.eqlIgnoreCase(value, keyword)) { return true; } } @@ -286,7 +311,7 @@ const CSSKeywords = struct { pub fn containsSpecialChar(value: []const u8) bool { for (value) |c| { - for (SpecialChars) |special| { + for (special_chars) |special| { if (c == special) { return true; } @@ -297,12 +322,12 @@ const CSSKeywords = struct { pub fn isValidUnit(unit: []const u8) bool { const all_units = [_][]const []const u8{ - &LengthUnits, &AngleUnits, &TimeUnits, &FrequencyUnits, &ResolutionUnits, + &length_units, &angle_units, &time_units, &frequency_units, &resolution_units, }; for (all_units) |category| { for (category) |valid_unit| { - if (std.mem.eql(u8, unit, valid_unit)) { + if (std.ascii.eqlIgnoreCase(unit, valid_unit)) { return true; } } @@ -312,16 +337,479 @@ const CSSKeywords = struct { } pub fn startsWithFunction(value: []const u8) bool { - for (Functions) |func| { - if (value.len >= func.len + 1 and - std.mem.startsWith(u8, value, func) and - value[func.len] == '(') - { - return true; + const open_paren = std.mem.indexOf(u8, value, "("); + const close_paren = std.mem.indexOf(u8, value, ")"); + + if (open_paren == null or close_paren == null) return false; + if (open_paren == 0) return false; + + const function_name = value[0..open_paren.?]; + return isValidFunctionName(function_name); + } + + fn isValidFunctionName(name: []const u8) bool { + if (name.len == 0) return false; + + const first = name[0]; + if (!std.ascii.isAlphabetic(first) and first != '_' and first != '-') { + return false; + } + + for (name[1..]) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-') { + return false; } } - return std.mem.indexOf(u8, value, "(") != null and - std.mem.indexOf(u8, value, ")") != null; + return true; } }; + +const testing = @import("../../testing.zig"); + +test "isNumericWithUnit - valid numbers with units" { + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10px")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14em")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5rem")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("+12.5%")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0vh")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".5vw")); +} + +test "isNumericWithUnit - scientific notation" { + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e5px")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("2.5E-3em")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e+2rem")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-3.14e10px")); +} + +test "isNumericWithUnit - edge cases and invalid inputs" { + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("")); + + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("px")); + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("--px")); + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(".px")); + + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e")); + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1epx")); + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+")); + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+px")); + + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1.2.3px")); + + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10xyz")); + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("5invalid")); + + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5")); +} + +test "isHexColor - valid hex colors" { + try testing.expect(CSSValueAnalyzer.isHexColor("#000")); + try testing.expect(CSSValueAnalyzer.isHexColor("#fff")); + try testing.expect(CSSValueAnalyzer.isHexColor("#123456")); + try testing.expect(CSSValueAnalyzer.isHexColor("#abcdef")); + try testing.expect(CSSValueAnalyzer.isHexColor("#ABCDEF")); + try testing.expect(CSSValueAnalyzer.isHexColor("#12345678")); +} + +test "isHexColor - invalid hex colors" { + try testing.expect(!CSSValueAnalyzer.isHexColor("")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#")); + try testing.expect(!CSSValueAnalyzer.isHexColor("000")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#00")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#0000")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#00000")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#0000000")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#000000000")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#gggggg")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#123xyz")); +} + +test "isMultiValueProperty - valid multi-value properties" { + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px")); + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red")); + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("#fff black")); + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("1em 2em 3em 4em")); + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) solid")); +} + +test "isMultiValueProperty - invalid multi-value properties" { + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("")); + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px")); + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("invalid unknown")); + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px invalid")); + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(" ")); +} + +test "isAlreadyQuoted - various quoting scenarios" { + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\"")); + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'world'")); + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"\"")); + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("''")); + + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello'")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello\"")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\"")); +} + +test "isValidPropertyName - valid property names" { + try testing.expect(CSSValueAnalyzer.isValidPropertyName("color")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("background-color")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("font-size")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("margin-top")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("z-index")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("line-height")); +} + +test "isValidPropertyName - invalid property names" { + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("")); + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("123color")); + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color!")); + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color space")); + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("@color")); + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color.test")); + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color_test")); +} + +test "extractImportant - with and without !important" { + var result = CSSValueAnalyzer.extractImportant("red !important"); + try testing.expect(result.is_important); + try testing.expectEqual("red", result.value); + + result = CSSValueAnalyzer.extractImportant("blue"); + try testing.expect(!result.is_important); + try testing.expectEqual("blue", result.value); + + result = CSSValueAnalyzer.extractImportant(" green !important "); + try testing.expect(result.is_important); + try testing.expectEqual("green", result.value); + + result = CSSValueAnalyzer.extractImportant("!important"); + try testing.expect(result.is_important); + try testing.expectEqual("", result.value); + + result = CSSValueAnalyzer.extractImportant("important"); + try testing.expect(!result.is_important); + try testing.expectEqual("important", result.value); +} + +test "needsQuotes - various scenarios" { + try testing.expect(CSSValueAnalyzer.needsQuotes("")); + try testing.expect(CSSValueAnalyzer.needsQuotes("hello world")); + try testing.expect(CSSValueAnalyzer.needsQuotes("test;")); + try testing.expect(CSSValueAnalyzer.needsQuotes("a{b}")); + try testing.expect(CSSValueAnalyzer.needsQuotes("test\"quote")); + + try testing.expect(!CSSValueAnalyzer.needsQuotes("\"already quoted\"")); + try testing.expect(!CSSValueAnalyzer.needsQuotes("'already quoted'")); + try testing.expect(!CSSValueAnalyzer.needsQuotes("url(image.png)")); + try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)")); + try testing.expect(!CSSValueAnalyzer.needsQuotes("10px 20px")); + try testing.expect(!CSSValueAnalyzer.needsQuotes("simple")); +} + +test "escapeCSSValue - escaping various characters" { + const allocator = testing.arena_allocator; + + var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "simple"); + try testing.expectEqual("simple", result); + + result = try CSSValueAnalyzer.escapeCSSValue(allocator, "\"already quoted\""); + try testing.expectEqual("\"already quoted\"", result); + + result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote"); + try testing.expectEqual("\"test\\\"quote\"", result); + + result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\nline"); + try testing.expectEqual("\"test\\A line\"", result); + + result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\\back"); + try testing.expectEqual("\"test\\\\back\"", result); +} + +test "CSSKeywords.isKnownKeyword - case sensitivity" { + try testing.expect(CSSKeywords.isKnownKeyword("red")); + try testing.expect(CSSKeywords.isKnownKeyword("solid")); + try testing.expect(CSSKeywords.isKnownKeyword("center")); + try testing.expect(CSSKeywords.isKnownKeyword("inherit")); + + try testing.expect(CSSKeywords.isKnownKeyword("RED")); + try testing.expect(CSSKeywords.isKnownKeyword("Red")); + try testing.expect(CSSKeywords.isKnownKeyword("SOLID")); + try testing.expect(CSSKeywords.isKnownKeyword("Center")); + + try testing.expect(!CSSKeywords.isKnownKeyword("invalid")); + try testing.expect(!CSSKeywords.isKnownKeyword("unknown")); + try testing.expect(!CSSKeywords.isKnownKeyword("")); +} + +test "CSSKeywords.containsSpecialChar - various special characters" { + try testing.expect(CSSKeywords.containsSpecialChar("test\"quote")); + try testing.expect(CSSKeywords.containsSpecialChar("test'quote")); + try testing.expect(CSSKeywords.containsSpecialChar("test;end")); + try testing.expect(CSSKeywords.containsSpecialChar("test{brace")); + try testing.expect(CSSKeywords.containsSpecialChar("test}brace")); + try testing.expect(CSSKeywords.containsSpecialChar("test\\back")); + try testing.expect(CSSKeywords.containsSpecialChar("testangle")); + try testing.expect(CSSKeywords.containsSpecialChar("test/slash")); + + try testing.expect(!CSSKeywords.containsSpecialChar("normal-text")); + try testing.expect(!CSSKeywords.containsSpecialChar("text123")); + try testing.expect(!CSSKeywords.containsSpecialChar("")); +} + +test "CSSKeywords.isValidUnit - various units" { + try testing.expect(CSSKeywords.isValidUnit("px")); + try testing.expect(CSSKeywords.isValidUnit("em")); + try testing.expect(CSSKeywords.isValidUnit("rem")); + try testing.expect(CSSKeywords.isValidUnit("%")); + + try testing.expect(CSSKeywords.isValidUnit("deg")); + try testing.expect(CSSKeywords.isValidUnit("rad")); + + try testing.expect(CSSKeywords.isValidUnit("s")); + try testing.expect(CSSKeywords.isValidUnit("ms")); + + try testing.expect(CSSKeywords.isValidUnit("PX")); + + try testing.expect(!CSSKeywords.isValidUnit("invalid")); + try testing.expect(!CSSKeywords.isValidUnit("")); +} + +test "CSSKeywords.startsWithFunction - function detection" { + try testing.expect(CSSKeywords.startsWithFunction("rgb(255, 0, 0)")); + try testing.expect(CSSKeywords.startsWithFunction("rgba(255, 0, 0, 0.5)")); + try testing.expect(CSSKeywords.startsWithFunction("url(image.png)")); + try testing.expect(CSSKeywords.startsWithFunction("calc(100% - 20px)")); + try testing.expect(CSSKeywords.startsWithFunction("var(--custom-property)")); + try testing.expect(CSSKeywords.startsWithFunction("linear-gradient(to right, red, blue)")); + + try testing.expect(CSSKeywords.startsWithFunction("custom-function(args)")); + try testing.expect(CSSKeywords.startsWithFunction("unknown(test)")); + + try testing.expect(!CSSKeywords.startsWithFunction("not-a-function")); + try testing.expect(!CSSKeywords.startsWithFunction("missing-paren)")); + try testing.expect(!CSSKeywords.startsWithFunction("missing-close(")); + try testing.expect(!CSSKeywords.startsWithFunction("")); + try testing.expect(!CSSKeywords.startsWithFunction("rgb")); +} + +test "isNumericWithUnit - whitespace handling" { + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10px")); + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10 px")); + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10px ")); + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10 px ")); +} + +test "extractImportant - whitespace edge cases" { + var result = CSSValueAnalyzer.extractImportant(" "); + try testing.expect(!result.is_important); + try testing.expectEqual("", result.value); + + result = CSSValueAnalyzer.extractImportant("\t\n\r !important\t\n"); + try testing.expect(result.is_important); + try testing.expectEqual("", result.value); + + result = CSSValueAnalyzer.extractImportant("red\t!important"); + try testing.expect(result.is_important); + try testing.expectEqual("red", result.value); +} + +test "isHexColor - mixed case handling" { + try testing.expect(CSSValueAnalyzer.isHexColor("#AbC")); + try testing.expect(CSSValueAnalyzer.isHexColor("#123aBc")); + try testing.expect(CSSValueAnalyzer.isHexColor("#FFffFF")); + try testing.expect(CSSValueAnalyzer.isHexColor("#000FFF")); +} + +test "edge case - very long inputs" { + const long_valid = "a" ** 1000 ++ "px"; + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(long_valid)); // not numeric + + const long_property = "a-" ** 100 ++ "property"; + try testing.expect(CSSValueAnalyzer.isValidPropertyName(long_property)); + + const long_hex = "#" ++ "a" ** 20; + try testing.expect(!CSSValueAnalyzer.isHexColor(long_hex)); +} + +test "boundary conditions - numeric parsing" { + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0px")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.0px")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".0px")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.px")); + + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("999999999px")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1.7976931348623157e+308px")); + + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.000000001px")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e-100px")); +} + +test "extractImportant - malformed important declarations" { + var result = CSSValueAnalyzer.extractImportant("red ! important"); + try testing.expect(!result.is_important); + try testing.expectEqual("red ! important", result.value); + + result = CSSValueAnalyzer.extractImportant("red !Important"); + try testing.expect(!result.is_important); + try testing.expectEqual("red !Important", result.value); + + result = CSSValueAnalyzer.extractImportant("red !IMPORTANT"); + try testing.expect(!result.is_important); + try testing.expectEqual("red !IMPORTANT", result.value); + + result = CSSValueAnalyzer.extractImportant("!importantred"); + try testing.expect(!result.is_important); + try testing.expectEqual("!importantred", result.value); + + result = CSSValueAnalyzer.extractImportant("red !important !important"); + try testing.expect(result.is_important); + try testing.expectEqual("red !important", result.value); +} + +test "isMultiValueProperty - complex spacing scenarios" { + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px")); + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red")); + + try testing.expect(CSSValueAnalyzer.isMultiValueProperty(" 10px 20px ")); + + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\t20px")); + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\n20px")); + + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px 30px")); +} + +test "isAlreadyQuoted - edge cases with quotes" { + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"'hello'\"")); + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'\"hello\"'")); + + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\\\"world\"")); + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'hello\\'world'")); + + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\"")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello'")); + + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"a\"")); + try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'b'")); +} + +test "needsQuotes - function and URL edge cases" { + try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)")); + try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)")); + + try testing.expect(!CSSValueAnalyzer.needsQuotes("url(path with spaces.jpg)")); + + try testing.expect(!CSSValueAnalyzer.needsQuotes("linear-gradient(to right, red, blue)")); + + try testing.expect(CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0")); +} + +test "escapeCSSValue - control characters and Unicode" { + const allocator = testing.arena_allocator; + + var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\ttab"); + try testing.expectEqual("\"test\\9 tab\"", result); + + result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\rreturn"); + try testing.expectEqual("\"test\\D return\"", result); + + result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x00null"); + try testing.expectEqual("\"test\\0null\"", result); + + result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x7Fdel"); + try testing.expectEqual("\"test\\7f del\"", result); + + result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote\nline\\back"); + try testing.expectEqual("\"test\\\"quote\\A line\\\\back\"", result); + allocator.free(result); +} + +test "isValidPropertyName - CSS custom properties and vendor prefixes" { + try testing.expect(CSSValueAnalyzer.isValidPropertyName("--custom-color")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("--my-variable")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("--123")); + + try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("-moz-border-radius")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("-ms-filter")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("-o-transition")); + + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-123invalid")); + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("--")); + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-")); +} + +test "startsWithFunction - case sensitivity and partial matches" { + try testing.expect(CSSKeywords.startsWithFunction("RGB(255, 0, 0)")); + try testing.expect(CSSKeywords.startsWithFunction("Rgb(255, 0, 0)")); + try testing.expect(CSSKeywords.startsWithFunction("URL(image.png)")); + + try testing.expect(CSSKeywords.startsWithFunction("rg(something)")); + try testing.expect(CSSKeywords.startsWithFunction("ur(something)")); + + try testing.expect(CSSKeywords.startsWithFunction("rgb(1,2,3)")); + try testing.expect(CSSKeywords.startsWithFunction("rgba(1,2,3,4)")); + + try testing.expect(CSSKeywords.startsWithFunction("my-custom-function(args)")); + try testing.expect(CSSKeywords.startsWithFunction("function-with-dashes(test)")); + + try testing.expect(!CSSKeywords.startsWithFunction("123function(test)")); +} + +test "isHexColor - Unicode and invalid characters" { + try testing.expect(!CSSValueAnalyzer.isHexColor("#ghijkl")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#12345g")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#xyz")); + + try testing.expect(!CSSValueAnalyzer.isHexColor("#АВС")); + + try testing.expect(!CSSValueAnalyzer.isHexColor("#1234567g")); + try testing.expect(!CSSValueAnalyzer.isHexColor("#g2345678")); +} + +test "complex integration scenarios" { + const allocator = testing.arena_allocator; + + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) url(bg.jpg)")); + + try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)")); + + const result = try CSSValueAnalyzer.escapeCSSValue(allocator, "fake(function with spaces"); + try testing.expectEqual("\"fake(function with spaces\"", result); + + const important_result = CSSValueAnalyzer.extractImportant("rgb(255,0,0) !important"); + try testing.expect(important_result.is_important); + try testing.expectEqual("rgb(255,0,0)", important_result.value); +} + +test "performance edge cases - empty and minimal inputs" { + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("")); + try testing.expect(!CSSValueAnalyzer.isHexColor("")); + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("")); + try testing.expect(!CSSValueAnalyzer.isValidPropertyName("")); + try testing.expect(CSSValueAnalyzer.needsQuotes("")); + try testing.expect(!CSSKeywords.isKnownKeyword("")); + try testing.expect(!CSSKeywords.containsSpecialChar("")); + try testing.expect(!CSSKeywords.isValidUnit("")); + try testing.expect(!CSSKeywords.startsWithFunction("")); + + try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("a")); + try testing.expect(!CSSValueAnalyzer.isHexColor("a")); + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("a")); + try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("a")); + try testing.expect(CSSValueAnalyzer.isValidPropertyName("a")); + try testing.expect(!CSSValueAnalyzer.needsQuotes("a")); +} From 5dbdf8321a1b8b7fb70921cd7674e72db73728ab Mon Sep 17 00:00:00 2001 From: Raph Date: Sat, 24 May 2025 02:13:08 +0200 Subject: [PATCH 4/8] removed unnecessary call to free --- src/browser/cssom/css_value_analyzer.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/browser/cssom/css_value_analyzer.zig b/src/browser/cssom/css_value_analyzer.zig index 36c4a0f2..394421b9 100644 --- a/src/browser/cssom/css_value_analyzer.zig +++ b/src/browser/cssom/css_value_analyzer.zig @@ -733,7 +733,6 @@ test "escapeCSSValue - control characters and Unicode" { result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote\nline\\back"); try testing.expectEqual("\"test\\\"quote\\A line\\\\back\"", result); - allocator.free(result); } test "isValidPropertyName - CSS custom properties and vendor prefixes" { From a2e0fd28e0bc8638391de3cc036f87867fe94dc5 Mon Sep 17 00:00:00 2001 From: Raph Date: Sat, 24 May 2025 02:20:15 +0200 Subject: [PATCH 5/8] added basic style test to HTMLElement --- src/browser/html/elements.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index 498f4387..0dc55391 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -1064,4 +1064,14 @@ test "Browser.HTML.Element" { .{ "document.getElementById('content').click()", "undefined" }, .{ "click_count", "1" }, }, .{}); + + try runner.testCases(&.{ + .{ "let style = document.getElementById('content').style", "undefined" }, + .{ "style.cssText = 'color: red; font-size: 12px; margin: 5px !important;'", "color: red; font-size: 12px; margin: 5px !important;" }, + .{ "style.length", "3" }, + .{ "style.setProperty('background-color', 'blue')", "undefined" }, + .{ "style.getPropertyValue('background-color')", "blue" }, + .{ "style.length", "4" }, + }, .{}); + } From 6ce24b34432acf900850674ecd4892126f3b8ab3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 24 May 2025 10:08:26 +0800 Subject: [PATCH 6/8] Rename `allocator` to `arena` to make the intent more clear Use expectEqual where possible deduplicate finalize and finishDeclaration --- src/browser/cssom/css_parser.zig | 77 ++++++++++---------------------- 1 file changed, 24 insertions(+), 53 deletions(-) diff --git a/src/browser/cssom/css_parser.zig b/src/browser/cssom/css_parser.zig index 6a4bfdf3..ac158044 100644 --- a/src/browser/cssom/css_parser.zig +++ b/src/browser/cssom/css_parser.zig @@ -17,6 +17,7 @@ // along with this program. If not, see . const std = @import("std"); +const Allocator = std.mem.Allocator; const CSSConstants = struct { const IMPORTANT = "!important"; @@ -62,9 +63,9 @@ pub const CSSParser = struct { }; } - pub fn parseDeclarations(allocator: std.mem.Allocator, text: []const u8) ![]CSSDeclaration { + pub fn parseDeclarations(arena: Allocator, text: []const u8) ![]CSSDeclaration { var parser = init(); - var declarations = std.ArrayListUnmanaged(CSSDeclaration){}; + var declarations: std.ArrayListUnmanaged(CSSDeclaration) = .empty; while (parser.position < text.len) { const c = text[parser.position]; @@ -121,7 +122,7 @@ pub const CSSParser = struct { } else if (c == ')' and parser.paren_depth > 0) { parser.paren_depth -= 1; } else if (c == ';' and parser.paren_depth == 0) { - try parser.finishDeclaration(allocator, &declarations, text); + try parser.finishDeclaration(arena, &declarations, text); parser.state = .seek_name; } }, @@ -163,12 +164,12 @@ pub const CSSParser = struct { parser.position += 1; } - try parser.finalize(allocator, &declarations, text); + try parser.finalize(arena, &declarations, text); return declarations.items; } - fn finishDeclaration(self: *CSSParser, allocator: std.mem.Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { + fn finishDeclaration(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { const name = std.mem.trim(u8, text[self.name_start..self.name_end], &std.ascii.whitespace); if (name.len == 0) return; @@ -180,51 +181,21 @@ pub const CSSParser = struct { if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT)) { is_important = true; - final_value = std.mem.trim(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace); + final_value = std.mem.trimRight(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace); } - try declarations.append(allocator, .{ + try declarations.append(arena, .{ .name = name, .value = final_value, .is_important = is_important, }); } - fn finalize(self: *CSSParser, allocator: std.mem.Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { - if (self.state == .in_value) { - const name = text[self.name_start..self.name_end]; - const trimmed_name = std.mem.trim(u8, name, &std.ascii.whitespace); - - if (trimmed_name.len > 0) { - const raw_value = text[self.value_start..self.position]; - const value = std.mem.trim(u8, raw_value, &std.ascii.whitespace); - - var final_value = value; - var is_important = false; - if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT)) { - is_important = true; - final_value = std.mem.trim(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace); - } - - try declarations.append(allocator, .{ - .name = trimmed_name, - .value = final_value, - .is_important = is_important, - }); - } + fn finalize(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void { + if (self.state != .in_value) { + return; } - } - - pub fn getState(self: *const CSSParser) CSSParserState { - return self.state; - } - - pub fn getPosition(self: *const CSSParser) usize { - return self.position; - } - - pub fn reset(self: *CSSParser) void { - self.* = init(); + return self.finishDeclaration(arena, declarations, text); } }; @@ -238,10 +209,10 @@ test "CSSParser - Simple property" { const declarations = try CSSParser.parseDeclarations(allocator, text); - try testing.expect(declarations.len == 1); + try testing.expectEqual(1, declarations.len); try testing.expectEqual("color", declarations[0].name); try testing.expectEqual("red", declarations[0].value); - try testing.expect(!declarations[0].is_important); + try testing.expectEqual(false, declarations[0].is_important); } test "CSSParser - Property with !important" { @@ -251,10 +222,10 @@ test "CSSParser - Property with !important" { const declarations = try CSSParser.parseDeclarations(allocator, text); - try testing.expect(declarations.len == 1); + try testing.expectEqual(1, declarations.len); try testing.expectEqual("margin", declarations[0].name); try testing.expectEqual("10px", declarations[0].value); - try testing.expect(declarations[0].is_important); + try testing.expectEqual(true, declarations[0].is_important); } test "CSSParser - Multiple properties" { @@ -268,15 +239,15 @@ test "CSSParser - Multiple properties" { try testing.expectEqual("color", declarations[0].name); try testing.expectEqual("red", declarations[0].value); - try testing.expect(!declarations[0].is_important); + try testing.expectEqual(false, declarations[0].is_important); try testing.expectEqual("font-size", declarations[1].name); try testing.expectEqual("12px", declarations[1].value); - try testing.expect(!declarations[1].is_important); + try testing.expectEqual(false, declarations[1].is_important); try testing.expectEqual("margin", declarations[2].name); try testing.expectEqual("5px", declarations[2].value); - try testing.expect(declarations[2].is_important); + try testing.expectEqual(true, declarations[2].is_important); } test "CSSParser - Quoted value with semicolon" { @@ -286,10 +257,10 @@ test "CSSParser - Quoted value with semicolon" { const declarations = try CSSParser.parseDeclarations(allocator, text); - try testing.expect(declarations.len == 1); + try testing.expectEqual(1, declarations.len); try testing.expectEqual("content", declarations[0].name); try testing.expectEqual("\"Hello; world!\"", declarations[0].value); - try testing.expect(!declarations[0].is_important); + try testing.expectEqual(false, declarations[0].is_important); } test "CSSParser - URL value" { @@ -299,10 +270,10 @@ test "CSSParser - URL value" { const declarations = try CSSParser.parseDeclarations(allocator, text); - try testing.expect(declarations.len == 1); + try testing.expectEqual(1, declarations.len); try testing.expectEqual("background-image", declarations[0].name); try testing.expectEqual("url(\"test.png\")", declarations[0].value); - try testing.expect(!declarations[0].is_important); + try testing.expectEqual(false, declarations[0].is_important); } test "CSSParser - Whitespace handling" { @@ -312,7 +283,7 @@ test "CSSParser - Whitespace handling" { const declarations = try CSSParser.parseDeclarations(allocator, text); - try testing.expect(declarations.len == 2); + try testing.expectEqual(2, declarations.len); try testing.expectEqual("color", declarations[0].name); try testing.expectEqual("purple", declarations[0].value); try testing.expectEqual("margin", declarations[1].name); From 1fe2bf5dd51f9977a670d6ace0e27897417916d4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 24 May 2025 10:23:37 +0800 Subject: [PATCH 7/8] Use fetchRemove and getOrPut to streamline map manipulation --- src/browser/cssom/css_style_declaration.zig | 40 ++++++++++----------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/browser/cssom/css_style_declaration.zig b/src/browser/cssom/css_style_declaration.zig index 153baa8d..e3103dc0 100644 --- a/src/browser/cssom/css_style_declaration.zig +++ b/src/browser/cssom/css_style_declaration.zig @@ -48,7 +48,7 @@ pub const CSSStyleDeclaration = struct { } pub fn get_cssText(self: *const CSSStyleDeclaration, state: *SessionState) ![]const u8 { - var buffer = std.ArrayListUnmanaged(u8){}; + var buffer: std.ArrayListUnmanaged(u8) = .empty; const writer = buffer.writer(state.call_arena); for (self.order.items) |name| { const prop = self.store.get(name).?; @@ -65,6 +65,8 @@ pub const CSSStyleDeclaration = struct { self.store.clearRetainingCapacity(); self.order.clearRetainingCapacity(); + // call_arena is safe here, because _setProperty will dupe the name + // using the state's longer-living arena. const declarations = try CSSParser.parseDeclarations(state.call_arena, text); for (declarations) |decl| { @@ -95,34 +97,30 @@ pub const CSSStyleDeclaration = struct { return if (index < self.order.items.len) self.order.items[index] else ""; } - pub fn _removeProperty(self: *CSSStyleDeclaration, name: []const u8, state: *SessionState) ![]const u8 { - if (self.store.get(name)) |prop| { - const value_copy = try state.call_arena.dupe(u8, prop.value); - _ = self.store.remove(name); - var i: usize = 0; - while (i < self.order.items.len) : (i += 1) { - if (std.mem.eql(u8, self.order.items[i], name)) { - _ = self.order.orderedRemove(i); - break; - } + pub fn _removeProperty(self: *CSSStyleDeclaration, name: []const u8) ![]const u8 { + const prop = self.store.fetchRemove(name) orelse return ""; + for (self.order.items, 0..) |item, i| { + if (std.mem.eql(u8, item, name)) { + _ = self.order.orderedRemove(i); + break; } - return value_copy; } - return ""; + // safe to return, since it's in our state.arena + return prop.value.value; } pub fn _setProperty(self: *CSSStyleDeclaration, name: []const u8, value: []const u8, priority: ?[]const u8, state: *SessionState) !void { + const owned_value = try state.arena.dupe(u8, value); const is_important = priority != null and std.ascii.eqlIgnoreCase(priority.?, "important"); - const value_copy = try state.arena.dupe(u8, value); - - if (!self.store.contains(name)) { - const name_copy = try state.arena.dupe(u8, name); - try self.order.append(state.arena, name_copy); - try self.store.put(state.arena, name_copy, Property{ .value = value_copy, .priority = is_important }); - } else { - try self.store.put(state.arena, name, Property{ .value = value_copy, .priority = is_important }); + const gop = try self.store.getOrPut(state.arena, name); + if (!gop.found_existing) { + const owned_name = try state.arena.dupe(u8, name); + gop.key_ptr.* = owned_name; + try self.order.append(state.arena, owned_name); } + + gop.value_ptr.* = .{ .value = owned_value, .priority = is_important }; } }; From bed394db801439a5afe2dc658fd9d66410fe18ad Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 24 May 2025 11:44:29 +0800 Subject: [PATCH 8/8] Prefix tests (easier to filter, i.e. make test F="CSSValue") Don't dupe value if it doesn't need to be quoted. --- src/browser/cssom/css_value_analyzer.zig | 145 +++++++++++------------ 1 file changed, 71 insertions(+), 74 deletions(-) diff --git a/src/browser/cssom/css_value_analyzer.zig b/src/browser/cssom/css_value_analyzer.zig index 394421b9..1fb35ed2 100644 --- a/src/browser/cssom/css_value_analyzer.zig +++ b/src/browser/cssom/css_value_analyzer.zig @@ -150,7 +150,7 @@ pub const CSSValueAnalyzer = struct { const trimmed = std.mem.trim(u8, value, &std.ascii.whitespace); if (std.mem.endsWith(u8, trimmed, "!important")) { - const clean_value = std.mem.trim(u8, trimmed[0 .. trimmed.len - 10], &std.ascii.whitespace); + const clean_value = std.mem.trimRight(u8, trimmed[0 .. trimmed.len - 10], &std.ascii.whitespace); return .{ .value = clean_value, .is_important = true }; } @@ -161,55 +161,52 @@ pub const CSSValueAnalyzer = struct { if (value.len == 0) return true; if (isAlreadyQuoted(value)) return false; - const has_spaces = std.mem.indexOf(u8, value, " ") != null; - const has_special_chars = CSSKeywords.containsSpecialChar(value); + if (CSSKeywords.containsSpecialChar(value)) { + return true; + } + + if (std.mem.indexOfScalar(u8, value, ' ') == null) { + return false; + } + const is_url = std.mem.startsWith(u8, value, "url("); const is_function = CSSKeywords.startsWithFunction(value); - const space_requires_quotes = has_spaces and - !isMultiValueProperty(value) and + return !isMultiValueProperty(value) and !is_url and !is_function; - - return has_special_chars or space_requires_quotes; } - pub fn escapeCSSValue(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { - var out = std.ArrayListUnmanaged(u8){}; - const writer = out.writer(allocator); - - if (isAlreadyQuoted(value)) { - try writer.writeAll(value); - return out.items; + pub fn escapeCSSValue(arena: std.mem.Allocator, value: []const u8) ![]const u8 { + if (!needsQuotes(value)) { + return value; } + var out: std.ArrayListUnmanaged(u8) = .empty; - const needs_quotes = needsQuotes(value); + // We'll need at least this much space, +2 for the quotes + try out.ensureTotalCapacity(arena, value.len + 2); + const writer = out.writer(arena); - if (needs_quotes) { - try writer.writeByte('"'); + try writer.writeByte('"'); - for (value, 0..) |c, i| { - switch (c) { - '"' => try writer.writeAll("\\\""), - '\\' => try writer.writeAll("\\\\"), - '\n' => try writer.writeAll("\\A "), - '\r' => try writer.writeAll("\\D "), - '\t' => try writer.writeAll("\\9 "), - 0...8, 11, 12, 14...31, 127 => { - try writer.print("\\{x}", .{c}); - if (i + 1 < value.len and std.ascii.isHex(value[i + 1])) { - try writer.writeByte(' '); - } - }, - else => try writer.writeByte(c), - } + for (value, 0..) |c, i| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\A "), + '\r' => try writer.writeAll("\\D "), + '\t' => try writer.writeAll("\\9 "), + 0...8, 11, 12, 14...31, 127 => { + try writer.print("\\{x}", .{c}); + if (i + 1 < value.len and std.ascii.isHex(value[i + 1])) { + try writer.writeByte(' '); + } + }, + else => try writer.writeByte(c), } - - try writer.writeByte('"'); - } else { - try writer.writeAll(value); } + try writer.writeByte('"'); return out.items; } @@ -337,13 +334,13 @@ const CSSKeywords = struct { } pub fn startsWithFunction(value: []const u8) bool { - const open_paren = std.mem.indexOf(u8, value, "("); - const close_paren = std.mem.indexOf(u8, value, ")"); + const pos = std.mem.indexOfScalar(u8, value, '(') orelse return false; + if (pos == 0) return false; - if (open_paren == null or close_paren == null) return false; - if (open_paren == 0) return false; - - const function_name = value[0..open_paren.?]; + if (std.mem.indexOfScalarPos(u8, value, pos, ')') == null) { + return false; + } + const function_name = value[0..pos]; return isValidFunctionName(function_name); } @@ -367,7 +364,7 @@ const CSSKeywords = struct { const testing = @import("../../testing.zig"); -test "isNumericWithUnit - valid numbers with units" { +test "CSSValueAnalyzer: isNumericWithUnit - valid numbers with units" { try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10px")); try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14em")); try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5rem")); @@ -376,14 +373,14 @@ test "isNumericWithUnit - valid numbers with units" { try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".5vw")); } -test "isNumericWithUnit - scientific notation" { +test "CSSValueAnalyzer: isNumericWithUnit - scientific notation" { try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e5px")); try testing.expect(CSSValueAnalyzer.isNumericWithUnit("2.5E-3em")); try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e+2rem")); try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-3.14e10px")); } -test "isNumericWithUnit - edge cases and invalid inputs" { +test "CSSValueAnalyzer: isNumericWithUnit - edge cases and invalid inputs" { try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("")); try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("px")); @@ -405,7 +402,7 @@ test "isNumericWithUnit - edge cases and invalid inputs" { try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5")); } -test "isHexColor - valid hex colors" { +test "CSSValueAnalyzer: isHexColor - valid hex colors" { try testing.expect(CSSValueAnalyzer.isHexColor("#000")); try testing.expect(CSSValueAnalyzer.isHexColor("#fff")); try testing.expect(CSSValueAnalyzer.isHexColor("#123456")); @@ -414,7 +411,7 @@ test "isHexColor - valid hex colors" { try testing.expect(CSSValueAnalyzer.isHexColor("#12345678")); } -test "isHexColor - invalid hex colors" { +test "CSSValueAnalyzer: isHexColor - invalid hex colors" { try testing.expect(!CSSValueAnalyzer.isHexColor("")); try testing.expect(!CSSValueAnalyzer.isHexColor("#")); try testing.expect(!CSSValueAnalyzer.isHexColor("000")); @@ -427,7 +424,7 @@ test "isHexColor - invalid hex colors" { try testing.expect(!CSSValueAnalyzer.isHexColor("#123xyz")); } -test "isMultiValueProperty - valid multi-value properties" { +test "CSSValueAnalyzer: isMultiValueProperty - valid multi-value properties" { try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px")); try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red")); try testing.expect(CSSValueAnalyzer.isMultiValueProperty("#fff black")); @@ -435,7 +432,7 @@ test "isMultiValueProperty - valid multi-value properties" { try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) solid")); } -test "isMultiValueProperty - invalid multi-value properties" { +test "CSSValueAnalyzer: isMultiValueProperty - invalid multi-value properties" { try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("")); try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px")); try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("invalid unknown")); @@ -443,7 +440,7 @@ test "isMultiValueProperty - invalid multi-value properties" { try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(" ")); } -test "isAlreadyQuoted - various quoting scenarios" { +test "CSSValueAnalyzer: isAlreadyQuoted - various quoting scenarios" { try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\"")); try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'world'")); try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"\"")); @@ -459,7 +456,7 @@ test "isAlreadyQuoted - various quoting scenarios" { try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\"")); } -test "isValidPropertyName - valid property names" { +test "CSSValueAnalyzer: isValidPropertyName - valid property names" { try testing.expect(CSSValueAnalyzer.isValidPropertyName("color")); try testing.expect(CSSValueAnalyzer.isValidPropertyName("background-color")); try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform")); @@ -469,7 +466,7 @@ test "isValidPropertyName - valid property names" { try testing.expect(CSSValueAnalyzer.isValidPropertyName("line-height")); } -test "isValidPropertyName - invalid property names" { +test "CSSValueAnalyzer: isValidPropertyName - invalid property names" { try testing.expect(!CSSValueAnalyzer.isValidPropertyName("")); try testing.expect(!CSSValueAnalyzer.isValidPropertyName("123color")); try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color!")); @@ -479,7 +476,7 @@ test "isValidPropertyName - invalid property names" { try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color_test")); } -test "extractImportant - with and without !important" { +test "CSSValueAnalyzer: extractImportant - with and without !important" { var result = CSSValueAnalyzer.extractImportant("red !important"); try testing.expect(result.is_important); try testing.expectEqual("red", result.value); @@ -501,7 +498,7 @@ test "extractImportant - with and without !important" { try testing.expectEqual("important", result.value); } -test "needsQuotes - various scenarios" { +test "CSSValueAnalyzer: needsQuotes - various scenarios" { try testing.expect(CSSValueAnalyzer.needsQuotes("")); try testing.expect(CSSValueAnalyzer.needsQuotes("hello world")); try testing.expect(CSSValueAnalyzer.needsQuotes("test;")); @@ -516,7 +513,7 @@ test "needsQuotes - various scenarios" { try testing.expect(!CSSValueAnalyzer.needsQuotes("simple")); } -test "escapeCSSValue - escaping various characters" { +test "CSSValueAnalyzer: escapeCSSValue - escaping various characters" { const allocator = testing.arena_allocator; var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "simple"); @@ -535,7 +532,7 @@ test "escapeCSSValue - escaping various characters" { try testing.expectEqual("\"test\\\\back\"", result); } -test "CSSKeywords.isKnownKeyword - case sensitivity" { +test "CSSValueAnalyzer: CSSKeywords.isKnownKeyword - case sensitivity" { try testing.expect(CSSKeywords.isKnownKeyword("red")); try testing.expect(CSSKeywords.isKnownKeyword("solid")); try testing.expect(CSSKeywords.isKnownKeyword("center")); @@ -551,7 +548,7 @@ test "CSSKeywords.isKnownKeyword - case sensitivity" { try testing.expect(!CSSKeywords.isKnownKeyword("")); } -test "CSSKeywords.containsSpecialChar - various special characters" { +test "CSSValueAnalyzer: CSSKeywords.containsSpecialChar - various special characters" { try testing.expect(CSSKeywords.containsSpecialChar("test\"quote")); try testing.expect(CSSKeywords.containsSpecialChar("test'quote")); try testing.expect(CSSKeywords.containsSpecialChar("test;end")); @@ -567,7 +564,7 @@ test "CSSKeywords.containsSpecialChar - various special characters" { try testing.expect(!CSSKeywords.containsSpecialChar("")); } -test "CSSKeywords.isValidUnit - various units" { +test "CSSValueAnalyzer: CSSKeywords.isValidUnit - various units" { try testing.expect(CSSKeywords.isValidUnit("px")); try testing.expect(CSSKeywords.isValidUnit("em")); try testing.expect(CSSKeywords.isValidUnit("rem")); @@ -585,7 +582,7 @@ test "CSSKeywords.isValidUnit - various units" { try testing.expect(!CSSKeywords.isValidUnit("")); } -test "CSSKeywords.startsWithFunction - function detection" { +test "CSSValueAnalyzer: CSSKeywords.startsWithFunction - function detection" { try testing.expect(CSSKeywords.startsWithFunction("rgb(255, 0, 0)")); try testing.expect(CSSKeywords.startsWithFunction("rgba(255, 0, 0, 0.5)")); try testing.expect(CSSKeywords.startsWithFunction("url(image.png)")); @@ -603,14 +600,14 @@ test "CSSKeywords.startsWithFunction - function detection" { try testing.expect(!CSSKeywords.startsWithFunction("rgb")); } -test "isNumericWithUnit - whitespace handling" { +test "CSSValueAnalyzer: isNumericWithUnit - whitespace handling" { try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10px")); try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10 px")); try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10px ")); try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10 px ")); } -test "extractImportant - whitespace edge cases" { +test "CSSValueAnalyzer: extractImportant - whitespace edge cases" { var result = CSSValueAnalyzer.extractImportant(" "); try testing.expect(!result.is_important); try testing.expectEqual("", result.value); @@ -624,14 +621,14 @@ test "extractImportant - whitespace edge cases" { try testing.expectEqual("red", result.value); } -test "isHexColor - mixed case handling" { +test "CSSValueAnalyzer: isHexColor - mixed case handling" { try testing.expect(CSSValueAnalyzer.isHexColor("#AbC")); try testing.expect(CSSValueAnalyzer.isHexColor("#123aBc")); try testing.expect(CSSValueAnalyzer.isHexColor("#FFffFF")); try testing.expect(CSSValueAnalyzer.isHexColor("#000FFF")); } -test "edge case - very long inputs" { +test "CSSValueAnalyzer: edge case - very long inputs" { const long_valid = "a" ** 1000 ++ "px"; try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(long_valid)); // not numeric @@ -642,7 +639,7 @@ test "edge case - very long inputs" { try testing.expect(!CSSValueAnalyzer.isHexColor(long_hex)); } -test "boundary conditions - numeric parsing" { +test "CSSValueAnalyzer: boundary conditions - numeric parsing" { try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0px")); try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.0px")); try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".0px")); @@ -655,7 +652,7 @@ test "boundary conditions - numeric parsing" { try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e-100px")); } -test "extractImportant - malformed important declarations" { +test "CSSValueAnalyzer: extractImportant - malformed important declarations" { var result = CSSValueAnalyzer.extractImportant("red ! important"); try testing.expect(!result.is_important); try testing.expectEqual("red ! important", result.value); @@ -677,7 +674,7 @@ test "extractImportant - malformed important declarations" { try testing.expectEqual("red !important", result.value); } -test "isMultiValueProperty - complex spacing scenarios" { +test "CSSValueAnalyzer: isMultiValueProperty - complex spacing scenarios" { try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px")); try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red")); @@ -689,7 +686,7 @@ test "isMultiValueProperty - complex spacing scenarios" { try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px 30px")); } -test "isAlreadyQuoted - edge cases with quotes" { +test "CSSValueAnalyzer: isAlreadyQuoted - edge cases with quotes" { try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"'hello'\"")); try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'\"hello\"'")); @@ -705,7 +702,7 @@ test "isAlreadyQuoted - edge cases with quotes" { try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'b'")); } -test "needsQuotes - function and URL edge cases" { +test "CSSValueAnalyzer: needsQuotes - function and URL edge cases" { try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)")); try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)")); @@ -716,7 +713,7 @@ test "needsQuotes - function and URL edge cases" { try testing.expect(CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0")); } -test "escapeCSSValue - control characters and Unicode" { +test "CSSValueAnalyzer: escapeCSSValue - control characters and Unicode" { const allocator = testing.arena_allocator; var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\ttab"); @@ -735,7 +732,7 @@ test "escapeCSSValue - control characters and Unicode" { try testing.expectEqual("\"test\\\"quote\\A line\\\\back\"", result); } -test "isValidPropertyName - CSS custom properties and vendor prefixes" { +test "CSSValueAnalyzer: isValidPropertyName - CSS custom properties and vendor prefixes" { try testing.expect(CSSValueAnalyzer.isValidPropertyName("--custom-color")); try testing.expect(CSSValueAnalyzer.isValidPropertyName("--my-variable")); try testing.expect(CSSValueAnalyzer.isValidPropertyName("--123")); @@ -750,7 +747,7 @@ test "isValidPropertyName - CSS custom properties and vendor prefixes" { try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-")); } -test "startsWithFunction - case sensitivity and partial matches" { +test "CSSValueAnalyzer: startsWithFunction - case sensitivity and partial matches" { try testing.expect(CSSKeywords.startsWithFunction("RGB(255, 0, 0)")); try testing.expect(CSSKeywords.startsWithFunction("Rgb(255, 0, 0)")); try testing.expect(CSSKeywords.startsWithFunction("URL(image.png)")); @@ -767,7 +764,7 @@ test "startsWithFunction - case sensitivity and partial matches" { try testing.expect(!CSSKeywords.startsWithFunction("123function(test)")); } -test "isHexColor - Unicode and invalid characters" { +test "CSSValueAnalyzer: isHexColor - Unicode and invalid characters" { try testing.expect(!CSSValueAnalyzer.isHexColor("#ghijkl")); try testing.expect(!CSSValueAnalyzer.isHexColor("#12345g")); try testing.expect(!CSSValueAnalyzer.isHexColor("#xyz")); @@ -778,7 +775,7 @@ test "isHexColor - Unicode and invalid characters" { try testing.expect(!CSSValueAnalyzer.isHexColor("#g2345678")); } -test "complex integration scenarios" { +test "CSSValueAnalyzer: complex integration scenarios" { const allocator = testing.arena_allocator; try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) url(bg.jpg)")); @@ -793,7 +790,7 @@ test "complex integration scenarios" { try testing.expectEqual("rgb(255,0,0)", important_result.value); } -test "performance edge cases - empty and minimal inputs" { +test "CSSValueAnalyzer: performance edge cases - empty and minimal inputs" { try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("")); try testing.expect(!CSSValueAnalyzer.isHexColor("")); try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(""));