From 62911abd1b0f598bb0ca8eb64fabdc95a27f8bc0 Mon Sep 17 00:00:00 2001 From: DukeDeSouth Date: Tue, 16 Jun 2026 10:38:55 -0400 Subject: [PATCH 1/2] Show nested JSDoc descriptions for destructured params in signature help Signature help rendered an empty description for object-destructured parameters whose docs live on nested `@param parent.child` tags. Resolve each destructured property's documentation the same way quick info does on hover (parent object type -> property -> getDocumentationComment) and show it alongside the parameter's own doc. Fixes #24746 Co-authored-by: Cursor --- src/services/signatureHelp.ts | 43 +++++++++++++++++- .../signatureHelpJSDocDestructuredParams.ts | 44 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/cases/fourslash/signatureHelpJSDocDestructuredParams.ts diff --git a/src/services/signatureHelp.ts b/src/services/signatureHelp.ts index 70e9174e64a60..e488ad1e6d6ac 100644 --- a/src/services/signatureHelp.ts +++ b/src/services/signatureHelp.ts @@ -28,12 +28,14 @@ import { getInvokedExpression, getPossibleGenericSignatures, getPossibleTypeArgumentsInfo, + getTextOfIdentifierOrLiteral, Identifier, identity, InternalSymbolName, isArrayBindingPattern, isBinaryExpression, isBindingElement, + isBindingPattern, isBlock, isCallOrNewExpression, isFunctionTypeNode, @@ -45,6 +47,7 @@ import { isMethodDeclaration, isNoSubstitutionTemplateLiteral, isObjectBindingPattern, + isOmittedExpression, isParameter, isPropertyAccessExpression, isSourceFile, @@ -59,6 +62,7 @@ import { JsxTagNameExpression, last, lastOrUndefined, + lineBreakPart, ListFormat, map, mapToDisplayParts, @@ -85,6 +89,7 @@ import { SyntaxKind, TaggedTemplateExpression, TemplateExpression, + textPart, TextSpan, tryCast, TupleTypeReference, @@ -795,7 +800,43 @@ function createSignatureHelpParameterForParameter(parameter: Symbol, checker: Ty }); const isOptional = checker.isOptionalParameter(parameter.valueDeclaration as ParameterDeclaration); const isRest = isTransientSymbol(parameter) && !!(parameter.links.checkFlags & CheckFlags.RestParameter); - return { name: parameter.name, documentation: parameter.getDocumentationComment(checker), displayParts, isOptional, isRest }; + let documentation = parameter.getDocumentationComment(checker); + // A destructured parameter (binding pattern) carries the per-property descriptions on nested + // `@param parent.child` tags, which are not part of the parameter symbol's own documentation. + // Surface those alongside the parameter doc, matching how quick info resolves them on hover. + const destructuredDocumentation = getDestructuredParameterDocumentation(parameter, checker); + if (destructuredDocumentation.length) { + documentation = documentation.length + ? [...documentation, lineBreakPart(), ...destructuredDocumentation] + : destructuredDocumentation; + } + return { name: parameter.name, documentation, displayParts, isOptional, isRest }; +} + +function getDestructuredParameterDocumentation(parameter: Symbol, checker: TypeChecker): SymbolDisplayPart[] { + const declaration = parameter.valueDeclaration; + if (!declaration || !isParameter(declaration) || !isBindingPattern(declaration.name)) { + return emptyArray; + } + const objectType = checker.getTypeAtLocation(declaration.name); + const types = objectType.isUnion() ? objectType.types : [objectType]; + const parts: SymbolDisplayPart[] = []; + for (const element of declaration.name.elements) { + if (isOmittedExpression(element)) continue; + const nameNode = element.propertyName || element.name; + if (!isIdentifier(nameNode)) continue; + const propertyName = getTextOfIdentifierOrLiteral(nameNode); + const propertyDocumentation = firstDefined(types, type => { + const property = type.getProperty(propertyName); + const doc = property && property.getDocumentationComment(checker); + return doc && doc.length ? doc : undefined; + }); + if (propertyDocumentation) { + if (parts.length) parts.push(lineBreakPart()); + parts.push(textPart(propertyName), textPart(": "), ...propertyDocumentation); + } + } + return parts; } function createSignatureHelpParameterForTypeParameter(typeParameter: TypeParameter, checker: TypeChecker, enclosingDeclaration: Node, sourceFile: SourceFile, printer: Printer): SignatureHelpParameter { diff --git a/tests/cases/fourslash/signatureHelpJSDocDestructuredParams.ts b/tests/cases/fourslash/signatureHelpJSDocDestructuredParams.ts new file mode 100644 index 0000000000000..484e8b6f8d875 --- /dev/null +++ b/tests/cases/fourslash/signatureHelpJSDocDestructuredParams.ts @@ -0,0 +1,44 @@ +/// + +// @allowJs: true +// @checkJs: true +// @Filename: a.js + +/////** +//// * @param {Object} opts The options bag. +//// * @param {number} opts.id The numeric id. +//// * @param {string} opts.label The display label. +//// */ +////function withParentDoc({ id, label }) {} +////withParentDoc(/*1*/); +//// +/////** +//// * @param {Object} opts +//// * @param {number} opts.id The numeric id. +//// * @param {string} opts.label The display label. +//// */ +////function withoutParentDoc({ id, label }) {} +////withoutParentDoc(/*2*/); + +verify.signatureHelp( + { + marker: "1", + parameterName: "__0", + parameterDocComment: "The options bag.\nid: The numeric id.\nlabel: The display label.", + tags: [ + { name: "param", text: [{ text: "opts", kind: "parameterName" }, { text: " ", kind: "space" }, { text: "The options bag.", kind: "text" }] }, + { name: "param", text: [{ text: "opts.id", kind: "parameterName" }, { text: " ", kind: "space" }, { text: "The numeric id.", kind: "text" }] }, + { name: "param", text: [{ text: "opts.label", kind: "parameterName" }, { text: " ", kind: "space" }, { text: "The display label.", kind: "text" }] }, + ], + }, + { + marker: "2", + parameterName: "__0", + parameterDocComment: "id: The numeric id.\nlabel: The display label.", + tags: [ + { name: "param", text: [{ text: "opts", kind: "text" }] }, + { name: "param", text: [{ text: "opts.id", kind: "parameterName" }, { text: " ", kind: "space" }, { text: "The numeric id.", kind: "text" }] }, + { name: "param", text: [{ text: "opts.label", kind: "parameterName" }, { text: " ", kind: "space" }, { text: "The display label.", kind: "text" }] }, + ], + }, +); From 664800995271b0c158868154ddea41e3e2014ad8 Mon Sep 17 00:00:00 2001 From: DukeDeSouth Date: Tue, 16 Jun 2026 10:54:25 -0400 Subject: [PATCH 2/2] Handle literal property names and skip rest in destructured-param signature help Follow-up to review feedback: the destructured-parameter doc helper rejected string/numeric property names (`{ "foo": x }`) because of an identifier-only guard, and it could borrow the documentation of a same-named property for an object-rest binding (`{ a, ...rest }`). Use isPropertyNameLiteral so quoted and numeric names resolve while computed/private names are skipped, and skip rest elements via dotDotDotToken. Extends the fourslash test accordingly. Co-authored-by: Cursor --- src/services/signatureHelp.ts | 8 ++++- .../signatureHelpJSDocDestructuredParams.ts | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/services/signatureHelp.ts b/src/services/signatureHelp.ts index e488ad1e6d6ac..2968cd45b9a2f 100644 --- a/src/services/signatureHelp.ts +++ b/src/services/signatureHelp.ts @@ -50,6 +50,7 @@ import { isOmittedExpression, isParameter, isPropertyAccessExpression, + isPropertyNameLiteral, isSourceFile, isSourceFileJS, isSpreadElement, @@ -823,8 +824,13 @@ function getDestructuredParameterDocumentation(parameter: Symbol, checker: TypeC const parts: SymbolDisplayPart[] = []; for (const element of declaration.name.elements) { if (isOmittedExpression(element)) continue; + // A rest element (`...rest`) captures the remaining properties; its name is not a property + // of the object type, so never attempt to resolve documentation for it. + if (element.dotDotDotToken) continue; const nameNode = element.propertyName || element.name; - if (!isIdentifier(nameNode)) continue; + // Property names may be identifiers, string literals or numeric literals; computed and + // private names cannot be resolved statically and are skipped. + if (!isPropertyNameLiteral(nameNode)) continue; const propertyName = getTextOfIdentifierOrLiteral(nameNode); const propertyDocumentation = firstDefined(types, type => { const property = type.getProperty(propertyName); diff --git a/tests/cases/fourslash/signatureHelpJSDocDestructuredParams.ts b/tests/cases/fourslash/signatureHelpJSDocDestructuredParams.ts index 484e8b6f8d875..e2e79a58b2462 100644 --- a/tests/cases/fourslash/signatureHelpJSDocDestructuredParams.ts +++ b/tests/cases/fourslash/signatureHelpJSDocDestructuredParams.ts @@ -19,6 +19,21 @@ //// */ ////function withoutParentDoc({ id, label }) {} ////withoutParentDoc(/*2*/); +//// +/////** +//// * @param {Object} opts +//// * @param {string} opts.foo a foo +//// */ +////function quotedName({ "foo": x }) {} +////quotedName(/*3*/); +//// +/////** +//// * @param {Object} opts +//// * @param {number} opts.a aaa +//// * @param {number} opts.rest REST_DOC +//// */ +////function withRest({ a, ...rest }) {} +////withRest(/*4*/); verify.signatureHelp( { @@ -41,4 +56,25 @@ verify.signatureHelp( { name: "param", text: [{ text: "opts.label", kind: "parameterName" }, { text: " ", kind: "space" }, { text: "The display label.", kind: "text" }] }, ], }, + { + // Quoted (string-literal) property name resolves its nested @param doc. + marker: "3", + parameterName: "__0", + parameterDocComment: "foo: a foo", + tags: [ + { name: "param", text: [{ text: "opts", kind: "text" }] }, + { name: "param", text: [{ text: "opts.foo", kind: "parameterName" }, { text: " ", kind: "space" }, { text: "a foo", kind: "text" }] }, + ], + }, + { + // Object-rest binding must not borrow the doc of a same-named property. + marker: "4", + parameterName: "__0", + parameterDocComment: "a: aaa", + tags: [ + { name: "param", text: [{ text: "opts", kind: "text" }] }, + { name: "param", text: [{ text: "opts.a", kind: "parameterName" }, { text: " ", kind: "space" }, { text: "aaa", kind: "text" }] }, + { name: "param", text: [{ text: "opts.rest", kind: "parameterName" }, { text: " ", kind: "space" }, { text: "REST_DOC", kind: "text" }] }, + ], + }, );