ember-template-lint
Version:
Linter for Ember or Handlebars templates.
587 lines (507 loc) • 19.7 kB
JavaScript
'use strict';
const DEPRECATED_RULES = require('../helpers/deprecated-rules');
const Scope = require('./internal/scope');
// Constant to return from AST handlers so we know to remove/unregister them
const REMOVE_HANDLER = {};
const MODULE_NAME = Symbol('_moduleName');
const loggedModules = new Set([]);
// If we need to log an error or warning about an instruction node (e.g. syntax
// error), we want to only log it once, rather than having every rule log it,
// spamming the user. So the rules push any nodes with syntax errors into this
// array so only the first one will log the error, and subsequent rules will
// see it already in the array and not log it.
const loggedNodes = [];
const reLines = /(.*?(?:\r\n?|\n|$))/gm;
const reWhitespace = /\s+/;
const reTree = /^(.*)-tree$/;
const reToggleRule = /^template-lint-(enable|disable)$/;
function last(array) {
return array[array.length - 1];
}
function unquote(str) {
if (str.length < 3) {
return str;
}
// Allow single or double quotes
let firstLetter = str[0];
let lastLetter = str[str.length - 1];
if (firstLetter === lastLetter && ['"', "'"].includes(firstLetter)) {
return str.slice(1, -1);
}
return str;
}
module.exports = class Base {
constructor(options) {
this.ruleName = options.name;
this._console = options.console || console;
this._configResolver = options.configResolver;
this._ruleNames = options.ruleNames;
this._allowInlineConfig =
typeof options.allowInlineConfig === 'boolean' ? options.allowInlineConfig : true;
this.severity = options.defaultSeverity;
this.results = [];
this.mode = options.shouldFix ? 'fix' : 'report';
// To support DOM-scoped configuration instructions, we need to maintain
// a stack of our configurations so we can restore the previous one when
// the current one goes out of scope. The current one is duplicated in
// this.config so the rule implementations don't need to worry about the
// fact that there is a stack.
this.config = this.parseConfig(options.config);
this._configStack = [this.config];
this._filePath = options.filePath;
this[MODULE_NAME] = options.moduleName;
this._rawSource = options.rawSource;
// split into a source array (allow windows and posix line endings)
this.source = options.rawSource.match(reLines);
}
get editorConfig() {
return this._configResolver.editorConfig();
}
get _moduleName() {
if (!loggedModules.has(this.ruleName)) {
this._console.log(
`The \`_moduleName\` property used in the '${this.ruleName}' rule is deprecated. Please use \`_filePath\` instead.`
);
loggedModules.add(this.ruleName);
}
return this[MODULE_NAME];
}
// The `name !== this.ruleName` check isn't strictly necessary, but allows
// unit tests to register test rules
_isRuleName(name) {
return this._ruleNames.includes(name) || name === this.ruleName || DEPRECATED_RULES.has(name);
}
_refersToCurrentRule(name) {
return name === this.ruleName || DEPRECATED_RULES.get(name) === this.ruleName;
}
parseConfig(config) {
return config;
}
getVisitor(env) {
let pluginContext = this; // eslint-disable-line unicorn/no-this-assignment
let visitor = {};
let ruleVisitor = this.visitor(env);
// We use this structure to advise/unadvise on AST events. The walkers we
// set up read this structure every time they get an AST event and call the
// appropriate functions. Handlers get added to the before/after list
// according to whether they will be called before/after the rule's AST
// event handlers.
let astHandlers = {
Template: {
enter: { before: [], after: [] },
exit: { before: [], after: [] },
},
Block: {
enter: { before: [], after: [] },
exit: { before: [], after: [] },
},
CommentStatement: {
enter: { before: [], after: [] },
exit: { before: [], after: [] },
},
MustacheCommentStatement: {
enter: { before: [], after: [] },
exit: { before: [], after: [] },
},
BlockStatement: {
enter: { before: [], after: [] },
exit: { before: [], after: [] },
},
PathExpression: {
enter: { before: [], after: [] },
exit: { before: [], after: [] },
},
ElementNode: {
enter: { before: [], after: [] },
exit: { before: [], after: [] },
keys: {
children: {
enter: { before: [], after: [] },
exit: { before: [], after: [] },
},
comments: {
enter: { before: [], after: [] },
exit: { before: [], after: [] },
},
},
},
};
// Keep a stack of ancestor elements so that when we encounter a comment
// that is the child of an element (i.e. not within the element's tag)
// we know what it's parent is so we can set up a listener for when we
// leave the comment's siblings.
let scope = (this.scope = new Scope());
// We don't traverse in-element comments until after we've entered the
// element, but we need to apply such instructions before we enter the
// element. So, we have an element enter handler that processes that
// element's comments, which means we need to distinguish in-element
// comments from child-of-element comments during the traverse so we can
// ignore the former, but still process the latter.
let inElementComments = false;
//
// Wrap the visitor handlers supplied by the rule, plus make sure we have entries
// for the handlers we use internally for instruction processing. This is
// done for two reasons -- one is to support instruction processing, but
// the other is to allow the rule's visitor handlers to have their this
// context set to the plugin, which isn't what HTMLBars does.
//
for (let key in ruleVisitor) {
visitor[key] = this._wrapVisitor(ruleVisitor[key], astHandlers[key]);
}
visitor.Template = visitor.Template || this._wrapVisitor(null, astHandlers.Template);
visitor.Block = visitor.Block || this._wrapVisitor(null, astHandlers.Block);
visitor.CommentStatement =
visitor.CommentStatement || this._wrapVisitor(null, astHandlers.CommentStatement);
visitor.MustacheCommentStatement =
visitor.MustacheCommentStatement ||
this._wrapVisitor(null, astHandlers.MustacheCommentStatement);
visitor.PathExpression =
visitor.PathExpression || this._wrapVisitor(null, astHandlers.PathExpression);
visitor.BlockStatement =
visitor.BlockStatement || this._wrapVisitor(null, astHandlers.BlockStatement);
visitor.ElementNode = visitor.ElementNode || this._wrapVisitor(null, astHandlers.ElementNode);
//
// Set up our handlers
//
// Manage scope
function pushFrame(node) {
scope.pushFrame(node);
}
function popFrame() {
scope.popFrame();
}
function localVariablesCatcher(node) {
scope.useLocal(node);
}
astHandlers.PathExpression.enter.before.push(localVariablesCatcher);
astHandlers.Template.enter.before.push(pushFrame);
astHandlers.Template.exit.after.push(popFrame);
astHandlers.Block.enter.before.push(pushFrame);
astHandlers.Block.exit.after.push(popFrame);
astHandlers.ElementNode.keys.children.enter.before.push(pushFrame, localVariablesCatcher);
astHandlers.ElementNode.keys.children.exit.after.push(popFrame);
// Manage inElementComments
astHandlers.ElementNode.keys.comments.enter.before.push(function () {
inElementComments = true;
});
astHandlers.ElementNode.keys.comments.exit.after.push(function () {
if (!inElementComments) {
throw new Error('Element comments state out of sync with AST!');
}
inElementComments = false;
});
// Handle element-child config statements
astHandlers.MustacheCommentStatement.enter.after.push(function (commentNode) {
if (inElementComments) {
return;
}
let config = pluginContext._processInstructionNode(commentNode);
if (!config) {
return;
}
pluginContext._pushConfig(config.value);
// Pop the config once we exit the comment's siblings
let parentNode = scope.currentNode;
astHandlers.ElementNode.keys.children.exit.before.unshift(function (elementNode) {
if (elementNode === parentNode) {
pluginContext._popConfig(config.value);
return REMOVE_HANDLER;
}
});
});
// Handle in-element config statements
astHandlers.ElementNode.enter.before.push(function (elementNode) {
for (const commentNode of elementNode.comments) {
let config = pluginContext._processInstructionNode(commentNode);
if (!config) {
continue;
}
pluginContext._pushConfig(config.value);
if (config.tree) {
// Applies to descendants, so pop the config once we exit the
// comment's element
astHandlers.ElementNode.exit.after.unshift(function (node) {
if (node === elementNode) {
pluginContext._popConfig(config.value);
return REMOVE_HANDLER;
}
});
} else {
// Applies to only this element, so pop the config when we enter the
// element's children, re-push it when we exit the element's
// children, and then re-pop it when we exit the element entirely.
// This is so that if the rule is listening for element exit events
// for some reason, the configuration will be applied when they fire,
// like it is applied when the enter events fire (but not applied when
// entering/exiting or traversing the children).
astHandlers.ElementNode.keys.children.enter.before.unshift(function (node) {
if (node === elementNode) {
pluginContext._popConfig(config.value);
return REMOVE_HANDLER;
}
});
astHandlers.ElementNode.keys.children.exit.after.push(function (node) {
if (node === elementNode) {
pluginContext._pushConfig(config.value);
return REMOVE_HANDLER;
}
});
astHandlers.ElementNode.exit.after.unshift(function (node) {
if (node === elementNode) {
pluginContext._popConfig(config.value);
return REMOVE_HANDLER;
}
});
}
}
});
return visitor;
}
visitor() {}
// Generate a visitor handler function for a specific node type/event that
// will call the internal handlers and the rule handler for that node
// type/event in the correct order.
_wrapVisitorHandler(ruleHandler, internalHandlers) {
internalHandlers = internalHandlers || {};
let beforeHandlers = internalHandlers.before || [];
let afterHandlers = internalHandlers.after || [];
let plugin = this; // eslint-disable-line unicorn/no-this-assignment
let i, ret;
return function () {
for (i = 0; i < beforeHandlers.length; i++) {
ret = beforeHandlers[i].apply(plugin, arguments); // eslint-disable-line prefer-rest-params
if (ret === REMOVE_HANDLER) {
beforeHandlers.splice(i, 1);
i -= 1;
}
}
if (ruleHandler && !plugin.isDisabled()) {
ruleHandler.apply(plugin, arguments); // eslint-disable-line prefer-rest-params
}
for (i = 0; i < afterHandlers.length; i++) {
ret = afterHandlers[i].apply(plugin, arguments); // eslint-disable-line prefer-rest-params
if (ret === REMOVE_HANDLER) {
afterHandlers.splice(i, 1);
i -= 1;
}
}
};
}
// Give the rule's visitor for a given node type, and our internal
// astHandlers registry for that node type, generate a visitor that will
// call all the various event handlers in the proper order
_wrapVisitor(ruleVisitor, astHandlers) {
if (typeof ruleVisitor === 'function') {
ruleVisitor = { enter: ruleVisitor };
}
ruleVisitor = ruleVisitor || {};
astHandlers = astHandlers || {};
let visitorKeys = ruleVisitor.keys || {};
let visitorChildren = visitorKeys.children || {};
let visitorComments = visitorKeys.comments || {};
let internalKeys = astHandlers.keys || {};
let internalChildren = internalKeys.children || {};
let internalComments = internalKeys.comments || {};
return {
enter: this._wrapVisitorHandler(ruleVisitor.enter, astHandlers.enter),
exit: this._wrapVisitorHandler(ruleVisitor.exit, astHandlers.exit),
keys: {
children: {
enter: this._wrapVisitorHandler(visitorChildren.enter, internalChildren.enter),
exit: this._wrapVisitorHandler(visitorChildren.exit, internalChildren.exit),
},
comments: {
enter: this._wrapVisitorHandler(visitorComments.enter, internalComments.enter),
exit: this._wrapVisitorHandler(visitorComments.exit, internalComments.exit),
},
},
};
}
_pushConfig(config) {
this._configStack.push(config);
this.config = config;
}
_popConfig(config) {
if (last(this._configStack) !== config) {
throw new Error('Configuration stack out of sync with AST!');
}
this._configStack.pop();
this.config = last(this._configStack);
}
// eslint-disable-next-line complexity
_processInstructionNode(node) {
if (!this._allowInlineConfig) {
// inline configuration is disabled, do nothing
return null;
}
let nodeValue = node.value.trim();
let instructionName = nodeValue.split(reWhitespace)[0];
let instructionArgs = nodeValue.slice(instructionName.length + 1).trim();
let config = { tree: false };
let m;
m = reTree.exec(instructionName);
if (m) {
config.tree = true;
instructionName = m[1];
}
m = reToggleRule.exec(instructionName);
if (m) {
// If no instructionArgs, it's just disabling everything, so apply the
// config
if (instructionArgs) {
// It includes an explicit list of rules, so not just disabling
// everything. So, let's validate the rule names, and then see if it
// contains us.
let names = instructionArgs.split(reWhitespace).map(unquote);
// Validate rule names. If one or more fail to validate, don't return,
// though, because if we can still find our rule name in the list, we
// can process the instruction just fine.
if (!loggedNodes.includes(node)) {
let badNames = names.filter((name) => !this._isRuleName(name));
for (const badName of badNames) {
this.log({
message: `unrecognized rule name \`${badName}\` in ${instructionName} instruction`,
line: node.loc && node.loc.start.line,
column: node.loc && node.loc.start.column,
source: this.sourceForNode(node),
rule: 'global',
});
}
if (badNames.length > 0) {
loggedNodes.push(node);
}
}
if (names.every((name) => !this._refersToCurrentRule(name))) {
// Explicit list that doesn't include us
return null;
}
}
if (m[1] === 'disable') {
config.value = false;
} else {
// Enable means set to the default config, i.e. the bottom of our
// config stack...unless it was originally disabled, and then just
// enable it.
if (this._configStack[0] === false) {
config.value = true;
} else {
config.value = this._configStack[0];
}
}
return config;
} else if (instructionName === 'template-lint-configure') {
// This instruction must list exactly one rule
let firstArg = instructionArgs.split(reWhitespace)[0];
let jsonString = instructionArgs.slice(firstArg.length).trim();
firstArg = unquote(firstArg);
// Make sure the first argument is a valid rule name as a heuristic to
// check for syntax errors.
if (!this._isRuleName(firstArg)) {
// Invalid rule
if (!loggedNodes.includes(node)) {
this.log({
message: `unrecognized rule name \`${firstArg}\` in template-lint-configure instruction`,
line: node.loc && node.loc.start.line,
column: node.loc && node.loc.start.column,
source: this.sourceForNode(node),
rule: 'global',
});
loggedNodes.push(node);
}
return null;
}
if (!this._refersToCurrentRule(firstArg)) {
// Valid rule, but not us, so not relevant
return null;
}
try {
const { determineRuleConfig } = require('../get-config');
config.value = JSON.parse(jsonString);
let ruleData = determineRuleConfig(config.value);
this.severity = ruleData.severity;
return config;
} catch {
if (!loggedNodes.includes(node)) {
this.log({
message: `malformed template-lint-configure instruction: \`${jsonString}\` is not valid JSON`,
line: node.loc && node.loc.start.line,
column: node.loc && node.loc.start.column,
source: this.sourceForNode(node),
rule: 'global',
});
loggedNodes.push(node);
}
}
} else if (instructionName.slice(0, 'template-lint'.length) === 'template-lint') {
if (!loggedNodes.includes(node)) {
this.log({
message: `unrecognized template-lint instruction: \`${instructionName}\``,
line: node.loc && node.loc.start.line,
column: node.loc && node.loc.start.column,
source: this.sourceForNode(node),
rule: 'global',
});
loggedNodes.push(node);
}
}
return null;
}
isDisabled() {
return !this.config;
}
log(result) {
let defaults = {
rule: this.ruleName,
severity: this.severity,
};
if (this._filePath) {
defaults.filePath = this._filePath;
}
if (this[MODULE_NAME]) {
defaults.moduleId = this[MODULE_NAME];
}
let reportedResult = Object.assign({}, defaults, result);
this.results.push(reportedResult);
}
detect() {
throw new Error('Must implemented #detect');
}
process() {
throw new Error('Must implemented #process');
}
// mostly copy/pasta from tildeio/htmlbars with a few tweaks:
// https://github.com/tildeio/htmlbars/blob/v0.14.17/packages/htmlbars-syntax/lib/parser.js#L59-L90
sourceForNode(node) {
if (node.loc) {
return this.sourceForLoc(node.loc);
}
}
sourceForLoc(loc) {
let firstLine = loc.start.line - 1;
let lastLine = loc.end.line - 1;
let currentLine = firstLine - 1;
let firstColumn = loc.start.column;
let lastColumn = loc.end.column;
let string = [];
let line;
while (currentLine < lastLine) {
currentLine++;
line = this.source[currentLine];
if (currentLine === firstLine) {
if (firstLine === lastLine) {
string.push(line.slice(firstColumn, lastColumn));
} else {
string.push(line.slice(firstColumn));
}
} else if (currentLine === lastLine) {
string.push(line.slice(0, lastColumn));
} else {
string.push(line);
}
}
return string.join('');
}
isLocal(node) {
return this.scope.isLocal(node);
}
};