diff --git a/src/services/completions.ts b/src/services/completions.ts index 28d29136dab89..ecd83e47f1f9e 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -232,6 +232,7 @@ import { isPropertyAssignment, isPropertyDeclaration, isPropertyNameLiteral, + isQualifiedName, isRegularExpressionLiteral, isSetAccessorDeclaration, isShorthandPropertyAssignment, @@ -256,9 +257,11 @@ import { isTypeOnlyImportDeclaration, isTypeOnlyImportOrExportDeclaration, isTypeParameterDeclaration, + isTypeReferenceNode, isValidTypeOnlyAliasUseSite, isVariableDeclaration, isVariableLike, + JSDoc, JsDoc, JSDocImportTag, JSDocParameterTag, @@ -316,6 +319,7 @@ import { or, ParameterDeclaration, ParenthesizedTypeNode, + parseIsolatedJSDocComment, positionBelongsToNode, positionIsASICandidate, positionsAreOnSameLine, @@ -3321,6 +3325,9 @@ function getCompletionData( let insideJsDocTagTypeExpression = false; let insideJsDocImportTag = false; let isInSnippetScope = false; + // For orphaned JSDoc with qualified name (e.g., t. in function foo(/** @type {t.} */) {}) + // we need to track the left identifier text to enable member completions. See #62281. + let orphanedJsDocQualifiedNameLeft: Identifier | undefined; if (insideComment) { if (hasDocComment(sourceFile, position)) { if (sourceFile.text.charCodeAt(position - 1) === CharacterCodes.at) { @@ -3382,6 +3389,33 @@ function getCompletionData( } } } + else { + // Fallback: Handle orphaned JSDoc comments not attached to any AST node. + // For example: function foo(/** @type {t.} */) {} - no parameter name means + // the parser creates 0 parameters and the JSDoc is not attached. See #62281. + const commentText = sourceFile.text.substring(insideComment.pos, insideComment.end); + const parsed = parseIsolatedJSDocComment(commentText); + if (parsed?.jsDoc?.tags) { + const posInComment = position - insideComment.pos; + for (const parsedTag of parsed.jsDoc.tags) { + const typeExpression = tryGetTypeExpressionFromTag(parsedTag); + if (!typeExpression || typeExpression.pos >= posInComment || posInComment > typeExpression.end) { + continue; + } + insideJsDocTagTypeExpression = true; + + // For member completions after a dot (e.g., t.), find the namespace identifier + if (typeExpression.kind === SyntaxKind.JSDocTypeExpression) { + const typeNode = typeExpression.type; + if (isTypeReferenceNode(typeNode) && isQualifiedName(typeNode.typeName) && posInComment > typeNode.typeName.left.end && isIdentifier(typeNode.typeName.left)) { + const leftText = commentText.substring(typeNode.typeName.left.pos, typeNode.typeName.left.end); + orphanedJsDocQualifiedNameLeft = findJsDocImportNamespaceIdentifier(sourceFile, leftText); + } + } + break; + } + } + } if (!insideJsDocTagTypeExpression && !insideJsDocImportTag) { // Proceed if the current position is in jsDoc tag expression; otherwise it is a normal @@ -3390,6 +3424,22 @@ function getCompletionData( return undefined; } } + else { + // Handle inline JSDoc on function parameters. For these, isInComment returns undefined + // but we can still find a JSDocTag ancestor from the currentToken. See #62281. + const tag = getJsDocTagAtPosition(currentToken, position); + if (tag) { + if (isJSDocImportTag(tag)) { + insideJsDocImportTag = true; + } + else { + const typeExpression = tryGetTypeExpressionFromTag(tag); + if (typeExpression && isCurrentlyEditingNode(typeExpression)) { + insideJsDocTagTypeExpression = true; + } + } + } + } start = timestamp(); // The decision to provide completion depends on the contextToken, which is determined through the previousToken. @@ -3440,7 +3490,8 @@ function getCompletionData( isNewIdentifierLocation = importStatementCompletionInfo.isNewIdentifierLocation; } // Bail out if this is a known invalid completion location - if (!importStatementCompletionInfo.replacementSpan && isCompletionListBlocker(contextToken)) { + // Skip the blocker check if we're inside a JSDoc type expression (including orphaned JSDoc). See #62281. + if (!importStatementCompletionInfo.replacementSpan && !insideJsDocTagTypeExpression && !insideJsDocImportTag && isCompletionListBlocker(contextToken)) { log("Returning an empty list because completion was requested in an invalid position."); return keywordFilters ? keywordCompletionData(keywordFilters, isJsOnlyLocation, computeCommitCharactersAndIsNewIdentifier().isNewIdentifierLocation) @@ -3580,6 +3631,13 @@ function getCompletionData( } } + // Handle orphaned JSDoc with qualified name (e.g., function foo(/** @type {t.} */) {}). + // We found the left identifier's AST node earlier; now use it for member completions. + if (orphanedJsDocQualifiedNameLeft) { + isRightOfDot = true; + node = orphanedJsDocQualifiedNameLeft; + } + const semanticStart = timestamp(); let completionKind = CompletionKind.None; let hasUnresolvedAutoImports = false; @@ -3704,6 +3762,27 @@ function getCompletionData( return undefined; } + /** + * Find a JSDocImportTag in the source file that creates a namespace with the given name. + * Used to enable member completions for orphaned JSDoc comments. See #62281. + */ + function findJsDocImportNamespaceIdentifier(sf: SourceFile, namespaceName: string): Identifier | undefined { + for (const statement of sf.statements) { + const jsDocNodes = (statement as Node & { jsDoc?: JSDoc[]; }).jsDoc; + for (const jsDoc of jsDocNodes ?? []) { + for (const tag of jsDoc.tags ?? []) { + if (isJSDocImportTag(tag)) { + const bindings = tag.importClause?.namedBindings; + if (bindings && isNamespaceImport(bindings) && bindings.name.text === namespaceName) { + return bindings.name; + } + } + } + } + } + return undefined; + } + function getTypeScriptMemberSymbols(): void { // Right of dot member completion list completionKind = CompletionKind.PropertyAccess; diff --git a/tests/cases/fourslash/jsdocTypeCompletionInFunctionParameter.ts b/tests/cases/fourslash/jsdocTypeCompletionInFunctionParameter.ts new file mode 100644 index 0000000000000..70a1592606fcf --- /dev/null +++ b/tests/cases/fourslash/jsdocTypeCompletionInFunctionParameter.ts @@ -0,0 +1,163 @@ +/// + +// Tests based on issue #62281 +// JSDoc @type completion in function parameter contexts + +// @allowJs: true +// @checkJs: true + +// @filename: /types.ts +////export interface MyType { +//// name: string; +//// value: number; +////} +////export interface OtherType { +//// id: number; +////} + +// @filename: /main.js +/////** @import * as t from "./types" */ +//// +/////** +//// * @typedef {Object} MyNamespace +//// * @property {string} name +//// */ +//// +/////** +//// * @typedef {Object} MyNamespace.NestedType +//// * @property {number} value +//// */ +//// +/////** @typedef {number} SomeNumber */ +//// +//// // ============================================================ +//// // Case 1: Regular @type on variable +//// // ============================================================ +//// +/////** @type {t./*case1*/} */ +////const x = {}; +//// +//// // ============================================================ +//// // Case 2: Inline @type with named parameter +//// // ============================================================ +//// +////function f2(/** @type {t./*case2*/} */ p) {} +//// +//// // ============================================================ +//// // Case 3: Property name in type literal (correctly NO completions) +//// // ============================================================ +//// +/////** @type { {/*case3*/ageX: number} } */ +////var y; +//// +//// // ============================================================ +//// // Case 4: @import with named argument +//// // ============================================================ +//// +////function f4(/** @type {t./*case4*/} */arg) {} +//// +//// // ============================================================ +//// // Case 5: @import with unnamed argument (PARSER LIMITATION) +//// // The function is parsed with 0 parameters, JSDoc is orphaned. +//// // ============================================================ +//// +////function f5(/** @type {t./*case5*/} */) {} +//// +//// // ============================================================ +//// // Case 6: @typedef with unnamed argument (PARSER LIMITATION) +//// // The function is parsed with 0 parameters, JSDoc is orphaned. +//// // ============================================================ +//// +////function f6(/** @type {S/*case6*/} */) {} +//// +//// // ============================================================ +//// // Case 7: @typedef with named argument +//// // ============================================================ +//// +////function f7(/** @type {S/*case7*/} */arg) {} +//// +//// // ============================================================ +//// // Case 8: @param tag in function JSDoc +//// // ============================================================ +//// +/////** +//// * @param {t./*case8*/} p +//// */ +////function f8(p) {} +//// +//// // ============================================================ +//// // Additional: @typedef namespace completions +//// // ============================================================ +//// +/////** @param {MyNamespace./*typedefInParam*/} p */ +////function f9(p) {} +//// +////function f10(/** @type {MyNamespace./*typedefInline*/} */ p) {} + +// Cases that SHOULD have completions +verify.completions( + // Case 1: Regular @type on variable + { + marker: "case1", + exact: [ + { name: "MyType", kind: "interface", kindModifiers: "export" }, + { name: "OtherType", kind: "interface", kindModifiers: "export" }, + ], + }, + // Case 2: Inline @type with named parameter + { + marker: "case2", + exact: [ + { name: "MyType", kind: "interface", kindModifiers: "export" }, + { name: "OtherType", kind: "interface", kindModifiers: "export" }, + ], + }, + // Case 4: @import with named argument + { + marker: "case4", + exact: [ + { name: "MyType", kind: "interface", kindModifiers: "export" }, + { name: "OtherType", kind: "interface", kindModifiers: "export" }, + ], + }, + // Case 7: @typedef with named argument + { + marker: "case7", + includes: [{ name: "SomeNumber", kind: "type" }], + }, + // Case 8: @param tag in function JSDoc + { + marker: "case8", + exact: [ + { name: "MyType", kind: "interface", kindModifiers: "export" }, + { name: "OtherType", kind: "interface", kindModifiers: "export" }, + ], + }, + // Additional: @typedef namespace completions + { + marker: ["typedefInParam", "typedefInline"], + includes: [{ name: "NestedType", kind: "type" }], + } +); + +// Case 3: Property name in type literal - NO completions +// (defining a property name, not referencing a type) +verify.completions({ + marker: "case3", + exact: undefined, +}); + +// Cases 5 & 6: Previously parser limitations, now FIXED with orphaned JSDoc handling. +verify.completions( + { + marker: "case5", + exact: [ + { name: "MyType", kind: "interface", kindModifiers: "export" }, + { name: "OtherType", kind: "interface", kindModifiers: "export" }, + ], + }, + { + marker: "case6", + includes: [{ name: "SomeNumber", kind: "type" }], + } +); diff --git a/tests/cases/fourslash/jsdocTypeCompletionOrphanedBranches.ts b/tests/cases/fourslash/jsdocTypeCompletionOrphanedBranches.ts new file mode 100644 index 0000000000000..bfc1d18ba39d2 --- /dev/null +++ b/tests/cases/fourslash/jsdocTypeCompletionOrphanedBranches.ts @@ -0,0 +1,217 @@ +/// + +// Comprehensive tests for orphaned JSDoc handling branches +// Tests issue #62281 - all logical branches + +// @allowJs: true +// @checkJs: true + +// @filename: /types.ts +////export interface MyType { name: string; } +////export interface OtherType { id: number; } +////export namespace Nested { +//// export interface DeepType { value: string; } +////} + +// @filename: /main.js +/////** @import * as t from "./types" */ +/////** @import { MyType } from "./types" */ +/////** @typedef {number} LocalNum */ +/////** @typedef {{name: string}} LocalObj */ +//// +////// ============================================================ +////// Branch 1: Valid qualified name after dot - MEMBER completions +////// ============================================================ +////function branch1(/** @type {t./*b1*/} */) {} +//// +////// ============================================================ +////// Branch 2: Simple type reference (no dot) - GLOBAL type completions +////// ============================================================ +////function branch2(/** @type {Local/*b2*/} */) {} +//// +////// ============================================================ +////// Branch 3: Primitive type - type completions +////// ============================================================ +////function branch3(/** @type {str/*b3*/} */) {} +//// +////// ============================================================ +////// Branch 4: Object literal type - cursor at property name +////// (Known limitation: orphaned JSDoc gives type completions here) +////// ============================================================ +////function branch4(/** @type {{/*b4*/name: string}} */) {} +//// +////// ============================================================ +////// Branch 5: Named import (not namespace) with dot +////// MyType is an interface, not a namespace - falls back to type completions +////// ============================================================ +////function branch5(/** @type {MyType./*b5*/} */) {} +//// +////// ============================================================ +////// Branch 6: Position BEFORE the dot - type completions, not member +////// ============================================================ +////function branch6(/** @type {t/*b6*/.} */) {} +//// +////// ============================================================ +////// Branch 7: Nested qualified name (a.b.) +////// Known limitation: only Identifier.X supported, not QualifiedName.X +////// ============================================================ +////function branch7(/** @type {t.Nested./*b7*/} */) {} +//// +////// ============================================================ +////// Branch 8: Empty/whitespace JSDoc - NO completions +////// ============================================================ +////function branch8(/** /*b8*/ */) {} +//// +////// ============================================================ +////// Branch 9: Regular comment (not JSDoc) - NO completions +////// ============================================================ +////function branch9(/* regular /*b9*/ comment */) {} +//// +////// ============================================================ +////// Branch 10: @param tag without type - NO completions +////// ============================================================ +////function branch10(/** @param /*b10*/ */) {} +//// +////// ============================================================ +////// Branch 11: @import not at first statement - should still find it +////// ============================================================ +////const dummy = 1; +/////** @import * as t2 from "./types" */ +////function branch11(/** @type {t2./*b11*/} */) {} +//// +////// ============================================================ +////// Branch 12: Multiple orphaned params - each should work +////// ============================================================ +////function branch12(/** @type {t./*b12a*/} */, /** @type {Local/*b12b*/} */) {} +//// +////// ============================================================ +////// Branch 13: Cursor right after opening brace - type completions +////// ============================================================ +////function branch13(/** @type {/*b13*/} */) {} +//// +////// ============================================================ +////// Branch 14: Non-existent namespace - falls back to type completions +////// ============================================================ +////function branch14(/** @type {nonexistent./*b14*/} */) {} +//// +////// ============================================================ +////// Branch 15: @satisfies tag (not just @type) - should also work +////// ============================================================ +////function branch15(/** @satisfies {t./*b15*/} */) {} + +// Branch 1: Qualified name with namespace import - MEMBER completions +verify.completions({ + marker: "b1", + exact: [ + { name: "MyType", kind: "interface", kindModifiers: "export" }, + { name: "Nested", kind: "module", kindModifiers: "export" }, + { name: "OtherType", kind: "interface", kindModifiers: "export" }, + ], +}); + +// Branch 2: Simple type reference - should get matching types +verify.completions({ + marker: "b2", + includes: [ + { name: "LocalNum", kind: "type" }, + { name: "LocalObj", kind: "type" }, + ], +}); + +// Branch 3: Primitive type prefix - should get type completions +verify.completions({ + marker: "b3", + includes: [{ name: "string", kind: "keyword", sortText: completion.SortText.GlobalsOrKeywords }], +}); + +// Branch 4: Object literal property name in orphaned JSDoc +// Known limitation: orphaned JSDoc provides type completions at property name positions +verify.completions({ + marker: "b4", + includes: [{ name: "string", kind: "keyword", sortText: completion.SortText.GlobalsOrKeywords }], +}); + +// Branch 5: Named import used with dot - type completions (not member) +// MyType is an interface, not a namespace, so no member completions +verify.completions({ + marker: "b5", + includes: [{ name: "string", kind: "keyword", sortText: completion.SortText.GlobalsOrKeywords }], + excludes: ["name"], // No member completions from MyType interface +}); + +// Branch 6: Position before dot - should get type completions, not member +verify.completions({ + marker: "b6", + includes: [{ name: "t", kind: "alias" }], + excludes: ["OtherType"], // OtherType is only accessible via t., not directly +}); + +// Branch 7: Nested qualified name (a.b.) - KNOWN LIMITATION +// Our orphaned JSDoc handler only handles simple Identifier.X patterns +verify.completions({ + marker: "b7", + includes: [{ name: "string", kind: "keyword", sortText: completion.SortText.GlobalsOrKeywords }], + excludes: ["DeepType"], // Can't resolve nested qualified names +}); + +// Branch 8: Empty JSDoc - NO completions +verify.completions({ + marker: "b8", + exact: undefined, +}); + +// Branch 9: Regular comment - NO completions +verify.completions({ + marker: "b9", + exact: undefined, +}); + +// Branch 10: @param without type - NO completions +verify.completions({ + marker: "b10", + exact: undefined, +}); + +// Branch 11: @import not first - should still find namespace +verify.completions({ + marker: "b11", + exact: [ + { name: "MyType", kind: "interface", kindModifiers: "export" }, + { name: "Nested", kind: "module", kindModifiers: "export" }, + { name: "OtherType", kind: "interface", kindModifiers: "export" }, + ], +}); + +// Branch 12a: First param in multi-param - member completions +verify.completions({ + marker: "b12a", + includes: [{ name: "MyType", kind: "interface", kindModifiers: "export" }], +}); + +// Branch 12b: Second param in multi-param - type completions +verify.completions({ + marker: "b12b", + includes: [{ name: "LocalNum", kind: "type" }], +}); + +// Branch 13: Right after opening brace - type completions +verify.completions({ + marker: "b13", + includes: [{ name: "string", kind: "keyword", sortText: completion.SortText.GlobalsOrKeywords }], +}); + +// Branch 14: Non-existent namespace - falls back to type completions +verify.completions({ + marker: "b14", + includes: [{ name: "string", kind: "keyword", sortText: completion.SortText.GlobalsOrKeywords }], +}); + +// Branch 15: @satisfies tag - works just like @type +verify.completions({ + marker: "b15", + exact: [ + { name: "MyType", kind: "interface", kindModifiers: "export" }, + { name: "Nested", kind: "module", kindModifiers: "export" }, + { name: "OtherType", kind: "interface", kindModifiers: "export" }, + ], +});