Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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";
}
94 changes: 93 additions & 1 deletion packages/cli/library-docs-generator/src/CppDocsGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* 2. Compute page keys, resolving filename collisions for template specializations
* 3. Render each compound page and stream to disk via MdxFileWriter
* 4. Generate hierarchical index pages (namespace → category folders → entity pages)
* 5. Generate group pages from the library's Doxygen groups, linking to the entity pages
*
* Designed for sequential rendering: global state in the renderers (nameToPathMap,
* currentPagePath) requires that pages are rendered one at a time.
Expand All @@ -24,6 +25,13 @@ import {
import type { CppCompoundIr } from "../cpp/src/renderers/CompoundPageRenderer.js";
import { renderCompoundPage } from "../cpp/src/renderers/CompoundPageRenderer.js";
import { renderSegmentsPlainText } from "../cpp/src/renderers/DescriptionRenderer.js";
import type { GroupListEntry } from "../cpp/src/renderers/GroupPageRenderer.js";
import {
collectGroupSections,
groupHasContent,
renderGroupPage,
renderGroupsIndexPage
} from "../cpp/src/renderers/GroupPageRenderer.js";
import type {
CategoryDefinition,
CategoryWithEntries,
Expand All @@ -37,7 +45,13 @@ import {
renderNamespacesIndexPage
} from "../cpp/src/renderers/IndexPageRenderer.js";
import { groupFunctionsByName, methodAnchorId } from "../cpp/src/renderers/MethodRenderer.js";
import type { CppClassIr, CppDocstringIr, CppLibraryDocsIr, CppNamespaceIr } from "./types/CppLibraryDocsIr.js";
import type {
CppClassIr,
CppDocstringIr,
CppGroupIr,
CppLibraryDocsIr,
CppNamespaceIr
} from "./types/CppLibraryDocsIr.js";
import { MdxFileWriter } from "./writers/MdxFileWriter.js";

export interface CppGenerateOptions {
Expand Down Expand Up @@ -115,6 +129,9 @@ export function generateCpp(options: CppGenerateOptions): CppGenerateResult {
generateIndexPages(libraryNs, title, writer, rootNsName, outputFolderSlug);
}

// Stage 5: Generate pages for the library's Doxygen groups
generateGroupPages(ir.groups ?? [], writer, repo);

return writer.result();
} finally {
clearEntityRegistry();
Expand Down Expand Up @@ -573,3 +590,78 @@ function generateIndexPages(
generateIndexPages(child, `Namespace ${child.path}`, writer, rootNsName, outputFolderSlug);
}
}

// ---------------------------------------------------------------------------
// Group page generation (Stage 5)
// ---------------------------------------------------------------------------

const GROUPS_FOLDER = "groups";

function groupFolderName(group: CppGroupIr): string {
return sanitizeForFilename(group.name || group.title);
}

function groupDisplayName(group: CppGroupIr): string {
return group.title || group.name;
}

/**
* Generate a page per Doxygen group, plus a groups/index.mdx listing them.
*
* Group pages link to the entity pages written in Stage 3 rather than
* re-rendering their members, so each symbol is documented in one place.
* Members that have no page (for example symbols the parser skipped) are
* listed without a link.
*/
function generateGroupPages(groups: CppGroupIr[], writer: MdxFileWriter, libraryTitle: string): void {
const renderable = groups.filter((group) => groupHasContent(group));
if (renderable.length === 0) {
return;
}

const indexPageKey = `${GROUPS_FOLDER}/index.mdx`;
setCurrentPageSlugPath(pageKeyToSlugPath(indexPageKey));
const entries: GroupListEntry[] = renderable.map((group) => ({
displayName: groupDisplayName(group),
linkPath: `${GROUPS_FOLDER}/${slugifySegment(groupFolderName(group))}`
}));
writer.writePage(indexPageKey, renderGroupsIndexPage(entries, libraryTitle));

const written = new Set<string>();
for (const group of renderable) {
writeGroupPage(group, `${GROUPS_FOLDER}/${groupFolderName(group)}`, writer, written);
}
}

/**
* Write one group page at `<dir>/index.mdx` and recurse into its subgroups.
*
* Links are relative to the group's own folder, matching how Fern resolves
* links on a folder index page (the `/index` suffix is stripped from the URL).
*
* `written` tracks the groups already emitted so a group tree that references
* one of its ancestors terminates instead of recursing forever.
*/
function writeGroupPage(group: CppGroupIr, dir: string, writer: MdxFileWriter, written: Set<string>): void {
if (written.has(group.id)) {
return;
}
written.add(group.id);

const pageKey = `${dir}/index.mdx`;
setCurrentPageSlugPath(pageKeyToSlugPath(pageKey));

const sections = collectGroupSections(group);
const subgroups = group.subgroups.filter((subgroup) => !written.has(subgroup.id) && groupHasContent(subgroup));
const dirSegment = slugifySegment(dir.split("/").pop() ?? "");
const subgroupEntries: GroupListEntry[] = subgroups.map((subgroup) => ({
displayName: groupDisplayName(subgroup),
linkPath: `${dirSegment}/${slugifySegment(groupFolderName(subgroup))}`
}));

writer.writePage(pageKey, renderGroupPage(group, sections, subgroupEntries));

for (const subgroup of subgroups) {
writeGroupPage(subgroup, `${dir}/${groupFolderName(subgroup)}`, writer, written);
}
Comment on lines +664 to +666

Copy link
Copy Markdown
Contributor

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 (writeGroupPage recursion at packages/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 in packages/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 calls groupHasContent per level (packages/cli/library-docs-generator/src/CppDocsGenerator.ts:646). A cycle or pathological nesting depth in subgroups causes non-termination or stack exhaustion.

Prompt for agents
Stage 5 of CppDocsGenerator walks ir.groups recursively (generateGroupPages -> writeGroupPage, plus groupHasContent in GroupPageRenderer) with no depth cap or visited set, while the IR is an unvalidated cast of remote/parser output (orchestrate.ts:299). REVIEW.md's Performance section requires recursive IR traversals to be depth-limited. Consider threading a depth counter (or a Set of already-visited group ids) through writeGroupPage/groupHasContent and bailing out (or raising a CliError) when a cycle or an unreasonable nesting depth is detected, so a malformed group tree cannot cause an infinite loop of page writes or stack exhaustion.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8448683. Both writeGroupPage and groupHasContent now 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.

}
Loading