Skip to content

Commit abb9681

Browse files
author
Andy
authored
Support completions for JSDoc @param tag names (#16299)
* Support completions for JSDoc @param tag names * Undo change to finishNode * Don't include trailing whitespace in @param range; instead, specialize getJsDocTagAtPosition
1 parent b57830f commit abb9681

21 files changed

+198
-122
lines changed

src/compiler/core.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,8 +523,8 @@ namespace ts {
523523
return result || array;
524524
}
525525

526-
export function mapDefined<T>(array: ReadonlyArray<T>, mapFn: (x: T, i: number) => T | undefined): ReadonlyArray<T> {
527-
const result: T[] = [];
526+
export function mapDefined<T, U>(array: ReadonlyArray<T>, mapFn: (x: T, i: number) => U | undefined): U[] {
527+
const result: U[] = [];
528528
for (let i = 0; i < array.length; i++) {
529529
const item = array[i];
530530
const mapped = mapFn(item, i);

src/compiler/parser.ts

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6663,14 +6663,12 @@ namespace ts {
66636663
});
66646664
}
66656665

6666-
function parseBracketNameInPropertyAndParamTag() {
6667-
let name: Identifier;
6668-
let isBracketed: boolean;
6666+
function parseBracketNameInPropertyAndParamTag(): { name: Identifier, isBracketed: boolean } {
66696667
// Looking for something like '[foo]' or 'foo'
6670-
if (parseOptionalToken(SyntaxKind.OpenBracketToken)) {
6671-
name = parseJSDocIdentifierName();
6668+
const isBracketed = parseOptional(SyntaxKind.OpenBracketToken);
6669+
const name = parseJSDocIdentifierName(/*createIfMissing*/ true);
6670+
if (isBracketed) {
66726671
skipWhitespace();
6673-
isBracketed = true;
66746672

66756673
// May have an optional default, e.g. '[foo = 42]'
66766674
if (parseOptionalToken(SyntaxKind.EqualsToken)) {
@@ -6679,9 +6677,7 @@ namespace ts {
66796677

66806678
parseExpected(SyntaxKind.CloseBracketToken);
66816679
}
6682-
else if (tokenIsIdentifierOrKeyword(token())) {
6683-
name = parseJSDocIdentifierName();
6684-
}
6680+
66856681
return { name, isBracketed };
66866682
}
66876683

@@ -6692,11 +6688,6 @@ namespace ts {
66926688
const { name, isBracketed } = parseBracketNameInPropertyAndParamTag();
66936689
skipWhitespace();
66946690

6695-
if (!name) {
6696-
parseErrorAtPosition(scanner.getStartPos(), 0, Diagnostics.Identifier_expected);
6697-
return undefined;
6698-
}
6699-
67006691
let preName: Identifier, postName: Identifier;
67016692
if (typeExpression) {
67026693
postName = name;
@@ -6947,14 +6938,19 @@ namespace ts {
69476938
return currentToken = scanner.scanJSDocToken();
69486939
}
69496940

6950-
function parseJSDocIdentifierName(): Identifier {
6951-
return createJSDocIdentifier(tokenIsIdentifierOrKeyword(token()));
6941+
function parseJSDocIdentifierName(createIfMissing = false): Identifier {
6942+
return createJSDocIdentifier(tokenIsIdentifierOrKeyword(token()), createIfMissing);
69526943
}
69536944

6954-
function createJSDocIdentifier(isIdentifier: boolean): Identifier {
6945+
function createJSDocIdentifier(isIdentifier: boolean, createIfMissing: boolean): Identifier {
69556946
if (!isIdentifier) {
6956-
parseErrorAtCurrentToken(Diagnostics.Identifier_expected);
6957-
return undefined;
6947+
if (createIfMissing) {
6948+
return <Identifier>createMissingNode(SyntaxKind.Identifier, /*reportAtCurrentPosition*/ true, Diagnostics.Identifier_expected);
6949+
}
6950+
else {
6951+
parseErrorAtCurrentToken(Diagnostics.Identifier_expected);
6952+
return undefined;
6953+
}
69586954
}
69596955

69606956
const pos = scanner.getTokenPos();

src/compiler/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ namespace ts {
425425
FirstNode = QualifiedName,
426426
FirstJSDocNode = JSDocTypeExpression,
427427
LastJSDocNode = JSDocLiteralType,
428-
FirstJSDocTagNode = JSDocComment,
428+
FirstJSDocTagNode = JSDocTag,
429429
LastJSDocTagNode = JSDocLiteralType
430430
}
431431

src/harness/fourslash.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,16 +1602,19 @@ namespace FourSlash {
16021602
}
16031603

16041604
private printMembersOrCompletions(info: ts.CompletionInfo) {
1605+
if (info === undefined) { return "No completion info."; }
1606+
const { entries } = info;
1607+
16051608
function pad(s: string, length: number) {
16061609
return s + new Array(length - s.length + 1).join(" ");
16071610
}
16081611
function max<T>(arr: T[], selector: (x: T) => number): number {
16091612
return arr.reduce((prev, x) => Math.max(prev, selector(x)), 0);
16101613
}
1611-
const longestNameLength = max(info.entries, m => m.name.length);
1612-
const longestKindLength = max(info.entries, m => m.kind.length);
1613-
info.entries.sort((m, n) => m.sortText > n.sortText ? 1 : m.sortText < n.sortText ? -1 : m.name > n.name ? 1 : m.name < n.name ? -1 : 0);
1614-
const membersString = info.entries.map(m => `${pad(m.name, longestNameLength)} ${pad(m.kind, longestKindLength)} ${m.kindModifiers}`).join("\n");
1614+
const longestNameLength = max(entries, m => m.name.length);
1615+
const longestKindLength = max(entries, m => m.kind.length);
1616+
entries.sort((m, n) => m.sortText > n.sortText ? 1 : m.sortText < n.sortText ? -1 : m.name > n.name ? 1 : m.name < n.name ? -1 : 0);
1617+
const membersString = entries.map(m => `${pad(m.name, longestNameLength)} ${pad(m.kind, longestKindLength)} ${m.kindModifiers}`).join("\n");
16151618
Harness.IO.log(membersString);
16161619
}
16171620

@@ -2163,7 +2166,7 @@ namespace FourSlash {
21632166
Harness.IO.log(this.spanInfoToString(this.getNameOrDottedNameSpan(pos), "**"));
21642167
}
21652168

2166-
private verifyClassifications(expected: { classificationType: string; text: string; textSpan?: TextSpan }[], actual: ts.ClassifiedSpan[]) {
2169+
private verifyClassifications(expected: { classificationType: string; text: string; textSpan?: TextSpan }[], actual: ts.ClassifiedSpan[], sourceFileText: string) {
21672170
if (actual.length !== expected.length) {
21682171
this.raiseError("verifyClassifications failed - expected total classifications to be " + expected.length +
21692172
", but was " + actual.length +
@@ -2203,9 +2206,11 @@ namespace FourSlash {
22032206
});
22042207

22052208
function jsonMismatchString() {
2209+
const showActual = actual.map(({ classificationType, textSpan }) =>
2210+
({ classificationType, text: sourceFileText.slice(textSpan.start, textSpan.start + textSpan.length) }));
22062211
return Harness.IO.newLine() +
22072212
"expected: '" + Harness.IO.newLine() + stringify(expected) + "'" + Harness.IO.newLine() +
2208-
"actual: '" + Harness.IO.newLine() + stringify(actual) + "'";
2213+
"actual: '" + Harness.IO.newLine() + stringify(showActual) + "'";
22092214
}
22102215
}
22112216

@@ -2228,14 +2233,14 @@ namespace FourSlash {
22282233
const actual = this.languageService.getSemanticClassifications(this.activeFile.fileName,
22292234
ts.createTextSpan(0, this.activeFile.content.length));
22302235

2231-
this.verifyClassifications(expected, actual);
2236+
this.verifyClassifications(expected, actual, this.activeFile.content);
22322237
}
22332238

22342239
public verifySyntacticClassifications(expected: { classificationType: string; text: string }[]) {
22352240
const actual = this.languageService.getSyntacticClassifications(this.activeFile.fileName,
22362241
ts.createTextSpan(0, this.activeFile.content.length));
22372242

2238-
this.verifyClassifications(expected, actual);
2243+
this.verifyClassifications(expected, actual, this.activeFile.content);
22392244
}
22402245

22412246
public verifyOutliningSpans(spans: TextSpan[]) {

src/services/classifier.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -814,7 +814,7 @@ namespace ts {
814814
* False will mean that node is not classified and traverse routine should recurse into node contents.
815815
*/
816816
function tryClassifyNode(node: Node): boolean {
817-
if (isJSDocTag(node)) {
817+
if (isJSDocNode(node)) {
818818
return true;
819819
}
820820

src/services/completions.ts

Lines changed: 74 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ namespace ts.Completions {
1818
return undefined;
1919
}
2020

21-
const { symbols, isGlobalCompletion, isMemberCompletion, isNewIdentifierLocation, location, requestJsDocTagName, requestJsDocTag, hasFilteredClassMemberKeywords } = completionData;
21+
const { symbols, isGlobalCompletion, isMemberCompletion, isNewIdentifierLocation, location, request, hasFilteredClassMemberKeywords } = completionData;
2222

2323
if (sourceFile.languageVariant === LanguageVariant.JSX &&
2424
location && location.parent && location.parent.kind === SyntaxKind.JsxClosingElement) {
@@ -36,14 +36,15 @@ namespace ts.Completions {
3636
}]};
3737
}
3838

39-
if (requestJsDocTagName) {
40-
// If the current position is a jsDoc tag name, only tag names should be provided for completion
41-
return { isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, entries: JsDoc.getJSDocTagNameCompletions() };
42-
}
43-
44-
if (requestJsDocTag) {
45-
// If the current position is a jsDoc tag, only tags should be provided for completion
46-
return { isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, entries: JsDoc.getJSDocTagCompletions() };
39+
if (request) {
40+
const entries = request.kind === "JsDocTagName"
41+
// If the current position is a jsDoc tag name, only tag names should be provided for completion
42+
? JsDoc.getJSDocTagNameCompletions()
43+
: request.kind === "JsDocTag"
44+
// If the current position is a jsDoc tag, only tags should be provided for completion
45+
? JsDoc.getJSDocTagCompletions()
46+
: JsDoc.getJSDocParameterNameCompletions(request.tag);
47+
return { isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, entries };
4748
}
4849

4950
const entries: CompletionEntry[] = [];
@@ -66,7 +67,7 @@ namespace ts.Completions {
6667
addRange(entries, classMemberKeywordCompletions);
6768
}
6869
// Add keywords if this is not a member completion list
69-
else if (!isMemberCompletion && !requestJsDocTag && !requestJsDocTagName) {
70+
else if (!isMemberCompletion) {
7071
addRange(entries, keywordCompletions);
7172
}
7273

@@ -347,16 +348,27 @@ namespace ts.Completions {
347348
return undefined;
348349
}
349350

350-
function getCompletionData(typeChecker: TypeChecker, log: (message: string) => void, sourceFile: SourceFile, position: number) {
351+
interface CompletionData {
352+
symbols: Symbol[];
353+
isGlobalCompletion: boolean;
354+
isMemberCompletion: boolean;
355+
isNewIdentifierLocation: boolean;
356+
location: Node;
357+
isRightOfDot: boolean;
358+
request?: Request;
359+
hasFilteredClassMemberKeywords: boolean;
360+
}
361+
type Request = { kind: "JsDocTagName" } | { kind: "JsDocTag" } | { kind: "JsDocParameterName", tag: JSDocParameterTag };
362+
363+
function getCompletionData(typeChecker: TypeChecker, log: (message: string) => void, sourceFile: SourceFile, position: number): CompletionData {
351364
const isJavaScriptFile = isSourceFileJavaScript(sourceFile);
352365

353-
// JsDoc tag-name is just the name of the JSDoc tagname (exclude "@")
354-
let requestJsDocTagName = false;
355-
// JsDoc tag includes both "@" and tag-name
356-
let requestJsDocTag = false;
366+
let request: Request | undefined;
357367

358368
let start = timestamp();
359-
const currentToken = getTokenAtPosition(sourceFile, position, /*includeJsDocComment*/ false); // TODO: GH#15853
369+
const currentToken = getTokenAtPosition(sourceFile, position, /*includeJsDocComment*/ false);
370+
// We will check for jsdoc comments with insideComment and getJsDocTagAtPosition. (TODO: that seems rather inefficient to check the same thing so many times.)
371+
360372
log("getCompletionData: Get current token: " + (timestamp() - start));
361373

362374
start = timestamp();
@@ -366,10 +378,10 @@ namespace ts.Completions {
366378

367379
if (insideComment) {
368380
if (hasDocComment(sourceFile, position)) {
369-
// The current position is next to the '@' sign, when no tag name being provided yet.
370-
// Provide a full list of tag names
371381
if (sourceFile.text.charCodeAt(position - 1) === CharacterCodes.at) {
372-
requestJsDocTagName = true;
382+
// The current position is next to the '@' sign, when no tag name being provided yet.
383+
// Provide a full list of tag names
384+
request = { kind: "JsDocTagName" };
373385
}
374386
else {
375387
// When completion is requested without "@", we will have check to make sure that
@@ -389,34 +401,39 @@ namespace ts.Completions {
389401
// * |c|
390402
// */
391403
const lineStart = getLineStartPositionForPosition(position, sourceFile);
392-
requestJsDocTag = !(sourceFile.text.substring(lineStart, position).match(/[^\*|\s|(/\*\*)]/));
404+
if (!(sourceFile.text.substring(lineStart, position).match(/[^\*|\s|(/\*\*)]/))) {
405+
request = { kind: "JsDocTag" };
406+
}
393407
}
394408
}
395409

396410
// Completion should work inside certain JsDoc tags. For example:
397411
// /** @type {number | string} */
398412
// Completion should work in the brackets
399413
let insideJsDocTagExpression = false;
400-
const tag = getJsDocTagAtPosition(sourceFile, position);
414+
const tag = getJsDocTagAtPosition(currentToken, position);
401415
if (tag) {
402416
if (tag.tagName.pos <= position && position <= tag.tagName.end) {
403-
requestJsDocTagName = true;
417+
request = { kind: "JsDocTagName" };
404418
}
405419

406420
switch (tag.kind) {
407421
case SyntaxKind.JSDocTypeTag:
408422
case SyntaxKind.JSDocParameterTag:
409423
case SyntaxKind.JSDocReturnTag:
410424
const tagWithExpression = <JSDocTypeTag | JSDocParameterTag | JSDocReturnTag>tag;
411-
if (tagWithExpression.typeExpression) {
412-
insideJsDocTagExpression = tagWithExpression.typeExpression.pos < position && position < tagWithExpression.typeExpression.end;
425+
if (tagWithExpression.typeExpression && tagWithExpression.typeExpression.pos < position && position < tagWithExpression.typeExpression.end) {
426+
insideJsDocTagExpression = true;
427+
}
428+
else if (isJSDocParameterTag(tag) && (nodeIsMissing(tag.name) || tag.name.pos <= position && position <= tag.name.end)) {
429+
request = { kind: "JsDocParameterName", tag };
413430
}
414431
break;
415432
}
416433
}
417434

418-
if (requestJsDocTagName || requestJsDocTag) {
419-
return { symbols: undefined, isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, location: undefined, isRightOfDot: false, requestJsDocTagName, requestJsDocTag, hasFilteredClassMemberKeywords: false };
435+
if (request) {
436+
return { symbols: undefined, isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, location: undefined, isRightOfDot: false, request, hasFilteredClassMemberKeywords: false };
420437
}
421438

422439
if (!insideJsDocTagExpression) {
@@ -553,7 +570,7 @@ namespace ts.Completions {
553570

554571
log("getCompletionData: Semantic work: " + (timestamp() - semanticStart));
555572

556-
return { symbols, isGlobalCompletion, isMemberCompletion, isNewIdentifierLocation, location, isRightOfDot: (isRightOfDot || isRightOfOpenTag), requestJsDocTagName, requestJsDocTag, hasFilteredClassMemberKeywords };
573+
return { symbols, isGlobalCompletion, isMemberCompletion, isNewIdentifierLocation, location, isRightOfDot: (isRightOfDot || isRightOfOpenTag), request, hasFilteredClassMemberKeywords };
557574

558575
function getTypeScriptMemberSymbols(): void {
559576
// Right of dot member completion list
@@ -1518,4 +1535,34 @@ namespace ts.Completions {
15181535
kind === SyntaxKind.EqualsEqualsEqualsToken ||
15191536
kind === SyntaxKind.ExclamationEqualsEqualsToken;
15201537
}
1538+
1539+
/** Get the corresponding JSDocTag node if the position is in a jsDoc comment */
1540+
function getJsDocTagAtPosition(node: Node, position: number): JSDocTag | undefined {
1541+
const { jsDoc } = getJsDocHavingNode(node);
1542+
if (!jsDoc) return undefined;
1543+
1544+
for (const { pos, end, tags } of jsDoc) {
1545+
if (!tags || position < pos || position > end) continue;
1546+
for (let i = tags.length - 1; i >= 0; i--) {
1547+
const tag = tags[i];
1548+
if (position >= tag.pos) {
1549+
return tag;
1550+
}
1551+
}
1552+
}
1553+
}
1554+
1555+
function getJsDocHavingNode(node: Node): Node {
1556+
if (!isToken(node)) return node;
1557+
1558+
switch (node.kind) {
1559+
case SyntaxKind.VarKeyword:
1560+
case SyntaxKind.LetKeyword:
1561+
case SyntaxKind.ConstKeyword:
1562+
// if the current token is var, let or const, skip the VariableDeclarationList
1563+
return node.parent.parent;
1564+
default:
1565+
return node.parent;
1566+
}
1567+
}
15211568
}

src/services/jsDoc.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,24 @@ namespace ts.JsDoc {
132132
}));
133133
}
134134

135+
export function getJSDocParameterNameCompletions(tag: JSDocParameterTag): CompletionEntry[] {
136+
const nameThusFar = tag.name.text;
137+
const jsdoc = tag.parent;
138+
const fn = jsdoc.parent;
139+
if (!ts.isFunctionLike(fn)) return [];
140+
141+
return mapDefined(fn.parameters, param => {
142+
if (!isIdentifier(param.name)) return undefined;
143+
144+
const name = param.name.text;
145+
if (jsdoc.tags.some(t => t !== tag && isJSDocParameterTag(t) && t.name.text === name)
146+
|| nameThusFar !== undefined && !startsWith(name, nameThusFar))
147+
return undefined;
148+
149+
return { name, kind: ScriptElementKind.parameterElement, kindModifiers: "", sortText: "0" };
150+
});
151+
}
152+
135153
/**
136154
* Checks if position points to a valid position to add JSDoc comments, and if so,
137155
* returns the appropriate template. Otherwise returns an empty string.

src/services/services.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ namespace ts {
136136
}
137137

138138
private createChildren(sourceFile?: SourceFileLike) {
139-
if (isJSDocTag(this)) {
139+
if (this.kind === SyntaxKind.JSDocComment || isJSDocTag(this)) {
140140
/** Don't add trivia for "tokens" since this is in a comment. */
141141
const children: Node[] = [];
142142
this.forEachChild(child => { children.push(child); });
@@ -146,9 +146,9 @@ namespace ts {
146146
const children: Node[] = [];
147147
scanner.setText((sourceFile || this.getSourceFile()).text);
148148
let pos = this.pos;
149-
const useJSDocScanner = this.kind >= SyntaxKind.FirstJSDocTagNode && this.kind <= SyntaxKind.LastJSDocTagNode;
149+
const useJSDocScanner = isJSDocNode(this);
150150
const processNode = (node: Node) => {
151-
const isJSDocTagNode = isJSDocTag(node);
151+
const isJSDocTagNode = isJSDocNode(node);
152152
if (!isJSDocTagNode && pos < node.pos) {
153153
pos = this.addSyntheticNodes(children, pos, node.pos, useJSDocScanner);
154154
}

0 commit comments

Comments
 (0)