Skip to content

Commit 2a01def

Browse files
Merge pull request #2685 from johanrd/day_fix/template-no-unknown-arguments-for-builtin-components
Post-merge-review: Add autofix to template-no-unknown-arguments-for-builtin-components: rename args and migrate events
2 parents cda8bed + 33faa9b commit 2a01def

4 files changed

Lines changed: 131 additions & 21 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ rules in templates can be disabled with eslint directives with mustache or html
418418
| [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | | | |
419419
| [template-no-shadowed-elements](docs/rules/template-no-shadowed-elements.md) | disallow ambiguity with block param names shadowing HTML elements | | | |
420420
| [template-no-unbalanced-curlies](docs/rules/template-no-unbalanced-curlies.md) | disallow unbalanced mustache curlies | | | |
421-
| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | | | |
421+
| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | | 🔧 | |
422422

423423
### Routes
424424

docs/rules/template-no-unknown-arguments-for-builtin-components.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# ember/template-no-unknown-arguments-for-builtin-components
22

3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
35
<!-- end auto-generated rule header -->
46

57
The builtin components `LinkTo`, `Input`, `Textarea` has list of allowed arguments, and some argument names may be mistyped, this rule trying to highlight possible typos, checking for unknown arguments, also, some components has conflicted and required arguments, rule addressing this behavior.

lib/rules/template-no-unknown-arguments-for-builtin-components.js

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,17 @@ function checkRequired(nodeMeta, node, seen, context) {
382382
}
383383
}
384384

