🌐 AI搜索 & 代理 主页
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
cbe8cc9
test: add test case
nayounsang Jun 28, 2025
a1902bb
feat: make flag whether node is inside tag
nayounsang Jun 28, 2025
4938a14
fix: if template literal is tagged and the text has an invalid escape…
nayounsang Jun 28, 2025
77960bf
fix: type error
nayounsang Jun 28, 2025
c11a244
chore: add snapshot
nayounsang Jun 28, 2025
cd7550f
test: add test case
nayounsang Jun 28, 2025
156b384
feat: make flag whether node is inside tag
nayounsang Jun 28, 2025
6103105
fix: if template literal is tagged and the text has an invalid escape…
nayounsang Jun 28, 2025
cbeb8d7
fix: type error
nayounsang Jun 28, 2025
954e84e
chore: add snapshot
nayounsang Jun 28, 2025
b5bc96d
Merge branch 'main' into tag-cook
JamesHenry Jul 1, 2025
8fdb4db
chore: try with SKIP_AST_SPEC_REBUILD false
JamesHenry Jul 1, 2025
2dc7c7b
Merge branch 'main' into tag-cook
nayounsang Jul 23, 2025
2e5857a
fix: fallback when cooked is null
nayounsang Jul 23, 2025
8279f15
Merge branch 'tag-cook' of https://github.com/nayounsang/typescript-e…
nayounsang Jul 23, 2025
49dcf05
chore: temp commit to see ci result
nayounsang Jul 23, 2025
84ec93c
fix: try to change input
nayounsang Jul 23, 2025
814604f
fix: mis config input
nayounsang Jul 23, 2025
888e312
chore: off ast spec env
nayounsang Jul 23, 2025
4c1891c
fix: lint err
nayounsang Jul 24, 2025
2a31006
chore: revert action.yml and package.json
nayounsang Jul 24, 2025
27e9b9a
chore: workspace sync
nayounsang Jul 31, 2025
30ed17f
test: add test snapshot
nayounsang Jul 31, 2025
3d5888d
test: move test to convert and add validate tests
nayounsang Aug 2, 2025
4d5d413
feat: change validate logic
nayounsang Aug 2, 2025
5e6e6b7
fix: (tmp) process cooked is null
nayounsang Aug 2, 2025
1ada433
Merge branch 'main' into tag-cook
nayounsang Aug 2, 2025
a051525
test: fix lint error on convert test
nayounsang Aug 2, 2025
a7311e6
fix: add case for $
nayounsang Aug 2, 2025
8a67d05
refactor: ignore with conditional statement
nayounsang Aug 2, 2025
b85dda1
fix: enhance test coverage and remove code for nextChar is null
nayounsang Aug 3, 2025
5b43ca8
fix: replace bool flag to node.parent
nayounsang Sep 5, 2025
c1ee701
chore: install unraw at typescript-estree
nayounsang Sep 5, 2025
547a239
chore: sync nx
nayounsang Sep 5, 2025
f151aeb
refactor: validate escape with unraw
nayounsang Sep 5, 2025
64628a3
test: remove testcases that are guaranted from unraw
nayounsang Sep 5, 2025
fd02eb4
test: add testcase for text without escape
nayounsang Sep 5, 2025
bcd84fa
Merge branch 'main' into tag-cook
nayounsang Sep 6, 2025
58ab6ae
chore: revert result of nx sync
nayounsang Sep 7, 2025
2f95549
Merge branch 'main' into tag-cook
nayounsang Sep 14, 2025
6fb4c78
chore: remove unraw
nayounsang Sep 14, 2025
10cdcd2
chore: set genrated type as nx input to type build
nayounsang Sep 14, 2025
e31cdbc
feat: process rules when cooked is null
nayounsang Sep 14, 2025
f43e69b
fix: case for head,middle and tail
nayounsang Sep 15, 2025
800deba
chore: install @babel/helper-string-parser
nayounsang Nov 19, 2025
42368ec
chore: remove @babel/helper-string-parse and install unraw
nayounsang Nov 19, 2025
d86ddbc
feat: use unraw when validate
nayounsang Nov 19, 2025
66b757d
Apply suggestion from @bradzacher
bradzacher Nov 23, 2025
1d73e5b
chore: remove unraw
nayounsang Dec 2, 2025
2d05e47
feat: simple valid logic for invalid esacpe
nayounsang Dec 2, 2025
50871ad
Merge branch 'tag-cook' of https://github.com/nayounsang/typescript-e…
nayounsang Dec 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/ast-spec/src/special/TemplateElement/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export interface TemplateElement extends BaseNode {
type: AST_NODE_TYPES.TemplateElement;
tail: boolean;
value: {
cooked: string;
cooked: string | null;
raw: string;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@ export default createRule<Options, MessageIds>({

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({
Expand Down Expand Up @@ -448,9 +452,13 @@ export default createRule<Options, MessageIds>({
}

function checkForUnnecesaryNoFormat(
text: string,
text: string | null,
expr: TSESTree.TaggedTemplateExpression,
): void {
if (text == null) {
Copy link
Contributor Author

@nayounsang nayounsang Aug 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change affects three rules.
Would it be better to simply ignore it? How should I handle it?
Once this is decided, I will also add tests.

return;
}

const formatted = getCodeFormatted(text);
if (formatted === text) {
context.report({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`}\`,
},
],
});
`,
],
});
4 changes: 3 additions & 1 deletion packages/eslint-plugin/src/rules/no-duplicate-enum-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Use nullThrows() here instead

}

if (value == null) {
Expand Down
6 changes: 5 additions & 1 deletion packages/eslint-plugin/src/rules/no-unsafe-assignment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an impossible condition (and the test provided doesn't reach it).

This branch refers to keys whose node type is a TemplateLiteral... Which necessarily means they are untagged. If it were tagged it would have the TaggedTemplateExpression.

Suggestion:
Use nullThrows() instead

continue;
}
key = cooked;
} else {
// can't figure out the name, so skip it
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
3 changes: 3 additions & 0 deletions packages/types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
"build": {
"dependsOn": [
"copy-ast-spec"
],
"inputs": [
"{projectRoot}/src/generated/**/*"
]
},
"copy-ast-spec": {
Expand Down
74 changes: 51 additions & 23 deletions packages/typescript-estree/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,16 @@ export class Converter {
}
}

#isValidEscape(text: string): boolean {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😬 this and #isInTaggedTemplate are a lot of logic to add. If it does stay in, I think this should at least be extracted to its own separate function to keep Converter from getting even more complex. Finding a package on npm that already does this would be even better.

But, can we get away with checking if template.text === template.rawText?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think template.text === template.rawText is difficult to solve this issue.
As node.text(node: ts.NoSubstitutionTemplateLiteral | ts.TemplateHead | ts.TemplateMiddle | ts.TemplateTail) is handle its escape char and , this condition is difficult when valid and invalid escape chrs are mixed.

So, find package is better. I found unraw package that parse escape chr and throws err when it is invalid. (I wanted to compare different packages, but I couldn't find any other suitable ones.)

I committed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can eval node.rawText it directly?

try {
  new Function(`return \`${node.rawText}\`;`)();
  return true;
} catch {
  return false;
}

I see they are stored in .templateSpans

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that's better. Thank you. For me, it's a much lighter and simpler way than adding dependencies, so I committed it. However, I know that eval has performance and security issues. Will this be okay? @JoshuaKGoldberg


p.s I tried to use ts.createSourceFile, but it fails in test cases.

ts.createSourceFile(
   'temp.ts',
   `const str = \`${text}\`;`,
    ts.ScriptTarget.Latest,
);
convert > tagged template literal cooked > should set cooked to null for invalid escape sequences in tagged template literals
AssertionError: expected '\n\uXXXXᄑ\t' to be null

- Expected: 
null

+ Received: 
"
\\uXXXXᄑ       "

convert > tagged template literal cooked > should set cooked to null for mixed valid and invalid escape sequences
AssertionError: expected '\uXXXX\xQW' to be null

- Expected: 
null

+ Received: 
"\\uXXXX\\xQW"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I should defer to @bradzacher on this. I'm quite weary of taking on eval or new Function. Even if we assume there are no security concerns, both are quite slow. I think we'd need to see benchmarks showing they don't significantly change parsing times for very large files that include many large template literals and edge cases.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, correctness is top priority for a "parser", if performance is important, why don't we always set it to null? User can't relay on it anyway. Or consider use a getter?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can ask ESTree to remove .cooked completely? Not much value in this property.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raised an issue in ESTree estree/estree#333

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, correctness is top priority for a "parser

I'd agree -- but we're not a parser! TypeScript is the parser -- we're just an intermediary on top of it.
IMO a little bit of leeway is okay because we're not a parser that's able to validate tokens inline as it lexes the code string -- having to back-track to validate with relatively complex regexes isn't really worth it unless there's a good motivating case.

Because it's an edge case that clearly few people use/care about I think that a bit of leeway to trade-off perf with "mostly correct" behaviour is fine, esp when the alternatives are:

  • the current and wrong behaviour
  • always using null which is also incorrect and could lead people to believe all escapes are invalid always
  • adding an expensive regex to catch a rare-to-likely-never case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I committed as suggested by @bradzacher.

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;
Expand Down Expand Up @@ -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<TSESTree.TemplateLiteral>(node, {
type: AST_NODE_TYPES.TemplateLiteral,
expressions: [],
Expand All @@ -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<TSESTree.TemplateLiteral>(node, {
Expand All @@ -1938,32 +1955,43 @@ export class Converter {
'Tagged template expressions are not permitted in an optional chain.',
);
}
return this.createNode<TSESTree.TaggedTemplateExpression>(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<TSESTree.TaggedTemplateExpression>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert?

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<TSESTree.TemplateElement>(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,
},
});
}
Expand Down
95 changes: 95 additions & 0 deletions packages/typescript-estree/tests/lib/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});
});
});
Loading