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
This commit is contained in:
Nathan Shively-Sanders
2021-03-16 16:26:01 -07:00
committed by GitHub
parent 3da5982c9a
commit ec77bff332
155 changed files with 20286 additions and 1432 deletions
+3
View File
@@ -740,6 +740,9 @@ namespace ts {
pos = tag.end;
break;
}
if (tag.comment) {
pushCommentRange(tag.comment.pos, tag.comment.end - tag.comment.pos);
}
}
}
+5 -5
View File
@@ -128,7 +128,7 @@ namespace ts.codefix {
const typeNode = getTypeNodeIfAccessible(type, parent, program, host);
if (typeNode) {
// Note that the codefix will never fire with an existing `@type` tag, so there is no need to merge tags
const typeTag = factory.createJSDocTypeTag(/*tagName*/ undefined, factory.createJSDocTypeExpression(typeNode), /*comment*/ "");
const typeTag = factory.createJSDocTypeTag(/*tagName*/ undefined, factory.createJSDocTypeExpression(typeNode), /*comment*/ undefined);
addJSDocTags(changes, sourceFile, cast(parent.parent.parent, isExpressionStatement), [typeTag]);
}
importAdder.writeFixes(changes);
@@ -307,7 +307,7 @@ namespace ts.codefix {
return;
}
const typeExpression = factory.createJSDocTypeExpression(typeNode);
const typeTag = isGetAccessorDeclaration(declaration) ? factory.createJSDocReturnTag(/*tagName*/ undefined, typeExpression, "") : factory.createJSDocTypeTag(/*tagName*/ undefined, typeExpression, "");
const typeTag = isGetAccessorDeclaration(declaration) ? factory.createJSDocReturnTag(/*tagName*/ undefined, typeExpression, /*comment*/ undefined) : factory.createJSDocTypeTag(/*tagName*/ undefined, typeExpression, /*comment*/ undefined);
addJSDocTags(changes, sourceFile, parent, [typeTag]);
}
else if (!tryReplaceImportTypeNodeWithAutoImport(typeNode, declaration, sourceFile, changes, importAdder, getEmitScriptTarget(program.getCompilerOptions()))) {
@@ -374,20 +374,20 @@ namespace ts.codefix {
}
else {
const paramTags = map(inferences, ({ name, typeNode, isOptional }) =>
factory.createJSDocParameterTag(/*tagName*/ undefined, name, /*isBracketed*/ !!isOptional, factory.createJSDocTypeExpression(typeNode), /* isNameFirst */ false, ""));
factory.createJSDocParameterTag(/*tagName*/ undefined, name, /*isBracketed*/ !!isOptional, factory.createJSDocTypeExpression(typeNode), /* isNameFirst */ false, /*comment*/ undefined));
addJSDocTags(changes, sourceFile, signature, paramTags);
}
}
export function addJSDocTags(changes: textChanges.ChangeTracker, sourceFile: SourceFile, parent: HasJSDoc, newTags: readonly JSDocTag[]): void {
const comments = mapDefined(parent.jsDoc, j => j.comment);
const comments = flatMap(parent.jsDoc, j => j.comment) as (JSDocText | JSDocLink)[];
const oldTags = flatMapToMutable(parent.jsDoc, j => j.tags);
const unmergedNewTags = newTags.filter(newTag => !oldTags || !oldTags.some((tag, i) => {
const merged = tryMergeJsdocTags(tag, newTag);
if (merged) oldTags[i] = merged;
return !!merged;
}));
const tag = factory.createJSDocComment(comments.join("\n"), factory.createNodeArray([...(oldTags || emptyArray), ...unmergedNewTags]));
const tag = factory.createJSDocComment(factory.createNodeArray(intersperse(comments, factory.createJSDocText("\n"))), factory.createNodeArray([...(oldTags || emptyArray), ...unmergedNewTags]));
const jsDocNode = parent.kind === SyntaxKind.ArrowFunction ? getJsDocNodeForArrowFunction(parent) : parent;
jsDocNode.jsDoc = parent.jsDoc;
jsDocNode.jsDocCache = parent.jsDocCache;
+25 -15
View File
@@ -82,21 +82,28 @@ namespace ts.JsDoc {
let jsDocTagNameCompletionEntries: CompletionEntry[];
let jsDocTagCompletionEntries: CompletionEntry[];
export function getJsDocCommentsFromDeclarations(declarations: readonly Declaration[]): SymbolDisplayPart[] {
export function getJsDocCommentsFromDeclarations(declarations: readonly Declaration[], checker?: TypeChecker): SymbolDisplayPart[] {
// Only collect doc comments from duplicate declarations once:
// In case of a union property there might be same declaration multiple times
// which only varies in type parameter
// Eg. const a: Array<string> | Array<number>; a.length
// The property length will have two declarations of property length coming
// from Array<T> - Array<string> and Array<number>
const documentationComment: string[] = [];
const parts: SymbolDisplayPart[][] = [];
forEachUnique(declarations, declaration => {
for (const { comment } of getCommentHavingNodes(declaration)) {
if (comment === undefined) continue;
pushIfUnique(documentationComment, comment);
const newparts = getDisplayPartsFromComment(comment, checker);
if (!contains(parts, newparts, isIdenticalListOfDisplayParts)) {
parts.push(newparts);
}
}
});
return intersperse(map(documentationComment, textPart), lineBreakPart());
return flatten(intersperse(parts, [lineBreakPart()]));
}
function isIdenticalListOfDisplayParts(parts1: SymbolDisplayPart[], parts2: SymbolDisplayPart[]) {
return arraysEqual(parts1, parts2, (p1, p2) => p1.kind === p2.kind && p1.text === p2.text);
}
function getCommentHavingNodes(declaration: Declaration): readonly (JSDoc | JSDocTag)[] {
@@ -112,18 +119,25 @@ namespace ts.JsDoc {
}
}
export function getJsDocTagsFromDeclarations(declarations?: Declaration[]): JSDocTagInfo[] {
export function getJsDocTagsFromDeclarations(declarations?: Declaration[], checker?: TypeChecker): JSDocTagInfo[] {
// Only collect doc comments from duplicate declarations once.
const tags: JSDocTagInfo[] = [];
forEachUnique(declarations, declaration => {
for (const tag of getJSDocTags(declaration)) {
tags.push({ name: tag.tagName.text, text: getCommentText(tag) });
tags.push({ name: tag.tagName.text, text: getCommentDisplayParts(tag, checker) });
}
});
return tags;
}
function getCommentText(tag: JSDocTag): string | undefined {
function getDisplayPartsFromComment(comment: readonly (JSDocText | JSDocLink)[], checker: TypeChecker | undefined): SymbolDisplayPart[] {
return flatMap(
comment,
node => node.kind === SyntaxKind.JSDocText ? [textPart(node.text)] : buildLinkParts(node, checker)
) as SymbolDisplayPart[];
}
function getCommentDisplayParts(tag: JSDocTag, checker?: TypeChecker): SymbolDisplayPart[] | undefined {
const { comment } = tag;
switch (tag.kind) {
case SyntaxKind.JSDocImplementsTag:
@@ -131,7 +145,7 @@ namespace ts.JsDoc {
case SyntaxKind.JSDocAugmentsTag:
return withNode((tag as JSDocAugmentsTag).class);
case SyntaxKind.JSDocTemplateTag:
return withList((tag as JSDocTemplateTag).typeParameters);
return addComment((tag as JSDocTemplateTag).typeParameters.map(tp => tp.getText()).join(", "));
case SyntaxKind.JSDocTypeTag:
return withNode((tag as JSDocTypeTag).typeExpression);
case SyntaxKind.JSDocTypedefTag:
@@ -140,21 +154,17 @@ namespace ts.JsDoc {
case SyntaxKind.JSDocParameterTag:
case SyntaxKind.JSDocSeeTag:
const { name } = tag as JSDocTypedefTag | JSDocPropertyTag | JSDocParameterTag | JSDocSeeTag;
return name ? withNode(name) : comment;
return name ? withNode(name) : comment && getDisplayPartsFromComment(comment, checker);
default:
return comment;
return comment && getDisplayPartsFromComment(comment, checker);
}
function withNode(node: Node) {
return addComment(node.getText());
}
function withList(list: NodeArray<Node>): string {
return addComment(list.map(x => x.getText()).join(", "));
}
function addComment(s: string) {
return comment === undefined ? s : `${s} ${comment}`;
return comment ? [textPart(s), spacePart(), ...getDisplayPartsFromComment(comment, checker)] : [textPart(s)];
}
}
+7 -13
View File
@@ -292,14 +292,11 @@ namespace ts {
// Undefined is used to indicate the value has not been computed. If, after computing, the
// symbol has no doc comment, then the empty array will be returned.
documentationComment?: SymbolDisplayPart[];
tags?: JSDocTagInfo[]; // same
contextualGetAccessorDocumentationComment?: SymbolDisplayPart[];
contextualSetAccessorDocumentationComment?: SymbolDisplayPart[];
// Undefined is used to indicate the value has not been computed. If, after computing, the
// symbol has no JSDoc tags, then the empty array will be returned.
tags?: JSDocTagInfo[];
constructor(flags: SymbolFlags, name: __String) {
this.flags = flags;
this.escapedName = name;
@@ -359,9 +356,9 @@ namespace ts {
}
}
getJsDocTags(): JSDocTagInfo[] {
getJsDocTags(checker?: TypeChecker): JSDocTagInfo[] {
if (this.tags === undefined) {
this.tags = JsDoc.getJsDocTagsFromDeclarations(this.declarations);
this.tags = JsDoc.getJsDocTagsFromDeclarations(this.declarations, checker);
}
return this.tags;
@@ -521,10 +518,7 @@ namespace ts {
// Undefined is used to indicate the value has not been computed. If, after computing, the
// symbol has no doc comment, then the empty array will be returned.
documentationComment?: SymbolDisplayPart[];
// Undefined is used to indicate the value has not been computed. If, after computing, the
// symbol has no doc comment, then the empty array will be returned.
jsDocTags?: JSDocTagInfo[];
jsDocTags?: JSDocTagInfo[]; // same
constructor(checker: TypeChecker, flags: SignatureFlags) {
this.checker = checker;
@@ -566,7 +560,7 @@ namespace ts {
}
function getJsDocTagsOfSignature(declaration: Declaration, checker: TypeChecker): JSDocTagInfo[] {
let tags = JsDoc.getJsDocTagsFromDeclarations([declaration]);
let tags = JsDoc.getJsDocTagsFromDeclarations([declaration], checker);
if (tags.length === 0 || hasJSDocInheritDocTag(declaration)) {
const inheritedTags = findBaseOfDeclaration(checker, declaration, symbol => symbol.declarations?.length === 1 ? symbol.getJsDocTags() : undefined);
if (inheritedTags) {
@@ -579,7 +573,7 @@ namespace ts {
function getDocumentationComment(declarations: readonly Declaration[] | undefined, checker: TypeChecker | undefined): SymbolDisplayPart[] {
if (!declarations) return emptyArray;
let doc = JsDoc.getJsDocCommentsFromDeclarations(declarations);
let doc = JsDoc.getJsDocCommentsFromDeclarations(declarations, checker);
if (checker && (doc.length === 0 || declarations.some(hasJSDocInheritDocTag))) {
forEachUnique(declarations, declaration => {
const inheritedDocs = findBaseOfDeclaration(checker, declaration, symbol => symbol.getDocumentationComment(checker));
@@ -1598,7 +1592,7 @@ namespace ts {
textSpan: createTextSpanFromNode(nodeForQuickInfo, sourceFile),
displayParts: typeChecker.runWithCancellationToken(cancellationToken, typeChecker => typeToDisplayParts(typeChecker, type, getContainerNode(nodeForQuickInfo))),
documentation: type.symbol ? type.symbol.getDocumentationComment(typeChecker) : undefined,
tags: type.symbol ? type.symbol.getJsDocTags() : undefined
tags: type.symbol ? type.symbol.getJsDocTags(typeChecker) : undefined
};
}
+1 -1
View File
@@ -574,7 +574,7 @@ namespace ts.SignatureHelp {
const parameters = typeParameters.map(t => createSignatureHelpParameterForTypeParameter(t, checker, enclosingDeclaration, sourceFile, printer));
const documentation = symbol.getDocumentationComment(checker);
const tags = symbol.getJsDocTags();
const tags = symbol.getJsDocTags(checker);
const prefixDisplayParts = [...typeSymbolDisplay, punctuationPart(SyntaxKind.LessThanToken)];
return { isVariadic: false, prefixDisplayParts, suffixDisplayParts: [punctuationPart(SyntaxKind.GreaterThanToken)], separatorDisplayParts, parameters, documentation, tags };
}
+3 -3
View File
@@ -441,7 +441,7 @@ namespace ts.SymbolDisplay {
}
else {
documentationFromAlias = resolvedSymbol.getContextualDocumentationComment(resolvedNode, typeChecker);
tagsFromAlias = resolvedSymbol.getJsDocTags();
tagsFromAlias = resolvedSymbol.getJsDocTags(typeChecker);
}
}
}
@@ -570,7 +570,7 @@ namespace ts.SymbolDisplay {
}
documentation = rhsSymbol.getDocumentationComment(typeChecker);
tags = rhsSymbol.getJsDocTags();
tags = rhsSymbol.getJsDocTags(typeChecker);
if (documentation.length > 0) {
break;
}
@@ -579,7 +579,7 @@ namespace ts.SymbolDisplay {
}
if (tags.length === 0 && !hasMultipleSignatures) {
tags = symbol.getJsDocTags();
tags = symbol.getJsDocTags(typeChecker);
}
if (documentation.length === 0 && documentationFromAlias) {
+18 -2
View File
@@ -43,7 +43,7 @@ namespace ts {
getDocumentationComment(typeChecker: TypeChecker | undefined): SymbolDisplayPart[];
/* @internal */
getContextualDocumentationComment(context: Node | undefined, checker: TypeChecker | undefined): SymbolDisplayPart[]
getJsDocTags(): JSDocTagInfo[];
getJsDocTags(checker?: TypeChecker): JSDocTagInfo[];
}
export interface Type {
@@ -1032,6 +1032,9 @@ namespace ts {
enumMemberName,
functionName,
regularExpressionLiteral,
link,
linkName,
linkText,
}
export interface SymbolDisplayPart {
@@ -1039,9 +1042,13 @@ namespace ts {
kind: string;
}
export interface JSDocLinkDisplayPart extends SymbolDisplayPart {
target: DocumentSpan;
}
export interface JSDocTagInfo {
name: string;
text?: string;
text?: SymbolDisplayPart[];
}
export interface QuickInfo {
@@ -1391,6 +1398,15 @@ namespace ts {
/** String literal */
string = "string",
/** Jsdoc @link: in `{@link C link text}`, the before and after text "{@link " and "}" */
link = "link",
/** Jsdoc @link: in `{@link C link text}`, the entity name "C" */
linkName = "link name",
/** Jsdoc @link: in `{@link C link text}`, the link text "link text" */
linkText = "link text",
}
export const enum ScriptElementKindModifier {
+39 -1
View File
@@ -105,7 +105,7 @@ namespace ts {
else if (isDeclarationName(node)) {
return getMeaningFromDeclaration(node.parent);
}
else if (isEntityName(node) && isJSDocNameReference(node.parent)) {
else if (isEntityName(node) && (isJSDocNameReference(node.parent) || isJSDocLink(node.parent))) {
return SemanticMeaning.All;
}
else if (isTypeReference(node)) {
@@ -2183,6 +2183,44 @@ namespace ts {
return displayPart(text, SymbolDisplayPartKind.text);
}
export function linkTextPart(text: string) {
return displayPart(text, SymbolDisplayPartKind.linkText);
}
export function linkNamePart(name: EntityName, target: Declaration): JSDocLinkDisplayPart {
return {
text: getTextOfNode(name),
kind: SymbolDisplayPartKind[SymbolDisplayPartKind.linkName],
target: {
fileName: getSourceFileOfNode(target).fileName,
textSpan: createTextSpanFromNode(target),
},
};
}
export function linkPart(text: string) {
return displayPart(text, SymbolDisplayPartKind.link);
}
export function buildLinkParts(link: JSDocLink, checker?: TypeChecker): SymbolDisplayPart[] {
const parts = [linkPart("{@link ")];
if (!link.name) {
if (link.text) {parts.push(linkTextPart(link.text));}
}
else {
const symbol = checker?.getSymbolAtLocation(link.name);
if (symbol?.valueDeclaration) {
parts.push(linkNamePart(link.name, symbol.valueDeclaration));
if (link.text) {parts.push(linkTextPart(link.text));}
}
else {
parts.push(linkTextPart(getTextOfNode(link.name) + link.text));
}
}
parts.push(linkPart("}"));
return parts;
}
const carriageReturnLineFeed = "\r\n";
/**
* The default is CRLF.