eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
559 lines (527 loc) • 17.2 kB
JavaScript
function deprecateArgument(componentName, argumentName, replacementAttribute) {
const msgs = [`Passing the "${argumentName}" argument to <${componentName} /> is deprecated.`];
if (replacementAttribute) {
msgs.push(
`Instead, please pass the attribute directly, i.e. "<${componentName} ${replacementAttribute}={{...}} />" instead of "<${componentName} ${argumentName}={{...}} />".`
);
}
return msgs.join('\n');
}
function deprecateEvent(componentName, argumentName, replacementAttribute) {
const msgs = [`Passing the "${argumentName}" argument to <${componentName} /> is deprecated.`];
if (replacementAttribute) {
msgs.push(
`Instead, please use the {{on}} modifier, i.e. "<${componentName} {{on "${replacementAttribute}" ...}} />" instead of "<${componentName} ${argumentName}={{...}} />".`
);
}
return msgs.join('\n');
}
const KnownArguments = {
LinkTo: {
arguments: [
'route',
'model',
'models',
'query',
'replace',
'disabled',
'current-when',
'activeClass',
'loadingClass',
'disabledClass',
],
deprecatedArguments: {
'@active': '',
'@loading': '',
'@init': '',
'@didRender': '',
'@willDestroy': '',
'@didReceiveAttrs': '',
'@willRender': '',
'@didInsertElement': '',
'@didUpdateAttrs': '',
'@willUpdate': '',
'@didUpdate': '',
'@willDestroyElement': '',
'@willClearRender': '',
'@didDestroyElement': '',
'@tagName': '',
'@id': 'id',
'@elementId': 'id',
'@ariaRole': 'role',
'@class': 'class',
'@classNames': 'class',
'@classNameBindings': 'class',
'@isVisible': 'style',
'@rel': 'rel',
'@tabindex': 'tabindex',
'@target': 'target',
'@title': 'title',
},
deprecatedEvents: {
'@click': 'click',
'@contextMenu': 'contextmenu',
'@doubleClick': 'dblclick',
'@drag': 'drag',
'@dragEnd': 'dragend',
'@dragEnter': 'dragenter',
'@dragLeave': 'dragleave',
'@dragOver': 'dragover',
'@dragStart': 'dragstart',
'@drop': 'drop',
'@focusIn': 'focusin',
'@focusOut': 'focusout',
'@input': 'input',
'@keyDown': 'keydown',
'@keyPress': 'keypress',
'@keyUp': 'keyup',
'@mouseDown': 'mousedown',
'@mouseEnter': 'mouseenter',
'@mouseLeave': 'mouseleave',
'@mouseMove': 'mousemove',
'@mouseUp': 'mouseup',
'@submit': 'submit',
'@touchCancel': 'touchcancel',
'@touchEnd': 'touchend',
'@touchMove': 'touchmove',
'@touchStart': 'touchstart',
},
conflicts: [['model', 'models']],
required: [['route', 'query', 'model', 'models']],
},
Input: {
arguments: ['type', 'value', 'checked', 'insert-newline', 'enter', 'escape-press'],
deprecatedArguments: {
'@bubbles': '',
'@cancel': '',
'@init': '',
'@didRender': '',
'@willDestroy': '',
'@didReceiveAttrs': '',
'@willRender': '',
'@didInsertElement': '',
'@didUpdateAttrs': '',
'@willUpdate': '',
'@didUpdate': '',
'@willDestroyElement': '',
'@willClearRender': '',
'@didDestroyElement': '',
'@id': 'id',
'@elementId': 'id',
'@ariaRole': 'role',
'@class': 'class',
'@classNames': 'class',
'@classNameBindings': 'class',
'@isVisible': 'style',
'@accept': 'accept',
'@autocapitalize': '',
'@autocomplete': 'autocomplete',
'@autocorrect': '',
'@autofocus': 'autofocus',
'@autosave': '',
'@dir': 'dir',
'@disabled': 'disabled',
'@form': 'form',
'@formaction': 'formaction',
'@formenctype': 'formenctype',
'@formmethod': 'formmethod',
'@formnovalidate': 'formnovalidate',
'@formtarget': 'formtarget',
'@height': 'height',
'@indeterminate': '',
'@inputmode': '',
'@lang': 'lang',
'@list': 'list',
'@max': 'max',
'@maxlength': 'maxlength',
'@min': 'min',
'@minlength': 'minlength',
'@multiple': 'multiple',
'@name': 'name',
'@pattern': 'pattern',
'@placeholder': 'placeholder',
'@readonly': 'readonly',
'@required': 'required',
'@selectionDirection': '',
'@size': 'size',
'@spellcheck': 'spellcheck',
'@step': 'step',
'@tabindex': 'tabindex',
'@title': 'title',
'@width': 'width',
},
conflicts: [['checked', 'value']],
deprecatedEvents: {
'@change': 'change',
'@click': 'click',
'@contextMenu': 'contextmenu',
'@doubleClick': 'dblclick',
'@drag': 'drag',
'@dragEnd': 'dragend',
'@dragEnter': 'dragenter',
'@dragLeave': 'dragleave',
'@dragOver': 'dragover',
'@dragStart': 'dragstart',
'@drop': 'drop',
'@input': 'input',
'@mouseDown': 'mousedown',
'@mouseEnter': 'mouseenter',
'@mouseLeave': 'mouseleave',
'@mouseMove': 'mousemove',
'@mouseUp': 'mouseup',
'@submit': 'submit',
'@touchCancel': 'touchcancel',
'@touchEnd': 'touchend',
'@touchMove': 'touchmove',
'@touchStart': 'touchstart',
'@focus-in': 'focusin',
'@focus-out': 'focusout',
'@key-down': 'keydown',
'@key-press': 'keypress',
'@key-up': 'keyup',
},
},
Textarea: {
arguments: ['value', 'insert-newline', 'enter', 'escape-press'],
deprecatedArguments: {
'@init': '',
'@didRender': '',
'@willDestroy': '',
'@didReceiveAttrs': '',
'@willRender': '',
'@didInsertElement': '',
'@didUpdateAttrs': '',
'@willUpdate': '',
'@didUpdate': '',
'@willDestroyElement': '',
'@willClearRender': '',
'@didDestroyElement': '',
'@id': 'id',
'@elementId': 'id',
'@ariaRole': 'role',
'@class': 'class',
'@classNames': 'class',
'@classNameBindings': 'class',
'@isVisible': 'style',
'@autocapitalize': '',
'@autocomplete': 'autocomplete',
'@autocorrect': '',
'@autofocus': 'autofocus',
'@cols': 'cols',
'@dir': 'dir',
'@disabled': 'disabled',
'@form': 'form',
'@lang': 'lang',
'@maxlength': 'maxlength',
'@minlength': 'minlength',
'@name': 'name',
'@placeholder': 'placeholder',
'@readonly': 'readonly',
'@required': 'required',
'@rows': 'rows',
'@selectionDirection': '',
'@selectionEnd': '',
'@selectionStart': '',
'@spellcheck': 'spellcheck',
'@tabindex': 'tabindex',
'@title': 'title',
'@wrap': 'wrap',
},
deprecatedEvents: {
'@bubbles': '',
'@cancel': '',
'@click': 'click',
'@contextMenu': 'contextmenu',
'@doubleClick': 'dblclick',
'@drag': 'drag',
'@dragEnd': 'dragend',
'@dragEnter': 'dragenter',
'@dragLeave': 'dragleave',
'@dragOver': 'dragover',
'@dragStart': 'dragstart',
'@drop': 'drop',
'@input': 'input',
'@mouseDown': 'mousedown',
'@mouseEnter': 'mouseenter',
'@mouseLeave': 'mouseleave',
'@mouseMove': 'mousemove',
'@mouseUp': 'mouseup',
'@submit': 'submit',
'@touchCancel': 'touchcancel',
'@touchEnd': 'touchend',
'@touchMove': 'touchmove',
'@touchStart': 'touchstart',
'@focus-in': 'focusin',
'@focus-out': 'focusout',
'@key-down': 'keydown',
'@key-press': 'keypress',
'@key-up': 'keyup',
},
},
};
function removeAtSymbol(txt) {
return txt.replace('@', '');
}
function fuzzyMatch(query, candidates) {
// Simple fuzzy match without external dependency
const q = query.toLowerCase();
let bestMatch = null;
let bestScore = 0;
for (const candidate of candidates) {
const c = candidate.toLowerCase();
// Simple substring/prefix matching
if (c === q) {
return candidate;
}
if (c.startsWith(q) || q.startsWith(c)) {
const score = Math.min(q.length, c.length) / Math.max(q.length, c.length);
if (score > bestScore) {
bestScore = score;
bestMatch = candidate;
}
}
// Levenshtein-like: count matching chars
let matchCount = 0;
let qi = 0;
for (let ci = 0; ci < c.length && qi < q.length; ci++) {
if (c[ci] === q[qi]) {
matchCount++;
qi++;
}
}
const score = matchCount / Math.max(q.length, c.length);
if (score > bestScore && score > 0.4) {
bestScore = score;
bestMatch = candidate;
}
}
return bestMatch;
}
function getErrorMessage(tagName, argumentName) {
const tagMeta = KnownArguments[tagName];
const deprecatedArgs = tagMeta.deprecatedArguments || {};
const deprecatedEvents = tagMeta.deprecatedEvents || {};
const candidates = [
...new Set([
...tagMeta.arguments,
...Object.keys(deprecatedArgs).map(removeAtSymbol),
...Object.keys(deprecatedEvents).map(removeAtSymbol),
]),
];
const pureQuery = removeAtSymbol(argumentName);
const hasArgumentsMatch = candidates.includes(pureQuery);
if (!hasArgumentsMatch) {
const msg = `"${argumentName}" is not a known argument for the <${tagName} /> component.`;
const suggestion = fuzzyMatch(pureQuery, candidates);
if (suggestion) {
return `${msg} Did you mean "@${suggestion}"?`;
}
return msg;
}
if (argumentName in deprecatedArgs) {
return deprecateArgument(tagName, argumentName, deprecatedArgs[argumentName]);
}
if (argumentName in deprecatedEvents) {
return deprecateEvent(tagName, argumentName, deprecatedEvents[argumentName]);
}
return `"${argumentName}" is unknown argument for <${tagName} /> component.`;
}
function checkConflicts(nodeMeta, node, seen, context) {
if (!nodeMeta.conflicts) {
return;
}
for (const conflictList of nodeMeta.conflicts) {
if (conflictList.every((item) => seen.includes(item))) {
for (const argName of conflictList) {
const attr = node.attributes.find(({ name }) => `@${argName}` === name);
if (attr) {
const conflictsWith = conflictList
.filter((el) => `@${el}` !== attr.name)
.map((el) => `"@${el}"`)
.join(', ');
context.report({
node: attr,
messageId: 'conflictArgument',
data: {
message: `"${attr.name}" conflicts with ${conflictsWith}, only one should exist.`,
},
});
}
}
}
}
}
function checkRequired(nodeMeta, node, seen, context) {
if (!nodeMeta.required) {
return;
}
for (const requiredItems of nodeMeta.required) {
const variants = Array.isArray(requiredItems) ? requiredItems : [requiredItems];
if (!variants.some((el) => seen.includes(el))) {
const argNames = variants.map((el) => `"@${el}"`).join(' or ');
context.report({
node,
messageId: 'requiredArgument',
data: {
message: `Argument${variants.length > 1 ? 's' : ''} ${argNames} is required for <${node.tag} /> component.`,
},
});
}
}
}
// 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: {
type: 'problem',
docs: {
description: 'disallow unknown arguments for built-in components',
category: 'Possible Errors',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unknown-arguments-for-builtin-components.md',
templateMode: 'both',
},
fixable: 'code',
schema: [],
messages: {
unknownArgument: '{{message}}',
conflictArgument: '{{message}}',
requiredArgument: '{{message}}',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-unknown-arguments-for-builtin-components.js',
docs: 'docs/rule/no-unknown-arguments-for-builtin-components.md',
tests: 'test/unit/rules/no-unknown-arguments-for-builtin-components-test.js',
},
},
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) {
return;
}
const nodeMeta = KnownArguments[node.tag];
if (!nodeMeta) {
return;
}
// In gjs/gts, if the tag name resolves to a JS-scope variable with a
// definition (e.g. an import or local variable), then it shadows the
// Ember built-in component — skip validation.
if (node.parent && node.parts && node.parts[0]) {
const scope = sourceCode.getScope(node.parent);
const isShadowed = scope.references.some(
(ref) =>
ref.identifier === node.parts[0] && ref.resolved && ref.resolved.defs.length > 0
);
if (isShadowed) {
return;
}
}
const seen = [];
const warns = [];
for (const attr of node.attributes) {
if (attr.type !== 'GlimmerAttrNode' || !attr.name?.startsWith('@')) {
continue;
}
const argName = removeAtSymbol(attr.name);
if (nodeMeta.arguments.includes(argName)) {
seen.push(argName);
} else {
warns.push(attr);
}
}
// 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,
});
}
checkConflicts(nodeMeta, node, seen, context);
checkRequired(nodeMeta, node, seen, context);
},
};
},
};