diff --git a/src/browser/cssom/css_parser.zig b/src/browser/cssom/css_parser.zig new file mode 100644 index 00000000..ac158044 --- /dev/null +++ b/src/browser/cssom/css_parser.zig @@ -0,0 +1,291 @@ +// 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 Allocator = std.mem.Allocator; + +const CSSConstants = struct { + const IMPORTANT = "!important"; + const URL_PREFIX = "url("; +}; + +pub const CSSParserState = enum { + 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 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 = .seek_name, + .name_start = 0, + .name_end = 0, + .value_start = 0, + .position = 0, + .paren_depth = 0, + .escape_next = false, + }; + } + + pub fn parseDeclarations(arena: Allocator, text: []const u8) ![]CSSDeclaration { + var parser = init(); + var declarations: std.ArrayListUnmanaged(CSSDeclaration) = .empty; + + while (parser.position < text.len) { + const c = text[parser.position]; + + switch (parser.state) { + .seek_name => { + if (!std.ascii.isWhitespace(c)) { + parser.name_start = parser.position; + parser.state = .in_name; + continue; + } + }, + .in_name => { + if (c == ':') { + parser.name_end = parser.position; + parser.state = .seek_value; + } else if (std.ascii.isWhitespace(c)) { + parser.name_end = parser.position; + parser.state = .seek_colon; + } + }, + .seek_colon => { + if (c == ':') { + parser.state = .seek_value; + } else if (!std.ascii.isWhitespace(c)) { + parser.state = .seek_name; + continue; + } + }, + .seek_value => { + if (!std.ascii.isWhitespace(c)) { + parser.value_start = parser.position; + if (c == '"') { + parser.state = .in_quoted_value; + } else if (c == '\'') { + 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 = .in_value; + continue; + } + } + }, + .in_value => { + 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(arena, &declarations, text); + parser.state = .seek_name; + } + }, + .in_quoted_value => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '"') { + parser.state = .in_value; + } + }, + .in_single_quoted_value => { + if (parser.escape_next) { + parser.escape_next = false; + } else if (c == '\\') { + parser.escape_next = true; + } else if (c == '\'') { + parser.state = .in_value; + } + }, + .in_url => { + 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 = .in_value; + } + } + }, + .in_important => {}, + } + + parser.position += 1; + } + + try parser.finalize(arena, &declarations, text); + + return declarations.items; + } + + 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; + + 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.trimRight(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace); + } + + try declarations.append(arena, .{ + .name = 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; + } + return self.finishDeclaration(arena, declarations, text); + } +}; + +const testing = @import("../../testing.zig"); + +test "CSSParser - Simple property" { + defer testing.reset(); + + const text = "color: red;"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expectEqual(1, declarations.len); + try testing.expectEqual("color", declarations[0].name); + try testing.expectEqual("red", declarations[0].value); + try testing.expectEqual(false, declarations[0].is_important); +} + +test "CSSParser - Property with !important" { + defer testing.reset(); + const text = "margin: 10px !important;"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expectEqual(1, declarations.len); + try testing.expectEqual("margin", declarations[0].name); + try testing.expectEqual("10px", declarations[0].value); + try testing.expectEqual(true, declarations[0].is_important); +} + +test "CSSParser - Multiple properties" { + defer testing.reset(); + const text = "color: red; font-size: 12px; margin: 5px !important;"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expect(declarations.len == 3); + + try testing.expectEqual("color", declarations[0].name); + try testing.expectEqual("red", declarations[0].value); + 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.expectEqual(false, declarations[1].is_important); + + try testing.expectEqual("margin", declarations[2].name); + try testing.expectEqual("5px", declarations[2].value); + try testing.expectEqual(true, declarations[2].is_important); +} + +test "CSSParser - Quoted value with semicolon" { + defer testing.reset(); + const text = "content: \"Hello; world!\";"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + try testing.expectEqual(1, declarations.len); + try testing.expectEqual("content", declarations[0].name); + try testing.expectEqual("\"Hello; world!\"", declarations[0].value); + try testing.expectEqual(false, declarations[0].is_important); +} + +test "CSSParser - URL value" { + defer testing.reset(); + const text = "background-image: url(\"test.png\");"; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + 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.expectEqual(false, declarations[0].is_important); +} + +test "CSSParser - Whitespace handling" { + defer testing.reset(); + const text = " color : purple ; margin : 10px ; "; + const allocator = testing.arena_allocator; + + const declarations = try CSSParser.parseDeclarations(allocator, text); + + 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); + 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 new file mode 100644 index 00000000..e3103dc0 --- /dev/null +++ b/src/browser/cssom/css_style_declaration.zig @@ -0,0 +1,223 @@ +// 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.ArrayListUnmanaged(u8) = .empty; + const writer = buffer.writer(state.call_arena); + for (self.order.items) |name| { + const prop = self.store.get(name).?; + 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.items; + } + + // TODO Propagate also upward to parent node + pub fn set_cssText(self: *CSSStyleDeclaration, text: []const u8, state: *SessionState) !void { + 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| { + 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) ![]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; + } + } + // 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 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 }; + } +}; + +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..1fb35ed2 --- /dev/null +++ b/src/browser/cssom/css_value_analyzer.zig @@ -0,0 +1,811 @@ +// 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) { + 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 (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; + + 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; + } + + 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; + } + + 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.trimRight(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; + + 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); + + return !isMultiValueProperty(value) and + !is_url and + !is_function; + } + + pub fn escapeCSSValue(arena: std.mem.Allocator, value: []const u8) ![]const u8 { + if (!needsQuotes(value)) { + return value; + } + var out: std.ArrayListUnmanaged(u8) = .empty; + + // We'll need at least this much space, +2 for the quotes + try out.ensureTotalCapacity(arena, value.len + 2); + const writer = out.writer(arena); + + 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), + } + } + + try writer.writeByte('"'); + return out.items; + } + + 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 border_styles = [_][]const u8{ + "none", "solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset", + }; + + const color_names = [_][]const u8{ + "black", "white", "red", "green", "blue", "yellow", "purple", "gray", "transparent", + "currentColor", "inherit", + }; + + const position_keywords = [_][]const u8{ + "auto", "center", "left", "right", "top", "bottom", + }; + + const background_repeat = [_][]const u8{ + "repeat", "no-repeat", "repeat-x", "repeat-y", "space", "round", + }; + + const font_styles = [_][]const u8{ + "normal", "italic", "oblique", "bold", "bolder", "lighter", + }; + + const font_sizes = [_][]const u8{ + "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", + "smaller", "larger", + }; + + const font_families = [_][]const u8{ + "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", + }; + + const css_global = [_][]const u8{ + "initial", "inherit", "unset", "revert", + }; + + const display_values = [_][]const u8{ + "block", "inline", "inline-block", "flex", "grid", "none", + }; + + const length_units = [_][]const u8{ + "px", "em", "rem", "vw", "vh", "vmin", "vmax", "%", "pt", "pc", "in", "cm", "mm", + "ex", "ch", "fr", + }; + + const angle_units = [_][]const u8{ + "deg", "rad", "grad", "turn", + }; + + const time_units = [_][]const u8{ + "s", "ms", + }; + + const frequency_units = [_][]const u8{ + "Hz", "kHz", + }; + + const resolution_units = [_][]const u8{ + "dpi", "dpcm", "dppx", + }; + + 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(", + }; + + pub fn isKnownKeyword(value: []const u8) bool { + const all_categories = [_][]const []const u8{ + &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.ascii.eqlIgnoreCase(value, keyword)) { + return true; + } + } + } + + return false; + } + + pub fn containsSpecialChar(value: []const u8) bool { + for (value) |c| { + for (special_chars) |special| { + if (c == special) { + return true; + } + } + } + return false; + } + + pub fn isValidUnit(unit: []const u8) bool { + const all_units = [_][]const []const u8{ + &length_units, &angle_units, &time_units, &frequency_units, &resolution_units, + }; + + for (all_units) |category| { + for (category) |valid_unit| { + if (std.ascii.eqlIgnoreCase(unit, valid_unit)) { + return true; + } + } + } + + return false; + } + + pub fn startsWithFunction(value: []const u8) bool { + const pos = std.mem.indexOfScalar(u8, value, '(') orelse return false; + if (pos == 0) return false; + + if (std.mem.indexOfScalarPos(u8, value, pos, ')') == null) { + return false; + } + const function_name = value[0..pos]; + 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 true; + } +}; + +const testing = @import("../../testing.zig"); + +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")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("+12.5%")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0vh")); + try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".5vw")); +} + +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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "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")); + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("1em 2em 3em 4em")); + try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) solid")); +} + +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")); + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px invalid")); + try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(" ")); +} + +test "CSSValueAnalyzer: 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 "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")); + 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "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")); + 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 "CSSValueAnalyzer: 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 "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)")); + 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 "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 "CSSValueAnalyzer: 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 "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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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); +} + +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")); + + 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 "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)")); + + 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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 "CSSValueAnalyzer: 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")); +} diff --git a/src/browser/env.zig b/src/browser/env.zig index dc205c93..e1321461 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 f84e8e0e..bf55b923 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.getOrCreateNodeWrapper(HTMLElement, @ptrCast(e)); + return &self.style; } pub fn get_innerText(e: *parser.ElementHTML) ![]const u8 { @@ -1106,6 +1111,15 @@ test "Browser.HTML.Element" { .{ "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" }, + }, .{}); + // Image try runner.testCases(&.{ // Testing constructors