ziex/tools/codegen/events.zig
2026-06-10 08:34:55 +06:00

462 lines
20 KiB
Zig

const std = @import("std");
const astgen = @import("astgen.zig");
// WebIDL primitive -> Zig type mapping.
// Returns null to signal "skip this field" for unsupported types.
fn idlToZig(idl_type: []const u8, nullable: bool) ?[]const u8 {
const inner: []const u8 = blk: {
if (eql(idl_type, "boolean")) break :blk "bool";
if (eql(idl_type, "long") or eql(idl_type, "signed long")) break :blk "i32";
if (eql(idl_type, "unsigned long")) break :blk "u32";
if (eql(idl_type, "short")) break :blk "i16";
if (eql(idl_type, "unsigned short")) break :blk "u16";
if (eql(idl_type, "long long")) break :blk "i64";
if (eql(idl_type, "unsigned long long")) break :blk "u64";
if (eql(idl_type, "octet") or eql(idl_type, "byte")) break :blk "u8";
if (eql(idl_type, "double") or
eql(idl_type, "float") or
eql(idl_type, "DOMHighResTimeStamp") or
eql(idl_type, "CSSNumberish")) break :blk "f64";
if (eql(idl_type, "DOMString") or
eql(idl_type, "USVString") or
eql(idl_type, "CSSOMString")) break :blk "[]const u8";
// EventTarget gets a common-fields sub-struct (see EventTarget decl below)
if (eql(idl_type, "EventTarget") or
eql(idl_type, "Element") or
eql(idl_type, "HTMLElement") or
eql(idl_type, "Node")) break :blk "EventTarget";
// Anything else (exotic interface, union, array, promise, …) is skipped
return null;
};
if (nullable) {
// Allocate a short buffer — caller uses arena so leaking is fine
const buf = std.heap.page_allocator.alloc(u8, inner.len + 1) catch return null;
buf[0] = '?';
@memcpy(buf[1..], inner);
return buf;
}
return inner;
}
fn eql(a: []const u8, b: []const u8) bool {
return std.mem.eql(u8, a, b);
}
fn isZigKeyword(name: []const u8) bool {
const keywords = [_][]const u8{
"addrspace", "align", "allowzero", "and", "anyframe", "anytype",
"asm", "async", "await", "break", "callconv", "catch",
"comptime", "const", "continue", "defer", "else", "enum",
"errdefer", "error", "export", "extern", "fn", "for",
"if", "inline", "linksection", "noalias", "noinline", "nosuspend",
"opaque", "or", "orelse", "packed", "pub", "resume",
"return", "struct", "suspend", "switch", "test", "threadlocal",
"try", "union", "unreachable", "usingnamespace", "var", "volatile",
"while",
};
for (keywords) |keyword| {
if (eql(name, keyword)) return true;
}
return false;
}
fn zigIdent(allocator: std.mem.Allocator, name: []const u8) ![]const u8 {
if (isValidIdent(name) and !isZigKeyword(name)) return name;
return try std.fmt.allocPrint(allocator, "@\"{s}\"", .{name});
}
// Converts a camelCase WebIDL identifier to snake_case for Zig field naming.
// Runs of uppercase letters are treated as acronyms, so `blockedURI` → `blocked_uri`
// and `newURL` → `new_url`. The original camelCase is preserved separately as the
// JS property name to read from the underlying DOM event object.
fn camelToSnake(allocator: std.mem.Allocator, name: []const u8) ![]const u8 {
var out: std.ArrayListUnmanaged(u8) = .empty;
for (name, 0..) |c, i| {
const is_upper = c >= 'A' and c <= 'Z';
if (is_upper and i > 0) {
const prev = name[i - 1];
const prev_is_lower = prev >= 'a' and prev <= 'z';
const next_is_lower = (i + 1 < name.len) and name[i + 1] >= 'a' and name[i + 1] <= 'z';
const prev_is_upper = prev >= 'A' and prev <= 'Z';
if (prev_is_lower or (prev_is_upper and next_is_lower)) {
try out.append(allocator, '_');
}
}
if (is_upper) {
try out.append(allocator, c - 'A' + 'a');
} else {
try out.append(allocator, c);
}
}
return out.toOwnedSlice(allocator);
}
// A parsed interface: flat list of (name, zig_type) after resolving inheritance.
const Interface = struct {
name: []const u8,
fields: std.ArrayListUnmanaged(Field),
href: []const u8 = "",
const Field = struct {
name: []const u8,
js_name: []const u8,
zig_type: []const u8,
};
};
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
const io = init.io;
const source = try generate(io, allocator);
defer allocator.free(source);
try std.Io.Dir.cwd().writeFile(io, .{
.sub_path = "src/runtime/client/events/generated.zig",
.data = source,
});
std.debug.print("Generated src/runtime/client/events/generated.zig\n", .{});
}
pub fn generate(io: std.Io, allocator: std.mem.Allocator) ![]const u8 {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
// -------------------------------------------------------------------------
// Step 1: build a global name->members map by scanning all idlparsed specs.
// Key: interface name, Value: array of raw member JSON objects.
// -------------------------------------------------------------------------
var raw_members = std.StringHashMap(
std.ArrayListUnmanaged(std.json.Value),
).init(a);
var raw_inheritance = std.StringHashMap([]const u8).init(a);
var spec_dir = try std.Io.Dir.cwd().openDir(io, "vendor/webref/ed/idlparsed", .{ .iterate = true });
defer spec_dir.close(io);
var it = spec_dir.iterate();
while (try it.next(io)) |entry| {
if (!std.mem.endsWith(u8, entry.name, ".json")) continue;
const content = try spec_dir.readFileAlloc(io, entry.name, a, .unlimited);
const parsed = std.json.parseFromSlice(std.json.Value, a, content, .{}) catch continue;
const idlp = parsed.value.object.get("idlparsed") orelse continue;
const idl_names = (idlp.object.get("idlNames") orelse continue).object;
for (idl_names.keys(), idl_names.values()) |name, def| {
const def_type = (def.object.get("type") orelse continue).string;
if (!eql(def_type, "interface")) continue;
if (!std.mem.endsWith(u8, name, "Event")) continue;
if (std.mem.indexOf(u8, name, "Init") != null) continue;
// Record inheritance
if (def.object.get("inheritance")) |inh| {
if (inh != .null and inh != .string) {} else if (inh == .string) {
try raw_inheritance.put(name, inh.string);
}
}
// Collect attribute members — merge with existing if partial
const members_json = (def.object.get("members") orelse continue).array;
const entry2 = try raw_members.getOrPut(name);
if (!entry2.found_existing) {
entry2.value_ptr.* = .empty;
}
for (members_json.items) |m| {
const mtype = (m.object.get("type") orelse continue).string;
if (!eql(mtype, "attribute")) continue;
try entry2.value_ptr.append(a, m);
}
}
// Also handle idlExtendedNames (partials)
const idl_ext = (idlp.object.get("idlExtendedNames") orelse continue).object;
for (idl_ext.keys(), idl_ext.values()) |name, partials| {
if (!std.mem.endsWith(u8, name, "Event")) continue;
if (std.mem.indexOf(u8, name, "Init") != null) continue;
for (partials.array.items) |partial| {
const members_json = (partial.object.get("members") orelse continue).array;
const entry2 = try raw_members.getOrPut(name);
if (!entry2.found_existing) {
entry2.value_ptr.* = .empty;
}
for (members_json.items) |m| {
const mtype = (m.object.get("type") orelse continue).string;
if (!eql(mtype, "attribute")) continue;
try entry2.value_ptr.append(a, m);
}
}
}
}
// -------------------------------------------------------------------------
// Step 2: flatten inheritance chains into resolved Interface structs.
// -------------------------------------------------------------------------
var interfaces = std.StringHashMap(Interface).init(a);
var key_it = raw_members.keyIterator();
while (key_it.next()) |key| {
const name = key.*;
if (interfaces.contains(name)) continue;
try resolveInterface(name, a, &raw_members, &raw_inheritance, &interfaces);
}
// -------------------------------------------------------------------------
// Step 3: load events.json → event-name to interface map
// -------------------------------------------------------------------------
const events_content = try std.Io.Dir.cwd().readFileAlloc(io, "vendor/webref/ed/events.json", a, .unlimited);
const events_parsed = try std.json.parseFromSlice(std.json.Value, a, events_content, .{});
const events_array = events_parsed.value.array;
// Deduplicate: one canonical event name -> interface name
var event_map = std.StringHashMap([]const u8).init(a);
for (events_array.items) |ev| {
const ev_type = (ev.object.get("type") orelse continue).string;
const iface = (ev.object.get("interface") orelse continue).string;
if (!event_map.contains(ev_type)) {
try event_map.put(ev_type, iface);
}
}
const interface_overrides = [_]struct { ev: []const u8, iface: []const u8 }{
.{ .ev = "click", .iface = "PointerEvent" },
.{ .ev = "auxclick", .iface = "PointerEvent" },
.{ .ev = "contextmenu", .iface = "PointerEvent" },
.{ .ev = "dblclick", .iface = "MouseEvent" },
.{ .ev = "focus", .iface = "FocusEvent" },
.{ .ev = "blur", .iface = "FocusEvent" },
};
for (interface_overrides) |o| {
if (interfaces.get(o.iface)) |iface| {
if (iface.fields.items.len > 0) {
try event_map.put(o.ev, o.iface);
}
}
}
// -------------------------------------------------------------------------
// Step 4: emit Zig source via astgen
// -------------------------------------------------------------------------
var file = astgen.File.init(allocator);
defer file.deinit();
try file.addRaw(
\\//! Generated by tools/codegen/events.zig — do not edit manually.
\\//! Run `zig build eventsgen` to regenerate.
\\//!
\\//! Provides typed Zig structs for all browser DOM events.
\\//! Use with `event.as(events.MouseEvent, allocator)`.
\\const std = @import("std");
);
// Emit EventTarget helper struct first. Field names are snake_case; the
// original camelCase DOM property names are resolved via `jsName` below.
try file.addRaw(
\\/// Minimal representation of EventTarget/Element for use as a nested field.
\\/// Contains the most commonly accessed DOM element properties.
\\pub const EventTarget = struct {
\\ tag_name: []const u8 = "",
\\ id: []const u8 = "",
\\ name: []const u8 = "",
\\ value: []const u8 = "",
\\ @"type": []const u8 = "",
\\ checked: bool = false,
\\ disabled: bool = false,
\\};
);
// Track snake_case → camelCase for every field whose names differ, so that
// `Event.as(T, …)` can translate Zig field names back to the JS property
// name when reading from the native DOM event object.
var js_name_pairs = std.StringHashMap([]const u8).init(a);
try js_name_pairs.put("tag_name", "tagName");
// Emit each interface struct
var iface_it = interfaces.iterator();
while (iface_it.next()) |iface_entry| {
const iface = iface_entry.value_ptr.*;
if (iface.fields.items.len == 0) continue;
const container = try file.addStruct(iface.name);
for (iface.fields.items) |field| {
// For string types we need a default of "" so zero-init works
const default: ?[]const u8 = blk: {
if (eql(field.zig_type, "[]const u8")) break :blk "\"\"";
if (eql(field.zig_type, "bool")) break :blk "false";
if (std.mem.startsWith(u8, field.zig_type, "?")) break :blk "null";
if (eql(field.zig_type, "EventTarget")) break :blk ".{}";
break :blk "0";
};
try container.addField(file.arena.allocator(), "", try zigIdent(file.arena.allocator(), field.name), field.zig_type, default);
if (!eql(field.name, field.js_name)) {
_ = try js_name_pairs.getOrPutValue(field.name, field.js_name);
}
}
}
// Collect (event_name, iface_name) pairs that have a backing struct so we can
// emit the aliases, the `Kind` enum, and the `Data` switch in one pass.
const Pair = struct { ev: []const u8, iface: []const u8 };
var pairs: std.ArrayListUnmanaged(Pair) = .empty;
var ev_it = event_map.iterator();
while (ev_it.next()) |entry| {
const ev_name = entry.key_ptr.*;
const iface_name = entry.value_ptr.*;
if (!interfaces.contains(iface_name)) continue;
if (interfaces.get(iface_name).?.fields.items.len == 0) continue;
try pairs.append(a, .{ .ev = ev_name, .iface = iface_name });
}
// Emit event name -> struct alias namespace
var aliases: std.Io.Writer.Allocating = .init(a);
const w = &aliases.writer;
try w.writeAll("/// Maps browser event names to their typed structs.\n");
try w.writeAll("/// Example: `event.as(events.click, allocator)` or `event.as(events.MouseEvent, allocator)`.\n");
try w.writeAll("pub const events = struct {\n");
for (pairs.items) |p| {
const safe = isValidIdent(p.ev) and !isZigKeyword(p.ev);
if (safe) {
try w.print(" pub const {s} = {s};\n", .{ p.ev, p.iface });
} else {
try w.print(" pub const @\"{s}\" = {s};\n", .{ p.ev, p.iface });
}
}
try w.writeAll("};\n\n");
// Emit an explicit `Kind` enum. This is what gives editors like ZLS proper
// dot-completion on `event.data(.…)` — an enum derived via
// `std.meta.DeclEnum` at comptime does not currently surface its tags to
// the language server, so we declare the tags literally.
try w.writeAll("/// Enum of all supported DOM event names. Use with `event.data(.kind, …)`.\n");
try w.writeAll("pub const Kind = enum {\n");
for (pairs.items) |p| {
const safe = isValidIdent(p.ev) and !isZigKeyword(p.ev);
if (safe) {
try w.print(" {s},\n", .{p.ev});
} else {
try w.print(" @\"{s}\",\n", .{p.ev});
}
}
try w.writeAll("};\n\n");
// Emit a `Data` function mapping each `Kind` tag to its typed struct.
// This mirrors `@field(events, @tagName(kind))` but keeps the mapping in
// one generated place so consumers can `const T = generated.Data(.click);`.
try w.writeAll("/// Returns the typed struct corresponding to a given event `Kind`.\n");
try w.writeAll("pub fn Data(comptime kind: Kind) type {\n");
try w.writeAll(" return switch (kind) {\n");
for (pairs.items) |p| {
const safe = isValidIdent(p.ev) and !isZigKeyword(p.ev);
if (safe) {
try w.print(" .{s} => {s},\n", .{ p.ev, p.iface });
} else {
try w.print(" .@\"{s}\" => {s},\n", .{ p.ev, p.iface });
}
}
try w.writeAll(" };\n");
try w.writeAll("}\n\n");
try w.writeAll("/// Resolves an event `type` string to its `Kind`, or null if unknown.\n");
try w.writeAll("pub fn kindForType(type_str: []const u8) ?Kind {\n");
try w.writeAll(" return kind_for_type.get(type_str);\n");
try w.writeAll("}\n");
try w.writeAll("const kind_for_type = std.StaticStringMap(Kind).initComptime(.{\n");
for (pairs.items) |p| {
const safe = isValidIdent(p.ev) and !isZigKeyword(p.ev);
if (safe) {
try w.print(" .{{ \"{s}\", .{s} }},\n", .{ p.ev, p.ev });
} else {
try w.print(" .{{ \"{s}\", .@\"{s}\" }},\n", .{ p.ev, p.ev });
}
}
try w.writeAll("});\n\n");
// Emit the snake_case → camelCase map used by `Event.as` at runtime to
// translate Zig field names back to the original DOM property names.
try w.writeAll("/// Maps snake_case Zig field names to their camelCase DOM property names.\n");
try w.writeAll("/// Fields whose snake_case equals the DOM name are omitted; callers should\n");
try w.writeAll("/// fall back to the original identifier when no mapping is present.\n");
try w.writeAll("pub const js_field_names = std.StaticStringMap([]const u8).initComptime(.{\n");
var pair_it = js_name_pairs.iterator();
// Sort entries for deterministic output.
var sorted_keys: std.ArrayListUnmanaged([]const u8) = .empty;
while (pair_it.next()) |e| try sorted_keys.append(a, e.key_ptr.*);
std.mem.sort([]const u8, sorted_keys.items, {}, struct {
fn lt(_: void, l: []const u8, r: []const u8) bool {
return std.mem.lessThan(u8, l, r);
}
}.lt);
for (sorted_keys.items) |k| {
const v = js_name_pairs.get(k).?;
try w.print(" .{{ \"{s}\", \"{s}\" }},\n", .{ k, v });
}
try w.writeAll("});\n\n");
try w.writeAll("/// Resolves the DOM property name for a given Zig field name.\n");
try w.writeAll("/// Returns the mapped camelCase identifier, or the input unchanged when none exists.\n");
try w.writeAll("pub fn jsName(name: []const u8) []const u8 {\n");
try w.writeAll(" return js_field_names.get(name) orelse name;\n");
try w.writeAll("}");
try file.addRaw(aliases.written());
return try file.finish();
}
fn isValidIdent(name: []const u8) bool {
if (name.len == 0) return false;
for (name, 0..) |c, i| {
const ok = (c >= 'a' and c <= 'z') or
(c >= 'A' and c <= 'Z') or
c == '_' or
(i > 0 and c >= '0' and c <= '9');
if (!ok) return false;
}
return true;
}
fn resolveInterface(
name: []const u8,
a: std.mem.Allocator,
raw_members: *std.StringHashMap(std.ArrayListUnmanaged(std.json.Value)),
raw_inheritance: *std.StringHashMap([]const u8),
out: *std.StringHashMap(Interface),
) !void {
if (out.contains(name)) return;
var fields: std.ArrayListUnmanaged(Interface.Field) = .empty;
var seen_names = std.StringHashMap(void).init(a);
// Walk inheritance chain depth-first: child fields first, then parent
var current: ?[]const u8 = name;
while (current) |iname| {
if (raw_members.get(iname)) |members| {
for (members.items) |m| {
const field_name = (m.object.get("name") orelse continue).string;
if (seen_names.contains(field_name)) continue;
const idt = m.object.get("idlType") orelse continue;
const generic = (idt.object.get("generic") orelse continue).string;
// Skip arrays/promises/sequences
if (generic.len > 0) continue;
const is_union = (idt.object.get("union") orelse continue).bool;
if (is_union) continue;
const nullable = (idt.object.get("nullable") orelse continue).bool;
const raw_type = idt.object.get("idlType") orelse continue;
if (raw_type != .string) continue;
const zig_type = idlToZig(raw_type.string, nullable) orelse continue;
try seen_names.put(field_name, {});
const snake = try camelToSnake(a, field_name);
try fields.append(a, .{ .name = snake, .js_name = field_name, .zig_type = zig_type });
}
}
current = raw_inheritance.get(iname);
// Break cycles
if (current != null and std.mem.eql(u8, current.?, name)) break;
}
try out.put(name, .{
.name = try a.dupe(u8, name),
.fields = fields,
});
}