Skip to content

Commit 9fe7c47

Browse files
authored
[webpack-plugin-utilities] Add evaluateConstantEstreeExpression (#5848)
Rename evaluateAcornNode to evaluateConstantEstreeExpression and remove the hand-rolled IAcornNode interface in favor of @types/estree types. - Use proper ESTree types, handling object spread elements, private/ computed property keys, and sparse array holes correctly. - Support UnaryExpression (e.g. negative numbers like -1) and no-substitution TemplateLiteral nodes. - Add a TSDoc comment describing usage, supported nodes, and limitations. - Add unit tests covering all supported node types and error paths. - Update the hashed-folder-copy-plugin consumer accordingly.
1 parent 58f2846 commit 9fe7c47

10 files changed

Lines changed: 376 additions & 50 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/hashed-folder-copy-plugin",
5+
"comment": "",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@rushstack/hashed-folder-copy-plugin"
10+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"comment": "Add `evaluateConstantEstreeExpression` for statically evaluating constant ESTree expression nodes.",
5+
"type": "minor",
6+
"packageName": "@rushstack/webpack-plugin-utilities"
7+
}
8+
],
9+
"packageName": "@rushstack/webpack-plugin-utilities",
10+
"email": "iclanton@users.noreply.github.com"
11+
}

common/config/subspaces/default/pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/reviews/api/webpack-plugin-utilities.api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55
```ts
66

77
import type { Configuration } from 'webpack';
8+
import type { Expression } from 'estree';
89
import { IFs } from 'memfs';
910
import type { MultiStats } from 'webpack';
11+
import type { SpreadElement } from 'estree';
1012
import type { Stats } from 'webpack';
1113
import type * as Webpack from 'webpack';
1214

15+
// @beta
16+
export function evaluateConstantEstreeExpression<TNode>(node: Expression | SpreadElement): TNode;
17+
1318
// @public
1419
function getTestingWebpackCompilerAsync(entry: string, additionalConfig?: Configuration, memFs?: IFs): Promise<(Stats | MultiStats) | undefined>;
1520

webpack/hashed-folder-copy-plugin/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
},
5353
"dependencies": {
5454
"@rushstack/node-core-library": "workspace:*",
55+
"@rushstack/webpack-plugin-utilities": "workspace:*",
5556
"fast-glob": "~3.3.1"
5657
},
5758
"devDependencies": {

webpack/hashed-folder-copy-plugin/src/HashedFolderCopyPlugin.ts

Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type webpack from 'webpack';
66
import type glob from 'fast-glob';
77

88
import { Async } from '@rushstack/node-core-library';
9+
import { evaluateConstantEstreeExpression } from '@rushstack/webpack-plugin-utilities';
910

1011
import {
1112
type IHashedFolderDependency,
@@ -25,16 +26,6 @@ const PLUGIN_NAME: 'hashed-folder-copy-plugin' = 'hashed-folder-copy-plugin';
2526

2627
const EXPRESSION_NAME: 'requireFolder' = 'requireFolder';
2728

28-
interface IAcornNode<TExpression> {
29-
computed: boolean | undefined;
30-
elements: IAcornNode<unknown>[];
31-
key: IAcornNode<unknown> | undefined;
32-
name: string | undefined;
33-
properties: IAcornNode<unknown>[] | undefined;
34-
type: 'Literal' | 'ObjectExpression' | 'Identifier' | 'ArrayExpression' | unknown;
35-
value: TExpression;
36-
}
37-
3829
export function renderError(errorMessage: string): string {
3930
return `(function () { throw new Error(${JSON.stringify(errorMessage)}); })()`;
4031
}
@@ -92,10 +83,9 @@ export class HashedFolderCopyPlugin implements webpack.WebpackPluginInstance {
9283
if (expression.arguments.length !== 1) {
9384
errorMessage = `Exactly one argument is required to be passed to "${EXPRESSION_NAME}"`;
9485
} else {
95-
const argument: IAcornNode<IRequireFolderOptions> = expression
96-
.arguments[0] as IAcornNode<IRequireFolderOptions>;
86+
const argument: Expression = expression.arguments[0] as Expression;
9787
try {
98-
requireFolderOptions = this._evaluateAcornNode(argument) as IRequireFolderOptions;
88+
requireFolderOptions = evaluateConstantEstreeExpression(argument);
9989
} catch (e) {
10090
errorMessage = (e as Error).message;
10191
}
@@ -164,37 +154,4 @@ export class HashedFolderCopyPlugin implements webpack.WebpackPluginInstance {
164154
}
165155
);
166156
}
167-
168-
private _evaluateAcornNode(node: IAcornNode<unknown>): unknown {
169-
switch (node.type) {
170-
case 'Literal': {
171-
return node.value;
172-
}
173-
174-
case 'ObjectExpression': {
175-
const result: Record<string, unknown> = {};
176-
177-
for (const property of node.properties!) {
178-
const keyNode: IAcornNode<unknown> = property.key!;
179-
if (keyNode.type !== 'Identifier' || keyNode.computed) {
180-
throw new Error('Property keys must be non-computed identifiers');
181-
}
182-
183-
const key: string = keyNode.name!;
184-
const value: unknown = this._evaluateAcornNode(property.value as IAcornNode<unknown>);
185-
result[key] = value;
186-
}
187-
188-
return result;
189-
}
190-
191-
case 'ArrayExpression': {
192-
return node.elements.map((element) => this._evaluateAcornNode(element));
193-
}
194-
195-
default: {
196-
throw new Error(`Unsupported node type: "${node.type}"`);
197-
}
198-
}
199-
}
200157
}

webpack/webpack-plugin-utilities/package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,14 @@
3535
},
3636
"scripts": {
3737
"build": "heft build --clean",
38-
"_phase:build": "heft run --only build -- --clean"
38+
"test": "heft run --only test",
39+
"_phase:build": "heft run --only build -- --clean",
40+
"_phase:test": "heft run --only test -- --clean"
3941
},
4042
"dependencies": {
41-
"webpack-merge": "~5.8.0",
42-
"memfs": "4.12.0"
43+
"@types/estree": "1.0.8",
44+
"memfs": "4.12.0",
45+
"webpack-merge": "~5.8.0"
4346
},
4447
"peerDependencies": {
4548
"@types/webpack": "^4.39.8",
@@ -55,9 +58,9 @@
5558
},
5659
"devDependencies": {
5760
"@rushstack/heft": "workspace:*",
61+
"@types/tapable": "1.0.6",
5862
"eslint": "~9.37.0",
5963
"local-node-rig": "workspace:*",
60-
"@types/tapable": "1.0.6",
6164
"webpack": "~5.105.2"
6265
},
6366
"sideEffects": false
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { Expression, PrivateIdentifier, SpreadElement } from 'estree';
5+
6+
/**
7+
* Statically evaluates an ESTree (acorn) expression node into its corresponding
8+
* runtime JavaScript value.
9+
*
10+
* @remarks
11+
* This is intended to be used inside webpack plugins and loaders that hook into
12+
* the parser (for example `parser.hooks.call`) and need to read the literal
13+
* arguments passed to a function call at build time, without actually executing
14+
* the user's code. A common use case is extracting a plain options object that
15+
* was passed to a custom `require()`-style expression.
16+
*
17+
* Only a small subset of expression types is supported, namely those needed to
18+
* express JSON-like constant values:
19+
*
20+
* - `Literal` (strings, numbers, booleans, `null`, etc.)
21+
* - `UnaryExpression` (the `-`, `+`, `!`, and `~` operators applied to a constant
22+
* argument, e.g. the `-1` in `{ count: -1 }`)
23+
* - `TemplateLiteral` (only when it has no `${...}` substitutions)
24+
* - `ObjectExpression` (with non-computed identifier keys)
25+
* - `ArrayExpression` (including sparse holes, which evaluate to `null`)
26+
*
27+
* @example
28+
* ```ts
29+
* // Given source: requireFolder({ outputFolder: 'assets', sources: [] })
30+
* const options: IRequireFolderOptions = evaluateConstantEstreeExpression(callExpression.arguments[0]);
31+
* ```
32+
*
33+
* @remarks Limitations
34+
* Because the node is evaluated statically rather than executed, anything that
35+
* is not a compile-time constant is unsupported and will cause an `Error` to be
36+
* thrown. This includes:
37+
*
38+
* - Identifiers and variable references (e.g. `someVariable`)
39+
* - Computed property keys (e.g. `{ [key]: value }`)
40+
* - Spread elements in object expressions (e.g. `{ ...other }`)
41+
* - Function calls, template literals, and any other expression type not listed above
42+
*
43+
* @param node - The ESTree expression node to evaluate.
44+
* @returns The evaluated value, cast to the caller-specified type `TNode`. Note
45+
* that the cast is unchecked; the caller is responsible for validating that the
46+
* returned shape matches `TNode`.
47+
* @throws An `Error` if the node (or any nested node) uses an unsupported
48+
* expression type or syntax.
49+
* @beta
50+
*/
51+
export function evaluateConstantEstreeExpression<TNode>(node: Expression | SpreadElement): TNode {
52+
switch (node.type) {
53+
case 'Literal': {
54+
return node.value as TNode;
55+
}
56+
57+
case 'UnaryExpression': {
58+
const argumentValue: unknown = evaluateConstantEstreeExpression(node.argument);
59+
switch (node.operator) {
60+
case '-': {
61+
return -(argumentValue as number) as TNode;
62+
}
63+
case '+': {
64+
return +(argumentValue as number) as TNode;
65+
}
66+
case '!': {
67+
return !argumentValue as TNode;
68+
}
69+
case '~': {
70+
return ~(argumentValue as number) as TNode;
71+
}
72+
default: {
73+
throw new Error(`Unsupported unary operator: "${node.operator}"`);
74+
}
75+
}
76+
}
77+
78+
case 'TemplateLiteral': {
79+
if (node.expressions.length > 0) {
80+
throw new Error('Template literals with substitutions are not supported');
81+
}
82+
83+
return node.quasis[0].value.cooked as TNode;
84+
}
85+
86+
case 'ObjectExpression': {
87+
const result: Record<string, unknown> = {};
88+
89+
for (const property of node.properties) {
90+
if (property.type === 'SpreadElement') {
91+
throw new Error('Spread elements are not supported in object expressions');
92+
}
93+
94+
const keyNode: Expression | PrivateIdentifier = property.key;
95+
if (keyNode.type !== 'Identifier' || property.computed) {
96+
throw new Error('Property keys must be non-computed identifiers');
97+
}
98+
99+
const key: string = keyNode.name;
100+
const value: unknown = evaluateConstantEstreeExpression(property.value as Expression);
101+
result[key] = value;
102+
}
103+
104+
return result as TNode;
105+
}
106+
107+
case 'ArrayExpression': {
108+
return node.elements.map((element) =>
109+
element === null ? null : evaluateConstantEstreeExpression(element)
110+
) as TNode;
111+
}
112+
113+
default: {
114+
throw new Error(`Unsupported node type: "${node.type}"`);
115+
}
116+
}
117+
}

webpack/webpack-plugin-utilities/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@
1010
import * as VersionDetection from './DetectWebpackVersion';
1111
import * as Testing from './Testing';
1212
export { VersionDetection, Testing };
13+
14+
export { evaluateConstantEstreeExpression } from './evaluateConstantEstreeExpression';

0 commit comments

Comments
 (0)