From 4ac9256535ec0b2d9e07a3efe2ae9936437b1a01 Mon Sep 17 00:00:00 2001 From: fraxken Date: Thu, 3 Jul 2025 13:11:27 +0200 Subject: [PATCH] feat(estree-ast-utils): add joinArrayExpression util --- .changeset/purple-emus-appear.md | 5 + workspaces/estree-ast-utils/README.md | 33 ++++- .../estree-ast-utils/src/arrayExpression.ts | 126 ++++++++++++++++ .../src/arrayExpressionToString.ts | 48 ------- .../src/concatBinaryExpression.ts | 2 +- workspaces/estree-ast-utils/src/index.ts | 3 +- workspaces/estree-ast-utils/src/utils/is.ts | 35 +++++ .../test/arrayExpression.spec.ts | 135 ++++++++++++++++++ .../test/arrayExpressionToString.spec.ts | 78 ---------- workspaces/estree-ast-utils/test/utils.ts | 4 +- 10 files changed, 339 insertions(+), 130 deletions(-) create mode 100644 .changeset/purple-emus-appear.md create mode 100644 workspaces/estree-ast-utils/src/arrayExpression.ts delete mode 100644 workspaces/estree-ast-utils/src/arrayExpressionToString.ts create mode 100644 workspaces/estree-ast-utils/src/utils/is.ts create mode 100644 workspaces/estree-ast-utils/test/arrayExpression.spec.ts delete mode 100644 workspaces/estree-ast-utils/test/arrayExpressionToString.spec.ts diff --git a/.changeset/purple-emus-appear.md b/.changeset/purple-emus-appear.md new file mode 100644 index 00000000..3ac9afae --- /dev/null +++ b/.changeset/purple-emus-appear.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/estree-ast-utils": minor +--- + +Implement a new joinArrayExpression utility diff --git a/workspaces/estree-ast-utils/README.md b/workspaces/estree-ast-utils/README.md index 901d59a3..9d61d669 100644 --- a/workspaces/estree-ast-utils/README.md +++ b/workspaces/estree-ast-utils/README.md @@ -33,7 +33,7 @@ You can provide a custom `externalIdentifierLookup` function to enable the utili ---
-arrayExpressionToString(node: ESTree.Node | null): IterableIterator< string > +arrayExpressionToString(node: ESTree.Node | null, options?: ArrayExpressionToStringOptions): IterableIterator< string > Transforms an ESTree `ArrayExpression` into an iterable of literal values. @@ -43,6 +43,37 @@ Transforms an ESTree `ArrayExpression` into an iterable of literal values. will yield `"foo"`, then `"bar"`. +```ts +export interface ArrayExpressionToStringOptions extends DefaultOptions { + /** + * When enabled, resolves the char code of the literal value. + * + * @default true + * @example + * [65, 66] // => ['A', 'B'] + */ + resolveCharCode?: boolean; +} +``` + +
+ +
+joinArrayExpression(node: ESTree.Node | null, options?: DefaultOptions): string | null + +Compute simple ArrayExpression that are using a CallExpression `join()` + +```js +{ + host: [ + ["goo", "g", "gle"].join(""), + "com" + ].join(".") +} +``` + +Will return `google.com` +
diff --git a/workspaces/estree-ast-utils/src/arrayExpression.ts b/workspaces/estree-ast-utils/src/arrayExpression.ts new file mode 100644 index 00000000..e7c9038f --- /dev/null +++ b/workspaces/estree-ast-utils/src/arrayExpression.ts @@ -0,0 +1,126 @@ +// Import Third-party Dependencies +import type { ESTree } from "meriyah"; + +// Import Internal Dependencies +import { + type DefaultOptions, + noop +} from "./options.js"; +import { + isNode, + isCallExpression, + isLiteral +} from "./utils/is.js"; +import { + getMemberExpressionIdentifier +} from "./getMemberExpressionIdentifier.js"; + +export interface ArrayExpressionToStringOptions extends DefaultOptions { + /** + * When enabled, resolves the char code of the literal value. + * + * @default true + * @example + * [65, 66] // => ['A', 'B'] + */ + resolveCharCode?: boolean; +} + +export function* arrayExpressionToString( + node: ESTree.Node | null, + options: ArrayExpressionToStringOptions = {} +): IterableIterator { + const { + externalIdentifierLookup = noop, + resolveCharCode = true + } = options; + + if (!isNode(node) || node.type !== "ArrayExpression") { + return; + } + + for (const row of node.elements) { + if (row === null) { + continue; + } + + switch (row.type) { + case "Literal": { + if ( + row.value === "" + ) { + continue; + } + + if (resolveCharCode) { + const value = Number(row.value); + yield Number.isNaN(value) ? + String(row.value) : + String.fromCharCode(value); + } + else { + yield String(row.value); + } + + break; + } + case "Identifier": { + const identifier = externalIdentifierLookup(row.name); + if (identifier !== null) { + yield identifier; + } + break; + } + case "CallExpression": { + const value = joinArrayExpression(row, { + externalIdentifierLookup + }); + if (value !== null) { + yield value; + } + break; + } + } + } +} + +export function joinArrayExpression( + node: ESTree.Node | null, + options: DefaultOptions = {} +): string | null { + if (!isCallExpression(node)) { + return null; + } + + if ( + node.arguments.length !== 1 || + ( + node.callee.type !== "MemberExpression" || + node.callee.object.type !== "ArrayExpression" + ) + ) { + return null; + } + + const id = Array.from( + getMemberExpressionIdentifier(node.callee) + ).join("."); + if ( + id !== "join" || + !isLiteral(node.arguments[0]) + ) { + return null; + } + + const separator = node.arguments[0].value; + + const iter = arrayExpressionToString( + node.callee.object, + { + ...options, + resolveCharCode: false + } + ); + + return [...iter].join(separator); +} diff --git a/workspaces/estree-ast-utils/src/arrayExpressionToString.ts b/workspaces/estree-ast-utils/src/arrayExpressionToString.ts deleted file mode 100644 index 1485950e..00000000 --- a/workspaces/estree-ast-utils/src/arrayExpressionToString.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Import Third-party Dependencies -import type { ESTree } from "meriyah"; - -// Import Internal Dependencies -import { - type DefaultOptions, - noop -} from "./options.js"; - -export function* arrayExpressionToString( - node: ESTree.Node | null, - options: DefaultOptions = {} -): IterableIterator { - const { externalIdentifierLookup = noop } = options; - - if (!node || node.type !== "ArrayExpression") { - return; - } - - for (const row of node.elements) { - if (row === null) { - continue; - } - - switch (row.type) { - case "Literal": { - if ( - row.value === "" - ) { - continue; - } - - const value = Number(row.value); - yield Number.isNaN(value) ? - String(row.value) : - String.fromCharCode(value); - break; - } - case "Identifier": { - const identifier = externalIdentifierLookup(row.name); - if (identifier !== null) { - yield identifier; - } - break; - } - } - } -} diff --git a/workspaces/estree-ast-utils/src/concatBinaryExpression.ts b/workspaces/estree-ast-utils/src/concatBinaryExpression.ts index fc34ab9e..8043fb98 100644 --- a/workspaces/estree-ast-utils/src/concatBinaryExpression.ts +++ b/workspaces/estree-ast-utils/src/concatBinaryExpression.ts @@ -2,7 +2,7 @@ import type { ESTree } from "meriyah"; // Import Internal Dependencies -import { arrayExpressionToString } from "./arrayExpressionToString.js"; +import { arrayExpressionToString } from "./arrayExpression.js"; import { type DefaultOptions, noop diff --git a/workspaces/estree-ast-utils/src/index.ts b/workspaces/estree-ast-utils/src/index.ts index ad72b8d6..66ce8064 100644 --- a/workspaces/estree-ast-utils/src/index.ts +++ b/workspaces/estree-ast-utils/src/index.ts @@ -3,6 +3,7 @@ export * from "./getCallExpressionIdentifier.js"; export * from "./getVariableDeclarationIdentifiers.js"; export * from "./getCallExpressionArguments.js"; export * from "./concatBinaryExpression.js"; -export * from "./arrayExpressionToString.js"; +export * from "./arrayExpression.js"; export * from "./extractLogicalExpression.js"; +export * from "./utils/is.js"; export type { DefaultOptions } from "./options.js"; diff --git a/workspaces/estree-ast-utils/src/utils/is.ts b/workspaces/estree-ast-utils/src/utils/is.ts new file mode 100644 index 00000000..ab875c16 --- /dev/null +++ b/workspaces/estree-ast-utils/src/utils/is.ts @@ -0,0 +1,35 @@ +// Import Third-party Dependencies +import type { ESTree } from "meriyah"; + +export type Literal = ESTree.Literal & { + value: T; +}; + +export type RegExpLiteral = ESTree.RegExpLiteral & { + value: T; +}; + +export function isNode( + value: any +): value is ESTree.Node { + return ( + value !== null && + typeof value === "object" && + "type" in value && + typeof value.type === "string" + ); +} + +export function isCallExpression( + node: any +): node is ESTree.CallExpression { + return isNode(node) && node.type === "CallExpression"; +} + +export function isLiteral( + node: any +): node is Literal { + return isNode(node) && + node.type === "Literal" && + typeof node.value === "string"; +} diff --git a/workspaces/estree-ast-utils/test/arrayExpression.spec.ts b/workspaces/estree-ast-utils/test/arrayExpression.spec.ts new file mode 100644 index 00000000..fc244e92 --- /dev/null +++ b/workspaces/estree-ast-utils/test/arrayExpression.spec.ts @@ -0,0 +1,135 @@ +// Import Node.js Dependencies +import { describe, test } from "node:test"; +import assert from "node:assert"; + +// Import Third-party Dependencies +import { IteratorMatcher } from "iterator-matcher"; + +// Import Internal Dependencies +import { + arrayExpressionToString, + joinArrayExpression +} from "../src/index.js"; +import { + codeToAst, + getExpressionFromStatement, + getExpressionFromStatementIf +} from "./utils.js"; + +describe("arrayExpressionToString", () => { + test("given an ArrayExpression with two Literals then the iterable must return them one by one", () => { + const [astNode] = codeToAst("['foo', 'bar']"); + const iter = arrayExpressionToString(getExpressionFromStatement(astNode)); + + const iterResult = new IteratorMatcher() + .expect("foo") + .expect("bar") + .execute(iter, { allowNoMatchingValues: false }); + + assert.strictEqual(iterResult.isMatching, true); + assert.strictEqual(iterResult.elapsedSteps, 2); + }); + + test("given an ArrayExpression with two Identifiers then the iterable must return value from the Tracer", () => { + const literalIdentifiers = new Map(); + literalIdentifiers.set("foo", "1"); + literalIdentifiers.set("bar", "2"); + + const [astNode] = codeToAst("[foo, bar]"); + const iter = arrayExpressionToString( + getExpressionFromStatement(astNode), + { + externalIdentifierLookup: (name: string) => literalIdentifiers.get(name) ?? null + } + ); + + const iterResult = new IteratorMatcher() + .expect("1") + .expect("2") + .execute(iter, { allowNoMatchingValues: false }); + + assert.strictEqual(iterResult.isMatching, true); + assert.strictEqual(iterResult.elapsedSteps, 2); + }); + + test(`given an ArrayExpression with two numbers + then the function must convert them as char code + and return them in the iterable`, () => { + const [astNode] = codeToAst("[65, 66]"); + const iter = arrayExpressionToString(getExpressionFromStatement(astNode)); + + const iterResult = new IteratorMatcher() + .expect("A") + .expect("B") + .execute(iter, { allowNoMatchingValues: false }); + + assert.strictEqual(iterResult.isMatching, true); + assert.strictEqual(iterResult.elapsedSteps, 2); + }); + + test("given an ArrayExpression with empty Literals then the iterable must return no values", () => { + const [astNode] = codeToAst("['', '']"); + const iter = arrayExpressionToString(getExpressionFromStatement(astNode)); + + const iterResult = [...iter]; + + assert.strictEqual(iterResult.length, 0); + }); + + test("given an AST that is not an ArrayExpression then it must return immediately", () => { + const [astNode] = codeToAst("const foo = 5;"); + const iter = arrayExpressionToString(astNode); + + const iterResult = [...iter]; + + assert.strictEqual(iterResult.length, 0); + }); +}); + +describe("joinArrayExpression", () => { + test("should return null if the node is not a CallExpression", () => { + const [ast] = codeToAst("const a = 1;"); + assert.strictEqual( + joinArrayExpression(getExpressionFromStatementIf(ast)), + null + ); + }); + + test("should combine and return the IP", () => { + const [ast] = codeToAst(`["127","0","0","1"].join(".");`); + assert.strictEqual( + joinArrayExpression(getExpressionFromStatementIf(ast)), + "127.0.0.1" + ); + }); + + test("should combine multiple depth of joined ArrayExpression", () => { + const [ast] = codeToAst(`[ + ["hello", "world"].join(" "), + "0", + "0", + "1" + ].join(".");`); + assert.strictEqual( + joinArrayExpression(getExpressionFromStatementIf(ast)), + "hello world.0.0.1" + ); + }); + + test("should look for external identifiers and join the two variables of the ArrayExpression", () => { + const literalIdentifiers = new Map(); + literalIdentifiers.set("a", "1"); + literalIdentifiers.set("b", "2"); + + const [ast] = codeToAst("[a, b].join('.');"); + assert.strictEqual( + joinArrayExpression( + getExpressionFromStatementIf(ast), + { + externalIdentifierLookup: (name: string) => literalIdentifiers.get(name) ?? null + } + ), + "1.2" + ); + }); +}); diff --git a/workspaces/estree-ast-utils/test/arrayExpressionToString.spec.ts b/workspaces/estree-ast-utils/test/arrayExpressionToString.spec.ts deleted file mode 100644 index ceb8d07b..00000000 --- a/workspaces/estree-ast-utils/test/arrayExpressionToString.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Import Node.js Dependencies -import { test } from "node:test"; -import assert from "node:assert"; - -// Import Third-party Dependencies -import { IteratorMatcher } from "iterator-matcher"; - -// Import Internal Dependencies -import { arrayExpressionToString } from "../src/index.js"; -import { codeToAst, getExpressionFromStatement } from "./utils.js"; - -test("given an ArrayExpression with two Literals then the iterable must return them one by one", () => { - const [astNode] = codeToAst("['foo', 'bar']"); - const iter = arrayExpressionToString(getExpressionFromStatement(astNode)); - - const iterResult = new IteratorMatcher() - .expect("foo") - .expect("bar") - .execute(iter, { allowNoMatchingValues: false }); - - assert.strictEqual(iterResult.isMatching, true); - assert.strictEqual(iterResult.elapsedSteps, 2); -}); - -test("given an ArrayExpression with two Identifiers then the iterable must return value from the Tracer", () => { - const literalIdentifiers = new Map(); - literalIdentifiers.set("foo", "1"); - literalIdentifiers.set("bar", "2"); - - const [astNode] = codeToAst("[foo, bar]"); - const iter = arrayExpressionToString( - getExpressionFromStatement(astNode), - { - externalIdentifierLookup: (name: string) => literalIdentifiers.get(name) ?? null - } - ); - - const iterResult = new IteratorMatcher() - .expect("1") - .expect("2") - .execute(iter, { allowNoMatchingValues: false }); - - assert.strictEqual(iterResult.isMatching, true); - assert.strictEqual(iterResult.elapsedSteps, 2); -}); - -test(`given an ArrayExpression with two numbers - then the function must convert them as char code - and return them in the iterable`, () => { - const [astNode] = codeToAst("[65, 66]"); - const iter = arrayExpressionToString(getExpressionFromStatement(astNode)); - - const iterResult = new IteratorMatcher() - .expect("A") - .expect("B") - .execute(iter, { allowNoMatchingValues: false }); - - assert.strictEqual(iterResult.isMatching, true); - assert.strictEqual(iterResult.elapsedSteps, 2); -}); - -test("given an ArrayExpression with empty Literals then the iterable must return no values", () => { - const [astNode] = codeToAst("['', '']"); - const iter = arrayExpressionToString(getExpressionFromStatement(astNode)); - - const iterResult = [...iter]; - - assert.strictEqual(iterResult.length, 0); -}); - -test("given an AST that is not an ArrayExpression then it must return immediately", () => { - const [astNode] = codeToAst("const foo = 5;"); - const iter = arrayExpressionToString(astNode); - - const iterResult = [...iter]; - - assert.strictEqual(iterResult.length, 0); -}); diff --git a/workspaces/estree-ast-utils/test/utils.ts b/workspaces/estree-ast-utils/test/utils.ts index d01e0c37..f952bf1a 100644 --- a/workspaces/estree-ast-utils/test/utils.ts +++ b/workspaces/estree-ast-utils/test/utils.ts @@ -19,6 +19,8 @@ export function getExpressionFromStatement(node: any) { return node.type === "ExpressionStatement" ? node.expression : null; } -export function getExpressionFromStatementIf(node: any) { +export function getExpressionFromStatementIf( + node: any +) { return node.type === "ExpressionStatement" ? node.expression : node; }