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" },
+ ],
+});