@ui5/linter
Version:
A static code analysis tool for UI5
664 lines • 32.2 kB
JavaScript
import he from "he";
import ViewGenerator from "./generator/ViewGenerator.js";
import FragmentGenerator from "./generator/FragmentGenerator.js";
import JSTokenizer from "./lib/JSTokenizer.js";
import { CoverageCategory } from "../LinterContext.js";
import { getLogger } from "@ui5/logger";
import { MESSAGE } from "../messages.js";
import BindingLinter from "../binding/BindingLinter.js";
import EventHandlerResolver from "./lib/EventHandlerResolver.js";
import BindingParser from "../binding/lib/BindingParser.js";
import { extractDirective } from "../../utils/xmlParser.js";
const log = getLogger("linter:xmlTemplate:Parser");
const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const TEMPLATING_NAMESPACE = "http://schemas.sap.com/sapui5/extension/sap.ui.core.template/1";
const FESR_NAMESPACE = "http://schemas.sap.com/sapui5/extension/sap.ui.core.FESR/1";
const SAP_BUILD_NAMESPACE = "sap.build";
const SAP_UI_DT_NAMESPACE = "sap.ui.dt";
const CUSTOM_DATA_NAMESPACE = "http://schemas.sap.com/sapui5/extension/sap.ui.core.CustomData/1";
const CORE_NAMESPACE = "sap.ui.core";
const PATTERN_LIBRARY_NAMESPACES = /^([a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*)$/;
function determineDocumentKind(resourcePath) {
if (/\.view.xml$/.test(resourcePath)) {
return 0 /* DocumentKind.View */;
}
else if (/\.fragment.xml$/.test(resourcePath)) {
return 1 /* DocumentKind.Fragment */;
}
else if (/\.control.xml$/.test(resourcePath)) {
throw new Error(`Control XML analysis is currently not supported for resource ${resourcePath}`);
}
else {
return null;
}
}
function toPosition(saxPos) {
return {
line: saxPos.line,
column: saxPos.character,
};
}
export default class Parser {
#resourcePath;
#xmlDocumentKind;
#context;
#directives = new Set();
#namespaceStack = [];
#nodeStack = [];
// For now, gather all require declarations, independent of the scope
// This might not always be correct, but for now we usually only care about whether
// there is a require declaration for a given string or not.
#requireDeclarations = [];
#bindingLinter;
#generator;
#apiExtract;
constructor(resourcePath, apiExtract, context, controllerByIdInfo) {
const xmlDocumentKind = determineDocumentKind(resourcePath);
if (xmlDocumentKind === null) {
throw new Error(`Unknown document type for resource ${resourcePath}`);
}
this.#resourcePath = resourcePath;
this.#xmlDocumentKind = xmlDocumentKind;
this.#generator = xmlDocumentKind === 0 /* DocumentKind.View */ ?
new ViewGenerator(resourcePath, controllerByIdInfo) :
new FragmentGenerator(resourcePath, controllerByIdInfo);
this.#apiExtract = apiExtract;
this.#context = context;
this.#bindingLinter = new BindingLinter(resourcePath, context);
}
pushTag(tag) {
this.#nodeStack.push(this._createNode(tag));
}
popTag(_tag) {
const closingNode = this.#nodeStack.pop();
const level = this.#nodeStack.length;
if (closingNode &&
(closingNode.kind & (1 /* NodeKind.Control */ | 4 /* NodeKind.FragmentDefinition */))) {
// Generate view code for this control
// If this is the root control, export it
if (level === 0) {
// Actually closingNode might be a FragmentDefinitionDeclaration here
// But that's tricky with the current generator signatures
this.#generator.writeRootControl(closingNode);
}
else {
this.#generator.writeControl(closingNode);
}
}
// Cleanup stacks stacks
this._removeNamespacesForLevel(level);
}
parseComment(comment) {
const directive = extractDirective(comment);
if (directive) {
this.#directives.add(directive);
}
}
generate() {
if (this.#directives.size) {
// Add directives to the context
this.#context.getMetadata(this.#resourcePath).directives = this.#directives;
}
const { source, map } = this.#generator.getModuleContent();
return {
source,
map,
};
}
_findParentNode(kindFilter) {
for (let i = this.#nodeStack.length - 1; i >= 0; i--) {
if (this.#nodeStack[i].kind & kindFilter) {
return this.#nodeStack[i];
}
}
return null;
}
_addNamespace(namespace, level) {
this.#namespaceStack.push({
namespace,
level,
});
}
_resolveNamespace(localName) {
// Search this.#namespaceStack in reverse order
for (let i = this.#namespaceStack.length - 1; i >= 0; i--) {
const ns = this.#namespaceStack[i];
if (ns.namespace.localName === localName) {
return ns.namespace.namespace;
}
}
}
_removeNamespacesForLevel(level) {
// Remove all namespaces for the given level
let i = this.#namespaceStack.length - 1;
while (i >= 0 && this.#namespaceStack[i].level >= level) {
this.#namespaceStack.pop();
i--;
}
}
_addDefaultAggregation(owner, control) {
let aggregationName = this.#apiExtract.getDefaultAggregation(`${owner.namespace}.${owner.name}`);
if (!aggregationName) {
log.verbose(`Failed to determine default aggregation for control ${owner.name} used in ` +
`resource ${this.#resourcePath}. Falling back to 'dependents'`);
// In case the default aggregation is unknown (e.g. in case of custom controls),
// fallback to use the generic "dependents" aggregation
// This is not correct at runtime, but it's the best we can do for linting purposes
aggregationName = "dependents";
}
if (!owner.aggregations.has(aggregationName)) {
const aggregation = {
kind: 2 /* NodeKind.Aggregation */,
name: aggregationName,
owner,
controls: [control],
namespace: owner.namespace,
start: control.start,
end: control.end,
};
owner.aggregations.set(aggregationName, aggregation);
}
else {
owner.aggregations.get(aggregationName).controls.push(control);
}
}
_parseRequireAttribute(attrValue) {
if (!attrValue) {
// Runtime allows empty require attributes, so we do too
return [];
}
try {
// This is no well-formed JSON, therefore we have to parse it manually
const requireMap = JSTokenizer.parseJS(attrValue);
return Object.keys(requireMap).map((variableName) => {
return {
moduleName: requireMap[variableName],
variableName,
};
});
}
catch (_) {
throw new Error(`Failed to parse require attribute value '${attrValue}' in resource ${this.#resourcePath}`);
}
}
_createNode(tag) {
let tagName = tag.name;
let tagNamespace = null; // default namespace
// Extract optional namespace from attribute name
if (tagName.includes(":")) {
[tagNamespace, tagName] = tagName.split(":");
}
const attributes = new Map();
tag.attributes.forEach((attr) => {
const attrName = attr.name.value;
const attrValue = he.decode(attr.value.value);
// Extract namespaces immediately so we can resolve namespaced attributes in the next go
if (attrName === "xmlns") {
// Declares the default namespace
this._addNamespace({
localName: null,
namespace: attrValue,
}, this.#nodeStack.length);
}
else if (attrName.startsWith("xmlns:")) {
// Named namespace
this._addNamespace({
localName: attrName.slice(6), // Remove "xmlns:"
namespace: attrValue,
}, this.#nodeStack.length);
}
else if (attrName.includes(":")) {
// Namespaced attribute
const [attrNamespace, attrLocalName] = attrName.split(":");
attributes.set(attrName, {
name: attrLocalName,
value: attrValue,
localNamespace: attrNamespace,
start: toPosition(attr.name.start),
end: toPosition({
line: attr.value.end.line,
character: attr.value.end.character + 1, // Add 1 to include the closing quote
}),
});
}
else {
attributes.set(attrName, {
name: attrName,
value: attrValue,
start: toPosition(attr.name.start),
end: toPosition({
line: attr.value.end.line,
character: attr.value.end.character + 1, // Add 1 to include the closing quote
}),
});
}
});
// Note: Resolve namespace *after* evaluating all attributes, since it might have been defined
// by one of them
let namespace = this._resolveNamespace(tagNamespace);
if (!namespace) {
throw new Error(`Unknown namespace ${tagNamespace} for tag ${tagName} in resource ${this.#resourcePath}`);
}
else if (namespace === SVG_NAMESPACE) {
// Ignore SVG nodes
this.#context.addLintingMessage(this.#resourcePath, MESSAGE.SVG_IN_XML, undefined, {
line: tag.openStart.line + 1, // Add one to align with IDEs
column: tag.openStart.character + 1,
});
return {
kind: 32 /* NodeKind.Svg */,
name: tagName,
namespace,
start: toPosition(tag.openStart),
end: toPosition(tag.openEnd),
};
}
else if (namespace === XHTML_NAMESPACE) {
// Ignore XHTML nodes for now
this.#context.addLintingMessage(this.#resourcePath, MESSAGE.HTML_IN_XML, undefined, {
line: tag.openStart.line + 1, // Add one to align with IDEs
column: tag.openStart.character + 1,
});
return {
kind: 16 /* NodeKind.Xhtml */,
name: tagName,
namespace,
start: toPosition(tag.openStart),
end: toPosition(tag.openEnd),
};
}
else if (namespace === TEMPLATING_NAMESPACE) {
return this._handleTemplatingNamespace(tagName, namespace, attributes, tag);
}
else if (PATTERN_LIBRARY_NAMESPACES.test(namespace)) {
const lastIdx = tagName.lastIndexOf(".");
if (lastIdx !== -1) {
// Resolve namespace prefix, e.g. "sap:m.Button"
namespace += `.${tagName.slice(0, lastIdx)}`;
tagName = tagName.slice(lastIdx + 1);
}
return this._handleUi5LibraryNamespace(tagName, namespace, attributes, tag);
}
else {
return {
kind: 0 /* NodeKind.Unknown */,
name: tagName,
namespace,
start: toPosition(tag.openStart),
end: toPosition(tag.openEnd),
};
}
}
_handleUi5LibraryNamespace(moduleName, namespace, attributes, tag) {
const controlProperties = new Set();
const customDataElements = [];
attributes.forEach((attr) => {
if (attr.localNamespace) {
// Resolve namespace
const resolvedNamespace = this._resolveNamespace(attr.localNamespace);
if (!resolvedNamespace) {
throw new Error(`Unknown namespace ${attr.localNamespace} for attribute ${attr.name} ` +
`in resource ${this.#resourcePath}`);
}
if ((resolvedNamespace === CORE_NAMESPACE ||
resolvedNamespace === TEMPLATING_NAMESPACE) && attr.name === "require") {
// sap.ui.core:require or template:require declaration
let requireDeclarations;
if (resolvedNamespace === TEMPLATING_NAMESPACE && !attr.value.startsWith("{")) {
/* From: https://github.com/SAP/openui5/blob/959dcf4d0ac771aa53ce4f4bf02832356afd8c23/src/sap.ui.core/src/sap/ui/core/util/XMLPreprocessor.js#L1301-L1306
* "template:require" attribute may contain either a space separated list of
* dot-separated module names or a JSON representation of a map from alias to
* slash-separated Unified Resource Names (URNs). In the first case, the resulting
* modules must be accessed from the global namespace. In the second case, they are
* available as local names (AMD style) similar to <template:alias> instructions.
*/
requireDeclarations = [];
attr.value.split(" ").map(function (sModuleName) {
const requiredModuleName = sModuleName.replace(/\./g, "/");
// We can't (and also really shouldn't) declare a global namespace for the imported
// module, so we just use the module name as variable name
const variableName = requiredModuleName.replaceAll("/", "_");
requireDeclarations.push({
moduleName: requiredModuleName,
variableName,
});
});
if (requireDeclarations.length) {
// Usage of space separated list is not recommended, as it only allows for global access
this.#context.addLintingMessage(this.#resourcePath, MESSAGE.NO_LEGACY_TEMPLATE_REQUIRE_SYNTAX, { moduleNames: attr.value }, attr.start);
}
}
else {
// Most common case: JSON-like representation
// e.g. core:require="{Helper: 'sap/ui/demo/todo/util/Helper'}"
requireDeclarations = this._parseRequireAttribute(attr.value);
}
const requireExpression = {
name: attr.name,
value: attr.value,
declarations: requireDeclarations,
start: attr.start,
end: attr.end,
};
this.#requireDeclarations.push(...requireDeclarations);
this.#generator.writeRequire(requireExpression);
}
else if (resolvedNamespace === FESR_NAMESPACE ||
resolvedNamespace === SAP_BUILD_NAMESPACE || resolvedNamespace === SAP_UI_DT_NAMESPACE) {
// Silently ignore FESR, sap.build and sap.ui.dt attributes
}
else if (resolvedNamespace === CUSTOM_DATA_NAMESPACE) {
// Add custom data element and add it as an aggregation
const customData = {
kind: 1 /* NodeKind.Control */,
name: "CustomData",
namespace: CORE_NAMESPACE,
properties: new Set([
{
name: "key",
value: attr.name,
start: attr.start,
end: attr.end,
},
{
name: "value",
value: attr.value,
start: attr.start,
end: attr.end,
},
]),
aggregations: new Map(),
start: attr.start,
end: attr.end,
};
customDataElements.push(customData);
// Immediately write the custom data element declaration to make it usable
// in the control aggregation
this.#generator.writeControl(customData);
}
else {
log.verbose(`Ignoring unknown namespaced attribute ${attr.localNamespace}:${attr.name} ` +
`for ${moduleName} in resource ${this.#resourcePath}`);
}
}
else {
controlProperties.add(attr);
}
});
const parentNode = this._findParentNode(1 /* NodeKind.Control */ | 2 /* NodeKind.Aggregation */ | 4 /* NodeKind.FragmentDefinition */);
if (/^[a-z]/.exec(moduleName)) {
const aggregationName = moduleName;
// TODO: Replace the above with a check against known controls. Even though there are
// no known cases of lower case control names in the framework.
// This node likely declares an aggregation
if (!parentNode || parentNode.kind === 4 /* NodeKind.FragmentDefinition */) {
if (this.#xmlDocumentKind !== 1 /* DocumentKind.Fragment */) {
throw new Error(`Unexpected top-level aggregation declaration: ` +
`${aggregationName} in resource ${this.#resourcePath}`);
}
let pos;
if (parentNode) {
pos = {
start: {
line: parentNode.start.line + 1,
column: parentNode.start.column + 1,
},
end: {
line: parentNode.end.line + 1,
column: parentNode.end.column + 1,
},
};
}
else {
pos = {
start: { line: 1, column: 1 },
end: { line: 1, column: 1 },
};
}
this.#context.addCoverageInfo(this.#resourcePath, {
category: CoverageCategory.UnknownControlAggregation,
line: pos.start.line,
column: pos.start.column,
endLine: pos.end.line,
endColumn: pos.end.column,
message: `Unable to analyze top-level aggregation "${aggregationName}". ` +
`Parent control could not be determined.`,
});
// In case of top-level aggregations in fragments, generate an sap.ui.core.Control instance and
// add the aggregation's content to it's dependents aggregation
const coreControl = {
kind: 1 /* NodeKind.Control */,
name: "Control",
namespace: CORE_NAMESPACE,
properties: new Set(),
aggregations: new Map(),
start: toPosition(tag.openStart),
end: toPosition(tag.openEnd),
};
return coreControl;
}
else if (parentNode.kind === 2 /* NodeKind.Aggregation */) {
throw new Error(`Unexpected aggregation ${aggregationName} within aggregation ${parentNode.name} ` +
`in resource ${this.#resourcePath}`);
}
const owner = parentNode;
let ownerAggregation = owner.aggregations.get(aggregationName);
if (!ownerAggregation) {
// Create aggregation declaration if not already declared before
// (duplicate aggregation tags are merged into the first occurrence)
ownerAggregation = {
kind: 2 /* NodeKind.Aggregation */,
name: aggregationName,
namespace,
owner: parentNode,
controls: [],
start: toPosition(tag.openStart),
end: toPosition(tag.openEnd),
};
owner.aggregations.set(aggregationName, ownerAggregation);
}
return ownerAggregation;
}
else if (this.#xmlDocumentKind === 1 /* DocumentKind.Fragment */ && moduleName === "FragmentDefinition" &&
namespace === CORE_NAMESPACE) {
// This node declares a fragment definition
const node = {
kind: 4 /* NodeKind.FragmentDefinition */,
name: moduleName,
namespace,
controls: new Set(),
start: toPosition(tag.openStart),
end: toPosition(tag.openEnd),
};
if (parentNode) {
throw new Error(`Unexpected nested FragmentDefinition in resource ${this.#resourcePath}`);
}
return node;
}
else {
for (const prop of controlProperties) {
// Check whether prop is of type "property" (indicating that it can have a binding)
// Note that some aggregations are handled like properties (0..n + alt type). Therefore check
// whether this is a property first. Additional aggregation-specific checks are not needed in that case
const symbolName = `${namespace}.${moduleName}`;
const position = {
line: prop.start.line + 1, // Add one to align with IDEs
column: prop.start.column + 1,
};
if (this.#apiExtract.isAggregation(symbolName, prop.name)) {
this.#bindingLinter.lintAggregationBinding(prop.value, this.#requireDeclarations, position);
}
else if (this.#apiExtract.isEvent(symbolName, prop.name)) {
// In XML templating, it's possible to have bindings in event handlers
// We need to parse and lint these as well, but the error should only be reported in case the event
// handler is not parsable. This prevents false-positive errors.
const { bindingInfo, errorMessage } = this.#bindingLinter.lintPropertyBinding(prop.value, this.#requireDeclarations, position, false);
let isValidEventHandler = true;
EventHandlerResolver.parse(prop.value).forEach((eventHandler) => {
if (eventHandler.startsWith("cmd:")) {
// No global usage possible via command execution
return;
}
// Try parsing the event handler expression to check whether a property binding parsing error
// should be reported or not.
// This prevents false-positive errors in case a valid event handler declaration is not parsable
// as a binding.
// TODO: The parsed expression could also be used to check for global references in the future
if (errorMessage && isValidEventHandler) {
try {
BindingParser.parseExpression(eventHandler.replace(/^\./, "$controller."), 0, {
bTolerateFunctionsNotFound: true,
}, {});
}
catch (err) {
isValidEventHandler = false;
// Also report the parsing error for the event handler. This creates multiple and
// sometimes duplicate messages, but it's better than not reporting the error at all
// and there is no easy way to know whether the input is intended to be a binding or
// event handler.
this.#context.addLintingMessage(this.#resourcePath, MESSAGE.PARSING_ERROR, {
message: err instanceof Error ? err.message : String(err),
}, position);
return;
}
}
let functionName;
// Check for a valid function/identifier name
// Currently XML views support the following syntaxes that are covered with this pattern:
// - myFunction
// - .myFunction
// - my.namespace.myFunction
// - my.namespace.myFunction(arg1, ${i18n>key}, "test")
const validFunctionName = /^(\.?[$_\p{ID_Start}][$\p{ID_Continue}]*(?:\.[$_\p{ID_Start}][$\p{ID_Continue}]*)*)\s*(?:\(|$)/u;
const match = validFunctionName.exec(eventHandler);
if (match) {
functionName = match[1];
}
else if (!eventHandler.includes("(") && !bindingInfo) {
// Simple case of a function call without arguments which can
// use more characters than just the ID_Start and ID_Continue
// as it is not parsed as expression
// We also get here when the event handler is a binding (resolved during XML templating).
// Therefore we can only assume the value is an actual event handler function if there is
// no binding info available.
functionName = eventHandler;
}
else {
return;
}
const variableName = this.#bindingLinter.getGlobalReference(functionName, this.#requireDeclarations);
if (!variableName) {
return;
}
if (!functionName.includes(".")) {
// If the event handler does not include a dot, it is most likely a reference to the
// controller which should be prefixed with a leading dot, but works in UI5 1.x runtime
// without also it.
// Note that this could also be a global function reference, but we can't distinguish
// that here.
this.#context.addLintingMessage(this.#resourcePath, MESSAGE.NO_AMBIGUOUS_EVENT_HANDLER, {
eventHandler: functionName,
}, position);
}
else {
this.#context.addLintingMessage(this.#resourcePath, MESSAGE.NO_GLOBALS, {
variableName,
namespace: functionName,
}, position);
}
});
if (!isValidEventHandler && errorMessage) {
this.#bindingLinter.reportParsingError(errorMessage, position);
}
}
else {
// XML templating processes all attributes as property bindings, so we don't need to check
// whether the attribute is a property or not
this.#bindingLinter.lintPropertyBinding(prop.value, this.#requireDeclarations, position);
}
}
// This node declares a control
// Or a fragment definition in case of a fragment
const node = {
kind: 1 /* NodeKind.Control */,
name: moduleName,
namespace,
properties: controlProperties,
aggregations: new Map(),
start: toPosition(tag.openStart),
end: toPosition(tag.openEnd),
};
if (customDataElements?.length) {
node.aggregations.set("customData", {
kind: 2 /* NodeKind.Aggregation */,
name: "customData",
namespace,
owner: node,
controls: customDataElements,
start: toPosition(tag.openStart),
end: toPosition(tag.openEnd),
});
}
if (parentNode) {
if (parentNode.kind === 1 /* NodeKind.Control */) {
// Insert the current control in the default aggregation of the last control
this._addDefaultAggregation(parentNode, node);
}
else if (parentNode.kind === 2 /* NodeKind.Aggregation */) {
const aggregationNode = parentNode;
aggregationNode.controls.push(node);
}
else if (parentNode.kind === 4 /* NodeKind.FragmentDefinition */) {
// Add the control to the fragment definition
parentNode.controls.add(node);
}
}
return node;
}
}
_handleTemplatingNamespace(tagName, namespace, attributes, tag) {
let globalReferenceCheckAttribute;
if (tagName === "alias") {
const aliasName = attributes.get("name");
if (aliasName) {
// Add alias to list of local names so that the global check takes them into account
this.#requireDeclarations.push({
variableName: aliasName.value,
});
}
globalReferenceCheckAttribute = attributes.get("value");
}
else if (tagName === "with") {
globalReferenceCheckAttribute = attributes.get("helper");
}
else if (tagName === "if" || tagName === "elseif") {
const testAttribute = attributes.get("test");
if (testAttribute) {
// template:if/elseif test attribute is handled like a property binding in XMLPreprocessor
this.#bindingLinter.lintPropertyBinding(testAttribute.value, this.#requireDeclarations, {
line: testAttribute.start.line + 1, // Add one to align with IDEs
column: testAttribute.start.column + 1,
});
}
}
if (globalReferenceCheckAttribute) {
this._checkGlobalReference(globalReferenceCheckAttribute.value, globalReferenceCheckAttribute.start);
}
return {
kind: 8 /* NodeKind.Template */,
name: tagName,
namespace,
start: toPosition(tag.openStart),
end: toPosition(tag.openEnd),
};
}
_checkGlobalReference(value, { line, column }) {
this.#bindingLinter.checkForGlobalReference(value, this.#requireDeclarations, {
// Add one to align with IDEs
line: line + 1,
column: column + 1,
});
}
}
//# sourceMappingURL=Parser.js.map