Parse jsdoc with normal TS type parser#17176
Conversation
This means that JSDoc types can include the full range of Typescript types now. It also means that Typescript annotations can include JSDoc types. This is disallowed with a new error, however. But Typescript can still give the correct types to JSDoc that shows up in .ts files by mistake. This can easily happen, for example with types like ```ts var x: number? = null; var y: ?string = null; var z: function(string,string): string = (s,t) => s + t; // less likely to show up, but still understood. var ka: ? = 1; ``` In the future, I will add a quick fix to convert these into the correct types. Fixes #16550
|
@Andy-MS asked for fourslash tests for
If any of these fails I will probably file issues instead of fixing in this PR because (1) It's not clear how much of this worked before because the binder didn't handle these parameters, and (2) I'm not sure anybody even uses this syntax. |
| parsesCorrectly("tupleType3", "{[number,string,boolean]}"); | ||
| parsesCorrectly("tupleTypeWithTrailingComma", "{[number,]}"); | ||
| parsesCorrectly("typeOfType", "{typeof M}"); | ||
| parsesCorrectly("tsConstructoType", "{new () => string}"); |
There was a problem hiding this comment.
Typo: constructo rather than constructor.
| paramSymbol = resolvedSymbol; | ||
| } | ||
| if (i === 0 && paramSymbol.name === "this") { | ||
| if (i === 0 && paramSymbol.name === "this" || (param.type && param.type.kind === SyntaxKind.JSDocThisType)) { |
There was a problem hiding this comment.
I'm not a fan of JSDocThisType. It looks like you would have a parameter with no name, and a type of kind JSDocThisType, where the type itself has a .type property storing the real type.
I think this: T should be parsed to the same AST whether we're in TypeScript or in JSDoc, so you don't need a condition for both here.
There was a problem hiding this comment.
I removed JSDocThisType and -ConstructorType like you suggested.
| return false; | ||
| } | ||
|
|
||
| export function isJSDocTypeReference(node: TypeReferenceType): node is TypeReferenceNode { |
There was a problem hiding this comment.
This is going to be super slow if we're not in jsdoc -- for any type reference not in jsdoc we will climb the whole tree looking for a JSDocTypeExpression that never comes. Can you use isInJavaScriptFile?
There was a problem hiding this comment.
That's correct. Any ideas for a better implementation?
There was a problem hiding this comment.
isInJavaScriptFile may not be sufficient because JSDoc types can appear anywhere in TS or JS files, inside or outside JSDoc. For example:
/** @type {...number} */
var x;
var y: ...number; // annotations are errors in JS file, but still fully checked and treated as number[]and
/** @type {...number} */ // actually, this isn't checked.
var x;
var y: ...number; // ... is an error in TS file, but still fully checked and treated as number[]There was a problem hiding this comment.
So if you have let o: ...object; in a JS file, you will see object and climb up the tree to determine whether it's in a JSDoc comment? Since that's a grammar error anyway, I don't think it's a big deal if we give the object there JSDoc semantics instead of TS semantics. Sure it appears in a TypeScript type position, but it's in a JS file, so we might as well treat it the same as any other JSDoc type (and issue a grammar error).
There was a problem hiding this comment.
There's a JSDoc Parser context flag now, right? Can it not just be persisted into all nodes as a node flag when they are created within a jsdoc context? So you just check if your node has the flag, and if so, then its inside a jsdoc and has jsdoc semantics?
There was a problem hiding this comment.
Yep, I realised that last night. In fact, finishNode in the parser persists the context flag, so it was there all along.
| case SyntaxKind.ObjectKeyword: | ||
| return nonPrimitiveType; | ||
| if (node.flags & NodeFlags.JavaScriptFile) { | ||
| return anyType; |
There was a problem hiding this comment.
There seems to be duplicate code in getPrimitiveTypeFromJSDocTypeReference, are these both needed?
There was a problem hiding this comment.
getPrimitiveTypeFromJSDocTypeReference operates on TypeReferenceNode not on a TypeNode with SyntaxKind.Object. Actually, the "object" case there no longer applies since it @param {object} x is no longer parsed as a type reference.
|
|
||
| function checkTypeReferenceNode(node: TypeReferenceNode | ExpressionWithTypeArguments) { | ||
| checkGrammarTypeArguments(node, node.typeArguments); | ||
| if (node.kind === SyntaxKind.TypeReference && node.typeName.jsdocDot && !isInJavaScriptFile(node) && !findAncestor(node, n => n.kind === SyntaxKind.JSDocTypeExpression)) { |
There was a problem hiding this comment.
Another findAncestor call here. We shouldn't be checking a type reference in a jsdoc comment if we're not in a JS file anyway, right?
| function checkTypeReferenceNode(node: TypeReferenceNode | ExpressionWithTypeArguments) { | ||
| checkGrammarTypeArguments(node, node.typeArguments); | ||
| if (node.kind === SyntaxKind.TypeReference && node.typeName.jsdocDot && !isInJavaScriptFile(node) && !findAncestor(node, n => n.kind === SyntaxKind.JSDocTypeExpression)) { | ||
| grammarErrorOnNode(node, Diagnostics.JSDoc_types_can_only_be_used_inside_documentation_comments); |
There was a problem hiding this comment.
So if I write: let x: N.M.O.<P, Q>; I'll get a grammar error on the whole node... it would be hard to spot the extra .. Could we try to calculate the range between O and < for the error, or use a more specific message?
There was a problem hiding this comment.
I'll just save the dots as we parse them and retain it for this error in the jsdoc case.
There was a problem hiding this comment.
Actually, that creates additional garbage from the dots we don't use, which is all dots except the last one in a JSDoc-syntax generic. These dots are relatively very rare, so I will save the start and end instead and manually specify the error span.
| } | ||
| } | ||
|
|
||
| function checkJsDoc(node: FunctionDeclaration | MethodDeclaration) { |
There was a problem hiding this comment.
This could be inlined. forEach(node.jsDoc, checkSourceElement).
There was a problem hiding this comment.
I'd like to keep the function around for readability and because it may someday be used from more locations.
| case SyntaxKind.JSDocNullableType: | ||
| case SyntaxKind.JSDocAllType: | ||
| case SyntaxKind.JSDocUnknownType: | ||
| if (!isInJavaScriptFile(node) && !findAncestor(node, n => n.kind === SyntaxKind.JSDocTypeExpression)) { |
| } | ||
|
|
||
| function checkFunctionOrMethodDeclaration(node: FunctionDeclaration | MethodDeclaration): void { | ||
| checkJsDoc(node); |
There was a problem hiding this comment.
Seems silly to call this in a TS file if the checkers for every possible jsdoc element will test if we're in a TS file and then do nothing?
There was a problem hiding this comment.
Good point. I moved the check into checkJSDoc
| // The allowReservedWords parameter controls whether reserved words are permitted after the first dot | ||
| function parseEntityName(allowReservedWords: boolean, diagnosticMessage?: DiagnosticMessage): EntityName { | ||
| let entity: EntityName = parseIdentifier(diagnosticMessage); | ||
| let entity: EntityName = allowReservedWords ? parseIdentifierName() : parseIdentifier(diagnosticMessage); |
There was a problem hiding this comment.
Why this change? So we can write const.const?
Comment needs an update if so.
There was a problem hiding this comment.
Yes, you can say @param {const} number in JSDoc. I'll update the comment.
There was a problem hiding this comment.
Actually, without the "right hand side" qualification, it's a garbage comment ("allowReservedWords allows reserved words!"). I deleted it.
| return finishNode(parameter); | ||
| } | ||
|
|
||
| function parseJSDocNodeWithType(kind: SyntaxKind): TypeNode { |
There was a problem hiding this comment.
Could provide a more specific type to kind.
|
|
||
| function isStartOfParameter(): boolean { | ||
| return token() === SyntaxKind.DotDotDotToken || isIdentifierOrPattern() || isModifierKind(token()) || token() === SyntaxKind.AtToken || token() === SyntaxKind.ThisKeyword; | ||
| return token() === SyntaxKind.DotDotDotToken || isIdentifierOrPattern() || isModifierKind(token()) || token() === SyntaxKind.AtToken || token() === SyntaxKind.ThisKeyword || token() === SyntaxKind.NewKeyword; |
There was a problem hiding this comment.
If JSDoc parameters can include any type, doesn't this leave out a lot of things?
There was a problem hiding this comment.
isIdentifierOrPattern and isModifierKind cover a lot of things like number, const, etc. But they happen not to cover this or new.
There was a problem hiding this comment.
Can we parse this?
/** @type {function({ x: number }, 1 | 2): number} */
const f = (x, y) => x.x + y;There was a problem hiding this comment.
not 1 | 2. I will try adding isStartOfType to the end of isStartOfParameter but I'm not sure if it will be successful.
There was a problem hiding this comment.
At least one test entered an infinite loop with isStartOfType inside isStartOfParameter. Instead I just added StringLiteral and NumericLiteral to the end.
| } | ||
|
|
||
| function parseFunctionOrConstructorType(kind: SyntaxKind): FunctionOrConstructorTypeNode { | ||
| function parseFunctionOrConstructorType(kind: SyntaxKind): FunctionOrConstructorTypeNode | JSDocConstructorType { |
There was a problem hiding this comment.
Could provide a more specific type to kind. I only see this being called with kind of SyntaxKind.FunctionType of SyntaxKind.ConstructorType, never SyntaxKind.JSDocConstructorType.
| if (kind === SyntaxKind.ConstructorType) { | ||
| parseExpected(SyntaxKind.NewKeyword); | ||
| if (token() === SyntaxKind.ColonToken) { | ||
| // JSDoc -- `new:T` as in `function(new:T, string, string)`; an infix constructor-return-type |
There was a problem hiding this comment.
What happens if we see new:T and it's not inside of function()?
| return token() === SyntaxKind.CloseParenToken || isStartOfParameter() || isStartOfType(); | ||
| } | ||
|
|
||
| function parseJSDocPostfixTypeOrHigher(): TypeNode { |
There was a problem hiding this comment.
Move this next to (or into) parseTypeOperatorOrHigher.
Can we parse foo?[] or foo![]? Could merge with parseArrayTypeOrHigher (also only called in one place).
I took a stab at making this neater:
function parseJSDocPostfixTypeOrHigher(): TypeNode {
const type = parseArrayTypeOrHigher();
const kind = getKind(token());
if (!kind) return type;
const postfix = createNode(kind, type.pos) as JSDocOptionalType | JSDocNonNullableType | JSDocNullableType;
postfix.type = type;
return finishNode(postfix);
function getKind(tokenKind: SyntaxKind): SyntaxKind | undefined {
switch (tokenKind) {
case SyntaxKind.EqualsToken:
// only parse postfix = inside jsdoc, because it's ambiguous elsewhere
return contextFlags & NodeFlags.JSDoc ? SyntaxKind.JSDocOptionalType : undefined;
case SyntaxKind.ExclamationToken:
return SyntaxKind.JSDocNonNullableType;
case SyntaxKind.QuestionToken:
return SyntaxKind.JSDocNullableType;
}
}
}There was a problem hiding this comment.
- No,
foo?[]would need to parse inside parseArrayTypeOrHigher. That does seem like it should parse asArray<foo | null>. - I don't think we should merge functions that would map to individual rules in a grammar.
- Thanks for the refactor.
There was a problem hiding this comment.
Seems like solving 1 would require ditching 2. If we want to be able to parse foo?[]?[]!, we would need to handle postfix types in a uniform way.
There was a problem hiding this comment.
So now I can type syntactically valid jsdoc types in place of swearing.
|
If you're checking out this branch on windows, note that I inadvertently created a new test |
|
|
||
| return finishNode(typedefTag); | ||
|
|
||
| function isObjectTypeReference(node: TypeNode) { |
There was a problem hiding this comment.
node.kind === SyntaxKind.ObjectKeyword || isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === "Object";
| tests/cases/conformance/classes/propertyMemberDeclarations/initializerReferencingConstructorLocals.ts(6,14): error TS2339: Property 'z' does not exist on type 'C'. | ||
| tests/cases/conformance/classes/propertyMemberDeclarations/initializerReferencingConstructorLocals.ts(7,15): error TS1003: Identifier expected. | ||
| tests/cases/conformance/classes/propertyMemberDeclarations/initializerReferencingConstructorLocals.ts(7,20): error TS2339: Property 'z' does not exist on type 'C'. | ||
| tests/cases/conformance/classes/propertyMemberDeclarations/initializerReferencingConstructorLocals.ts(7,15): error TS2304: Cannot find name 'this'. |
There was a problem hiding this comment.
This tests a TS file -- it looks like we are now parsing this as an identifier.
There was a problem hiding this comment.
That's because parseEntityName now allows reserved words anywhere, not just on the right of a dot. That affects even single identifiers, but in this case it now allows this in this.x to be parsed as an identifier.
Other callers that pass allowReservedWords: true are parseTypeQuery and parseIsolatedEntityName. Plus parseTypeReference in the case of JSDoc.
I think the change is fine in parseTypeQuery since this error is at least as easy to read as the old one. parseIsolatedEntityName just seems to be used for parsing --jsxFactory, so that change should be fine too (although I'm not sure why it allows reserved words at all).
| var weird1: new:string = {}; | ||
| ~~~~~~ | ||
| !!! error TS2322: Type '{}' is not assignable to type 'string'. | ||
| ~~~~~~~ |
There was a problem hiding this comment.
This range seems off -- shouldn't it include new?
| // @module: commonjs | ||
| // @filename: node.d.ts | ||
| // @noImplicitAny: true | ||
| declare function require(id: string): any; |
There was a problem hiding this comment.
This doesn't seem relevant to the test?
Also, the test name seems to imply that jsdoc types will appear inside of type annotations.
There was a problem hiding this comment.
Actually, I don't think this test is testing anything unique. I'll just remove it.
|
From in-person discussion with @Andy-MS: I should maybe try to check JSDocFunctionType with checkFunctionLikeDeclaration in order to further reduce duplication and improve checking thoroughness. |
1. Remove checkJSDocFunctionType in favour of checkSignature. 2. Check that 'new', in addition to 'this', must be the first parameter. 3. Remove prematurely added JSDoc-quickfix test.
Also some cleanup from PR comments
|
I think I've addressed all the comments up to this afternoon. |
| >z : Symbol(z, Decl(functions.js, 26, 3)) | ||
| >length : Symbol(length, Decl(functions.js, 12, 27)) | ||
|
|
||
| /** @type {function ("a" | "b"): 1 | 2} */ |
There was a problem hiding this comment.
This doesn't actually test the ability to parse a number literal as a parameter type, that would be function("a" | "b", 1 | 2): whatever.
This means that JSDoc types can include the full range of Typescript types now. It also means that Typescript annotations can include JSDoc types. This is disallowed with a new error, however. But Typescript can still give the correct types to JSDoc that shows up in .ts files by mistake. This can easily happen, for example with types like:
This change saves around 300 lines of code and some of the confusion from having exact duplicate parsing and types for JSDoc vs normal types. Now only JSDoc types and syntax that differ from Typescript's need to be handled separately.
In the future, I will add a quick fix to convert these into the correct types.
Fixes #16550