diff --git a/packages/ast-spec/src/legacy-fixtures/types/fixtures/template-literal-type-1/snapshots/5-AST-Alignment-AST.shot b/packages/ast-spec/src/legacy-fixtures/types/fixtures/template-literal-type-1/snapshots/5-AST-Alignment-AST.shot index cdda4e0f0b5b..a24c8bb9f5d6 100644 --- a/packages/ast-spec/src/legacy-fixtures/types/fixtures/template-literal-type-1/snapshots/5-AST-Alignment-AST.shot +++ b/packages/ast-spec/src/legacy-fixtures/types/fixtures/template-literal-type-1/snapshots/5-AST-Alignment-AST.shot @@ -2,4 +2,76 @@ exports[`AST Fixtures > legacy-fixtures > types > template-literal-type-1 > AST Alignment - AST`] Snapshot Diff: -Compared values have no visual difference. +- TSESTree ++ Babel + + Program { + type: 'Program', + body: Array [ + TSTypeAliasDeclaration { + type: 'TSTypeAliasDeclaration', + declare: false, + id: Identifier { + type: 'Identifier', + decorators: Array [], + name: 'T', + optional: false, + + range: [78, 79], + loc: { + start: { column: 5, line: 3 }, + end: { column: 6, line: 3 }, + }, + }, + typeAnnotation: TSLiteralType { + type: 'TSLiteralType', + literal: TemplateLiteral { + type: 'TemplateLiteral', + expressions: Array [], + quasis: Array [ + TemplateElement { + type: 'TemplateElement', + tail: true, + value: Object { +- 'cooked': null, ++ 'cooked': 'foo', + 'raw': 'foo', + }, + + range: [82, 87], + loc: { + start: { column: 9, line: 3 }, + end: { column: 14, line: 3 }, + }, + }, + ], + + range: [82, 87], + loc: { + start: { column: 9, line: 3 }, + end: { column: 14, line: 3 }, + }, + }, + + range: [82, 87], + loc: { + start: { column: 9, line: 3 }, + end: { column: 14, line: 3 }, + }, + }, + + range: [73, 88], + loc: { + start: { column: 0, line: 3 }, + end: { column: 15, line: 3 }, + }, + }, + ], + sourceType: 'script', + + range: [73, 89], + loc: { + start: { column: 0, line: 3 }, + end: { column: 0, line: 4 }, + }, + } diff --git a/packages/ast-spec/src/special/TemplateElement/spec.ts b/packages/ast-spec/src/special/TemplateElement/spec.ts index cb5d1c6e76f8..dda44172c500 100644 --- a/packages/ast-spec/src/special/TemplateElement/spec.ts +++ b/packages/ast-spec/src/special/TemplateElement/spec.ts @@ -5,7 +5,7 @@ export interface TemplateElement extends BaseNode { type: AST_NODE_TYPES.TemplateElement; tail: boolean; value: { - cooked: string; + cooked: string | null; raw: string; }; } diff --git a/packages/eslint-plugin-internal/src/rules/plugin-test-formatting.ts b/packages/eslint-plugin-internal/src/rules/plugin-test-formatting.ts index 26fcc60d8d2b..78eef0741c86 100644 --- a/packages/eslint-plugin-internal/src/rules/plugin-test-formatting.ts +++ b/packages/eslint-plugin-internal/src/rules/plugin-test-formatting.ts @@ -284,6 +284,10 @@ export default createRule({ const text = literal.quasis[0].value.cooked; + if (text == null) { + return; + } + if (literal.loc.end.line === literal.loc.start.line) { // don't use template strings for single line tests return context.report({ @@ -448,9 +452,13 @@ export default createRule({ } function checkForUnnecesaryNoFormat( - text: string, + text: string | null, expr: TSESTree.TaggedTemplateExpression, ): void { + if (text == null) { + return; + } + const formatted = getCodeFormatted(text); if (formatted === text) { context.report({ diff --git a/packages/eslint-plugin-internal/tests/rules/plugin-test-formatting.test.ts b/packages/eslint-plugin-internal/tests/rules/plugin-test-formatting.test.ts index 29ee7dde5168..25a534d685b2 100644 --- a/packages/eslint-plugin-internal/tests/rules/plugin-test-formatting.test.ts +++ b/packages/eslint-plugin-internal/tests/rules/plugin-test-formatting.test.ts @@ -886,5 +886,14 @@ const test = [ errors: [], })); `, + // cooked is null for invalid escape sequences in tagged template literals. ignore it. + ` +ruleTester.run({ + valid: [ + {code: tag\`${String.raw`\uXXXX`}\`, + }, + ], +}); + `, ], }); diff --git a/packages/eslint-plugin/src/rules/no-duplicate-enum-values.ts b/packages/eslint-plugin/src/rules/no-duplicate-enum-values.ts index c5a858dbdcaa..4e26d2fbdd90 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-enum-values.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-enum-values.ts @@ -62,7 +62,9 @@ export default createRule({ } else if (isNumberLiteral(member.initializer)) { value = member.initializer.value; } else if (isStaticTemplateLiteral(member.initializer)) { - value = member.initializer.quasis[0].value.cooked; + // cooked can only be null inside a TaggedTemplateExpression, which is not possible. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + value = member.initializer.quasis[0].value.cooked!; } if (value == null) { diff --git a/packages/eslint-plugin/src/rules/no-unsafe-assignment.ts b/packages/eslint-plugin/src/rules/no-unsafe-assignment.ts index 6b3e47e3a3b4..fdc527441100 100644 --- a/packages/eslint-plugin/src/rules/no-unsafe-assignment.ts +++ b/packages/eslint-plugin/src/rules/no-unsafe-assignment.ts @@ -202,7 +202,11 @@ export default createRule({ receiverProperty.key.type === AST_NODE_TYPES.TemplateLiteral && receiverProperty.key.quasis.length === 1 ) { - key = receiverProperty.key.quasis[0].value.cooked; + const cooked = receiverProperty.key.quasis[0].value.cooked; + if (cooked == null) { + continue; + } + key = cooked; } else { // can't figure out the name, so skip it continue; diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-assignment.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-assignment.test.ts index e744d560218d..59f017c40501 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-assignment.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-assignment.test.ts @@ -116,6 +116,11 @@ type Foo = { bar: unknown }; const bar: any = 1; const foo: Foo = { bar }; `, + // cooked is null for invalid escape sequences in tagged template literals. ignore it. + // this case is based on "[{ ['x']: x }] = [{ ['x']: 1 }] as [{ ['x']: any }];" + ` +[{[tag\`${String.raw`\uXXXX`}\`]: x }] = [{ [tag\`${String.raw`\uXXXX`}\`]: 1 }] as [{ [tag\`${String.raw`\uXXXX`}\`]: any }]; + `, ], invalid: [ { diff --git a/packages/types/package.json b/packages/types/package.json index 890b838e2549..9385cf174879 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -67,6 +67,9 @@ "build": { "dependsOn": [ "copy-ast-spec" + ], + "inputs": [ + "{projectRoot}/src/generated/**/*" ] }, "copy-ast-spec": { diff --git a/packages/typescript-estree/src/convert.ts b/packages/typescript-estree/src/convert.ts index 0ffc735c3a14..e0d3ce5329d1 100644 --- a/packages/typescript-estree/src/convert.ts +++ b/packages/typescript-estree/src/convert.ts @@ -401,6 +401,16 @@ export class Converter { } } + #isValidEscape(text: string): boolean { + if (/\\[xu]/.test(text)) { + const hasInvalidUnicodeEscape = /\\u(?![0-9a-fA-F]{4}|{)/.test(text); + const hasInvalidHexEscape = /\\x(?![0-9a-fA-F]{2})/.test(text); + + return !hasInvalidUnicodeEscape && !hasInvalidHexEscape; + } + return true; + } + #throwError(node: number | ts.Node | TSESTree.Range, message: string): never { let start; let end; @@ -1894,7 +1904,12 @@ export class Converter { // Template Literals - case SyntaxKind.NoSubstitutionTemplateLiteral: + case SyntaxKind.NoSubstitutionTemplateLiteral: { + const rawText = this.ast.text.slice( + node.getStart(this.ast) + 1, + node.end - 1, + ); + return this.createNode(node, { type: AST_NODE_TYPES.TemplateLiteral, expressions: [], @@ -1903,15 +1918,17 @@ export class Converter { type: AST_NODE_TYPES.TemplateElement, tail: true, value: { - cooked: node.text, - raw: this.ast.text.slice( - node.getStart(this.ast) + 1, - node.end - 1, - ), + cooked: + node.parent.kind === SyntaxKind.TaggedTemplateExpression && + !this.#isValidEscape(rawText) + ? null + : node.text, + raw: rawText, }, }), ], }); + } case SyntaxKind.TemplateExpression: { const result = this.createNode(node, { @@ -1938,32 +1955,43 @@ export class Converter { 'Tagged template expressions are not permitted in an optional chain.', ); } - return this.createNode(node, { - type: AST_NODE_TYPES.TaggedTemplateExpression, - quasi: this.convertChild(node.template), - tag: this.convertChild(node.tag), - typeArguments: - node.typeArguments && - this.convertTypeArgumentsToTypeParameterInstantiation( - node.typeArguments, - node, - ), - }); + const result = this.createNode( + node, + { + type: AST_NODE_TYPES.TaggedTemplateExpression, + quasi: this.convertChild(node.template), + tag: this.convertChild(node.tag), + typeArguments: + node.typeArguments && + this.convertTypeArgumentsToTypeParameterInstantiation( + node.typeArguments, + node, + ), + }, + ); + return result; } - case SyntaxKind.TemplateHead: case SyntaxKind.TemplateMiddle: case SyntaxKind.TemplateTail: { const tail = node.kind === SyntaxKind.TemplateTail; + const rawText = this.ast.text.slice( + node.getStart(this.ast) + 1, + node.end - (tail ? 1 : 2), + ); + const isTagged = + node.kind === SyntaxKind.TemplateHead + ? node.parent.parent.kind === SyntaxKind.TaggedTemplateExpression + : node.parent.parent.parent.kind === + SyntaxKind.TaggedTemplateExpression; + return this.createNode(node, { type: AST_NODE_TYPES.TemplateElement, tail, value: { - cooked: node.text, - raw: this.ast.text.slice( - node.getStart(this.ast) + 1, - node.end - (tail ? 1 : 2), - ), + cooked: + isTagged && !this.#isValidEscape(rawText) ? null : node.text, + raw: rawText, }, }); } diff --git a/packages/typescript-estree/tests/lib/convert.test.ts b/packages/typescript-estree/tests/lib/convert.test.ts index 77dea1f5f4b4..330000068419 100644 --- a/packages/typescript-estree/tests/lib/convert.test.ts +++ b/packages/typescript-estree/tests/lib/convert.test.ts @@ -449,4 +449,99 @@ describe('convert', () => { expect(Object.keys(tsMappedType)).toContain('typeParameter'); }); }); + + describe('tagged template literal cooked', () => { + const getTemplateElement = (code: string): TSESTree.TemplateElement[] => { + const result = convertCode(code); + const converter = new Converter(result); + const program = converter.convertProgram(); + + const taggedTemplate = program.body.find( + b => b.type === AST_NODE_TYPES.ExpressionStatement, + ); + const expression = taggedTemplate?.expression; + if (expression?.type !== AST_NODE_TYPES.TaggedTemplateExpression) { + throw new Error('TaggedTemplateExpression not found'); + } + return expression.quasi.quasis; + }; + + const invalidEscapeSequences = [String.raw`\uXXXX`, String.raw`\xQW`]; + + it('should set cooked to null for invalid escape sequences in tagged template literals', () => { + const code = `tag\`${invalidEscapeSequences[0]}${invalidEscapeSequences[1]}\``; + const templateElement = getTemplateElement(code); + + expect(templateElement[0].value.cooked).toBeNull(); + }); + + it('should set cooked to null for mixed valid and invalid escape sequences', () => { + const code = `tag\`\n${invalidEscapeSequences[0]}\u{1111}\t\${}${invalidEscapeSequences[1]}\``; + const templateElement = getTemplateElement(code); + + expect(templateElement[0].value.cooked).toBeNull(); + expect(templateElement[1].value.cooked).toBeNull(); + }); + + it('should set cooked to null for invalid escape sequences in the tagged template literal head with expressions', () => { + const code = `tag\`${invalidEscapeSequences[0]}\${exp}middle\${exp}tail\``; + const templateElement = getTemplateElement(code); + + expect(templateElement[0].value.cooked).toBeNull(); + }); + + it('should set cooked to null for invalid escape sequences in the tagged template literal middle with expressions', () => { + const code = `tag\`head\${exp}${invalidEscapeSequences[0]}\${exp}tail\``; + const templateElement = getTemplateElement(code); + + expect(templateElement[1].value.cooked).toBeNull(); + }); + + it('should set cooked to null for invalid escape sequences in the tagged template literal tail with expressions', () => { + const code = `tag\`head\${exp}middle\${exp}${invalidEscapeSequences[0]}\``; + const templateElement = getTemplateElement(code); + + expect(templateElement[2].value.cooked).toBeNull(); + }); + + it('should not set cooked to null for text without invalid escape sequences', () => { + const code = `tag\`foo\n\\\u1111\t + bar + baz\``; + const templateElement = getTemplateElement(code); + + expect(templateElement[0].value.cooked).toBe(`foo\n\u1111\t + bar + baz`); + }); + + it('should not set cooked to null for text without escape sequences', () => { + const code = `tag\`foo\``; + const templateElement = getTemplateElement(code); + + expect(templateElement[0].value.cooked).toBe(`foo`); + }); + + it('should not set cooked to null for untagged template literals', () => { + const code = `const foo = \`${invalidEscapeSequences[0]}\``; + const result = convertCode(code); + const converter = new Converter(result); + const program = converter.convertProgram(); + + const variableDeclaration = program.body.find( + b => b.type === AST_NODE_TYPES.VariableDeclaration, + ); + const variableDeclarator = variableDeclaration?.declarations[0]; + if (variableDeclarator?.type !== AST_NODE_TYPES.VariableDeclarator) { + throw new Error('VariableDeclarator not found'); + } + const init = variableDeclarator.init; + if (init?.type !== AST_NODE_TYPES.TemplateLiteral) { + throw new Error('TemplateLiteral not found'); + } + const templateElement = init.quasis[0]; + + expect(templateElement.value.cooked).toBe(`\\uXXXX`); + }); + }); });