Skip to content

Commit 24ee22e

Browse files
committed
Add autofix to template-no-unknown-arguments-for-builtin-components: rename args and migrate events
Mirrors upstream's autofix: - @elementId/@disabled/@class/@tabindex/etc. → HTML attribute rename - @click/@focus/etc. → {{on "event" expr}} modifier migration - Remove-only autofix for deprecated-but-unmigratable args (@TagName, @bubbles, @init) Truly unknown args still report without fix.
1 parent 56f913d commit 24ee22e

File tree

2 files changed

+127
-20
lines changed

2 files changed

+127
-20
lines changed

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: 35 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,7 @@ 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: '<LinkTo @route="info" @model={{this.model}} {{on "dblclick" (action this.click)}} />',
290307
errors: [
291308
{
292309
message: `Passing the "@doubleClick" argument to <LinkTo /> is deprecated.
@@ -296,12 +313,12 @@ Instead, please use the {{on}} modifier, i.e. "<LinkTo {{on "dblclick" ...}} />"
296313
},
297314
{
298315
code: '<Input @value="1" @bubbles={{false}} />',
299-
output: null,
316+
output: '<Input @value="1" />',
300317
errors: [{ message: 'Passing the "@bubbles" argument to <Input /> is deprecated.' }],
301318
},
302319
{
303320
code: '<Input @value="1" @elementId="42" @disabled="disabled" />',
304-
output: null,
321+
output: '<Input @value="1" id="42" disabled="disabled" />',
305322
errors: [
306323
{
307324
message: `Passing the "@elementId" argument to <Input /> is deprecated.
@@ -315,7 +332,7 @@ Instead, please pass the attribute directly, i.e. "<Input disabled={{...}} />" i
315332
},
316333
{
317334
code: '<Input @value="1" @key-up={{ths.onKeyUp}} />',
318-
output: null,
335+
output: '<Input @value="1" {{on "keyup" ths.onKeyUp}} />',
319336
errors: [
320337
{
321338
message: `Passing the "@key-up" argument to <Input /> is deprecated.
@@ -325,12 +342,12 @@ Instead, please use the {{on}} modifier, i.e. "<Input {{on "keyup" ...}} />" ins
325342
},
326343
{
327344
code: '<Textarea @value="1" @bubbles={{false}} />',
328-
output: null,
345+
output: '<Textarea @value="1" />',
329346
errors: [{ message: 'Passing the "@bubbles" argument to <Textarea /> is deprecated.' }],
330347
},
331348
{
332349
code: '<Textarea @value="1" @elementId="42" />',
333-
output: null,
350+
output: '<Textarea @value="1" id="42" />',
334351
errors: [
335352
{
336353
message: `Passing the "@elementId" argument to <Textarea /> is deprecated.
@@ -340,7 +357,7 @@ Instead, please pass the attribute directly, i.e. "<Textarea id={{...}} />" inst
340357
},
341358
{
342359
code: '<Textarea @value="1" @key-up={{ths.onKeyUp}} />',
343-
output: null,
360+
output: '<Textarea @value="1" {{on "keyup" ths.onKeyUp}} />',
344361
errors: [
345362
{
346363
message: `Passing the "@key-up" argument to <Textarea /> is deprecated.

0 commit comments

Comments
 (0)