UNPKG

@yusufkandemir/eslint-plugin-lodash-template

Version:

ESLint plugin for John Resig-style micro template, Lodash's template, Underscore's template and EJS.

1,002 lines (933 loc) 37.7 kB
"use strict"; const utils = require("../utils"); const { getSourceCode } = require("eslint-compat-utils"); const IGNORE_INDENT_ELEMENT_NAMES = ["pre", "script", "style"]; /** * Normalize options. * @param {number|string|undefined} type The type of indentation. * @param {object} options Other options. * @returns {object} Normalized options. */ function parseOptions(type, options) { const ret = { indentChar: " ", indentSize: 2, attribute: 1, closeBracket: 0, }; if (Number.isSafeInteger(type)) { ret.indentSize = type; } else if (type === "tab") { ret.indentChar = "\t"; ret.indentSize = 1; } if (Number.isSafeInteger(options.attribute)) { ret.attribute = options.attribute; } if (Number.isSafeInteger(options.closeBracket)) { ret.closeBracket = options.closeBracket; } return ret; } /** * Check whether the given token is a left parenthesis. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a left parenthesis. */ function isLeftParen(token) { return token && token.type === "Punctuator" && token.value === "("; } /** * Check whether the given token is a right parenthesis. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a right parenthesis. */ function isRightParen(token) { return token && token.type === "Punctuator" && token.value === ")"; } /** * Check whether the given token is a left brace. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a left brace. */ function isLeftBrace(token) { return token && token.type === "Punctuator" && token.value === "{"; } /** * Check whether the given token is a right brace. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a right brace. */ function isRightBrace(token) { return token && token.type === "Punctuator" && token.value === "}"; } /** * Check whether the given token is a left bracket. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a left bracket. */ function isLeftBracket(token) { return token && token.type === "Punctuator" && token.value === "["; } /** * Check whether the given token is a right bracket. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a right bracket. */ function isRightBracket(token) { return token && token.type === "Punctuator" && token.value === "]"; } /** * Check whether the given token is a left indentation start punctuators. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a left indentation start punctuators. */ function isIndentationStartPunctuator(token) { return isLeftParen(token) || isLeftBrace(token) || isLeftBracket(token); } /** * Check whether the given token is a right indentation end punctuators. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a right indentation end punctuators. */ function isIndentationEndPunctuator(token) { return isRightParen(token) || isRightBrace(token) || isRightBracket(token); } /** * Check whether the given tokens is a equals range. * @param {Token} a The token * @param {Token} b The token * @returns {boolean} `true` if the given tokens is a equals range. */ function equalsLocation(a, b) { return a.range[0] === b.range[0] && a.range[1] === b.range[1]; } module.exports = { meta: { docs: { description: "enforce consistent HTML indentation.", category: "recommended-with-html", url: "https://ota-meshi.github.io/eslint-plugin-lodash-template/rules/html-indent.html", }, fixable: "whitespace", messages: { unexpectedIndentationCharacter: "Expected {{expected}} character, but found {{actual}} character.", unexpectedIndentation: "Expected indentation of {{expectedIndent}} {{unit}}{{expectedIndentPlural}} but found {{actualIndent}} {{unit}}{{actualIndentPlural}}.", }, schema: [ { anyOf: [{ type: "integer", minimum: 1 }, { enum: ["tab"] }], }, { type: "object", properties: { attribute: { type: "integer", minimum: 0 }, closeBracket: { type: "integer", minimum: 0 }, }, additionalProperties: false, }, ], type: "layout", }, create(context) { const sourceCode = getSourceCode(context); if (!sourceCode.parserServices.getMicroTemplateService) { return {}; } if (!utils.isHtmlFile(context.getFilename())) { return {}; } const microTemplateService = sourceCode.parserServices.getMicroTemplateService(); const options = parseOptions( context.options[0], context.options[1] || {}, ); const offsets = new Map(); const actualLineIndentTexts = new Map(); const ignoreRanges = []; const unit = options.indentChar === "\t" ? "tab" : "space"; /** * Get line text from the given number of line. * @param {number} line The number of line. * @returns {string} The line text . */ function getLineText(line) { return sourceCode.getLines()[line - 1]; } /** * Get the actual indent text of the line which the given line is on. * @param {number} line The line no. * @returns {string} The text of actual indent. */ function getActualLineIndentText(line) { let actualText = actualLineIndentTexts.get(line); if (actualText === undefined) { const lineText = getLineText(line); const index = lineText.search(/\S/u); if (index >= 0) { actualText = lineText.slice(0, index); } else { actualText = lineText; } actualLineIndentTexts.set(line, actualText); } return actualText; } /** * Check whether the given location is a line start location. * @param {Location} loc The location to check. * @returns {boolean} `true` if the location is a line start location. */ function isLineStart(loc) { const actualText = getActualLineIndentText(loc.line); return actualText.length === loc.column; } /** * Set offset to the given location. * @param {Location} loc The start index to set. * @param {number} offset The offset of the tokens. * @param {number} baseline The line of the base offset. * @returns {void} */ function setOffsetToLoc(loc, offset, baseline) { if (baseline >= loc.line) { return; } const actualText = getActualLineIndentText(loc.line); if (actualText.length !== loc.column) { return; } offsets.set(loc.line, { actualText, baseline, offset, expectedIndent: undefined, }); } /** * Set offset to the given index. * @param {number} startIndex The start index to set. * @param {number} offset The offset of the tokens. * @param {number} baseline The line of the base offset. * @returns {void} */ function setOffsetToIndex(startIndex, offset, baseline) { const loc = sourceCode.getLocFromIndex(startIndex); setOffsetToLoc(loc, offset, baseline); } /** * Set offset to the given tokens. * @param {Token|Token[]} token The token to set. * @param {number} offset The offset of the tokens. * @param {number} baseline The line of the base offset. * @returns {void} */ function setOffsetToToken(token, offset, baseline) { if (Array.isArray(token)) { for (const t of token) { setOffsetToToken(t, offset, baseline); } return; } setOffsetToLoc(token.loc.start, offset, baseline); } /** * Set offset to the given line. * @param {number} line The line to set. * @param {number} offset The offset of the tokens. * @param {number} baseline The line of the base offset. * @returns {void} */ function setOffsetToLine(line, offset, baseline) { if (baseline >= line) { return; } const actualText = getActualLineIndentText(line); offsets.set(line, { actualText, baseline, offset, expectedIndent: undefined, }); } /** * Set root line offset to the given index. * @param {number} startIndex The start index to set. * @returns {void} */ function setOffsetRootToIndex(startIndex) { const loc = sourceCode.getLocFromIndex(startIndex); if (!offsets.has(loc.line)) { setOffsetToLoc(loc, 0, -1); } } /** * Calculate correct indentation of the line of the given line. * @param {number} line The number of line. * @returns {number} Correct indentation. If it failed to calculate then `NaN`. */ function getExpectedIndent(line) { const offset = offsets.get(line); if (!offset) { return 0; } if (offset.expectedIndent !== undefined) { return offset.expectedIndent; } const baseIndent = getExpectedIndent(offset.baseline); const expectedIndent = baseIndent + offset.offset * options.indentSize; offset.expectedIndent = expectedIndent; return expectedIndent; } //------------------------------------------------------------------------------ // Process HTML(step 1) //------------------------------------------------------------------------------ /** * Process HTML indentation. * @returns {void} */ function processHTML() { /** * Genetrate an iterator of the index where non whitespace appear * @param {string} text The text to set * @returns {number} The index */ function* genWordStartIndices(text) { if (!text.trim()) { return; } const re = /\s/u; let preIsWhitespace = true; let i = 0; for (const c of text) { const isWhitespace = re.test(c); if (preIsWhitespace && !isWhitespace) { yield i; } preIsWhitespace = isWhitespace; i++; } } /** * Check whether the given token is a dummy element. * @param {Token} node The token to check. * @returns {boolean} `true` if the token is a dummy element. */ function isDummyElement(node) { return ( !node.startTag && (node.name === "body" || node.name === "head" || node.name === "html") ); } /** * Get the start token of Element * @param {Node} elementNode The element node * @returns {Token} The start token */ function getStartToken(elementNode) { if (!elementNode) { return null; } if (elementNode.type !== "HTMLElement") { return null; } if (isDummyElement(elementNode)) { return null; } return elementNode.startTag || elementNode; } microTemplateService.traverseDocumentNodes({ HTMLElement(node) { if (isDummyElement(node)) { return; } const elementNode = microTemplateService.getPathCoveredHtmlNode( node, sourceCode, ) || node; const startToken = getStartToken(elementNode); const parentStartToken = getStartToken(elementNode.parent); if (parentStartToken) { // self indent setOffsetToToken( startToken, 1, parentStartToken.loc.start.line, ); } else { setOffsetRootToIndex(startToken.range[0]); } if ( IGNORE_INDENT_ELEMENT_NAMES.find( (name) => name === elementNode.name, ) ) { ignoreRanges.push([ elementNode.startTag ? elementNode.startTag.range[1] : elementNode.range[0], elementNode.endTag ? elementNode.endTag.range[0] : elementNode.range[1], ]); } }, HTMLStartTag(node) { const attrs = node.attributes.concat( node.ignoredAttributes, ); setOffsetToToken( attrs, options.attribute, node.loc.start.line, ); const start = node.tagOpen ? node.tagOpen.range[1] : node.range[0]; const end = node.tagClose ? node.tagClose.range[0] : node.range[1]; const attrTemplateTokens = microTemplateService .getMicroTemplateTokens() .filter((token) => { if ( token.range[0] < start || end < token.range[1] ) { return false; } return !attrs.some( (n) => token.range[0] < n.range[1] && n.range[0] < token.range[1], ); }); setOffsetToToken( attrTemplateTokens, options.attribute, node.loc.start.line, ); if (node.tagClose) { setOffsetToToken( node.tagClose, options.closeBracket, node.loc.start.line, ); } }, HTMLEndTag(node) { const endTag = microTemplateService.getPathCoveredHtmlNode( node, sourceCode, ) || node; const startToken = getStartToken(endTag.parent); if (startToken) { // self indent setOffsetToToken(endTag, 0, startToken.loc.start.line); } if (endTag.tagClose) { setOffsetToToken( endTag.tagClose, options.closeBracket, endTag.loc.start.line, ); } }, HTMLText(node) { const textHTML = sourceCode.text.slice( node.range[0], node.range[1], ); const textNode = microTemplateService.findToken( microTemplateService.getPathCoveredHtmlDocument( node, sourceCode, ), (n) => n.type === "HTMLText" && n.range[0] >= node.range[0] && node.range[1] <= n.range[1], ) || node; const startToken = getStartToken(textNode.parent); if (startToken) { // self indent for (const i of genWordStartIndices(textHTML)) { setOffsetToIndex( node.range[0] + i, 1, startToken.loc.start.line, ); } } else { for (const i of genWordStartIndices(textHTML)) { setOffsetRootToIndex(node.range[0] + i); } } }, HTMLComment(node) { const commentNode = microTemplateService.getPathCoveredHtmlNode( node, sourceCode, ) || node; const startToken = getStartToken(commentNode.parent); if (startToken) { // self indent setOffsetToToken( commentNode, 1, startToken.loc.start.line, ); } else { setOffsetRootToIndex(commentNode.range[0]); } const commentStart = commentNode.range[0] + 4; const commentEnd = commentNode.range[1] - 3; const comment = sourceCode.text.slice( commentStart, commentEnd, ); for (const i of genWordStartIndices(comment)) { setOffsetToIndex( commentStart + i, 1, commentNode.loc.start.line, ); } setOffsetToIndex(commentEnd, 0, commentNode.loc.start.line); }, }); } //------------------------------------------------------------------------------ // Process micro-template evaluates(step 2) //------------------------------------------------------------------------------ /** * Process micro-template evaluates indentation. * @returns {void} */ function processEvaluates() { const evaluates = microTemplateService .getMicroTemplateTokens() .filter((t) => t.type === "MicroTemplateEvaluate") .sort((a, b) => a.range[0] - b.range[0]); /** * Find indentation-start punctuator token, within micro-template evaluate. * @param {Node} evaluate The micro-template evaluate to set * @returns {Token} The indentation-start punctuator token. */ function findIndentationStartPunctuator(evaluate) { const searchIndex = evaluate.code.search(/(\S)\s*$/u); if (searchIndex < 0) { return null; } const charIndex = evaluate.expressionStart.range[1] + searchIndex; const node = sourceCode.getNodeByRangeIndex(charIndex); if (!node) { // comment only return null; } const tokens = sourceCode .getTokens(node) .filter( (t) => t.range[0] <= evaluate.range[1] && t.range[1] >= evaluate.range[0], ); let targetToken = tokens.find( (t) => t.range[0] <= charIndex && charIndex < t.range[1], ); if (!targetToken) { targetToken = tokens .reverse() .find((t) => t.range[1] <= charIndex); } let token = targetToken; while (token) { if ( token.range[0] > evaluate.range[1] || token.range[1] < evaluate.range[0] ) { return null; } if (isIndentationStartPunctuator(token)) { return token; } if (isIndentationEndPunctuator(token)) { // skip const next = findPairOpenPunctuator(token); token = sourceCode.getTokenBefore(next); continue; } token = sourceCode.getTokenBefore(token); } return null; } /** * Find pair open punctuator token. * @param {Node} closeToken The close punctuator token * @returns {Token} The indentation-start punctuator token. */ function findPairOpenPunctuator(closeToken) { const closePunctuatorText = closeToken.value; const isPairOpenPunctuator = closePunctuatorText === ")" ? isLeftParen : closePunctuatorText === "}" ? isLeftBrace : isLeftBracket; let token = sourceCode.getTokenBefore(closeToken); while (token) { if (isPairOpenPunctuator(token)) { return token; } if (isIndentationEndPunctuator(token)) { // skip const next = findPairOpenPunctuator(token); token = sourceCode.getTokenBefore(next); continue; } token = sourceCode.getTokenBefore(token); } return null; } /** * Find pair close punctuator token. * @param {Node} openToken The open punctuator token * @returns {Token} The indentation-end punctuator token. */ function findPairClosePunctuator(openToken) { const openPunctuatorText = openToken.value; const isPairClosePunctuator = openPunctuatorText === "(" ? isRightParen : openPunctuatorText === "{" ? isRightBrace : isRightBracket; let token = sourceCode.getTokenAfter(openToken); while (token) { if (isPairClosePunctuator(token)) { return token; } if (isIndentationStartPunctuator(token)) { // skip const next = findPairClosePunctuator(token); token = sourceCode.getTokenAfter(next); continue; } token = sourceCode.getTokenAfter(token); } return null; } /** * Set offset to the given range line blick. * @param {number} baseAndStartLine The start line no to set. * @param {number} endLine The end line no to set. * @returns {void} */ function setOffsetToRangeLines(baseAndStartLine, endLine) { const baseLine = baseAndStartLine; for (let lineNo = baseLine + 1; lineNo <= endLine; lineNo++) { const offset = offsets.get(lineNo); if (offset) { if (offset.baseline <= baseLine) { offset.baseline = baseLine; offset.offset = 1; } } else { setOffsetToLine(lineNo, 1, baseLine); } } } /** * Set offset to the given range line blick. * @param {number} baseAndStartLine The start line no to set. * @param {number} endLine The end line no to set. * @param {ASTNode} switchStatementNode The node of SwitchStatement. * @returns {void} */ function setOffsetToRangeLinesForSwitch( baseAndStartLine, endLine, switchStatementNode, ) { /** * Find evaluate token of SwitchCase body from the given line. * @param {number} line The line no to set. * @returns {Token} The evaluate token of SwitchCase. */ function findSwitchCaseBodyInfoAtLineStart(line) { const index = sourceCode.getIndexFromLoc({ line, column: 0, }); let switchCaseNode = sourceCode.getNodeByRangeIndex(index); if ( !switchCaseNode || switchCaseNode.type === "SwitchStatement" ) { switchCaseNode = switchStatementNode.cases.find( (_token, i) => { const next = switchStatementNode.cases[i + 1]; if (!next || index < next.range[0]) { return true; } return false; }, ); } if ( !switchCaseNode || switchCaseNode.type !== "SwitchCase" ) { // not SwitchCase return null; } if (switchCaseNode.test) { if (index < switchCaseNode.test.range[1]) { // not body return null; } } else { // default: const fToken = sourceCode.getFirstToken(switchCaseNode); const colon = sourceCode.getTokenAfter( fToken, (t) => t.type === "Punctuator" && t.value === ":", ); if (index < colon.range[1]) { // not body return null; } } if ( !equalsLocation( switchCaseNode.parent, switchStatementNode, ) ) { // not target return null; } const casesIndex = switchStatementNode.cases.findIndex( (c) => equalsLocation(c, switchCaseNode), ); const evaluate = evaluates.find( (e) => e.range[0] <= switchCaseNode.range[0] && switchCaseNode.range[0] < e.range[1], ); return { evaluate, casesIndex, node: switchCaseNode, }; } const switchStatementLine = baseAndStartLine; // const switchStatementLineOffset = getBaseLineOffset( // switchStatementLine // ) for ( let lineNo = baseAndStartLine + 1; lineNo <= endLine; lineNo++ ) { const switchCaseInfo = findSwitchCaseBodyInfoAtLineStart(lineNo); if (!switchCaseInfo) { continue; } const baseLine = switchCaseInfo.evaluate.loc.start.line; const offset = offsets.get(lineNo); if (offset) { if (offset.baseline <= baseLine) { offset.baseline = switchStatementLine; offset.offset = 1; } } else { setOffsetToLine(lineNo, 1, switchStatementLine); } } } const closeEvaluateOffsets = new Map(); for (const evaluate of evaluates) { const leftToken = findIndentationStartPunctuator(evaluate); if (!leftToken) { continue; } const rightToken = findPairClosePunctuator(leftToken); if (!rightToken) { continue; } const closeEvaluate = evaluates.find( (e) => e.range[0] <= rightToken.range[0] && rightToken.range[0] < e.range[1], ); if (!closeEvaluate) { continue; } const closingStartIsBaseIndent = isLineStart(closeEvaluate.loc.start) && closeEvaluate.loc.start.line === rightToken.loc.start.line; const endLine = closingStartIsBaseIndent ? closeEvaluate.loc.start.line - 1 : closeEvaluate.loc.start.line; setOffsetToRangeLines(leftToken.loc.start.line, endLine); const startAstNode = sourceCode.getNodeByRangeIndex( leftToken.range[0], ); const isSwitchStatement = startAstNode && startAstNode.type === "SwitchStatement"; if (isSwitchStatement) { setOffsetToRangeLinesForSwitch( leftToken.loc.start.line, endLine, startAstNode, ); } closeEvaluateOffsets.set(closeEvaluate, { baseline: leftToken.loc.start.line, startLine: endLine + 1, }); } // 最後にscript閉じ括弧位置の調整 closeEvaluateOffsets.forEach((info, closeEvaluate) => { const baseline = info.baseline; // 実際は開始位置しかインデントされない。 // 後続で正しいインデント位置を知るためにマークをつけている。 for ( let line = info.startLine; line <= closeEvaluate.loc.end.line; line++ ) { setOffsetToLine(line, 0, baseline); } }); } //------------------------------------------------------------------------------ // Validate(step 3) //------------------------------------------------------------------------------ /** * Validate indentation. * @returns {void} */ function validateIndent() { /** * Check whether the line start is in template tag. * @param {number} line The line. * @returns {boolean} `true` if the line start is in template tag. */ function inTemplateTag(line) { if (line <= 1) { return false; } const lineStartIndex = sourceCode.getIndexFromLoc({ line, column: 0, }); return microTemplateService.inTemplateTag(lineStartIndex - 1); } /** * Check whether the line start is in ignore range. * @param {number} line The line. * @returns {boolean} `true` if the line start is in ignore range. */ function inIgnore(line) { const index = sourceCode.getIndexFromLoc({ line, column: 0, }); return ignoreRanges.find( (range) => range[0] <= index && index < range[1], ); } /** * Define the function which fixes the problem. * @param {number} line The number of line. * @param {number} actualIndent The number of actual indentation. * @param {number} expectedIndent The number of expected indentation. * @returns {function} The defined function. */ function defineFix(line, actualIndent, expectedIndent) { return (fixer) => { const start = sourceCode.getIndexFromLoc({ line, column: 0, }); const indent = options.indentChar.repeat(expectedIndent); return fixer.replaceTextRange( [start, start + actualIndent], indent, ); }; } offsets.forEach((value, line) => { if (inTemplateTag(line)) { return; } if (inIgnore(line)) { return; } if (!getLineText(line).trim()) { // empty line return; } const actualText = value.actualText; const actualIndent = actualText.length; const expectedIndent = getExpectedIndent(line); for (let i = 0; i < actualText.length; ++i) { if (actualText[i] !== options.indentChar) { context.report({ loc: { start: { line, column: i }, end: { line, column: i + 1 }, }, messageId: "unexpectedIndentationCharacter", data: { expected: JSON.stringify(options.indentChar), actual: JSON.stringify(actualText[i]), }, fix: defineFix(line, actualIndent, expectedIndent), }); return; } } if (actualIndent !== expectedIndent) { context.report({ loc: { start: { line, column: 0 }, end: { line, column: actualIndent }, }, messageId: "unexpectedIndentation", data: { expectedIndent, actualIndent, unit, expectedIndentPlural: expectedIndent === 1 ? "" : "s", actualIndentPlural: actualIndent === 1 ? "" : "s", }, fix: defineFix(line, actualIndent, expectedIndent), }); } }); } return { "Program:exit"() { processHTML(); processEvaluates(); validateIndent(); }, }; }, };