diff --git a/client/web/divkit/src/expressions/const.ts b/client/web/divkit/src/expressions/const.ts index c9349a2be..b15488c3f 100644 --- a/client/web/divkit/src/expressions/const.ts +++ b/client/web/divkit/src/expressions/const.ts @@ -13,3 +13,4 @@ export const URL = 'url'; export const DATETIME = 'datetime'; export const DICT = 'dict'; export const ARRAY = 'array'; +export const FUNCTION = 'function'; diff --git a/client/web/divkit/src/expressions/eval.ts b/client/web/divkit/src/expressions/eval.ts index 183aefa75..2fe1ff023 100644 --- a/client/web/divkit/src/expressions/eval.ts +++ b/client/web/divkit/src/expressions/eval.ts @@ -16,6 +16,8 @@ import { convertArgs, findBestMatchedFunc, type Func, funcByArgs, type FuncMatch import { checkIntegerOverflow, evalError, + evalOuterError, + FuncError, integerToNumber, roundInteger, typeToString, @@ -492,35 +494,46 @@ function evalCallExpression(ctx: EvalContext, expr: CallExpression): EvalValue { try { return func.cb(ctx, ...args); } catch (err: any) { + if (err && err instanceof FuncError) { + throw err; + } + const prefix = `${funcName}(${argsToStr(args)})`; evalError(prefix, err.message); } } -function logFunctionMatchError(funcName: string, args: EvalValue[], findRes: FuncMatchError): never { +export function logFunctionMatchError( + funcName: string, + args: EvalValue[], + findRes: FuncMatchError, + isOuterFunc = false +): never { const argsType = args.map(arg => typeToString(arg.type)).join(', '); const prefix = `${funcName}(${argsToStr(args)})`; + const makeError: (msg: string, details: string) => never = + isOuterFunc ? evalOuterError : evalError; if (findRes.type === 'few' && args.length === 0 && findRes.hasOverloads) { - evalError(prefix, 'Function requires non empty argument list.'); + makeError(prefix, 'Function requires non empty argument list.'); } else if (findRes.type === 'many' || findRes.type === 'few' || findRes.type === 'mismatch') { if (findRes.hasOverloads) { - evalError(prefix, `Function has no matching overload for given argument types: ${argsType}.`); + makeError(prefix, `Function has no matching overload for given argument types: ${argsType}.`); } else { // eslint-disable-next-line no-lonely-if if (findRes.type === 'many' || findRes.type === 'few') { if (findRes.def.args.some(arg => typeof arg === 'object' && arg.isVararg)) { - evalError(prefix, `At least ${findRes.def.args.length} argument(s) expected.`); + makeError(prefix, `At least ${findRes.def.args.length} argument(s) expected.`); } else { - evalError(prefix, `Exactly ${findRes.def.args.length} argument(s) expected.`); + makeError(prefix, `Exactly ${findRes.def.args.length} argument(s) expected.`); } } else { const expectedArgs = findRes.def.args.map(arg => typeToString(typeof arg === 'string' ? arg : arg.type)).join(', '); - evalError(prefix, `Invalid argument type: expected ${expectedArgs}, got ${argsType}.`); + makeError(prefix, `Invalid argument type: expected ${expectedArgs}, got ${argsType}.`); } } } else { - evalError(prefix, `Unknown function name: ${funcName}.`); + makeError(prefix, `Unknown function name: ${funcName}.`); } } @@ -564,6 +577,10 @@ function evalMethodExpression(ctx: EvalContext, expr: MethodExpression): EvalVal try { return func.cb(ctx, ...args); } catch (err: any) { + if (err && err instanceof FuncError) { + throw err; + } + const prefix = `${methodName}(${argsToStr(args.slice(1))})`; evalError(prefix, err.message); } diff --git a/client/web/divkit/src/expressions/funcs/array.ts b/client/web/divkit/src/expressions/funcs/array.ts index f85619027..b6a40657b 100644 --- a/client/web/divkit/src/expressions/funcs/array.ts +++ b/client/web/divkit/src/expressions/funcs/array.ts @@ -1,8 +1,9 @@ +import { parseColor } from '../../utils/correctColor'; import { toBigInt } from '../bigint'; -import { ARRAY, BOOLEAN, COLOR, DICT, INTEGER, NUMBER, STRING, URL } from '../const'; -import type { ArrayValue, BooleanValue, ColorValue, EvalContext, EvalTypes, EvalValue, IntegerValue, NumberValue, StringValue, UrlValue } from '../eval'; -import { checkIntegerOverflow, checkUrl, transformColorValue, typeToString } from '../utils'; -import { registerFunc, registerMethod } from './funcs'; +import { ARRAY, BOOLEAN, COLOR, DICT, FUNCTION, INTEGER, NUMBER, STRING, URL } from '../const'; +import { logFunctionMatchError, type ArrayValue, type BooleanValue, type ColorValue, type EvalContext, type EvalTypes, type EvalValue, type FuncValue, type IntegerValue, type NumberValue, type StringValue, type UrlValue } from '../eval'; +import { checkIntegerOverflow, checkUrl, convertJsValueToDivKit, safeCheckUrl, transformColorValue, typeToString } from '../utils'; +import { findBestMatchedFuncList, registerFunc, registerMethod, type Func, type FuncMatch } from './funcs'; function arrayGetter(jsType: string, runtimeType: string) { return (ctx: EvalContext, array: ArrayValue, index: IntegerValue): EvalValue => { @@ -128,6 +129,111 @@ function isEmpty(_ctx: EvalContext, array: ArrayValue): EvalValue { }; } +function filter(ctx: EvalContext, array: ArrayValue, fn: FuncValue): EvalValue { + if (!array.value.length) { + return { + type: ARRAY, + value: [] + }; + } + + return { + type: ARRAY, + value: array.value.filter(it => { + const argMatchers: EvalValue[][] = []; + + if (typeof it === 'string') { + if (parseColor(it)) { + argMatchers.push([{ + type: COLOR, + value: it + }]); + } + if (safeCheckUrl(it)) { + argMatchers.push([{ + type: URL, + value: it + }]); + } + argMatchers.push([{ + type: STRING, + value: it + }]); + } else if (typeof it === 'number') { + if (Math.round(it) === it) { + checkIntegerOverflow(ctx, it); + argMatchers.push([{ + type: INTEGER, + value: toBigInt(it) + }]); + } + argMatchers.push([{ + type: NUMBER, + value: it + }]); + } else if (typeof it === 'bigint') { + checkIntegerOverflow(ctx, it); + argMatchers.push([{ + type: INTEGER, + value: it + }]); + } else if (Array.isArray(it)) { + argMatchers.push([{ + type: ARRAY, + value: it + }]); + } else if (typeof it === 'object') { + if (it === null) { + throw new Error('Incorrect value type: Null'); + } + argMatchers.push([{ + type: DICT, + value: it + }]); + } else if (typeof it === 'boolean') { + argMatchers.push([{ + type: BOOLEAN, + value: it ? 1 : 0 + }]); + } else { + throw new Error(`Incorrect value type: ${typeToString(typeof it)}`); + } + + let fnMatch: FuncMatch = { + type: 'missing' + }; + for (const matchItem of argMatchers) { + fnMatch = findBestMatchedFuncList(fn.value, matchItem); + if ('func' in fnMatch) { + break; + } + } + + let selectedFn: Func; + if ('func' in fnMatch) { + selectedFn = fnMatch.func; + } else { + const selectedFn = fn.value[0]; + logFunctionMatchError(selectedFn.name || 'Function', argMatchers[0], fnMatch, true); + } + + const argType = selectedFn.args[0]; + const value = convertJsValueToDivKit( + ctx, + it, + typeof argType === 'string' ? argType : argType.type + ); + const res = selectedFn.cb(ctx, value); + + if (res.type !== BOOLEAN) { + throw new Error('Function must return boolean value.'); + } + + return res.value; + }) + }; +} + export function registerArray(): void { registerFunc('getArrayString', [ ARRAY, @@ -302,4 +408,5 @@ export function registerArray(): void { registerMethod('getArray', [ARRAY, INTEGER], getArrayArray); registerMethod('getDict', [ARRAY, INTEGER], getArrayDict); registerMethod('isEmpty', [ARRAY], isEmpty); + registerMethod('filter', [ARRAY, FUNCTION], filter); } diff --git a/client/web/divkit/src/expressions/funcs/funcs.ts b/client/web/divkit/src/expressions/funcs/funcs.ts index 11b445749..d85458e41 100644 --- a/client/web/divkit/src/expressions/funcs/funcs.ts +++ b/client/web/divkit/src/expressions/funcs/funcs.ts @@ -259,8 +259,7 @@ function matchFuncArgs(func: Func, args: EvalValue[], hasOverloads: boolean): { }; } -export function findBestMatchedFunc(map: Map, funcName: string, args: EvalValue[]): FuncMatch { - const list = map.get(funcName); +export function findBestMatchedFuncList(list: Func[] | undefined, args: EvalValue[]): FuncMatch { if (!list) { return { type: 'missing' @@ -298,6 +297,10 @@ export function findBestMatchedFunc(map: Map, funcName: string, return bestFunc; } +export function findBestMatchedFunc(map: Map, funcName: string, args: EvalValue[]): FuncMatch { + return findBestMatchedFuncList(map.get(funcName), args); +} + export function convertArgs(func: Func, args: EvalValue[]): EvalValue[] { return args.map((arg, i) => { let funcArg = i >= func.args.length ? func.args[func.args.length - 1] : func.args[i]; diff --git a/client/web/divkit/src/expressions/utils.ts b/client/web/divkit/src/expressions/utils.ts index 84b3c1537..d0072f3f8 100644 --- a/client/web/divkit/src/expressions/utils.ts +++ b/client/web/divkit/src/expressions/utils.ts @@ -1,4 +1,4 @@ -import type { EvalContext, EvalTypesWithoutDatetime, EvalValue, EvalValueBase, IntegerValue, NumberValue } from './eval'; +import type { EvalContext, EvalTypes, EvalTypesWithoutDatetime, EvalValue, EvalValueBase, IntegerValue, NumberValue } from './eval'; import type { Node, Variable } from './ast'; import type { VariablesMap } from './eval'; import { walk } from './walk'; @@ -9,6 +9,9 @@ import { BOOLEAN, NUMBER } from './const'; import type { TypedValue } from '../../typings/common'; import type { MaybeMissing } from './json'; +export class FuncError extends Error { +} + export function valToInternal(val: EvalValue): EvalValue { if (val.type === 'url' || val.type === 'color') { return { @@ -112,6 +115,15 @@ export function checkUrl(val: unknown): void { } } +export function safeCheckUrl(val: unknown): boolean { + try { + checkUrl(val); + return true; + } catch { + return false; + } +} + export function gatherVarsFromAst(ast: Node): string[] { const res = new Set(); @@ -125,7 +137,11 @@ export function gatherVarsFromAst(ast: Node): string[] { } export function evalError(msg: string, details: string): never { - throw new Error(`Failed to evaluate [${msg}]. ${details}`); + throw new FuncError(`Failed to evaluate [${msg}]. ${details}`); +} + +export function evalOuterError(_msg: string, details: string): never { + throw new Error(details); } export function containsUnsetVariables(ast: Node, variables: VariablesMap): boolean { @@ -181,12 +197,13 @@ const EVAL_TYPE_TO_JS_TYPE = { color: 'string', url: 'string', array: 'array', - dict: 'object' + dict: 'object', + datetime: 'never' }; export function convertJsValueToDivKit( ctx: EvalContext | undefined, val: unknown, - evalType: EvalTypesWithoutDatetime + evalType: EvalTypes ): EvalValue { if (evalType === 'function') { throw new Error('Cannot convert function'); diff --git a/expression-api/methods_signatures_array.json b/expression-api/methods_signatures_array.json index cbde149b1..3b49a662d 100644 --- a/expression-api/methods_signatures_array.json +++ b/expression-api/methods_signatures_array.json @@ -268,7 +268,9 @@ } ], "return_type": "array", - "platforms": [] + "platforms": [ + "web" + ] } ] } diff --git a/test_data/expression_test_data/filter_function_array.json b/test_data/expression_test_data/filter_function_array.json index 6b9a63700..41fc58f8e 100644 --- a/test_data/expression_test_data/filter_function_array.json +++ b/test_data/expression_test_data/filter_function_array.json @@ -40,7 +40,9 @@ ] } ], - "platforms": [] + "platforms": [ + "web" + ] }, { "expression": "@{source.filter(equalsOne)}", @@ -74,7 +76,9 @@ ] } ], - "platforms": [] + "platforms": [ + "web" + ] }, { "expression": "@{source.filter(lessThanThree)}", @@ -109,7 +113,9 @@ ] } ], - "platforms": [] + "platforms": [ + "web" + ] }, { "expression": "@{source.filter(alwaysFalse)}", @@ -141,7 +147,9 @@ ] } ], - "platforms": [] + "platforms": [ + "web" + ] }, { "expression": "@{boolArray.filter(isTrue)}", @@ -177,13 +185,15 @@ ] } ], - "platforms": [] + "platforms": [ + "web" + ] }, { "expression": "@{source.filter(nonExistentFunction)}", "expected": { "type": "error", - "value": "Failed to evaluate [filter(nonExistentFunction)]. Unknown function name: nonExistentFunction." + "value": "Variable 'nonExistentFunction' is missing." }, "variables": [ { @@ -196,7 +206,9 @@ } ], "functions": [], - "platforms": [] + "platforms": [ + "web" + ] }, { "expression": "@{source.filter(wrongReturnType)}", @@ -227,13 +239,15 @@ ] } ], - "platforms": [] + "platforms": [ + "web" + ] }, { "expression": "@{source.filter(containsQuery)}", "expected": { "type": "error", - "value": "Failed to evaluate [containsQuery]. Exactly 2 argument(s) expected." + "value": "Failed to evaluate [filter(containsQuery)]. Exactly 2 argument(s) expected." }, "variables": [ { @@ -263,13 +277,15 @@ ] } ], - "platforms": [] + "platforms": [ + "web" + ] }, { "expression": "@{heterogeneousArray.filter(isStringOne)}", "expected": { "type": "error", - "value": "Failed to evaluate [filter(isStringOne)]. Incorrect value type: expected String, got Integer." + "value": "Failed to evaluate [filter(isStringOne)]. Invalid argument type: expected String, got Integer." }, "variables": [ { @@ -295,7 +311,253 @@ ] } ], - "platforms": [] + "platforms": [ + "web" + ] + }, + { + "expression": "@{emptyArray.filter(anyFunc)}", + "expected": { + "type": "array", + "value": [] + }, + "variables": [ + { + "name": "emptyArray", + "type": "array", + "value": [] + } + ], + "functions": [ + { + "name": "anyFunc", + "body": "@{true}", + "return_type": "boolean", + "arguments": [] + } + ], + "platforms": [ + "web" + ] + }, + { + "expression": "@{integerArray.filter(manyFunc)}", + "expected": { + "type": "array", + "value": [ + ] + }, + "variables": [ + { + "name": "integerArray", + "type": "array", + "value": [ + 20, + 40 + ] + } + ], + "functions": [ + { + "name": "manyFunc", + "body": "@{val > 10.0}", + "return_type": "boolean", + "arguments": [ + { + "name": "val", + "type": "number" + } + ] + }, + { + "name": "manyFunc", + "body": "@{val < 10}", + "return_type": "boolean", + "arguments": [ + { + "name": "val", + "type": "integer" + } + ] + } + ], + "platforms": [ + "web" + ] + }, + { + "expression": "@{mixedArray.filter(manyNumberFunc)}", + "expected": { + "type": "array", + "value": [ + 40.5 + ] + }, + "variables": [ + { + "name": "mixedArray", + "type": "array", + "value": [ + 20, + 40.5 + ] + } + ], + "functions": [ + { + "name": "manyNumberFunc", + "body": "@{val > 10.0}", + "return_type": "boolean", + "arguments": [ + { + "name": "val", + "type": "number" + } + ] + }, + { + "name": "manyNumberFunc", + "body": "@{val < 10}", + "return_type": "boolean", + "arguments": [ + { + "name": "val", + "type": "integer" + } + ] + } + ], + "platforms": [ + "web" + ] + }, + { + "expression": "@{mixedColorArray.filter(manyColorFunc)}", + "expected": { + "type": "array", + "value": [ + "#f00", + "blue" + ] + }, + "variables": [ + { + "name": "mixedColorArray", + "type": "array", + "value": [ + "#f00", + "blue" + ] + } + ], + "functions": [ + { + "name": "manyColorFunc", + "body": "@{val == toColor('#FFFF0000')}", + "return_type": "boolean", + "arguments": [ + { + "name": "val", + "type": "color" + } + ] + }, + { + "name": "manyColorFunc", + "body": "@{val == 'blue'}", + "return_type": "boolean", + "arguments": [ + { + "name": "val", + "type": "string" + } + ] + } + ], + "platforms": [ + "web" + ] + }, + { + "expression": "@{mixedUrlArray.filter(manyUrlFunc)}", + "expected": { + "type": "array", + "value": [ + "https://divkit.tech", + "str" + ] + }, + "variables": [ + { + "name": "mixedUrlArray", + "type": "array", + "value": [ + "https://divkit.tech", + "str" + ] + } + ], + "functions": [ + { + "name": "manyUrlFunc", + "body": "@{val == toUrl('https://divkit.tech')}", + "return_type": "boolean", + "arguments": [ + { + "name": "val", + "type": "url" + } + ] + }, + { + "name": "manyUrlFunc", + "body": "@{val == 'str'}", + "return_type": "boolean", + "arguments": [ + { + "name": "val", + "type": "string" + } + ] + } + ], + "platforms": [ + "web" + ] + }, + { + "expression": "@{integerArray.filter(datetimeFunc)}", + "expected": { + "type": "error", + "value": "Failed to evaluate [filter(datetimeFunc)]. Invalid argument type: expected DateTime, got Integer." + }, + "variables": [ + { + "name": "integerArray", + "type": "array", + "value": [ + 1, + 2, + 3 + ] + } + ], + "functions": [ + { + "name": "datetimeFunc", + "body": "@{true}", + "return_type": "boolean", + "arguments": [ + { + "name": "val", + "type": "datetime" + } + ] + } + ], + "platforms": [ + "web" + ] } ] }