Files
browser/src/script/Schema.zig
Adrià Arrufat 144b3a58a0 terminal: support positional args for single-field schemas
Allows tools like `/getEnv` with a single optional field to accept
positional arguments. Also adds autocompletion and ghost hints for
live `LP_*` environment variables.

Additionally, fixes a memory leak in `settings.zig` when parsing
invalid ZON configurations.
2026-06-17 16:26:43 +02:00

848 lines
34 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
//! Cached, schema-extracted view of a single browser tool. Per-tool
//! semantics (record / heal / locator / data) live on `BrowserTool`.
//! `Schema.all()` is the lazy process-wide cache.
const std = @import("std");
const lp = @import("lightpanda");
const browser_tools = lp.tools;
const BrowserTool = browser_tools.Tool;
const string = @import("../string.zig");
const Schema = @This();
tool: BrowserTool,
tool_name: []const u8,
description: []const u8,
summary: []const u8,
required: []const []const u8,
/// Selector-first positional fields, shared by the marshaller and recorder so the
/// two can't drift. Empty for tools with no positional argument.
positional: []const []const u8,
fields: []const FieldEntry,
hints: []const HintSlot,
parameters: std.json.Value,
pub const FieldType = enum { string, integer, number, boolean, other };
pub const FieldEntry = struct {
name: []const u8,
field_type: FieldType,
/// Used by `Command.format` to omit `checked=true` when emitting `/setChecked`.
default_true: bool = false,
/// Allowed values when the schema constrains the field with `enum`; empty
/// otherwise. The REPL completer/hinter offers these as value suggestions.
enum_values: []const []const u8 = &.{},
/// `backendNodeId` is ephemeral, never replayable. Boolean fields
/// matching the schema default are cosmetic noise.
pub fn skipForFormat(self: FieldEntry, v: std.json.Value) bool {
if (std.mem.eql(u8, self.name, "backendNodeId")) return true;
return v == .bool and v.bool and self.default_true;
}
};
/// REPL argument-syntax hint slot. `fragment` is pre-rendered as `<name>`
/// for required and `[name=…]` for optional.
pub const HintSlot = struct {
name: []const u8,
required: bool,
fragment: []const u8,
};
/// Asserted at schema build time so adding a tool with more fields fails loud.
pub const max_hint_slots: usize = 16;
pub const ParseError = error{
MissingName,
UnknownTool,
UnknownField,
MissingRequired,
DuplicateField,
MalformedKv,
PositionalNotAllowed,
PositionalMustComeFirst,
UnterminatedQuote,
UnsupportedEscape,
InvalidValue,
OutOfMemory,
};
pub const Split = struct {
name: []const u8,
rest: []const u8,
};
/// Populated by `parseValueDiag` on `InvalidValue` so callers can surface
/// the offending field/type/value to the user.
pub const Diag = struct {
bad_field: []const u8 = "",
expected_type: FieldType = .other,
bad_value: []const u8 = "",
};
/// Selector-first positional order for a tool's script builtin — the source of
/// truth `marshalArgs` and the recorder both read.
pub fn positionalFor(tool: BrowserTool) []const []const u8 {
return switch (tool) {
.goto => &.{"url"},
.evaluate, .waitForScript => &.{"script"},
.waitForSelector, .click, .hover => &.{"selector"},
.waitForState => &.{"state"},
.fill, .selectOption => &.{ "selector", "value" },
.setChecked => &.{ "selector", "checked" },
.press => &.{ "selector", "key" },
// Only the recorder reads this; the runtime marshals extract separately.
.extract => &.{"schema"},
else => &.{},
};
}
pub fn findField(self: Schema, key: []const u8) ?FieldEntry {
for (self.fields) |f| {
if (std.ascii.eqlIgnoreCase(f.name, key)) return f;
}
return null;
}
/// Rename keys in `obj` to canonical casing. Unknown keys pass through;
/// keys that collide on the canonical form return `error.DuplicateField`.
pub fn normalizeKeys(self: Schema, obj: *std.json.ObjectMap) !void {
const Rename = struct { from: []const u8, to: []const u8 };
var renames: std.ArrayList(Rename) = .empty;
var it = obj.iterator();
while (it.next()) |entry| {
const field = self.findField(entry.key_ptr.*) orelse continue;
if (!std.mem.eql(u8, field.name, entry.key_ptr.*)) {
if (obj.contains(field.name)) return error.DuplicateField;
for (renames.items) |r| {
if (std.mem.eql(u8, r.to, field.name)) return error.DuplicateField;
}
try renames.append(obj.allocator, .{ .from = entry.key_ptr.*, .to = field.name });
}
}
for (renames.items) |r| {
const kv = obj.fetchSwapRemove(r.from) orelse continue;
try obj.put(r.to, kv.value);
}
}
fn fieldType(self: Schema, key: []const u8) FieldType {
if (self.findField(key)) |f| return f.field_type;
return .other;
}
fn isFieldDefaultTrue(self: Schema, key: []const u8) bool {
if (self.findField(key)) |f| return f.default_true;
return false;
}
/// `backendNodeId` is ephemeral, never replayable. Boolean fields
/// matching the schema default are cosmetic noise.
pub fn skipForFormat(self: Schema, key: []const u8, v: std.json.Value) bool {
if (self.findField(key)) |f| return f.skipForFormat(v);
return std.mem.eql(u8, key, "backendNodeId");
}
pub fn visibleArgCount(self: Schema, args: std.json.ObjectMap) usize {
var n: usize = 0;
for (self.fields) |f| {
const v = args.get(f.name) orelse continue;
if (f.skipForFormat(v)) continue;
n += 1;
}
return n;
}
/// True when a recorded call renders as bare `tool(value)` rather than the object
/// form: the lone required field is the tool's leading positional, the only visible
/// arg, and a string. False for press/fill/selectOption — their required field sits
/// behind `selector`, so they round-trip via the object form.
pub fn isBarePositional(self: Schema, args: std.json.ObjectMap) bool {
if (self.required.len != 1 or self.positional.len == 0) return false;
if (!std.mem.eql(u8, self.required[0], self.positional[0])) return false;
if (self.visibleArgCount(args) != 1) return false;
const v = args.get(self.required[0]) orelse return false;
return v == .string;
}
/// The field a single leading positional binds to: the lone required field,
/// or — when nothing is required — the tool's only field (e.g. getEnv binds to
/// `name`). Null when there's no required field and more than one optional
/// field, since the target would be ambiguous (click's selector/backendNodeId).
pub fn leadingPositionalField(self: Schema) ?[]const u8 {
if (self.required.len == 1) return self.required[0];
if (self.required.len == 0 and self.fields.len == 1) return self.fields[0].name;
return null;
}
/// Parse `rest` (args portion of a slash command) into a `std.json.Value`.
/// Returns null when the schema takes no args and `rest` is empty.
///
/// Argument-binding rules:
/// - Bare `{json}` payload returned as-is.
/// - Single leading positional binds to `required[0]` when there's
/// exactly one required. Otherwise positionals error.
/// - Everything else is `key=value` with type coercion.
pub fn parseValue(self: Schema, arena: std.mem.Allocator, rest: []const u8) ParseError!?std.json.Value {
return self.parseValueDiag(arena, rest, null);
}
/// Same as `parseValue` but populates `diag` on `error.InvalidValue` so
/// callers can render a user-facing message like
/// "y: expected integer, got 'fast'".
pub fn parseValueDiag(self: Schema, arena: std.mem.Allocator, rest_raw: []const u8, diag: ?*Diag) ParseError!?std.json.Value {
const rest = std.mem.trim(u8, rest_raw, &std.ascii.whitespace);
if (rest.len == 0) {
if (self.required.len > 0) return error.MissingRequired;
return null;
}
if (rest[0] == '{') {
var parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, rest, .{}) catch return error.MalformedKv;
// Same validation the kv path applies: reject unknown keys and
// fill default-true required fields when omitted.
if (parsed != .object) return error.MalformedKv;
try self.validateAndFillObject(&parsed.object);
return parsed;
}
const tokens = try tokenize(arena, rest);
const leading_positional = tokens.len >= 1 and !looksLikeKv(tokens[0]);
const positional_field = self.leadingPositionalField();
if (leading_positional and positional_field == null) return error.PositionalNotAllowed;
var list = try std.ArrayList(KvPair).initCapacity(arena, tokens.len + self.required.len);
const kv_start: usize = if (leading_positional) 1 else 0;
if (leading_positional) {
list.appendAssumeCapacity(.{ .key = positional_field.?, .value = stripQuotes(tokens[0]) });
}
for (tokens[kv_start..]) |tok| {
const eq = std.mem.indexOfScalar(u8, tok, '=') orelse {
// `/extract save=x '{…}'` — the value would have bound fine as a
// leading positional, so point at the ordering instead of the
// generic kv complaint.
if (positional_field != null and !leading_positional) return error.PositionalMustComeFirst;
return error.MalformedKv;
};
if (eq == 0 or eq == tok.len - 1) return error.MalformedKv;
const key = tok[0..eq];
// Reject typos like `checke=false` that would otherwise be silently
// absorbed while the actual required field gets default-filled.
const field = self.findField(key) orelse return error.UnknownField;
list.appendAssumeCapacity(.{ .key = field.name, .value = stripQuotes(tok[eq + 1 ..]) });
}
// Default-true booleans (e.g. setChecked.checked) so `/setChecked
// selector='#a'` works without `checked=true`.
required: for (self.required) |req| {
for (list.items) |p| if (std.mem.eql(u8, p.key, req)) continue :required;
if (!self.isFieldDefaultTrue(req)) return error.MissingRequired;
list.appendAssumeCapacity(.{ .key = req, .value = "true" });
}
return try self.buildValue(arena, list.items, diag);
}
fn validateAndFillObject(self: Schema, obj: *std.json.ObjectMap) ParseError!void {
// Stricter than the LLM path: an unknown field is a user typo, not noise to drop.
var it = obj.iterator();
while (it.next()) |entry| {
if (self.findField(entry.key_ptr.*) == null) return error.UnknownField;
}
try self.normalizeKeys(obj);
for (self.required) |req| {
if (obj.contains(req)) continue;
if (!self.isFieldDefaultTrue(req)) return error.MissingRequired;
try obj.put(req, .{ .bool = true });
}
}
const KvPair = struct {
key: []const u8,
value: []const u8,
};
fn buildValue(self: Schema, arena: std.mem.Allocator, pairs: []const KvPair, diag: ?*Diag) ParseError!std.json.Value {
var obj: std.json.ObjectMap = .init(arena);
try obj.ensureTotalCapacity(pairs.len);
for (pairs) |p| {
const v = try self.coerce(arena, p.key, p.value, diag);
const gop = try obj.getOrPut(p.key);
if (gop.found_existing) return error.DuplicateField;
gop.value_ptr.* = v;
}
return .{ .object = obj };
}
fn coerce(self: Schema, arena: std.mem.Allocator, key: []const u8, value: []const u8, diag: ?*Diag) ParseError!std.json.Value {
const ft = self.fieldType(key);
switch (ft) {
.integer => {
if (std.fmt.parseInt(i64, value, 10)) |n| return .{ .integer = n } else |_| {
if (diag) |d| d.* = .{ .bad_field = key, .expected_type = ft, .bad_value = value };
return error.InvalidValue;
}
},
.number => {
if (std.fmt.parseFloat(f64, value)) |n| return .{ .float = n } else |_| {
if (diag) |d| d.* = .{ .bad_field = key, .expected_type = ft, .bad_value = value };
return error.InvalidValue;
}
},
.boolean => {
if (std.mem.eql(u8, value, "true")) return .{ .bool = true };
if (std.mem.eql(u8, value, "false")) return .{ .bool = false };
if (diag) |d| d.* = .{ .bad_field = key, .expected_type = ft, .bad_value = value };
return error.InvalidValue;
},
else => {},
}
return .{ .string = try arena.dupe(u8, value) };
}
/// Split a slash-command body into `<name> <rest>`. Null on empty input.
pub fn splitNameRest(input: []const u8) ?Split {
const trimmed = std.mem.trim(u8, input, &std.ascii.whitespace);
if (trimmed.len == 0) return null;
const name_end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len;
return .{
.name = trimmed[0..name_end],
.rest = std.mem.trimStart(u8, trimmed[name_end..], &std.ascii.whitespace),
};
}
/// Parse input as a slash command, rejecting leading whitespace or spaces after '/'.
pub fn parseSlashCommand(input: []const u8) ?Split {
if (input.len < 2 or input[0] != '/' or std.ascii.isWhitespace(input[1])) return null;
return splitNameRest(input[1..]);
}
fn find(schemas: []const Schema, name: []const u8) ?*const Schema {
if (std.meta.stringToEnum(BrowserTool, name)) |tool| {
const idx = @intFromEnum(tool);
if (idx < schemas.len) return &schemas[idx];
}
for (schemas) |*s| {
if (std.ascii.eqlIgnoreCase(s.tool_name, name)) return s;
}
return null;
}
pub fn findByName(name: []const u8) ?*const Schema {
return find(all(), name);
}
/// Lazy process-wide cache, keyed by `@intFromEnum(BrowserTool)`.
/// Panics on init failure — `tool_defs` is comptime-constant, so any
/// parse/build error is a build-time bug.
pub fn all() []const Schema {
global_once.call();
return global_storage[0..browser_tools.tool_defs.len];
}
var global_storage: [browser_tools.tool_defs.len]Schema = undefined;
var global_arena: std.heap.ArenaAllocator = undefined;
var global_once = std.once(initGlobal);
fn initGlobal() void {
global_arena = .init(std.heap.page_allocator);
const a = global_arena.allocator();
for (browser_tools.tool_defs, 0..) |td, i| {
const tool: BrowserTool = @enumFromInt(i);
const parsed = std.json.parseFromSliceLeaky(std.json.Value, a, td.input_schema, .{}) catch |err| {
std.debug.panic("failed to parse schema for tool '{s}': {s}", .{ @tagName(tool), @errorName(err) });
};
global_storage[i] = buildOne(a, tool, td, parsed) catch |err| {
std.debug.panic("failed to build schema for tool '{s}': {s}", .{ @tagName(tool), @errorName(err) });
};
}
}
fn buildOne(arena: std.mem.Allocator, tool: BrowserTool, td: BrowserTool.Definition, parsed: std.json.Value) !Schema {
var info: Schema = .{
.tool = tool,
.tool_name = @tagName(tool),
.description = td.description,
.summary = td.summary,
.required = &.{},
.positional = positionalFor(tool),
.fields = &.{},
.hints = &.{},
.parameters = parsed,
};
if (parsed != .object) return info;
if (parsed.object.get("required")) |req| {
info.required = try jsonStringArray(arena, req);
}
if (parsed.object.get("properties")) |props| {
if (props == .object) {
const map = props.object;
const fields = try arena.alloc(FieldEntry, map.count());
var it = map.iterator();
for (fields) |*f| {
const entry = it.next().?;
f.* = .{
.name = entry.key_ptr.*,
.field_type = fieldTypeOf(entry.value_ptr.*),
.default_true = booleanDefaultTrue(entry.value_ptr.*),
.enum_values = try enumValuesOf(arena, entry.value_ptr.*),
};
}
info.fields = fields;
}
}
info.hints = try buildHints(arena, info.required, info.fields);
std.debug.assert(info.hints.len <= max_hint_slots);
return info;
}
fn buildHints(arena: std.mem.Allocator, required: []const []const u8, fields: []const FieldEntry) ![]const HintSlot {
if (fields.len == 0 and required.len == 0) return &.{};
var optional_count: usize = 0;
for (fields) |f| {
if (!string.isOneOf(f.name, required)) optional_count += 1;
}
const out = try arena.alloc(HintSlot, required.len + optional_count);
var idx: usize = 0;
defer std.debug.assert(idx == out.len);
for (required) |name| {
out[idx] = .{
.name = name,
.required = true,
.fragment = try std.fmt.allocPrint(arena, "<{s}>", .{name}),
};
idx += 1;
}
for (fields) |f| {
if (string.isOneOf(f.name, required)) continue;
out[idx] = .{
.name = f.name,
.required = false,
.fragment = try std.fmt.allocPrint(arena, "[{s}=…]", .{f.name}),
};
idx += 1;
}
return out;
}
fn fieldTypeOf(value: std.json.Value) FieldType {
if (value != .object) return .other;
const ty = value.object.get("type") orelse return .other;
if (ty != .string) return .other;
return std.meta.stringToEnum(FieldType, ty.string) orelse .other;
}
fn booleanDefaultTrue(value: std.json.Value) bool {
if (value != .object) return false;
const d = value.object.get("default") orelse return false;
return d == .bool and d.bool;
}
fn enumValuesOf(arena: std.mem.Allocator, value: std.json.Value) ![]const []const u8 {
if (value != .object) return &.{};
return jsonStringArray(arena, value.object.get("enum") orelse return &.{});
}
/// Collect a JSON array's string items into an arena-owned slice. Non-string
/// items are skipped; a non-array yields an empty slice.
fn jsonStringArray(arena: std.mem.Allocator, value: std.json.Value) ![]const []const u8 {
if (value != .array) return &.{};
var out: std.ArrayList([]const u8) = .empty;
try out.ensureTotalCapacity(arena, value.array.items.len);
for (value.array.items) |item| {
if (item != .string) continue;
out.appendAssumeCapacity(item.string);
}
return out.toOwnedSlice(arena);
}
/// Tokenize on whitespace. `"…"` and `'…'` (single or triple) are kept
/// whole; quote stripping happens later. Tokens may contain `=`.
fn tokenize(arena: std.mem.Allocator, input: []const u8) ParseError![][]const u8 {
var out: std.ArrayList([]const u8) = .empty;
var i: usize = 0;
while (i < input.len) {
while (i < input.len and std.ascii.isWhitespace(input[i])) i += 1;
if (i >= input.len) break;
const tok_start = i;
while (i < input.len and !std.ascii.isWhitespace(input[i])) : (i += 1) {
const ch = input[i];
if (ch == '"' or ch == '\'') {
const is_triple = i + 2 < input.len and input[i + 1] == ch and input[i + 2] == ch;
if (is_triple) {
const triple_delim = input[i .. i + 3];
const close = std.mem.indexOfPos(u8, input, i + 3, triple_delim) orelse return error.UnterminatedQuote;
i = close + 2;
} else {
// Odd run of `\` before the closer = escape attempt; even = literal.
var j = i + 1;
var pending_bs: usize = 0;
while (j < input.len) : (j += 1) {
if (input[j] == '\\') {
pending_bs += 1;
continue;
}
if (input[j] == ch) {
if (pending_bs % 2 == 1) return error.UnsupportedEscape;
break;
}
pending_bs = 0;
} else return error.UnterminatedQuote;
i = j;
}
}
}
try out.append(arena, input[tok_start..i]);
}
return try out.toOwnedSlice(arena);
}
fn stripQuotes(raw: []const u8) []const u8 {
if (raw.len >= 6) {
if (std.mem.startsWith(u8, raw, "'''") and std.mem.endsWith(u8, raw, "'''")) {
return raw[3 .. raw.len - 3];
}
if (std.mem.startsWith(u8, raw, "\"\"\"") and std.mem.endsWith(u8, raw, "\"\"\"")) {
return raw[3 .. raw.len - 3];
}
}
if (raw.len >= 2) {
const first = raw[0];
const last = raw[raw.len - 1];
if ((first == '\'' and last == '\'') or (first == '"' and last == '"')) {
return raw[1 .. raw.len - 1];
}
}
return raw;
}
/// True for unquoted `<identifier>=<value>` tokens. Quoted positionals
/// and URLs containing `=` fall through.
fn looksLikeKv(tok: []const u8) bool {
if (tok.len == 0) return false;
if (tok[0] == '\'' or tok[0] == '"') return false;
const end = std.mem.indexOfAny(u8, tok, "'\"") orelse tok.len;
const eq = std.mem.indexOfScalar(u8, tok[0..end], '=') orelse return false;
if (eq == 0) return false;
if (!std.ascii.isAlphabetic(tok[0]) and tok[0] != '_') return false;
for (tok[1..eq]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '_') return false;
}
return true;
}
/// True when `input` opens a `'''` or `"""` block that hasn't been closed
/// yet. The REPL hinter/completer call this to silence arg ghost-text once
/// the user is typing inside a multi-line body.
pub fn hasUnclosedTripleQuote(input: []const u8) bool {
var i: usize = 0;
var open: ?u8 = null;
while (i + 3 <= input.len) {
const c0 = input[i];
if ((c0 == '\'' or c0 == '"') and input[i + 1] == c0 and input[i + 2] == c0) {
if (open) |q| {
if (q == c0) open = null;
} else {
open = c0;
}
i += 3;
continue;
}
i += 1;
}
return open != null;
}
/// `body=true`: string is emitted as a `'''…'''` block (newlines OK).
/// `body=false`: single-line kv quoting (no newlines representable).
pub fn quotableInline(s: []const u8, body: bool) bool {
const has_triple_single = std.mem.indexOf(u8, s, "'''") != null;
const has_triple_double = std.mem.indexOf(u8, s, "\"\"\"") != null;
if (body) return !(has_triple_single and has_triple_double);
if (std.mem.indexOfScalar(u8, s, '\n') != null) return false;
const has_single = std.mem.indexOfScalar(u8, s, '\'') != null;
const has_double = std.mem.indexOfScalar(u8, s, '"') != null;
if (has_single and has_double) return !(has_triple_single and has_triple_double);
return true;
}
const testing = @import("../testing.zig");
test "all: comptime tool defs reduce cleanly" {
const schemas = Schema.all();
try testing.expect(schemas.len == browser_tools.tool_defs.len);
const goto = Schema.find(schemas, "goto").?;
try testing.expect(goto.tool.isRecorded());
const scroll = Schema.find(schemas, "scroll").?;
try testing.expect(scroll.tool.isRecorded());
const tree = Schema.find(schemas, "tree").?;
try testing.expect(!tree.tool.isRecorded());
try testing.expect(tree.tool.producesData());
const set_checked = Schema.find(schemas, "setChecked").?;
var checked_default_true = false;
for (set_checked.fields) |f| {
if (std.mem.eql(u8, f.name, "checked")) checked_default_true = f.default_true;
}
try testing.expect(checked_default_true);
}
test "parseValue: single-required positional binds" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
const v = (try goto.parseValue(arena.allocator(), "https://example.com")).?;
try testing.expectString("https://example.com", v.object.get("url").?.string);
}
test "parseValue: positional then kv tail" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
const v = (try goto.parseValue(arena.allocator(), "https://example.com timeout=5000")).?;
try testing.expectString("https://example.com", v.object.get("url").?.string);
try testing.expectEqual(@as(i64, 5000), v.object.get("timeout").?.integer);
}
test "parseValue: unquoted URL with `=` in query string binds positional" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
const v = (try goto.parseValue(arena.allocator(), "https://example.com/?id=42")).?;
try testing.expectString("https://example.com/?id=42", v.object.get("url").?.string);
}
test "parseValue: kv-only multi-required" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const fill = Schema.find(Schema.all(), "fill").?;
const v = (try fill.parseValue(arena.allocator(), "selector='#email' value='foo@x.com'")).?;
try testing.expectString("#email", v.object.get("selector").?.string);
try testing.expectString("foo@x.com", v.object.get("value").?.string);
}
test "parseValue: kv-only zero-required" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const scroll = Schema.find(Schema.all(), "scroll").?;
const v = (try scroll.parseValue(arena.allocator(), "y=200")).?;
try testing.expectEqual(@as(i64, 200), v.object.get("y").?.integer);
}
test "parseValue: missing required errors" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
try testing.expectError(error.MissingRequired, goto.parseValue(arena.allocator(), ""));
}
test "parseValue: positional with zero-required schema errors" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const find_el = Schema.find(Schema.all(), "findElement").?;
try testing.expectError(error.PositionalNotAllowed, find_el.parseValue(arena.allocator(), "button"));
}
test "parseValue: unknown field is rejected, not absorbed into default-true fill" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const set_checked = Schema.find(Schema.all(), "setChecked").?;
// Typo `checke=false`: must error, not silently default `checked=true`.
try testing.expectError(error.UnknownField, set_checked.parseValue(arena.allocator(), "selector='#x' checke=false"));
}
test "parseValue: arg keys are case-insensitive (kv path)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const click = Schema.find(Schema.all(), "click").?;
const v = (try click.parseValue(arena.allocator(), "Selector='#btn'")).?;
try testing.expectString("#btn", v.object.get("selector").?.string);
try testing.expect(v.object.get("Selector") == null);
}
test "parseValue: arg keys are case-insensitive (json path)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const click = Schema.find(Schema.all(), "click").?;
const v = (try click.parseValue(arena.allocator(), "{\"Selector\":\"#btn\"}")).?;
try testing.expectString("#btn", v.object.get("selector").?.string);
try testing.expect(v.object.get("Selector") == null);
}
test "parseValue: duplicate case-variants of the same field are rejected" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const click = Schema.find(Schema.all(), "click").?;
try testing.expectError(
error.DuplicateField,
click.parseValue(arena.allocator(), "{\"Selector\":\"#a\",\"selector\":\"#b\"}"),
);
try testing.expectError(
error.DuplicateField,
click.parseValue(arena.allocator(), "Selector='#a' selector='#b'"),
);
}
test "parseValue: setChecked defaults checked=true when omitted" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const set_checked = Schema.find(Schema.all(), "setChecked").?;
const v = (try set_checked.parseValue(arena.allocator(), "selector='#agree'")).?;
try testing.expectString("#agree", v.object.get("selector").?.string);
try testing.expect(v.object.get("checked").?.bool);
}
test "parseValue: zero-arg tool returns null when rest empty" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const get_cookies = Schema.find(Schema.all(), "getCookies").?;
try testing.expect((try get_cookies.parseValue(arena.allocator(), "")) == null);
}
test "parseValue: quoted positional with '=' in body is not mistaken for kv" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
const v = (try goto.parseValue(arena.allocator(), "'https://example.com?id=42'")).?;
try testing.expectString("https://example.com?id=42", v.object.get("url").?.string);
}
test "parseValue: bare JSON passthrough" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const find_el = Schema.find(Schema.all(), "findElement").?;
const v = (try find_el.parseValue(arena.allocator(), "{\"role\":\"button\"}")).?;
try testing.expectString("button", v.object.get("role").?.string);
}
test "parseValue: bare JSON enforces required, default-true, and unknown keys" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// `checked` is required but default-true: empty object fills it.
const set_checked = Schema.find(Schema.all(), "setChecked").?;
const filled = (try set_checked.parseValue(arena.allocator(), "{\"selector\":\"#x\"}")).?;
try testing.expect(filled.object.get("checked").?.bool);
// Unknown key in JSON must error.
try testing.expectError(error.UnknownField, set_checked.parseValue(arena.allocator(), "{\"selector\":\"#x\",\"checke\":false}"));
// Required field without a default must error MissingRequired.
const goto = Schema.find(Schema.all(), "goto").?;
try testing.expectError(error.MissingRequired, goto.parseValue(arena.allocator(), "{}"));
}
test "splitNameRest: trims and handles empty" {
try testing.expect(Schema.splitNameRest("") == null);
try testing.expect(Schema.splitNameRest(" ") == null);
const r = Schema.splitNameRest(" goto https://x ").?;
try testing.expectString("goto", r.name);
try testing.expectString("https://x", r.rest);
}
test "parseSlashCommand: validates command and rejects whitespace after slash" {
try testing.expect(Schema.parseSlashCommand("") == null);
try testing.expect(Schema.parseSlashCommand("/") == null);
try testing.expect(Schema.parseSlashCommand("/ ") == null);
try testing.expect(Schema.parseSlashCommand("/ foo") == null);
try testing.expect(Schema.parseSlashCommand(" /foo") == null);
const r1 = Schema.parseSlashCommand("/goto https://x").?;
try testing.expectString("goto", r1.name);
try testing.expectString("https://x", r1.rest);
const r2 = Schema.parseSlashCommand("/help").?;
try testing.expectString("help", r2.name);
try testing.expectString("", r2.rest);
}
test "parseValue: double-quoted script with embedded single-quoted argument" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const evaluate = Schema.find(Schema.all(), "evaluate").?;
const v = (try evaluate.parseValue(arena.allocator(), "\"setTimeout(() => document.querySelector('a'), 1000)\"")).?;
try testing.expectString("setTimeout(() => document.querySelector('a'), 1000)", v.object.get("script").?.string);
}
test "tokenize: inline triple quotes with spaces" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const tokens = try tokenize(arena.allocator(), "selector='''hello world''' value=\"\"\"foo bar\"\"\"");
try testing.expectEqual(@as(usize, 2), tokens.len);
try testing.expectString("selector='''hello world'''", tokens[0]);
try testing.expectString("value=\"\"\"foo bar\"\"\"", tokens[1]);
}
test "tokenize: rejects backslash-escaped quote inside same-style quote" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.UnsupportedEscape, tokenize(arena.allocator(), "value=\"hello \\\"world\\\"\""));
try testing.expectError(error.UnsupportedEscape, tokenize(arena.allocator(), "value='it\\'s'"));
}
test "tokenize: bare backslash inside quotes is allowed (e.g. Windows paths)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const tokens = try tokenize(arena.allocator(), "value='C:\\Users\\bob'");
try testing.expectEqual(@as(usize, 1), tokens.len);
try testing.expectString("value='C:\\Users\\bob'", tokens[0]);
}
test "tokenize: even-count backslashes before close-quote are literal" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// `"\\"` is two literal backslashes (even run before the closer), not an escape.
const tokens = try tokenize(arena.allocator(), "value=\"\\\\\"");
try testing.expectEqual(@as(usize, 1), tokens.len);
try testing.expectString("value=\"\\\\\"", tokens[0]);
}
test "hasUnclosedTripleQuote" {
try testing.expect(!hasUnclosedTripleQuote(""));
try testing.expect(!hasUnclosedTripleQuote("/goto https://x"));
try testing.expect(!hasUnclosedTripleQuote("/evaluate '''const x = 1;'''"));
try testing.expect(hasUnclosedTripleQuote("/evaluate '''\nconst x = 1;"));
try testing.expect(hasUnclosedTripleQuote("/evaluate \"\"\"\nconst x = 1;"));
try testing.expect(!hasUnclosedTripleQuote("/evaluate \"\"\"\nconst x = 1;\n\"\"\""));
// Mismatched closer — still open.
try testing.expect(hasUnclosedTripleQuote("/evaluate '''\nfoo\n\"\"\""));
}
test "parseValue: rejects non-object JSON payloads" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const goto = Schema.find(Schema.all(), "goto").?;
try testing.expectError(error.MalformedKv, goto.parseValue(arena.allocator(), "[1, 2, 3]"));
// "\"hello\"" is a valid positional argument, not a JSON payload, so it should succeed
const v = (try goto.parseValue(arena.allocator(), "\"hello\"")).?;
try testing.expectString("hello", v.object.get("url").?.string);
}