diff --git a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts index 53b0dd5..9e28c6e 100644 --- a/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts +++ b/packages/cli/src/core/parser/jsdoc/jsdoc-parser.ts @@ -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"; @@ -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(); - - 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(); + 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, - 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 { diff --git a/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts b/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts index 83fb89a..a5b97a6 100644 --- a/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts +++ b/packages/cli/src/tests/core/parser/jsdoc/jsdoc-parser.spec.ts @@ -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)", () => { + 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);