-
Notifications
You must be signed in to change notification settings - Fork 336
feat(cli): render Doxygen group pages for C++ library docs #17420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Ryan-Amirthan
wants to merge
2
commits into
main
Choose a base branch
from
devin/1786721871-cpp-group-pages
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
packages/cli/cli/changes/unreleased/cpp-library-docs-groups.yml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json | ||
|
|
||
| - summary: | | ||
| C++ library docs now render Doxygen groups (`@defgroup` / `@ingroup` / `@addtogroup`). | ||
| Each group, including nested subgroups, gets a page under `groups/` that links to the | ||
| generated symbol pages, so a library's authored organization shows up in the navigation. | ||
| type: feat |
170 changes: 170 additions & 0 deletions
170
packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| /** | ||
| * Renders pages for Doxygen groups (the "Modules" of Doxygen's own HTML output). | ||
| * | ||
| * Groups are authored in the source comments with `@defgroup` / `@ingroup` / | ||
| * `@addtogroup` and describe how the library's author organizes the API. They | ||
| * are rendered as index pages that link to the per-symbol pages produced from | ||
| * the namespace tree, so a symbol's documentation lives in exactly one place. | ||
| * | ||
| * Two page types are produced: | ||
| * 1. Group page — the group's docs plus links to its members and subgroups | ||
| * 2. Groups index — a bulleted list of the top-level groups | ||
| */ | ||
|
|
||
| import type { CppFunctionIr, CppGroupIr } from "../../../src/types/CppLibraryDocsIr.js"; | ||
| import { buildLinkPath, stripTemplateArgs } from "../context.js"; | ||
| import { | ||
| renderDescriptionBlocksDeduped, | ||
| renderSegmentsPlainText, | ||
| renderSegmentsTrimmed | ||
| } from "./DescriptionRenderer.js"; | ||
| import { renderFrontmatter, trimTrailingBlankLines } from "./shared.js"; | ||
|
|
||
| export interface GroupMemberEntry { | ||
| displayName: string; | ||
| linkPath: string | undefined; | ||
| } | ||
|
|
||
| export interface GroupSection { | ||
| heading: string; | ||
| entries: GroupMemberEntry[]; | ||
| } | ||
|
|
||
| export interface GroupListEntry { | ||
| displayName: string; | ||
| linkPath: string; | ||
| } | ||
|
|
||
| function collectEntries<T extends { path: string }>(items: T[]): GroupMemberEntry[] { | ||
| return items | ||
| .map((item) => ({ displayName: item.path, linkPath: buildLinkPath(item.path) })) | ||
| .sort((a, b) => a.displayName.localeCompare(b.displayName)); | ||
| } | ||
|
|
||
| function collectFunctionEntries(functions: CppFunctionIr[]): GroupMemberEntry[] { | ||
| // Deduplicate by path — overloads share one page | ||
| const seen = new Set<string>(); | ||
| const entries: GroupMemberEntry[] = []; | ||
| for (const func of functions) { | ||
| const stripped = stripTemplateArgs(func.path); | ||
| if (seen.has(stripped)) { | ||
| continue; | ||
| } | ||
| seen.add(stripped); | ||
| entries.push({ displayName: func.path, linkPath: buildLinkPath(stripped) }); | ||
| } | ||
| return entries.sort((a, b) => a.displayName.localeCompare(b.displayName)); | ||
| } | ||
|
|
||
| /** | ||
| * Collect a group's inlined members into rendered sections, skipping empty ones. | ||
| * | ||
| * Must be called with the group's page as the current page (see | ||
| * `setCurrentPageSlugPath`) so that member links resolve to relative paths. | ||
| */ | ||
| export function collectGroupSections(group: CppGroupIr): GroupSection[] { | ||
| const classes = group.classes ?? []; | ||
| const sections: GroupSection[] = [ | ||
| { heading: "Classes", entries: collectEntries(classes.filter((cls) => cls.kind !== "struct")) }, | ||
| { heading: "Structs", entries: collectEntries(classes.filter((cls) => cls.kind === "struct")) }, | ||
| { heading: "Functions", entries: collectFunctionEntries(group.functions ?? []) }, | ||
| { heading: "Enumerations", entries: collectEntries(group.enums ?? []) }, | ||
| { heading: "Type Definitions", entries: collectEntries(group.typedefs ?? []) }, | ||
| { heading: "Variables", entries: collectEntries(group.variables ?? []) } | ||
| ]; | ||
| return sections.filter((section) => section.entries.length > 0); | ||
| } | ||
|
|
||
| /** | ||
| * Whether a group (or any of its subgroups) has anything to render. | ||
| * | ||
| * A group tree that references one of its ancestors would otherwise recurse | ||
| * forever, so already-visited groups are treated as having no content. | ||
| */ | ||
| export function groupHasContent(group: CppGroupIr, visited: Set<string> = new Set()): boolean { | ||
| if (visited.has(group.id)) { | ||
| return false; | ||
| } | ||
| visited.add(group.id); | ||
| const hasMembers = [group.classes, group.functions, group.enums, group.typedefs, group.variables].some( | ||
| (members) => members != null && members.length > 0 | ||
| ); | ||
| return hasMembers || group.subgroups.some((subgroup) => groupHasContent(subgroup, visited)); | ||
| } | ||
|
|
||
| function renderEntries(entries: GroupMemberEntry[], lines: string[]): void { | ||
| for (const entry of entries) { | ||
| if (entry.linkPath) { | ||
| lines.push(`- [\`${entry.displayName}\`](${entry.linkPath})`); | ||
| } else { | ||
| lines.push(`- \`${entry.displayName}\``); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Render a single group page. | ||
| * | ||
| * @param group - The group to render | ||
| * @param sections - Pre-computed non-empty member sections | ||
| * @param subgroupEntries - Pre-computed links to nested groups | ||
| */ | ||
| export function renderGroupPage( | ||
| group: CppGroupIr, | ||
| sections: GroupSection[], | ||
| subgroupEntries: GroupListEntry[] | ||
| ): string { | ||
| const lines: string[] = []; | ||
| const docstring = group.docstring; | ||
|
|
||
| const title = group.title || group.name; | ||
| const summaryText = docstring ? renderSegmentsPlainText(docstring.summary) : ""; | ||
| const description = summaryText.length > 0 ? summaryText : `Members of the ${title} group.`; | ||
| lines.push(...renderFrontmatter(title, description)); | ||
|
|
||
| if (docstring?.summary && docstring.summary.length > 0) { | ||
| const summary = renderSegmentsTrimmed(docstring.summary); | ||
| if (summary) { | ||
| lines.push("", summary); | ||
| } | ||
| } | ||
|
|
||
| if (docstring?.description && docstring.description.length > 0) { | ||
| const desc = renderDescriptionBlocksDeduped(docstring.description, docstring.summary); | ||
| if (desc) { | ||
| lines.push("", desc); | ||
| } | ||
| } | ||
|
|
||
| for (const section of sections) { | ||
| lines.push("", `## ${section.heading}`, ""); | ||
| renderEntries(section.entries, lines); | ||
| } | ||
|
|
||
| if (subgroupEntries.length > 0) { | ||
| lines.push("", "## Subgroups", ""); | ||
| for (const entry of subgroupEntries) { | ||
| lines.push(`- [${entry.displayName}](${entry.linkPath})`); | ||
| } | ||
| } | ||
|
|
||
| trimTrailingBlankLines(lines); | ||
| return lines.join("\n") + "\n"; | ||
| } | ||
|
|
||
| /** | ||
| * Render the groups/index.mdx page listing the top-level groups. | ||
| */ | ||
| export function renderGroupsIndexPage(entries: GroupListEntry[], libraryTitle: string): string { | ||
| const lines: string[] = []; | ||
|
|
||
| lines.push(...renderFrontmatter(`${libraryTitle} — Groups`, `Documentation groups in ${libraryTitle}.`)); | ||
| lines.push(""); | ||
|
|
||
| for (const entry of entries) { | ||
| lines.push(`- [${entry.displayName}](${entry.linkPath})`); | ||
| } | ||
|
|
||
| trimTrailingBlankLines(lines); | ||
| return lines.join("\n") + "\n"; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Group traversal has no depth limit, so a self-referencing group tree never terminates
Documentation groups are walked recursively with no depth or visited-node limit (
writeGroupPagerecursion atpackages/cli/library-docs-generator/src/CppDocsGenerator.ts:655-657), so a group tree that references an ancestor makes generation loop forever writing pages.Impact: Docs generation can hang and fill the disk instead of failing cleanly.
Repository rule and the three unbounded traversals
REVIEW.md(Performance) requires flagging "any unbounded loops or recursive traversals over IR nodes without depth limits". Stage 5 adds three such traversals over untrusted IR that is cast without validation inpackages/cli/library-docs-generator/src/orchestrate.ts:299:groupHasContent(packages/cli/library-docs-generator/cpp/src/renderers/GroupPageRenderer.ts:85),writeGroupPage(packages/cli/library-docs-generator/src/CppDocsGenerator.ts:655-657), and the subgroup filtering that callsgroupHasContentper level (packages/cli/library-docs-generator/src/CppDocsGenerator.ts:646). A cycle or pathological nesting depth insubgroupscauses non-termination or stack exhaustion.Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 8448683. Both
writeGroupPageandgroupHasContentnow carry a visited set of group ids and bail on a repeat, so a subgroup that points back at an ancestor terminates instead of writing pages forever. Added a test with a deliberately cyclic IR (terminates on a group tree whose subgroup references an ancestor) that asserts exactly 3 group pages are written.Went with a visited set rather than a depth cap since the cycle is the real failure mode here — depth is bounded by the group tree once each id is written at most once.