-
Notifications
You must be signed in to change notification settings - Fork 12.8k
inferFromUsage codefix now emits JSDoc in JS files #27610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1bc0cd1
e34bf54
0f4a800
be3577c
bf5529b
4ee4d69
9ae4a99
09ae612
e99220f
ae062b2
69dec3f
b469d9f
56bcf3f
84b08c0
a3aae7b
d22961a
3cc99ea
31db1ce
6b0be8b
edcb30d
d129e32
e3cf787
bc32d3c
5066880
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -466,4 +466,4 @@ namespace ts { | |
}; | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -26,9 +26,6 @@ namespace ts.codefix { | |
errorCodes, | ||
getCodeActions(context) { | ||
const { sourceFile, program, span: { start }, errorCode, cancellationToken, host } = context; | ||
if (isSourceFileJS(sourceFile)) { | ||
return undefined; // TODO: GH#20113 | ||
} | ||
|
||
const token = getTokenAtPosition(sourceFile, start); | ||
let declaration!: Declaration | undefined; | ||
|
@@ -50,7 +47,7 @@ namespace ts.codefix { | |
function getDiagnostic(errorCode: number, token: Node): DiagnosticMessage { | ||
switch (errorCode) { | ||
case Diagnostics.Parameter_0_implicitly_has_an_1_type.code: | ||
return isSetAccessor(getContainingFunction(token)!) ? Diagnostics.Infer_type_of_0_from_usage : Diagnostics.Infer_parameter_types_from_usage; // TODO: GH#18217 | ||
return isSetAccessorDeclaration(getContainingFunction(token)!) ? Diagnostics.Infer_type_of_0_from_usage : Diagnostics.Infer_parameter_types_from_usage; // TODO: GH#18217 | ||
case Diagnostics.Rest_parameter_0_implicitly_has_an_any_type.code: | ||
return Diagnostics.Infer_parameter_types_from_usage; | ||
default: | ||
|
@@ -59,7 +56,7 @@ namespace ts.codefix { | |
} | ||
|
||
function doChange(changes: textChanges.ChangeTracker, sourceFile: SourceFile, token: Node, errorCode: number, program: Program, cancellationToken: CancellationToken, markSeen: NodeSeenTracker, host: LanguageServiceHost): Declaration | undefined { | ||
if (!isParameterPropertyModifier(token.kind) && token.kind !== SyntaxKind.Identifier && token.kind !== SyntaxKind.DotDotDotToken) { | ||
if (!isParameterPropertyModifier(token.kind) && token.kind !== SyntaxKind.Identifier && token.kind !== SyntaxKind.DotDotDotToken && token.kind !== SyntaxKind.ThisKeyword) { | ||
return undefined; | ||
} | ||
|
||
|
@@ -72,6 +69,14 @@ namespace ts.codefix { | |
annotateVariableDeclaration(changes, sourceFile, parent, program, host, cancellationToken); | ||
return parent; | ||
} | ||
if (isPropertyAccessExpression(parent)) { | ||
const type = inferTypeForVariableFromUsage(parent.name, program, cancellationToken); | ||
const typeNode = type && getTypeNodeIfAccessible(type, parent, program, host); | ||
if (typeNode) { | ||
changes.tryInsertJSDocType(sourceFile, parent, typeNode); | ||
} | ||
return parent; | ||
} | ||
return undefined; | ||
|
||
case Diagnostics.Variable_0_implicitly_has_an_1_type.code: { | ||
|
@@ -92,7 +97,7 @@ namespace ts.codefix { | |
switch (errorCode) { | ||
// Parameter declarations | ||
case Diagnostics.Parameter_0_implicitly_has_an_1_type.code: | ||
if (isSetAccessor(containingFunction)) { | ||
if (isSetAccessorDeclaration(containingFunction)) { | ||
annotateSetAccessor(changes, sourceFile, containingFunction, program, host, cancellationToken); | ||
return containingFunction; | ||
} | ||
|
@@ -108,15 +113,15 @@ namespace ts.codefix { | |
// Get Accessor declarations | ||
case Diagnostics.Property_0_implicitly_has_type_any_because_its_get_accessor_lacks_a_return_type_annotation.code: | ||
case Diagnostics._0_which_lacks_return_type_annotation_implicitly_has_an_1_return_type.code: | ||
if (isGetAccessor(containingFunction) && isIdentifier(containingFunction.name)) { | ||
if (isGetAccessorDeclaration(containingFunction) && isIdentifier(containingFunction.name)) { | ||
annotate(changes, sourceFile, containingFunction, inferTypeForVariableFromUsage(containingFunction.name, program, cancellationToken), program, host); | ||
return containingFunction; | ||
} | ||
return undefined; | ||
|
||
// Set Accessor declarations | ||
case Diagnostics.Property_0_implicitly_has_type_any_because_its_set_accessor_lacks_a_parameter_type_annotation.code: | ||
if (isSetAccessor(containingFunction)) { | ||
if (isSetAccessorDeclaration(containingFunction)) { | ||
annotateSetAccessor(changes, sourceFile, containingFunction, program, host, cancellationToken); | ||
return containingFunction; | ||
} | ||
|
@@ -150,35 +155,61 @@ namespace ts.codefix { | |
return; | ||
} | ||
|
||
const types = inferTypeForParametersFromUsage(containingFunction, sourceFile, program, cancellationToken) || | ||
containingFunction.parameters.map(p => isIdentifier(p.name) ? inferTypeForVariableFromUsage(p.name, program, cancellationToken) : undefined); | ||
// We didn't actually find a set of type inference positions matching each parameter position | ||
if (!types || containingFunction.parameters.length !== types.length) { | ||
return; | ||
} | ||
const parameterInferences = inferTypeForParametersFromUsage(containingFunction, sourceFile, program, cancellationToken) || | ||
containingFunction.parameters.map<ParameterInference>(p => ({ | ||
declaration: p, | ||
type: isIdentifier(p.name) ? inferTypeForVariableFromUsage(p.name, program, cancellationToken) : undefined | ||
})); | ||
Debug.assert(containingFunction.parameters.length === parameterInferences.length); | ||
|
||
zipWith(containingFunction.parameters, types, (parameter, type) => { | ||
if (!parameter.type && !parameter.initializer) { | ||
annotate(changes, sourceFile, parameter, type, program, host); | ||
if (isInJSFile(containingFunction)) { | ||
annotateJSDocParameters(changes, sourceFile, parameterInferences, program, host); | ||
} | ||
else { | ||
for (const { declaration, type } of parameterInferences) { | ||
if (declaration && !declaration.type && !declaration.initializer) { | ||
annotate(changes, sourceFile, declaration, type, program, host); | ||
} | ||
} | ||
}); | ||
} | ||
} | ||
|
||
function annotateSetAccessor(changes: textChanges.ChangeTracker, sourceFile: SourceFile, setAccessorDeclaration: SetAccessorDeclaration, program: Program, host: LanguageServiceHost, cancellationToken: CancellationToken): void { | ||
const param = firstOrUndefined(setAccessorDeclaration.parameters); | ||
if (param && isIdentifier(setAccessorDeclaration.name) && isIdentifier(param.name)) { | ||
const type = inferTypeForVariableFromUsage(setAccessorDeclaration.name, program, cancellationToken) || | ||
inferTypeForVariableFromUsage(param.name, program, cancellationToken); | ||
annotate(changes, sourceFile, param, type, program, host); | ||
if (isInJSFile(setAccessorDeclaration)) { | ||
annotateJSDocParameters(changes, sourceFile, [{ declaration: param, type }], program, host); | ||
} | ||
else { | ||
annotate(changes, sourceFile, param, type, program, host); | ||
} | ||
} | ||
} | ||
|
||
function annotate(changes: textChanges.ChangeTracker, sourceFile: SourceFile, declaration: textChanges.TypeAnnotatable, type: Type | undefined, program: Program, host: LanguageServiceHost): void { | ||
const typeNode = type && getTypeNodeIfAccessible(type, declaration, program, host); | ||
if (typeNode) changes.tryInsertTypeAnnotation(sourceFile, declaration, typeNode); | ||
if (typeNode) { | ||
if (isInJSFile(sourceFile) && declaration.kind !== SyntaxKind.PropertySignature) { | ||
changes.tryInsertJSDocType(sourceFile, declaration, typeNode); | ||
} | ||
else { | ||
changes.tryInsertTypeAnnotation(sourceFile, declaration, typeNode); | ||
} | ||
} | ||
} | ||
|
||
function getTypeNodeIfAccessible(type: Type, enclosingScope: Node, program: Program, host: LanguageServiceHost): TypeNode | undefined { | ||
function annotateJSDocParameters(changes: textChanges.ChangeTracker, sourceFile: SourceFile, parameterInferences: ParameterInference[], program: Program, host: LanguageServiceHost): void { | ||
const result = mapDefined(parameterInferences, inference => { | ||
const param = inference.declaration; | ||
const typeNode = inference.type && getTypeNodeIfAccessible(inference.type, param, program, host); | ||
return typeNode && !param.initializer && !getJSDocType(param) ? { ...inference, typeNode } : undefined; | ||
}); | ||
changes.tryInsertJSDocParameters(sourceFile, result); | ||
} | ||
|
||
function getTypeNodeIfAccessible(type: Type, enclosingScope: Node, program: Program, host: LanguageServiceHost): TypeNode | undefined { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This looks unintentional. |
||
const checker = program.getTypeChecker(); | ||
let typeIsAccessible = true; | ||
const notAccessible = () => { typeIsAccessible = false; }; | ||
|
@@ -212,7 +243,7 @@ namespace ts.codefix { | |
return InferFromReference.inferTypeFromReferences(getReferences(token, program, cancellationToken), program.getTypeChecker(), cancellationToken); | ||
} | ||
|
||
function inferTypeForParametersFromUsage(containingFunction: FunctionLikeDeclaration, sourceFile: SourceFile, program: Program, cancellationToken: CancellationToken): (Type | undefined)[] | undefined { | ||
function inferTypeForParametersFromUsage(containingFunction: FunctionLikeDeclaration, sourceFile: SourceFile, program: Program, cancellationToken: CancellationToken): ParameterInference[] | undefined { | ||
switch (containingFunction.kind) { | ||
case SyntaxKind.Constructor: | ||
case SyntaxKind.FunctionExpression: | ||
|
@@ -228,6 +259,13 @@ namespace ts.codefix { | |
} | ||
} | ||
|
||
interface ParameterInference { | ||
declaration: ParameterDeclaration; | ||
type?: Type; | ||
typeNode?: TypeNode; | ||
isOptional?: boolean; | ||
} | ||
|
||
namespace InferFromReference { | ||
interface CallContext { | ||
argumentTypes: Type[]; | ||
|
@@ -255,7 +293,7 @@ namespace ts.codefix { | |
return getTypeFromUsageContext(usageContext, checker); | ||
} | ||
|
||
export function inferTypeForParametersFromReferences(references: ReadonlyArray<Identifier>, declaration: FunctionLikeDeclaration, checker: TypeChecker, cancellationToken: CancellationToken): (Type | undefined)[] | undefined { | ||
export function inferTypeForParametersFromReferences(references: ReadonlyArray<Identifier>, declaration: FunctionLikeDeclaration, checker: TypeChecker, cancellationToken: CancellationToken): ParameterInference[] | undefined { | ||
if (references.length === 0) { | ||
return undefined; | ||
} | ||
|
@@ -274,8 +312,10 @@ namespace ts.codefix { | |
return callContexts && declaration.parameters.map((parameter, parameterIndex) => { | ||
const types: Type[] = []; | ||
const isRest = isRestParameter(parameter); | ||
let isOptional = false; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't understand this flag. I'll drop by. |
||
for (const callContext of callContexts) { | ||
if (callContext.argumentTypes.length <= parameterIndex) { | ||
isOptional = isInJSFile(declaration); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I assume |
||
continue; | ||
} | ||
|
||
|
@@ -289,10 +329,14 @@ namespace ts.codefix { | |
} | ||
} | ||
if (!types.length) { | ||
return undefined; | ||
return { declaration: parameter }; | ||
sandersn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
const type = checker.getWidenedType(checker.getUnionType(types, UnionReduction.Subtype)); | ||
return isRest ? checker.createArrayType(type) : type; | ||
return { | ||
type: isRest ? checker.createArrayType(type) : type, | ||
isOptional: isOptional && !isRest, | ||
declaration: parameter | ||
}; | ||
}); | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -209,6 +209,12 @@ namespace ts.textChanges { | |
|
||
export type TypeAnnotatable = SignatureDeclaration | VariableDeclaration | ParameterDeclaration | PropertyDeclaration | PropertySignature; | ||
|
||
interface JSDocParameter { | ||
declaration: ParameterDeclaration; | ||
typeNode: TypeNode; | ||
isOptional?: boolean; | ||
} | ||
|
||
export class ChangeTracker { | ||
private readonly changes: Change[] = []; | ||
private readonly newFiles: { readonly oldFile: SourceFile | undefined, readonly fileName: string, readonly statements: ReadonlyArray<Statement> }[] = []; | ||
|
@@ -339,6 +345,12 @@ namespace ts.textChanges { | |
this.insertText(sourceFile, token.getStart(sourceFile), text); | ||
} | ||
|
||
public insertCommentThenNewline(sourceFile: SourceFile, character: number, position: number, commentText: string): void { | ||
sandersn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const token = getTouchingToken(sourceFile, position); | ||
const text = "/**" + commentText + "*/" + this.newLineCharacter + repeatString(" ", character); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Will the formatting done after the fix is applied convert this to tabs if that's what the user prefers? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What we do for other fixes is to copy exactly the indentation string that they were using -- if their preferred indentation is tab, space, space, tab, space, space, we should copy that. I'll make a new PR unifying our comment code since #27565 has its own separate jsdoc-adding helpers. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are cases where we need to make to new indentation, though, as in the single-line test case that needs to annotate |
||
this.insertText(sourceFile, token.getStart(sourceFile), text); | ||
} | ||
|
||
public replaceRangeWithText(sourceFile: SourceFile, range: TextRange, text: string) { | ||
this.changes.push({ kind: ChangeKind.Text, sourceFile, range, text }); | ||
} | ||
|
@@ -347,6 +359,23 @@ namespace ts.textChanges { | |
this.replaceRangeWithText(sourceFile, createRange(pos), text); | ||
} | ||
|
||
public tryInsertJSDocParameters(sourceFile: SourceFile, parameters: JSDocParameter[]) { | ||
if (parameters.length === 0) { | ||
return; | ||
} | ||
const parent = parameters[0].declaration.parent; | ||
const indent = getLineAndCharacterOfPosition(sourceFile, parent.getStart()).character; | ||
let commentText = "\n"; | ||
for (const { declaration, typeNode, isOptional } of parameters) { | ||
if (isIdentifier(declaration.name)) { | ||
const printed = changesToText.getNonformattedText(typeNode, sourceFile, this.newLineCharacter).text; | ||
commentText += this.printJSDocParameter(indent, printed, declaration.name, isOptional); | ||
} | ||
} | ||
commentText += repeatString(" ", indent + 1); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This seems like it belongs in |
||
this.insertCommentThenNewline(sourceFile, indent, parent.getStart(), commentText); | ||
} | ||
|
||
/** Prefer this over replacing a node with another that has a type annotation, as it avoids reformatting the other parts of the node. */ | ||
public tryInsertTypeAnnotation(sourceFile: SourceFile, node: TypeAnnotatable, type: TypeNode): void { | ||
let endNode: Node | undefined; | ||
|
@@ -365,6 +394,27 @@ namespace ts.textChanges { | |
this.insertNodeAt(sourceFile, endNode.end, type, { prefix: ": " }); | ||
} | ||
|
||
public tryInsertJSDocType(sourceFile: SourceFile, node: Node, type: TypeNode): void { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I feel like I'm missing something obvious, but why "try"? |
||
const printed = changesToText.getNonformattedText(type, sourceFile, this.newLineCharacter).text; | ||
let commentText; | ||
if (isGetAccessorDeclaration(node)) { | ||
commentText = ` @return {${printed}} `; | ||
} | ||
else { | ||
commentText = ` @type {${printed}} `; | ||
node = node.parent; | ||
} | ||
this.insertCommentThenNewline(sourceFile, getLineAndCharacterOfPosition(sourceFile, node.getStart(sourceFile)).character, node.getStart(sourceFile), commentText); | ||
} | ||
|
||
private printJSDocParameter(indent: number, printed: string, name: Identifier, isOptionalParameter: boolean | undefined) { | ||
let printName = unescapeLeadingUnderscores(name.escapedText); | ||
if (isOptionalParameter) { | ||
printName = `[${printName}]`; | ||
} | ||
return repeatString(" ", indent) + ` * @param {${printed}} ${printName}\n`; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Having these scattered through the code seems fragile. What about passing an array of lines to |
||
} | ||
|
||
public insertTypeParameters(sourceFile: SourceFile, node: SignatureDeclaration, typeParameters: ReadonlyArray<TypeParameterDeclaration>): void { | ||
// If no `(`, is an arrow function `x => x`, so use the pos of the first parameter | ||
const start = (findChildOfKind(node, SyntaxKind.OpenParenToken, sourceFile) || first(node.parameters)).getStart(sourceFile); | ||
|
@@ -806,7 +856,7 @@ namespace ts.textChanges { | |
} | ||
|
||
/** Note: output node may be mutated input node. */ | ||
function getNonformattedText(node: Node, sourceFile: SourceFile | undefined, newLineCharacter: string): { text: string, node: Node } { | ||
export function getNonformattedText(node: Node, sourceFile: SourceFile | undefined, newLineCharacter: string): { text: string, node: Node } { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Why is this exported? |
||
const writer = new Writer(newLineCharacter); | ||
const newLine = newLineCharacter === "\n" ? NewLineKind.LineFeed : NewLineKind.CarriageReturnLineFeed; | ||
createPrinter({ newLine, neverAsciiEscape: true }, writer).writeNode(EmitHint.Unspecified, node, sourceFile, writer); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
/// <reference path='fourslash.ts' /> | ||
|
||
// @allowJs: true | ||
// @checkJs: true | ||
// @noImplicitAny: true | ||
// @Filename: test.js | ||
////function wat(b) { | ||
//// b(); | ||
////} | ||
|
||
verify.codeFixAll({ | ||
fixId: "inferFromUsage", | ||
fixAllDescription: "Infer all types from usage", | ||
newFileContent: | ||
`/** | ||
* @param {() => void} b | ||
*/ | ||
function wat(b) { | ||
b(); | ||
}`}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
/// <reference path='fourslash.ts' /> | ||
|
||
// @allowJs: true | ||
// @checkJs: true | ||
// @noImplicitAny: true | ||
// @Filename: test.js | ||
////[|var foo;|] | ||
////function f() { | ||
//// foo += 2; | ||
////} | ||
|
||
verify.rangeAfterCodeFix("/** @type {number} */\nvar foo;",/*includeWhiteSpace*/ undefined, /*errorCode*/ undefined, 2); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
/// <reference path='fourslash.ts' /> | ||
|
||
// @allowJs: true | ||
// @checkJs: true | ||
// @noImplicitAny: true | ||
// @Filename: important.js | ||
|
||
/////** @typedef {{ [|p |]}} I */ | ||
/////** @type {I} */ | ||
////var i; | ||
////i.p = 0; | ||
|
||
|
||
verify.rangeAfterCodeFix("p: number", undefined, undefined, 1); | ||
sandersn marked this conversation as resolved.
Show resolved
Hide resolved
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we have both of these --
isSetAccessor
andisSetAccessorDeclaration
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No reason, but removing one would change the public API, so I decided not do it in this PR.