diff --git a/snapshots/godot_bare_class_refs.md b/snapshots/godot_bare_class_refs.md new file mode 100644 index 0000000..9490dfb --- /dev/null +++ b/snapshots/godot_bare_class_refs.md @@ -0,0 +1,3 @@ +Both `Node2D` and `Control` inherit from `CanvasItem`. + +See also `@GDScript` and `Transform2D`. \ No newline at end of file diff --git a/snapshots/godot_codeblock.md b/snapshots/godot_codeblock.md new file mode 100644 index 0000000..64c02d6 --- /dev/null +++ b/snapshots/godot_codeblock.md @@ -0,0 +1,11 @@ +Example usage: + + +``` +for i in range(10): + var node = get_child(i) + node.queue_free() +``` + + +This frees all children. \ No newline at end of file diff --git a/snapshots/godot_codeblocks_multilang.md b/snapshots/godot_codeblocks_multilang.md new file mode 100644 index 0000000..2b4fd8a --- /dev/null +++ b/snapshots/godot_codeblocks_multilang.md @@ -0,0 +1,20 @@ +Iterate through collisions: + + +```gdscript +for i in get_slide_collision_count(): + var collision = get_slide_collision(i) + print(collision.get_collider().name) +``` + + +```csharp +for (int i = 0; i < GetSlideCollisionCount(); i++) +{ + var collision = GetSlideCollision(i); + GD.Print(collision.GetCollider().Name); +} +``` + + +See `move_and_slide()`. \ No newline at end of file diff --git a/snapshots/godot_cross_refs.md b/snapshots/godot_cross_refs.md new file mode 100644 index 0000000..a6761ac --- /dev/null +++ b/snapshots/godot_cross_refs.md @@ -0,0 +1,7 @@ +See `position` and `look_at()`. + +Uses *delta* with `NAN`. + +Emits `finished` for `LoopMode`. + +Apply `@export` to `color`. \ No newline at end of file diff --git a/snapshots/godot_mixed_with_bbcode.md b/snapshots/godot_mixed_with_bbcode.md new file mode 100644 index 0000000..8f573b1 --- /dev/null +++ b/snapshots/godot_mixed_with_bbcode.md @@ -0,0 +1,5 @@ +**Note:** The `position` is relative to the parent. + +Use `get_node()` or `get_child()` to access children. + +See *also* [the docs](https://docs.godotengine.org). \ No newline at end of file diff --git a/snapshots/godot_xml_roundtrip_class.md b/snapshots/godot_xml_roundtrip_class.md new file mode 100644 index 0000000..793c616 --- /dev/null +++ b/snapshots/godot_xml_roundtrip_class.md @@ -0,0 +1,13 @@ +# TestGodotTags + +*Inherits: Node2D* + +See `position` and `look_at()`. + +## Description + +Uses *delta* with `Node2D` class. See `NOTIFICATION_READY`. + +## Methods + +- **do_thing(speed: float)** - Applies *speed* using `move_and_slide()`. Returns `OK` on success. diff --git a/snapshots/godot_xml_roundtrip_method.md b/snapshots/godot_xml_roundtrip_method.md new file mode 100644 index 0000000..095806f --- /dev/null +++ b/snapshots/godot_xml_roundtrip_method.md @@ -0,0 +1,7 @@ +# TestGodotTags.do_thing(speed: float) + +**Parent**: TestGodotTags + +## Description + +Applies *speed* using `move_and_slide()`. Returns `OK` on success. diff --git a/src/DocDatabase.zig b/src/DocDatabase.zig index d69efc3..c87c185 100644 --- a/src/DocDatabase.zig +++ b/src/DocDatabase.zig @@ -43,14 +43,195 @@ pub const Entry = struct { fn bbcodeToMarkdown(allocator: Allocator, input: []const u8) ![]const u8 { var output: std.Io.Writer.Allocating = .init(allocator); - const bbcode_doc = try bbcodez.loadFromBuffer(allocator, input, .{}); + const bbcode_doc = try bbcodez.loadFromBuffer(allocator, input, .{ + .verbatim_tags = &.{ "code", "codeblock", "codeblocks" }, + .tokenizer_options = .{ .equals_required_in_parameters = false }, + .parser_options = .{ .is_self_closing_fn = &isGodotSelfClosing }, + }); defer bbcode_doc.deinit(); - try bbcodez.fmt.md.renderDocument(allocator, bbcode_doc, &output.writer, .{}); + try bbcodez.fmt.md.renderDocument(allocator, bbcode_doc, &output.writer, .{ + .write_element_fn = &writeGodotElement, + }); return try output.toOwnedSlice(); } +/// Known Godot cross-reference tag names (self-closing, space-separated value). +const godot_cross_ref_tags = [_][]const u8{ + "member", + "method", + "param", + "constant", + "signal", + "enum", + "annotation", + "theme_item", +}; + +fn isGodotCrossRefTag(name: []const u8) bool { + for (&godot_cross_ref_tags) |tag| { + if (std.mem.eql(u8, name, tag)) return true; + } + return false; +} + +/// Returns true if a tag is a bare Godot class reference like [Node2D] or [@GDScript]. +fn isBareClassRef(name: []const u8) bool { + if (name.len < 2) return false; + if (std.ascii.isUpper(name[0])) return true; + if (name[0] == '@' and name.len > 1 and std.ascii.isUpper(name[1])) return true; + return false; +} + +/// Parser callback: marks Godot cross-ref tags and bare class refs as self-closing. +fn isGodotSelfClosing(_: ?*anyopaque, token: bbcodez.tokenizer.TokenResult.Token) bool { + return isGodotCrossRefTag(token.name) or isBareClassRef(token.name); +} + +/// Renderer callback: converts Godot-specific elements to markdown. +fn writeGodotElement(node: bbcodez.Node, ctx_ptr: ?*const anyopaque) anyerror!bool { + if (node.type != .element) return false; + + const ctx: *const bbcodez.fmt.md.WriteContext = @ptrCast(@alignCast(ctx_ptr)); + const name = try node.getName(); + + // Cross-reference tags: [member X], [method X], [param X], etc. + if (isGodotCrossRefTag(name)) { + const value = (try node.getValue()) orelse return false; + if (std.mem.eql(u8, name, "method")) { + try ctx.writer.print("`{s}()`", .{value}); + } else if (std.mem.eql(u8, name, "param")) { + try ctx.writer.print("*{s}*", .{value}); + } else { + try ctx.writer.print("`{s}`", .{value}); + } + return true; + } + + // Code blocks: [codeblock]...[/codeblock] + if (std.mem.eql(u8, name, "codeblock")) { + const content = getVerbatimContent(node); + try writeFencedCode(ctx.writer, content, null); + return true; + } + + // Multi-language code blocks: [codeblocks][gdscript]...[/gdscript][csharp]...[/csharp][/codeblocks] + if (std.mem.eql(u8, name, "codeblocks")) { + const content = getVerbatimContent(node); + try writeCodeblocks(ctx.writer, content); + return true; + } + + // Bare class references: [Node2D], [Transform2D], [@GDScript] + if (node.type == .element and isBareClassRef(name)) { + try ctx.writer.print("`{s}`", .{name}); + return true; + } + + return false; +} + +/// Extracts the text content from a verbatim element's children. +fn getVerbatimContent(node: bbcodez.Node) []const u8 { + if (node.type != .element) return ""; + for (node.children.items) |child| { + if (child.type == .text) { + return child.value.text; + } + } + return ""; +} + +/// Extracts language blocks from [codeblocks] verbatim content and writes fenced code. +fn writeCodeblocks(writer: *std.Io.Writer, content: []const u8) !void { + const lang_tags = [_][]const u8{ "gdscript", "csharp" }; + var wrote_any = false; + + for (&lang_tags) |lang| { + if (findTagContent(content, lang)) |span| { + const trimmed = std.mem.trim(u8, span, " \t\n\r"); + if (trimmed.len > 0) { + if (wrote_any) try writer.writeAll("\n"); + try writeFencedCode(writer, span, lang); + wrote_any = true; + } + } + } + + if (!wrote_any) { + // Fallback: emit raw content as plain code block + try writeFencedCode(writer, content, null); + } +} + +/// Finds content between [tag]...[/tag] in a string. +fn findTagContent(content: []const u8, tag: []const u8) ?[]const u8 { + // Build "[tag]" pattern + var open_buf: [64]u8 = undefined; + const open_tag = std.fmt.bufPrint(&open_buf, "[{s}]", .{tag}) catch return null; + + // Build "[/tag]" pattern + var close_buf: [64]u8 = undefined; + const close_tag = std.fmt.bufPrint(&close_buf, "[/{s}]", .{tag}) catch return null; + + const open_pos = std.mem.indexOf(u8, content, open_tag) orelse return null; + const content_start = open_pos + open_tag.len; + const close_pos = std.mem.indexOfPos(u8, content, content_start, close_tag) orelse return null; + + return content[content_start..close_pos]; +} + +/// Writes content as a fenced code block, stripping common leading indentation. +fn writeFencedCode(writer: *std.Io.Writer, raw: []const u8, lang: ?[]const u8) !void { + // Strip only leading/trailing newlines, preserving indentation structure + const trimmed = std.mem.trim(u8, raw, "\n\r"); + if (std.mem.trim(u8, trimmed, " \t").len == 0) return; + + // Find minimum indentation across non-empty lines + var min_indent: usize = std.math.maxInt(usize); + var line_iter = std.mem.splitScalar(u8, trimmed, '\n'); + while (line_iter.next()) |line| { + const stripped = std.mem.trimLeft(u8, line, " \t"); + if (stripped.len == 0) continue; + min_indent = @min(min_indent, line.len - stripped.len); + } + if (min_indent == std.math.maxInt(usize)) min_indent = 0; + + // Write fenced block with dedented lines + if (lang) |l| { + try writer.print("\n```{s}\n", .{l}); + } else { + try writer.writeAll("\n```\n"); + } + + // Count non-trailing-blank lines + var line_count: usize = 0; + var count_iter = std.mem.splitScalar(u8, trimmed, '\n'); + while (count_iter.next()) |_| line_count += 1; + // Find last non-blank line + var last_content_line: usize = 0; + var idx: usize = 0; + var scan_iter = std.mem.splitScalar(u8, trimmed, '\n'); + while (scan_iter.next()) |line| : (idx += 1) { + if (std.mem.trimLeft(u8, line, " \t").len > 0) last_content_line = idx; + } + + line_iter = std.mem.splitScalar(u8, trimmed, '\n'); + var line_idx: usize = 0; + var first = true; + while (line_iter.next()) |line| : (line_idx += 1) { + if (line_idx > last_content_line) break; + if (!first) try writer.writeAll("\n"); + first = false; + if (line.len > min_indent) { + try writer.writeAll(line[min_indent..]); + } + } + + try writer.writeAll("\n```\n"); +} + pub fn lookupSymbolExact(self: DocDatabase, symbol: []const u8) DocDatabase.Error!Entry { return self.symbols.get(symbol) orelse return DocDatabase.Error.SymbolNotFound; } @@ -1171,6 +1352,131 @@ test "lookupSymbolExact returns SymbolNotFound for missing symbol in XML databas try std.testing.expectError(DocDatabase.Error.SymbolNotFound, result); } +// --- Godot BBCode conversion snapshot tests --- + +fn writeSnapshot(content: []const u8, path: []const u8) !void { + var file = try std.fs.cwd().createFile(path, .{}); + defer file.close(); + + var buf: [4096]u8 = undefined; + var file_writer = file.writer(&buf); + try file_writer.interface.writeAll(content); + try file_writer.interface.flush(); +} + +test "snapshot: cross-reference tags" { + const allocator = std.testing.allocator; + const result = try bbcodeToMarkdown(allocator, + \\See [member position] and [method look_at]. + \\Uses [param delta] with [constant NAN]. + \\Emits [signal finished] for [enum LoopMode]. + \\Apply [annotation @export] to [theme_item color]. + ); + defer allocator.free(result); + try writeSnapshot(result, "snapshots/godot_cross_refs.md"); +} + +test "snapshot: bare class references" { + const allocator = std.testing.allocator; + const result = try bbcodeToMarkdown(allocator, + \\Both [Node2D] and [Control] inherit from [CanvasItem]. + \\See also [@GDScript] and [Transform2D]. + ); + defer allocator.free(result); + try writeSnapshot(result, "snapshots/godot_bare_class_refs.md"); +} + +test "snapshot: mixed Godot tags with standard BBCode" { + const allocator = std.testing.allocator; + const result = try bbcodeToMarkdown(allocator, + \\[b]Note:[/b] The [member position] is relative to the parent. + \\Use [code]get_node()[/code] or [method get_child] to access children. + \\See [i]also[/i] [url=https://docs.godotengine.org]the docs[/url]. + ); + defer allocator.free(result); + try writeSnapshot(result, "snapshots/godot_mixed_with_bbcode.md"); +} + +test "snapshot: codeblock with indented content" { + const allocator = std.testing.allocator; + const result = try bbcodeToMarkdown(allocator, + \\Example usage: + \\[codeblock] + \\for i in range(10): + \\ var node = get_child(i) + \\ node.queue_free() + \\[/codeblock] + \\This frees all children. + ); + defer allocator.free(result); + try writeSnapshot(result, "snapshots/godot_codeblock.md"); +} + +test "snapshot: codeblocks with gdscript and csharp" { + const allocator = std.testing.allocator; + const result = try bbcodeToMarkdown(allocator, + \\Iterate through collisions: + \\[codeblocks] + \\[gdscript] + \\for i in get_slide_collision_count(): + \\ var collision = get_slide_collision(i) + \\ print(collision.get_collider().name) + \\[/gdscript] + \\[csharp] + \\for (int i = 0; i < GetSlideCollisionCount(); i++) + \\{ + \\ var collision = GetSlideCollision(i); + \\ GD.Print(collision.GetCollider().Name); + \\} + \\[/csharp] + \\[/codeblocks] + \\See [method move_and_slide]. + ); + defer allocator.free(result); + try writeSnapshot(result, "snapshots/godot_codeblocks_multilang.md"); +} + +test "snapshot: XML roundtrip with Godot tags" { + 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 = + \\ + \\ + \\ See [member position] and [method look_at]. + \\ Uses [param delta] with [Node2D] class. See [constant NOTIFICATION_READY]. + \\ + \\ + \\ + \\ + \\ Applies [param speed] using [method move_and_slide]. Returns [constant OK] on success. + \\ + \\ + \\ + ; + try tmp_dir.dir.writeFile(.{ .sub_path = "TestGodotTags.xml", .data = xml_content }); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const db = try DocDatabase.loadFromXmlDir(arena.allocator(), allocator, tmp_path); + + // Write class index snapshot + var allocating: Writer.Allocating = .init(allocator); + defer allocating.deinit(); + try db.generateMarkdownForSymbol(allocator, "TestGodotTags", &allocating.writer); + try writeSnapshot(allocating.written(), "snapshots/godot_xml_roundtrip_class.md"); + + // Write method snapshot + var allocating2: Writer.Allocating = .init(allocator); + defer allocating2.deinit(); + try db.generateMarkdownForSymbol(allocator, "TestGodotTags.do_thing", &allocating2.writer); + try writeSnapshot(allocating2.written(), "snapshots/godot_xml_roundtrip_method.md"); +} + const std = @import("std"); const ArenaAllocator = std.heap.ArenaAllocator; const Allocator = std.mem.Allocator;