diff --git a/src/browser/tests/element/attribute_value_escapes.html b/src/browser/tests/element/attribute_value_escapes.html
new file mode 100644
index 00000000..8ccefbd9
--- /dev/null
+++ b/src/browser/tests/element/attribute_value_escapes.html
@@ -0,0 +1,31 @@
+
+
+
+
backslash
+embedded quote
+
+
diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig
index 947b0211..d327e65f 100644
--- a/src/browser/webapi/selector/Parser.zig
+++ b/src/browser/webapi/selector/Parser.zig
@@ -921,8 +921,7 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute {
const matcher_type = try self.attributeMatcher();
_ = self.skipSpaces();
- const value_raw = try self.attributeValue();
- const value = try arena.dupe(u8, value_raw);
+ const value = try self.attributeValue(arena);
_ = self.skipSpaces();
// Parse optional case-sensitivity flag
@@ -1002,7 +1001,7 @@ fn attributeMatcher(self: *Parser) !std.meta.FieldEnum(Selector.AttributeMatcher
};
}
-fn attributeValue(self: *Parser) ![]const u8 {
+fn attributeValue(self: *Parser, arena: Allocator) ![]const u8 {
const input = self.input;
if (input.len == 0) {
return error.InvalidAttributeSelector;
@@ -1010,10 +1009,41 @@ fn attributeValue(self: *Parser) ![]const u8 {
const quote = input[0];
if (quote == '"' or quote == '\'') {
- const end = std.mem.indexOfScalarPos(u8, input, 1, quote) orelse return error.InvalidAttributeSelector;
- const value = input[1..end];
- self.input = input[end + 1 ..];
- return value;
+ // Walk the string respecting backslash escapes per CSS Syntax Level 3 §4.3.5.
+ // Decode \\ \" \' and \, treat \ as a line continuation,
+ // and stop at the matching unescaped closing quote.
+ // https://drafts.csswg.org/css-syntax/#consume-string-token
+ var result = try std.ArrayList(u8).initCapacity(arena, input.len);
+ var i: usize = 1;
+ while (i < input.len and input[i] != quote) {
+ const b = input[i];
+ if (b == '\\') {
+ if (i + 1 >= input.len) {
+ // Backslash at EOF inside a string is a parse error per spec;
+ // surface it as a missing closing quote.
+ return error.InvalidAttributeSelector;
+ }
+ const after = input[i + 1];
+ if (after == '\n') {
+ // Escaped newline inside a string is a line continuation: drop both.
+ i += 2;
+ continue;
+ }
+ const escape = try parseEscape(input[i + 1 ..], arena);
+ try result.appendSlice(arena, escape.bytes);
+ i += 1 + escape.consumed;
+ continue;
+ }
+ if (b == '\n') {
+ // Bare newline terminates a string token (parse error).
+ return error.InvalidAttributeSelector;
+ }
+ try result.append(arena, b);
+ i += 1;
+ }
+ if (i >= input.len) return error.InvalidAttributeSelector;
+ self.input = input[i + 1 ..];
+ return result.items;
}
var i: usize = 0;
@@ -1032,7 +1062,7 @@ fn attributeValue(self: *Parser) ![]const u8 {
const value = input[0..i];
self.input = input[i..];
- return value;
+ return arena.dupe(u8, value);
}
fn asUint(comptime string: anytype) std.meta.Int(
@@ -1546,3 +1576,96 @@ test "Selector: Parser.parseNthPattern" {
try testing.expectEqual(" )", parser.input);
}
}
+
+test "Selector: Parser.attributeValue" {
+ defer testing.reset();
+ const arena = testing.arena_allocator;
+
+ // Unquoted identifier value (unchanged path).
+ {
+ var parser = Parser{ .input = "abc]" };
+ try testing.expectEqual("abc", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Plain double-quoted value with no escapes.
+ {
+ var parser = Parser{ .input = "\"abc\"]" };
+ try testing.expectEqual("abc", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Plain single-quoted value with no escapes.
+ {
+ var parser = Parser{ .input = "'abc']" };
+ try testing.expectEqual("abc", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Escaped backslash inside a double-quoted value: "abc\\def" -> abc\def.
+ {
+ var parser = Parser{ .input = "\"abc\\\\def\"]" };
+ try testing.expectEqual("abc\\def", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Escaped quote inside a double-quoted value: "foo\"bar" -> foo"bar.
+ {
+ var parser = Parser{ .input = "\"foo\\\"bar\"]" };
+ try testing.expectEqual("foo\"bar", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Escaped single quote inside a single-quoted value: 'foo\'bar' -> foo'bar.
+ {
+ var parser = Parser{ .input = "'foo\\'bar']" };
+ try testing.expectEqual("foo'bar", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Hex escape with explicit space terminator: "\41 B" -> "AB" (space is consumed).
+ {
+ var parser = Parser{ .input = "\"\\41 B\"]" };
+ try testing.expectEqual("AB", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Hex escape consumes up to 6 hex digits with no delimiter: "\41B" -> "ƛ" (U+041B).
+ {
+ var parser = Parser{ .input = "\"\\41B\"]" };
+ try testing.expectEqual("\u{041B}", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Hex escape decoding to a multi-byte UTF-8 sequence: "\1F3A8" -> "🎨".
+ {
+ var parser = Parser{ .input = "\"\\1F3A8\"]" };
+ try testing.expectEqual("🎨", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Escaped newline inside a string is a line continuation (drops the newline).
+ {
+ var parser = Parser{ .input = "\"foo\\\nbar\"]" };
+ try testing.expectEqual("foobar", try parser.attributeValue(arena));
+ try testing.expectEqual("]", parser.input);
+ }
+
+ // Missing closing quote.
+ {
+ var parser = Parser{ .input = "\"abc" };
+ try testing.expectError(error.InvalidAttributeSelector, parser.attributeValue(arena));
+ }
+
+ // Unescaped newline inside a string terminates with a parse error.
+ {
+ var parser = Parser{ .input = "\"abc\ndef\"]" };
+ try testing.expectError(error.InvalidAttributeSelector, parser.attributeValue(arena));
+ }
+
+ // Trailing backslash before EOF is a parse error.
+ {
+ var parser = Parser{ .input = "\"abc\\" };
+ try testing.expectError(error.InvalidAttributeSelector, parser.attributeValue(arena));
+ }
+}