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
125 changes: 73 additions & 52 deletions packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { JSDoc } from "ts-morph";
import * as commentParser from "comment-parser";
import { initial, uniq } from "es-toolkit";
import { JSDoc } from "ts-morph";
import {
ParsedJSDoc,
ExampleData,
ParameterData,
ParsedJSDoc,
ReturnData,
SeeData,
ThrowsData,
TypedefData,
ExampleData,
SeeData,
VersionData,
} from "../../types/parser.types.js";

Expand Down Expand Up @@ -104,64 +105,84 @@ export class JSDocParser {

private extractParameters(block: commentParser.Block): ParameterData[] {
const paramTags = block.tags.filter(tag => tag.tag === "param");
const parameters: ParameterData[] = [];
const paramMap = new Map<string, ParameterData>();

for (const tag of paramTags) {
const param = this.parseParameterTag(tag);
if (!param) continue;

if (param.name.includes(".")) {
this.handleNestedParameter(param, paramMap, parameters);
} else {
paramMap.set(param.name, param);
parameters.push(param);
}
}
const uniqueParamTags = this.removeDuplicateParamTags(paramTags);
const allParamTags = [...uniqueParamTags, ...this.createMissingParentParams(uniqueParamTags)];
const topLevelParams = allParamTags.filter(paramTag => !paramTag.name.includes("."));

return parameters;
return topLevelParams.map(paramTag => this.paramTagToParameterData(paramTag, allParamTags));
}

private parseParameterTag(tag: commentParser.Spec): ParameterData {
const { type, name, description, optional, default: defaultValue } = tag;
// only keep the last tag with the same name
private removeDuplicateParamTags(paramTags: commentParser.Spec[]): commentParser.Spec[] {
const uniqueByName = new Map<string, commentParser.Spec>();
for (const paramTag of paramTags) {
uniqueByName.set(paramTag.name, paramTag);
}

return [...uniqueByName.values()];
}

private paramTagToParameterData(paramTag: commentParser.Spec, allParamTags: commentParser.Spec[]): ParameterData {
return {
name,
type,
description,
required: !optional,
defaultValue: defaultValue,
nested: [],
name: this.getParamShortName(paramTag.name),
type: paramTag.type,
description: paramTag.description,
required: !paramTag.optional,
defaultValue: paramTag.default,
nested: this.buildNestedParams(paramTag.name, allParamTags),
};
}

private handleNestedParameter(
param: ParameterData,
paramMap: Map<string, ParameterData>,
parameters: ParameterData[]
): void {
const parts = param.name.split(".");
const parentName = parts[0];
if (!parentName) return;

let parent = paramMap.get(parentName);

if (!parent) {
parent = {
name: parentName,
type: "Object",
description: "",
required: true,
defaultValue: undefined,
nested: [],
};
paramMap.set(parentName, parent);
parameters.push(parent);
private buildNestedParams(parentParamName: string, allParamTags: commentParser.Spec[]): ParameterData[] {
const childParams = allParamTags.filter(paramTag => this.getParentParamName(paramTag.name) === parentParamName);

return childParams.map(paramTag => this.paramTagToParameterData(paramTag, allParamTags));
}

private createMissingParentParams(paramTags: commentParser.Spec[]): commentParser.Spec[] {
const existingParamNames = paramTags.map(paramTag => paramTag.name);
const ancestorParamNames = paramTags.flatMap(paramTag => this.getParamAncestorNames(paramTag.name));
const missingParentNames = uniq(ancestorParamNames.filter(name => !existingParamNames.includes(name)));

return missingParentNames.map(name => this.createParamPlaceholderTag(name));
}

private getParamAncestorNames(paramName: string): string[] {
const parentName = this.getParentParamName(paramName);
if (parentName == null) {
return [];
}

return [parentName, ...this.getParamAncestorNames(parentName)];
}

private getParentParamName(paramName: string): string | null {
const segments = paramName.split(".");
const hasParent = segments.length > 1;
if (hasParent) {
return initial(segments).join(".");
}

const nestedParam = { ...param, name: parts.slice(1).join(".") };
parent.nested = parent.nested || [];
parent.nested.push(nestedParam);
return null;
}

private getParamShortName(paramName: string) {
const segments = paramName.split(".");

return segments.at(-1) ?? paramName;
}

private createParamPlaceholderTag(name: string): commentParser.Spec {
return {
tag: "param",
name,
type: "Object",
description: "",
optional: false,
default: undefined,
source: [],
problems: [],
};
}

private extractReturns(block: commentParser.Block): ReturnData | undefined {
Expand Down
117 changes: 117 additions & 0 deletions packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,123 @@ export function processOptions(options: { name: string; age: number; active?: bo
expect(result.parameters?.[0].nested?.[2].defaultValue).toBe("true");
});

it("should parse deeply nested parameter properties (3+ levels)", () => {

Choose a reason for hiding this comment

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

I think we should add some test cases for edge cases like this to improve robustness. For example, what happens if the parent is declared after its children?

/**
 * @param {Object} config.database.connection - Connection settings
 * @param {string} config.database.connection.host - Database host
 * @param {number} config.database.connection.port - Database port
 * @param {string} config.database.name - Database name
 * @param {Object} config.database - Database settings
 * @param {Object} config - Configuration object
 */
export function configure(config: any): void {
  // implementation
}

Copy link
Collaborator Author

@Zih0 Zih0 Jan 26, 2026

Choose a reason for hiding this comment

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

I added test cases.
cf3e539
a3d58cd

const sourceFile = project.createSourceFile(
"test.ts",
`
/**
* @param {Object} config - Configuration object
* @param {Object} config.database - Database settings
* @param {Object} config.database.connection - Connection settings
* @param {string} config.database.connection.host - Database host
* @param {number} config.database.connection.port - Database port
* @param {string} config.database.name - Database name
*/
export function configure(config: any): void {
// implementation
}
`
);

const func = sourceFile.getFunction("configure")!;
const jsDoc = func.getJsDocs()[0]!;

const result = parser.parse(jsDoc);

expect(result.parameters).toMatchObject([
{
name: "config",
nested: [
{
name: "database",
nested: [{ name: "connection", nested: [{ name: "host" }, { name: "port" }] }, { name: "name" }],
},
],
},
]);
});

it("should parse deeply nested parameters regardless of declaration order", () => {
const sourceFile = project.createSourceFile(
"test.ts",
`
/**
* @param {Object} config.database.connection - Connection settings
* @param {string} config.database.connection.host - Database host
* @param {number} config.database.connection.port - Database port
* @param {string} config.database.name - Database name
* @param {Object} config.database - Database settings
* @param {Object} config - Configuration object
*/
export function configure(config: any): void {
// implementation
}
`
);

const func = sourceFile.getFunction("configure")!;
const jsDoc = func.getJsDocs()[0]!;

const result = parser.parse(jsDoc);

expect(result.parameters).toHaveLength(1);
expect(result.parameters).toMatchObject([
{
name: "config",
type: "Object",
nested: [
{
name: "database",
type: "Object",
nested: [
{
name: "connection",
type: "Object",
nested: [{ name: "host" }, { name: "port" }],
},
{ name: "name" },
],
},
],
},
]);
});

it("should create placeholder for parent when only nested parameter is defined", () => {
const sourceFile = project.createSourceFile(
"test.ts",
`
/**
* @param {Object} config.database - Database settings
* @param {string} config.database.host - Database host
*/
export function configure(config: any): void {
// implementation
}
`
);

const func = sourceFile.getFunction("configure")!;
const jsDoc = func.getJsDocs()[0]!;

const result = parser.parse(jsDoc);

expect(result.parameters).toHaveLength(1);
expect(result.parameters?.[0]).toMatchObject({
name: "config",
type: "Object",
description: "",
nested: [
{
name: "database",
type: "Object",
description: "- Database settings",
nested: [{ name: "host", type: "string" }],
},
],
});
});

it("should handle JSDoc without any documentation", () => {
const result = parser.parse(null as unknown as JSDoc);

Expand Down