diff --git a/.nycrc b/.nycrc index 068466d06..42c78c76c 100644 --- a/.nycrc +++ b/.nycrc @@ -5,7 +5,9 @@ "exclude": [ "src/cli/middlewares/updateNotifier.ts", "src/cli/utils/profile.ts", - "src/linter/xmlTemplate/lib/JSTokenizer.js" + "src/linter/xmlTemplate/lib/JSTokenizer.js", + "src/linter/binding/lib/**/*.js", + "**/*.d.ts" ], "check-coverage": true, "statements": 87, diff --git a/.reuse/dep5 b/.reuse/dep5 index a491c521c..ac2c1bcad 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -33,6 +33,31 @@ Copyright: 2009-2024 SAP SE or an SAP affiliate company License: Apache-2.0 Comment: This file is a copy of sap/base/util/JSTokenizer.js from the OpenUI5 project. +Files: src/linter/binding/lib/BindingParser.js +Copyright: 2009-2025 SAP SE or an SAP affiliate company +License: Apache-2.0 +Comment: This file is a copy of sap/ui/base/BindingParser.js from the OpenUI5 project. + +Files: src/linter/binding/lib/ExpressionParser.js +Copyright: 2009-2025 SAP SE or an SAP affiliate company +License: Apache-2.0 +Comment: This file is a copy of sap/ui/base/ExpressionParser.js from the OpenUI5 project. + +Files: src/linter/binding/lib/BindingMode.js +Copyright: 2009-2025 SAP SE or an SAP affiliate company +License: Apache-2.0 +Comment: This file is a copy of sap/ui/model/BindingMode.js from the OpenUI5 project. + +Files: src/linter/binding/lib/util/deepEqual.js +Copyright: 2009-2025 SAP SE or an SAP affiliate company +License: Apache-2.0 +Comment: This file is a copy of sap/base/util/deepEqual.js from the OpenUI5 project. + +Files: src/linter/binding/lib/strings/escapeRegExp.js +Copyright: 2009-2025 SAP SE or an SAP affiliate company +License: Apache-2.0 +Comment: This file is a copy of sap/base/strings/escapeRegExp.js from the OpenUI5 project. + Files: src/formatter/lib/resolveLinks.ts Copyright: 2009-2024 SAP SE or an SAP affiliate company License: Apache-2.0 diff --git a/src/linter/binding/BindingLinter.ts b/src/linter/binding/BindingLinter.ts new file mode 100644 index 000000000..a6b5ce22a --- /dev/null +++ b/src/linter/binding/BindingLinter.ts @@ -0,0 +1,67 @@ +import LinterContext, {PositionInfo, ResourcePath} from "../LinterContext.js"; +import {MESSAGE} from "../messages.js"; +import {RequireDeclaration} from "../xmlTemplate/Parser.js"; +import BindingParser, {BindingInfo} from "./lib/BindingParser.js"; + +export default class BindingLinter { + #resourcePath: string; + #context: LinterContext; + + constructor(resourcePath: ResourcePath, context: LinterContext) { + this.#resourcePath = resourcePath; + this.#context = context; + } + + #parseBinding(binding: string): BindingInfo { + const bindingInfo = BindingParser.complexParser(binding, null, true, true, true, true); + return bindingInfo; + } + + #analyzeBinding(bindingInfo: BindingInfo, requireDeclarations: RequireDeclaration[], position: PositionInfo) { + const {formatter, events} = bindingInfo; + if (formatter) { + this.#checkForGlobalReference(formatter, requireDeclarations, position); + } + if (events && typeof events === "object") { + for (const eventHandler of Object.values(events)) { + this.#checkForGlobalReference(eventHandler, requireDeclarations, position); + } + } + } + + #checkForGlobalReference(ref: string, requireDeclarations: RequireDeclaration[], position: PositionInfo) { + if (ref.startsWith(".")) { + // Ignore empty reference or reference to the controller (as indicated by the leading dot) + return false; + } + const parts = ref.split("."); + let variableName; + if (parts.length) { + variableName = parts[0]; + } else { + variableName = ref; + } + const requireDeclaration = requireDeclarations.find((decl) => decl.variableName === variableName); + if (requireDeclaration) { + return false; + } + + // Global reference detected + this.#context.addLintingMessage(this.#resourcePath, MESSAGE.NO_GLOBALS, { + variableName, + namespace: ref, + }, position); + } + + lintPropertyBinding(bindingDefinition: string, requireDeclarations: RequireDeclaration[], position: PositionInfo) { + try { + const bindingInfo = this.#parseBinding(bindingDefinition); + if (bindingInfo) { + this.#analyzeBinding(bindingInfo, requireDeclarations, position); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.#context.addLintingMessage(this.#resourcePath, MESSAGE.PARSING_ERROR, {message}, position); + } + } +} diff --git a/src/linter/binding/lib/BindingMode.js b/src/linter/binding/lib/BindingMode.js new file mode 100644 index 000000000..8d3ae7c93 --- /dev/null +++ b/src/linter/binding/lib/BindingMode.js @@ -0,0 +1,41 @@ +/* + * This is a copy of the sap/ui/model/BindingMode.js module of the OpenUI5 project + * https://github.com/SAP/openui5/blob/a4507f0d4f8a56cc881e8983479c8f9b21bfb96b/src/sap.ui.core/src/sap/ui/model/BindingMode.js + */ + +/** +* Binding type definitions. +* +* @enum {string} +* @public +* @alias sap.ui.model.BindingMode +*/ +var BindingMode = { + + /** + * BindingMode default means that the binding mode of the model is used + * @public + */ + Default: "Default", + + /** + * BindingMode one time means value is only read from the model once + * @public + */ + OneTime: "OneTime", + + /** + * BindingMode one way means from model to view + * @public + */ + OneWay: "OneWay", + + /** + * BindingMode two way means from model to view and vice versa + * @public + */ + TwoWay: "TwoWay", + +}; + +export default BindingMode; diff --git a/src/linter/binding/lib/BindingParser.d.ts b/src/linter/binding/lib/BindingParser.d.ts new file mode 100644 index 000000000..e1ae4f1b8 --- /dev/null +++ b/src/linter/binding/lib/BindingParser.d.ts @@ -0,0 +1,23 @@ +export interface BindingInfo { + path?: string; + model?: string; + formatter?: string; + events?: Record; +} + +interface BindingParser { + complexParser: ( + sString: string, + oContext: object | null, + bUnescape?: boolean, + bTolerateFunctionsNotFound?: boolean, + bStaticContext?: boolean, + bPreferContext?: boolean, + mLocals?: Record, + bResolveTypesAsync?: boolean + ) => BindingInfo; +} + +declare const BindingParser: BindingParser; + +export default BindingParser; diff --git a/src/linter/binding/lib/BindingParser.js b/src/linter/binding/lib/BindingParser.js new file mode 100644 index 000000000..399d160db --- /dev/null +++ b/src/linter/binding/lib/BindingParser.js @@ -0,0 +1,735 @@ +/* + * This is a copy of the sap/ui/base/BindingParser.js module of the OpenUI5 project + * https://github.com/SAP/openui5/blob/a4507f0d4f8a56cc881e8983479c8f9b21bfb96b/src/sap.ui.core/src/sap/ui/base/BindingParser.js + * + * Modifications are marked with "UI5 LINTER MODIFICATION" + */ + +/* UI5 LINTER MODIFICATION: Replace sap.ui.define with ESM imports and export */ +import JSTokenizer from "../../xmlTemplate/lib/JSTokenizer.js"; +import BindingMode from "./BindingMode.js"; +import ExpressionParser from "./ExpressionParser.js"; +/* eslint-disable */ + +/** + * @static + * @namespace + * @alias sap.ui.base.BindingParser + */ +var BindingParser = { + _keepBindingStrings : false +}; + +/** + * Regular expression to check for a (new) object literal. + */ +var rObject = /^\{\s*('|"|)[a-zA-Z$_][a-zA-Z0-9$_]*\1\s*:/; + +/** + * Regular expression to split the binding string into hard coded string fragments and embedded bindings. + * + * Also handles escaping of '{' and '}'. + */ +var rFragments = /(\\[\\\{\}])|(\{)/g; + +/** + * Regular expression to escape potential binding chars + */ +var rBindingChars = /([\\\{\}])/g; + +/* UI5 LINTER MODIFICATION: + Removed function implementations "composeFormatters" and "makeFormatter" since they are unused here. +*/ + +/** + * Creates a binding info object with the given path. + * + * If the path contains a model specifier (prefix separated with a '>'), + * the model property is set as well and the prefix is + * removed from the path. + * + * @param {string} sPath + * the given path + * @param {object} [oEnv] + * the "environment" + * @returns {object} + * a binding info object + */ +function makeSimpleBindingInfo(sPath, oEnv) { + var iPos = sPath.indexOf(">"), + oBindingInfo = { path : sPath }; + + if ( iPos > 0 ) { + oBindingInfo.model = sPath.slice(0,iPos); + oBindingInfo.path = sPath.slice(iPos + 1); + } + if (oEnv?.mLocals && oBindingInfo.path.includes("@@")) { + oBindingInfo.parameters = {scope : oEnv.mLocals}; + } + + return oBindingInfo; +} + + +/** + * Delegates to BindingParser.mergeParts, but stifles any errors. + * + * @param {object} oBindingInfo + * a binding info object + * @param {string} [sBinding] + * the original binding string as a detail for error logs + */ +function mergeParts(oBindingInfo, sBinding) { + try { + BindingParser.mergeParts(oBindingInfo); + } catch (e) { + /* UI5 LINTER MODIFICATION: "future.errorThrows" => "throw new Error" */ + throw new Error(`sap.ui.base.BindingParser: Cannot merge parts for binding "${sBinding}"`, { cause: e }); + } +} + +// A qualified name, followed by a .bind(id) call +// 1st capturing group matches the qualified name w/o .bind() call +// 2nd capturing group matches the .bind() argument +const rFormatterBind = /(^(?:[$_\p{ID_Start}][$_\p{ID_Continue}]*\.)*[\p{ID_Start}][$_\p{ID_Continue}]*)\.bind\(([$_\p{ID_Start}][$_\p{ID_Continue}]*)\)$/u; + + +/* UI5 LINTER MODIFICATION: + Disabled function "resolveBindingInfo": UI5 linter does not require a fully resolved binding info. +*/ +// function resolveBindingInfo(oEnv, oBindingInfo) { +// var mVariables = Object.assign({".": oEnv.oContext}, oEnv.mLocals); + +// /* +// * Resolves a function name to a function. +// * +// * Names can consist of multiple segments, separated by dots. +// * +// * If the name starts with a dot ('.'), lookup happens within the given context only; +// * otherwise it will first happen within the given context (only if +// * bPreferContext is set) and then use mLocals to resolve +// * the function and finally fall back to the global context (window). +// * +// * @param {object} o Object from which the property should be read and resolved +// * @param {string} sProp name of the property to resolve +// */ +// function resolveRef(o,sProp) { +// if ( typeof o[sProp] === "string" ) { +// let sName = o[sProp]; +// let bSkipBindContext = false; +// let aMatch = [], mBindableValues = {}; + +// // check for .bind()-syntax +// if (sProp == "formatter" && sName.includes(".bind(")) { +// aMatch = sName.match(rFormatterBind); + +// if (!aMatch) { +// throw new Error(`Error in formatter '${sName}': Either syntax error in the usage of '.bind(...)' or wrong number of arguments given. Only one argument is allowed when using '.bind()'.`); +// } +// if (aMatch[2].startsWith("$") && !Object.hasOwn(oEnv.mAdditionalBindableValues, aMatch[2])) { +// throw new Error(`Error in formatter '${sName}': The argument '${aMatch[2]}' used in the '.bind()' call starts with '$', which is only allowed for framework-reserved variables. Please rename the variable so that it doesn't start with '$'.`); +// } + +// bSkipBindContext = true; +// mBindableValues = Object.assign(mBindableValues, oEnv.mLocals, oEnv.mAdditionalBindableValues); + +// // only pass function name to resolveReference +// sName = aMatch[1]; +// } + +// o[sProp] = resolveReference(sName, mVariables, { +// preferDotContext: oEnv.bPreferContext, +// bindContext: !bSkipBindContext, +// bindDotContext: !oEnv.bStaticContext +// }); + +// if (typeof (o[sProp]) !== "function") { +// if (oEnv.bTolerateFunctionsNotFound) { +// oEnv.aFunctionsNotFound = oEnv.aFunctionsNotFound || []; +// oEnv.aFunctionsNotFound.push(sName); +// } else { +// future.errorThrows(sProp + " function " + sName + " not found!"); +// } +// } + +// if (bSkipBindContext) { +// if (!Object.hasOwn(mBindableValues, aMatch[2])) { +// throw new Error(`Error in formatter '${sName}': Unknown argument '${aMatch[2]}' passed to '.bind()' call.`); +// } +// o[sProp] = mBindableValues[aMatch[2]] !== null ? o[sProp].bind(mBindableValues[aMatch[2]]) : o[sProp]; +// } +// } +// } + +// /* +// * Resolves a data type name and configuration either to a type constructor or to a type instance. +// * +// * The name is resolved locally (against oEnv.oContext) if it starts with a '.', otherwise against +// * the oEnv.mLocals and if it's still not resolved, against the global context (window). +// * +// * The resolution is done in place. If the name resolves to a function, it is assumed to be the +// * constructor of a data type. A new instance will be created, using the values of the +// * properties 'constraints' and 'formatOptions' as parameters of the constructor. +// * Both properties will be removed from o. +// * +// * @param {object} o Object from which a property should be read and resolved +// */ +// function resolveType(o) { +// var FNType; +// var sType = o.type; +// if (typeof sType === "string" ) { +// FNType = resolveReference(sType, mVariables, { +// bindContext: false, +// // only when types aren't expected to be loaded asynchronously, we try to use a +// // probing-require to fetch it in case it can't be resolved with 'mVariables' +// useProbingRequire: !oEnv.aTypePromises +// }); + +// var fnInstantiateType = function(TypeClass) { +// if (typeof TypeClass === "function") { +// o.type = new TypeClass(o.formatOptions, o.constraints); +// } else { +// o.type = TypeClass; +// } + +// if (!o.type) { +// future.errorThrows("Failed to resolve type '" + sType + "'. Maybe not loaded or a typo?"); +// } + +// // TODO why are formatOptions and constraints also removed for an already instantiated type? +// // TODO why is a value of type object not validated (instanceof Type) +// delete o.formatOptions; +// delete o.constraints; +// }; + +// if (oEnv.aTypePromises) { +// var pType; + +// // FNType is either: +// // a) a function +// // * a lazy-stub +// // * a regular constructor function +// // b) an object that must implement Type interface (we take this "as-is") +// // c) undefined, we try to interpret the original string as a module name then +// if (typeof FNType === "function" && !FNType._sapUiLazyLoader || +// FNType && typeof FNType === "object") { +// pType = Promise.resolve(fnInstantiateType(FNType)); +// } else { +// // load type asynchronously +// pType = new Promise(function(fnResolve, fnReject) { +// sap.ui.require([sType.replace(/\./g, "/")], fnResolve, fnReject); +// }).catch(function(oError){ +// // [Compatibility]: We must not throw an error during type creation (except constructor failures!). +// // We catch any require() rejection and log the error. +// future.errorThrows(oError); +// }).then(fnInstantiateType); +// } + +// oEnv.aTypePromises.push(pType); +// } else { +// fnInstantiateType(FNType); +// } +// } +// } + +// /* +// * Resolves a map of event listeners, keyed by the event name. +// * +// * Each listener can be the name of a single function that will be resolved +// * in the given context (oEnv). +// */ +// function resolveEvents(oEvents) { +// if ( oEvents != null && typeof oEvents === 'object' ) { +// for ( var sName in oEvents ) { +// resolveRef(oEvents, sName); +// } +// } +// } + +// /* +// * Converts filter definitions to sap.ui.model.Filter instances. +// * +// * The value of the given property can either be a single filter definition object +// * which will be fed into the constructor of sap.ui.model.Filter. +// * Or it can be an array of such objects. +// * +// * If any of the filter definition objects contains a property named 'filters', +// * that property will be resolved as filters recursively. +// * +// * A property 'test' will be resolved as function in the given context. +// */ +// function resolveFilters(o, sProp) { +// var v = o[sProp]; + +// if ( Array.isArray(v) ) { +// v.forEach(function(oObject, iIndex) { +// resolveFilters(v, iIndex); +// }); +// return; +// } + +// if ( v && typeof v === 'object' ) { +// resolveRef(v, 'test'); +// resolveFilters(v, 'filters'); +// resolveFilters(v, 'condition'); +// o[sProp] = new Filter(v); +// } +// } + +// /* +// * Converts sorter definitions to sap.ui.model.Sorter instances. +// * +// * The value of the given property can either be a single sorter definition object +// * which then will be fed into the constructor of sap.ui.model.Sorter, or it can +// * be an array of such objects. +// * +// * Properties 'group' and 'comparator' in any of the sorter definitions +// * will be resolved as functions in the given context (oEnv). +// */ +// function resolveSorters(o, sProp) { +// var v = o[sProp]; + +// if ( Array.isArray(v) ) { +// v.forEach(function(oObject, iIndex) { +// resolveSorters(v, iIndex); +// }); +// return; +// } + +// if ( v && typeof v === 'object' ) { +// resolveRef(v, "group"); +// resolveRef(v, "comparator"); +// o[sProp] = new Sorter(v); +// } +// } + +// if ( typeof oBindingInfo === 'object' ) { +// // Note: this resolves deeply nested bindings although CompositeBinding doesn't support them +// if ( Array.isArray(oBindingInfo.parts) ) { +// oBindingInfo.parts.forEach(function(oPart) { +// resolveBindingInfo(oEnv, oPart); +// }); +// } +// resolveType(oBindingInfo); +// resolveFilters(oBindingInfo,'filters'); +// resolveSorters(oBindingInfo,'sorter'); +// resolveEvents(oBindingInfo.events); +// resolveRef(oBindingInfo,'formatter'); +// resolveRef(oBindingInfo,'factory'); // list binding +// resolveRef(oBindingInfo,'groupHeaderFactory'); // list binding +// if (oEnv.mLocals && oBindingInfo.path?.includes("@@") +// && oBindingInfo.parameters?.scope === undefined) { +// oBindingInfo.parameters ??= {}; +// oBindingInfo.parameters.scope = oEnv.mLocals; +// } +// } + +// return oBindingInfo; +// } + +/** + * Determines the binding info for the given string sInput starting at the given iStart and + * returns an object with the corresponding binding info as result and the + * position where to continue parsing as at property. + * + * @param {object} oEnv + * the "environment" + * @param {object} oEnv.oContext + * the context object from complexBinding (read-only) + * @param {boolean} oEnv.bTolerateFunctionsNotFound + * if true, unknown functions are gathered in aFunctionsNotFound, otherwise an + * error is logged (read-only) + * @param {string[]} oEnv.aFunctionsNotFound + * a list of functions that could not be found if oEnv.bTolerateFunctionsNotFound is true + * (append only) + * @param {string} sInput + * The input string from which to resolve an embedded binding + * @param {int} iStart + * The start index for binding resolution in the input string + * @returns {object} + * An object with the following properties: + * result: The binding info for the embedded binding + * at: The position after the last character for the embedded binding in the input string + */ +function resolveEmbeddedBinding(oEnv, sInput, iStart) { + var parseObject = JSTokenizer.parseJS, + oParseResult, + iEnd; + + // an embedded binding: check for a property name that would indicate a complex object + if ( rObject.test(sInput.slice(iStart)) ) { + oParseResult = parseObject(sInput, iStart); + + /* UI5 LINTER MODIFICATION: + Disabled call to resolveBindingInfo. See the comment above the function's definition for details + */ + // resolveBindingInfo(oEnv, oParseResult.result); + return oParseResult; + } + // otherwise it must be a simple binding (path only) + iEnd = sInput.indexOf('}', iStart); + if ( iEnd < iStart ) { + throw new SyntaxError("no closing braces found in '" + sInput + "' after pos:" + iStart); + } + return { + result: makeSimpleBindingInfo(sInput.slice(iStart + 1, iEnd), oEnv), + at: iEnd + 1 + }; +} + +BindingParser.simpleParser = function(sString) { + // The simpleParser only needs the first string argument and additionally in the async case the 7th one. + // see "BindingParser.complexParser" for the other arguments + var bResolveTypesAsync = arguments[7]; + + var oBindingInfo; + if ( sString.startsWith("{") && sString.endsWith("}") ) { + oBindingInfo = makeSimpleBindingInfo(sString.slice(1, -1)); + } + + if (bResolveTypesAsync) { + return { + bindingInfo: oBindingInfo, + resolved: Promise.resolve() + }; + } + + return oBindingInfo; +}; + +BindingParser.simpleParser.escape = function(sValue) { + // there was no escaping defined for the simple parser + return sValue; +}; + +/* + * @param {boolean} [bTolerateFunctionsNotFound=false] + * if true, function names which cannot be resolved to a reference are reported via the + * string array functionsNotFound of the result object; else they are logged + * as errors + * @param {boolean} [bStaticContext=false] + * If true, relative function names found via oContext will not be treated as + * instance methods of the context, but as static methods. + * @param {boolean} [bPreferContext=false] + * if true, names without an initial dot are searched in the given context first and then + * globally + * @param {object} [mLocals] + * variables allowed in the expression as map of variable name to its value + * @param {boolean} [bResolveTypesAsync] + * whether the Type classes should be resolved asynchronously. + * The parsing result is enriched with an additional Promise capturing all transitive Type loading. + */ +BindingParser.complexParser = function(sString, oContext, bUnescape, + bTolerateFunctionsNotFound, bStaticContext, bPreferContext, mLocals, bResolveTypesAsync, mAdditionalBindableValues) { + var b2ndLevelMergedNeeded = false, // whether some 2nd level parts again have parts + oBindingInfo = {parts:[]}, + bMergeNeeded = false, // whether some top-level parts again have parts + oEnv = { + oContext: oContext, + mLocals: mLocals, + aFunctionsNotFound: undefined, // lazy creation + bPreferContext : bPreferContext, + bStaticContext: bStaticContext, + bTolerateFunctionsNotFound: bTolerateFunctionsNotFound, + aTypePromises: bResolveTypesAsync ? [] : undefined, + mAdditionalBindableValues: mAdditionalBindableValues + }, + aFragments = [], + bUnescaped, + p = 0, + m, + oEmbeddedBinding; + + /** + * Parses an expression. Sets the flags accordingly. + * + * @param {string} sInput The input string to parse from + * @param {int} iStart The start index + * @param {sap.ui.model.BindingMode} oBindingMode the binding mode + * @returns {object} a result object with the binding in result and the index + * after the last character belonging to the expression in at + * @throws SyntaxError if the expression string is invalid + */ + function expression(sInput, iStart, oBindingMode) { + var oBinding = ExpressionParser.parse(resolveEmbeddedBinding.bind(null, oEnv), sString, + iStart, null, mLocals || (bStaticContext ? oContext : null)); + + /** + * Recursively sets the mode oBindingMode on the given binding (or its + * parts). + * + * @param {object} oBinding + * a binding which may be composite + * @param {int} [iIndex] + * index provided by forEach + */ + function setMode(oBinding, iIndex) { + if (oBinding.parts) { + oBinding.parts.forEach(function (vPart, i) { + if (typeof vPart === "string") { + vPart = oBinding.parts[i] = {path : vPart}; + } + setMode(vPart, i); + }); + b2ndLevelMergedNeeded = b2ndLevelMergedNeeded || iIndex !== undefined; + } else { + oBinding.mode = oBindingMode; + } + } + + if (sInput.charAt(oBinding.at) !== "}") { + throw new SyntaxError("Expected '}' and instead saw '" + + sInput.charAt(oBinding.at) + + "' in expression binding " + + sInput + + " at position " + + oBinding.at); + } + oBinding.at += 1; + if (oBinding.result) { + setMode(oBinding.result); + } else { + aFragments[aFragments.length - 1] = String(oBinding.constant); + bUnescaped = true; + } + return oBinding; + } + + rFragments.lastIndex = 0; //previous parse call may have thrown an Error: reset lastIndex + while ( (m = rFragments.exec(sString)) !== null ) { + + // check for a skipped literal string fragment + if ( p < m.index ) { + aFragments.push(sString.slice(p, m.index)); + } + + // handle the different kinds of matches + if ( m[1] ) { + + // an escaped opening bracket, closing bracket or backslash + aFragments.push(m[1].slice(1)); + bUnescaped = true; + + } else { + aFragments.push(oBindingInfo.parts.length); + if (sString.indexOf(":=", m.index) === m.index + 1) { + oEmbeddedBinding = expression(sString, m.index + 3, BindingMode.OneTime); + } else if (sString.charAt(m.index + 1) === "=") { //expression + oEmbeddedBinding = expression(sString, m.index + 2, BindingMode.OneWay); + } else { + oEmbeddedBinding = resolveEmbeddedBinding(oEnv, sString, m.index); + } + if (oEmbeddedBinding.result) { + oBindingInfo.parts.push(oEmbeddedBinding.result); + bMergeNeeded = bMergeNeeded || "parts" in oEmbeddedBinding.result; + } + rFragments.lastIndex = oEmbeddedBinding.at; + } + + // remember where we are + p = rFragments.lastIndex; + } + + // check for a trailing literal string fragment + if ( p < sString.length ) { + aFragments.push(sString.slice(p)); + } + + // only if a part has been found we can return a binding info + if (oBindingInfo.parts.length > 0) { + // Note: aFragments.length >= 1 + if ( aFragments.length === 1 /* implies: && typeof aFragments[0] === "number" */ ) { + // special case: a single binding only + oBindingInfo = oBindingInfo.parts[0]; + bMergeNeeded = b2ndLevelMergedNeeded; + } else { + // create the formatter function from the fragments + /* UI5 LINTER MODIFICATION: + Disabled next line to prevent creation of formatter function. + We are only interested in the property access string. + */ + // oBindingInfo.formatter = makeFormatter(aFragments); + } + if (bMergeNeeded) { + mergeParts(oBindingInfo, sString); + } + if (BindingParser._keepBindingStrings) { + oBindingInfo.bindingString = sString; + } + if (oEnv.aFunctionsNotFound) { + oBindingInfo.functionsNotFound = oEnv.aFunctionsNotFound; + } + + if (bResolveTypesAsync) { + // parse result contains additionally a Promise with all asynchronously loaded types + return { + bindingInfo: oBindingInfo, + resolved: Promise.all(oEnv.aTypePromises), + wait : oEnv.aTypePromises.length > 0 + }; + } + + return oBindingInfo; + } else if ( bUnescape && bUnescaped ) { + var sResult = aFragments.join(''); + if (bResolveTypesAsync) { + return { + bindingInfo: sResult, + resolved: Promise.resolve() + }; + } + return sResult; + } + +}; + +BindingParser.complexParser.escape = function(sValue) { + return sValue.replace(rBindingChars, "\\$1"); +}; + +/** + * Merges the given binding info object's parts, which may have parts themselves, into a flat + * list of parts, taking care of existing formatter functions. If the given binding info does + * not have a root formatter, Array.prototype.join(., " ") is used instead. + * Parts which are not binding info objects are also supported; they are removed from the + * "parts" array and taken care of by the new root-level formatter function, which feeds them + * into the old formatter function at the right place. + * + * Note: Truly hierarchical composite bindings are not yet supported. This method deals with a + * special case of a two-level hierarchy which can be turned into a one-level hierarchy. The + * precondition is that the parts which have parts themselves are not too complex, i.e. must + * have no other properties than "formatter" and "parts". A missing formatter on that level + * is replaced with the default Array.prototype.join(., " "). + * + * @param {object} oBindingInfo + * a binding info object with a possibly empty array of parts and a new formatter function + * @throws {Error} + * in case precondition is not met + * @private + */ +BindingParser.mergeParts = function (oBindingInfo) { + var aFormatters = [], + aParts = []; + + /* UI5 LINTER MODIFICATION START */ + if (oBindingInfo.formatter) { + aFormatters.push(oBindingInfo.formatter); + } + oBindingInfo.formatter = aFormatters; + for (const vEmbeddedBinding of oBindingInfo.parts) { + if (typeof vEmbeddedBinding === "object" && vEmbeddedBinding.formatter) { + aFormatters.push(vEmbeddedBinding.formatter); + } + } + /* UI5 LINTER MODIFICATION END */ + + /* UI5 LINTER MODIFICATION: Disabled the remainder of the function */ + + // oBindingInfo.parts.forEach(function (vEmbeddedBinding) { + // var iEnd, + // fnFormatter = function () { + // return vEmbeddedBinding; // just return constant value + // }, + // sName, + // iStart = aParts.length; + + // /* + // * Selects the overall argument corresponding to the current part. + // * + // * @returns {any} + // * the argument at index iStart + // */ + // function select() { + // return arguments[iStart]; + // } + + // // @see sap.ui.base.ManagedObject#extractBindingInfo + // if (vEmbeddedBinding && typeof vEmbeddedBinding === "object") { + // if (vEmbeddedBinding.parts) { + // for (sName in vEmbeddedBinding) { + // if (sName !== "formatter" && sName !== "parts") { + // throw new Error("Unsupported property: " + sName); + // } + // } + + // aParts = aParts.concat(vEmbeddedBinding.parts); + // iEnd = aParts.length; + // if (vEmbeddedBinding.formatter) { + // if (vEmbeddedBinding.formatter.requiresIContext === true) { + // fnFormatter = function (oInterface) { + // // old formatter needs to operate on its own slice of overall args + // var aArguments + // = Array.prototype.slice.call(arguments, iStart + 1, iEnd + 1); + + // aArguments.unshift(oInterface._slice(iStart, iEnd)); + + // return vEmbeddedBinding.formatter.apply(this, aArguments); + // }; + // fnFormatter.requiresIContext = true; + // } else { + // fnFormatter = function () { + // // old formatter needs to operate on its own slice of overall args + // return vEmbeddedBinding.formatter.apply(this, + // Array.prototype.slice.call(arguments, iStart, iEnd)); + // }; + // } + // } else if (iEnd - iStart > 1) { + // fnFormatter = function () { + // // @see sap.ui.model.CompositeBinding#getExternalValue + // // "default: multiple values are joined together as space separated + // // list if no formatter or type specified" + // return Array.prototype.slice.call(arguments, iStart, iEnd).join(" "); + // }; + // } else { + // fnFormatter = select; + // } + // } else if ("path" in vEmbeddedBinding) { + // aParts.push(vEmbeddedBinding); + // fnFormatter = select; + // } + // } + // aFormatters.push(fnFormatter); + // }); + + // oBindingInfo.parts = aParts; + // oBindingInfo.formatter = composeFormatters(aFormatters, oBindingInfo.formatter); +}; + +/** + * Parses a string sInput with an expression. The input string is parsed starting + * at the index iStart and the return value contains the index after the last + * character belonging to the expression. + * + * @param {string} sInput + * the string to be parsed + * @param {int} iStart + * the index to start parsing + * @param {object} [oEnv] + * the "environment" (see resolveEmbeddedBinding function for details) + * @param {object} [mLocals] + * variables allowed in the expression as map of variable name to value + * @returns {object} + * the parse result with the following properties + * + * @throws SyntaxError + * If the expression string is invalid or unsupported. The at property of + * the error contains the position where parsing failed. + * @private + */ +BindingParser.parseExpression = function (sInput, iStart, oEnv, mLocals) { + oEnv = oEnv || {}; + + if (mLocals) { + oEnv.mLocals = mLocals; + } + + return ExpressionParser.parse(resolveEmbeddedBinding.bind(null, oEnv), sInput, iStart, mLocals); +}; + +export default BindingParser; diff --git a/src/linter/binding/lib/ExpressionParser.js b/src/linter/binding/lib/ExpressionParser.js new file mode 100644 index 000000000..8cc398525 --- /dev/null +++ b/src/linter/binding/lib/ExpressionParser.js @@ -0,0 +1,943 @@ +/* + * This is a copy of the sap/ui/base/ExpressionParser.js module of the OpenUI5 project + * https://github.com/SAP/openui5/blob/a4507f0d4f8a56cc881e8983479c8f9b21bfb96b/src/sap.ui.core/src/sap/ui/base/ExpressionParser.js + * + * Modifications are marked with "UI5 LINTER MODIFICATION" + */ + +/* UI5 LINTER MODIFICATION: Replace sap.ui.define with ESM imports and export */ +import JSTokenizer from "../../xmlTemplate/lib/JSTokenizer.js"; +import escapeRegExp from "./strings/escapeRegExp.js"; +import deepEqual from "./util/deepEqual.js"; +/* eslint-disable */ + +//SAP's Independent Implementation of "Top Down Operator Precedence" by Vaughan R. Pratt, +// see http://portal.acm.org/citation.cfm?id=512931 +//Inspired by "TDOP" of Douglas Crockford which is also an implementation of Pratt's article +// see https://github.com/douglascrockford/TDOP +//License granted by Douglas Crockford to SAP, Apache License 2.0 +// (http://www.apache.org/licenses/LICENSE-2.0) +// +//led = "left denotation" +//lbp = "left binding power", for values see +//https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence +//nud = "null denotation" +//rbp = "right binding power" +var fnUndefined = CONSTANT.bind(null, undefined), + mDefaultGlobals = { + "Array": Array, + "Boolean": Boolean, + "Date": Date, + "encodeURIComponent": encodeURIComponent, + "Infinity": Infinity, + "isFinite": isFinite, + "isNaN": isNaN, + "JSON": JSON, + "Math": Math, + "NaN": NaN, + "Number": Number, + "Object": Object, + "odata": { + "collection": function (aElements) { + return aElements.filter(function (vElement) { + return vElement !== undefined; + }); + }, + "compare": function () { + var oODataUtils = sap.ui.require("sap/ui/model/odata/v4/ODataUtils"); + + /** @deprecated As of version 1.120.0 */ + if (!oODataUtils) { + oODataUtils = sap.ui.requireSync("sap/ui/model/odata/v4/ODataUtils"); + } + if (!oODataUtils) { + throw new TypeError("Expression uses 'odata.compare' which requires to" + + " import 'sap/ui/model/odata/v4/ODataUtils' in advance"); + } + + return oODataUtils.compare.apply(oODataUtils, arguments); + }, + "fillUriTemplate": function (sExpression, mData) { + /** @deprecated As of version 1.120.0 */ + if (!URI.expand) { + // probing is not required since the presence of URI.expand is the indicator + // that URITemplate has been loaded already + /* URITemplate = */ sap.ui.requireSync("sap/ui/thirdparty/URITemplate"); + } + if (!URI.expand) { + throw new TypeError("Expression uses 'odata.fillUriTemplate' which requires" + + " to import 'sap/ui/thirdparty/URITemplate' in advance"); + } + + return URI.expand(sExpression.trim(), mData).toString(); + }, + "uriEncode": function () { + var oODataUtils = sap.ui.require("sap/ui/model/odata/ODataUtils"); + + /** @deprecated As of version 1.120.0 */ + if (!oODataUtils) { + oODataUtils = sap.ui.requireSync("sap/ui/model/odata/ODataUtils"); + } + if (!oODataUtils) { + throw new TypeError("Expression uses 'odata.uriEncode' which requires to" + + " import 'sap/ui/model/odata/ODataUtils' in advance"); + } + + return oODataUtils.formatValue.apply(oODataUtils, arguments); + } + }, + "parseFloat": parseFloat, + "parseInt": parseInt, + "RegExp": RegExp, + "String": String, + "undefined": undefined + }, + rDigit = /\d/, + sExpressionParser = "sap.ui.base.ExpressionParser", + rIdentifier = /[a-z_$][a-z0-9_$]*/i, + rIdentifierStart = /[a-z_$]/i, + aPerformanceCategories = [sExpressionParser], + sPerformanceParse = sExpressionParser + "#parse", + mSymbols = { //symbol table + "BINDING": { + led: unexpected, // Note: cannot happen due to lbp: 0 + nud: function (oToken, oParser) { + return BINDING.bind(null, oToken.value); + } + }, + "ERROR": { + lbp: Infinity, + led: function (oToken, oParser, fnLeft) { + error(oToken.value.message, oToken.value.text, oToken.value.at); + }, + nud: function (oToken, oParser) { + error(oToken.value.message, oToken.value.text, oToken.value.at); + } + }, + "IDENTIFIER": { + led: unexpected, // Note: cannot happen due to lbp: 0 + nud: function (oToken, oParser) { + if (!(oToken.value in oParser.globals)) { + Log.warning("Unsupported global identifier '" + oToken.value + + "' in expression parser input '" + oParser.input + "'", + undefined, + sExpressionParser); + } + return CONSTANT.bind(null, oParser.globals[oToken.value]); + } + }, + "CONSTANT": { + led: unexpected, // Note: cannot happen due to lbp: 0 + nud: function (oToken, oParser) { + return CONSTANT.bind(null, oToken.value); + } + }, + ".": { + lbp: 18, + led: function (oToken, oParser, fnLeft) { + return DOT.bind(null, fnLeft, oParser.advance("IDENTIFIER").value); + }, + nud: unexpected + }, + "(": { + lbp: 17, + led: function (oToken, oParser, fnLeft) { + var aArguments = [], + bFirst = true; + + while (oParser.current().id !== ")") { + if (bFirst) { + bFirst = false; + } else { + oParser.advance(","); //consume "," from predecessor argument + } + aArguments.push(oParser.expression(0)); + } + oParser.advance(")"); + return FUNCTION_CALL.bind(null, fnLeft, aArguments); + }, + nud: function (oToken, oParser) { + var fnValue = oParser.expression(0); + + oParser.advance(")"); + return fnValue; + } + }, + "[": { + lbp: 18, + led: function (oToken, oParser, fnLeft) { + var fnName = oParser.expression(0); + + oParser.advance("]"); + return PROPERTY_ACCESS.bind(null, fnLeft, fnName); + }, + nud: function (oToken, oParser) { + var aElements = [], + bFirst = true; + + while (oParser.current().id !== "]") { + if (bFirst) { + bFirst = false; + } else { + oParser.advance(","); //consume "," from predecessor element + } + aElements.push( + oParser.current().id === "," ? fnUndefined : oParser.expression(0)); + } + oParser.advance("]"); + return ARRAY.bind(null, aElements); + } + }, + "!": { + lbp: 15, + led: unexpected, + nud: function (oToken, oParser) { + return UNARY.bind(null, oParser.expression(this.lbp), function (x) { + return !x; + }); + } + }, + "typeof": { + lbp: 15, + led: unexpected, + nud: function (oToken, oParser) { + return UNARY.bind(null, oParser.expression(this.lbp), function (x) { + return typeof x; + }); + } + }, + "?": { + lbp: 4, + led: function (oToken, oParser, fnLeft) { + var fnElse, fnThen; + + fnThen = oParser.expression(this.lbp - 1); + oParser.advance(":"); + fnElse = oParser.expression(this.lbp - 1); + return CONDITIONAL.bind(null, fnLeft, fnThen, fnElse); + }, + nud: unexpected + }, + ")": { + led: unexpected, + nud: unexpected + }, + "]": { + led: unexpected, + nud: unexpected + }, + "{": { + led: unexpected, + nud: function (oToken, oParser) { + var bFirst = true, + sKey, + mMap = {}, + fnValue; + + while (oParser.current().id !== "}") { + if (bFirst) { + bFirst = false; + } else { + oParser.advance(","); + } + if (oParser.current() && oParser.current().id === "CONSTANT" + && typeof oParser.current().value === "string") { + sKey = oParser.advance().value; + } else { + sKey = oParser.advance("IDENTIFIER").value; + } + oParser.advance(":"); + fnValue = oParser.expression(0); + mMap[sKey] = fnValue; + } + oParser.advance("}"); + return MAP.bind(null, mMap); + } + }, + "}": { + lbp: -1, // Note: also terminates end of our input! + led: unexpected, + nud: unexpected + }, + ",": { + led: unexpected, + nud: unexpected + }, + ":": { + led: unexpected, + nud: unexpected + } + }, + //Fix length tokens. A token being a prefix of another must come last, e.g. ! after !== + aTokens = ["===", "!==", "!", "||", "&&", ".", "(", ")", "{", "}", ":", ",", "?", "*", + "/", "%", "+", "-", "<=", "<", ">=", ">", "[", "]"], + rTokens; + +aTokens.forEach(function (sToken, i) { + // Note: this function is executed at load time only! + aTokens[i] = escapeRegExp(sToken); +}); +rTokens = new RegExp(aTokens.join("|"), "g"); + +addInfix("*", 14, function (x, y) { + return x * y; +}); +addInfix("/", 14, function (x, y) { + return x / y; +}); +addInfix("%", 14, function (x, y) { + return x % y; +}); +addInfix("+", 13, function (x, y) { + return x + y; +}).nud = function (oToken, oParser) { + return UNARY.bind(null, oParser.expression(this.lbp), function (x) { + return +x; + }); +}; +addInfix("-", 13, function (x, y) { + return x - y; +}).nud = function (oToken, oParser) { + return UNARY.bind(null, oParser.expression(this.lbp), function (x) { + return -x; + }); +}; +addInfix("<=", 11, function (x, y) { + return x <= y; +}); +addInfix("<", 11, function (x, y) { + return x < y; +}); +addInfix(">=", 11, function (x, y) { + return x >= y; +}); +addInfix(">", 11, function (x, y) { + return x > y; +}); +addInfix("in", 11, function (x, y) { + return x in y; +}); +addInfix("===", 10, function (x, y) { + return x === y; +}); +addInfix("!==", 10, function (x, y) { + return x !== y; +}); +addInfix("&&", 7, function (x, fnY) { + return x && fnY(); +}, true); +addInfix("||", 6, function (x, fnY) { + return x || fnY(); +}, true); + +//Formatter functions to evaluate symbols like literals or operators in the expression grammar +/** + * Formatter function for an array literal. + * @param {function[]} aElements - array of formatter functions for the array elements + * @param {any[]} aParts - the array of binding values + * @return {any[]} - the resulting array value + */ +function ARRAY(aElements, aParts) { + return aElements.map(function (fnElement) { + return fnElement(aParts); + }); +} + +/** + * Formatter function for an embedded binding. + * @param {int} i - the index of the binding as it appears when reading the + * expression from the left + * @param {any[]} aParts - the array of binding values + * @returns {any} the binding value + */ +function BINDING(i, aParts) { + return clean(aParts[i]); +} + +/** + * Formatter function for executing the conditional operator with the given condition, "then" + * and "else" clause. + * @param {function} fnCondition - formatter function for the condition + * @param {function} fnThen - formatter function for the "then" clause + * @param {function} fnElse - formatter function for the "else" clause + * @param {any[]} aParts - the array of binding values + * @return {any} - the value of the "then" or "else" clause, depending on the value of the + * condition + */ +function CONDITIONAL(fnCondition, fnThen, fnElse, aParts) { + return fnCondition(aParts) ? fnThen(aParts) : fnElse(aParts); +} + +/** + * Formatter function for any constant value such as a literal or identifier. + * @param {any} v - any value + * @returns {any} the given value + */ +function CONSTANT(v) { + return v; +} + +/** + * Formatter function for member access via the dot operator. + * @param {function} fnLeft - formatter function for the left operand + * @param {string} sIdentifier - the identifier on the dot's right side + * @param {any[]} aParts - the array of binding values + * @param {object} [oReference] + * optional side channel to return the base value (left operand) of the reference + * @return {any} - the left operand's member with the name + */ +function DOT(fnLeft, sIdentifier, aParts, oReference) { + var oParent = fnLeft(aParts), + vChild = oParent[sIdentifier]; + + if (oReference) { + oReference.base = oParent; + } + return clean(vChild); +} + +/** + * Formatter function for a call to the function returned by fnLeft. + * @param {function} fnLeft - formatter function for the left operand: the function to call + * @param {function[]} aArguments - array of formatter functions for the arguments + * @param {any[]} aParts - the array of binding values + * @return {any} - the return value of the function applied to the arguments + */ +function FUNCTION_CALL(fnLeft, aArguments, aParts) { + var oReference = {}; + + // evaluate function expression and call it + return clean(fnLeft(aParts, oReference).apply(oReference.base, + aArguments.map(function (fnArgument) { + return fnArgument(aParts); // evaluate argument + }))); +} + +/** + * Formatter function for an infix operator. + * + * @param {function} fnLeft - formatter function for the left operand + * @param {function} fnRight - formatter function for the right operand + * @param {function} fnOperator + * function taking two arguments which evaluates the infix operator + * @param {boolean} bLazy - whether the right operand is e + * @param {any[]} aParts - the array of binding values + * @return {any} - the result of the operator function applied to the two operands + */ +function INFIX(fnLeft, fnRight, fnOperator, bLazy, aParts) { + return fnOperator(fnLeft(aParts), + bLazy ? fnRight.bind(null, aParts) : fnRight(aParts)); +} + +/** + * Formatter function for an object literal. + * @param {object} mMap - map from key to formatter functions for the values + * @param {any[]} aParts - the array of binding values + * @return {object} - the resulting map + */ +function MAP(mMap, aParts) { + var sKey, mResult = {}; + + for (sKey in mMap) { + mResult[sKey] = mMap[sKey](aParts); // evaluate value + } + return mResult; +} + +/** + * Formatter function for a property access. + * @param {function} fnLeft - formatter function for the left operand: the array or object to + * access + * @param {function} fnName - formatter function for the property name + * @param {any[]} aParts - the array of binding values + * @param {object} [oReference] + * optional side channel to return the base value (left operand) of the reference + * @return {any} - the array element or object property + */ +function PROPERTY_ACCESS(fnLeft, fnName, aParts, oReference) { + var oParent = fnLeft(aParts), + sIdentifier = fnName(aParts), // BEWARE: evaluate propertyNameValue AFTER baseValue! + vChild = oParent[sIdentifier]; + + if (oReference) { + oReference.base = oParent; + } + return clean(vChild); +} + +/** + * Formatter function for a unary operator. + * + * @param {function} fnRight - formatter function for the operand + * @param {function} fnOperator + * function to evaluate the unary operator taking one argument + * @param {any[]} aParts - the array of binding values + * @return {any} - the result of the operator function applied to the operand + */ +function UNARY(fnRight, fnOperator, aParts) { + return fnOperator(fnRight(aParts)); +} + +/** + * Adds the infix operator with the given id, binding power and formatter function to the + * symbol table. + * @param {string} sId - the id of the infix operator + * @param {int} iBindingPower - the binding power = precedence of the infix operator + * @param {function} fnOperator - the function to evaluate the operator + * @param {boolean} [bLazy=false] - whether the right operand is lazily evaluated + * @return {object} the newly created symbol for the infix operator + */ +function addInfix(sId, iBindingPower, fnOperator, bLazy) { + // Note: this function is executed at load time only! + mSymbols[sId] = { + lbp: iBindingPower, + led: function (oToken, oParser, fnLeft) { + //lazy evaluation is right associative: performance optimization for guard and + //default operator, e.g. true || A || B || C does not execute the || for B and C + var rbp = bLazy ? this.lbp - 1 : this.lbp; + + return INFIX.bind(null, fnLeft, oParser.expression(rbp), + fnOperator, bLazy); + }, + nud: unexpected + }; + return mSymbols[sId]; +} + +/** + * Cleans the given vValue. + * + * @param {any} vValue - the value to be cleaned + * @returns {any} the cleaned value + */ +function clean(vValue) { + return vValue === Function ? undefined : vValue; +} + +/** + * Throws a SyntaxError with the given sMessage as message, its + * at property set to iAt and its text property to + * sInput. + * In addition, logs a corresponding error message to the console with sInput + * as details. + * + * @param {string} sMessage - the error message + * @param {string} sInput - the input string + * @param {int} [iAt] - the index in the input string where the error occurred; the index + * starts counting at 1 to be consistent with positions provided in tokenizer error messages. + */ +function error(sMessage, sInput, iAt) { + var oError = new SyntaxError(sMessage); + + oError.at = iAt; + oError.text = sInput; + if (iAt !== undefined) { + sMessage += " at position " + iAt; + } + Log.error(sMessage, sInput, sExpressionParser); + throw oError; +} + +/** + * Throws and logs an error for the unexpected token oToken. + * @param {object} oToken - the unexpected token + */ +function unexpected(oToken) { + // Note: position for error starts counting at 1 + error("Unexpected " + oToken.id, oToken.input, oToken.start + 1); +} + +/** + * Computes the tokens according to the expression grammar in sInput starting at iStart and + * uses fnResolveBinding to resolve bindings embedded in the expression. + * @param {function} fnResolveBinding - the function to resolve embedded bindings + * @param {string} sInput - the string to be parsed + * @param {int} [iStart=0] - the index to start parsing + * @returns {object} Tokenization result object with the following properties + * at: the index after the last character consumed by the tokenizer in the input string + * parts: array with parts corresponding to resolved embedded bindings + * tokens: the array of tokens where each token is a tuple of ID, optional value, and + * optional source text + */ +function tokenize(fnResolveBinding, sInput, iStart) { + var aParts = [], // the resulting parts (corresponds to aPrimitiveValueBindings) + aPrimitiveValueBindings = [], // the bindings with primitive values only + aTokens = [], + oTokenizer = new JSTokenizer(); + + /** + * Saves the binding as a part. Reuses an existing part if the binding is identical. + * @param {object} oBinding + * the binding to save + * @param {int} iStart + * the binding's start index in the input string + * @param {boolean} [bTargetTypeAny=false] + * whether the binding's "targetType" should default to "any" (recursively, for all parts) + * @returns {int} + * the index at which it has been saved/found in aParts + */ + function saveBindingAsPart(oBinding, iStart, bTargetTypeAny) { + var bHasNonPrimitiveValue = false, + sKey, + oPrimitiveValueBinding, + i; + + /* + * Sets the target type of the given binding to the default "any", if applicable. + * + * @param {object} oBinding + * A binding + */ + function setTargetType(oBinding) { + if (bTargetTypeAny) { + if (oBinding.parts) { + oBinding.parts.forEach(setTargetType); + // Note: targetType not allowed here, see BindingParser.mergeParts + } else { + oBinding.targetType = oBinding.targetType || "any"; + } + } + } + + for (sKey in oBinding) { + if (sKey === "parameters") { + // parameters are not converted from name to object, but even a simple binding + // may have the implicit object parameter "scope" + continue; + } + switch (typeof oBinding[sKey]) { + case "boolean": + case "number": + case "string": + case "undefined": + break; + default: + // binding has at least one property of non-primitive value + bHasNonPrimitiveValue = true; + } + } + setTargetType(oBinding); + if (bHasNonPrimitiveValue) { + // the binding must be a complex binding; property "type" (and poss. others) are + // newly created objects and thus incomparable -> parse again to have the names + oPrimitiveValueBinding = JSTokenizer.parseJS(sInput, iStart).result; + setTargetType(oPrimitiveValueBinding); + } else { + // only primitive values; easily comparable + oPrimitiveValueBinding = oBinding; + } + for (i = 0; i < aParts.length; i += 1) { + // Note: order of top-level properties must not matter for equality! + if (deepEqual(aPrimitiveValueBindings[i], oPrimitiveValueBinding)) { + return i; + } + } + aPrimitiveValueBindings[i] = oPrimitiveValueBinding; + aParts[i] = oBinding; + return i; + } + + /** + * Consumes the next token in the input string and pushes it to the array of tokens. + * + * @returns {boolean} whether a token is recognized + * @throws {Error|Object|SyntaxError} + * fnResolveBinding may throw SyntaxError; + * oTokenizer.setIndex() may throw Error; + * oTokenizer may also throw {name: 'SyntaxError', ...} + */ + function consumeToken() { + var ch, oBinding, iIndex, aMatches, oToken; + + oTokenizer.white(); + ch = oTokenizer.getCh(); + iIndex = oTokenizer.getIndex(); + + if ((ch === "$" || ch === "%") && sInput[iIndex + 1] === "{") { //binding + oBinding = fnResolveBinding(sInput, iIndex + 1); + oToken = { + id: "BINDING", + value: saveBindingAsPart(oBinding.result, iIndex + 1, ch === "%") + }; + oTokenizer.setIndex(oBinding.at); //go to first character after binding string + } else if (rIdentifierStart.test(ch)) { + aMatches = rIdentifier.exec(sInput.slice(iIndex)); + switch (aMatches[0]) { + case "false": + case "null": + case "true": + oToken = {id: "CONSTANT", value: oTokenizer.word()}; + break; + case "in": + case "typeof": + oToken = {id: aMatches[0]}; + oTokenizer.setIndex(iIndex + aMatches[0].length); + break; + default: + oToken = {id: "IDENTIFIER", value: aMatches[0]}; + oTokenizer.setIndex(iIndex + aMatches[0].length); + } + } else if (rDigit.test(ch) + || ch === "." && rDigit.test(sInput[iIndex + 1])) { + oToken = {id: "CONSTANT", value: oTokenizer.number()}; + } else if (ch === "'" || ch === '"') { + oToken = {id: "CONSTANT", value: oTokenizer.string()}; + } else { + rTokens.lastIndex = iIndex; + aMatches = rTokens.exec(sInput); + if (!aMatches || aMatches.index !== iIndex) { + return false; // end of input or unrecognized character + } + oToken = {id: aMatches[0]}; + oTokenizer.setIndex(iIndex + aMatches[0].length); + } + oToken.input = sInput; + oToken.start = iIndex; + oToken.end = oTokenizer.getIndex(); + aTokens.push(oToken); + return true; + } + + oTokenizer.init(sInput, iStart); + + try { + /* eslint-disable no-empty */ + while (consumeToken()) { /* deliberately empty */ } + /* eslint-enable no-empty */ + } catch (e) { + // Note: new SyntaxError().name === "SyntaxError" + if (e.name === "SyntaxError") { // remember tokenizer error + aTokens.push({ + id: "ERROR", + value: e + }); + } else { + throw e; + } + } + + return { + at: oTokenizer.getIndex(), + parts: aParts, + tokens: aTokens + }; +} + +/** + * Returns a function which wraps the given formatter function into a try/catch block. + * In case of an error it is caught, a warning containing the given original input is issued, + * and undefined is returned instead. + * + * @param {function} fnFormatter - any (formatter) function + * @param {string} sInput - the expression string (used when logging errors) + * @returns {function} - the wrapped function + */ +function tryCatch(fnFormatter, sInput) { + return function () { + try { + return fnFormatter.apply(this, arguments); + } catch (ex) { + Log.warning(String(ex), sInput, sExpressionParser); + } + }; +} + +/** + * Parses expression tokens to a result object as specified to be returned by + * {@link sap.ui.base.ExpressionParser#parse}. + * @param {object[]} aTokens - the array with the tokens + * @param {string} sInput - the expression string (used when logging errors) + * @param {object} mGlobals - the map of global variables + * @returns {object} the parse result with the following properties + * formatter: the formatter function to evaluate the expression which + * takes the parts corresponding to bindings embedded in the expression as + * parameters; undefined in case of an invalid expression + * at: the index of the first character after the expression in sInput, or + * undefined if all tokens have been consumed + */ +function parse(aTokens, sInput, mGlobals) { + var fnFormatter, + iNextToken = 0, + oParser = { + advance: advance, + current: current, + expression: expression, + globals: mGlobals, + input: sInput + }, + oToken; + + /** + * Returns the next token in the array of tokens and advances the index in this array. + * Throws an error if the next token's ID is not equal to the optional + * sExpectedTokenId. + * @param {string} [sExpectedTokenId] - the expected id of the next token + * @returns {object|undefined} - the next token or undefined if all tokens have been read + */ + function advance(sExpectedTokenId) { + var oToken = aTokens[iNextToken]; + + if (sExpectedTokenId) { + if (!oToken) { + error("Expected " + sExpectedTokenId + " but instead saw end of input", + sInput); + } else if (oToken.id !== sExpectedTokenId) { + error("Expected " + sExpectedTokenId + " but instead saw " + + sInput.slice(oToken.start, oToken.end), + sInput, + oToken.start + 1); + } + } + iNextToken += 1; + return oToken; + } + + /** + * Returns the next token in the array of tokens, but does not advance the index. + * @returns {object|undefined} - the next token or undefined if all tokens have been read + */ + function current() { + return aTokens[iNextToken]; + } + + /** + * Parse an expression starting at the current token. Throws an error if there are no more + * tokens and + * + * @param {number} rbp + * a "right binding power" + * @returns {function} The formatter function for the expression + */ + function expression(rbp) { + var fnLeft; + + oToken = advance(); + if (!oToken) { + error("Expected expression but instead saw end of input", sInput); + } + fnLeft = mSymbols[oToken.id].nud(oToken, oParser); + + while (iNextToken < aTokens.length) { + oToken = current(); + if (rbp >= (mSymbols[oToken.id].lbp || 0)) { + break; + } + advance(); + fnLeft = mSymbols[oToken.id].led(oToken, oParser, fnLeft); + } + + return fnLeft; + } + + fnFormatter = expression(0); // do this before calling current() below! + return { + at: current() && current().start, + // call separate function to reduce the closure size of the formatter + formatter: tryCatch(fnFormatter, sInput) + }; +} + +/** + * The parser to parse expressions in bindings. + * + * @alias sap.ui.base.ExpressionParser + * @private + */ +export default { + /** + * Parses a string sInput with an expression based on the syntax sketched + * below. + * + * If a start index iStart for parsing is provided, the input string is parsed + * starting from this index and the return value contains the index after the last + * character belonging to the expression. + * + * The expression syntax is a subset of JavaScript expression syntax with the + * enhancement that the only "variable" parts in an expression are bindings. + * The following expression constructs are supported: + * + * @param {function} fnResolveBinding - the function to resolve embedded bindings + * @param {string} sInput - the string to be parsed + * @param {int} [iStart=0] - the index to start parsing + * @param {object} [mGlobals] + * global variables allowed in the expression as map of variable name to its value; + * note that there is a default map of known global variables + * @param {object} [mLocals={}] + * local variables additionally allowed in the expression (shadowing global ones) + * as map of variable name to its value + * @returns {object} the parse result with the following properties + * result: object with the properties + * formatter: the formatter function to evaluate the expression which + * takes the parts corresponding to bindings embedded in the expression as + * parameters + * parts: the array of parts contained in the expression string which is + * empty if no parts exist + * at: the index of the first character after the expression in sInput + * @throws SyntaxError + * If the expression string is invalid or unsupported. The at property of + * the error contains the position where parsing failed. + */ + parse: function (fnResolveBinding, sInput, iStart, mGlobals, mLocals) { + var oResult, oTokens; + + /* UI5 LINTER MODIFICATION: + Disabled next if-clause. "Measurement" is not available in Node.js context + */ + // Measurement.average(sPerformanceParse, "", aPerformanceCategories); + oTokens = tokenize(fnResolveBinding, sInput, iStart); + mGlobals = mGlobals || mDefaultGlobals; + if (mLocals) { + mGlobals = Object.assign({}, mGlobals, mLocals); + } + oResult = parse(oTokens.tokens, sInput, mGlobals); + + /* UI5 LINTER MODIFICATION: + Disabled next if-clause. "Measurement" is not available in Node.js context + */ + // Measurement.end(sPerformanceParse); + if (!oTokens.parts.length) { + return { + /* UI5 LINTER MODIFICATION: + Disabled next line. ExpressionParser formatter functions are not required and shouldn't be executed + */ + // constant: oResult.formatter(), + at: oResult.at || oTokens.at + }; + } + + /* UI5 LINTER MODIFICATION: + Disabled the next few formatter-related lines. + The ExpressionParser formatter function is not required for linting purposes + */ + // function formatter() { + // //turn separate parameters for parts into one (array like) parameter + // return oResult.formatter(arguments); + // } + // formatter.textFragments = true; //use CompositeBinding even if there is only one part + return { + result: { + // formatter: formatter, + parts: oTokens.parts + }, + at: oResult.at || oTokens.at + }; + } +}; diff --git a/src/linter/binding/lib/strings/escapeRegExp.js b/src/linter/binding/lib/strings/escapeRegExp.js new file mode 100644 index 000000000..f7724a8be --- /dev/null +++ b/src/linter/binding/lib/strings/escapeRegExp.js @@ -0,0 +1,36 @@ +/* + * This is a copy of the sap/base/strings/escapeRegExp.js module of the OpenUI5 project + * https://github.com/SAP/openui5/blob/a4507f0d4f8a56cc881e8983479c8f9b21bfb96b/src/sap.ui.core/src/sap/base/strings/escapeRegExp.js + */ + +var rEscapeRegExp = /[[\]{}()*+?.\\^$|]/g; + +/** + * Escapes all characters that would have a special meaning in a regular expression. + * + * This method can be used when a string with arbitrary content has to be integrated + * into a regular expression and when the whole string should match literally. + * + * @example + * sap.ui.require(["sap/base/strings/escapeRegExp"], function(escapeRegExp) { + * + * var text = "E=m*c^2"; // text to search + * var search = "m*c"; // text to search for + * + * text.match( new RegExp( search ) ); // [ "c" ] + * text.match( new RegExp( escapeRegExp(search) ) ); // [ "m*c" ] + * + * }); + * + * @function + * @since 1.58 + * @alias module:sap/base/strings/escapeRegExp + * @param {string} sString String to escape + * @returns {string} The escaped string + * @public + * @SecPassthrough {0|return} + */ +var fnEscapeRegExp = function (sString) { + return sString.replace(rEscapeRegExp, "\\$&"); +}; +export default fnEscapeRegExp; diff --git a/src/linter/binding/lib/util/deepEqual.js b/src/linter/binding/lib/util/deepEqual.js new file mode 100644 index 000000000..6fedf27d4 --- /dev/null +++ b/src/linter/binding/lib/util/deepEqual.js @@ -0,0 +1,90 @@ +/* + * This is a copy of the sap/base/util/deepEqual.js module of the OpenUI5 project + * https://github.com/SAP/openui5/blob/a4507f0d4f8a56cc881e8983479c8f9b21bfb96b/src/sap.ui.core/src/sap/base/util/deepEqual.js + */ +/* eslint-disable */ + +/** + * Compares the two given values for equality, especially by comparing the content. + * + * Note: Function does not work with comparing XML objects. + * + * @function + * @since 1.58 + * @param {any} a A value of any type + * @param {any} b A value of any type + * @param {int} [maxDepth=10] Maximum recursion depth + * @param {boolean} [contains] Whether all existing properties in a are equal as in b + * @alias module:sap/base/util/deepEqual + * @return {boolean} Whether a and b are equal + * @public + */ +var fnEqual = function(a, b, maxDepth, contains, depth) { + // Optional parameter normalization + if (typeof maxDepth == "boolean") { + contains = maxDepth; + maxDepth = undefined; + } + if (!depth) { + depth = 0; + } + if (!maxDepth) { + maxDepth = 10; + } + if (depth > maxDepth) { + /* UI5 LINTER MODIFICATION: + Disabled next line. "Log" is not available in Node.js context, and the warning likely not helpful + to UI5 linter users. + */ + // Log.warning("deepEqual comparison exceeded maximum recursion depth of " + maxDepth + ". Treating values as unequal"); + return false; + } + + if (a === b || Number.isNaN(a) && Number.isNaN(b)) { + return true; + } + + if (Array.isArray(a) && Array.isArray(b)) { + if (!contains && a.length !== b.length) { + return false; + } + if (a.length > b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (!fnEqual(a[i], b[i], maxDepth, contains, depth + 1)) { + return false; + } + } + return true; + } + if (typeof a == "object" && typeof b == "object") { + if (!a || !b) { + return false; + } + if (a.constructor !== b.constructor) { + return false; + } + if (!contains && Object.keys(a).length !== Object.keys(b).length) { + return false; + } + /* UI5 LINTER MODIFICATION: + Disabled next if-clause. "Node" is not available in Node.js context + */ + // if (a instanceof Node) { + // return a.isEqualNode(b); + // } + if (a instanceof Date) { + return a.valueOf() === b.valueOf(); + } + for (var i in a) { + if (!fnEqual(a[i], b[i], maxDepth, contains, depth + 1)) { + return false; + } + } + return true; + } + return false; +}; + +export default fnEqual; diff --git a/src/linter/xmlTemplate/Parser.ts b/src/linter/xmlTemplate/Parser.ts index feb857d74..476c242ce 100644 --- a/src/linter/xmlTemplate/Parser.ts +++ b/src/linter/xmlTemplate/Parser.ts @@ -10,6 +10,7 @@ import {getLogger} from "@ui5/logger"; import {MESSAGE} from "../messages.js"; import {ApiExtract} from "../../utils/ApiExtract.js"; import ControllerByIdInfo from "./ControllerByIdInfo.js"; +import BindingLinter from "../binding/BindingLinter.js"; const log = getLogger("linter:xmlTemplate:Parser"); export type Namespace = string; @@ -81,7 +82,7 @@ export interface RequireExpression extends AttributeDeclaration { export interface RequireDeclaration { moduleName: string; - variableName?: string; + variableName: string; } interface NamespaceStackEntry { @@ -104,13 +105,13 @@ const enum DocumentKind { Fragment, } -function determineDocumentKind(resourceName: string): DocumentKind | null { - if (/\.view.xml$/.test(resourceName)) { +function determineDocumentKind(resourcePath: string): DocumentKind | null { + if (/\.view.xml$/.test(resourcePath)) { return DocumentKind.View; - } else if (/\.fragment.xml$/.test(resourceName)) { + } else if (/\.fragment.xml$/.test(resourcePath)) { return DocumentKind.Fragment; - } else if (/\.control.xml$/.test(resourceName)) { - throw new Error(`Control XML analysis is currently not supported for resource ${resourceName}`); + } else if (/\.control.xml$/.test(resourcePath)) { + throw new Error(`Control XML analysis is currently not supported for resource ${resourcePath}`); } else { return null; } @@ -124,31 +125,39 @@ function toPosition(saxPos: SaxPosition): Position { } export default class Parser { - #resourceName: string; + #resourcePath: string; #xmlDocumentKind: DocumentKind; #context: LinterContext; #namespaceStack: NamespaceStackEntry[] = []; #nodeStack: NodeDeclaration[] = []; + // 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: RequireDeclaration[] = []; + #bindingLinter: BindingLinter; + #generator: AbstractGenerator; #apiExtract: ApiExtract; constructor( - resourceName: string, apiExtract: ApiExtract, context: LinterContext, controllerByIdInfo: ControllerByIdInfo + resourcePath: string, apiExtract: ApiExtract, context: LinterContext, controllerByIdInfo: ControllerByIdInfo ) { - const xmlDocumentKind = determineDocumentKind(resourceName); + const xmlDocumentKind = determineDocumentKind(resourcePath); if (xmlDocumentKind === null) { - throw new Error(`Unknown document type for resource ${resourceName}`); + throw new Error(`Unknown document type for resource ${resourcePath}`); } - this.#resourceName = resourceName; + this.#resourcePath = resourcePath; this.#xmlDocumentKind = xmlDocumentKind; this.#generator = xmlDocumentKind === DocumentKind.View ? - new ViewGenerator(resourceName, controllerByIdInfo) : - new FragmentGenerator(resourceName, controllerByIdInfo); + new ViewGenerator(resourcePath, controllerByIdInfo) : + new FragmentGenerator(resourcePath, controllerByIdInfo); this.#apiExtract = apiExtract; this.#context = context; + + this.#bindingLinter = new BindingLinter(resourcePath, context); } pushTag(tag: SaxTag) { @@ -225,7 +234,7 @@ export default class Parser { if (!aggregationName) { log.verbose(`Failed to determine default aggregation for control ${owner.name} used in ` + - `resource ${this.#resourceName}. Falling back to 'dependents'`); + `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 @@ -258,7 +267,7 @@ export default class Parser { }; }); } catch (_) { - throw new Error(`Failed to parse require attribute value ${attrValue} in resource ${this.#resourceName}`); + throw new Error(`Failed to parse require attribute value ${attrValue} in resource ${this.#resourcePath}`); } } @@ -318,10 +327,10 @@ export default class Parser { // by one of them let namespace = this._resolveNamespace(tagNamespace); if (!namespace) { - throw new Error(`Unknown namespace ${tagNamespace} for tag ${tagName} in resource ${this.#resourceName}`); + 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.#resourceName, + this.#context.addLintingMessage(this.#resourcePath, MESSAGE.SVG_IN_XML, undefined as never, { @@ -338,7 +347,7 @@ export default class Parser { }; } else if (namespace === XHTML_NAMESPACE) { // Ignore XHTML nodes for now - this.#context.addLintingMessage(this.#resourceName, + this.#context.addLintingMessage(this.#resourcePath, MESSAGE.HTML_IN_XML, undefined as never, { @@ -393,7 +402,7 @@ export default class Parser { const resolvedNamespace = this._resolveNamespace(attr.localNamespace); if (!resolvedNamespace) { throw new Error(`Unknown namespace ${attr.localNamespace} for attribute ${attr.name} ` + - `in resource ${this.#resourceName}`); + `in resource ${this.#resourcePath}`); } if ((resolvedNamespace === CORE_NAMESPACE || resolvedNamespace === TEMPLATING_NAMESPACE) && attr.name === "require") { @@ -419,7 +428,8 @@ export default class Parser { }); }); } else { - // Common case: JSON-like representation + // Most common case: JSON-like representation + // e.g. core:require="{Helper: 'sap/ui/demo/todo/util/Helper'}" requireDeclarations = this._parseRequireAttribute(attr.value); } const requireExpression = { @@ -430,6 +440,7 @@ export default class Parser { end: attr.end, } as RequireExpression; + this.#requireDeclarations.push(...requireDeclarations); this.#generator.writeRequire(requireExpression); } else if (resolvedNamespace === FESR_NAMESPACE || resolvedNamespace === SAP_BUILD_NAMESPACE || resolvedNamespace === SAP_UI_DT_NAMESPACE) { @@ -464,7 +475,7 @@ export default class Parser { this.#generator.writeControl(customData); } else { log.verbose(`Ignoring unknown namespaced attribute ${attr.localNamespace}:${attr.name} ` + - `for ${moduleName} in resource ${this.#resourceName}`); + `for ${moduleName} in resource ${this.#resourcePath}`); } } else { controlProperties.add(attr); @@ -482,7 +493,7 @@ export default class Parser { if (!parentNode || parentNode.kind === NodeKind.FragmentDefinition) { if (this.#xmlDocumentKind !== DocumentKind.Fragment) { throw new Error(`Unexpected top-level aggregation declaration: ` + - `${aggregationName} in resource ${this.#resourceName}`); + `${aggregationName} in resource ${this.#resourcePath}`); } // 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 @@ -498,7 +509,7 @@ export default class Parser { return coreControl; } else if (parentNode.kind === NodeKind.Aggregation) { throw new Error(`Unexpected aggregation ${aggregationName} within aggregation ${parentNode.name} ` + - `in resource ${this.#resourceName}`); + `in resource ${this.#resourcePath}`); } const owner = parentNode as ControlDeclaration; @@ -531,10 +542,16 @@ export default class Parser { }; if (parentNode) { - throw new Error(`Unexpected nested FragmentDefiniton in resource ${this.#resourceName}`); + throw new Error(`Unexpected nested FragmentDefiniton 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) + if (this.#apiExtract.isProperty(`${namespace}.${moduleName}`, prop.name)) { + this.#bindingLinter.lintPropertyBinding(prop.value, this.#requireDeclarations, prop.start); + } + } // This node declares a control // Or a fragment definition in case of a fragment const node: ControlDeclaration = { @@ -569,7 +586,7 @@ export default class Parser { // Add the control to the fragment definition (parentNode as FragmentDefinitionDeclaration).controls.add(node); } else { - throw new Error(`Unexpected node kind ${parentNode.kind} in resource ${this.#resourceName}`); + throw new Error(`Unexpected node kind ${parentNode.kind} in resource ${this.#resourcePath}`); } } return node; diff --git a/src/linter/xmlTemplate/lib/JSTokenizer.js b/src/linter/xmlTemplate/lib/JSTokenizer.js index 7e173fcbc..66473e72e 100644 --- a/src/linter/xmlTemplate/lib/JSTokenizer.js +++ b/src/linter/xmlTemplate/lib/JSTokenizer.js @@ -61,12 +61,10 @@ JSTokenizer.prototype.error = function(m) { // Call error when something is wrong. - throw { - name: 'SyntaxError', - message: m, - at: this.at, - text: this.text - }; + const err = new SyntaxError(m); + err.at = this.at; + err.text = this.text; + throw err; }; JSTokenizer.prototype.next = function(c) { @@ -175,7 +173,7 @@ if (allowed(this.ch)) { name += this.ch; } else { - this.error("Bad name"); + this.error("Bad name: " + this.ch); } while (this.next()) { @@ -189,7 +187,7 @@ if (allowed(this.ch)) { name += this.ch; } else { - this.error("Bad name"); + this.error("Bad name: " + this.ch); } } this.error("Bad name"); diff --git a/test/fixtures/transpiler/xml/XMLFragmentBindings.fragment.xml b/test/fixtures/transpiler/xml/XMLFragmentBindings.fragment.xml new file mode 100644 index 000000000..9ffdd80c6 --- /dev/null +++ b/test/fixtures/transpiler/xml/XMLFragmentBindings.fragment.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + diff --git a/test/fixtures/transpiler/xml/XMLViewBindings.view.xml b/test/fixtures/transpiler/xml/XMLViewBindings.view.xml new file mode 100644 index 000000000..2c7b51b6f --- /dev/null +++ b/test/fixtures/transpiler/xml/XMLViewBindings.view.xml @@ -0,0 +1,41 @@ + + + + + + + + + + diff --git a/test/lib/linter/binding/BindingLinter.ts b/test/lib/linter/binding/BindingLinter.ts new file mode 100644 index 000000000..8cddbdb53 --- /dev/null +++ b/test/lib/linter/binding/BindingLinter.ts @@ -0,0 +1,126 @@ +import anyTest, {TestFn} from "ava"; +import BindingLinter from "../../../../src/linter/binding/BindingLinter.js"; +import LinterContext from "../../../../src/linter/LinterContext.js"; + +const test = anyTest as TestFn<{ + linterContext: LinterContext; + bindingLinter: BindingLinter; +}>; + +test.beforeEach((t) => { + t.context.linterContext = new LinterContext({ + rootDir: "/", + }); + t.context.bindingLinter = new BindingLinter("/test.js", t.context.linterContext); +}); + +test("XML Property Binding: Global Formatter", (t) => { + const {bindingLinter, linterContext} = t.context; + + // Accessing formatter functions using global notation is no longer supported + bindingLinter.lintPropertyBinding(`{ + path: 'invoice>Status', + formatter: 'ui5.walkthrough.model.formatter.statusText' + }`, [], {line: 1, column: 1}); + + t.snapshot(linterContext.generateLintResult("/test.js")); +}); + +test("XML Property Binding: Global Formatter with bind call", (t) => { + const {bindingLinter, linterContext} = t.context; + + // Accessing formatter functions using global notation is no longer supported + bindingLinter.lintPropertyBinding(`{ + path: 'invoice>Status', + formatter: 'ui5.walkthrough.model.formatter.statusText.bind($controller)' + }`, [], {line: 1, column: 1}); + + t.snapshot(linterContext.generateLintResult("/test.js")); +}); + +test("XML Property Binding: Controller Formatter", (t) => { + const {bindingLinter, linterContext} = t.context; + + // Formatter functions on the controller can be referenced using a dot "." prefix + bindingLinter.lintPropertyBinding(`{ + path: 'invoice>Status', + formatter: '.statusText' + }`, [{ + moduleName: "some/formatter/module", + variableName: "unusedFormatter", + }], {line: 1, column: 1}); + + t.snapshot(linterContext.generateLintResult("/test.js")); +}); + +test("XML Property Binding: Imported Formatter", (t) => { + const {bindingLinter, linterContext} = t.context; + + // Formatter functions of properly imported modules can be accessed using the variable name + bindingLinter.lintPropertyBinding(`{ + path: 'invoice>Status', + formatter: 'Formatter.statusText' + }`, [{ + moduleName: "some/formatter/module", + variableName: "Formatter", + }], {line: 1, column: 1}); + + t.snapshot(linterContext.generateLintResult("/test.js")); +}); + +test("XML Property Binding: Imported Formatter with bind call", (t) => { + const {bindingLinter, linterContext} = t.context; + + // Formatter functions of properly imported modules can be accessed using the variable name + bindingLinter.lintPropertyBinding(`{ + path: 'invoice>Status', + formatter: 'Formatter.statusText.bind($controller)' + }`, [{ + moduleName: "some/formatter/module", + variableName: "Formatter", + }], {line: 1, column: 1}); + + t.snapshot(linterContext.generateLintResult("/test.js")); +}); + +test("XML Property Binding: Global Event Handler", (t) => { + const {bindingLinter, linterContext} = t.context; + + bindingLinter.lintPropertyBinding(`{ + path: '/firstName', + events: { + dataRequested: 'global.onMyDataRequested' + } + }`, [], {line: 1, column: 1}); + + t.snapshot(linterContext.generateLintResult("/test.js")); +}); + +test("XML Property Binding: Controller Event Handler", (t) => { + const {bindingLinter, linterContext} = t.context; + + bindingLinter.lintPropertyBinding(`{ + path: '/firstName', + events: { + dataRequested: '.onMyDataRequested' + } + }`, [], {line: 1, column: 1}); + + t.snapshot(linterContext.generateLintResult("/test.js")); +}); + +test("XML Property Binding: Imported Event Handler", (t) => { + const {bindingLinter, linterContext} = t.context; + + bindingLinter.lintPropertyBinding(`{ + path: '/firstName', + events: { + dataRequested: 'Handler.onMyDataRequested' + } + }`, [{ + moduleName: "some/event/handler", + variableName: "Handler", + }], {line: 1, column: 1}); + + t.snapshot(linterContext.generateLintResult("/test.js")); +}); diff --git a/test/lib/linter/binding/lib/BindingParser.ts b/test/lib/linter/binding/lib/BindingParser.ts new file mode 100644 index 000000000..1dae5a002 --- /dev/null +++ b/test/lib/linter/binding/lib/BindingParser.ts @@ -0,0 +1,118 @@ +import anyTest, {TestFn} from "ava"; +import BindingParser, {BindingInfo} from "../../../../../src/linter/binding/lib/BindingParser.js"; + +const test = anyTest as TestFn<{ + parse: (string: string) => BindingInfo; +}>; + +test.before((t) => { + t.context.parse = function (string: string) { + return BindingParser.complexParser(string, null, true, true, true, true); + }; +}); + +test("XML Binding: Formatter", (t) => { + const {parse} = t.context; + + // Formatters using global notation are no longer supported. + const res = parse(`{ + path: 'invoice>Status', + formatter: 'ui5.walkthrough.model.formatter.statusText' + }`); + t.snapshot(res); +}); + +test("XML Binding: Formatter bound to controller", (t) => { + const {parse} = t.context; + + const res = parse(`{ + path: 'invoice>Status', + formatter: 'Formatter.statusText.bind($controller)' + }`); + t.snapshot(res); +}); + +test("XML Binding: Type", (t) => { + const {parse} = t.context; + + const res = parse(`{ + formatOptions: {showDate: false, showTime: false}, + parts: [{value: null}, {path: 'TimezoneID'}], + type: 'sap.ui.model.odata.type.DateTimeWithTimezone' + }`); + t.snapshot(res); +}); + +test("XML Binding: Calculated Fields Local formatter", (t) => { + const {parse} = t.context; + + const res = parse(`Hello Mr. { + path: '/singleEntry/firstName', + formatter: '.myFormatter' + }, + { + /singleEntry/lastName + } + `); + t.snapshot(res); +}); + +test("XML Binding: Calculated Fields global formatter", (t) => { + const {parse} = t.context; + + const res = parse(`Hello Mr. { + path:'/singleEntry/firstName', + formatter: 'global.yFormatter' + }, + { + /singleEntry/lastName + } + `); + t.snapshot(res); +}); + +test("XML Binding: Parts", (t) => { + const {parse} = t.context; + + const res = parse(`{ + parts: [ + {path:'birthday/day'}, + {path:'birthday/month'}, + {path:'birthday/year'} + ], + formatter:'my.globalFormatter' + }`); + t.snapshot(res); +}); + +test("XML Binding: Event Handler", (t) => { + const {parse} = t.context; + + const res = parse(`{ + path: '/firstName', + events: { + dataRequested: '.onMyDataRequested' + } + }`); + t.snapshot(res); +}); + +test("XML Binding: Expression Binding", (t) => { + const {parse} = t.context; + + const res = parse(`{= %{status} === 'critical' }`); + t.snapshot(res); +}); + +test("XML Binding: Expression Binding with an embedded composite binding", (t) => { + const {parse} = t.context; + + const res = parse(`{= %{/data/message}.length < 20 + ? %{i18n>errorMsg} + : %{parts: [ + {path: 'i18n>successMsg'}, + {path: '/data/today', type:'sap.ui.model.type.Date', constraints:{displayFormat:'Date'}}, + {path: '/data/tomorrow', type:'sap.ui.model.type.Date', constraints:{displayFormat:'Date'}} + ], formatter: 'my.globalFormatter'}}`); + t.snapshot(res); +}); diff --git a/test/lib/linter/binding/lib/snapshots/BindingParser.ts.md b/test/lib/linter/binding/lib/snapshots/BindingParser.ts.md new file mode 100644 index 000000000..34a960152 --- /dev/null +++ b/test/lib/linter/binding/lib/snapshots/BindingParser.ts.md @@ -0,0 +1,174 @@ +# Snapshot report for `test/lib/linter/binding/lib/BindingParser.ts` + +The actual snapshot is saved in `BindingParser.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## XML Binding: Formatter + +> Snapshot 1 + + { + formatter: 'ui5.walkthrough.model.formatter.statusText', + path: 'invoice>Status', + } + +## XML Binding: Formatter bound to controller + +> Snapshot 1 + + { + formatter: 'Formatter.statusText.bind($controller)', + path: 'invoice>Status', + } + +## XML Binding: Type + +> Snapshot 1 + + { + formatOptions: { + showDate: false, + showTime: false, + }, + parts: [ + { + value: null, + }, + { + path: 'TimezoneID', + }, + ], + type: 'sap.ui.model.odata.type.DateTimeWithTimezone', + } + +## XML Binding: Calculated Fields Local formatter + +> Snapshot 1 + + { + parts: [ + { + formatter: '.myFormatter', + path: '/singleEntry/firstName', + }, + { + path: `␊ + /singleEntry/lastName␊ + `, + }, + ], + } + +## XML Binding: Calculated Fields global formatter + +> Snapshot 1 + + { + parts: [ + { + formatter: 'global.yFormatter', + path: '/singleEntry/firstName', + }, + { + path: `␊ + /singleEntry/lastName␊ + `, + }, + ], + } + +## XML Binding: Parts + +> Snapshot 1 + + { + formatter: 'my.globalFormatter', + parts: [ + { + path: 'birthday/day', + }, + { + path: 'birthday/month', + }, + { + path: 'birthday/year', + }, + ], + } + +## XML Binding: Event Handler + +> Snapshot 1 + + { + events: { + dataRequested: '.onMyDataRequested', + }, + path: '/firstName', + } + +## XML Binding: Expression Binding + +> Snapshot 1 + + { + parts: [ + { + mode: 'OneWay', + path: 'status', + targetType: 'any', + }, + ], + } + +## XML Binding: Expression Binding with an embedded composite binding + +> Snapshot 1 + + { + formatter: [ + 'my.globalFormatter', + ], + parts: [ + { + mode: 'OneWay', + path: '/data/message', + targetType: 'any', + }, + { + mode: 'OneWay', + model: 'i18n', + path: 'errorMsg', + targetType: 'any', + }, + { + formatter: 'my.globalFormatter', + parts: [ + { + mode: 'OneWay', + path: 'i18n>successMsg', + targetType: 'any', + }, + { + constraints: { + displayFormat: 'Date', + }, + mode: 'OneWay', + path: '/data/today', + targetType: 'any', + type: 'sap.ui.model.type.Date', + }, + { + constraints: { + displayFormat: 'Date', + }, + mode: 'OneWay', + path: '/data/tomorrow', + targetType: 'any', + type: 'sap.ui.model.type.Date', + }, + ], + }, + ], + } diff --git a/test/lib/linter/binding/lib/snapshots/BindingParser.ts.snap b/test/lib/linter/binding/lib/snapshots/BindingParser.ts.snap new file mode 100644 index 000000000..1c616c85c Binary files /dev/null and b/test/lib/linter/binding/lib/snapshots/BindingParser.ts.snap differ diff --git a/test/lib/linter/binding/snapshots/BindingLinter.ts.md b/test/lib/linter/binding/snapshots/BindingLinter.ts.md new file mode 100644 index 000000000..2e24306d0 --- /dev/null +++ b/test/lib/linter/binding/snapshots/BindingLinter.ts.md @@ -0,0 +1,133 @@ +# Snapshot report for `test/lib/linter/binding/BindingLinter.ts` + +The actual snapshot is saved in `BindingLinter.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## XML Property Binding: Global Formatter + +> Snapshot 1 + + { + coverageInfo: [], + errorCount: 1, + fatalErrorCount: 0, + filePath: '/test.js', + messages: [ + { + column: 1, + line: 1, + message: 'Access of global variable \'ui5\' (ui5.walkthrough.model.formatter.statusText)', + ruleId: 'no-globals', + severity: 2, + }, + ], + warningCount: 0, + } + +## XML Property Binding: Global Formatter with bind call + +> Snapshot 1 + + { + coverageInfo: [], + errorCount: 1, + fatalErrorCount: 0, + filePath: '/test.js', + messages: [ + { + column: 1, + line: 1, + message: 'Access of global variable \'ui5\' (ui5.walkthrough.model.formatter.statusText.bind($controller))', + ruleId: 'no-globals', + severity: 2, + }, + ], + warningCount: 0, + } + +## XML Property Binding: Controller Formatter + +> Snapshot 1 + + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: '/test.js', + messages: [], + warningCount: 0, + } + +## XML Property Binding: Imported Formatter + +> Snapshot 1 + + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: '/test.js', + messages: [], + warningCount: 0, + } + +## XML Property Binding: Imported Formatter with bind call + +> Snapshot 1 + + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: '/test.js', + messages: [], + warningCount: 0, + } + +## XML Property Binding: Global Event Handler + +> Snapshot 1 + + { + coverageInfo: [], + errorCount: 1, + fatalErrorCount: 0, + filePath: '/test.js', + messages: [ + { + column: 1, + line: 1, + message: 'Access of global variable \'global\' (global.onMyDataRequested)', + ruleId: 'no-globals', + severity: 2, + }, + ], + warningCount: 0, + } + +## XML Property Binding: Controller Event Handler + +> Snapshot 1 + + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: '/test.js', + messages: [], + warningCount: 0, + } + +## XML Property Binding: Imported Event Handler + +> Snapshot 1 + + { + coverageInfo: [], + errorCount: 0, + fatalErrorCount: 0, + filePath: '/test.js', + messages: [], + warningCount: 0, + } diff --git a/test/lib/linter/binding/snapshots/BindingLinter.ts.snap b/test/lib/linter/binding/snapshots/BindingLinter.ts.snap new file mode 100644 index 000000000..21105cbda Binary files /dev/null and b/test/lib/linter/binding/snapshots/BindingLinter.ts.snap differ diff --git a/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.md b/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.md index d9a96136f..02ccfe04a 100644 --- a/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.md +++ b/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.md @@ -1896,10 +1896,18 @@ Generated by [AVA](https://avajs.dev). [ { coverageInfo: [], - errorCount: 2, + errorCount: 3, fatalErrorCount: 0, filePath: 'XMLTemplatingRequire.view.xml', messages: [ + { + column: 8, + line: 14, + message: 'Access of global variable \'AH\' (AH.format)', + messageDetails: 'Do not use global variables to access UI5 modules or APIs. See Best Practices for Developers (https://ui5.sap.com/#/topic/28fcd55b04654977b63dacbee0552712)', + ruleId: 'no-globals', + severity: 2, + }, { column: 2, line: 6, diff --git a/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.snap b/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.snap index b057cfad9..2153a607d 100644 Binary files a/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.snap and b/test/lib/linter/rules/snapshots/NoDeprecatedApi.ts.snap differ diff --git a/test/lib/linter/xmlTemplate/snapshots/transpiler.ts.md b/test/lib/linter/xmlTemplate/snapshots/transpiler.ts.md index d7930da56..3108b2108 100644 --- a/test/lib/linter/xmlTemplate/snapshots/transpiler.ts.md +++ b/test/lib/linter/xmlTemplate/snapshots/transpiler.ts.md @@ -527,6 +527,84 @@ Generated by [AVA](https://avajs.dev). Map {} +## Transpile XMLFragmentBindings.fragment.xml + +> source + + `import EventHandler from "my/event/handler";␊ + import Formatter from "ui5/walkthrough/model/formatter";␊ + import ObjectStatus from "sap/m/ObjectStatus";␊ + import ObjectStatus2 from "sap/m/ObjectStatus";␊ + import VBox from "sap/m/VBox";␊ + const oObjectStatus = new ObjectStatus({␊ + text: "{\\n\\t\\t\\tpath: 'invoice>Status',\\n\\t\\t\\tformatter: 'ui5.walkthrough.model.formatter.statusText',\\n\\t\\t\\tevents: {\\n\\t\\t\\t\\tdataRequested: 'global.onMyDataRequested'\\n\\t\\t\\t}\\n\\t\\t}",␊ + tooltip: "{\\n\\t\\t\\tpath: 'invoice>StatusDetails',\\n\\t\\t\\tformatter: 'globalTooltipFormatter'\\n\\t\\t}",␊ + formatError: "global.handleEvent",␊ + });␊ + ␊ + const oObjectStatus2 = new ObjectStatus2({␊ + text: "{\\n\\t\\t\\tpath: 'invoice>Status',\\n\\t\\t\\tformatter: 'Formatter.statusText.bind($controller)',\\n\\t\\t\\tevents: {\\n\\t\\t\\t\\tdataRequested: 'EventHandler.onMyDataRequested'\\n\\t\\t\\t}\\n\\t\\t}",␊ + });␊ + ␊ + const oVBox = new VBox({␊ + items: [␊ + oObjectStatus,␊ + oObjectStatus2,␊ + ],␊ + });␊ + ␊ + export default {␊ + createContent: function () {␊ + return oVBox;␊ + };␊ + }␊ + ` + +> map + + { + file: 'XMLFragmentBindings.fragment.js', + mappings: 'AA0BE,4CAGE;AAHF,wDAGE;AAtBH,8CAeE;AAGF,+CAYE;AArCH,8BAEC;sBAKA,kBAeE;IAdD,6LAME;IAEF,qGAGE;IAEF,kCAAgC;;;uBAIjC,mBAYE;IAPD,+LAME;;;cApCJ,UAEC;IAKA,KAeE', + names: [], + sources: [ + 'XMLFragmentBindings.fragment.xml', + ], + version: 3, + } + +> messages + + [ + { + column: 2, + line: 8, + message: 'Access of global variable \'ui5\' (ui5.walkthrough.model.formatter.statusText)', + messageDetails: 'Do not use global variables to access UI5 modules or APIs. See Best Practices for Developers (https://ui5.sap.com/#/topic/28fcd55b04654977b63dacbee0552712)', + ruleId: 'no-globals', + severity: 2, + }, + { + column: 2, + line: 8, + message: 'Access of global variable \'global\' (global.onMyDataRequested)', + messageDetails: 'Do not use global variables to access UI5 modules or APIs. See Best Practices for Developers (https://ui5.sap.com/#/topic/28fcd55b04654977b63dacbee0552712)', + ruleId: 'no-globals', + severity: 2, + }, + { + column: 2, + line: 16, + message: 'Access of global variable \'globalTooltipFormatter\' (globalTooltipFormatter)', + messageDetails: 'Do not use global variables to access UI5 modules or APIs. See Best Practices for Developers (https://ui5.sap.com/#/topic/28fcd55b04654977b63dacbee0552712)', + ruleId: 'no-globals', + severity: 2, + }, + ] + +> controllerByIdInfo + + Map {} + ## Transpile XMLFragmentDefinition.fragment.xml > source @@ -1119,7 +1197,16 @@ Generated by [AVA](https://avajs.dev). > messages - [] + [ + { + column: 8, + line: 14, + message: 'Access of global variable \'AH\' (AH.format)', + messageDetails: 'Do not use global variables to access UI5 modules or APIs. See Best Practices for Developers (https://ui5.sap.com/#/topic/28fcd55b04654977b63dacbee0552712)', + ruleId: 'no-globals', + severity: 2, + }, + ] > controllerByIdInfo @@ -1218,6 +1305,80 @@ Generated by [AVA](https://avajs.dev). }, } +## Transpile XMLViewBindings.view.xml + +> source + + `import EventHandler from "my/event/handler";␊ + import Formatter from "ui5/walkthrough/model/formatter";␊ + import ObjectStatus from "sap/m/ObjectStatus";␊ + import ObjectStatus2 from "sap/m/ObjectStatus";␊ + import View from "sap/ui/core/mvc/View";␊ + const oObjectStatus = new ObjectStatus({␊ + text: "{\\n\\t\\t\\tpath: 'invoice>Status',\\n\\t\\t\\tformatter: 'ui5.walkthrough.model.formatter.statusText',\\n\\t\\t\\tevents: {\\n\\t\\t\\t\\tdataRequested: 'global.onMyDataRequested'\\n\\t\\t\\t}\\n\\t\\t}",␊ + tooltip: "{\\n\\t\\t\\tpath: 'invoice>StatusDetails',\\n\\t\\t\\tformatter: 'globalTooltipFormatter'\\n\\t\\t}",␊ + formatError: "global.handleEvent",␊ + });␊ + ␊ + const oObjectStatus2 = new ObjectStatus2({␊ + text: "{\\n\\t\\t\\tpath: 'invoice>Status',\\n\\t\\t\\tformatter: 'Formatter.statusText.bind($controller)',\\n\\t\\t\\tevents: {\\n\\t\\t\\t\\tdataRequested: 'EventHandler.onMyDataRequested'\\n\\t\\t\\t}\\n\\t\\t}",␊ + });␊ + ␊ + export default const oView = new View({␊ + controllerName: "com.myapp.controller.Main",␊ + content: [␊ + oObjectStatus,␊ + oObjectStatus2,␊ + ],␊ + });␊ + ␊ + ` + +> map + + { + file: 'XMLViewBindings.view.js', + mappings: 'AA2BE,4CAGE;AAHF,wDAGE;AAtBH,8CAeE;AAGF,+CAYE;AAtCH,wCAIC;sBAIA,kBAeE;IAdD,6LAME;IAEF,qGAGE;IAEF,kCAAgC;;;uBAIjC,mBAYE;IAPD,+LAME;;;6BArCJ,UAIC;IADA,4CAA0C;IAK1C,OAeE', + names: [], + sources: [ + 'XMLViewBindings.view.xml', + ], + version: 3, + } + +> messages + + [ + { + column: 2, + line: 9, + message: 'Access of global variable \'ui5\' (ui5.walkthrough.model.formatter.statusText)', + messageDetails: 'Do not use global variables to access UI5 modules or APIs. See Best Practices for Developers (https://ui5.sap.com/#/topic/28fcd55b04654977b63dacbee0552712)', + ruleId: 'no-globals', + severity: 2, + }, + { + column: 2, + line: 9, + message: 'Access of global variable \'global\' (global.onMyDataRequested)', + messageDetails: 'Do not use global variables to access UI5 modules or APIs. See Best Practices for Developers (https://ui5.sap.com/#/topic/28fcd55b04654977b63dacbee0552712)', + ruleId: 'no-globals', + severity: 2, + }, + { + column: 2, + line: 17, + message: 'Access of global variable \'globalTooltipFormatter\' (globalTooltipFormatter)', + messageDetails: 'Do not use global variables to access UI5 modules or APIs. See Best Practices for Developers (https://ui5.sap.com/#/topic/28fcd55b04654977b63dacbee0552712)', + ruleId: 'no-globals', + severity: 2, + }, + ] + +> controllerByIdInfo + + Map {} + ## Transpile XMLViewNamespacePrefix.view.xml > source diff --git a/test/lib/linter/xmlTemplate/snapshots/transpiler.ts.snap b/test/lib/linter/xmlTemplate/snapshots/transpiler.ts.snap index 965f09402..a706dcc5f 100644 Binary files a/test/lib/linter/xmlTemplate/snapshots/transpiler.ts.snap and b/test/lib/linter/xmlTemplate/snapshots/transpiler.ts.snap differ