@html-eslint/eslint-plugin
Version:
ESLint plugin for HTML
516 lines (481 loc) • 13.5 kB
JavaScript
/**
* @import {
* AnyNode,
* CloseTemplate,
* OpenTemplate,
* ScriptTag,
* StyleTag,
* Tag,
* TemplateLiteral,
* TemplateText,
* Text
* } from "@html-eslint/types"
* @import {AST} from "eslint"
* @import {
* Context,
* Line,
* RuleListener,
* RuleModule
* } from "../../types"
* @typedef {AnyNode | Line} AnyNodeOrLine
*
* @typedef {Object} IndentType
* @property {"tab"} TAB
* @property {"space"} SPACE
*
* @typedef {Object} MessageId
* @property {"wrongIndent"} WRONG_INDENT
*
* @typedef {Object} IndentOptionInfo
* @property {IndentType["TAB"] | IndentType["SPACE"]} indentType
* @property {number} indentSize
* @property {string} indentChar
*
* @typedef {"tab" | number} Option1
*
* @typedef {Object} Option2
* @property {number} [Option2.Attribute]
* @property {Record<string, number>} [Option2.tagChildrenIndent]
* @property {boolean} [Option2.ignoreComment]
* @property {"first" | "templateTag"} [Option2.templateIndentBase]
*/
const { parseTemplateLiteral } = require("../utils/template-literal");
const { NODE_TYPES } = require("@html-eslint/parser");
const { RULE_CATEGORY } = require("../../constants");
const {
splitToLineNodes,
isLine,
isTag,
isScript,
isStyle,
isText,
} = require("../utils/node");
const {
shouldCheckTaggedTemplateExpression,
shouldCheckTemplateLiteral,
} = require("../utils/settings");
const { getSourceCode } = require("../utils/source-code");
const IndentLevel = require("./indent-level");
/** @type {MessageId} */
const MESSAGE_ID = {
WRONG_INDENT: "wrongIndent",
};
/** @type {IndentType} */
const INDENT_TYPES = {
TAB: "tab",
SPACE: "space",
};
const IGNORING_NODES = ["pre", "xmp"];
/** @type {RuleModule<[Option1, Option2]>} */
module.exports = {
meta: {
type: "code",
docs: {
description: "Enforce consistent indentation",
category: RULE_CATEGORY.STYLE,
recommended: true,
},
fixable: true,
schema: [
{
oneOf: [
{
enum: ["tab"],
},
{
type: "integer",
minimum: 0,
},
],
},
{
type: "object",
properties: {
Attribute: {
type: "integer",
minimum: 1,
default: 1,
},
tagChildrenIndent: {
default: {},
type: "object",
patternProperties: {
"^[a-z]+$": {
type: "integer",
minimum: 0,
},
},
additionalProperties: false,
},
ignoreComment: {
type: "boolean",
default: false,
},
templateIndentBase: {
type: "string",
enum: ["first", "templateTag"],
default: "templateTag",
},
},
additionalProperties: false,
},
],
messages: {
[MESSAGE_ID.WRONG_INDENT]:
"Expected indentation of {{expected}} but found {{actual}}.",
},
},
create(context) {
const sourceCode = getSourceCode(context);
const indentLevelOptions = (context.options && context.options[1]) || {};
const lines = sourceCode.getLines();
const ignoreComment = indentLevelOptions.ignoreComment === true;
const autoBaseIndent = indentLevelOptions.templateIndentBase === "first";
const { indentType, indentSize, indentChar } = getIndentOptionInfo(context);
/**
* @param {Tag | ScriptTag | StyleTag | Text} node
* @returns {number}
*/
function getTagIncreasingLevel(node) {
if (
node.parent &&
isTag(node.parent) &&
indentLevelOptions &&
typeof indentLevelOptions.tagChildrenIndent === "object" &&
indentLevelOptions.tagChildrenIndent
) {
const option =
indentLevelOptions.tagChildrenIndent[node.parent.name.toLowerCase()];
if (typeof option === "number") {
return option;
}
}
return 1;
}
/**
* @param {AnyNode} node
* @returns {number}
*/
function getIncreasingLevel(node) {
if (isLine(node)) {
return 1;
}
if (isTag(node) || isScript(node) || isStyle(node) || isText(node)) {
return getTagIncreasingLevel(node);
}
const type = node.type;
if (type === NODE_TYPES.Attribute) {
const optionIndent = indentLevelOptions[type];
if (typeof optionIndent === "number") {
return optionIndent;
}
}
return 1;
}
/**
* @param {TemplateLiteral} node
* @returns {number}
*/
function getAutoBaseSpaces(node) {
if (!autoBaseIndent) {
return 0;
}
const startLineIndex = node.loc.start.line;
const endLineIndex = node.loc.end.line - 1;
const templateLines = lines.slice(startLineIndex, endLineIndex);
for (let i = 0; i < templateLines.length; i++) {
const line = templateLines[i];
if (line.trim()) {
return countLeftPadding(line);
}
}
return 0;
}
/**
* @param {TemplateLiteral} node
* @returns {number}
*/
function getTemplateLiteralBaseIndentLevel(node) {
if (autoBaseIndent) {
return 0;
}
// @ts-ignore
const lineIndex = node.loc.start.line - 1;
const line = lines[lineIndex];
const spaceCount = countLeftPadding(line);
if (indentType === "space") {
return Math.floor(spaceCount / indentSize) + 1;
} else {
return spaceCount + 1;
}
}
/**
* @param {number} baseLevel
* @param {number} baseSpaces
*/
function createIndentVisitor(baseLevel, baseSpaces) {
const indentLevel = new IndentLevel({
getIncreasingLevel,
});
indentLevel.setBase(baseLevel);
let parentIgnoringChildCount = 0;
/**
* @param {AnyNode
* | Line
* | TemplateText
* | OpenTemplate
* | CloseTemplate} node
* @returns {string}
*/
function getActualIndent(node) {
const lines = sourceCode.getLines();
const line = lines[node.loc.start.line - 1];
let column = node.loc.start.column;
if (isLine(node)) {
column += countLeftPadding(node.value);
}
return line.slice(0, column);
}
/** @returns {string} */
function getExpectedIndent() {
let base = "";
if (indentType === "space") {
base = " ".repeat(baseSpaces);
} else {
base = indentChar.repeat(baseSpaces);
}
return base + indentChar.repeat(indentLevel.value());
}
/**
* @param {string} actualIndent
* @param {number} expectedIndentSize
*/
function getMessageData(actualIndent, expectedIndentSize) {
const actualTabs = (actualIndent.match(/\t/g) || []).length;
const actualSpaces = (actualIndent.match(/[^\S\t\n\r]/g) || []).length;
let actual = "";
if (!actualTabs && !actualSpaces) {
actual = "no indent";
} else {
if (actualTabs) {
actual += `${actualTabs} tab`;
}
if (actualSpaces) {
if (actual) {
actual += ", ";
}
actual += `${actualSpaces} space`;
}
}
if (indentType === "space") {
expectedIndentSize *= indentSize;
}
return {
actual,
expected: `${expectedIndentSize} ${indentType}`,
};
}
/**
* @param {AnyNode
* | Line
* | TemplateText
* | OpenTemplate
* | CloseTemplate} node
*/
function checkIndent(node) {
if (parentIgnoringChildCount > 0) {
return;
}
const actualIndent = getActualIndent(node);
const expectedIndent = getExpectedIndent();
if (actualIndent.trim().length) {
return;
}
if (actualIndent !== expectedIndent) {
const targetNode = getIndentNodeToReport(node, actualIndent);
context.report({
loc: targetNode.loc,
messageId: MESSAGE_ID.WRONG_INDENT,
data: getMessageData(actualIndent, indentLevel.value()),
fix(fixer) {
return fixer.replaceTextRange(targetNode.range, expectedIndent);
},
});
}
}
/** @type {RuleListener} */
const commentVisitor = {
Comment(node) {
indentLevel.indent(node);
},
CommentOpen: checkIndent,
CommentContent(node) {
indentLevel.indent(node);
const lineNodes = splitToLineNodes(node);
lineNodes.forEach((lineNode) => {
if (
lineNode.isOverlapTemplate &&
!lineNode.hasCloseTemplate &&
!lineNode.hasOpenTemplate
) {
return;
}
if (lineNode.value.trim().length) {
checkIndent(lineNode);
}
});
},
CommentClose: checkIndent,
"Comment:exit"(node) {
indentLevel.dedent(node);
},
"CommentContent:exit"(node) {
indentLevel.dedent(node);
},
};
/** @type {RuleListener} */
const visitor = {
Tag(node) {
if (IGNORING_NODES.includes(node.name)) {
parentIgnoringChildCount++;
}
indentLevel.indent(node);
},
ScriptTag(node) {
indentLevel.indent(node);
},
"ScriptTag:exit"(node) {
indentLevel.dedent(node);
},
OpenScriptTagStart: checkIndent,
OpenScriptTagEnd: checkIndent,
CloseScriptTag: checkIndent,
StyleTag(node) {
indentLevel.indent(node);
},
"StyleTag:exit"(node) {
indentLevel.dedent(node);
},
OpenStyleTagStart: checkIndent,
OpenStyleTagEnd: checkIndent,
CloseStyleTag: checkIndent,
OpenTagStart: checkIndent,
OpenTagEnd(node) {
checkIndent(node);
},
CloseTag: checkIndent,
"Tag:exit"(node) {
if (IGNORING_NODES.includes(node.name)) {
parentIgnoringChildCount--;
}
indentLevel.dedent(node);
},
Attribute(node) {
indentLevel.indent(node);
},
AttributeKey: checkIndent,
AttributeValue: checkIndent,
"Attribute:exit"(node) {
indentLevel.dedent(node);
},
Text(node) {
indentLevel.indent(node);
const lineNodes = splitToLineNodes(node);
lineNodes.forEach((lineNode) => {
if (
lineNode.isOverlapTemplate &&
!lineNode.hasCloseTemplate &&
!lineNode.hasOpenTemplate
) {
return;
}
if (lineNode.value.trim().length) {
checkIndent(lineNode);
}
});
},
"Text:exit"(node) {
indentLevel.dedent(node);
},
...(ignoreComment ? {} : commentVisitor),
};
return visitor;
}
return {
...createIndentVisitor(0, 0),
TaggedTemplateExpression(node) {
if (shouldCheckTaggedTemplateExpression(node, context)) {
const base = getTemplateLiteralBaseIndentLevel(node.quasi);
const baseSpaces = getAutoBaseSpaces(node.quasi);
parseTemplateLiteral(
node.quasi,
getSourceCode(context),
createIndentVisitor(base, baseSpaces)
);
}
},
TemplateLiteral(node) {
if (shouldCheckTemplateLiteral(node, context)) {
const base = getTemplateLiteralBaseIndentLevel(node);
const baseSpaces = getAutoBaseSpaces(node);
parseTemplateLiteral(
node,
getSourceCode(context),
createIndentVisitor(base, baseSpaces)
);
}
},
};
},
};
/**
* @param {AnyNodeOrLine | TemplateText | OpenTemplate | CloseTemplate} node
* @param {string} actualIndent
* @returns {{ range: AST.Range; loc: AST.SourceLocation }}
*/
function getIndentNodeToReport(node, actualIndent) {
let rangeStart = node.range[0];
if (!isLine(node)) {
rangeStart -= actualIndent.length;
}
return {
range: [rangeStart, rangeStart + actualIndent.length],
loc: {
start: {
column: 0,
line: node.loc.start.line,
},
end: {
column: actualIndent.length,
line: node.loc.start.line,
},
},
};
}
/**
* @param {string} str
* @returns {number}
*/
function countLeftPadding(str) {
return str.length - str.replace(/^[\s\t]+/, "").length;
}
/**
* @param {Context<any[]>} context
* @returns {IndentOptionInfo}
*/
function getIndentOptionInfo(context) {
const options = context.options;
/** @type {IndentType["SPACE"] | IndentType["TAB"]} */
let indentType = INDENT_TYPES.SPACE;
let indentSize = 4;
if (options.length) {
if (options[0] === INDENT_TYPES.TAB) {
indentType = INDENT_TYPES.TAB;
} else {
indentSize = options[0];
}
}
const indentChar =
indentType === INDENT_TYPES.SPACE ? " ".repeat(indentSize) : "\t";
return { indentType, indentSize, indentChar };
}