diff --git a/README.md b/README.md index 6294da41dd..5477e02842 100644 --- a/README.md +++ b/README.md @@ -418,7 +418,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | | | | | [template-no-shadowed-elements](docs/rules/template-no-shadowed-elements.md) | disallow ambiguity with block param names shadowing HTML elements | | | | | [template-no-unbalanced-curlies](docs/rules/template-no-unbalanced-curlies.md) | disallow unbalanced mustache curlies | | | | -| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | | | | +| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | | 🔧 | | ### Routes diff --git a/docs/rules/template-no-unknown-arguments-for-builtin-components.md b/docs/rules/template-no-unknown-arguments-for-builtin-components.md index fa454ec909..7626bb0fa6 100644 --- a/docs/rules/template-no-unknown-arguments-for-builtin-components.md +++ b/docs/rules/template-no-unknown-arguments-for-builtin-components.md @@ -1,5 +1,7 @@ # ember/template-no-unknown-arguments-for-builtin-components +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + 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. diff --git a/lib/rules/template-no-unknown-arguments-for-builtin-components.js b/lib/rules/template-no-unknown-arguments-for-builtin-components.js index 5b2c36a793..043e73a110 100644 --- a/lib/rules/template-no-unknown-arguments-for-builtin-components.js +++ b/lib/rules/template-no-unknown-arguments-for-builtin-components.js @@ -382,6 +382,17 @@ function checkRequired(nodeMeta, node, seen, context) { } } +// Rename `@argName=value` to `newName=value` — strips the `@` and swaps +// the identifier. Used when a deprecated argument has a direct HTML +// attribute replacement (e.g. `@elementId` -> `id`). +function buildRenameFix(attr, newName) { + return (fixer) => { + const nameStart = attr.range[0]; + const nameEnd = nameStart + attr.name.length; + return fixer.replaceTextRange([nameStart, nameEnd], newName); + }; +} + /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -392,7 +403,7 @@ module.exports = { url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unknown-arguments-for-builtin-components.md', templateMode: 'both', }, - fixable: null, + fixable: 'code', schema: [], messages: { unknownArgument: '{{message}}', @@ -410,6 +421,83 @@ module.exports = { create(context) { const sourceCode = context.sourceCode; + // Remove the attribute entirely (including any preceding whitespace that + // separates it from the previous token). + function buildRemovalFix(attr) { + return (fixer) => { + const text = sourceCode.getText(); + const attrStart = attr.range[0]; + const attrEnd = attr.range[1]; + + let removeStart = attrStart; + while (removeStart > 0 && /\s/.test(text[removeStart - 1])) { + removeStart--; + } + + return fixer.removeRange([removeStart, attrEnd]); + }; + } + + // Migrate `@eventName={{expr}}` to `{{on "htmlEvent" expr}}` modifier + // (or `{{on "htmlEvent" (helper ...params)}}` when the value is a call). + // Only safe when the attribute value is a mustache expression. + function buildEventMigrationFix(attr, htmlEventName) { + return (fixer) => { + const valueText = sourceCode.getText(attr.value); + // Strip outer `{{` and `}}` to get the expression text. + let inner = valueText; + if (inner.startsWith('{{') && inner.endsWith('}}')) { + inner = inner.slice(2, -2).trim(); + } + // If the value has parameters (e.g. `action this.click`), wrap as + // a sub-expression so the modifier receives a single callable. + const hasParams = + attr.value && + attr.value.type === 'GlimmerMustacheStatement' && + Array.isArray(attr.value.params) && + attr.value.params.length > 0; + const expr = hasParams ? `(${inner})` : inner; + const modifier = `{{on "${htmlEventName}" ${expr}}}`; + return fixer.replaceTextRange([attr.range[0], attr.range[1]], modifier); + }; + } + + function buildFix(node, attr) { + const tagMeta = KnownArguments[node.tag]; + if (!tagMeta) { + return null; + } + const deprecatedArgs = tagMeta.deprecatedArguments || {}; + const deprecatedEvents = tagMeta.deprecatedEvents || {}; + + if (attr.name in deprecatedArgs) { + const replacement = deprecatedArgs[attr.name]; + if (replacement) { + // Rename to the equivalent HTML attribute. + return buildRenameFix(attr, replacement); + } + // No replacement attribute — just remove the deprecated arg. + return buildRemovalFix(attr); + } + + if (attr.name in deprecatedEvents) { + const replacement = deprecatedEvents[attr.name]; + if (!replacement) { + // No replacement event (e.g. `@bubbles`) — just remove. + return buildRemovalFix(attr); + } + // Only migrate to `{{on}}` when the value is a mustache expression. + // Otherwise (string literal, valueless), leave unfixed. + if (attr.value && attr.value.type === 'GlimmerMustacheStatement') { + return buildEventMigrationFix(attr, replacement); + } + return null; + } + + // Truly unknown argument (typo) — no autofix. + return null; + } + return { GlimmerElementNode(node) { if (!node.tag || !node.attributes) { @@ -451,12 +539,14 @@ module.exports = { } } - // Report unknown/deprecated arguments + // Report unknown/deprecated arguments. for (const attr of warns) { + const fix = buildFix(node, attr); context.report({ node: attr, messageId: 'unknownArgument', data: { message: getErrorMessage(node.tag, attr.name) }, + fix: fix || null, }); } diff --git a/tests/lib/rules/template-no-unknown-arguments-for-builtin-components.js b/tests/lib/rules/template-no-unknown-arguments-for-builtin-components.js index 48f62799a0..aedabbac69 100644 --- a/tests/lib/rules/template-no-unknown-arguments-for-builtin-components.js +++ b/tests/lib/rules/template-no-unknown-arguments-for-builtin-components.js @@ -135,55 +135,72 @@ ruleTester.run('template-no-unknown-arguments-for-builtin-components', rule, { errors: [{ messageId: 'conflictArgument' }, { messageId: 'conflictArgument' }], }, { + // Deprecated argument without a replacement attribute — autofixed by removal. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }], }, { + // Deprecated argument with replacement — autofixed by renaming to the HTML attribute. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }], }, { + // Deprecated event with a helper invocation value — migrated to an `{{on}}` modifier with the helper as a sub-expression. code: '', - output: null, + output: + '', errors: [{ messageId: 'unknownArgument' }], }, { + // Deprecated argument without a replacement attribute — autofixed by removal. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }], }, { + // Two deprecated arguments on Input — both renamed to HTML attributes. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }, { messageId: 'unknownArgument' }], }, { + // Deprecated event with a simple path value — migrated to an `{{on}}` modifier. code: '', - output: null, + output: '', errors: [{ messageId: 'unknownArgument' }], }, { + // Deprecated argument without a replacement attribute — autofixed by removal. code: '