polymer-analyzer
Version:
Static analysis for Web Components
424 lines • 18 kB
JavaScript
"use strict";
/**
* @license
* Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at
* http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at
* http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at
* http://polymer.github.io/PATENTS.txt
*/
Object.defineProperty(exports, "__esModule", { value: true });
const babel = require("@babel/types");
const dom5 = require("dom5/lib/index-next");
const astValue = require("../javascript/ast-value");
const javascript_parser_1 = require("../javascript/javascript-parser");
const model_1 = require("../model/model");
const p = dom5.predicates;
const isTemplate = p.hasTagName('template');
const isDataBindingTemplate = p.AND(isTemplate, p.OR(p.hasAttrValue('is', 'dom-bind'), p.hasAttrValue('is', 'dom-if'), p.hasAttrValue('is', 'dom-repeat'), p.parentMatches(p.OR(p.hasTagName('dom-bind'), p.hasTagName('dom-if'), p.hasTagName('dom-repeat'), p.hasTagName('dom-module')))));
/**
* Given a node, return all databinding templates inside it.
*
* A template is "databinding" if polymer databinding expressions are expected
* to be evaluated inside. e.g. <template is='dom-if'> or <dom-module><template>
*
* Results include both direct and nested templates (e.g. dom-if inside
* dom-module).
*/
function getAllDataBindingTemplates(node) {
return dom5.queryAll(node, isDataBindingTemplate, dom5.childNodesIncludeTemplate);
}
exports.getAllDataBindingTemplates = getAllDataBindingTemplates;
class DatabindingExpression {
constructor(sourceRange, expressionText, ast, limitation, document) {
this.warnings = [];
/**
* Toplevel properties on the model that are referenced in this expression.
*
* e.g. in {{foo(bar, baz.zod)}} the properties are foo, bar, and baz
* (but not zod).
*/
this.properties = [];
this.sourceRange = sourceRange;
this.expressionText = expressionText;
this._expressionAst = ast;
this.locationOffset = {
line: sourceRange.start.line,
col: sourceRange.start.column
};
this._document = document;
this._extractPropertiesAndValidate(limitation);
}
/**
* Given an estree node in this databinding expression, give its source range.
*/
sourceRangeForNode(node) {
if (!node || !node.loc) {
return;
}
const databindingRelativeSourceRange = {
file: this.sourceRange.file,
// Note: estree uses 1-indexed lines, but SourceRange uses 0 indexed.
start: { line: (node.loc.start.line - 1), column: node.loc.start.column },
end: { line: (node.loc.end.line - 1), column: node.loc.end.column }
};
return model_1.correctSourceRange(databindingRelativeSourceRange, this.locationOffset);
}
_extractPropertiesAndValidate(limitation) {
if (this._expressionAst.body.length !== 1) {
this.warnings.push(this._validationWarning(`Expected one expression, got ${this._expressionAst.body.length}`, this._expressionAst));
return;
}
const expressionStatement = this._expressionAst.body[0];
if (!babel.isExpressionStatement(expressionStatement)) {
this.warnings.push(this._validationWarning(`Expect an expression, not a ${expressionStatement.type}`, expressionStatement));
return;
}
let expression = expressionStatement.expression;
this._validateLimitation(expression, limitation);
if (babel.isUnaryExpression(expression) && expression.operator === '!') {
expression = expression.argument;
}
this._extractAndValidateSubExpression(expression, true);
}
_validateLimitation(expression, limitation) {
switch (limitation) {
case 'identifierOnly':
if (!babel.isIdentifier(expression)) {
this.warnings.push(this._validationWarning(`Expected just a name here, not an expression`, expression));
}
break;
case 'callExpression':
if (!babel.isCallExpression(expression)) {
this.warnings.push(this._validationWarning(`Expected a function call here.`, expression));
}
break;
case 'full':
break; // no checks needed
default:
const never = limitation;
throw new Error(`Got unknown limitation: ${never}`);
}
}
_extractAndValidateSubExpression(expression, callAllowed) {
if (babel.isUnaryExpression(expression) && expression.operator === '-') {
if (!babel.isNumericLiteral(expression.argument)) {
this.warnings.push(this._validationWarning('The - operator is only supported for writing negative numbers.', expression));
return;
}
this._extractAndValidateSubExpression(expression.argument, false);
return;
}
if (babel.isLiteral(expression)) {
return;
}
if (babel.isIdentifier(expression)) {
this.properties.push({
name: expression.name,
sourceRange: this.sourceRangeForNode(expression)
});
return;
}
if (babel.isMemberExpression(expression)) {
this._extractAndValidateSubExpression(expression.object, false);
return;
}
if (callAllowed && babel.isCallExpression(expression)) {
this._extractAndValidateSubExpression(expression.callee, false);
for (const arg of expression.arguments) {
this._extractAndValidateSubExpression(arg, false);
}
return;
}
this.warnings.push(this._validationWarning(`Only simple syntax is supported in Polymer databinding expressions. ` +
`${expression.type} not expected here.`, expression));
}
_validationWarning(message, node) {
return new model_1.Warning({
code: 'invalid-polymer-expression',
message,
sourceRange: this.sourceRangeForNode(node),
severity: model_1.Severity.WARNING,
parsedDocument: this._document,
});
}
}
exports.DatabindingExpression = DatabindingExpression;
class AttributeDatabindingExpression extends DatabindingExpression {
constructor(astNode, isCompleteBinding, direction, eventName, attribute, sourceRange, expressionText, ast, document) {
super(sourceRange, expressionText, ast, 'full', document);
this.databindingInto = 'attribute';
this.astNode = astNode;
this.isCompleteBinding = isCompleteBinding;
this.direction = direction;
this.eventName = eventName;
this.attribute = attribute;
}
}
exports.AttributeDatabindingExpression = AttributeDatabindingExpression;
class TextNodeDatabindingExpression extends DatabindingExpression {
constructor(direction, astNode, sourceRange, expressionText, ast, document) {
super(sourceRange, expressionText, ast, 'full', document);
this.databindingInto = 'text-node';
this.direction = direction;
this.astNode = astNode;
}
}
exports.TextNodeDatabindingExpression = TextNodeDatabindingExpression;
class JavascriptDatabindingExpression extends DatabindingExpression {
constructor(astNode, sourceRange, expressionText, ast, kind, document) {
super(sourceRange, expressionText, ast, kind, document);
this.databindingInto = 'javascript';
this.astNode = astNode;
}
}
exports.JavascriptDatabindingExpression = JavascriptDatabindingExpression;
/**
* Find and parse Polymer databinding expressions in HTML.
*/
function scanDocumentForExpressions(document) {
return extractDataBindingsFromTemplates(document, getAllDataBindingTemplates(document.ast));
}
exports.scanDocumentForExpressions = scanDocumentForExpressions;
function scanDatabindingTemplateForExpressions(document, template) {
return extractDataBindingsFromTemplates(document, [template].concat([...getAllDataBindingTemplates(template.content)]));
}
exports.scanDatabindingTemplateForExpressions = scanDatabindingTemplateForExpressions;
function extractDataBindingsFromTemplates(document, templates) {
const results = [];
const warnings = [];
for (const template of templates) {
for (const node of dom5.depthFirst(template.content)) {
if (dom5.isTextNode(node) && node.value) {
extractDataBindingsFromTextNode(document, node, results, warnings);
}
if (node.attrs) {
for (const attr of node.attrs) {
extractDataBindingsFromAttr(document, node, attr, results, warnings);
}
}
}
}
return { expressions: results, warnings };
}
function extractDataBindingsFromTextNode(document, node, results, warnings) {
const text = node.value || '';
const dataBindings = findDatabindingInString(text);
if (dataBindings.length === 0) {
return;
}
const nodeSourceRange = document.sourceRangeForNode(node);
if (!nodeSourceRange) {
return;
}
const startOfTextNodeOffset = document.sourcePositionToOffset(nodeSourceRange.start);
for (const dataBinding of dataBindings) {
const sourceRange = document.offsetsToSourceRange(dataBinding.startIndex + startOfTextNodeOffset, dataBinding.endIndex + startOfTextNodeOffset);
const parseResult = parseExpression(dataBinding.expressionText, sourceRange);
if (!parseResult) {
continue;
}
if (parseResult.type === 'failure') {
warnings.push(new model_1.Warning(Object.assign({ parsedDocument: document }, parseResult.warningish)));
}
else {
const expression = new TextNodeDatabindingExpression(dataBinding.direction, node, sourceRange, dataBinding.expressionText, parseResult.parsedFile.program, document);
for (const warning of expression.warnings) {
warnings.push(warning);
}
results.push(expression);
}
}
}
function extractDataBindingsFromAttr(document, node, attr, results, warnings) {
if (!attr.value) {
return;
}
const dataBindings = findDatabindingInString(attr.value);
const attributeValueRange = document.sourceRangeForAttributeValue(node, attr.name, true);
if (!attributeValueRange) {
return;
}
const attributeOffset = document.sourcePositionToOffset(attributeValueRange.start);
for (const dataBinding of dataBindings) {
const isFullAttributeBinding = dataBinding.startIndex === 2 &&
dataBinding.endIndex + 2 === attr.value.length;
let expressionText = dataBinding.expressionText;
let eventName = undefined;
if (dataBinding.direction === '{') {
const match = expressionText.match(/(.*)::(.*)/);
if (match) {
expressionText = match[1];
eventName = match[2];
}
}
const sourceRange = document.offsetsToSourceRange(dataBinding.startIndex + attributeOffset, dataBinding.endIndex + attributeOffset);
const parseResult = parseExpression(expressionText, sourceRange);
if (!parseResult) {
continue;
}
if (parseResult.type === 'failure') {
warnings.push(new model_1.Warning(Object.assign({ parsedDocument: document }, parseResult.warningish)));
}
else {
const expression = new AttributeDatabindingExpression(node, isFullAttributeBinding, dataBinding.direction, eventName, attr, sourceRange, expressionText, parseResult.parsedFile.program, document);
for (const warning of expression.warnings) {
warnings.push(warning);
}
results.push(expression);
}
}
}
function findDatabindingInString(str) {
const expressions = [];
const openers = /{{|\[\[/g;
let match;
while (match = openers.exec(str)) {
const matchedOpeners = match[0];
const startIndex = match.index + 2;
const direction = matchedOpeners === '{{' ? '{' : '[';
const closers = matchedOpeners === '{{' ? '}}' : ']]';
const endIndex = str.indexOf(closers, startIndex);
if (endIndex === -1) {
// No closers, this wasn't an expression after all.
break;
}
const expressionText = str.slice(startIndex, endIndex);
expressions.push({ startIndex, endIndex, expressionText, direction });
// Start looking for the next expression after the end of this one.
openers.lastIndex = endIndex + 2;
}
return expressions;
}
function transformPath(expression) {
return expression
// replace .0, .123, .kebab-case with ['0'], ['123'], ['kebab-case']
.replace(/\.([a-zA-Z_$]([\w:$*]*-+[\w:$*]*)+|[1-9][0-9]*|0)/g, '[\'$1\']')
// remove .* and .splices from the end of the paths
.replace(/\.(\*|splices)$/, '');
}
/**
* Transform polymer expression based on
* https://github.com/Polymer/polymer/blob/10aded461b1a107ed1cfc4a1d630149ad8508bda/lib/mixins/property-effects.html#L864
*/
function transformPolymerExprToJS(expression) {
const method = expression.match(/([^\s]+?)\(([\s\S]*)\)/);
if (method) {
const methodName = method[1];
if (method[2].trim()) {
// replace escaped commas with comma entity, split on un-escaped commas
const args = method[2].replace(/\\,/g, ',').split(',');
return methodName + '(' + args.map(transformArg).join(',') + ')';
}
else {
return expression;
}
}
return transformPath(expression);
}
function transformArg(rawArg) {
const arg = rawArg
// replace comma entity with comma
.replace(/,/g, ',')
// repair extra escape sequences; note only commas strictly
// need escaping, but we allow any other char to be escaped
// since its likely users will do this
.replace(/\\(.)/g, '\$1');
// detect literal value (must be String or Number)
const i = arg.search(/[^\s]/);
let fc = arg[i];
if (fc === '-') {
fc = arg[i + 1];
}
if (fc >= '0' && fc <= '9') {
fc = '#';
}
switch (fc) {
case '\'':
case '"':
return arg;
case '#':
return arg;
}
if (arg.indexOf('.') !== -1) {
return transformPath(arg);
}
return arg;
}
function parseExpression(content, expressionSourceRange) {
const expressionOffset = {
line: expressionSourceRange.start.line,
col: expressionSourceRange.start.column
};
const parseResult = javascript_parser_1.parseJs(transformPolymerExprToJS(content), expressionSourceRange.file, expressionOffset, 'polymer-expression-parse-error');
if (parseResult.type === 'success') {
return parseResult;
}
// The polymer databinding expression language allows for foo.0 and foo.*
// formats when accessing sub properties. These aren't valid JS, but we don't
// want to warn for them either. So just return undefined for now.
if (/\.(\*|\d+)/.test(content)) {
return undefined;
}
return parseResult;
}
function parseExpressionInJsStringLiteral(document, stringLiteral, kind) {
const warnings = [];
const result = {
databinding: undefined,
warnings
};
const sourceRangeForLiteral = document.sourceRangeForNode(stringLiteral);
const expressionText = astValue.expressionToValue(stringLiteral);
if (expressionText === undefined) {
// Should we warn here? It's potentially valid, just unanalyzable. Maybe
// just an info that someone could escalate to a warning/error?
warnings.push(new model_1.Warning({
code: 'unanalyzable-polymer-expression',
message: `Can only analyze databinding expressions in string literals.`,
severity: model_1.Severity.INFO,
sourceRange: sourceRangeForLiteral,
parsedDocument: document
}));
return result;
}
if (typeof expressionText !== 'string') {
warnings.push(new model_1.Warning({
code: 'invalid-polymer-expression',
message: `Expected a string, got a ${typeof expressionText}.`,
sourceRange: sourceRangeForLiteral,
severity: model_1.Severity.WARNING,
parsedDocument: document
}));
return result;
}
const sourceRange = {
file: sourceRangeForLiteral.file,
start: {
column: sourceRangeForLiteral.start.column + 1,
line: sourceRangeForLiteral.start.line
},
end: {
column: sourceRangeForLiteral.end.column - 1,
line: sourceRangeForLiteral.end.line
}
};
const parsed = parseExpression(expressionText, sourceRange);
if (parsed && parsed.type === 'failure') {
warnings.push(new model_1.Warning(Object.assign({ parsedDocument: document }, parsed.warningish)));
}
else if (parsed && parsed.type === 'success') {
result.databinding = new JavascriptDatabindingExpression(stringLiteral, sourceRange, expressionText, parsed.parsedFile.program, kind, document);
for (const warning of result.databinding.warnings) {
warnings.push(warning);
}
}
return result;
}
exports.parseExpressionInJsStringLiteral = parseExpressionInJsStringLiteral;
//# sourceMappingURL=expression-scanner.js.map