eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
550 lines (502 loc) • 18.5 kB
JavaScript
'use strict';
function getWhiteSpaceLength(statement) {
const whiteSpace = statement.match(/^\s+/) || [];
return (whiteSpace[0] || '').length;
}
function getEndLocationForOpen(node) {
return node.type === 'GlimmerBlockStatement' ? node.program.loc.start : node.loc.end;
}
function canApplyRule(node, config, sourceCode) {
let end;
if (node.type === 'GlimmerElementNode') {
// Use the first `>` token to find the end of the opening tag
const tokens = sourceCode.getTokens(node);
const openEnd = tokens.find((t) => t.value === '>');
end = openEnd ? openEnd.loc.end : node.loc.end;
} else {
end = getEndLocationForOpen(node);
}
const start = node.loc.start;
if (start.line === end.line) {
return end.column - start.column > config.maxLength;
}
return true;
}
function getSourceForLoc(sourceLines, loc) {
const startLine = loc.start.line;
const startColumn = loc.start.column;
const endLine = loc.end?.line || startLine;
const endColumn = loc.end?.column;
if (startLine === endLine) {
return endColumn === undefined
? sourceLines[startLine - 1].slice(startColumn)
: sourceLines[startLine - 1].slice(startColumn, endColumn);
}
const lines = [];
for (let i = startLine; i <= endLine; i++) {
if (i === startLine) {
lines.push(sourceLines[i - 1].slice(startColumn));
} else if (i === endLine && endColumn !== undefined) {
lines.push(sourceLines[i - 1].slice(0, endColumn));
} else {
lines.push(sourceLines[i - 1]);
}
}
return lines.join('\n');
}
function getSourceForNode(sourceLines, node) {
return getSourceForLoc(sourceLines, node.loc);
}
function parseOptions(options) {
if (!options || typeof options !== 'object') {
return {
maxLength: 80,
indentation: 2,
processElements: true,
mustacheOpenEnd: 'new-line',
elementOpenEnd: 'new-line',
};
}
const result = {
maxLength: 80,
indentation: 2,
mustacheOpenEnd: 'new-line',
elementOpenEnd: 'new-line',
};
if ('open-invocation-max-len' in options) {
result.maxLength = options['open-invocation-max-len'];
}
if ('indentation' in options) {
result.indentation = options.indentation;
}
if ('process-elements' in options) {
result.processElements = options['process-elements'];
}
if ('mustache-open-end' in options) {
result.mustacheOpenEnd = options['mustache-open-end'];
}
if ('element-open-end' in options) {
result.processElements = true;
result.elementOpenEnd = options['element-open-end'];
}
if ('as-indentation' in options) {
result.asIndentation = options['as-indentation'];
}
return result;
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'layout',
docs: {
description: 'enforce proper indentation of attributes and arguments in multi-line templates',
category: 'Stylistic Issues',
recommended: false,
recommendedGjs: false,
recommendedGts: false,
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-attribute-indentation.md',
templateMode: 'both',
},
fixable: null,
schema: [
{
type: 'object',
properties: {
'open-invocation-max-len': { type: 'integer', minimum: 0 },
indentation: { type: 'integer', minimum: 0 },
'process-elements': { type: 'boolean' },
'mustache-open-end': { enum: ['new-line', 'last-attribute'] },
'element-open-end': { enum: ['new-line', 'last-attribute'] },
'as-indentation': { enum: ['attribute', 'closing-brace'] },
},
additionalProperties: false,
},
],
messages: {
incorrectParamIndentation:
"Incorrect indentation of {{paramType}} '{{paramName}}' beginning at L{{actualLine}}:C{{actualColumn}}. Expected '{{paramName}}' to be at L{{expectedLine}}:C{{expectedColumn}}.",
incorrectCloseBrace:
"Incorrect indentation of close curly braces '}}' for the component '{{{{componentName}}}}' beginning at L{{actualLine}}:C{{actualColumn}}. Expected '{{{{componentName}}}}' to be at L{{expectedLine}}:C{{expectedColumn}}.",
incorrectCloseBracket:
"Incorrect indentation of close bracket '>' for the element '<{{tagName}}>' beginning at L{{actualLine}}:C{{actualColumn}}. Expected '<{{tagName}}>' to be at L{{expectedLine}}:C{{expectedColumn}}.",
incorrectBlockParamIndentation:
"Incorrect indentation of block params '{{blockParamStatement}}' beginning at L{{actualLine}}:C{{actualColumn}}. Expecting the block params to be at L{{expectedLine}}:C{{expectedColumn}}.",
incorrectClosingTag:
"Incorrect indentation of close tag '</{{tagName}}>' for element '<{{tagName}}>' beginning at L{{actualLine}}:C{{actualColumn}}. Expected '</{{tagName}}>' to be at L{{expectedLine}}:C{{expectedColumn}}.",
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/attribute-indentation.js',
docs: 'docs/rule/attribute-indentation.md',
tests: 'test/unit/rules/attribute-indentation-test.js',
},
},
create(context) {
const config = parseOptions(context.options[0]);
const sourceCode = context.sourceCode;
const sourceLines = sourceCode.getText().split('\n');
function getLineIndentation(node) {
const currentLine = sourceLines[node.loc.start.line - 1];
const leadingWhitespace = getWhiteSpaceLength(currentLine);
if (leadingWhitespace === 0) {
return node.loc.start.column;
}
return leadingWhitespace;
}
function getBlockParamStartLoc(node) {
let actual, expected;
const actualProgramStartLine = /^\s*}}/.test(sourceLines[node.program.loc.start.line - 1])
? 1
: 0;
const programStartLoc = {
line: node.program.loc.start.line - actualProgramStartLine,
column: node.program.loc.start.column,
};
const nodeStart = node.loc.start;
if (node.params.length === 0 && (!node.hash || node.hash.pairs.length === 0)) {
expected = {
line: nodeStart.line + 1,
column: nodeStart.column,
};
if (nodeStart.line === programStartLoc.line) {
const displayName = `{{#${node.path.original}`;
actual = {
line: nodeStart.line,
column: displayName.length,
};
} else {
const source = getSourceForLoc(sourceLines, {
start: {
line: programStartLoc.line,
column: 0,
},
end: programStartLoc,
});
actual = {
line: programStartLoc.line,
column: getWhiteSpaceLength(source),
};
}
} else {
let paramOrHashPairEndLoc;
if (node.params.length > 0) {
paramOrHashPairEndLoc = node.params.at(-1).loc.end;
}
if (node.hash && node.hash.pairs.length > 0) {
paramOrHashPairEndLoc = node.hash.loc.end;
}
const indentation = config.asIndentation === 'attribute' ? 2 : 0;
expected = {
line: paramOrHashPairEndLoc.line + 1,
column: node.loc.start.column + indentation,
};
if (paramOrHashPairEndLoc.line === programStartLoc.line) {
actual = paramOrHashPairEndLoc;
} else if (paramOrHashPairEndLoc.line < programStartLoc.line) {
const loc = {
start: paramOrHashPairEndLoc,
end: {
line: paramOrHashPairEndLoc.line,
},
};
const hashPairLineEndSource = getSourceForLoc(sourceLines, loc).trim();
actual = hashPairLineEndSource
? paramOrHashPairEndLoc
: {
line: programStartLoc.line,
column: getWhiteSpaceLength(sourceLines[programStartLoc.line - 1]),
};
}
}
return { actual, expected };
}
function validateBlockParams(node) {
const location = getBlockParamStartLoc(node);
const actual = location.actual;
const expected = location.expected;
if (actual.line !== expected.line || actual.column !== expected.column) {
const blockParamStatement = getSourceForLoc(sourceLines, {
start: actual,
end: node.program.loc.start,
}).trim();
context.report({
node,
messageId: 'incorrectBlockParamIndentation',
loc: { line: actual.line, column: actual.column },
data: {
blockParamStatement,
actualLine: actual.line,
actualColumn: actual.column,
expectedLine: expected.line,
expectedColumn: expected.column,
},
});
}
const expectedColumnNextLocation =
node.type === 'GlimmerElementNode' && !node.selfClosing ? 1 : 2;
return {
line: expected.line + 1,
column: expected.column + node.program.loc.start.column - expectedColumnNextLocation,
};
}
function iterateParams(params, type, initialExpectedLineStart, expectedColumnStart, node) {
let expectedLineStart = initialExpectedLineStart;
let paramType = type;
let namePath;
switch (type) {
case 'positional': {
paramType = 'positional param';
namePath = 'original';
break;
}
case 'htmlAttribute': {
paramType = 'htmlAttribute';
namePath = 'name';
break;
}
case 'element modifier': {
paramType = 'element modifier';
break;
}
default: {
paramType = type;
namePath = 'key';
}
}
let nextColumn = expectedColumnStart;
for (const param of params) {
const actualStartLocation = param.loc.start;
nextColumn = param.loc.end.column;
if (
expectedLineStart !== actualStartLocation.line ||
expectedColumnStart !== actualStartLocation.column
) {
const paramName = param[namePath] || param.path?.original;
context.report({
node: param,
messageId: 'incorrectParamIndentation',
loc: { line: actualStartLocation.line, column: actualStartLocation.column },
data: {
paramType,
paramName,
actualLine: actualStartLocation.line,
actualColumn: actualStartLocation.column,
expectedLine: expectedLineStart,
expectedColumn: expectedColumnStart,
},
});
}
const paramValueType = param.value ? param.value.type : param.type;
if (paramValueType === 'GlimmerSubExpression' || paramValueType === 'SubExpression') {
if (param.loc.start.line !== param.loc.end.line) {
expectedLineStart = param.loc.end.line;
}
} else if (
paramValueType === 'GlimmerMustacheStatement' ||
paramValueType === 'MustacheStatement'
) {
expectedLineStart = param.value.loc.end.line;
nextColumn = param.value.loc.end.column;
}
expectedLineStart++;
}
return {
line: expectedLineStart,
column: nextColumn,
};
}
function validateParams(node) {
const leadingWhitespace = getLineIndentation(node);
const expectedColumnStart = leadingWhitespace + config.indentation;
const expectedLineStart = node.loc.start.line + 1;
let nextLocation = {
line: expectedLineStart,
column: node.loc.start.column,
};
if (node.type === 'GlimmerElementNode') {
if (node.attributes.length > 0) {
nextLocation = iterateParams(
node.attributes,
'htmlAttribute',
expectedLineStart,
expectedColumnStart,
node
);
}
if (node.modifiers.length > 0) {
nextLocation = iterateParams(
node.modifiers,
'element modifier',
nextLocation.line,
expectedColumnStart,
node
);
}
} else {
if (node.params.length > 0) {
nextLocation = iterateParams(
node.params,
'positional',
expectedLineStart,
expectedColumnStart,
node
);
}
if (node.hash && node.hash.pairs.length > 0) {
nextLocation = iterateParams(
node.hash.pairs,
'attribute',
nextLocation.line,
expectedColumnStart,
node
);
}
}
return nextLocation;
}
function validateCloseBrace(node, nextLocation) {
const openIndentation = getLineIndentation(node);
let actualStartLocation;
if (node.type === 'GlimmerElementNode') {
// Use tokens to find the actual `>` position
const tokens = sourceCode.getTokens(node);
const openEnd = tokens.find((t) => t.value === '>');
if (!openEnd) {
return;
}
// For self-closing `/>`, the `>` is preceded by `/`
if (node.selfClosing) {
const slashToken = tokens.find((t) => t.value === '/' && t.range[1] === openEnd.range[0]);
actualStartLocation = slashToken ? slashToken.loc.start : openEnd.loc.start;
} else {
actualStartLocation = openEnd.loc.start;
}
} else {
const end = getEndLocationForOpen(node);
const actualColumnStartLocation =
node.type === 'GlimmerMustacheStatement' && node.trusting ? 3 : 2;
actualStartLocation = {
line: end.line,
column: end.column - actualColumnStartLocation,
};
}
const endPosition =
node.type === 'GlimmerElementNode' ? config.elementOpenEnd : config.mustacheOpenEnd;
const expectedStartLocation = {
line: endPosition === 'last-attribute' ? nextLocation.line - 1 : nextLocation.line,
column: endPosition === 'last-attribute' ? nextLocation.column : openIndentation,
};
if (
actualStartLocation.line !== expectedStartLocation.line ||
actualStartLocation.column !== expectedStartLocation.column
) {
if (node.type === 'GlimmerElementNode') {
const tagName = node.tag;
context.report({
node,
messageId: 'incorrectCloseBracket',
loc: { line: actualStartLocation.line, column: actualStartLocation.column },
data: {
tagName,
actualLine: actualStartLocation.line,
actualColumn: actualStartLocation.column,
expectedLine: expectedStartLocation.line,
expectedColumn: expectedStartLocation.column,
},
});
} else {
const componentName = node.path.original;
context.report({
node,
messageId: 'incorrectCloseBrace',
loc: { line: actualStartLocation.line, column: actualStartLocation.column },
data: {
componentName,
actualLine: actualStartLocation.line,
actualColumn: actualStartLocation.column,
expectedLine: expectedStartLocation.line,
expectedColumn: expectedStartLocation.column,
},
});
}
}
}
function validateClosingTag(node, lastChildEndLine) {
// `</tag>` is `2 + tag.length + 1` chars: `</` + tag + `>`
const actualColumnStartLocation = 3 + node.tag.length;
const actualColumn = node.loc.end.column - actualColumnStartLocation;
const expectedColumn = node.loc.start.column;
const actualLine = node.loc.end.line;
// Closing tag must not appear before the last child ends (line check),
// and must be column-aligned with the opening tag.
// Note: Glimmer AST may not include trailing whitespace TextNodes, so the
// closing tag can be on a LATER line than lastChildEndLine (whitespace gap).
// The original ember-template-lint's strict line equality works because
// Handlebars AST includes trailing TextNodes that span to the closing tag line.
if (actualLine < lastChildEndLine || actualColumn !== expectedColumn) {
context.report({
node,
messageId: 'incorrectClosingTag',
loc: { line: actualLine, column: actualColumn },
data: {
tagName: node.tag,
actualLine,
actualColumn,
expectedLine: lastChildEndLine,
expectedColumn,
},
});
}
}
function validateNonBlockForm(node) {
if (node.params.length > 0 || (node.hash && node.hash.pairs.length > 0)) {
const nextLocation = validateParams(node);
validateCloseBrace(node, nextLocation);
return nextLocation;
}
return undefined;
}
function validateBlockForm(node) {
let nextLocation;
if (node.params.length > 0 || (node.hash && node.hash.pairs.length > 0)) {
nextLocation = validateParams(node);
}
if (node.program?.blockParams && node.program.blockParams.length > 0) {
nextLocation = validateBlockParams(node);
}
if (nextLocation) {
validateCloseBrace(node, nextLocation);
}
}
return {
GlimmerBlockStatement(node) {
if (canApplyRule(node, config, sourceCode)) {
validateBlockForm(node);
}
},
GlimmerMustacheStatement(node) {
if (canApplyRule(node, config, sourceCode)) {
validateNonBlockForm(node);
}
},
GlimmerElementNode(node) {
if (config.processElements) {
if (canApplyRule(node, config, sourceCode)) {
if (node.modifiers.length > 0 || node.attributes.length > 0) {
const expectedCloseBraceLocation = validateParams(node);
validateCloseBrace(node, expectedCloseBraceLocation);
}
if (node.children.length > 0) {
const lastChild = node.children.at(-1);
const expectedStartLine =
lastChild.type === 'GlimmerBlockStatement'
? lastChild.loc.end.line + 1
: lastChild.loc.end.line;
validateClosingTag(node, expectedStartLine);
}
}
}
},
};
},
};