Skip to content

Commit db6cd55

Browse files
hmajorosHenry Majoroslin-ll
authored
Fix issue with token mapping for lint errors on template tokens in gjs/gts files by displaying eslint error on the opening <template> tag (#1801)
* fix issue with token detection eagerly matching the wrong instance of token * display scope token lint errors on opening template tag * fix linting * remove TODO from now passing test * update regex to allow more characters in scope token search * fix overly permissive regex --------- Co-authored-by: Henry Majoros <[email protected]> Co-authored-by: Lucy Lin <[email protected]>
1 parent 127b09b commit db6cd55

File tree

2 files changed

+157
-51
lines changed

2 files changed

+157
-51
lines changed

lib/preprocessors/glimmer.js

Lines changed: 51 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@ const util = require('ember-template-imports/src/util');
99
const TRANSFORM_CACHE = new Map();
1010
const TEXT_CACHE = new Map();
1111

12-
// source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
13-
function escapeRegExp(string) {
14-
return string.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&'); // $& means the whole matched string
15-
}
16-
1712
function arrayEq(arr1, arr2) {
1813
return arr1.length === arr2.length && arr1.every((val, idx) => val === arr2[idx]);
1914
}
@@ -22,11 +17,16 @@ function arrayEq(arr1, arr2) {
2217
const oneLineTemplateRegex = /\[__GLIMMER_TEMPLATE\(`(.*)`, {.*}\)]/;
2318

2419
// regex to capture opening template tag
25-
const openingTemplateTagRegex = /\[__GLIMMER_TEMPLATE\(`$/;
20+
const openingTemplateTagRegex = /\[__GLIMMER_TEMPLATE\(`/;
2621

2722
// regex to capture closing template tag
2823
const closingTemplateTagRegex = /`, {.*}\)]/;
2924

25+
// regex with capture group on scope token variables. In the following example:
26+
// `, { strictMode: true, scope: () => ({on,noop}) })]
27+
// the capture group would contain `on,noop`
28+
const getScopeTokenRegex = /scope: \(\) => \({(.*)}\) }\)/;
29+
3030
/**
3131
* This function is responsible for running the embedded templates transform
3232
* from ember-template-imports.
@@ -145,50 +145,57 @@ function mapRange(messages, filename) {
145145
return message;
146146
}
147147

148-
// when the lines do not match, we've hit a lint error on the line containing the
149-
// <template> tag, meaning we need to re-work the message
150-
const modified = { ...message };
151-
let token = transformedLine.slice(message.column - 1, message.endColumn - 1);
148+
// when the lines do not match, we've hit a lint error on a line containing the
149+
// <template> tag, the closing </template> tag, or possibly both.
152150

153-
// lint error is specifically on JUST the opening <template> tag
154-
if (openingTemplateTagRegex.test(token)) {
155-
token = `<${util.TEMPLATE_TAG_NAME}>`;
151+
const modified = { ...message };
152+
const token = transformedLine.slice(message.column - 1, message.endColumn - 1);
153+
const lineHasOpeningTag = openingTemplateTagRegex.test(transformedLine);
154+
const lineHasClosingTag = closingTemplateTagRegex.test(transformedLine);
156155

157-
// this case is simple: we know the starting column will be correct, so we just
158-
// need to adjust the endColumn for the difference in length between the transformed
159-
// token and the original token.
160-
modified.endColumn = modified.column + token.length;
161-
} else if (oneLineTemplateRegex.test(token)) {
156+
if (oneLineTemplateRegex.test(token)) {
162157
// lint error is on a full, one-line <template>foo</template>
163158
const templateContext = token.match(oneLineTemplateRegex)[1];
164-
token = `<${util.TEMPLATE_TAG_NAME}>${templateContext}<${util.TEMPLATE_TAG_NAME}>`;
159+
const newToken = `<${util.TEMPLATE_TAG_NAME}>${templateContext}<${util.TEMPLATE_TAG_NAME}>`;
165160

166161
// this case is simple: we know we have a one-line template invocation, and the
167162
// start `column` will be the same regardless of syntax. simply calculate the
168163
// length of the full token `<template>...</template>` and set the endColumn.
169-
modified.endColumn = modified.column + token.length;
170-
} else {
171-
// if we haven't fallen into one of the cases above, we are likely dealing with
172-
// a scope token in the template placeholder, typically for being used but not
173-
// defined. the `token` should be the specific template API which the error is on,
174-
// so we need to find the usage of the token in the template
175-
176-
// TODO: this is still bug-prone, and should be refactored to do something like:
177-
// 1. for given token, find associated template tag
178-
// 2. conduct search for token only within associated template
179-
// 3. ensure we are not matching token inside comments
180-
for (const [index, line] of originalLines.entries()) {
181-
const newColumn = line.search(new RegExp(`\\b${escapeRegExp(token)}\\b`));
182-
if (newColumn > -1) {
183-
modified.line = index + 1;
184-
modified.column = newColumn + 1;
185-
modified.endColumn = newColumn + token.length + 1;
186-
break;
164+
modified.endColumn = modified.column + newToken.length + 1;
165+
return modified;
166+
}
167+
168+
if (lineHasClosingTag) {
169+
const scopeTokens = transformedLine.match(getScopeTokenRegex)?.[1]?.split(',') ?? [];
170+
if (scopeTokens.includes(token)) {
171+
// when we have an error specifically with a scope token, we output the error
172+
// on the starting <template> tag. Currently, we do not know the position of
173+
// the token in the template, so there were efforts to do regex searches to
174+
// find the token. Long story short, these all were very bug-prone, so now we
175+
// just modify the message slightly and return it on the opening template tag.
176+
177+
let idx = message.line - 1;
178+
let curLine = lines[idx];
179+
180+
while (idx) {
181+
const templateTag = curLine.search(openingTemplateTagRegex);
182+
if (templateTag > -1) {
183+
modified.line = idx + 1;
184+
modified.endLine = idx + 1;
185+
modified.column = templateTag + 1;
186+
modified.endColumn = templateTag + `<${util.TEMPLATE_TAG_NAME}>`.length + 1;
187+
modified.message = `Error in template: ${message.message}`;
188+
return modified;
189+
}
190+
curLine = lines[--idx];
187191
}
188192
}
193+
} else if (lineHasOpeningTag) {
194+
// token is before the <template> tag, no modifications necessary
195+
if (transformedLine.indexOf(token) < transformedLine.search(openingTemplateTagRegex)) {
196+
return message;
197+
}
189198
}
190-
191-
return modified;
192199
} else {
193200
// 2. handle multi-line diagnostics from eslint
194201
const originalRange = originalLines.slice(message.line - 1, message.endLine);
@@ -215,12 +222,16 @@ function mapRange(messages, filename) {
215222
const closingTagIndex = originalEndLine.indexOf(closingTemplateTag);
216223

217224
if (closingTagIndex) {
218-
modified.endColumn = closingTagIndex + closingTemplateTag.length;
225+
modified.endColumn = closingTagIndex + closingTemplateTag.length + 1;
219226
}
220227
}
221228

222229
return modified;
223230
}
231+
232+
// if all else fails: return the original message. It may not have the correct line/col,
233+
// but it is better to return a mis-aligned diagnostic message than none at all.
234+
return message;
224235
});
225236
}
226237

tests/lib/rules-preprocessor/gjs-gts-processor-test.js

Lines changed: 106 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,10 @@ const invalid = [
110110
`,
111111
errors: [
112112
{
113-
message: "'on' is not defined.",
114-
line: 5,
115-
column: 11,
113+
message: "Error in template: 'on' is not defined.",
114+
line: 4,
115+
column: 7,
116+
endColumn: 17,
116117
},
117118
],
118119
},
@@ -125,9 +126,10 @@ const invalid = [
125126
`,
126127
errors: [
127128
{
128-
message: "'noop' is not defined.",
129-
line: 3,
130-
column: 11,
129+
message: "Error in template: 'noop' is not defined.",
130+
line: 2,
131+
column: 7,
132+
endColumn: 17,
131133
},
132134
],
133135
},
@@ -140,9 +142,26 @@ const invalid = [
140142
`,
141143
errors: [
142144
{
143-
message: "'Foo' is not defined.",
144-
line: 3,
145-
column: 10,
145+
message: "Error in template: 'Foo' is not defined.",
146+
line: 2,
147+
column: 7,
148+
endColumn: 17,
149+
},
150+
],
151+
},
152+
{
153+
filename: 'my-component.gjs',
154+
code: `
155+
<template>
156+
<F_0_O />
157+
</template>
158+
`,
159+
errors: [
160+
{
161+
message: "Error in template: 'F_0_O' is not defined.",
162+
line: 2,
163+
column: 7,
164+
endColumn: 17,
146165
},
147166
],
148167
},
@@ -162,7 +181,7 @@ const invalid = [
162181
line: 6,
163182
endLine: 6,
164183
column: 9,
165-
endColumn: 33,
184+
endColumn: 34,
166185
},
167186
],
168187
},
@@ -183,7 +202,7 @@ const invalid = [
183202
line: 6,
184203
endLine: 7,
185204
column: 9,
186-
endColumn: 19,
205+
endColumn: 20,
187206
},
188207
],
189208
},
@@ -326,6 +345,82 @@ describe('lint errors on the exact line as the <template> tag', () => {
326345
});
327346

328347
describe('multiple tokens in same file', () => {
348+
it('correctly maps duplicate tokens to the correct lines', async () => {
349+
const eslint = initESLint();
350+
const code = `
351+
// comment one
352+
// comment two
353+
// comment three
354+
const two = 2;
355+
356+
const three = <template> "bar" </template>
357+
`;
358+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
359+
360+
const resultErrors = results.flatMap((result) => result.messages);
361+
expect(resultErrors).toHaveLength(2);
362+
363+
expect(resultErrors[0]).toStrictEqual({
364+
column: 13,
365+
endColumn: 16,
366+
endLine: 5,
367+
line: 5,
368+
message: "'two' is assigned a value but never used.",
369+
messageId: 'unusedVar',
370+
nodeType: 'Identifier',
371+
ruleId: 'no-unused-vars',
372+
severity: 2,
373+
});
374+
375+
expect(resultErrors[1]).toStrictEqual({
376+
column: 13,
377+
endColumn: 18,
378+
endLine: 7,
379+
line: 7,
380+
message: "'three' is assigned a value but never used.",
381+
messageId: 'unusedVar',
382+
nodeType: 'Identifier',
383+
ruleId: 'no-unused-vars',
384+
severity: 2,
385+
});
386+
});
387+
388+
it('handles duplicate template tokens', async () => {
389+
const eslint = initESLint();
390+
const code = `
391+
// comment Bad
392+
393+
const tmpl = <template><Bad /></template>
394+
`;
395+
const results = await eslint.lintText(code, { filePath: 'my-component.gjs' });
396+
397+
const resultErrors = results.flatMap((result) => result.messages);
398+
expect(resultErrors).toHaveLength(2);
399+
400+
expect(resultErrors[0]).toStrictEqual({
401+
column: 13,
402+
endColumn: 17,
403+
endLine: 4,
404+
line: 4,
405+
message: "'tmpl' is assigned a value but never used.",
406+
messageId: 'unusedVar',
407+
nodeType: 'Identifier',
408+
ruleId: 'no-unused-vars',
409+
severity: 2,
410+
});
411+
412+
expect(resultErrors[1]).toStrictEqual({
413+
column: 20,
414+
endColumn: 30,
415+
endLine: 4,
416+
line: 4,
417+
message: "Error in template: 'Bad' is not defined.",
418+
messageId: 'undef',
419+
nodeType: 'Identifier',
420+
ruleId: 'no-undef',
421+
severity: 2,
422+
});
423+
});
329424
it('correctly maps duplicate <template> tokens to the correct lines', async () => {
330425
const eslint = initESLint();
331426
const code = `

0 commit comments

Comments
 (0)