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");