385+
// Rename `@argName=value` to `newName=value` — strips the `@` and swaps
386+
// the identifier. Used when a deprecated argument has a direct HTML
387+
// attribute replacement (e.g. `@elementId` -> `id`).
388+
function buildRenameFix(attr, newName) {
389+
return (fixer) => {
390+
const nameStart = attr.range[0];
391+
const nameEnd = nameStart + attr.name.length;
392+
return fixer.replaceTextRange([nameStart, nameEnd], newName);
393+
};
394+
}
395+
385396
/** @type {import('eslint').Rule.RuleModule} */
386397
module.exports = {
387398
meta: {
@@ -392,7 +403,7 @@ module.exports = {
392403
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unknown-arguments-for-builtin-components.md',
393404
templateMode: 'both',
394405
},
395-
fixable: null,
406+
fixable: 'code',
396407
schema: [],
397408
messages: {
398409
unknownArgument: '{{message}}',
@@ -410,6 +421,83 @@ module.exports = {
410421
create(context) {
411422
const sourceCode = context.sourceCode;
412423

424+
// Remove the attribute entirely (including any preceding whitespace that
425+
// separates it from the previous token).
426+
function buildRemovalFix(attr) {
427+
return (fixer) => {
428+
const text = sourceCode.getText();
429+
const attrStart = attr.range[0];
430+
const attrEnd = attr.range[1];
431+
432+
let removeStart = attrStart;
433+
while (removeStart > 0 && /\s/.test(text[removeStart - 1])) {
434+
removeStart--;
435+
}
436+
437+
return fixer.removeRange([removeStart, attrEnd]);
438+
};
439+
}
440+
441+
// Migrate `@eventName={{expr}}` to `{{on "htmlEvent" expr}}` modifier
442+
// (or `{{on "htmlEvent" (helper ...params)}}` when the value is a call).
443+
// Only safe when the attribute value is a mustache expression.
444+
function buildEventMigrationFix(attr, htmlEventName) {
445+
return (fixer) => {
446+
const valueText = sourceCode.getText(attr.value);
447+
// Strip outer `{{` and `}}` to get the expression text.
448+
let inner = valueText;
449+
if (inner.startsWith('{{') && inner.endsWith('}}')) {
450+
inner = inner.slice(2, -2).trim();
451+
}
452+
// If the value has parameters (e.g. `action this.click`), wrap as
453+
// a sub-expression so the modifier receives a single callable.
454+
const hasParams =
455+
attr.value &&
456+
attr.value.type === 'GlimmerMustacheStatement' &&
457+
Array.isArray(attr.value.params) &&
458+
attr.value.params.length > 0;
459+
const expr = hasParams ? `(${inner})` : inner;
460+
const modifier = `{{on "${htmlEventName}" ${expr}}}`;
461+
return fixer.replaceTextRange([attr.range[0], attr.range[1]], modifier);
462+
};
463+
}
464+
465+
function buildFix(node, attr) {
466+
const tagMeta = KnownArguments[node.tag];
467+
if (!tagMeta) {
468+
return null;
469+
}
470+
const deprecatedArgs = tagMeta.deprecatedArguments || {};
471+
const deprecatedEvents = tagMeta.deprecatedEvents || {};
472+
473+
if (attr.name in deprecatedArgs) {
474+
const replacement = deprecatedArgs[attr.name];
475+
if (replacement) {
476+
// Rename to the equivalent HTML attribute.
477+
return buildRenameFix(attr, replacement);
478+
}
479+
// No replacement attribute — just remove the deprecated arg.
480+
return buildRemovalFix(attr);
481+
}
482+
483+
if (attr.name in deprecatedEvents) {
484+
const replacement = deprecatedEvents[attr.name];
485+
if (!replacement) {
486+
// No replacement event (e.g. `@bubbles`) — just remove.
487+
return buildRemovalFix(attr);
488+
}
489+
// Only migrate to `{{on}}` when the value is a mustache expression.
490+
// Otherwise (string literal, valueless), leave unfixed.
491+
if (attr.value && attr.value.type === 'GlimmerMustacheStatement') {
492+
return buildEventMigrationFix(attr, replacement);
493+
}
494+
return null;
495+
}
496+
497+
// Truly unknown argument (typo) — no autofix.
498+
return null;
499+
}
500+
413501
return {
414502
GlimmerElementNode(node) {
415503
if (!node.tag || !node.attributes) {
@@ -451,12 +539,14 @@ module.exports = {
451539
}
452540
}
453541

454-
// Report unknown/deprecated arguments
542+
// Report unknown/deprecated arguments.
455543
for (const attr of warns) {
544+
const fix = buildFix(node, attr);
456545
context.report({
457546
node: attr,
458547
messageId: 'unknownArgument',
459548
data: { message: getErrorMessage(node.tag, attr.name) },
549+
fix: fix || null,
460550
});
461551
}
462552

tests/lib/rules/template-no-unknown-arguments-for-builtin-components.js

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -135,55 +135,72 @@ ruleTester.run('template-no-unknown-arguments-for-builtin-components', rule, {
135135
errors: [{ messageId: 'conflictArgument' }, { messageId: 'conflictArgument' }],
136136
},
137137
{
138+
// Deprecated argument without a replacement attribute — autofixed by removal.
138139
code: '<template><LinkTo @route="info" @model={{this.model}} @tagName="button" /></template>',
139-
output: null,
140+
output: '<template><LinkTo @route="info" @model={{this.model}} /></template>',
140141
errors: [{ messageId: 'unknownArgument' }],
141142
},
142143
{
144+
// Deprecated argument with replacement — autofixed by renaming to the HTML attribute.
143145
code: '<template><LinkTo @route="info" @model={{this.model}} @elementId="superstar" /></template>',
144-
output: null,
146+
output: '<template><LinkTo @route="info" @model={{this.model}} id="superstar" /></template>',
145147
errors: [{ messageId: 'unknownArgument' }],
146148
},
147149
{
150+
// Deprecated event with a helper invocation value — migrated to an `{{on}}` modifier with the helper as a sub-expression.
148151
code: '<template><LinkTo @route="info" @model={{this.model}} @doubleClick={{action this.click}} /></template>',
149-
output: null,
152+
output:
153+
'<template><LinkTo @route="info" @model={{this.model}} {{on "dblclick" (action this.click)}} /></template>',
150154
errors: [{ messageId: 'unknownArgument' }],
151155
},
152156
{
157+
// Deprecated argument without a replacement attribute — autofixed by removal.
153158
code: '<template><Input @value="1" @bubbles={{false}} /></template>',
154-
output: null,
159+
output: '<template><Input @value="1" /></template>',
155160
errors: [{ messageId: 'unknownArgument' }],
156161
},
157162
{
163+
// Two deprecated arguments on Input — both renamed to HTML attributes.
158164
code: '<template><Input @value="1" @elementId="42" @disabled="disabled" /></template>',
159-
output: null,
165+
output: '<template><Input @value="1" id="42" disabled="disabled" /></template>',
160166
errors: [{ messageId: 'unknownArgument' }, { messageId: 'unknownArgument' }],
161167
},
162168
{
169+
// Deprecated event with a simple path value — migrated to an `{{on}}` modifier.
163170
code: '<template><Input @value="1" @key-up={{ths.onKeyUp}} /></template>',
164-
output: null,
171+
output: '<template><Input @value="1" {{on "keyup" ths.onKeyUp}} /></template>',
165172
errors: [{ messageId: 'unknownArgument' }],
166173
},
167174
{
175+
// Deprecated argument without a replacement attribute — autofixed by removal.
168176
code: '<template><Textarea @value="1" @bubbles={{false}} /></template>',
169-
output: null,
177+
output: '<template><Textarea @value="1" /></template>',
170178
errors: [{ messageId: 'unknownArgument' }],
171179
},
172180
{
181+
// Deprecated argument with replacement — autofixed by renaming to the HTML attribute.
173182
code: '<template><Textarea @value="1" @elementId="42" /></template>',
174-
output: null,
183+
output: '<template><Textarea @value="1" id="42" /></template>',
175184
errors: [{ messageId: 'unknownArgument' }],
176185
},
177186
{
187+
// Deprecated event with a simple path value — migrated to an `{{on}}` modifier.
178188
code: '<template><Textarea @value="1" @key-up={{ths.onKeyUp}} /></template>',
179-
output: null,
189+
output: '<template><Textarea @value="1" {{on "keyup" ths.onKeyUp}} /></template>',
180190
errors: [{ messageId: 'unknownArgument' }],
181191
},
182192
{
193+
// Truly unknown/typo argument — not autofixed.
183194
code: '<template> <LinkTo class="auk-search-results-list__item" @route={{@route}} @models={{this.models}} @random="test" @query={{@query}} ...attributes >Hello</LinkTo></template>',
184195
output: null,
185196
errors: [{ messageId: 'unknownArgument' }],
186197
},
198+
{
199+
// Deprecated event with a string-literal value — cannot migrate to `{{on}}`, so no autofix.
200+
code: '<template><Input @value="1" @click="noop" /></template>',
201+
output: null,
202+
errors: [{ messageId: 'unknownArgument' }],
203+
},
187204

188205
// Missing required arguments
189206
{
@@ -271,12 +288,12 @@ hbsRuleTester.run('template-no-unknown-arguments-for-builtin-components', rule,
271288
},
272289
{
273290
code: '<LinkTo @route="info" @model={{this.model}} @tagName="button" />',
274-
output: null,
291+
output: '<LinkTo @route="info" @model={{this.model}} />',
275292
errors: [{ message: 'Passing the "@tagName" argument to <LinkTo /> is deprecated.' }],
276293
},
277294
{
278295
code: '<LinkTo @route="info" @model={{this.model}} @elementId="superstar" />',
279-
output: null,
296+
output: '<LinkTo @route="info" @model={{this.model}} id="superstar" />',
280297
errors: [
281298
{
282299
message: `Passing the "@elementId" argument to <LinkTo /> is deprecated.
@@ -286,7 +303,8 @@ Instead, please pass the attribute directly, i.e. "<LinkTo id={{...}} />" instea
286303
},
287304
{
288305
code: '<LinkTo @route="info" @model={{this.model}} @doubleClick={{action this.click}} />',
289-
output: null,
306+
output:
307+
'<LinkTo @route="info" @model={{this.model}} {{on "dblclick" (action this.click)}} />',
290308
errors: [
291309
{
292310
message: `Passing the "@doubleClick" argument to <LinkTo /> is deprecated.
@@ -296,12 +314,12 @@ Instead, please use the {{on}} modifier, i.e. "<LinkTo {{on "dblclick" ...}} />"
296314
},
297315
{
298316
code: '<Input @value="1" @bubbles={{false}} />',
299-
output: null,
317+
output: '<Input @value="1" />',
300318
errors: [{ message: 'Passing the "@bubbles" argument to <Input /> is deprecated.' }],
301319
},
302320
{
303321
code: '<Input @value="1" @elementId="42" @disabled="disabled" />',
304-
output: null,
322+
output: '<Input @value="1" id="42" disabled="disabled" />',
305323
errors: [
306324
{
307325
message: `Passing the "@elementId" argument to <Input /> is deprecated.
@@ -315,7 +333,7 @@ Instead, please pass the attribute directly, i.e. "<Input disabled={{...}} />" i
315333
},
316334
{
317335
code: '<Input @value="1" @key-up={{ths.onKeyUp}} />',
318-
output: null,
336+
output: '<Input @value="1" {{on "keyup" ths.onKeyUp}} />',
319337
errors: [
320338
{
321339
message: `Passing the "@key-up" argument to <Input /> is deprecated.
@@ -325,12 +343,12 @@ Instead, please use the {{on}} modifier, i.e. "<Input {{on "keyup" ...}} />" ins
325343
},
326344
{
327345
code: '<Textarea @value="1" @bubbles={{false}} />',
328-
output: null,
346+
output: '<Textarea @value="1" />',
329347
errors: [{ message: 'Passing the "@bubbles" argument to <Textarea /> is deprecated.' }],
330348
},
331349
{
332350
code: '<Textarea @value="1" @elementId="42" />',
333-
output: null,
351+
output: '<Textarea @value="1" id="42" />',
334352
errors: [
335353
{
336354
message: `Passing the "@elementId" argument to <Textarea /> is deprecated.
@@ -340,7 +358,7 @@ Instead, please pass the attribute directly, i.e. "<Textarea id={{...}} />" inst
340358
},
341359
{
342360
code: '<Textarea @value="1" @key-up={{ths.onKeyUp}} />',
343-
output: null,
361+
output: '<Textarea @value="1" {{on "keyup" ths.onKeyUp}} />',
344362
errors: [
345363
{
346364
message: `Passing the "@key-up" argument to <Textarea /> is deprecated.

0 commit comments

Comments
 (0)