diff --git a/CLAUDE.md b/CLAUDE.md
index 5ae5efd..2b1cd8e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,8 +9,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**gdoc** is a CLI documentation viewer for Godot API documentation, similar to `zigdoc`. It parses Godot's API documentation and displays it in the terminal with BBCode-to-Markdown conversion.
Key behavior:
-- Uses the user's local `godot` executable if available
-- Falls back to downloading the latest API JSON from GitHub into cache if Godot is not installed
+- Requires `godot` executable to determine version and fetch XML class documentation
+- Downloads XML docs from GitHub, parses them, and builds a markdown cache
- Converts BBCode documentation to Markdown using the `bbcodez` library for terminal display
## Build System
@@ -57,11 +57,14 @@ The build system imports `bbcodez` as a dependency and makes it available to the
### Expected Data Flow
1. Parse CLI arguments for symbol lookup (e.g., `gdoc Node2D.position`)
-2. Locate Godot API JSON:
- - Check for local `godot` executable → run `godot --dump-extension-api`
- - If not found → download from GitHub to cache directory
-3. Parse JSON to find requested symbol documentation
-4. Convert BBCode documentation to Markdown using `bbcodez`
+2. Check if markdown cache is populated (xml_docs/.complete marker + Object/index.md sentinel)
+3. If cache is empty:
+ - Run `godot --version` to determine Godot version
+ - Download XML class docs tarball from GitHub
+ - Parse all XML files into DocDatabase via `loadFromXmlDir`
+ - Convert BBCode descriptions to Markdown
+ - Generate markdown cache files
+4. Read requested symbol's markdown from cache
5. Display formatted output to terminal
### Integration with bbcodez
diff --git a/src/Config.zig b/src/Config.zig
index c38b759..e29c2d8 100644
--- a/src/Config.zig
+++ b/src/Config.zig
@@ -3,7 +3,6 @@ const known_folders = @import("known-folders");
const Config = @This();
-no_xml: bool,
cache_dir: []const u8,
pub fn init(allocator: std.mem.Allocator) !Config {
@@ -18,7 +17,6 @@ pub fn init(allocator: std.mem.Allocator) !Config {
};
return .{
- .no_xml = hasEnv("GDOC_NO_XML"),
.cache_dir = cache_dir,
};
}
@@ -28,7 +26,6 @@ pub fn deinit(self: Config, allocator: std.mem.Allocator) void {
}
pub const testing: Config = .{
- .no_xml = true,
.cache_dir = "/tmp/gdoc-test-cache",
};
@@ -45,6 +42,5 @@ test "init" {
}
test "testing config" {
- try std.testing.expect(Config.testing.no_xml);
try std.testing.expectEqualStrings("/tmp/gdoc-test-cache", Config.testing.cache_dir);
}
diff --git a/src/DocDatabase.zig b/src/DocDatabase.zig
index ce27bf3..d69efc3 100644
--- a/src/DocDatabase.zig
+++ b/src/DocDatabase.zig
@@ -6,12 +6,11 @@ symbols: StringArrayHashMap(Entry) = .empty,
pub const Error = error{
SymbolNotFound,
- InvalidApiJson,
};
pub const EntryKind = enum {
- builtin_class,
class,
+ constructor,
method,
property,
constant,
@@ -36,373 +35,377 @@ pub const Entry = struct {
signature: ?[]const u8 = null,
members: ?[]usize = null,
tutorials: ?[]const Tutorial = null,
+ inherits: ?[]const u8 = null,
+ qualifiers: ?[]const u8 = null,
+ default_value: ?[]const u8 = null,
};
-const RootState = enum {
- init,
- builtin_classes,
- classes,
- global_constants,
- global_enums,
- native_structures,
- singletons,
- utility_functions,
-};
+fn bbcodeToMarkdown(allocator: Allocator, input: []const u8) ![]const u8 {
+ var output: std.Io.Writer.Allocating = .init(allocator);
-pub fn loadFromJsonFileLeaky(arena_allocator: Allocator, file: File) !DocDatabase {
- var buf: [4096]u8 = undefined;
- var file_reader = file.reader(&buf);
- const reader = &file_reader.interface;
+ const bbcode_doc = try bbcodez.loadFromBuffer(allocator, input, .{});
+ defer bbcode_doc.deinit();
- const file_content = try reader.readAlloc(arena_allocator, try file.getEndPos());
- defer arena_allocator.free(file_content);
+ try bbcodez.fmt.md.renderDocument(allocator, bbcode_doc, &output.writer, .{});
- var scanner = Scanner.initCompleteInput(arena_allocator, file_content);
- defer scanner.deinit();
+ return try output.toOwnedSlice();
+}
- return loadFromJsonLeaky(arena_allocator, &scanner) catch |err| switch (err) {
- Scanner.Error.SyntaxError, Scanner.Error.UnexpectedEndOfInput => return Error.InvalidApiJson,
- else => return err,
- };
+pub fn lookupSymbolExact(self: DocDatabase, symbol: []const u8) DocDatabase.Error!Entry {
+ return self.symbols.get(symbol) orelse return DocDatabase.Error.SymbolNotFound;
}
-pub fn loadFromJsonLeaky(arena_allocator: Allocator, scanner: *Scanner) !DocDatabase {
- var db = DocDatabase{};
-
- while (true) {
- const token = try scanner.next();
- switch (token) {
- .string => |s| {
- std.debug.assert(scanner.string_is_object_key);
- const state = std.meta.stringToEnum(RootState, s) orelse {
- try scanner.skipValue();
- continue;
- };
+pub fn loadFromXmlDir(arena_allocator: Allocator, tmp_allocator: Allocator, xml_dir_path: []const u8) !DocDatabase {
+ var db: DocDatabase = .{};
- switch (state) {
- .builtin_classes => try db.parseClasses(.builtin_class, arena_allocator, scanner),
- .classes => try db.parseClasses(.class, arena_allocator, scanner),
- .utility_functions => try db.parseGlobalMethods(arena_allocator, scanner),
- else => continue,
+ var dir = try std.fs.openDirAbsolute(xml_dir_path, .{ .iterate = true });
+ defer dir.close();
+
+ // First pass: collect all files, parse them, register classes.
+ // We need two passes for GlobalScope precedence, but we can do it in one
+ // by deferring global registration.
+ const GlobalEntry = struct {
+ key: []const u8,
+ entry: Entry,
+ };
+ var global_scope_entries: ArrayList(GlobalEntry) = .empty;
+ defer global_scope_entries.deinit(tmp_allocator);
+ var gdscript_entries: ArrayList(GlobalEntry) = .empty;
+ defer gdscript_entries.deinit(tmp_allocator);
+
+ var iter = dir.iterate();
+ while (iter.next() catch return error.ReadFailed) |dir_entry| {
+ if (dir_entry.kind != .file) continue;
+ if (!std.mem.endsWith(u8, dir_entry.name, ".xml")) continue;
+
+ const content = dir.readFileAlloc(tmp_allocator, dir_entry.name, 2 * 1024 * 1024) catch continue;
+ defer tmp_allocator.free(content);
+
+ const class_doc = XmlDocParser.parseClassDoc(arena_allocator, content) catch |err| {
+ const class_name = dir_entry.name[0 .. dir_entry.name.len - 4];
+ parser_log.warn("failed to parse XML doc for {s}: {}", .{ class_name, err });
+ continue;
+ };
+
+ // Convert tutorials
+ const db_tutorials: ?[]const Tutorial = if (class_doc.tutorials) |tutorials| blk: {
+ const result = try arena_allocator.alloc(Tutorial, tutorials.len);
+ for (tutorials, 0..) |t, i| {
+ result[i] = .{ .title = t.title, .url = t.url };
+ }
+ break :blk result;
+ } else null;
+
+ // Convert BBCode descriptions to Markdown
+ const description = if (class_doc.description) |desc|
+ bbcodeToMarkdown(arena_allocator, desc) catch desc
+ else
+ null;
+ const brief_description = if (class_doc.brief_description) |desc|
+ bbcodeToMarkdown(arena_allocator, desc) catch desc
+ else
+ null;
+
+ // Create class entry
+ const class_key = class_doc.name;
+ try db.symbols.put(arena_allocator, class_key, .{
+ .key = class_key,
+ .name = class_key,
+ .kind = .class,
+ .description = description,
+ .brief_description = brief_description,
+ .inherits = class_doc.inherits,
+ .tutorials = db_tutorials,
+ });
+ const class_idx = db.symbols.getIndex(class_key).?;
+
+ var member_indices: ArrayList(usize) = .empty;
+ defer member_indices.deinit(tmp_allocator);
+
+ const is_global_scope = std.mem.eql(u8, class_doc.name, "@GlobalScope");
+ const is_gdscript = std.mem.eql(u8, class_doc.name, "@GDScript");
+
+ // Process methods
+ if (class_doc.methods) |methods| {
+ for (methods) |method| {
+ const sig = try buildMethodSignature(arena_allocator, method);
+ const dotted_key = try std.fmt.allocPrint(arena_allocator, "{s}.{s}", .{ class_doc.name, method.name });
+ const child_kind: EntryKind = if (is_global_scope or is_gdscript) .global_function else .method;
+ const desc = if (method.description) |d|
+ bbcodeToMarkdown(arena_allocator, d) catch d
+ else
+ null;
+ const child: Entry = .{
+ .key = dotted_key,
+ .name = method.name,
+ .parent_index = class_idx,
+ .kind = child_kind,
+ .description = desc,
+ .signature = sig,
+ .qualifiers = method.qualifiers,
+ };
+ try db.symbols.put(arena_allocator, dotted_key, child);
+ const child_idx = db.symbols.getIndex(dotted_key).?;
+ try member_indices.append(tmp_allocator, child_idx);
+
+ // Track for top-level registration
+ if (is_global_scope) {
+ try global_scope_entries.append(tmp_allocator, .{ .key = method.name, .entry = child });
+ } else if (is_gdscript) {
+ try gdscript_entries.append(tmp_allocator, .{ .key = method.name, .entry = child });
}
- },
- .end_of_document => break,
- else => {},
+ }
}
- }
- return db;
-}
+ // Process properties
+ if (class_doc.properties) |properties| {
+ for (properties) |prop| {
+ const sig = try buildPropertySignature(arena_allocator, prop);
+ const dotted_key = try std.fmt.allocPrint(arena_allocator, "{s}.{s}", .{ class_doc.name, prop.name });
+ const desc = if (prop.description) |d|
+ bbcodeToMarkdown(arena_allocator, d) catch d
+ else
+ null;
+ const child: Entry = .{
+ .key = dotted_key,
+ .name = prop.name,
+ .parent_index = class_idx,
+ .kind = .property,
+ .description = desc,
+ .signature = sig,
+ .default_value = prop.default_value,
+ };
+ try db.symbols.put(arena_allocator, dotted_key, child);
+ const child_idx = db.symbols.getIndex(dotted_key).?;
+ try member_indices.append(tmp_allocator, child_idx);
+ }
+ }
-fn parseGlobalMethods(self: *DocDatabase, allocator: Allocator, scanner: *Scanner) !void {
- std.debug.assert(try scanner.next() == .array_begin);
-
- while (true) {
- const token = try scanner.next();
- switch (token) {
- .object_begin => {
- const method = try self.parseEntry(.global_function, allocator, scanner);
- try self.symbols.put(allocator, method.key, method);
- },
- .array_end => break,
- .end_of_document => unreachable,
- else => {},
+ // Process signals
+ if (class_doc.signals) |signals| {
+ for (signals) |signal| {
+ const sig = try buildSignalSignature(arena_allocator, signal);
+ const dotted_key = try std.fmt.allocPrint(arena_allocator, "{s}.{s}", .{ class_doc.name, signal.name });
+ const desc = if (signal.description) |d|
+ bbcodeToMarkdown(arena_allocator, d) catch d
+ else
+ null;
+ const child: Entry = .{
+ .key = dotted_key,
+ .name = signal.name,
+ .parent_index = class_idx,
+ .kind = .signal,
+ .description = desc,
+ .signature = sig,
+ };
+ try db.symbols.put(arena_allocator, dotted_key, child);
+ const child_idx = db.symbols.getIndex(dotted_key).?;
+ try member_indices.append(tmp_allocator, child_idx);
+ }
}
- }
-}
-fn parseClasses(self: *DocDatabase, comptime kind: EntryKind, allocator: Allocator, scanner: *Scanner) !void {
- std.debug.assert(try scanner.next() == .array_begin);
-
- while (true) {
- const token = try scanner.next();
- switch (token) {
- .object_begin => {
- try self.parseClass(kind, allocator, scanner);
- },
- .array_end => break,
- .end_of_document => unreachable,
- else => {},
+ // Process constants (with enum grouping)
+ if (class_doc.constants) |constants| {
+ for (constants) |constant| {
+ const sig = try buildConstantSignature(arena_allocator, constant);
+ const desc = if (constant.description) |d|
+ bbcodeToMarkdown(arena_allocator, d) catch d
+ else
+ null;
+ if (constant.qualifiers) |enum_name| {
+ // Enum-grouped constant: "ClassName.EnumName.VALUE_NAME"
+ const dotted_key = try std.fmt.allocPrint(arena_allocator, "{s}.{s}.{s}", .{ class_doc.name, enum_name, constant.name });
+ const child: Entry = .{
+ .key = dotted_key,
+ .name = constant.name,
+ .parent_index = class_idx,
+ .kind = .enum_value,
+ .description = desc,
+ .signature = sig,
+ .qualifiers = constant.qualifiers,
+ .default_value = constant.default_value,
+ };
+ try db.symbols.put(arena_allocator, dotted_key, child);
+ const child_idx = db.symbols.getIndex(dotted_key).?;
+ try member_indices.append(tmp_allocator, child_idx);
+ } else {
+ // Regular constant: "ClassName.CONSTANT_NAME"
+ const dotted_key = try std.fmt.allocPrint(arena_allocator, "{s}.{s}", .{ class_doc.name, constant.name });
+ const child: Entry = .{
+ .key = dotted_key,
+ .name = constant.name,
+ .parent_index = class_idx,
+ .kind = .constant,
+ .description = desc,
+ .signature = sig,
+ .default_value = constant.default_value,
+ };
+ try db.symbols.put(arena_allocator, dotted_key, child);
+ const child_idx = db.symbols.getIndex(dotted_key).?;
+ try member_indices.append(tmp_allocator, child_idx);
+ }
+ }
}
- }
-}
-const ClassKey = enum {
- name,
- methods,
- properties,
- signals,
- constants,
- description,
- brief_description,
- enums,
-};
+ // Process constructors
+ if (class_doc.constructors) |constructors| {
+ for (constructors) |ctor| {
+ const sig = try buildConstructorSignature(arena_allocator, ctor);
+ const dotted_key = try std.fmt.allocPrint(arena_allocator, "{s}.{s}", .{ class_doc.name, ctor.name });
+ const desc = if (ctor.description) |d|
+ bbcodeToMarkdown(arena_allocator, d) catch d
+ else
+ null;
+ const child: Entry = .{
+ .key = dotted_key,
+ .name = ctor.name,
+ .parent_index = class_idx,
+ .kind = .constructor,
+ .description = desc,
+ .signature = sig,
+ };
+ // Constructors may have duplicate keys (overloads); only keep first
+ if (db.symbols.get(dotted_key) == null) {
+ try db.symbols.put(arena_allocator, dotted_key, child);
+ const child_idx = db.symbols.getIndex(dotted_key).?;
+ try member_indices.append(tmp_allocator, child_idx);
+ }
+ }
+ }
-fn bbcodeToMarkdown(allocator: Allocator, input: []const u8) ![]const u8 {
- var output: std.Io.Writer.Allocating = .init(allocator);
+ // Process operators
+ if (class_doc.operators) |operators| {
+ for (operators) |op| {
+ const sig = try buildOperatorSignature(arena_allocator, op);
+ const dotted_key = try std.fmt.allocPrint(arena_allocator, "{s}.{s}", .{ class_doc.name, op.name });
+ const desc = if (op.description) |d|
+ bbcodeToMarkdown(arena_allocator, d) catch d
+ else
+ null;
+ const child: Entry = .{
+ .key = dotted_key,
+ .name = op.name,
+ .parent_index = class_idx,
+ .kind = .operator,
+ .description = desc,
+ .signature = sig,
+ };
+ if (db.symbols.get(dotted_key) == null) {
+ try db.symbols.put(arena_allocator, dotted_key, child);
+ const child_idx = db.symbols.getIndex(dotted_key).?;
+ try member_indices.append(tmp_allocator, child_idx);
+ }
+ }
+ }
- const bbcode_doc = try bbcodez.loadFromBuffer(allocator, input, .{});
- defer bbcode_doc.deinit();
+ // Update class entry's members
+ if (member_indices.items.len > 0) {
+ const members_slice = try arena_allocator.dupe(usize, member_indices.items);
+ var class_ptr = db.symbols.getPtr(class_key).?;
+ class_ptr.members = members_slice;
+ }
+ }
- try bbcodez.fmt.md.renderDocument(allocator, bbcode_doc, &output.writer, .{});
+ // Register @GDScript functions as top-level (lower precedence)
+ for (gdscript_entries.items) |ge| {
+ if (db.symbols.get(ge.key) == null) {
+ var entry = ge.entry;
+ entry.key = try arena_allocator.dupe(u8, ge.key);
+ try db.symbols.put(arena_allocator, entry.key, entry);
+ }
+ }
- return try output.toOwnedSlice();
+ // Register @GlobalScope functions as top-level (higher precedence, overwrites)
+ for (global_scope_entries.items) |ge| {
+ var entry = ge.entry;
+ entry.key = try arena_allocator.dupe(u8, ge.key);
+ try db.symbols.put(arena_allocator, entry.key, entry);
+ }
+
+ return db;
}
-fn parseClass(self: *DocDatabase, comptime kind: EntryKind, allocator: Allocator, scanner: *Scanner) !void {
- var entry: Entry = .{
- .name = undefined,
- .key = undefined,
- .kind = kind,
- };
+fn buildMethodSignature(allocator: Allocator, method: XmlDocParser.MemberDoc) !?[]const u8 {
+ var buf: std.Io.Writer.Allocating = .init(allocator);
+ errdefer buf.deinit();
- var methods: []Entry = &.{};
- var properties: []Entry = &.{};
- var signals: []Entry = &.{};
- var constants: []Entry = &.{};
- var enums: []Entry = &.{};
-
- while (true) {
- const token = try scanner.next();
- switch (token) {
- .string => |s| {
- std.debug.assert(scanner.string_is_object_key);
- const class_key = std.meta.stringToEnum(ClassKey, s) orelse {
- try scanner.skipValue();
- continue;
- };
+ try buf.writer.writeByte('(');
+ try writeParams(&buf.writer, method.params);
+ try buf.writer.writeByte(')');
- switch (class_key) {
- .name => {
- const name = try scanner.next();
- std.debug.assert(name == .string);
-
- entry.name = try allocator.dupe(u8, name.string);
- entry.key = entry.name;
- },
- .methods => methods = try self.parseEntryArray(.method, allocator, scanner),
- .properties => properties = try self.parseEntryArray(.property, allocator, scanner),
- .signals => signals = try self.parseEntryArray(.signal, allocator, scanner),
- .constants => constants = try self.parseEntryArray(.constant, allocator, scanner),
- .enums => enums = try self.parseEntryArray(.enum_value, allocator, scanner),
- .brief_description => entry.brief_description = try nextTokenToMarkdownAlloc(allocator, scanner),
- .description => entry.description = try nextTokenToMarkdownAlloc(allocator, scanner),
- }
- },
- .object_end => break,
- .end_of_document => unreachable,
- else => {},
+ if (method.return_type) |rt| {
+ if (!std.mem.eql(u8, rt, "void")) {
+ try buf.writer.print(" -> {s}", .{rt});
}
}
- try self.symbols.put(allocator, entry.key, entry);
- const entry_idx = self.symbols.getIndex(entry.name).?;
-
- const member_count = methods.len + properties.len + signals.len + constants.len + enums.len;
-
- var member_indices: ArrayList(usize) = .empty;
- defer member_indices.deinit(allocator);
- try member_indices.ensureTotalCapacity(allocator, member_count);
-
- try self.appendEntries(allocator, entry, entry_idx, methods, &member_indices);
- try self.appendEntries(allocator, entry, entry_idx, properties, &member_indices);
- try self.appendEntries(allocator, entry, entry_idx, signals, &member_indices);
- try self.appendEntries(allocator, entry, entry_idx, constants, &member_indices);
- try self.appendEntries(allocator, entry, entry_idx, enums, &member_indices);
+ return try buf.toOwnedSlice();
+}
- if (member_indices.items.len > 0) {
- var entry_ptr = self.symbols.getPtr(entry.key).?;
- entry_ptr.members = try member_indices.toOwnedSlice(allocator);
+fn buildPropertySignature(allocator: Allocator, prop: XmlDocParser.MemberDoc) !?[]const u8 {
+ if (prop.return_type) |rt| {
+ return try std.fmt.allocPrint(allocator, ": {s}", .{rt});
}
+ return null;
}
-fn appendEntries(self: *DocDatabase, allocator: Allocator, parent: Entry, parent_idx: usize, entries: []Entry, indices: *ArrayList(usize)) !void {
- for (entries) |*property_entry| {
- property_entry.parent_index = parent_idx;
- property_entry.key = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ parent.name, property_entry.name });
+fn buildConstructorSignature(allocator: Allocator, ctor: XmlDocParser.MemberDoc) !?[]const u8 {
+ var buf: std.Io.Writer.Allocating = .init(allocator);
+ errdefer buf.deinit();
- // store property entry in the database
- try self.symbols.put(allocator, property_entry.key, property_entry.*);
+ try buf.writer.writeByte('(');
+ try writeParams(&buf.writer, ctor.params);
+ try buf.writer.writeByte(')');
- // update property index on the parent entry
- const property_index = self.symbols.getIndex(property_entry.key).?;
- indices.appendAssumeCapacity(property_index);
- }
+ return try buf.toOwnedSlice();
}
-const MethodKey = enum {
- name,
- description,
-};
-
-const PropertyKey = enum {
- name,
- type,
- getter,
- setter,
- description,
-};
-
-const ConstantKey = enum {
- name,
- description,
-};
+fn buildOperatorSignature(allocator: Allocator, op: XmlDocParser.MemberDoc) !?[]const u8 {
+ var buf: std.Io.Writer.Allocating = .init(allocator);
+ errdefer buf.deinit();
-const SignalKey = enum {
- name,
- description,
-};
+ try buf.writer.writeByte('(');
+ try writeParams(&buf.writer, op.params);
+ try buf.writer.writeByte(')');
-const EnumKey = enum {
- name,
- description,
-};
+ if (op.return_type) |rt| {
+ try buf.writer.print(" -> {s}", .{rt});
+ }
-const kind_key_map: std.StaticStringMap(type) = .initComptime(.{
- .{ @tagName(EntryKind.method), MethodKey },
- .{ @tagName(EntryKind.constant), ConstantKey },
- .{ @tagName(EntryKind.signal), SignalKey },
- .{ @tagName(EntryKind.enum_value), EnumKey },
- .{ @tagName(EntryKind.property), PropertyKey },
- .{ @tagName(EntryKind.global_function), MethodKey },
-});
-
-const constant_handler_map: std.StaticStringMap(*const fn (Allocator, *Entry, *Scanner) anyerror!void) = .initComptime(.{
- .{ @tagName(ConstantKey.name), handleEntryName },
- .{ @tagName(ConstantKey.description), handleEntryDescription },
-});
-
-const method_handler_map: std.StaticStringMap(*const fn (Allocator, *Entry, *Scanner) anyerror!void) = .initComptime(.{
- .{ @tagName(MethodKey.name), handleEntryName },
- .{ @tagName(MethodKey.description), handleEntryDescription },
-});
-
-const signal_handler_map: std.StaticStringMap(*const fn (Allocator, *Entry, *Scanner) anyerror!void) = .initComptime(.{
- .{ @tagName(SignalKey.name), handleEntryName },
- .{ @tagName(SignalKey.description), handleEntryDescription },
-});
-
-const enum_value_handler_map: std.StaticStringMap(*const fn (Allocator, *Entry, *Scanner) anyerror!void) = .initComptime(.{
- .{ @tagName(EnumKey.name), handleEntryName },
- .{ @tagName(EnumKey.description), handleEntryDescription },
-});
-
-const property_handler_map: std.StaticStringMap(*const fn (Allocator, *Entry, *Scanner) anyerror!void) = .initComptime(.{
- .{ @tagName(PropertyKey.name), handleEntryName },
- .{ @tagName(PropertyKey.type), handlePropertyType },
- .{ @tagName(PropertyKey.getter), skipValue },
- .{ @tagName(PropertyKey.setter), skipValue },
- // TODO: bbcodez throws for some reason
- // .{ @tagName(PropertyKey.description), handleEntryDescription },
-});
-
-const kind_handler_map: std.StaticStringMap(std.StaticStringMap(*const fn (Allocator, *Entry, *Scanner) anyerror!void)) = .initComptime(.{
- .{ @tagName(EntryKind.constant), constant_handler_map },
- .{ @tagName(EntryKind.signal), signal_handler_map },
- .{ @tagName(EntryKind.enum_value), enum_value_handler_map },
- .{ @tagName(EntryKind.property), property_handler_map },
- .{ @tagName(EntryKind.method), method_handler_map },
- .{ @tagName(EntryKind.global_function), method_handler_map },
-});
-
-fn handleEntryName(allocator: Allocator, entry: *Entry, scanner: *Scanner) anyerror!void {
- const name = try scanner.next();
- std.debug.assert(name == .string);
-
- entry.name = try allocator.dupe(u8, name.string);
- entry.key = entry.name;
+ return try buf.toOwnedSlice();
}
-fn handlePropertyType(allocator: Allocator, entry: *Entry, scanner: *Scanner) anyerror!void {
- const @"type" = try scanner.next();
- std.debug.assert(@"type" == .string);
- entry.signature = try std.fmt.allocPrint(allocator, ": {s}", .{@"type".string});
-}
+fn buildSignalSignature(allocator: Allocator, signal: XmlDocParser.MemberDoc) !?[]const u8 {
+ if (signal.params) |_| {
+ var buf: std.Io.Writer.Allocating = .init(allocator);
+ errdefer buf.deinit();
-fn handleEntryDescription(allocator: Allocator, entry: *Entry, scanner: *Scanner) anyerror!void {
- entry.description = try nextTokenToMarkdownAlloc(allocator, scanner);
-}
+ try buf.writer.writeByte('(');
+ try writeParams(&buf.writer, signal.params);
+ try buf.writer.writeByte(')');
-fn skipValue(allocator: Allocator, entry: *Entry, scanner: *Scanner) anyerror!void {
- _ = allocator;
- _ = entry;
- try scanner.skipValue();
+ return try buf.toOwnedSlice();
+ }
+ return null;
}
-fn parseEntry(self: *const DocDatabase, comptime kind: EntryKind, allocator: Allocator, scanner: *Scanner) !Entry {
- _ = self; // autofix
-
- var entry: Entry = .{
- .name = undefined,
- .key = undefined,
- .kind = kind,
- };
-
- const KeyType = kind_key_map.get(@tagName(kind)) orelse @compileError("No key type found for kind: " ++ @tagName(kind));
- const handlers = kind_handler_map.get(@tagName(kind)) orelse std.debug.panic("No handlers found for kind: {}", .{kind});
-
- while (true) {
- const token = try scanner.next();
- switch (token) {
- .string => |s| {
- std.debug.assert(scanner.string_is_object_key);
- const key = std.meta.stringToEnum(KeyType, s) orelse {
- try scanner.skipValue();
- continue;
- };
-
- const handler = handlers.get(@tagName(key)) orelse {
- parser_log.warn("No handler found for key: {s}.{s}", .{ @tagName(kind), @tagName(key) });
- try scanner.skipValue();
- continue;
- };
-
- try handler(allocator, &entry, scanner);
- },
- .object_end => break,
- .end_of_document => unreachable,
- else => {},
- }
+fn buildConstantSignature(allocator: Allocator, constant: XmlDocParser.MemberDoc) !?[]const u8 {
+ if (constant.default_value) |val| {
+ return try std.fmt.allocPrint(allocator, " = {s}", .{val});
}
-
- return entry;
+ return null;
}
-fn parseEntryArray(self: *const DocDatabase, comptime kind: EntryKind, allocator: Allocator, scanner: *Scanner) ![]Entry {
- var entries: ArrayList(Entry) = .empty;
- defer entries.deinit(allocator);
-
- const constants_token = try scanner.next();
- std.debug.assert(constants_token == .array_begin);
-
- while (true) {
- const constant_token = try scanner.next();
- switch (constant_token) {
- .object_begin => {
- try entries.append(allocator, try self.parseEntry(kind, allocator, scanner));
- },
- .array_end => break,
- .end_of_document => unreachable,
- else => {},
+fn writeParams(writer: *std.Io.Writer, params: ?[]XmlDocParser.ParamDoc) !void {
+ if (params) |ps| {
+ for (ps, 0..) |p, i| {
+ if (i > 0) try writer.writeAll(", ");
+ try writer.print("{s}: {s}", .{ p.name, p.type });
+ if (p.default_value) |dv| {
+ try writer.print(" = {s}", .{dv});
+ }
}
}
-
- return entries.toOwnedSlice(allocator);
-}
-
-fn nextTokenToMarkdownAlloc(allocator: Allocator, scanner: *Scanner) ![]const u8 {
- const token = try scanner.nextAlloc(allocator, .alloc_if_needed);
-
- const value = switch (token) {
- inline .string, .allocated_string => |str| str,
- else => unreachable,
- };
-
- return try bbcodeToMarkdown(allocator, value);
-}
-
-pub fn lookupSymbolExact(self: DocDatabase, symbol: []const u8) DocDatabase.Error!Entry {
- return self.symbols.get(symbol) orelse return DocDatabase.Error.SymbolNotFound;
}
fn generateMarkdownForEntry(self: DocDatabase, allocator: Allocator, entry: Entry, writer: *Writer) !void {
@@ -414,6 +417,10 @@ fn generateMarkdownForEntry(self: DocDatabase, allocator: Allocator, entry: Entr
try writer.writeByte('\n');
+ if (entry.inherits) |inherits| {
+ try writer.print("\n*Inherits: {s}*\n", .{inherits});
+ }
+
if (entry.parent_index) |parent_index| {
const parent = self.symbols.values()[parent_index];
try writer.print("\n**Parent**: {s}\n", .{parent.name});
@@ -442,14 +449,18 @@ fn generateMarkdownForEntry(self: DocDatabase, allocator: Allocator, entry: Entr
}
fn generateMemberListings(self: DocDatabase, allocator: Allocator, member_indices: []usize, writer: *Writer) !void {
+ var constructors: ArrayList(usize) = .empty;
var properties: ArrayList(usize) = .empty;
var methods: ArrayList(usize) = .empty;
+ var operators: ArrayList(usize) = .empty;
var signals: ArrayList(usize) = .empty;
var constants: ArrayList(usize) = .empty;
var enums: ArrayList(usize) = .empty;
+ defer constructors.deinit(allocator);
defer properties.deinit(allocator);
defer methods.deinit(allocator);
+ defer operators.deinit(allocator);
defer signals.deinit(allocator);
defer constants.deinit(allocator);
defer enums.deinit(allocator);
@@ -457,8 +468,10 @@ fn generateMemberListings(self: DocDatabase, allocator: Allocator, member_indice
for (member_indices) |idx| {
const member: Entry = self.symbols.values()[idx];
switch (member.kind) {
+ .constructor => try constructors.append(allocator, idx),
.property => try properties.append(allocator, idx),
.method => try methods.append(allocator, idx),
+ .operator => try operators.append(allocator, idx),
.signal => try signals.append(allocator, idx),
.constant => try constants.append(allocator, idx),
.enum_value => try enums.append(allocator, idx),
@@ -466,8 +479,10 @@ fn generateMemberListings(self: DocDatabase, allocator: Allocator, member_indice
}
}
+ try self.formatMemberSection("Constructors", constructors.items, writer);
try self.formatMemberSection("Properties", properties.items, writer);
try self.formatMemberSection("Methods", methods.items, writer);
+ try self.formatMemberSection("Operators", operators.items, writer);
try self.formatMemberSection("Signals", signals.items, writer);
try self.formatMemberSection("Constants", constants.items, writer);
try self.formatMemberSection("Enums", enums.items, writer);
@@ -491,7 +506,15 @@ fn formatMemberLine(self: DocDatabase, member_idx: usize, writer: *Writer) !void
try writer.writeAll(sig);
}
- try writer.writeAll("**");
+ if (member.qualifiers) |quals| {
+ try writer.print("** `{s}`", .{quals});
+ } else {
+ try writer.writeAll("**");
+ }
+
+ if (member.default_value) |default| {
+ try writer.print(" = `{s}`", .{default});
+ }
if (member.brief_description) |brief| {
try writer.print(" - {s}", .{brief});
@@ -508,493 +531,6 @@ pub fn generateMarkdownForSymbol(self: DocDatabase, allocator: Allocator, symbol
try self.generateMarkdownForEntry(allocator, self.symbols.get(symbol) orelse return error.SymbolNotFound, writer);
}
-test "parse simple builtin class from JSON" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- // Minimal JSON with one builtin class
- const json_source =
- \\{
- \\ "builtin_classes": [
- \\ {
- \\ "name": "bool"
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
-
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Verify the class was parsed
- const entry = db.symbols.get("bool");
- try std.testing.expect(entry != null);
- try std.testing.expectEqualStrings("bool", entry.?.name);
- try std.testing.expectEqual(EntryKind.builtin_class, entry.?.kind);
-}
-
-test "parse regular class from JSON" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- const json_source =
- \\{
- \\ "classes": [
- \\ {
- \\ "name": "Node2D"
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- const entry = db.symbols.get("Node2D");
- try std.testing.expect(entry != null);
- try std.testing.expectEqualStrings("Node2D", entry.?.name);
- try std.testing.expectEqual(EntryKind.class, entry.?.kind);
-}
-
-test "parse method with parent" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- const json_source =
- \\{
- \\ "classes": [
- \\ {
- \\ "name": "Node2D",
- \\ "methods": [
- \\ {
- \\ "name": "get_global_position"
- \\ }
- \\ ]
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Verify the class exists
- const class_entry = db.symbols.get("Node2D");
- try std.testing.expect(class_entry != null);
-
- // Verify the method exists with correct full_path
- const method_entry = db.symbols.get("Node2D.get_global_position");
- try std.testing.expect(method_entry != null);
- try std.testing.expectEqualStrings("get_global_position", method_entry.?.name);
- try std.testing.expectEqualStrings("Node2D.get_global_position", method_entry.?.key);
- try std.testing.expectEqual(EntryKind.method, method_entry.?.kind);
-
- // Verify parent points to the class
- try std.testing.expect(method_entry.?.parent_index != null);
-
- const parent = db.symbols.values()[method_entry.?.parent_index.?];
- try std.testing.expectEqualStrings("Node2D", parent.name);
-}
-
-test "parse utility functions as global functions" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- const json_source =
- \\{
- \\ "utility_functions": [
- \\ {
- \\ "name": "sin"
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- const entry = db.symbols.get("sin");
- try std.testing.expect(entry != null);
- try std.testing.expectEqualStrings("sin", entry.?.name);
- try std.testing.expectEqualStrings("sin", entry.?.key);
- try std.testing.expectEqual(EntryKind.global_function, entry.?.kind);
- try std.testing.expect(entry.?.parent_index == null); // No parent
-}
-
-test "class stores member indices not strings" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- const json_source =
- \\{
- \\ "classes": [
- \\ {
- \\ "name": "Vector2",
- \\ "methods": [
- \\ {
- \\ "name": "normalized"
- \\ },
- \\ {
- \\ "name": "length"
- \\ }
- \\ ]
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Verify the class has members array
- const class_entry = db.symbols.get("Vector2");
- try std.testing.expect(class_entry != null);
- try std.testing.expect(class_entry.?.members != null);
-
- // Should have 2 members
- const members = class_entry.?.members.?;
- try std.testing.expectEqual(@as(usize, 2), members.len);
-
- // Members should be indices into the symbols array
- const first_member = db.symbols.values()[members[0]];
- const second_member = db.symbols.values()[members[1]];
-
- // Verify members are the methods
- try std.testing.expectEqualStrings("normalized", first_member.name);
- try std.testing.expectEqual(EntryKind.method, first_member.kind);
-
- try std.testing.expectEqualStrings("length", second_member.name);
- try std.testing.expectEqual(EntryKind.method, second_member.kind);
-}
-
-test "convert BBCode description to Markdown" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- const json_source =
- \\{
- \\ "classes": [
- \\ {
- \\ "name": "Node2D",
- \\ "brief_description": "A 2D game object with [b]position[/b] and [i]rotation[/i]."
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- const entry = db.symbols.get("Node2D");
- try std.testing.expect(entry != null);
- try std.testing.expect(entry.?.brief_description != null);
-
- // BBCode should be converted to Markdown
- // [b]text[/b] -> **text**
- // [i]text[/i] -> *text*
- const expected = "A 2D game object with **position** and *rotation*.";
- try std.testing.expectEqualStrings(expected, entry.?.brief_description.?);
-}
-
-test "skip unknown root-level keys like header" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- // JSON with all unknown root-level keys that should be skipped
- const json_source =
- \\{
- \\ "header": {
- \\ "version_major": 4,
- \\ "version_minor": 5
- \\ },
- \\ "builtin_class_sizes": [
- \\ {
- \\ "build_configuration": "float_32",
- \\ "sizes": [
- \\ {
- \\ "name": "bool",
- \\ "size": 1
- \\ }
- \\ ]
- \\ }
- \\ ],
- \\ "builtin_class_member_offsets": [],
- \\ "global_constants": [],
- \\ "global_enums": [],
- \\ "native_structures": [],
- \\ "singletons": [],
- \\ "classes": [
- \\ {
- \\ "name": "Node2D"
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Should successfully parse despite unknown keys
- const entry = db.symbols.get("Node2D");
- try std.testing.expect(entry != null);
- try std.testing.expectEqualStrings("Node2D", entry.?.name);
-}
-
-test "skip unknown method fields like return_type" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- // JSON with method containing fields beyond just "name"
- const json_source =
- \\{
- \\ "utility_functions": [
- \\ {
- \\ "name": "sin",
- \\ "return_type": "float",
- \\ "category": "math",
- \\ "is_vararg": false,
- \\ "hash": 12345,
- \\ "arguments": []
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Should successfully parse method despite unknown fields
- const entry = db.symbols.get("sin");
- try std.testing.expect(entry != null);
- try std.testing.expectEqualStrings("sin", entry.?.name);
- try std.testing.expectEqual(EntryKind.global_function, entry.?.kind);
-}
-
-test "skip unknown class fields like api_type" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- // JSON with class containing fields beyond name/methods/brief_description
- const json_source =
- \\{
- \\ "classes": [
- \\ {
- \\ "name": "Node2D",
- \\ "api_type": "core",
- \\ "inherits": "CanvasItem",
- \\ "is_instantiable": true,
- \\ "is_refcounted": false,
- \\ "description": "A 2D game object.",
- \\ "enums": []
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Should successfully parse class despite unknown fields
- const entry = db.symbols.get("Node2D");
- try std.testing.expect(entry != null);
- try std.testing.expectEqualStrings("Node2D", entry.?.name);
- try std.testing.expectEqual(EntryKind.class, entry.?.kind);
-}
-
-// RED PHASE: Test parsing properties from class
-test "parse class with properties array" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- const json_source =
- \\{
- \\ "classes": [
- \\ {
- \\ "name": "Node2D",
- \\ "properties": [
- \\ {
- \\ "name": "position",
- \\ "type": "Vector2",
- \\ "setter": "set_position",
- \\ "getter": "get_position"
- \\ }
- \\ ]
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Verify the class has members with the property
- const class_entry = db.symbols.get("Node2D");
- try std.testing.expect(class_entry != null);
- try std.testing.expect(class_entry.?.members != null);
- try std.testing.expectEqual(@as(usize, 1), class_entry.?.members.?.len);
-
- // Verify the property entry exists
- const property_entry = db.symbols.get("Node2D.position");
- try std.testing.expect(property_entry != null);
- try std.testing.expectEqualStrings("position", property_entry.?.name);
- try std.testing.expectEqualStrings("Node2D.position", property_entry.?.key);
- try std.testing.expectEqual(EntryKind.property, property_entry.?.kind);
-
- // Verify property is in the class members
- const member = db.symbols.values()[class_entry.?.members.?[0]];
- try std.testing.expectEqualStrings("position", member.name);
- try std.testing.expectEqual(EntryKind.property, member.kind);
-}
-
-// RED PHASE: Test parsing signals from class
-test "parse class with signals array" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- const json_source =
- \\{
- \\ "classes": [
- \\ {
- \\ "name": "Area2D",
- \\ "signals": [
- \\ {
- \\ "name": "body_entered",
- \\ "description": "Emitted when a body enters."
- \\ }
- \\ ]
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Verify the class has members with the signal
- const class_entry = db.symbols.get("Area2D");
- try std.testing.expect(class_entry != null);
- try std.testing.expect(class_entry.?.members != null);
- try std.testing.expectEqual(@as(usize, 1), class_entry.?.members.?.len);
-
- // Verify the signal entry exists
- const signal_entry = db.symbols.get("Area2D.body_entered");
- try std.testing.expect(signal_entry != null);
- try std.testing.expectEqualStrings("body_entered", signal_entry.?.name);
- try std.testing.expectEqualStrings("Area2D.body_entered", signal_entry.?.key);
- try std.testing.expectEqual(EntryKind.signal, signal_entry.?.kind);
-
- // Verify signal is in the class members
- const member = db.symbols.values()[class_entry.?.members.?[0]];
- try std.testing.expectEqualStrings("body_entered", member.name);
- try std.testing.expectEqual(EntryKind.signal, member.kind);
-}
-
-// RED PHASE: Test parsing constants from class
-test "parse class with constants array" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- const json_source =
- \\{
- \\ "classes": [
- \\ {
- \\ "name": "Node",
- \\ "constants": [
- \\ {
- \\ "name": "NOTIFICATION_READY",
- \\ "value": 30
- \\ }
- \\ ]
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Verify the class has members with the constant
- const class_entry = db.symbols.get("Node");
- try std.testing.expect(class_entry != null);
- try std.testing.expect(class_entry.?.members != null);
- try std.testing.expectEqual(@as(usize, 1), class_entry.?.members.?.len);
-
- // Verify the constant entry exists
- const constant_entry = db.symbols.get("Node.NOTIFICATION_READY");
- try std.testing.expect(constant_entry != null);
- try std.testing.expectEqualStrings("NOTIFICATION_READY", constant_entry.?.name);
- try std.testing.expectEqualStrings("Node.NOTIFICATION_READY", constant_entry.?.key);
- try std.testing.expectEqual(EntryKind.constant, constant_entry.?.kind);
-
- // Verify constant is in the class members
- const member = db.symbols.values()[class_entry.?.members.?[0]];
- try std.testing.expectEqualStrings("NOTIFICATION_READY", member.name);
- try std.testing.expectEqual(EntryKind.constant, member.kind);
-}
-
-// RED PHASE: Test parsing enums from class
-test "parse class with enums array" {
- var arena = ArenaAllocator.init(std.testing.allocator);
- const allocator = arena.allocator();
- defer arena.deinit();
-
- const json_source =
- \\{
- \\ "classes": [
- \\ {
- \\ "name": "AESContext",
- \\ "enums": [
- \\ {
- \\ "name": "Mode",
- \\ "is_bitfield": false,
- \\ "values": [
- \\ {
- \\ "name": "MODE_ECB_ENCRYPT",
- \\ "value": 0
- \\ }
- \\ ]
- \\ }
- \\ ]
- \\ }
- \\ ]
- \\}
- ;
-
- var json_scanner = Scanner.initCompleteInput(allocator, json_source);
- const db = try DocDatabase.loadFromJsonLeaky(allocator, &json_scanner);
-
- // Verify the class has members with the enum
- const class_entry = db.symbols.get("AESContext");
- try std.testing.expect(class_entry != null);
- try std.testing.expect(class_entry.?.members != null);
- try std.testing.expectEqual(@as(usize, 1), class_entry.?.members.?.len);
-
- // Verify the enum value entry exists
- const enum_entry = db.symbols.get("AESContext.Mode");
- try std.testing.expect(enum_entry != null);
- try std.testing.expectEqualStrings("Mode", enum_entry.?.name);
- try std.testing.expectEqualStrings("AESContext.Mode", enum_entry.?.key);
- try std.testing.expectEqual(EntryKind.enum_value, enum_entry.?.kind);
-
- // Verify enum value is in the class members
- const member = db.symbols.values()[class_entry.?.members.?[0]];
- try std.testing.expectEqualStrings("Mode", member.name);
- try std.testing.expectEqual(EntryKind.enum_value, member.kind);
-}
-
// RED PHASE: Tests for DocDatabase.generateMarkdownForSymbol using snapshot testing
test "generateMarkdownForSymbol for global function" {
const allocator = std.testing.allocator;
@@ -1313,15 +849,335 @@ test "generateMarkdownForSymbol for class with tutorials" {
try writer.flush();
}
+test "Entry supports inherits, qualifiers, and default_value fields" {
+ const entry = Entry{
+ .key = "Node2D.position",
+ .name = "position",
+ .kind = .property,
+ .inherits = null,
+ .qualifiers = null,
+ .default_value = "Vector2(0, 0)",
+ };
+ try std.testing.expectEqualStrings("Vector2(0, 0)", entry.default_value.?);
+}
+
+test "EntryKind has constructor value" {
+ const kind: EntryKind = .constructor;
+ try std.testing.expect(kind == .constructor);
+}
+
+test "generateMarkdownForSymbol shows inheritance" {
+ const allocator = std.testing.allocator;
+
+ var db = DocDatabase{ .symbols = StringArrayHashMap(Entry).empty };
+ defer db.symbols.deinit(allocator);
+
+ try db.symbols.put(allocator, "Node2D", Entry{
+ .key = "Node2D",
+ .name = "Node2D",
+ .kind = .class,
+ .inherits = "CanvasItem",
+ .brief_description = "A 2D game object.",
+ });
+
+ var allocating: std.Io.Writer.Allocating = .init(allocator);
+ defer allocating.deinit();
+
+ try db.generateMarkdownForSymbol(allocator, "Node2D", &allocating.writer);
+ const written = allocating.written();
+
+ try std.testing.expect(std.mem.indexOf(u8, written, "*Inherits: CanvasItem*") != null);
+}
+
+test "loadFromXmlDir parses XML files into symbol table" {
+ var arena = ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ var tmp_dir = std.testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+
+ const sprite2d_xml =
+ \\
+ \\
+ \\ A 2D sprite node.
+ \\ Displays a 2D texture.
+ \\
+ \\
+ \\
+ \\ Returns whether the sprite is flipped horizontally.
+ \\
+ \\
+ \\
+ \\ The texture to display.
+ \\
+ \\
+ ;
+
+ try tmp_dir.dir.writeFile(.{ .sub_path = "Sprite2D.xml", .data = sprite2d_xml });
+
+ const tmp_path = try tmp_dir.dir.realpathAlloc(std.testing.allocator, ".");
+ defer std.testing.allocator.free(tmp_path);
+
+ const db = try DocDatabase.loadFromXmlDir(arena.allocator(), std.testing.allocator, tmp_path);
+
+ // Verify class entry
+ const class_entry = db.symbols.get("Sprite2D").?;
+ try std.testing.expectEqual(EntryKind.class, class_entry.kind);
+ try std.testing.expectEqualStrings("Node2D", class_entry.inherits.?);
+ try std.testing.expectEqualStrings("A 2D sprite node.", class_entry.brief_description.?);
+ try std.testing.expectEqualStrings("Displays a 2D texture.", class_entry.description.?);
+
+ // Verify method entry
+ const method_entry = db.symbols.get("Sprite2D.is_flipped_h").?;
+ try std.testing.expectEqual(EntryKind.method, method_entry.kind);
+ try std.testing.expect(method_entry.signature != null);
+ try std.testing.expect(std.mem.indexOf(u8, method_entry.signature.?, "bool") != null);
+ try std.testing.expectEqualStrings("const", method_entry.qualifiers.?);
+
+ // Verify property entry
+ const prop_entry = db.symbols.get("Sprite2D.texture").?;
+ try std.testing.expectEqual(EntryKind.property, prop_entry.kind);
+ try std.testing.expectEqualStrings("null", prop_entry.default_value.?);
+}
+
+test "loadFromXmlDir groups constants with enum attribute as enum_value entries" {
+ var arena = ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ var tmp_dir = std.testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+
+ const node_xml =
+ \\
+ \\
+ \\ Base class.
+ \\ Base node.
+ \\
+ \\ Ready notification.
+ \\ Inherits process mode.
+ \\ Always process.
+ \\
+ \\
+ ;
+
+ try tmp_dir.dir.writeFile(.{ .sub_path = "Node.xml", .data = node_xml });
+
+ const tmp_path = try tmp_dir.dir.realpathAlloc(std.testing.allocator, ".");
+ defer std.testing.allocator.free(tmp_path);
+
+ const db = try DocDatabase.loadFromXmlDir(arena.allocator(), std.testing.allocator, tmp_path);
+
+ // Regular constant
+ const notif_entry = db.symbols.get("Node.NOTIFICATION_READY").?;
+ try std.testing.expectEqual(EntryKind.constant, notif_entry.kind);
+
+ // Enum-grouped constants
+ const inherit_entry = db.symbols.get("Node.ProcessMode.PROCESS_MODE_INHERIT").?;
+ try std.testing.expectEqual(EntryKind.enum_value, inherit_entry.kind);
+
+ const always_entry = db.symbols.get("Node.ProcessMode.PROCESS_MODE_ALWAYS").?;
+ try std.testing.expectEqual(EntryKind.enum_value, always_entry.kind);
+}
+
+test "loadFromXmlDir registers GlobalScope functions as top-level entries" {
+ var arena = ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ var tmp_dir = std.testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+
+ const global_scope_xml =
+ \\
+ \\
+ \\ Global scope.
+ \\ Global scope constants and functions.
+ \\
+ \\
+ \\
+ \\
+ \\ Returns the absolute value.
+ \\
+ \\
+ \\
+ ;
+
+ try tmp_dir.dir.writeFile(.{ .sub_path = "@GlobalScope.xml", .data = global_scope_xml });
+
+ const tmp_path = try tmp_dir.dir.realpathAlloc(std.testing.allocator, ".");
+ defer std.testing.allocator.free(tmp_path);
+
+ const db = try DocDatabase.loadFromXmlDir(arena.allocator(), std.testing.allocator, tmp_path);
+
+ // Verify dotted key exists
+ const dotted_entry = db.symbols.get("@GlobalScope.abs").?;
+ try std.testing.expectEqual(EntryKind.global_function, dotted_entry.kind);
+
+ // Verify top-level entry exists
+ const top_entry = db.symbols.get("abs").?;
+ try std.testing.expectEqual(EntryKind.global_function, top_entry.kind);
+}
+
+test "loadFromXmlDir converts BBCode descriptions to Markdown" {
+ const allocator = std.testing.allocator;
+
+ var tmp_dir = std.testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+ const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
+ defer allocator.free(tmp_path);
+
+ const xml_content =
+ \\
+ \\
+ \\ Has [b]bold[/b] text.
+ \\ Uses [code]code[/code] and [i]italic[/i].
+ \\
+ ;
+ try tmp_dir.dir.writeFile(.{ .sub_path = "TestBBCode.xml", .data = xml_content });
+
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ defer arena.deinit();
+ const db = try DocDatabase.loadFromXmlDir(arena.allocator(), allocator, tmp_path);
+ const entry = db.symbols.get("TestBBCode").?;
+
+ // BBCode should be converted to Markdown
+ try std.testing.expect(std.mem.indexOf(u8, entry.brief_description.?, "**bold**") != null);
+ try std.testing.expect(std.mem.indexOf(u8, entry.description.?, "`code`") != null);
+}
+
+test "XML dir to markdown roundtrip" {
+ const allocator = std.testing.allocator;
+
+ var tmp_dir = std.testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+
+ const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
+ defer allocator.free(tmp_path);
+
+ // Write a realistic XML doc
+ const xml_content =
+ \\
+ \\
+ \\ A test class.
+ \\ A class for testing.
+ \\
+ \\ https://example.com
+ \\
+ \\
+ \\
+ \\
+ \\ Default constructor.
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\ Does a thing.
+ \\
+ \\
+ \\
+ \\ Movement speed.
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\ Multiplies by a scalar.
+ \\
+ \\
+ \\
+ \\ Maximum speed.
+ \\
+ \\
+ ;
+
+ // Write XML to a subdir
+ const xml_dir = try std.fmt.allocPrint(allocator, "{s}/xml", .{tmp_path});
+ defer allocator.free(xml_dir);
+ try std.fs.makeDirAbsolute(xml_dir);
+ const xml_path = try std.fmt.allocPrint(allocator, "{s}/TestClass.xml", .{xml_dir});
+ defer allocator.free(xml_path);
+ try std.fs.cwd().writeFile(.{ .sub_path = xml_path, .data = xml_content });
+
+ // Load from XML
+ var arena_alloc = std.heap.ArenaAllocator.init(allocator);
+ defer arena_alloc.deinit();
+ const db = try DocDatabase.loadFromXmlDir(arena_alloc.allocator(), allocator, xml_dir);
+
+ // Generate markdown cache
+ const cache_dir = try std.fmt.allocPrint(allocator, "{s}/cache", .{tmp_path});
+ defer allocator.free(cache_dir);
+ try cache.generateMarkdownCache(allocator, db, cache_dir);
+
+ // Read back the class markdown
+ var output: std.Io.Writer.Allocating = .init(allocator);
+ defer output.deinit();
+ try cache.readSymbolMarkdown(allocator, "TestClass", cache_dir, &output.writer);
+ const written = output.written();
+
+ // Verify key content
+ try std.testing.expect(std.mem.indexOf(u8, written, "# TestClass") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "*Inherits: RefCounted*") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "## Tutorials") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "## Constructors") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "## Methods") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "## Properties") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "## Operators") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "## Constants") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "do_thing") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "speed") != null);
+ try std.testing.expect(std.mem.indexOf(u8, written, "operator *") != null);
+}
+
+test "loadFromXmlDir skips malformed XML files" {
+ const allocator = std.testing.allocator;
+
+ var tmp_dir = std.testing.tmpDir(.{});
+ defer tmp_dir.cleanup();
+ const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
+ defer allocator.free(tmp_path);
+
+ try tmp_dir.dir.writeFile(.{ .sub_path = "Bad.xml", .data = "\n
+ \\
+ \\ Base class.
+ \\ Base node class.
+ \\
+ });
+
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ defer arena.deinit();
+
+ const db = try DocDatabase.loadFromXmlDir(arena.allocator(), allocator, tmp_path);
+ const result = db.lookupSymbolExact("NonExistent");
+ try std.testing.expectError(DocDatabase.Error.SymbolNotFound, result);
+}
+
const std = @import("std");
const ArenaAllocator = std.heap.ArenaAllocator;
const Allocator = std.mem.Allocator;
-const Scanner = std.json.Scanner;
-const Reader = std.json.Reader;
-const Token = std.json.Token;
const StringArrayHashMap = std.StringArrayHashMapUnmanaged;
const ArrayList = std.ArrayListUnmanaged;
-const File = std.fs.File;
const Writer = std.Io.Writer;
const bbcodez = @import("bbcodez");
+const cache = @import("cache.zig");
+const XmlDocParser = @import("XmlDocParser.zig");
diff --git a/src/XmlDocParser.zig b/src/XmlDocParser.zig
index 155c953..4c89d75 100644
--- a/src/XmlDocParser.zig
+++ b/src/XmlDocParser.zig
@@ -9,9 +9,19 @@ pub const Tutorial = struct {
url: []const u8,
};
+pub const ParamDoc = struct {
+ name: []const u8,
+ type: []const u8,
+ default_value: ?[]const u8 = null,
+};
+
pub const MemberDoc = struct {
name: []const u8,
description: ?[]const u8 = null,
+ qualifiers: ?[]const u8 = null,
+ default_value: ?[]const u8 = null,
+ return_type: ?[]const u8 = null,
+ params: ?[]ParamDoc = null,
};
pub const ClassDoc = struct {
@@ -24,6 +34,8 @@ pub const ClassDoc = struct {
properties: ?[]MemberDoc = null,
signals: ?[]MemberDoc = null,
constants: ?[]MemberDoc = null,
+ constructors: ?[]MemberDoc = null,
+ operators: ?[]MemberDoc = null,
};
pub const ParseError = error{
@@ -53,6 +65,10 @@ pub fn parseClassDoc(allocator: Allocator, xml_content: []const u8) ParseError!C
defer signals.deinit(allocator);
var constants: std.ArrayListUnmanaged(MemberDoc) = .empty;
defer constants.deinit(allocator);
+ var constructors: std.ArrayListUnmanaged(MemberDoc) = .empty;
+ defer constructors.deinit(allocator);
+ var operators: std.ArrayListUnmanaged(MemberDoc) = .empty;
+ defer operators.deinit(allocator);
var found_class = false;
@@ -80,21 +96,39 @@ pub fn parseClassDoc(allocator: Allocator, xml_content: []const u8) ParseError!C
}
try tutorials.append(allocator,.{ .title = title, .url = url });
} else if (std.mem.eql(u8, name, "method")) {
- const method_name = try getAttributeAlloc(allocator, reader, "name") orelse continue;
- const desc = try readNestedDescription(allocator, reader, "method");
- try methods.append(allocator,.{ .name = method_name, .description = desc });
+ const method_doc = try parseMethodElement(allocator, reader);
+ try methods.append(allocator, method_doc);
} else if (std.mem.eql(u8, name, "member")) {
const member_name = try getAttributeAlloc(allocator, reader, "name") orelse continue;
+ const member_type = try getAttributeAlloc(allocator, reader, "type");
+ const member_default = try getAttributeAlloc(allocator, reader, "default");
const desc = try readTextContent(allocator, reader);
- try properties.append(allocator,.{ .name = member_name, .description = desc });
+ try properties.append(allocator, .{
+ .name = member_name,
+ .description = desc,
+ .return_type = member_type,
+ .default_value = member_default,
+ });
} else if (std.mem.eql(u8, name, "signal")) {
- const signal_name = try getAttributeAlloc(allocator, reader, "name") orelse continue;
- const desc = try readNestedDescription(allocator, reader, "signal");
- try signals.append(allocator,.{ .name = signal_name, .description = desc });
+ const signal_doc = try parseMethodElement(allocator, reader);
+ try signals.append(allocator, signal_doc);
+ } else if (std.mem.eql(u8, name, "constructor")) {
+ const ctor_doc = try parseMethodElement(allocator, reader);
+ try constructors.append(allocator, ctor_doc);
+ } else if (std.mem.eql(u8, name, "operator")) {
+ const op_doc = try parseMethodElement(allocator, reader);
+ try operators.append(allocator, op_doc);
} else if (std.mem.eql(u8, name, "constant")) {
const constant_name = try getAttributeAlloc(allocator, reader, "name") orelse continue;
+ const constant_value = try getAttributeAlloc(allocator, reader, "value");
+ const constant_enum = try getAttributeAlloc(allocator, reader, "enum");
const desc = try readTextContent(allocator, reader);
- try constants.append(allocator,.{ .name = constant_name, .description = desc });
+ try constants.append(allocator, .{
+ .name = constant_name,
+ .description = desc,
+ .default_value = constant_value,
+ .qualifiers = constant_enum,
+ });
}
},
else => continue,
@@ -108,6 +142,8 @@ pub fn parseClassDoc(allocator: Allocator, xml_content: []const u8) ParseError!C
doc.properties = if (properties.items.len > 0) try properties.toOwnedSlice(allocator) else null;
doc.signals = if (signals.items.len > 0) try signals.toOwnedSlice(allocator) else null;
doc.constants = if (constants.items.len > 0) try constants.toOwnedSlice(allocator) else null;
+ doc.constructors = if (constructors.items.len > 0) try constructors.toOwnedSlice(allocator) else null;
+ doc.operators = if (operators.items.len > 0) try operators.toOwnedSlice(allocator) else null;
return doc;
}
@@ -126,11 +162,22 @@ pub fn freeClassDoc(allocator: Allocator, doc: ClassDoc) void {
allocator.free(tutorials);
}
- inline for (.{ "methods", "properties", "signals", "constants" }) |field| {
+ inline for (.{ "methods", "properties", "signals", "constants", "constructors", "operators" }) |field| {
if (@field(doc, field)) |members| {
for (members) |m| {
allocator.free(m.name);
if (m.description) |d| allocator.free(d);
+ if (m.qualifiers) |q| allocator.free(q);
+ if (m.default_value) |dv| allocator.free(dv);
+ if (m.return_type) |rt| allocator.free(rt);
+ if (m.params) |params| {
+ for (params) |p| {
+ allocator.free(p.name);
+ allocator.free(p.type);
+ if (p.default_value) |pdv| allocator.free(pdv);
+ }
+ allocator.free(params);
+ }
}
allocator.free(members);
}
@@ -196,6 +243,57 @@ fn readNestedDescription(allocator: Allocator, reader: *xml.Reader, container_el
return null;
}
+fn parseMethodElement(allocator: Allocator, reader: *xml.Reader) ParseError!MemberDoc {
+ const method_name = try getAttributeAlloc(allocator, reader, "name") orelse return ParseError.MissingNameAttribute;
+ const qualifiers = try getAttributeAlloc(allocator, reader, "qualifiers");
+
+ var return_type: ?[]const u8 = null;
+ var description: ?[]const u8 = null;
+ var params: std.ArrayListUnmanaged(ParamDoc) = .empty;
+ defer params.deinit(allocator);
+
+ var depth: usize = 1;
+ while (depth > 0) {
+ const node = reader.read() catch return ParseError.MalformedXml;
+ switch (node) {
+ .eof => break,
+ .element_start => {
+ const name = reader.elementName();
+ if (depth == 1 and std.mem.eql(u8, name, "return")) {
+ return_type = try getAttributeAlloc(allocator, reader, "type");
+ depth += 1;
+ } else if (depth == 1 and std.mem.eql(u8, name, "param")) {
+ const param_name = try getAttributeAlloc(allocator, reader, "name") orelse try allocator.dupe(u8, "");
+ const param_type = try getAttributeAlloc(allocator, reader, "type") orelse try allocator.dupe(u8, "");
+ const param_default = try getAttributeAlloc(allocator, reader, "default");
+ try params.append(allocator, .{
+ .name = param_name,
+ .type = param_type,
+ .default_value = param_default,
+ });
+ depth += 1;
+ } else if (depth == 1 and std.mem.eql(u8, name, "description")) {
+ description = try readTextContent(allocator, reader);
+ } else {
+ depth += 1;
+ }
+ },
+ .element_end => {
+ depth -= 1;
+ },
+ else => continue,
+ }
+ }
+
+ return .{
+ .name = method_name,
+ .description = description,
+ .qualifiers = qualifiers,
+ .return_type = return_type,
+ .params = if (params.items.len > 0) try params.toOwnedSlice(allocator) else null,
+ };
+}
+
fn expandDocsUrl(allocator: Allocator, url: []const u8) Allocator.Error![]const u8 {
const prefix = "$DOCS_URL";
if (std.mem.startsWith(u8, url, prefix)) {
@@ -306,6 +404,17 @@ test "parses properties from members element" {
try std.testing.expectEqualStrings("Position, relative to the node's parent.", props[0].description.?);
}
+test "parses property default value" {
+ const allocator = std.testing.allocator;
+ const doc = try parseClassDoc(allocator, test_xml);
+ defer freeClassDoc(allocator, doc);
+
+ const props = doc.properties.?;
+ try std.testing.expectEqual(1, props.len);
+ try std.testing.expectEqualStrings("Vector2(0, 0)", props[0].default_value.?);
+ try std.testing.expectEqualStrings("Vector2", props[0].return_type.?);
+}
+
test "parses signals with descriptions" {
const allocator = std.testing.allocator;
const doc = try parseClassDoc(allocator, test_xml);
@@ -328,6 +437,131 @@ test "parses constants with descriptions" {
try std.testing.expectEqualStrings("Maximum allowed value.", consts[0].description.?);
}
+const test_xml_with_constructors_and_operators =
+ \\
+ \\
+ \\ A 2D vector.
+ \\ 2D vector type.
+ \\
+ \\
+ \\
+ \\ Constructs a default Vector2.
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\ Constructs from x and y.
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\ Adds two vectors.
+ \\
+ \\
+ \\
+;
+
+test "parses constructors" {
+ const allocator = std.testing.allocator;
+ const doc = try parseClassDoc(allocator, test_xml_with_constructors_and_operators);
+ defer freeClassDoc(allocator, doc);
+
+ const ctors = doc.constructors.?;
+ try std.testing.expectEqual(2, ctors.len);
+ try std.testing.expectEqualStrings("Vector2", ctors[0].name);
+ try std.testing.expect(ctors[0].params == null);
+ try std.testing.expectEqual(2, ctors[1].params.?.len);
+}
+
+test "parses operators" {
+ const allocator = std.testing.allocator;
+ const doc = try parseClassDoc(allocator, test_xml_with_constructors_and_operators);
+ defer freeClassDoc(allocator, doc);
+
+ const ops = doc.operators.?;
+ try std.testing.expectEqual(1, ops.len);
+ try std.testing.expectEqualStrings("operator +", ops[0].name);
+ try std.testing.expectEqualStrings("Vector2", ops[0].return_type.?);
+ try std.testing.expectEqual(1, ops[0].params.?.len);
+}
+
+const test_xml_with_enums =
+ \\
+ \\
+ \\ Base class.
+ \\ Base node.
+ \\
+ \\ Ready notification.
+ \\ Inherits process mode.
+ \\ Always process.
+ \\
+ \\
+;
+
+test "parses constant value and enum attribute" {
+ const allocator = std.testing.allocator;
+ const doc = try parseClassDoc(allocator, test_xml_with_enums);
+ defer freeClassDoc(allocator, doc);
+
+ const consts = doc.constants.?;
+ try std.testing.expectEqual(3, consts.len);
+
+ // Regular constant — no enum
+ try std.testing.expectEqualStrings("13", consts[0].default_value.?);
+ try std.testing.expect(consts[0].qualifiers == null);
+
+ // Enum constant — enum name stored in qualifiers field
+ try std.testing.expectEqualStrings("0", consts[1].default_value.?);
+ try std.testing.expectEqualStrings("ProcessMode", consts[1].qualifiers.?);
+}
+
+const test_xml_with_params =
+ \\
+ \\
+ \\ A 2D game object.
+ \\ Node2D is the base class for 2D.
+ \\
+ \\
+ \\
+ \\
+ \\ Returns the angle between the node and the point.
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\ Applies a local translation on the X axis.
+ \\
+ \\
+ \\
+;
+
+test "parses method params and return type" {
+ const allocator = std.testing.allocator;
+ const doc = try parseClassDoc(allocator, test_xml_with_params);
+ defer freeClassDoc(allocator, doc);
+
+ const methods = doc.methods.?;
+ try std.testing.expectEqual(2, methods.len);
+
+ // First method: get_angle_to
+ try std.testing.expectEqualStrings("const", methods[0].qualifiers.?);
+ try std.testing.expectEqualStrings("float", methods[0].return_type.?);
+ const params0 = methods[0].params.?;
+ try std.testing.expectEqual(1, params0.len);
+ try std.testing.expectEqualStrings("point", params0[0].name);
+ try std.testing.expectEqualStrings("Vector2", params0[0].type);
+ try std.testing.expect(params0[0].default_value == null);
+
+ // Second method: move_local_x with default param
+ const params1 = methods[1].params.?;
+ try std.testing.expectEqual(2, params1.len);
+ try std.testing.expectEqualStrings("false", params1[1].default_value.?);
+}
+
test "freeClassDoc doesn't leak" {
const allocator = std.testing.allocator;
const doc = try parseClassDoc(allocator, test_xml);
diff --git a/src/api.zig b/src/api.zig
deleted file mode 100644
index 69b7552..0000000
--- a/src/api.zig
+++ /dev/null
@@ -1,112 +0,0 @@
-pub fn generateApiJsonIfNotExists(allocator: Allocator, godot_path: []const u8, destination_dir: []const u8) !void {
- const json_path = try cache.getJsonCachePathInDir(allocator, destination_dir);
- defer allocator.free(json_path);
-
- if (std.fs.openFileAbsolute(json_path, .{}) catch |err| switch (err) {
- error.FileNotFound => null,
- else => return err,
- }) |json_file| {
- json_file.close();
- return;
- }
-
- const result = try Child.run(.{
- .cwd = destination_dir,
- .argv = &[_][]const u8{ godot_path, "--dump-extension-api-with-docs", "--headless" },
- .allocator = allocator,
- });
- defer allocator.free(result.stdout);
- defer allocator.free(result.stderr);
-
- switch (result.term) {
- .Exited => |code| {
- if (code != 0) {
- return error.GodotExecutionFailed;
- }
- },
- else => return error.GodotExecutionFailed,
- }
-}
-
-// Tests for generateApiJson function
-
-test "generateApiJson executes godot and creates extension_api.json in cache" {
- const allocator = std.testing.allocator;
-
- // Create a fake godot script that creates extension_api.json
- // We'll use a shell script to simulate godot's behavior
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- const fake_godot = try std.fmt.allocPrint(allocator, "{s}/fake-godot.sh", .{tmp_path});
- defer allocator.free(fake_godot);
-
- // Create fake godot script that writes extension_api.json
- const script_content =
- \\#!/bin/sh
- \\echo '{"version": "test"}' > extension_api.json
- ;
-
- try tmp_dir.dir.writeFile(.{ .sub_path = "fake-godot.sh", .data = script_content });
-
- // Make it executable
- var file = try tmp_dir.dir.openFile("fake-godot.sh", .{});
- try file.chmod(0o755);
- file.close();
-
- // Generate the API JSON
- try generateApiJsonIfNotExists(allocator, fake_godot, tmp_path);
-
- // Verify the JSON file was created in cache directory
- const json_path = try std.fmt.allocPrint(allocator, "{s}/extension_api.json", .{tmp_path});
- defer allocator.free(json_path);
-
- const json_data = try std.fs.cwd().readFileAlloc(allocator, json_path, 1024 * 1024);
- defer allocator.free(json_data);
-
- // Should contain the test JSON
- try std.testing.expect(std.mem.indexOf(u8, json_data, "test") != null);
-}
-
-test "generateApiJson returns error when godot executable not found" {
- const allocator = std.testing.allocator;
-
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- const non_existant_godot = try std.fmt.allocPrint(allocator, "{s}/godot", .{tmp_path});
- defer allocator.free(non_existant_godot);
-
- const result = generateApiJsonIfNotExists(allocator, non_existant_godot, tmp_path);
-
- try std.testing.expectError(error.FileNotFound, result);
-}
-
-test "generateApiJson returns error on non-zero exit code" {
- const allocator = std.testing.allocator;
-
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- // Use 'false' command which always exits with code 1
- const result = generateApiJsonIfNotExists(allocator, "false", tmp_path);
-
- try std.testing.expectError(error.GodotExecutionFailed, result);
-}
-
-const std = @import("std");
-const Allocator = std.mem.Allocator;
-const Child = std.process.Child;
-
-const cache = @import("cache.zig");
-
-const DocDatabase = @import("DocDatabase.zig");
diff --git a/src/cache.zig b/src/cache.zig
index c289711..83119e3 100644
--- a/src/cache.zig
+++ b/src/cache.zig
@@ -9,7 +9,7 @@ pub fn clearCache(config: *const Config) !void {
pub fn ensureDirectoryExists(dir_path: []const u8) !void {
var dir = std.fs.openDirAbsolute(dir_path, .{}) catch |err| switch (err) {
error.FileNotFound => {
- try std.fs.makeDirAbsolute(dir_path);
+ try std.fs.cwd().makePath(dir_path);
return;
},
else => return err,
@@ -17,14 +17,6 @@ pub fn ensureDirectoryExists(dir_path: []const u8) !void {
defer dir.close();
}
-pub fn getJsonCachePathInDir(allocator: Allocator, cache_dir: []const u8) ![]const u8 {
- return std.fmt.allocPrint(
- allocator,
- "{f}",
- .{std.fs.path.fmtJoin(&[_][]const u8{ cache_dir, "extension_api.json" })},
- );
-}
-
pub fn resolveSymbolPath(allocator: Allocator, cache_path: []const u8, symbol: []const u8) ![]const u8 {
// dot notation
if (std.mem.indexOf(u8, symbol, ".")) |dot_pos| {
@@ -105,23 +97,25 @@ pub fn generateMarkdownCache(allocator: Allocator, db: DocDatabase, cache_path:
}
pub fn cacheIsPopulated(allocator: Allocator, cache_path: []const u8) !bool {
- const json_file_path = try getJsonCachePathInDir(allocator, cache_path);
- defer allocator.free(json_file_path);
+ // Check xml_docs/.complete marker
+ const xml_dir = try getXmlDocsDirInCache(allocator, cache_path);
+ defer allocator.free(xml_dir);
- const json_file = std.fs.openFileAbsolute(json_file_path, .{}) catch |err| switch (err) {
- error.FileNotFound => return false,
- else => return err,
- };
- defer json_file.close();
+ if (source_fetch.readCompleteMarker(allocator, xml_dir)) |m| {
+ allocator.free(m);
+ } else {
+ return false;
+ }
- const node_path = try resolveSymbolPath(allocator, cache_path, "Node");
- defer allocator.free(node_path);
+ // Check Object/index.md sentinel
+ const object_path = try resolveSymbolPath(allocator, cache_path, "Object");
+ defer allocator.free(object_path);
- const node_file = std.fs.openFileAbsolute(node_path, .{}) catch |err| switch (err) {
+ const object_file = std.fs.openFileAbsolute(object_path, .{}) catch |err| switch (err) {
error.FileNotFound => return false,
else => return err,
};
- defer node_file.close();
+ object_file.close();
return true;
}
@@ -134,18 +128,6 @@ pub fn getXmlDocsDirInCache(allocator: Allocator, cache_dir: []const u8) ![]cons
);
}
-pub fn xmlDocsArePopulated(allocator: Allocator, cache_dir: []const u8) !bool {
- const xml_dir = try getXmlDocsDirInCache(allocator, cache_dir);
- defer allocator.free(xml_dir);
-
- const marker = source_fetch.readCompleteMarker(allocator, xml_dir);
- if (marker) |m| {
- allocator.free(m);
- return true;
- }
- return false;
-}
-
test "testing config has valid cache directory" {
try std.testing.expect(Config.testing.cache_dir.len > 0);
try std.testing.expect(std.mem.indexOf(u8, Config.testing.cache_dir, "gdoc") != null);
@@ -208,12 +190,12 @@ test "clearCache deletes cache directory" {
// Ensure cache directory exists
try ensureDirectoryExists(cache_dir);
- // Create JSON file and markdown files
- const json_path = try getJsonCachePathInDir(allocator, cache_dir);
- defer allocator.free(json_path);
+ // Create a dummy file
+ const dummy_path = try std.fmt.allocPrint(allocator, "{s}/dummy.txt", .{cache_dir});
+ defer allocator.free(dummy_path);
- var json_file = try std.fs.createFileAbsolute(json_path, .{});
- json_file.close();
+ var dummy_file = try std.fs.createFileAbsolute(dummy_path, .{});
+ dummy_file.close();
const node_dir = try std.fmt.allocPrint(allocator, "{s}/Node", .{cache_dir});
defer allocator.free(node_dir);
@@ -224,7 +206,7 @@ test "clearCache deletes cache directory" {
try std.fs.cwd().writeFile(.{ .sub_path = index_path, .data = "# Node\n" });
// Verify files exist
- _ = try std.fs.openFileAbsolute(json_path, .{});
+ _ = try std.fs.openFileAbsolute(dummy_path, .{});
_ = try std.fs.openFileAbsolute(index_path, .{});
// Clear cache
@@ -671,7 +653,7 @@ test "cacheIsPopulated returns false for nonexistent directory" {
try std.testing.expect(!result);
}
-test "cacheIsPopulated returns true when cache has markdown files" {
+test "cacheIsPopulated returns true when cache has xml marker and Object sentinel" {
const allocator = std.testing.allocator;
var tmp_dir = std.testing.tmpDir(.{});
@@ -680,26 +662,27 @@ test "cacheIsPopulated returns true when cache has markdown files" {
const cache_dir = try tmp_dir.dir.realpathAlloc(allocator, ".");
defer allocator.free(cache_dir);
- // Create the JSON file (required by implementation)
- const json_path = try getJsonCachePathInDir(allocator, cache_dir);
- defer allocator.free(json_path);
- try std.fs.cwd().writeFile(.{ .sub_path = json_path, .data = "{}" });
+ // Create xml_docs/.complete marker
+ const xml_dir = try getXmlDocsDirInCache(allocator, cache_dir);
+ defer allocator.free(xml_dir);
+ try std.fs.makeDirAbsolute(xml_dir);
+ source_fetch.writeCompleteMarker(allocator, xml_dir, "4.3.stable") catch unreachable;
- // Create Node symbol directory with markdown file
- const node_dir = try std.fmt.allocPrint(allocator, "{s}/Node", .{cache_dir});
- defer allocator.free(node_dir);
- try std.fs.makeDirAbsolute(node_dir);
+ // Create Object symbol directory with markdown file
+ const object_dir = try std.fmt.allocPrint(allocator, "{s}/Object", .{cache_dir});
+ defer allocator.free(object_dir);
+ try std.fs.makeDirAbsolute(object_dir);
- const index_path = try std.fmt.allocPrint(allocator, "{s}/index.md", .{node_dir});
+ const index_path = try std.fmt.allocPrint(allocator, "{s}/index.md", .{object_dir});
defer allocator.free(index_path);
- try std.fs.cwd().writeFile(.{ .sub_path = index_path, .data = "# Node\n" });
+ try std.fs.cwd().writeFile(.{ .sub_path = index_path, .data = "# Object\n" });
- // Should return true since cache has both JSON and markdown files
+ // Should return true since cache has both xml marker and Object sentinel
const result = try cacheIsPopulated(allocator, cache_dir);
try std.testing.expect(result);
}
-test "cacheIsPopulated returns false when only extension_api.json exists" {
+test "cacheIsPopulated returns false when only xml marker exists" {
const allocator = std.testing.allocator;
var tmp_dir = std.testing.tmpDir(.{});
@@ -708,12 +691,13 @@ test "cacheIsPopulated returns false when only extension_api.json exists" {
const cache_dir = try tmp_dir.dir.realpathAlloc(allocator, ".");
defer allocator.free(cache_dir);
- // Create only the JSON file, no markdown files
- const json_path = try std.fmt.allocPrint(allocator, "{s}/extension_api.json", .{cache_dir});
- defer allocator.free(json_path);
- try std.fs.cwd().writeFile(.{ .sub_path = json_path, .data = "{}" });
+ // Create xml_docs/.complete marker but no Object sentinel
+ const xml_dir = try getXmlDocsDirInCache(allocator, cache_dir);
+ defer allocator.free(xml_dir);
+ try std.fs.makeDirAbsolute(xml_dir);
+ source_fetch.writeCompleteMarker(allocator, xml_dir, "4.3.stable") catch unreachable;
- // Should return false - JSON alone doesn't mean cache is populated
+ // Should return false - xml marker alone doesn't mean cache is populated
const result = try cacheIsPopulated(allocator, cache_dir);
try std.testing.expect(!result);
}
diff --git a/src/cli/root.zig b/src/cli/root.zig
index 0546b81..93b4e27 100644
--- a/src/cli/root.zig
+++ b/src/cli/root.zig
@@ -18,13 +18,6 @@ pub fn build(allocator: Allocator, writer: *Writer, reader: *Reader) !*Command {
.default_value = .{ .Bool = false },
});
- try root.addFlag(.{
- .name = "godot-extension-api",
- .description = "Path to Godot extension_api.json file (bypasses cache)",
- .type = .String,
- .default_value = .{ .String = "" },
- });
-
try root.addFlag(.{
.name = "output-format",
.description = "Output format (markdown or terminal). Defaults to terminal for TTY, markdown otherwise.",
@@ -44,14 +37,11 @@ pub fn build(allocator: Allocator, writer: *Writer, reader: *Reader) !*Command {
fn runLookup(ctx: CommandContext) !void {
const clear_cache = ctx.flag("clear-cache", bool);
- const api_json_path_raw = ctx.flag("godot-extension-api", []const u8);
- const api_json_path: ?[]const u8 = if (api_json_path_raw.len == 0) null else api_json_path_raw;
-
const output_format_raw = ctx.flag("output-format", []const u8);
const output_format: OutputFormat = std.meta.stringToEnum(OutputFormat, output_format_raw) orelse .detect;
// print help when no arguments/flags are provided
- if (!clear_cache and ctx.positional_args.len == 0 and api_json_path == null) {
+ if (!clear_cache and ctx.positional_args.len == 0) {
try ctx.command.printHelp();
return;
}
@@ -65,10 +55,8 @@ fn runLookup(ctx: CommandContext) !void {
const symbol = ctx.getArg("symbol") orelse return;
- gdoc.formatAndDisplay(ctx.allocator, symbol, api_json_path, ctx.writer, output_format, config) catch |err| switch (err) {
+ gdoc.formatAndDisplay(ctx.allocator, symbol, ctx.writer, output_format, config) catch |err| switch (err) {
DocDatabaseError.SymbolNotFound => try ctx.writer.print("Symbol '{s}' not found.\n", .{symbol}),
- error.ApiFileNotFound => try ctx.writer.print("Error: API file not found: {s}\n", .{api_json_path.?}),
- error.InvalidApiJson => try ctx.writer.print("Error: Invalid JSON in API file: {s}\n", .{api_json_path.?}),
else => return err,
};
diff --git a/src/root.zig b/src/root.zig
index e85784e..3e2ecf1 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -4,83 +4,83 @@ pub const OutputFormat = enum {
detect,
};
-pub const LookupError = error{
- ApiFileNotFound,
-} || Writer.Error || DocDatabase.Error || File.OpenError;
+pub const LookupError = Writer.Error || DocDatabase.Error || File.OpenError;
-pub fn markdownForSymbol(allocator: Allocator, symbol: []const u8, api_json_path: ?[]const u8, writer: *Writer, config: *const Config) !void {
+pub fn markdownForSymbol(allocator: Allocator, symbol: []const u8, writer: *Writer, config: *const Config) !void {
var arena = ArenaAllocator.init(allocator);
defer arena.deinit();
- const api_json_file = if (api_json_path) |path| std.fs.cwd().openFile(path, .{}) catch |err| switch (err) {
- error.FileNotFound => return LookupError.ApiFileNotFound,
- else => return err,
- } else null;
- defer if (api_json_file) |f| f.close();
-
- if (api_json_file) |f| {
- const db = try DocDatabase.loadFromJsonFileLeaky(arena.allocator(), f);
- const entry = try db.lookupSymbolExact(symbol);
-
- try writer.print("# {s}\n", .{symbol});
-
- if (entry.brief_description) |brief| {
- try writer.print("\n{s}\n", .{brief});
- }
-
- if (entry.description) |desc| {
- try writer.print("\n## Description\n\n{s}\n", .{desc});
- }
- } else {
- const cache_path = config.cache_dir;
-
- const needs_full_rebuild = !try cache.cacheIsPopulated(allocator, cache_path);
-
- if (needs_full_rebuild) {
- try cache.ensureDirectoryExists(cache_path);
- try api.generateApiJsonIfNotExists(allocator, "godot", cache_path);
-
- // Fetch XML docs if missing (best-effort, requires godot)
- if (!config.no_xml and !try cache.xmlDocsArePopulated(allocator, cache_path)) {
- fetchXmlDocs(allocator, cache_path);
+ const cache_path = config.cache_dir;
+
+ const needs_full_rebuild = !try cache.cacheIsPopulated(allocator, cache_path);
+
+ if (needs_full_rebuild) {
+ try cache.ensureDirectoryExists(cache_path);
+
+ const xml_dir = try cache.getXmlDocsDirInCache(allocator, cache_path);
+ defer allocator.free(xml_dir);
+ try cache.ensureDirectoryExists(xml_dir);
+
+ const version = source_fetch.getGodotVersion(allocator) orelse
+ return error.GodotNotFound;
+ defer version.deinit(allocator);
+
+ var url_buf: [256]u8 = undefined;
+ const url = source_fetch.buildTarballUrl(&url_buf, version) orelse
+ return error.GodotNotFound;
+
+ var spinner = Spinner{ .message = "Downloading XML docs..." };
+ spinner.start();
+
+ source_fetch.fetchAndExtractXmlDocs(allocator, url, xml_dir) catch |err| {
+ if (version.hash) |hash| {
+ var hash_url_buf: [256]u8 = undefined;
+ const hash_url = source_fetch.buildTarballUrlFromHash(&hash_url_buf, hash) orelse {
+ spinner.finish();
+ return err;
+ };
+ source_fetch.fetchAndExtractXmlDocs(allocator, hash_url, xml_dir) catch {
+ spinner.finish();
+ return err;
+ };
+ } else {
+ spinner.finish();
+ return err;
}
+ };
- var spinner: Spinner = .{ .message = "Building documentation cache..." };
- if (!config.no_xml) spinner.start();
- defer spinner.finish();
-
- const json_path = try cache.getJsonCachePathInDir(allocator, cache_path);
- defer allocator.free(json_path);
-
- const json_file = try std.fs.openFileAbsolute(json_path, .{});
- defer json_file.close();
-
- var db = try DocDatabase.loadFromJsonFileLeaky(arena.allocator(), json_file);
+ spinner.finish();
- mergeXmlDocs(arena.allocator(), allocator, &db, cache_path);
+ var version_buf: [64]u8 = undefined;
+ const version_str = version.formatVersion(&version_buf) orelse return error.GodotNotFound;
+ source_fetch.writeCompleteMarker(allocator, xml_dir, version_str) catch return error.GodotNotFound;
- try cache.generateMarkdownCache(allocator, db, cache_path);
- }
+ var build_spinner = Spinner{ .message = "Building documentation cache..." };
+ build_spinner.start();
+ defer build_spinner.finish();
- try cache.readSymbolMarkdown(allocator, symbol, cache_path, writer);
+ const db = try DocDatabase.loadFromXmlDir(arena.allocator(), allocator, xml_dir);
+ try cache.generateMarkdownCache(allocator, db, cache_path);
}
+
+ try cache.readSymbolMarkdown(allocator, symbol, cache_path, writer);
}
-pub fn formatAndDisplay(allocator: Allocator, symbol: []const u8, api_json_path: ?[]const u8, writer: *Writer, format: OutputFormat, config: *const Config) !void {
+pub fn formatAndDisplay(allocator: Allocator, symbol: []const u8, writer: *Writer, format: OutputFormat, config: *const Config) !void {
switch (format) {
- .markdown => try markdownForSymbol(allocator, symbol, api_json_path, writer, config),
- .terminal => try renderWithZigdown(allocator, symbol, api_json_path, writer, config),
+ .markdown => try markdownForSymbol(allocator, symbol, writer, config),
+ .terminal => try renderWithZigdown(allocator, symbol, writer, config),
.detect => {
- try formatAndDisplay(allocator, symbol, api_json_path, writer, if (File.stdout().isTty()) .terminal else .markdown, config);
+ try formatAndDisplay(allocator, symbol, writer, if (File.stdout().isTty()) .terminal else .markdown, config);
},
}
}
-fn renderWithZigdown(allocator: Allocator, symbol: []const u8, api_json_path: ?[]const u8, writer: *Writer, config: *const Config) !void {
+fn renderWithZigdown(allocator: Allocator, symbol: []const u8, writer: *Writer, config: *const Config) !void {
var markdown_buf: AllocatingWriter = .init(allocator);
defer markdown_buf.deinit();
- try markdownForSymbol(allocator, symbol, api_json_path, &markdown_buf.writer, config);
+ try markdownForSymbol(allocator, symbol, &markdown_buf.writer, config);
const markdown = markdown_buf.written();
renderMarkdownWithZigdown(allocator, markdown, writer) catch |err| {
@@ -109,141 +109,6 @@ fn renderMarkdownWithZigdown(allocator: Allocator, markdown: []const u8, writer:
try renderer.renderBlock(parser.document);
}
-test "markdownForSymbol returns ApiFileNotFound for nonexistent file" {
- const allocator = std.testing.allocator;
-
- // Create a temporary directory for test
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- const nonexistent_path = try std.fmt.allocPrint(allocator, "{s}/nonexistent.json", .{tmp_path});
- defer allocator.free(nonexistent_path);
-
- // Create a discarding writer (we don't care about output for this error test)
- var buf: [4096]u8 = undefined;
- var discard = std.io.Writer.Discarding.init(&buf);
-
- const result = markdownForSymbol(allocator, "Node2D", nonexistent_path, &discard.writer, &Config.testing);
-
- try std.testing.expectError(LookupError.ApiFileNotFound, result);
-}
-
-test "markdownForSymbol returns InvalidApiJson for malformed JSON" {
- const allocator = std.testing.allocator;
-
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- const bad_api_path = try std.fmt.allocPrint(allocator, "{s}/bad_api.json", .{tmp_path});
- defer allocator.free(bad_api_path);
-
- // Write invalid JSON
- try tmp_dir.dir.writeFile(.{ .sub_path = "bad_api.json", .data = "{ invalid json" });
-
- var buf: [4096]u8 = undefined;
- var discard = std.io.Writer.Discarding.init(&buf);
-
- const result = markdownForSymbol(allocator, "Node2D", bad_api_path, &discard.writer, &Config.testing);
-
- try std.testing.expectError(DocDatabase.Error.InvalidApiJson, result);
-}
-
-test "markdownForSymbol loads from custom API file and finds symbol" {
- const allocator = std.testing.allocator;
-
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- const api_path = try std.fmt.allocPrint(allocator, "{s}/test_api.json", .{tmp_path});
- defer allocator.free(api_path);
-
- // Write minimal valid API JSON with a test class
- const test_json =
- \\{"builtin_classes": [{"name": "TestClass", "brief_description": "A test class"}]}
- ;
- try tmp_dir.dir.writeFile(.{ .sub_path = "test_api.json", .data = test_json });
-
- var allocating_writer = std.Io.Writer.Allocating.init(allocator);
- defer allocating_writer.deinit();
-
- // Should successfully load and display TestClass
- try markdownForSymbol(allocator, "TestClass", api_path, &allocating_writer.writer, &Config.testing);
-
- // Verify something was written to output
- const written = try allocating_writer.toOwnedSlice();
- defer allocator.free(written);
- try std.testing.expect(written.len > 0);
-}
-
-test "markdownForSymbol returns SymbolNotFound when symbol doesn't exist" {
- const allocator = std.testing.allocator;
-
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- const api_path = try std.fmt.allocPrint(allocator, "{s}/test_api.json", .{tmp_path});
- defer allocator.free(api_path);
-
- // Write valid API JSON but without the symbol we're looking for
- const test_json =
- \\{"builtin_classes": [{"name": "TestClass", "brief_description": "A test class"}]}
- ;
- try tmp_dir.dir.writeFile(.{ .sub_path = "test_api.json", .data = test_json });
-
- var buf: [4096]u8 = undefined;
- var discard = std.io.Writer.Discarding.init(&buf);
-
- // Try to look up NonExistentClass which is not in the API
- const result = markdownForSymbol(allocator, "NonExistentClass", api_path, &discard.writer, &Config.testing);
-
- try std.testing.expectError(DocDatabase.Error.SymbolNotFound, result);
-}
-
-test "markdownForSymbol works with relative path" {
- const allocator = std.testing.allocator;
-
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- // Write minimal valid API JSON
- const test_json =
- \\{"builtin_classes": [{"name": "TestClass", "brief_description": "A test class"}]}
- ;
- try tmp_dir.dir.writeFile(.{ .sub_path = "test_api.json", .data = test_json });
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- // Change to tmp directory and use relative path
- const original_cwd = try std.fs.cwd().realpathAlloc(allocator, ".");
- defer allocator.free(original_cwd);
- defer std.posix.chdir(original_cwd) catch {};
-
- try std.posix.chdir(tmp_path);
-
- var allocating_writer = std.Io.Writer.Allocating.init(allocator);
- defer allocating_writer.deinit();
-
- // Use relative path
- try markdownForSymbol(allocator, "TestClass", "test_api.json", &allocating_writer.writer, &Config.testing);
-
- const written = try allocating_writer.toOwnedSlice();
- defer allocator.free(written);
- try std.testing.expect(written.len > 0);
-}
-
test "OutputFormat enum has markdown and terminal values" {
// Test that OutputFormat enum exists with expected values
const format_markdown: OutputFormat = .markdown;
@@ -253,71 +118,7 @@ test "OutputFormat enum has markdown and terminal values" {
try std.testing.expect(format_terminal == .terminal);
}
-test "formatAndDisplay with markdown format produces markdown output" {
- const allocator = std.testing.allocator;
-
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- const api_path = try std.fmt.allocPrint(allocator, "{s}/test_api.json", .{tmp_path});
- defer allocator.free(api_path);
-
- // Write minimal valid API JSON with a test class
- const test_json =
- \\{"builtin_classes": [{"name": "TestClass", "brief_description": "A test class"}]}
- ;
- try tmp_dir.dir.writeFile(.{ .sub_path = "test_api.json", .data = test_json });
-
- var allocating_writer = std.Io.Writer.Allocating.init(allocator);
- defer allocating_writer.deinit();
-
- // Call formatAndDisplay with markdown format
- try formatAndDisplay(allocator, "TestClass", api_path, &allocating_writer.writer, .markdown, &Config.testing);
-
- const written = try allocating_writer.toOwnedSlice();
- defer allocator.free(written);
-
- // Verify markdown output was produced
- try std.testing.expect(written.len > 0);
- try std.testing.expect(std.mem.indexOf(u8, written, "TestClass") != null);
-}
-
-test "formatAndDisplay with terminal format produces terminal output" {
- const allocator = std.testing.allocator;
-
- var tmp_dir = std.testing.tmpDir(.{});
- defer tmp_dir.cleanup();
-
- const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, ".");
- defer allocator.free(tmp_path);
-
- const api_path = try std.fmt.allocPrint(allocator, "{s}/test_api.json", .{tmp_path});
- defer allocator.free(api_path);
-
- // Write minimal valid API JSON with a test class
- const test_json =
- \\{"builtin_classes": [{"name": "TestClass", "brief_description": "A test class"}]}
- ;
- try tmp_dir.dir.writeFile(.{ .sub_path = "test_api.json", .data = test_json });
-
- var allocating_writer = std.Io.Writer.Allocating.init(allocator);
- defer allocating_writer.deinit();
-
- // Call formatAndDisplay with terminal format
- try formatAndDisplay(allocator, "TestClass", api_path, &allocating_writer.writer, .terminal, &Config.testing);
-
- const written = try allocating_writer.toOwnedSlice();
- defer allocator.free(written);
-
- // Verify terminal output was produced (should still contain the symbol name)
- try std.testing.expect(written.len > 0);
- try std.testing.expect(std.mem.indexOf(u8, written, "TestClass") != null);
-}
-
-// Test verifies the normal cache flow when api_json_path is null
+// Test verifies the normal cache flow when cache is pre-populated
test "markdownForSymbol reads from markdown cache when available" {
const allocator = std.testing.allocator;
const cache_dir = Config.testing.cache_dir;
@@ -328,15 +129,20 @@ test "markdownForSymbol reads from markdown cache when available" {
// Ensure cache directory exists
try cache.ensureDirectoryExists(cache_dir);
- // Create extension_api.json in cache to prevent godot execution
- // Use a different class name so it doesn't overwrite our test markdown
- const json_path = try cache.getJsonCachePathInDir(allocator, cache_dir);
- defer allocator.free(json_path);
+ // Create xml_docs/.complete marker to make cacheIsPopulated return true
+ const xml_dir = try cache.getXmlDocsDirInCache(allocator, cache_dir);
+ defer allocator.free(xml_dir);
+ try cache.ensureDirectoryExists(xml_dir);
+ try source_fetch.writeCompleteMarker(allocator, xml_dir, "4.3.stable");
+
+ // Create Object/index.md sentinel to make cacheIsPopulated return true
+ const object_dir = try std.fmt.allocPrint(allocator, "{s}/Object", .{cache_dir});
+ defer allocator.free(object_dir);
+ try cache.ensureDirectoryExists(object_dir);
- const test_json =
- \\{"builtin_classes": [{"name": "DummyClass", "brief_description": "A dummy class"}]}
- ;
- try std.fs.cwd().writeFile(.{ .sub_path = json_path, .data = test_json });
+ const object_index = try std.fmt.allocPrint(allocator, "{s}/index.md", .{object_dir});
+ defer allocator.free(object_index);
+ try std.fs.cwd().writeFile(.{ .sub_path = object_index, .data = "# Object\n" });
// Pre-populate cache with a markdown file for TestCachedClass
const testclass_dir = try std.fmt.allocPrint(allocator, "{s}/TestCachedClass", .{cache_dir});
@@ -352,8 +158,8 @@ test "markdownForSymbol reads from markdown cache when available" {
var allocating_writer = std.Io.Writer.Allocating.init(allocator);
defer allocating_writer.deinit();
- // Call markdownForSymbol with null api_json_path - should use cache
- try markdownForSymbol(allocator, "TestCachedClass", null, &allocating_writer.writer, &Config.testing);
+ // Call markdownForSymbol - should use cache
+ try markdownForSymbol(allocator, "TestCachedClass", &allocating_writer.writer, &Config.testing);
const written = allocating_writer.written();
@@ -364,190 +170,6 @@ test "markdownForSymbol reads from markdown cache when available" {
cache.clearCache(&Config.testing) catch {};
}
-// RED PHASE: Test for automatic cache population
-// This test verifies that markdownForSymbol auto-generates the cache when empty
-test "markdownForSymbol generates markdown cache when cache is empty" {
- const allocator = std.testing.allocator;
- const cache_dir = Config.testing.cache_dir;
-
- // Clear cache to start empty
- cache.clearCache(&Config.testing) catch {};
-
- // Ensure cache directory exists
- try cache.ensureDirectoryExists(cache_dir);
-
- // Create a JSON file in the cache directory
- const json_path = try cache.getJsonCachePathInDir(allocator, cache_dir);
- defer allocator.free(json_path);
-
- const test_json =
- \\{"builtin_classes": [{"name": "AutoGenClass", "brief_description": "Auto-generated from empty cache"}]}
- ;
- try std.fs.cwd().writeFile(.{ .sub_path = json_path, .data = test_json });
-
- var allocating_writer = std.Io.Writer.Allocating.init(allocator);
- defer allocating_writer.deinit();
-
- // Call markdownForSymbol with null api_json_path
- // Should auto-generate cache and return the symbol
- try markdownForSymbol(allocator, "AutoGenClass", null, &allocating_writer.writer, &Config.testing);
-
- const written = allocating_writer.written();
-
- // Verify it generated markdown for the symbol
- try std.testing.expect(std.mem.indexOf(u8, written, "AutoGenClass") != null);
-
- // Verify the cache was actually created
- const autogen_path = try std.fmt.allocPrint(allocator, "{s}/AutoGenClass/index.md", .{cache_dir});
- defer allocator.free(autogen_path);
-
- const cache_file = try std.fs.openFileAbsolute(autogen_path, .{});
- cache_file.close();
-
- // Cleanup
- cache.clearCache(&Config.testing) catch {};
-}
-
-fn fetchXmlDocs(allocator: Allocator, cache_path: []const u8) void {
- const xml_dir = cache.getXmlDocsDirInCache(allocator, cache_path) catch return;
- defer allocator.free(xml_dir);
-
- cache.ensureDirectoryExists(xml_dir) catch return;
-
- const version = source_fetch.getGodotVersion(allocator) orelse return;
- defer version.deinit(allocator);
-
- var url_buf: [256]u8 = undefined;
- const url = source_fetch.buildTarballUrl(&url_buf, version) orelse return;
-
- var spinner = Spinner{ .message = "Downloading XML docs..." };
- spinner.start();
- defer spinner.finish();
-
- source_fetch.fetchAndExtractXmlDocs(allocator, url, xml_dir) catch |err| {
- // Try hash-based fallback URL
- if (version.hash) |hash| {
- var hash_url_buf: [256]u8 = undefined;
- const hash_url = source_fetch.buildTarballUrlFromHash(&hash_url_buf, hash) orelse return;
- source_fetch.fetchAndExtractXmlDocs(allocator, hash_url, xml_dir) catch {
- std.log.warn("XML doc fetch failed ({}), proceeding without XML supplementation", .{err});
- return;
- };
- } else {
- std.log.warn("XML doc fetch failed ({}), proceeding without XML supplementation", .{err});
- return;
- }
- };
-
- var version_buf: [64]u8 = undefined;
- const version_str = version.formatVersion(&version_buf) orelse return;
-
- source_fetch.writeCompleteMarker(allocator, xml_dir, version_str) catch return;
-}
-
-fn mergeXmlDocs(arena_allocator: Allocator, tmp_allocator: Allocator, db: *DocDatabase, cache_path: []const u8) void {
- const xml_dir = cache.getXmlDocsDirInCache(tmp_allocator, cache_path) catch return;
- defer tmp_allocator.free(xml_dir);
-
- var dir = std.fs.openDirAbsolute(xml_dir, .{ .iterate = true }) catch return;
- defer dir.close();
-
- var iter = dir.iterate();
- while (iter.next() catch return) |entry| {
- if (entry.kind != .file) continue;
- if (!std.mem.endsWith(u8, entry.name, ".xml")) continue;
-
- const class_name = entry.name[0 .. entry.name.len - 4]; // strip .xml
-
- // Read XML file
- const content = dir.readFileAlloc(tmp_allocator, entry.name, 2 * 1024 * 1024) catch continue;
- defer tmp_allocator.free(content);
-
- // Parse with arena_allocator so strings outlive this function
- const class_doc = XmlDocParser.parseClassDoc(arena_allocator, content) catch |err| {
- std.log.warn("failed to parse XML doc for {s}: {}", .{ class_name, err });
- continue;
- };
- // Do NOT freeClassDoc -- arena owns the memory
-
- // Merge tutorials
- if (class_doc.tutorials) |tutorials| {
- if (tutorials.len > 0) {
- if (db.symbols.getPtr(class_name)) |db_entry| {
- if (db_entry.tutorials == null) {
- const db_tutorials = arena_allocator.alloc(DocDatabase.Tutorial, tutorials.len) catch continue;
- for (tutorials, 0..) |t, i| {
- db_tutorials[i] = .{ .title = t.title, .url = t.url };
- }
- db_entry.tutorials = db_tutorials;
- }
- }
- }
- }
-
- // Fill missing class description
- if (class_doc.description) |xml_desc| {
- if (db.symbols.getPtr(class_name)) |db_entry| {
- if (db_entry.description == null) {
- db_entry.description = xml_desc;
- }
- }
- }
-
- // Merge member descriptions (methods, properties, signals)
- if (class_doc.methods) |members| {
- for (members) |member| {
- const member_key = std.fmt.allocPrint(tmp_allocator, "{s}.{s}", .{ class_name, member.name }) catch continue;
- defer tmp_allocator.free(member_key);
-
- if (db.symbols.getPtr(member_key)) |db_entry| {
- if (db_entry.description == null) {
- db_entry.description = member.description;
- }
- }
- }
- }
-
- if (class_doc.properties) |members| {
- for (members) |member| {
- const member_key = std.fmt.allocPrint(tmp_allocator, "{s}.{s}", .{ class_name, member.name }) catch continue;
- defer tmp_allocator.free(member_key);
-
- if (db.symbols.getPtr(member_key)) |db_entry| {
- if (db_entry.description == null) {
- db_entry.description = member.description;
- }
- }
- }
- }
-
- if (class_doc.signals) |members| {
- for (members) |member| {
- const member_key = std.fmt.allocPrint(tmp_allocator, "{s}.{s}", .{ class_name, member.name }) catch continue;
- defer tmp_allocator.free(member_key);
-
- if (db.symbols.getPtr(member_key)) |db_entry| {
- if (db_entry.description == null) {
- db_entry.description = member.description;
- }
- }
- }
- }
-
- // Add entries for classes found in XML but not in JSON
- if (db.symbols.get(class_name) == null) {
- const key = std.fmt.allocPrint(arena_allocator, "{s}", .{class_name}) catch continue;
- db.symbols.put(arena_allocator, key, .{
- .key = key,
- .name = key,
- .kind = .class,
- .description = class_doc.description,
- .brief_description = class_doc.brief_description,
- }) catch continue;
- }
- }
-}
-
comptime {
std.testing.refAllDecls(@This());
}
@@ -563,7 +185,6 @@ pub const DocDatabase = @import("DocDatabase.zig");
pub const XmlDocParser = @import("XmlDocParser.zig");
pub const cache = @import("cache.zig");
pub const Config = @import("Config.zig");
-pub const api = @import("api.zig");
pub const source_fetch = @import("source_fetch.zig");
const Spinner = @import("Spinner.zig");