Skip to content

Commit ec77bff

Browse files
authored
Editor support for link tag (#41877)
* Initial scribbles * Compiles but provides spans instead of location pairs Probably need to fork the services/server types and provide a conversion with Session.toFileSpan. Not sure where to put the conversion. * Switch to DocumentSpan In theory this is already better supported, but not sure practise bears that out. * Builds w/protocol types + conversions * cleanup:better names and scrub TODOs * fix test harness too * Misc 1. Simplify protocol after talking to @mjbvz. 2. Add more tests. 3. Initial notes about where to add parsing. * Parse and store links in the compiler The text of the link is still stored in the comment text, but that's now kept in an object instead of just a string. Each link has the parse for the entity reference, if there is one. Needs lots more tests -- this just makes all the existing jsdoc tests pass. * more tests and some fixes * Fix other failing tests * fix bad merge * polish parser * improve names and array types * slight tweaks * remove some done TODOs * more tests + resulting fixes * add+fix cross-module tests * Support `@see {@link` Plus find-all-refs support equivalent to @see's. * add server test * Make comments actually part of the AST * Add span for link.name in language service/protocol * Make checker optional in getJSDocTags Also change to JSDocCommentText from JSDocCommentComment * Use getTokenValue instead of getTokenText Measure twice, slice once * Add missing support for top-level links The language service and protocol were missing support for top-level links. This commit adds that plumbing. * add string back to comment type in node constructors * Full parse of link tags and jsdoc comment text - Doesn't pass fourslash yet, I'm going to switch to baselines for failures there. - Still needs some work on the protocol to convert file+offset to file+line+offset. * fix lint * Fix missing newlines in inferFromUsage codefix * Parse jsdoc comments as text node/link array And switch to line+character offsets in the protocol * Fix fourslash tests Mostly ones that can't be baselined, but I switched a couple more over to baselines * Improve types and documentation * Test+fix @link emit, scrub other TODOs * update API baselines * test that goto-def works with @link * Split link displaypart into 3 One for link prefix and suffix, one for link name, and one for link text. * update baselines * Provide JSDocTagInfo.text: string to full clients by default Instead of upgrading them to displayparts. * Real server tests * Disambiguate {@link} and @param x {type} They are ambiguous; previously the parser preferred the type interpretation, but will now look ahead and parse links instead when the prefix is `{@link`. * Add explanatory comment in test * fix location in richResponse in protocol * update API baseline * Address PR comments 1. Add a cross-file goto-def test. 2. Switch from per-message args to UserPreference. * use arraysEqual from core
1 parent 3da5982 commit ec77bff

File tree

155 files changed

+20456
-1602
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

155 files changed

+20456
-1602
lines changed

src/compiler/checker.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -5187,7 +5187,7 @@ namespace ts {
51875187
function preserveCommentsOn<T extends Node>(node: T) {
51885188
if (some(propertySymbol.declarations, d => d.kind === SyntaxKind.JSDocPropertyTag)) {
51895189
const d = propertySymbol.declarations?.find(d => d.kind === SyntaxKind.JSDocPropertyTag)! as JSDocPropertyTag;
5190-
const commentText = d.comment;
5190+
const commentText = getTextOfJSDocComment(d.comment);
51915191
if (commentText) {
51925192
setSyntheticLeadingComments(node, [{ kind: SyntaxKind.MultiLineCommentTrivia, text: "*\n * " + commentText.replace(/\n/g, "\n * ") + "\n ", pos: -1, end: -1, hasTrailingNewLine: true }]);
51935193
}
@@ -6702,7 +6702,7 @@ namespace ts {
67026702
const typeParams = getSymbolLinks(symbol).typeParameters;
67036703
const typeParamDecls = map(typeParams, p => typeParameterToDeclaration(p, context));
67046704
const jsdocAliasDecl = symbol.declarations?.find(isJSDocTypeAlias);
6705-
const commentText = jsdocAliasDecl ? jsdocAliasDecl.comment || jsdocAliasDecl.parent.comment : undefined;
6705+
const commentText = getTextOfJSDocComment(jsdocAliasDecl ? jsdocAliasDecl.comment || jsdocAliasDecl.parent.comment : undefined);
67066706
const oldFlags = context.flags;
67076707
context.flags |= NodeBuilderFlags.InTypeAlias;
67086708
const oldEnclosingDecl = context.enclosingDeclaration;
@@ -38577,6 +38577,10 @@ namespace ts {
3857738577
const meaning = SymbolFlags.Type | SymbolFlags.Namespace | SymbolFlags.Value;
3857838578
return resolveEntityName(<EntityName>name, meaning, /*ignoreErrors*/ false, /*dontResolveAlias*/ true, getHostSignatureFromJSDoc(name));
3857938579
}
38580+
else if (isJSDocLink(name.parent)) {
38581+
const meaning = SymbolFlags.Type | SymbolFlags.Namespace | SymbolFlags.Value;
38582+
return resolveEntityName(<EntityName>name, meaning, /*ignoreErrors*/ true);
38583+
}
3858038584

3858138585
if (name.parent.kind === SyntaxKind.TypePredicate) {
3858238586
return resolveEntityName(<Identifier>name, /*meaning*/ SymbolFlags.FunctionScopedVariable);

src/compiler/emitter.ts

+14-10
Original file line numberDiff line numberDiff line change
@@ -3565,13 +3565,16 @@ namespace ts {
35653565
function emitJSDoc(node: JSDoc) {
35663566
write("/**");
35673567
if (node.comment) {
3568-
const lines = node.comment.split(/\r\n?|\n/g);
3569-
for (const line of lines) {
3570-
writeLine();
3571-
writeSpace();
3572-
writePunctuation("*");
3573-
writeSpace();
3574-
write(line);
3568+
const text = getTextOfJSDocComment(node.comment);
3569+
if (text) {
3570+
const lines = text.split(/\r\n?|\n/g);
3571+
for (const line of lines) {
3572+
writeLine();
3573+
writeSpace();
3574+
writePunctuation("*");
3575+
writeSpace();
3576+
write(line);
3577+
}
35753578
}
35763579
}
35773580
if (node.tags) {
@@ -3704,10 +3707,11 @@ namespace ts {
37043707
emit(tagName);
37053708
}
37063709

3707-
function emitJSDocComment(comment: string | undefined) {
3708-
if (comment) {
3710+
function emitJSDocComment(comment: NodeArray<JSDocText | JSDocLink> | undefined) {
3711+
const text = getTextOfJSDocComment(comment);
3712+
if (text) {
37093713
writeSpace();
3710-
write(comment);
3714+
write(text);
37113715
}
37123716
}
37133717

src/compiler/factory/nodeFactory.ts

+64-31
Large diffs are not rendered by default.

src/compiler/factory/nodeTests.ts

+4
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,10 @@ namespace ts {
770770
return node.kind === SyntaxKind.JSDocNameReference;
771771
}
772772

773+
export function isJSDocLink(node: Node): node is JSDocLink {
774+
return node.kind === SyntaxKind.JSDocLink;
775+
}
776+
773777
export function isJSDocAllType(node: Node): node is JSDocAllType {
774778
return node.kind === SyntaxKind.JSDocAllType;
775779
}

src/compiler/parser.ts

+130-62
Large diffs are not rendered by default.

src/compiler/types.ts

+61-44
Large diffs are not rendered by default.

src/compiler/utilitiesPublic.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,11 @@ namespace ts {
896896
return getJSDocTags(node).filter(doc => doc.kind === kind);
897897
}
898898

899+
/** Gets the text of a jsdoc comment, flattening links to their text. */
900+
export function getTextOfJSDocComment(comment?: NodeArray<JSDocText | JSDocLink>) {
901+
return comment?.map(c => c.kind === SyntaxKind.JSDocText ? c.text : `{@link ${c.name ? entityNameToString(c.name) + " " : ""}${c.text}}`).join("");
902+
}
903+
899904
/**
900905
* Gets the effective type parameters. If the node was parsed in a
901906
* JavaScript file, gets the type parameters from the `@template` tag from JSDoc.
@@ -1860,7 +1865,13 @@ namespace ts {
18601865

18611866
/** True if node is of a kind that may contain comment text. */
18621867
export function isJSDocCommentContainingNode(node: Node): boolean {
1863-
return node.kind === SyntaxKind.JSDocComment || node.kind === SyntaxKind.JSDocNamepathType || isJSDocTag(node) || isJSDocTypeLiteral(node) || isJSDocSignature(node);
1868+
return node.kind === SyntaxKind.JSDocComment
1869+
|| node.kind === SyntaxKind.JSDocNamepathType
1870+
|| node.kind === SyntaxKind.JSDocText
1871+
|| node.kind === SyntaxKind.JSDocLink
1872+
|| isJSDocTag(node)
1873+
|| isJSDocTypeLiteral(node)
1874+
|| isJSDocSignature(node);
18641875
}
18651876

18661877
// TODO: determine what this does before making it public.

src/deprecatedCompat/deprecations.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1229,7 +1229,7 @@ namespace ts {
12291229

12301230
/** @deprecated Use `factory.createJSDocParameterTag` or the factory supplied by your transformation context instead. */
12311231
export const createJSDocParamTag = Debug.deprecate(function createJSDocParamTag(name: EntityName, isBracketed: boolean, typeExpression?: JSDocTypeExpression, comment?: string): JSDocParameterTag {
1232-
return factory.createJSDocParameterTag(/*tagName*/ undefined, name, isBracketed, typeExpression, /*isNameFirst*/ false, comment);
1232+
return factory.createJSDocParameterTag(/*tagName*/ undefined, name, isBracketed, typeExpression, /*isNameFirst*/ false, comment ? factory.createNodeArray([factory.createJSDocText(comment)]) : undefined);
12331233
}, factoryDeprecation);
12341234

12351235
/** @deprecated Use `factory.createComma` or the factory supplied by your transformation context instead. */
@@ -1374,4 +1374,4 @@ namespace ts {
13741374
});
13751375

13761376
// #endregion Renamed node Tests
1377-
}
1377+
}

src/harness/client.ts

+12-4
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ namespace ts.server {
175175
kindModifiers: body.kindModifiers,
176176
textSpan: this.decodeSpan(body, fileName),
177177
displayParts: [{ kind: "text", text: body.displayString }],
178-
documentation: [{ kind: "text", text: body.documentation }],
179-
tags: body.tags
178+
documentation: typeof body.documentation === "string" ? [{ kind: "text", text: body.documentation }] : body.documentation,
179+
tags: this.decodeLinkDisplayParts(body.tags)
180180
};
181181
}
182182

@@ -536,6 +536,13 @@ namespace ts.server {
536536
this.lineOffsetToPosition(fileName, span.end, lineMap));
537537
}
538538

539+
private decodeLinkDisplayParts(tags: (protocol.JSDocTagInfo | JSDocTagInfo)[]): JSDocTagInfo[] {
540+
return tags.map(tag => typeof tag.text === "string" ? {
541+
...tag,
542+
text: [textPart(tag.text)]
543+
} : (tag as JSDocTagInfo));
544+
}
545+
539546
getNameOrDottedNameSpan(_fileName: string, _startPos: number, _endPos: number): TextSpan {
540547
return notImplemented();
541548
}
@@ -554,9 +561,10 @@ namespace ts.server {
554561
return undefined;
555562
}
556563

557-
const { items, applicableSpan: encodedApplicableSpan, selectedItemIndex, argumentIndex, argumentCount } = response.body;
564+
const { items: encodedItems, applicableSpan: encodedApplicableSpan, selectedItemIndex, argumentIndex, argumentCount } = response.body;
558565

559-
const applicableSpan = this.decodeSpan(encodedApplicableSpan, fileName);
566+
const applicableSpan = encodedApplicableSpan as unknown as TextSpan;
567+
const items = (encodedItems as (SignatureHelpItem | protocol.SignatureHelpItem)[]).map(item => ({ ...item, tags: this.decodeLinkDisplayParts(item.tags) }));
560568

561569
return { items, applicableSpan, selectedItemIndex, argumentIndex, argumentCount };
562570
}

src/harness/fourslashImpl.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1583,7 +1583,7 @@ namespace FourSlash {
15831583
assert.equal(actualTags.length, (options.tags || ts.emptyArray).length, this.assertionMessageAtLastKnownMarker("signature help tags"));
15841584
ts.zipWith((options.tags || ts.emptyArray), actualTags, (expectedTag, actualTag) => {
15851585
assert.equal(actualTag.name, expectedTag.name);
1586-
assert.equal(actualTag.text, expectedTag.text, this.assertionMessageAtLastKnownMarker("signature help tag " + actualTag.name));
1586+
assert.deepEqual(actualTag.text, expectedTag.text, this.assertionMessageAtLastKnownMarker("signature help tag " + actualTag.name));
15871587
});
15881588

15891589
const allKeys: readonly (keyof FourSlashInterface.VerifySignatureHelpOptions)[] = [

src/server/protocol.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,16 @@ namespace ts.server.protocol {
979979
file: string;
980980
}
981981

982+
export interface JSDocTagInfo {
983+
/** Name of the JSDoc tag */
984+
name: string;
985+
/**
986+
* Comment text after the JSDoc tag -- the text after the tag name until the next tag or end of comment
987+
* Display parts when UserPreferences.displayPartsForJSDoc is true, flattened to string otherwise.
988+
*/
989+
text?: string | SymbolDisplayPart[];
990+
}
991+
982992
export interface TextSpanWithContext extends TextSpan {
983993
contextStart?: Location;
984994
contextEnd?: Location;
@@ -1951,6 +1961,7 @@ namespace ts.server.protocol {
19511961
*/
19521962
export interface QuickInfoRequest extends FileLocationRequest {
19531963
command: CommandTypes.Quickinfo;
1964+
arguments: FileLocationRequestArgs;
19541965
}
19551966

19561967
/**
@@ -1984,8 +1995,9 @@ namespace ts.server.protocol {
19841995

19851996
/**
19861997
* Documentation associated with symbol.
1998+
* Display parts when UserPreferences.displayPartsForJSDoc is true, flattened to string otherwise.
19871999
*/
1988-
documentation: string;
2000+
documentation: string | SymbolDisplayPart[];
19892001

19902002
/**
19912003
* JSDoc tags associated with symbol.
@@ -2208,6 +2220,12 @@ namespace ts.server.protocol {
22082220
kind: string;
22092221
}
22102222

2223+
/** A part of a symbol description that links from a jsdoc @link tag to a declaration */
2224+
export interface JSDocLinkDisplayPart extends SymbolDisplayPart {
2225+
/** The location of the declaration that the @link tag links to. */
2226+
target: FileSpan;
2227+
}
2228+
22112229
/**
22122230
* An item found in a completion response.
22132231
*/
@@ -3301,6 +3319,7 @@ namespace ts.server.protocol {
33013319
readonly allowRenameOfImportPath?: boolean;
33023320
readonly includePackageJsonAutoImports?: "auto" | "on" | "off";
33033321

3322+
readonly displayPartsForJSDoc?: boolean;
33043323
readonly generateReturnInDocTemplate?: boolean;
33053324
}
33063325

src/server/session.ts

+56-21
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,32 @@ namespace ts.server {
12741274
result;
12751275
}
12761276

1277+
private mapJSDocTagInfo(tags: JSDocTagInfo[] | undefined, project: Project, richResponse: boolean): protocol.JSDocTagInfo[] {
1278+
return tags ? tags.map(tag => ({
1279+
...tag,
1280+
text: richResponse ? this.mapDisplayParts(tag.text, project) : tag.text?.map(part => part.text).join("")
1281+
})) : [];
1282+
}
1283+
1284+
private mapDisplayParts(parts: SymbolDisplayPart[] | undefined, project: Project): protocol.SymbolDisplayPart[] {
1285+
if (!parts) {
1286+
return [];
1287+
}
1288+
return parts.map(part => part.kind !== "linkName" ? part : {
1289+
...part,
1290+
target: this.toFileSpan((part as JSDocLinkDisplayPart).target.fileName, (part as JSDocLinkDisplayPart).target.textSpan, project),
1291+
});
1292+
}
1293+
1294+
private mapSignatureHelpItems(items: SignatureHelpItem[], project: Project, richResponse: boolean): protocol.SignatureHelpItem[] {
1295+
return items.map(item => ({
1296+
...item,
1297+
documentation: this.mapDisplayParts(item.documentation, project),
1298+
parameters: item.parameters.map(p => ({ ...p, documentation: this.mapDisplayParts(p.documentation, project) })),
1299+
tags: this.mapJSDocTagInfo(item.tags, project, richResponse),
1300+
}));
1301+
}
1302+
12771303
private mapDefinitionInfo(definitions: readonly DefinitionInfo[], project: Project): readonly protocol.FileSpanWithContext[] {
12781304
return definitions.map(def => this.toFileSpanWithContext(def.fileName, def.textSpan, def.contextSpan, project));
12791305
}
@@ -1685,22 +1711,24 @@ namespace ts.server {
16851711
return undefined;
16861712
}
16871713

1714+
const useDisplayParts = !!this.getPreferences(file).displayPartsForJSDoc;
16881715
if (simplifiedResult) {
16891716
const displayString = displayPartsToString(quickInfo.displayParts);
1690-
const docString = displayPartsToString(quickInfo.documentation);
1691-
16921717
return {
16931718
kind: quickInfo.kind,
16941719
kindModifiers: quickInfo.kindModifiers,
16951720
start: scriptInfo.positionToLineOffset(quickInfo.textSpan.start),
16961721
end: scriptInfo.positionToLineOffset(textSpanEnd(quickInfo.textSpan)),
16971722
displayString,
1698-
documentation: docString,
1699-
tags: quickInfo.tags || []
1723+
documentation: useDisplayParts ? this.mapDisplayParts(quickInfo.documentation, project) : displayPartsToString(quickInfo.documentation),
1724+
tags: this.mapJSDocTagInfo(quickInfo.tags, project, useDisplayParts),
17001725
};
17011726
}
17021727
else {
1703-
return quickInfo;
1728+
return useDisplayParts ? quickInfo : {
1729+
...quickInfo,
1730+
tags: this.mapJSDocTagInfo(quickInfo.tags, project, /*useDisplayParts*/ false) as JSDocTagInfo[]
1731+
};
17041732
}
17051733
}
17061734

@@ -1830,19 +1858,25 @@ namespace ts.server {
18301858
return res;
18311859
}
18321860

1833-
private getCompletionEntryDetails(args: protocol.CompletionDetailsRequestArgs, simplifiedResult: boolean): readonly protocol.CompletionEntryDetails[] | readonly CompletionEntryDetails[] {
1861+
private getCompletionEntryDetails(args: protocol.CompletionDetailsRequestArgs, fullResult: boolean): readonly protocol.CompletionEntryDetails[] | readonly CompletionEntryDetails[] {
18341862
const { file, project } = this.getFileAndProject(args);
18351863
const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file)!;
18361864
const position = this.getPosition(args, scriptInfo);
18371865
const formattingOptions = project.projectService.getFormatCodeOptions(file);
1866+
const useDisplayParts = !!this.getPreferences(file).displayPartsForJSDoc;
18381867

18391868
const result = mapDefined(args.entryNames, entryName => {
18401869
const { name, source, data } = typeof entryName === "string" ? { name: entryName, source: undefined, data: undefined } : entryName;
18411870
return project.getLanguageService().getCompletionEntryDetails(file, position, name, formattingOptions, source, this.getPreferences(file), data ? cast(data, isCompletionEntryData) : undefined);
18421871
});
1843-
return simplifiedResult
1844-
? result.map(details => ({ ...details, codeActions: map(details.codeActions, action => this.mapCodeAction(action)) }))
1845-
: result;
1872+
return fullResult
1873+
? (useDisplayParts ? result : result.map(details => ({ ...details, tags: this.mapJSDocTagInfo(details.tags, project, /*richResponse*/ false) as JSDocTagInfo[] })))
1874+
: result.map(details => ({
1875+
...details,
1876+
codeActions: map(details.codeActions, action => this.mapCodeAction(action)),
1877+
documentation: this.mapDisplayParts(details.documentation, project),
1878+
tags: this.mapJSDocTagInfo(details.tags, project, useDisplayParts),
1879+
}));
18461880
}
18471881

18481882
private getCompileOnSaveAffectedFileList(args: protocol.FileRequestArgs): readonly protocol.CompileOnSaveAffectedFileListSingleProject[] {
@@ -1902,26 +1936,27 @@ namespace ts.server {
19021936
const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file)!;
19031937
const position = this.getPosition(args, scriptInfo);
19041938
const helpItems = project.getLanguageService().getSignatureHelpItems(file, position, args);
1905-
if (!helpItems) {
1906-
return undefined;
1907-
}
1908-
1909-
if (simplifiedResult) {
1939+
const useDisplayParts = !!this.getPreferences(file).displayPartsForJSDoc;
1940+
if (helpItems && simplifiedResult) {
19101941
const span = helpItems.applicableSpan;
19111942
return {
1912-
items: helpItems.items,
1943+
...helpItems,
19131944
applicableSpan: {
19141945
start: scriptInfo.positionToLineOffset(span.start),
19151946
end: scriptInfo.positionToLineOffset(span.start + span.length)
19161947
},
1917-
selectedItemIndex: helpItems.selectedItemIndex,
1918-
argumentIndex: helpItems.argumentIndex,
1919-
argumentCount: helpItems.argumentCount,
1948+
items: this.mapSignatureHelpItems(helpItems.items, project, useDisplayParts),
19201949
};
19211950
}
1922-
else {
1951+
else if (useDisplayParts || !helpItems) {
19231952
return helpItems;
19241953
}
1954+
else {
1955+
return {
1956+
...helpItems,
1957+
items: helpItems.items.map(item => ({ ...item, tags: this.mapJSDocTagInfo(item.tags, project, /*richResponse*/ false) as JSDocTagInfo[] }))
1958+
};
1959+
}
19251960
}
19261961

19271962
private toPendingErrorCheck(uncheckedFileName: string): PendingErrorCheck | undefined {
@@ -2700,10 +2735,10 @@ namespace ts.server {
27002735
return this.requiredResponse(this.getCompletions(request.arguments, CommandNames.CompletionsFull));
27012736
},
27022737
[CommandNames.CompletionDetails]: (request: protocol.CompletionDetailsRequest) => {
2703-
return this.requiredResponse(this.getCompletionEntryDetails(request.arguments, /*simplifiedResult*/ true));
2738+
return this.requiredResponse(this.getCompletionEntryDetails(request.arguments, /*fullResult*/ false));
27042739
},
27052740
[CommandNames.CompletionDetailsFull]: (request: protocol.CompletionDetailsRequest) => {
2706-
return this.requiredResponse(this.getCompletionEntryDetails(request.arguments, /*simplifiedResult*/ false));
2741+
return this.requiredResponse(this.getCompletionEntryDetails(request.arguments, /*fullResult*/ true));
27072742
},
27082743
[CommandNames.CompileOnSaveAffectedFileList]: (request: protocol.CompileOnSaveAffectedFileListRequest) => {
27092744
return this.requiredResponse(this.getCompileOnSaveAffectedFileList(request.arguments));

src/services/classifier.ts

+3
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,9 @@ namespace ts {
740740
pos = tag.end;
741741
break;
742742
}
743+
if (tag.comment) {
744+
pushCommentRange(tag.comment.pos, tag.comment.end - tag.comment.pos);
745+
}
743746
}
744747
}
745748

0 commit comments

Comments
 (0)