ember-template-lint
Version:
Linter for Ember or Handlebars templates.
374 lines (291 loc) • 8.54 kB
JavaScript
import { builders as b } from 'ember-template-recast';
import Rule from './_base.js';
function cloneIfTextNode(node) {
if (node.type === 'MustacheStatement') {
return node;
}
return b.text(node.chars);
}
function getAttributeName(node) {
return node.name;
}
function getAttributePosition(node) {
const name = getAttributeName(node);
if (name.startsWith('@')) {
return 1;
}
if (name === '...attributes') {
return 3;
}
return 2;
}
function getHashPairName(node) {
return node.key;
}
function getModifierName(node) {
if (node.path.type !== 'PathExpression') {
return '';
}
return node.path.original;
}
function canSkipListSplattributesLast(node) {
const { attributes, modifiers } = node;
const splattributes = attributes.at(-1);
const lastModifier = modifiers.at(-1);
if (splattributes?.name !== '...attributes' || !lastModifier) {
return true;
}
// Check that ...attributes appears after the last modifier
const splattributesPosition = splattributes.loc.start;
const lastModifierPosition = lastModifier.loc.start;
if (splattributesPosition.line > lastModifierPosition.line) {
return true;
}
return splattributesPosition.column > lastModifierPosition.column;
}
function compareAttributes(a, b) {
const positionA = getAttributePosition(a);
const positionB = getAttributePosition(b);
if (positionA > positionB) {
return 1;
}
if (positionB > positionA) {
return -1;
}
const nameA = getAttributeName(a);
const nameB = getAttributeName(b);
if (nameA > nameB) {
return 1;
}
if (nameB > nameA) {
return -1;
}
return 0;
}
function compareHashPairs(a, b) {
const nameA = getHashPairName(a);
const nameB = getHashPairName(b);
if (nameA > nameB) {
return 1;
}
if (nameB > nameA) {
return -1;
}
return 0;
}
function compareModifiers(a, b) {
const nameA = getModifierName(a);
const nameB = getModifierName(b);
if (nameA > nameB) {
return 1;
}
if (nameB > nameA) {
return -1;
}
if (nameA !== 'on') {
return 0;
}
// Sort {{on}} modifiers
const eventA = a.params[0];
const eventB = b.params[0];
if (eventA.type === 'StringLiteral' && eventB.type === 'StringLiteral') {
const eventNameA = eventA.original;
const eventNameB = eventB.original;
if (eventNameA > eventNameB) {
return 1;
}
if (eventNameB > eventNameA) {
return -1;
}
}
return 0;
}
function getUnsortedAttributeIndex(attributes) {
return attributes.findIndex((attribute, index) => {
if (index === attributes.length - 1) {
return false;
}
return compareAttributes(attribute, attributes[index + 1]) === 1;
});
}
function getUnsortedHashPairIndex(hash) {
return hash.pairs.findIndex((hashPair, index) => {
if (index === hash.pairs.length - 1) {
return false;
}
return compareHashPairs(hashPair, hash.pairs[index + 1]) === 1;
});
}
function getUnsortedModifierIndex(modifiers) {
return modifiers.findIndex((modifier, index) => {
if (index === modifiers.length - 1) {
return false;
}
return compareModifiers(modifier, modifiers[index + 1]) === 1;
});
}
function listSplattributesLast(node) {
let { attributes, modifiers } = node;
const splattributes = attributes.at(-1);
// Assign each modifier the location of its predecessor
let start = splattributes.loc.start;
modifiers = modifiers.map((modifier) => {
const { hash, loc, params, path } = modifier;
const newLocation = {
start,
end: {
column: start.column + (loc.end.column - loc.start.column),
line: start.line + (loc.end.line - loc.start.line) + 1,
},
};
start = newLocation.end;
return b.elementModifier(path, params, hash, newLocation);
});
// Assign ...attributes the original location of the last modifier
attributes.splice(
-1,
1,
b.attr('...attributes', b.text(''), {
start,
end: {
column: start.column + '...attributes'.length,
line: start.line,
},
})
);
return {
attributes,
modifiers,
};
}
function sortAttributes(attributes) {
return attributes.sort(compareAttributes).map((attribute) => {
const { name, value } = attribute;
switch (value.type) {
case 'ConcatStatement': {
// Bug in ember-template-recast@6.1.5 (it removes TextNode's with a single character)
const parts = value.parts.map(cloneIfTextNode);
// eslint-disable-next-line unicorn/prefer-spread
return b.attr(name, b.concat(parts));
}
case 'TextNode': {
// Bug in ember-template-recast@6.1.5 (it removes values that are an empty string)
if (value.chars === '') {
const { start, end } = value.loc;
const isValueUndefined = start.line === end.line && start.column === end.column;
if (!isValueUndefined) {
return b.attr(name, b.mustache(b.string('')));
}
}
break;
}
}
return b.attr(name, value);
});
}
function sortHash(hash) {
return b.hash(hash.pairs.sort(compareHashPairs));
}
function sortModifiers(modifiers) {
return modifiers.sort(compareModifiers).map((modifier) => {
const { hash, params, path } = modifier;
return b.elementModifier(path, params, hash);
});
}
export default class SortInvocations extends Rule {
/**
* @returns {import('./types.js').VisitorReturnType<SortInvocations>}
*/
visitor() {
return {
BlockStatement(node) {
const { hash, params, path } = node;
let index = getUnsortedHashPairIndex(hash);
if (index === -1) {
return;
}
if (this.mode === 'fix') {
node = b.block(path, params, sortHash(hash), b.blockItself());
} else {
this.log({
isFixable: true,
message: `\`${getHashPairName(hash.pairs[index])}\` must appear after \`${getHashPairName(hash.pairs[index + 1])}\``,
node,
});
}
},
ElementNode(node) {
const { attributes, modifiers } = node;
let index = getUnsortedAttributeIndex(attributes);
if (index !== -1) {
if (this.mode === 'fix') {
node.attributes = sortAttributes(attributes);
} else {
this.log({
isFixable: true,
message: `\`${getAttributeName(attributes[index])}\` must appear after \`${getAttributeName(attributes[index + 1])}\``,
node,
});
}
}
index = getUnsortedModifierIndex(modifiers);
if (index !== -1) {
if (this.mode === 'fix') {
node.modifiers = sortModifiers(modifiers);
} else {
this.log({
isFixable: true,
message: `\`{{${getModifierName(modifiers[index])}}}\` must appear after \`{{${getModifierName(modifiers[index + 1])}}}\``,
node,
});
}
}
if (!canSkipListSplattributesLast(node)) {
if (this.mode === 'fix') {
const { attributes, modifiers } = listSplattributesLast(node);
node.attributes = attributes;
node.modifiers = modifiers;
} else {
this.log({
isFixable: true,
message: `\`...attributes\` must appear after modifiers`,
node,
});
}
}
},
MustacheStatement(node) {
const { hash } = node;
let index = getUnsortedHashPairIndex(hash);
if (index === -1) {
return;
}
if (this.mode === 'fix') {
node.hash = sortHash(hash);
} else {
this.log({
isFixable: true,
message: `\`${getHashPairName(hash.pairs[index])}\` must appear after \`${getHashPairName(hash.pairs[index + 1])}\``,
node,
});
}
},
SubExpression(node) {
const { hash, params, path } = node;
let index = getUnsortedHashPairIndex(hash);
if (index === -1) {
return;
}
if (this.mode === 'fix') {
node = b.sexpr(path, params, sortHash(hash));
} else {
this.log({
isFixable: true,
message: `\`${getHashPairName(hash.pairs[index])}\` must appear after \`${getHashPairName(hash.pairs[index + 1])}\``,
node,
});
}
},
};
}
}