@yusufkandemir/eslint-plugin-lodash-template
Version:
ESLint plugin for John Resig-style micro template, Lodash's template, Underscore's template and EJS.
648 lines (597 loc) • 19.5 kB
JavaScript
"use strict";
const EventEmitter = require("events");
const esquery = interop(require("esquery"));
const parseHtml = require("./html-parser");
const Traverser = require("./traverser");
const commentDirective = require("./comment-directive");
const PathCoveredTemplateStore = require("./path-covered-template-store");
// eslint-disable-next-line jsdoc/require-jsdoc -- ignore
function interop(obj) {
if (obj && obj.__esModule) {
return obj.default;
}
return obj;
}
/**
* Parses a raw selector string, and throws a useful error if parsing fails.
* @param {string} rawSelector A raw AST selector
* @returns {Selector} An object (from esquery) describing the matching behavior of this selector
* @throws An error if the selector is invalid
*/
function tryParseSelector(rawSelector) {
try {
return esquery.parse(rawSelector.replace(/:exit$/u, ""));
} catch (err) {
if (typeof err.offset === "number") {
throw new Error(
`Syntax error in selector "${rawSelector}" at position ${err.offset}: ${err.message}`,
);
}
throw err;
}
}
/**
* Parses a raw selector string, and returns the parsed selector along with specificity and type information.
* @param {string} rawSelector A raw AST selector
* @returns {ASTSelector} A selector descriptor
*/
function parseSelector(rawSelector) {
const parsedSelector = tryParseSelector(rawSelector);
return {
rawSelector,
isExit: rawSelector.endsWith(":exit"),
parsedSelector,
};
}
/**
* NodeEventGenerator
*/
class NodeEventGenerator {
/**
* constructor
* @param {object} visitor The visitor.
* @returns {NodeEventGenerator} NodeEventGenerator.
*/
constructor(visitor) {
const emitter = (this.emitter = new EventEmitter());
this.currentAncestry = [];
this.anyTypeEnterSelectors = [];
this.anyTypeExitSelectors = [];
for (const rawSelector of Object.keys(visitor)) {
if (typeof rawSelector === "symbol") {
continue;
}
emitter.on(rawSelector, visitor[rawSelector]);
const selector = parseSelector(rawSelector);
(selector.isExit
? this.anyTypeExitSelectors
: this.anyTypeEnterSelectors
).push(selector);
}
}
/**
* Checks a selector against a node, and emits it if it matches
* @param {Token} node The node to check
* @param {Selector} selector An AST selector descriptor
* @returns {void}
*/
applySelector(node, selector) {
if (
esquery.matches(node, selector.parsedSelector, this.currentAncestry)
) {
this.emitter.emit(selector.rawSelector, node);
}
}
/**
* Applies all appropriate selectors to a node, in specificity order
* @param {Token} node The node to check
* @param {boolean} isExit `false` if the node is currently being entered, `true` if it's currently being exited
* @returns {void}
*/
applySelectors(node, isExit) {
const anyTypeSelectors = isExit
? this.anyTypeExitSelectors
: this.anyTypeEnterSelectors;
let anyTypeSelectorsIndex = 0;
while (anyTypeSelectorsIndex < anyTypeSelectors.length) {
this.applySelector(node, anyTypeSelectors[anyTypeSelectorsIndex++]);
}
}
/**
* Emits an event of entering AST node.
* @param {Token} node - A node which was entered.
* @returns {void}
*/
enterNode(node) {
if (node.parent) {
this.currentAncestry.unshift(node.parent);
}
this.applySelectors(node, false);
}
/**
* Emits an event of leaving AST node.
* @param {Token} node - A node which was left.
* @returns {void}
*/
leaveNode(node) {
this.applySelectors(node, true);
this.currentAncestry.shift();
}
}
/**
* Traverse the given tokens.
* @param {Token|Token[]} tokens tokens.
* @param {object} options The option object.
* @returns {void}
*/
function traverse(tokens, options) {
const traverser = new Traverser();
const nodes = Array.isArray(tokens) ? tokens : [tokens];
for (const node of nodes) {
traverser.traverse(node, options);
if (traverser.isBroken()) {
return;
}
}
}
/**
* Traverse the given AST tree.
* @param {object} nodes Root node to traverse.
* @param {object} visitor Visitor.
* @returns {void}
*/
function traverseNodes(nodes, visitor) {
const ne = new NodeEventGenerator(visitor);
traverse(nodes, {
enter(child) {
ne.enterNode(child);
},
leave(child) {
ne.leaveNode(child);
},
});
}
/**
* Find for the token that hit on the given test.
* @param {object} node Root node to traverse.
* @param {function} test test.
* @returns {Token|null} The token that hit on the given test
*/
function findToken(node, test) {
let find = null;
traverse(node, {
enter(child) {
if (test(child)) {
find = child;
this.break();
}
},
});
return find;
}
/**
* Check whether the location is in range location.
* @param {object} loc The location.
* @param {object} start The start location.
* @param {object} end The end location.
* @returns {boolean} `true` if the location is in range location.
*/
function locationInRangeLoc(loc, start, end) {
if (loc.line < start.line || end.line < loc.line) {
return false;
}
if (loc.line === start.line) {
if (start.column > loc.column) {
return false;
}
}
if (loc.line === end.line) {
if (loc.column >= end.column) {
return false;
}
}
return true;
}
/**
* Check whether the location is in token.
* @param {object|number} loc The location or index.
* @param {object} token The token.
* @returns {boolean} `true` if the location is in token.
*/
function inToken(loc, token) {
if (typeof loc === "number") {
return token.range[0] <= loc && loc < token.range[1];
}
return locationInRangeLoc(loc, token.loc.start, token.loc.end);
}
/**
* The traverser class to traverse cache tokens.
* @param {object} tokens The tokens.
*/
class FlattenTraverser {
/**
* constructor
* @param {object} tokens The tokens.
*/
constructor(tokens) {
if (Array.isArray(tokens)) {
this.tokens = tokens;
} else {
this.tokens = [tokens];
}
}
/**
* Traverse the given tokens.
* @param {object} visitor Visitor.
* @returns {void}
*/
traverseTokens(visitor) {
const ne = new NodeEventGenerator(visitor);
if (this._flatten) {
for (const n of this._flatten) {
ne[n.name](n.node);
}
return;
}
const flatten = (this._flatten = []);
traverse(this.tokens, {
enter(child) {
ne.enterNode(child);
flatten.push({
name: "enterNode",
node: child,
});
},
leave(child) {
ne.leaveNode(child);
flatten.push({
name: "leaveNode",
node: child,
});
},
});
}
}
/**
* The parser service
* @param {object} options The constructor option.
*/
class MicroTemplateService {
/**
* constructor
* @param {object} options The constructor option.
*/
constructor(options) {
this.sourceCodeText = options.code;
this.template = options.template;
this.script = options.script;
this._microTemplateTokens = options.microTemplateTokens;
this._sourceCodeStore = options.sourceCodeStore;
this._ast = options.ast;
}
/**
* Get the code text.
* @returns {string} The code text.
*/
get text() {
return this.sourceCodeText;
}
/**
* Converts a source text index into a (line, column) pair.
* @param {number} index The index of a character in a file
* @returns {object} A {line, column} location object with a 0-indexed column
*/
getLocFromIndex(index) {
return this._sourceCodeStore.getLocFromIndex(index);
}
/**
* Converts a (line, column) pair into a range index.
* @param {Object} loc A line/column location
* @param {number} loc.line The line number of the location (1-indexed)
* @param {number} loc.column The column number of the location (0-indexed)
* @returns {number} The range index of the location in the file.
* @public
*/
getIndexFromLoc(loc) {
return this._sourceCodeStore.getIndexFromLoc(loc);
}
/**
* Get the micro-template tokens.
* @returns {Array} The micro-template tokens.
*/
getMicroTemplateTokens() {
return this._microTemplateTokens;
}
/**
* Get the html ast.
* @returns {object} The html ast.
*/
getDocument() {
return this._doc || (this._doc = this.parseHtml(this.template));
}
/**
* Parse the given html.
* @param {string} html The html source code to parse.
* @returns {object} The parsing result.
*/
parseHtml(html) {
return parseHtml(html, this._sourceCodeStore);
}
/**
* Traverse the document tree.
* @param {object} visitor Visitor.
* @returns {void}
*/
traverseDocumentNodes(visitor) {
if (!this._documentFlattenTraverser) {
this._documentFlattenTraverser = new FlattenTraverser(
this.getDocument(),
);
}
this._documentFlattenTraverser.traverseTokens(visitor);
}
/**
* Traverse the micro-template tokens.
* @param {object} visitor Visitor.
* @returns {void}
*/
traverseMicroTemplates(visitor) {
if (!this._microTemplateFlattenTraverser) {
this._microTemplateFlattenTraverser = new FlattenTraverser(
this.getMicroTemplateTokens(),
);
}
this._microTemplateFlattenTraverser.traverseTokens(visitor);
}
/**
* Traverse the given tokens.
* @param {Token|Token[]} tokens tokens.
* @param {object} visitor Visitor.
* @returns {void}
*/
traverseTokens(tokens, visitor) {
traverseNodes(tokens, visitor);
}
/**
* Find for the token that hit on the given test.
* @param {Token|Token[]} tokens tokens.
* @param {function} test test.
* @returns {Token|null} The token that hit on the given test
*/
findToken(tokens, test) {
return findToken(tokens, test);
}
/**
* Get the template tag token containing a range index.
* @param {number} index Range index of the desired node.
* @returns {Token} The token if found or null if not found.
*/
getTemplateTagByRangeIndex(index) {
return this.getMicroTemplateTokens().find(
(t) => t.range[0] <= index && index < t.range[1],
);
}
/**
* Check whether the location is in template.
* @param {object|index} loc The location or index.
* @returns {boolean} `true` if the location is in template.
*/
inTemplate(loc) {
return !this.inTemplateTag(loc);
}
/**
* Check whether the location is in template tag.
* @param {object|index|[number, number]} loc The location or index.
* @returns {boolean} `true` if the location is in template tag.
*/
inTemplateTag(loc) {
for (const token of this.getMicroTemplateTokens()) {
if (inToken(loc, token)) {
return true;
}
}
return false;
}
/**
* Check whether the location is in interpolate or escape.
* @param {object|index} loc The location or index.
* @returns {boolean} `true` if the location is in interpolate or escape.
*/
inInterpolateOrEscape(loc) {
for (const token of this.getMicroTemplateTokens().filter(
(t) =>
t.type === "MicroTemplateEscape" ||
t.type === "MicroTemplateInterpolate",
)) {
if (inToken(loc, token)) {
return true;
}
}
return false;
}
/**
* Check whether the location is in delimiter marks.
* @param {object|index} loc The location or index.
* @returns {boolean} `true` if the location is in delimiter marks.
*/
inDelimiterMarks(loc) {
for (const token of this.getMicroTemplateTokens()) {
for (const delimiter of [
token.expressionStart,
token.expressionEnd,
]) {
if (inToken(loc, delimiter)) {
return true;
}
}
}
return false;
}
/**
* Check whether the message is disable rule.
* @param {object} message The message.
* @returns {boolean} `true` if the message is disable rule.
*/
isDisableMessageForHtml(message) {
if (!this._htmlCommentDirectiveContext) {
this._htmlCommentDirectiveContext =
commentDirective.createHtmlCommentDirectiveContext(
!this._doc,
this.template,
this,
);
}
return this._htmlCommentDirectiveContext.isDisableMessage(message);
}
/**
* Check whether the message is disable rule.
* @param {object} message The message.
* @returns {boolean} `true` if the message is disable rule.
*/
isDisableMessageForEjs(message) {
if (!this._ejsCommentDirectiveContext) {
this._ejsCommentDirectiveContext =
commentDirective.createEjsCommentDirectiveContext(this);
}
return this._ejsCommentDirectiveContext.isDisableMessage(message);
}
/**
* Get HTML document of holds the path of the targetNode
* @param {ASTNode} targetNode The target node.
* @param {SourceCode} sourceCode The source code.
* @returns {PathCoveredHTML} HTML document of holds the path of the targetNode
*/
getPathCoveredHtmlDocument(targetNode, sourceCode) {
if (!this._pathCoveredTemplateStore) {
this._pathCoveredTemplateStore = new PathCoveredTemplateStore(
sourceCode.ast,
sourceCode.visitorKeys,
this,
);
}
if (!this._pathCoveredTemplateStore.hasBranchStatements()) {
return this.getDocument();
}
const pathCoveredTemplate =
this._pathCoveredTemplateStore.getPathCoveredTemplate(
targetNode.range[0],
);
if (!pathCoveredTemplate.doc) {
pathCoveredTemplate.doc = this.parseHtml(
pathCoveredTemplate.template,
);
}
return pathCoveredTemplate.doc;
}
/**
* Get a Node that matches the targetNode from the HTML document of holds the path of the targetNode.
* @param {ASTNode} targetNode The target node.
* @param {SourceCode} sourceCode The source code.
* @returns {PathCoveredHTML} Node that matches the targetNode from the HTML document of holds the path of the targetNode.
*/
getPathCoveredHtmlNode(targetNode, sourceCode) {
const doc = this.getPathCoveredHtmlDocument(targetNode, sourceCode);
return this.findToken(
doc,
(n) =>
n.type === targetNode.type &&
n.range[0] === targetNode.range[0] &&
n.range[1] === targetNode.range[1],
);
}
/**
* Gets all script tokens that are contained to the micro-template given node.
* @param {Node} node - The micro-template node.
* @returns {object} The tokens information.
*/
getMicroTemplateTokensInfo(node) {
const scriptInfo = node._scriptInfo || (node._scriptInfo = {});
if (scriptInfo.tokens) {
return scriptInfo.tokens;
}
const open = node.expressionStart;
const close = node.expressionEnd;
const innerTokens = this.getTokens(open.range[1], close.range[0]);
const tokens = this.getTokens(node.range[0], node.range[1]);
scriptInfo.tokens = {
innerTokens,
tokens,
};
return scriptInfo.tokens;
}
/**
* Get the ExpressionStatement of the given micro-template node.
* @param {Node} node - The micro-template node.
* @param {SourceCode} sourceCode The source code.
* @returns {ExpressionStatement|null} The expression statement.
*/
getMicroTemplateExpressionStatement(node, sourceCode) {
const scriptInfo = node._scriptInfo || (node._scriptInfo = {});
if (scriptInfo.statement !== undefined) {
return scriptInfo.statement;
}
/**
* Find the ExpressionStatement of range.
* @param {number} start - The start of range.
* @param {number} end - The end of range.
* @returns {ExpressionStatement} The expression statement.
*/
function findExpressionStatement(start, end) {
let target = sourceCode.getNodeByRangeIndex(start);
while (
target &&
target.range[1] <= end &&
start <= target.range[0]
) {
if (
target.type === "ExpressionStatement" &&
start === target.range[0] &&
target.range[1] === end
) {
return target;
}
target = target.parent;
}
return null;
}
const tokenInfo = this.getMicroTemplateTokensInfo(node);
const tokens = tokenInfo.tokens;
const first = tokens[0];
const last = tokens[tokens.length - 1];
scriptInfo.statement =
findExpressionStatement(first.range[0], last.range[1]) || null;
if (!scriptInfo.statement) {
const innerTokens = tokenInfo.innerTokens;
const innerFirst = innerTokens[0];
const innerLast = innerTokens[innerTokens.length - 1];
if (last.value === ";" && innerLast.value === ";") {
scriptInfo.statement =
findExpressionStatement(
innerFirst.range[0],
innerLast.range[1],
) || null;
}
}
return scriptInfo.statement;
}
/**
* Gets all tokens that are contained to the given range.
* @param {number} start - The start of range.
* @param {number} end - The end of range.
* @returns {Token[]} Array of objects representing tokens.
*/
getTokens(start, end) {
const results = [];
for (const token of this._ast.tokens) {
if (token.range[1] <= start) {
continue;
}
if (end <= token.range[0]) {
break;
}
results.push(token);
}
return results;
}
}
module.exports = MicroTemplateService;