diff --git a/packages/sdk/src/expression.test.ts b/packages/sdk/src/expression.test.ts index 4c0d966031a2..9a0abb2812d8 100644 --- a/packages/sdk/src/expression.test.ts +++ b/packages/sdk/src/expression.test.ts @@ -157,7 +157,7 @@ describe("lint expression", () => { ).toEqual([ error(1, 13, "Functions are not supported"), error(17, 25, "Functions are not supported"), - error(29, 33, "Functions are not supported"), + error(29, 33, `"fn" function is not supported`), ]); }); @@ -220,6 +220,71 @@ describe("lint expression", () => { error(1, 8, `"await" keyword is not supported`), ]); }); + + test.each([ + "toLowerCase", + "replace", + "split", + "at", + "endsWith", + "includes", + "startsWith", + "toUpperCase", + "toLocaleLowerCase", + "toLocaleUpperCase", + ])("allow safe string method: %s", (method) => { + expect( + lintExpression({ + expression: `title.${method}()`, + availableVariables: new Set(["title"]), + }) + ).toEqual([]); + }); + + test.each(["at", "includes", "join", "slice"])( + "allow safe array method: %s", + (method) => { + expect( + lintExpression({ + expression: `arr.${method}()`, + availableVariables: new Set(["arr"]), + }) + ).toEqual([]); + } + ); + + test("allow chained string methods", () => { + expect( + lintExpression({ + expression: `title.toLowerCase().replace(" ", "-").split("-")`, + availableVariables: new Set(["title"]), + }) + ).toEqual([]); + }); + + test("forbid unsafe method calls", () => { + expect( + lintExpression({ + expression: `arr.pop()`, + availableVariables: new Set(["arr"]), + }) + ).toEqual([error(0, 9, `"pop" function is not supported`)]); + expect( + lintExpression({ + expression: `obj.push(1)`, + availableVariables: new Set(["obj"]), + }) + ).toEqual([error(0, 11, `"push" function is not supported`)]); + }); + + test("forbid standalone function calls", () => { + expect( + lintExpression({ + expression: `func()`, + availableVariables: new Set(["func"]), + }) + ).toEqual([error(0, 6, `"func" function is not supported`)]); + }); }); test("check simple literals", () => { @@ -346,6 +411,81 @@ describe("transpile expression", () => { } expect(errorString).toEqual(`Unexpected token (1:0) in ""`); }); + + test("transpile string methods with optional chaining", () => { + expect( + transpileExpression({ + expression: "title.toLowerCase()", + executable: true, + }) + ).toEqual("title?.toLowerCase?.()"); + expect( + transpileExpression({ + expression: "user.name.replace(' ', '-')", + executable: true, + }) + ).toEqual("user?.name?.replace?.(' ', '-')"); + expect( + transpileExpression({ + expression: "data.title.split('-')", + executable: true, + }) + ).toEqual("data?.title?.split?.('-')"); + }); + + test("transpile chained string methods with optional chaining", () => { + expect( + transpileExpression({ + expression: "title.toLowerCase().replace(/\\s+/g, '-')", + executable: true, + }) + ).toEqual("title?.toLowerCase?.()?.replace?.(/\\s+/g, '-')"); + expect( + transpileExpression({ + expression: "user.name.toLowerCase().replace(' ', '-').split('-')", + executable: true, + }) + ).toEqual("user?.name?.toLowerCase?.()?.replace?.(' ', '-')?.split?.('-')"); + }); + + test("transpile array methods with optional chaining", () => { + expect( + transpileExpression({ + expression: "items.map(item => item.id)", + executable: true, + }) + ).toEqual("items?.map?.(item => item?.id)"); + expect( + transpileExpression({ + expression: "data.list.filter(x => x > 0)", + executable: true, + }) + ).toEqual("data?.list?.filter?.(x => x > 0)"); + }); + + test("transpile nested method calls with optional chaining", () => { + expect( + transpileExpression({ + expression: "obj.method().prop.anotherMethod()", + executable: true, + }) + ).toEqual("obj?.method?.()?.prop?.anotherMethod?.()"); + }); + + test("preserve existing optional chaining", () => { + expect( + transpileExpression({ + expression: "obj?.method?.()", + executable: true, + }) + ).toEqual("obj?.method?.()"); + expect( + transpileExpression({ + expression: "obj?.prop?.method?.()", + executable: true, + }) + ).toEqual("obj?.prop?.method?.()"); + }); }); describe("object expression transformations", () => { diff --git a/packages/sdk/src/expression.ts b/packages/sdk/src/expression.ts index f1c1e91108e5..c4a066202a05 100644 --- a/packages/sdk/src/expression.ts +++ b/packages/sdk/src/expression.ts @@ -29,6 +29,21 @@ type ExpressionVisitor = { [K in Expression["type"]]: (node: Extract) => void; }; +const allowedStringMethods = new Set([ + "toLowerCase", + "replace", + "split", + "at", + "endsWith", + "includes", + "startsWith", + "toUpperCase", + "toLocaleLowerCase", + "toLocaleUpperCase", +]); + +const allowedArrayMethods = new Set(["at", "includes", "join", "slice"]); + export const lintExpression = ({ expression, availableVariables = new Set(), @@ -112,7 +127,28 @@ export const lintExpression = ({ ThisExpression: addMessage(`"this" keyword is not supported`), FunctionExpression: addMessage("Functions are not supported"), UpdateExpression: addMessage("Increment and decrement are not supported"), - CallExpression: addMessage("Functions are not supported"), + CallExpression(node) { + let calleeName; + if (node.callee.type === "MemberExpression") { + if (node.callee.property.type === "Identifier") { + const methodName = node.callee.property.name; + if ( + allowedStringMethods.has(methodName) || + allowedArrayMethods.has(methodName) + ) { + return; + } + calleeName = methodName; + } + } else if (node.callee.type === "Identifier") { + calleeName = node.callee.name; + } + if (calleeName) { + addMessage(`"${calleeName}" function is not supported`)(node); + } else { + addMessage("Functions are not supported")(node); + } + }, NewExpression: addMessage("Classes are not supported"), SequenceExpression: addMessage(`Only single expression is supported`), ArrowFunctionExpression: addMessage("Functions are not supported"), @@ -257,6 +293,19 @@ export const transpileExpression = ({ replacements.push([dotIndex, dotIndex, "?."]); } }, + CallExpression(node) { + if (executable === false || node.optional) { + return; + } + // Add optional chaining to method calls: obj.method() -> obj?.method?.() + if (node.callee.type === "MemberExpression") { + // Find the opening parenthesis after the method name + const openParenIndex = expression.indexOf("(", node.callee.end); + if (openParenIndex !== -1) { + replacements.push([openParenIndex, openParenIndex, "?."]); + } + } + }, }); // order from the latest to the first insertion to not break other positions replacements.sort(([leftStart], [rightStart]) => rightStart - leftStart);