@lexical/compiler 0.0.0-bootstrap.0 → 0.49.1-nightly.20260902.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -2
- package/dist/LexicalCompiler.dev.js +1725 -0
- package/dist/LexicalCompiler.dev.mjs +1704 -0
- package/dist/LexicalCompiler.js +11 -0
- package/dist/LexicalCompiler.js.flow +32 -0
- package/dist/LexicalCompiler.mjs +17 -0
- package/dist/LexicalCompiler.node.mjs +15 -0
- package/dist/LexicalCompiler.prod.js +9 -0
- package/dist/LexicalCompiler.prod.mjs +9 -0
- package/dist/LexicalCompilerPureAnnotations.dev.js +1725 -0
- package/dist/LexicalCompilerPureAnnotations.dev.mjs +1704 -0
- package/dist/LexicalCompilerPureAnnotations.js +11 -0
- package/dist/LexicalCompilerPureAnnotations.js.flow +78 -0
- package/dist/LexicalCompilerPureAnnotations.mjs +17 -0
- package/dist/LexicalCompilerPureAnnotations.node.mjs +15 -0
- package/dist/LexicalCompilerPureAnnotations.prod.js +9 -0
- package/dist/LexicalCompilerPureAnnotations.prod.mjs +9 -0
- package/dist/PureAnnotations.d.ts +172 -0
- package/dist/index.d.ts +8 -0
- package/dist/passes/pureAnnotations.d.mts +246 -0
- package/dist/typescript-too-old.d.ts +18 -0
- package/package.json +111 -6
- package/src/PureAnnotations.ts +206 -0
- package/src/index.ts +12 -0
- package/src/passes/pureAnnotations.mjs +1618 -0
|
@@ -0,0 +1,1618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The tree-shaking pass: the implementation behind
|
|
11
|
+
* `@lexical/compiler/PureAnnotations`, whose typed facade is
|
|
12
|
+
* `src/PureAnnotations.ts`. A pass lives here as plain JavaScript for
|
|
13
|
+
* bootstrapping reasons — `scripts/build.mjs` runs under plain node and
|
|
14
|
+
* imports it from source to annotate the packages it is building, before
|
|
15
|
+
* anything has been compiled, and node cannot load TypeScript. Its types
|
|
16
|
+
* are JSDoc, and `tsconfig.scripts.json` type-checks this directory with
|
|
17
|
+
* `checkJs` and `strict`.
|
|
18
|
+
*
|
|
19
|
+
* The comments here spell the annotations without their leading sigil
|
|
20
|
+
* (`__PURE__`, `__NO_SIDE_EFFECTS__`) because bundlers scan comments for
|
|
21
|
+
* annotations, and one that does not precede a call expression is reported
|
|
22
|
+
* as misplaced (rollup's INVALID_ANNOTATION). Only the string and regexp
|
|
23
|
+
* literals below contain the real tokens.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {parse} from '@babel/parser';
|
|
27
|
+
import MagicString from 'magic-string';
|
|
28
|
+
import * as fs from 'node:fs';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The Lexical factories that are annotated with `__NO_SIDE_EFFECTS__` at
|
|
33
|
+
* their definition. That annotation is only honored by esbuild for calls in
|
|
34
|
+
* the same file as the definition (and not at all by webpack/terser), so
|
|
35
|
+
* every module-scope call site needs its own `__PURE__` annotation for
|
|
36
|
+
* bundlers to tree-shake unused extension/command/rule definitions out of
|
|
37
|
+
* application bundles.
|
|
38
|
+
*
|
|
39
|
+
* Argument-position calls (e.g. `configExtension` / `safeCast` inside a
|
|
40
|
+
* `defineExtension` config) matter too: a pure call is only removable when
|
|
41
|
+
* its arguments are also side-effect-free, so one unannotated nested call
|
|
42
|
+
* pins the entire enclosing definition.
|
|
43
|
+
*
|
|
44
|
+
* @type {ReadonlyArray<string>}
|
|
45
|
+
*/
|
|
46
|
+
export const PURE_FACTORY_FUNCTIONS = [
|
|
47
|
+
'configExtension',
|
|
48
|
+
'createCommand',
|
|
49
|
+
'createContextState',
|
|
50
|
+
'createImportState',
|
|
51
|
+
'createRenderState',
|
|
52
|
+
'createState',
|
|
53
|
+
'declarePeerDependency',
|
|
54
|
+
'defineExtension',
|
|
55
|
+
'defineImportRule',
|
|
56
|
+
'defineOverlayRules',
|
|
57
|
+
'domOverride',
|
|
58
|
+
'safeCast',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The factories whose implementation is a trivial expression over their own
|
|
63
|
+
* arguments, and the shape a call to them can be replaced with. Eliding the
|
|
64
|
+
* call is better than annotating it: a literal is inert for every bundler,
|
|
65
|
+
* with no annotation to preserve and nothing left to pin the definition it
|
|
66
|
+
* appears in.
|
|
67
|
+
*
|
|
68
|
+
* Each of these is marked `@lexical-inline <form>` where it is defined, and
|
|
69
|
+
* the unit tests check both that the marker and this table agree and that
|
|
70
|
+
* the real function still returns what the form claims. `createCommand` is
|
|
71
|
+
* deliberately not here: `{type: x}` is more bytes than a call to a minified
|
|
72
|
+
* one-character name.
|
|
73
|
+
*
|
|
74
|
+
* - `identity` returns its single argument
|
|
75
|
+
* - `args` returns all of its arguments as an array
|
|
76
|
+
*
|
|
77
|
+
* @type {ReadonlyMap<string, {form: 'args' | 'identity'}>}
|
|
78
|
+
*/
|
|
79
|
+
const INLINE_FACTORIES = new Map([
|
|
80
|
+
['configExtension', {form: 'args'}],
|
|
81
|
+
['declarePeerDependency', {form: 'args'}],
|
|
82
|
+
['defineExtension', {form: 'identity'}],
|
|
83
|
+
['defineImportRule', {form: 'identity'}],
|
|
84
|
+
['safeCast', {form: 'identity'}],
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** The marker a definition uses to declare itself inlinable. */
|
|
88
|
+
// A JSDoc tag, so it has to start its line (after the comment's own `*`).
|
|
89
|
+
// Prose that merely mentions the tag — including this file's own
|
|
90
|
+
// documentation of it — is not a marker.
|
|
91
|
+
const INLINE_MARKER_RE =
|
|
92
|
+
/^[^\S\n]*\*?[^\S\n]*@lexical-inline(?:[^\S\n]+(\S+))?[^\S\n]*$/m;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* An error the author has to act on — a malformed marker, or a call this
|
|
96
|
+
* transform cannot prove is safe — as opposed to source it cannot parse.
|
|
97
|
+
* The plugin passes an unparseable module through with a warning; these it
|
|
98
|
+
* rethrows, because ignoring them would silently produce the bundle the
|
|
99
|
+
* author was trying to avoid.
|
|
100
|
+
*/
|
|
101
|
+
export class PureAnnotationsError extends Error {
|
|
102
|
+
/** @param {string} message */
|
|
103
|
+
constructor(message) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.name = 'PureAnnotationsError';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The tag that declares an object's methods side-effect free, so that a
|
|
110
|
+
// namespace reached through a relative import or a local alias — as
|
|
111
|
+
// @lexical/html's own modules reach `selBase` — is recognized without
|
|
112
|
+
// having to be listed in the table above.
|
|
113
|
+
const NAMESPACE_MARKER_RE =
|
|
114
|
+
/^[^\S\n]*\*?[^\S\n]*@lexical-pure-namespace[^\S\n]*$/m;
|
|
115
|
+
|
|
116
|
+
/** The forms a `@lexical-inline` marker may name. */
|
|
117
|
+
const INLINE_FORMS = new Set(['args', 'identity']);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Imported objects whose method calls are side-effect free, so that a
|
|
121
|
+
* module-scope call like `sel.tag('p').attr('data-x', true)` is annotated
|
|
122
|
+
* the same way a call to a factory function is. Only the outermost call of
|
|
123
|
+
* a chain is annotated: rollup, terser and esbuild all drop the whole chain
|
|
124
|
+
* from that one annotation.
|
|
125
|
+
*
|
|
126
|
+
* As with the factories, the name is only trusted when it is imported from
|
|
127
|
+
* a Lexical package.
|
|
128
|
+
*/
|
|
129
|
+
export const PURE_NAMESPACES = ['sel'];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The form each inlinable factory is replaced with, for the tests that keep
|
|
133
|
+
* this table honest and for anyone who wants to know what `inline` does.
|
|
134
|
+
*
|
|
135
|
+
* @type {ReadonlyMap<string, string>}
|
|
136
|
+
*/
|
|
137
|
+
export const INLINE_FACTORY_FORMS = new Map(
|
|
138
|
+
Array.from(INLINE_FACTORIES, ([name, spec]) => [name, spec.form]),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The annotation inserted before a module-scope factory call. No trailing
|
|
143
|
+
* space: a block comment is a token separator on its own, so the space would
|
|
144
|
+
* only be there to be stripped again by the minifier.
|
|
145
|
+
*/
|
|
146
|
+
const ANNOTATION = '/* @__PURE__ */';
|
|
147
|
+
|
|
148
|
+
/** Matches the contents of a comment that is already a pure annotation. */
|
|
149
|
+
/**
|
|
150
|
+
* `overwrite` clears the intro and outro of every chunk it spans unless it is
|
|
151
|
+
* told to touch the content only — and an annotation this transform appended
|
|
152
|
+
* to a nested call lives in the outro of the chunk that ends where that call
|
|
153
|
+
* begins. Replacing a call's `factory(` with `([` would take the annotation
|
|
154
|
+
* on its first argument with it.
|
|
155
|
+
*
|
|
156
|
+
* @type {{contentOnly: true}}
|
|
157
|
+
*/
|
|
158
|
+
const CONTENT_ONLY = {contentOnly: true};
|
|
159
|
+
|
|
160
|
+
const PURE_ANNOTATION_RE = /[#@]__PURE__/;
|
|
161
|
+
|
|
162
|
+
/** The file extensions the bundler plugin transforms by default. */
|
|
163
|
+
const DEFAULT_INCLUDE = /\.(?:[cm]?[jt]sx?)$/;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Module specifiers whose exports are trusted to be the Lexical factories.
|
|
167
|
+
* Every factory in PURE_FACTORY_FUNCTIONS is declared side-effect free in
|
|
168
|
+
* these packages, so an import from one of them is evidence enough. A call
|
|
169
|
+
* to a same-named function from anywhere else is only annotated when the
|
|
170
|
+
* declaration it resolves to is itself marked side-effect free.
|
|
171
|
+
*/
|
|
172
|
+
const DEFAULT_SOURCES = [/^lexical$/, /^@lexical\//];
|
|
173
|
+
|
|
174
|
+
/** Matches the annotation a definition uses to declare itself pure. */
|
|
175
|
+
const NO_SIDE_EFFECTS_RE = /[#@]__NO_SIDE_EFFECTS__/;
|
|
176
|
+
|
|
177
|
+
/** An import of a module next to this one, which may declare a namespace. */
|
|
178
|
+
const RELATIVE_IMPORT_RE = /\bfrom\s*['"]\./;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Expression types that read the same with or without the parentheses the
|
|
182
|
+
* call they replace was written with: they are a single delimited token, so
|
|
183
|
+
* no surrounding operator can bind into them. Anything else keeps the
|
|
184
|
+
* parentheses, which costs two characters a minifier removes anyway.
|
|
185
|
+
*/
|
|
186
|
+
const SELF_DELIMITING_TYPES = new Set([
|
|
187
|
+
'ArrayExpression',
|
|
188
|
+
'BigIntLiteral',
|
|
189
|
+
'BooleanLiteral',
|
|
190
|
+
'DecimalLiteral',
|
|
191
|
+
'Identifier',
|
|
192
|
+
'JSXElement',
|
|
193
|
+
'JSXFragment',
|
|
194
|
+
'NullLiteral',
|
|
195
|
+
'NumericLiteral',
|
|
196
|
+
'ObjectExpression',
|
|
197
|
+
'RegExpLiteral',
|
|
198
|
+
'StringLiteral',
|
|
199
|
+
'TemplateLiteral',
|
|
200
|
+
'ThisExpression',
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
/** Extensions tried when resolving an extensionless relative import. */
|
|
204
|
+
const RESOLVE_EXTENSIONS = [
|
|
205
|
+
'.ts',
|
|
206
|
+
'.tsx',
|
|
207
|
+
'.mts',
|
|
208
|
+
'.cts',
|
|
209
|
+
'.js',
|
|
210
|
+
'.jsx',
|
|
211
|
+
'.mjs',
|
|
212
|
+
'.cjs',
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Node types whose contents are not evaluated when the module is
|
|
217
|
+
* initialized. A `__PURE__` annotation only affects tree-shaking of
|
|
218
|
+
* module-scope evaluation, so the walk never descends into these.
|
|
219
|
+
*/
|
|
220
|
+
/**
|
|
221
|
+
* Built-ins whose construction or call has no side effects. Bundlers know
|
|
222
|
+
* this for themselves (rollup's known globals, esbuild's primitive
|
|
223
|
+
* handling), so requiring an annotation on `new Map([...])` inside a
|
|
224
|
+
* definition would be noise.
|
|
225
|
+
*/
|
|
226
|
+
const PURE_GLOBALS = new Set([
|
|
227
|
+
'Array',
|
|
228
|
+
'BigInt',
|
|
229
|
+
'Boolean',
|
|
230
|
+
'Map',
|
|
231
|
+
'Number',
|
|
232
|
+
'Object',
|
|
233
|
+
'RegExp',
|
|
234
|
+
'Set',
|
|
235
|
+
'String',
|
|
236
|
+
'Symbol',
|
|
237
|
+
'WeakMap',
|
|
238
|
+
'WeakSet',
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A dependency's own code. It is still annotated — a Lexical consumed
|
|
243
|
+
* through its `source` export condition is resolved from here — but `strict`
|
|
244
|
+
* does not apply to it: a definition somebody else shipped is not the
|
|
245
|
+
* building project's to fix, and neither is a marker they got wrong.
|
|
246
|
+
*/
|
|
247
|
+
const NODE_MODULES_RE = /[\\/]node_modules[\\/]/;
|
|
248
|
+
|
|
249
|
+
const DEFERRED_TYPES = new Set([
|
|
250
|
+
'ArrowFunctionExpression',
|
|
251
|
+
'ClassAccessorProperty',
|
|
252
|
+
'ClassMethod',
|
|
253
|
+
'ClassPrivateMethod',
|
|
254
|
+
'ClassPrivateProperty',
|
|
255
|
+
'ClassProperty',
|
|
256
|
+
'FunctionDeclaration',
|
|
257
|
+
'FunctionExpression',
|
|
258
|
+
'ObjectMethod',
|
|
259
|
+
'PropertyDefinition',
|
|
260
|
+
'StaticBlock',
|
|
261
|
+
'TSDeclareMethod',
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
/** AST properties that never contain child nodes worth visiting. */
|
|
265
|
+
/** @type {ReadonlySet<string>} */
|
|
266
|
+
const EMPTY_NAMES = new Set();
|
|
267
|
+
|
|
268
|
+
const SKIPPED_KEYS = new Set([
|
|
269
|
+
'comments',
|
|
270
|
+
'extra',
|
|
271
|
+
'innerComments',
|
|
272
|
+
'leadingComments',
|
|
273
|
+
'loc',
|
|
274
|
+
'trailingComments',
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @param {undefined | string} filename
|
|
279
|
+
* @param {undefined | ReadonlyArray<any>} extraPlugins
|
|
280
|
+
* @returns {Array<any>} the Babel parser plugins to parse this file with
|
|
281
|
+
*/
|
|
282
|
+
function parserPluginsFor(filename, extraPlugins) {
|
|
283
|
+
const name = typeof filename === 'string' ? filename : '';
|
|
284
|
+
const isTypeScript = /\.[cm]?tsx?$/i.test(name);
|
|
285
|
+
/** @type {Array<any>} */
|
|
286
|
+
const plugins = ['explicitResourceManagement'];
|
|
287
|
+
if (isTypeScript) {
|
|
288
|
+
plugins.push('typescript');
|
|
289
|
+
}
|
|
290
|
+
// `<T>(value: T) => value` in a .ts file is a generic arrow function, not
|
|
291
|
+
// an opening JSX element, so the jsx plugin must stay off there. Any other
|
|
292
|
+
// extension (including an unknown one) is parsed with jsx enabled.
|
|
293
|
+
if (!isTypeScript || /\.[cm]?tsx$/i.test(name)) {
|
|
294
|
+
plugins.push('jsx');
|
|
295
|
+
}
|
|
296
|
+
return extraPlugins ? plugins.concat(extraPlugins) : plugins;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Find the block comment that ends immediately before `offset` (ignoring
|
|
301
|
+
* whitespace), if any.
|
|
302
|
+
*
|
|
303
|
+
* This reads the source text rather than the parsed `leadingComments` so
|
|
304
|
+
* that a comment attached to an enclosing statement (one that precedes
|
|
305
|
+
* `export const x = f()` rather than the call itself, where it does nothing)
|
|
306
|
+
* is not mistaken for an annotation on the call.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} code
|
|
309
|
+
* @param {number} offset
|
|
310
|
+
* @returns {null | {end: number, start: number, value: string}}
|
|
311
|
+
*/
|
|
312
|
+
function blockCommentBefore(code, offset) {
|
|
313
|
+
let end = offset;
|
|
314
|
+
while (end > 0 && /\s/.test(code[end - 1])) {
|
|
315
|
+
end--;
|
|
316
|
+
}
|
|
317
|
+
if (end < 4 || code[end - 1] !== '/' || code[end - 2] !== '*') {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
const start = code.lastIndexOf('/*', end - 2);
|
|
321
|
+
if (start === -1) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return {end, start, value: code.slice(start + 2, end - 2)};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {string} code
|
|
329
|
+
* @param {number} offset the start of a call expression
|
|
330
|
+
* @returns {boolean} true when the call already carries a pure annotation
|
|
331
|
+
*/
|
|
332
|
+
function hasPureAnnotation(code, offset) {
|
|
333
|
+
const comment = blockCommentBefore(code, offset);
|
|
334
|
+
return comment !== null && PURE_ANNOTATION_RE.test(comment.value);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The identifier a member-expression callee is rooted at, so that every call
|
|
339
|
+
* in `sel.tag('p').attr('x')` resolves to `sel`.
|
|
340
|
+
*
|
|
341
|
+
* @param {any} callee
|
|
342
|
+
* @returns {null | string} the name, or null when the callee is not rooted
|
|
343
|
+
* at a plain identifier
|
|
344
|
+
*/
|
|
345
|
+
export function rootObjectName(callee) {
|
|
346
|
+
for (let node = callee; node; ) {
|
|
347
|
+
if (node.type === 'Identifier') {
|
|
348
|
+
return node.name;
|
|
349
|
+
} else if (node.type === 'MemberExpression') {
|
|
350
|
+
node = node.object;
|
|
351
|
+
} else if (node.type === 'CallExpression') {
|
|
352
|
+
node = node.callee;
|
|
353
|
+
} else {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The names a nested block binds for itself with `let`, `const`, `class` or
|
|
362
|
+
* a function declaration. Only these shadow an outer binding: `var` is
|
|
363
|
+
* scoped to the module, so a `var` of an imported name is a redeclaration
|
|
364
|
+
* error rather than a shadow.
|
|
365
|
+
*
|
|
366
|
+
* @param {any} node
|
|
367
|
+
* @returns {null | Set<string>} the names, or null when this node binds none
|
|
368
|
+
*/
|
|
369
|
+
function blockScopedNames(node) {
|
|
370
|
+
// Only a nested block: the module's own declarations are the bindings the
|
|
371
|
+
// transform resolved in the first place, not shadows of them.
|
|
372
|
+
const body =
|
|
373
|
+
node.type === 'BlockStatement'
|
|
374
|
+
? node.body
|
|
375
|
+
: node.type === 'SwitchCase'
|
|
376
|
+
? node.consequent
|
|
377
|
+
: null;
|
|
378
|
+
if (body === null) {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
/** @type {null | Set<string>} */
|
|
382
|
+
let names = null;
|
|
383
|
+
/** @param {string} name */
|
|
384
|
+
const add = name => {
|
|
385
|
+
names = names || new Set();
|
|
386
|
+
names.add(name);
|
|
387
|
+
};
|
|
388
|
+
for (const statement of body) {
|
|
389
|
+
if (statement.type === 'VariableDeclaration' && statement.kind !== 'var') {
|
|
390
|
+
for (const declarator of statement.declarations) {
|
|
391
|
+
if (declarator.id.type === 'Identifier') {
|
|
392
|
+
add(declarator.id.name);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} else if (
|
|
396
|
+
(statement.type === 'FunctionDeclaration' ||
|
|
397
|
+
statement.type === 'ClassDeclaration') &&
|
|
398
|
+
statement.id
|
|
399
|
+
) {
|
|
400
|
+
add(statement.id.name);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return names;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Walk every node that is evaluated when the module is initialized, calling
|
|
408
|
+
* `visitor` with each module-scope call to one of the factories, or to a
|
|
409
|
+
* method of one of the namespaces, and where it sits: `discarded` when the
|
|
410
|
+
* call's value is thrown away (it is the whole of an expression statement),
|
|
411
|
+
* and `statementStart` when it begins one, both of which constrain what it
|
|
412
|
+
* can be replaced with. `namespace` marks a method call, which is annotated
|
|
413
|
+
* but never replaced.
|
|
414
|
+
*
|
|
415
|
+
* @param {any} node
|
|
416
|
+
* @param {{functions: ReadonlyMap<string, any> | ReadonlySet<string>, namespaces?: ReadonlySet<string>}} names
|
|
417
|
+
* @param {(node: any, position: {discarded: boolean, namespace?: boolean, statementStart: boolean}) => void} visitor
|
|
418
|
+
* @param {{discarded: boolean, shadowed: ReadonlySet<string>, statementStarts: Set<number>}} [state]
|
|
419
|
+
*/
|
|
420
|
+
function visitModuleScopeCalls(node, names, visitor, state) {
|
|
421
|
+
const {functions, namespaces} = names;
|
|
422
|
+
const walkState = state || {
|
|
423
|
+
discarded: false,
|
|
424
|
+
shadowed: EMPTY_NAMES,
|
|
425
|
+
statementStarts: new Set(),
|
|
426
|
+
};
|
|
427
|
+
if (Array.isArray(node)) {
|
|
428
|
+
for (const child of node) {
|
|
429
|
+
visitModuleScopeCalls(child, names, visitor, {
|
|
430
|
+
discarded: false,
|
|
431
|
+
shadowed: walkState.shadowed,
|
|
432
|
+
statementStarts: walkState.statementStarts,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (!node || typeof node !== 'object' || typeof node.type !== 'string') {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (DEFERRED_TYPES.has(node.type)) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (node.type === 'ExpressionStatement') {
|
|
444
|
+
// Every node that begins an expression statement starts at the same
|
|
445
|
+
// offset, so recording that one offset identifies the whole leftmost
|
|
446
|
+
// chain — the positions where an object literal would be read as a
|
|
447
|
+
// block, whatever it is nested in.
|
|
448
|
+
walkState.statementStarts.add(node.expression.start);
|
|
449
|
+
}
|
|
450
|
+
// Only a plain CallExpression: `factory?.(x)` parses as an
|
|
451
|
+
// OptionalCallExpression, and it evaluates to undefined when the factory
|
|
452
|
+
// is missing rather than to what the factory returns.
|
|
453
|
+
if (node.type === 'CallExpression' && node.callee) {
|
|
454
|
+
const position = {
|
|
455
|
+
discarded: walkState.discarded,
|
|
456
|
+
statementStart: walkState.statementStarts.has(node.start),
|
|
457
|
+
};
|
|
458
|
+
if (
|
|
459
|
+
node.callee.type === 'Identifier' &&
|
|
460
|
+
functions.has(node.callee.name) &&
|
|
461
|
+
!walkState.shadowed.has(node.callee.name)
|
|
462
|
+
) {
|
|
463
|
+
visitor(node, position);
|
|
464
|
+
} else if (namespaces !== undefined && namespaces.size > 0) {
|
|
465
|
+
const root = rootObjectName(node.callee);
|
|
466
|
+
if (
|
|
467
|
+
root !== null &&
|
|
468
|
+
namespaces.has(root) &&
|
|
469
|
+
!walkState.shadowed.has(root)
|
|
470
|
+
) {
|
|
471
|
+
visitor(node, {...position, namespace: true});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// A block that rebinds one of the names is talking about something else
|
|
476
|
+
// inside itself, and that something else may well have side effects.
|
|
477
|
+
const bound = blockScopedNames(node);
|
|
478
|
+
const shadowed =
|
|
479
|
+
bound === null
|
|
480
|
+
? walkState.shadowed
|
|
481
|
+
: new Set([...walkState.shadowed, ...bound]);
|
|
482
|
+
for (const key of Object.keys(node)) {
|
|
483
|
+
if (!SKIPPED_KEYS.has(key)) {
|
|
484
|
+
const value = node[key];
|
|
485
|
+
if (value && typeof value === 'object') {
|
|
486
|
+
visitModuleScopeCalls(value, names, visitor, {
|
|
487
|
+
discarded:
|
|
488
|
+
node.type === 'ExpressionStatement' && key === 'expression',
|
|
489
|
+
shadowed,
|
|
490
|
+
statementStarts: walkState.statementStarts,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* True when a call to a factory of this form can be replaced by the literal
|
|
499
|
+
* it would have returned. Anything the form cannot account for — a spread
|
|
500
|
+
* argument, the wrong number of arguments, a comma expression that would
|
|
501
|
+
* lose its parentheses — is annotated instead.
|
|
502
|
+
*
|
|
503
|
+
* @param {any} node the call expression
|
|
504
|
+
* @param {{form: string}} spec
|
|
505
|
+
* @returns {boolean}
|
|
506
|
+
*/
|
|
507
|
+
function canInlineCall(node, spec) {
|
|
508
|
+
const args = node.arguments;
|
|
509
|
+
if (spec.form === 'identity') {
|
|
510
|
+
return (
|
|
511
|
+
args.length === 1 &&
|
|
512
|
+
args[0].type !== 'SpreadElement' &&
|
|
513
|
+
args[0].type !== 'SequenceExpression'
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Walk every node in the tree, calling `visit` with each node and the node
|
|
521
|
+
* and key that hold it. Unlike visitModuleScopeCalls this does not stop at
|
|
522
|
+
* function boundaries: it is used to prove that a name is *not* referenced
|
|
523
|
+
* anywhere else in the module.
|
|
524
|
+
*
|
|
525
|
+
* @param {any} node
|
|
526
|
+
* @param {(node: any, parent: any, key: string) => void} visit
|
|
527
|
+
* @param {any} [parent]
|
|
528
|
+
* @param {string} [key]
|
|
529
|
+
*/
|
|
530
|
+
function walkAllNodes(node, visit, parent, key) {
|
|
531
|
+
if (Array.isArray(node)) {
|
|
532
|
+
for (const child of node) {
|
|
533
|
+
walkAllNodes(child, visit, parent, key);
|
|
534
|
+
}
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!node || typeof node !== 'object' || typeof node.type !== 'string') {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
visit(node, parent, /** @type {string} */ (key));
|
|
541
|
+
for (const childKey of Object.keys(node)) {
|
|
542
|
+
if (!SKIPPED_KEYS.has(childKey)) {
|
|
543
|
+
const value = node[childKey];
|
|
544
|
+
if (value && typeof value === 'object') {
|
|
545
|
+
walkAllNodes(value, visit, node, childKey);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* True when an identifier node is a reference to a binding rather than a
|
|
553
|
+
* name in some other position (a property name, an import specifier, ...).
|
|
554
|
+
* Anything unrecognized counts as a reference, so an unexpected position
|
|
555
|
+
* keeps the import rather than removing one that is still needed.
|
|
556
|
+
*
|
|
557
|
+
* @param {any} node
|
|
558
|
+
* @param {any} parent
|
|
559
|
+
* @param {string} key
|
|
560
|
+
* @returns {boolean}
|
|
561
|
+
*/
|
|
562
|
+
function isValueReference(node, parent, key) {
|
|
563
|
+
if (node.type !== 'Identifier' && node.type !== 'JSXIdentifier') {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
if (!parent) {
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
switch (parent.type) {
|
|
570
|
+
case 'ImportDefaultSpecifier':
|
|
571
|
+
case 'ImportNamespaceSpecifier':
|
|
572
|
+
case 'ImportSpecifier':
|
|
573
|
+
return false;
|
|
574
|
+
case 'ExportSpecifier':
|
|
575
|
+
// `export {imported as somethingElse}` still refers to the binding.
|
|
576
|
+
return key === 'local';
|
|
577
|
+
case 'OptionalMemberExpression':
|
|
578
|
+
case 'MemberExpression':
|
|
579
|
+
return key !== 'property' || parent.computed === true;
|
|
580
|
+
case 'ClassMethod':
|
|
581
|
+
case 'ClassProperty':
|
|
582
|
+
case 'ObjectMethod':
|
|
583
|
+
case 'ObjectProperty':
|
|
584
|
+
return key !== 'key' || parent.computed === true;
|
|
585
|
+
default:
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Remove the import specifiers whose only uses were calls that have just
|
|
592
|
+
* been replaced by a literal, so the transform does not leave behind an
|
|
593
|
+
* import that nothing references (which bundlers then warn about).
|
|
594
|
+
*
|
|
595
|
+
* @param {MagicString} magicString
|
|
596
|
+
* @param {string} code
|
|
597
|
+
* @param {any} program
|
|
598
|
+
* @param {ReadonlyArray<any>} inlinedCalls the call nodes that were replaced
|
|
599
|
+
*/
|
|
600
|
+
function removeUnusedImports(magicString, code, program, inlinedCalls) {
|
|
601
|
+
const callees = new Set(inlinedCalls.map(node => node.callee));
|
|
602
|
+
const names = new Set(inlinedCalls.map(node => node.callee.name));
|
|
603
|
+
if (names.size === 0) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// A name is still needed if anything other than an inlined callee refers
|
|
607
|
+
// to it, anywhere in the module.
|
|
608
|
+
walkAllNodes(program, (node, parent, key) => {
|
|
609
|
+
if (
|
|
610
|
+
names.has(node.name) &&
|
|
611
|
+
!callees.has(node) &&
|
|
612
|
+
isValueReference(node, parent, key)
|
|
613
|
+
) {
|
|
614
|
+
names.delete(node.name);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
for (const statement of program.body) {
|
|
618
|
+
if (
|
|
619
|
+
statement.type !== 'ImportDeclaration' ||
|
|
620
|
+
statement.importKind === 'type'
|
|
621
|
+
) {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const {specifiers} = statement;
|
|
625
|
+
const named = specifiers.filter(
|
|
626
|
+
/** @param {any} specifier */ specifier =>
|
|
627
|
+
specifier.type === 'ImportSpecifier',
|
|
628
|
+
);
|
|
629
|
+
const removable = named.filter(
|
|
630
|
+
/** @param {any} specifier */ specifier =>
|
|
631
|
+
specifier.importKind !== 'type' && names.has(specifier.local.name),
|
|
632
|
+
);
|
|
633
|
+
if (removable.length === 0) {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (removable.length < named.length) {
|
|
637
|
+
// Some names are still used: take out each unused one along with the
|
|
638
|
+
// comma that separates it from the named import next to it. Adjacent
|
|
639
|
+
// unused ones go as a single range — two overlapping removals would
|
|
640
|
+
// leave the comma of the first behind (`import {a, } from 'x'`).
|
|
641
|
+
const removableSet = new Set(removable);
|
|
642
|
+
for (let start = 0; start < named.length; start++) {
|
|
643
|
+
if (!removableSet.has(named[start])) {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
let end = start;
|
|
647
|
+
while (end + 1 < named.length && removableSet.has(named[end + 1])) {
|
|
648
|
+
end++;
|
|
649
|
+
}
|
|
650
|
+
if (end + 1 < named.length) {
|
|
651
|
+
magicString.remove(named[start].start, named[end + 1].start);
|
|
652
|
+
} else {
|
|
653
|
+
magicString.remove(named[start - 1].end, named[end].end);
|
|
654
|
+
}
|
|
655
|
+
start = end;
|
|
656
|
+
}
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const close = code.indexOf('}', named[named.length - 1].end);
|
|
660
|
+
if (named.length < specifiers.length) {
|
|
661
|
+
// A default or namespace import is still there, so only the braced
|
|
662
|
+
// clause and the comma before it go.
|
|
663
|
+
const previous = specifiers[specifiers.indexOf(named[0]) - 1];
|
|
664
|
+
magicString.remove(code.indexOf(',', previous.end), close + 1);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
// Nothing is imported from the module any more. Dropping the import
|
|
668
|
+
// drops the module, which is only safe because it has no side effects to
|
|
669
|
+
// run: the factories come either from a Lexical package (all of which
|
|
670
|
+
// are `sideEffects: false`) or from a module that declares functions
|
|
671
|
+
// side-effect free and inlinable. A module that exports such a factory
|
|
672
|
+
// and also does something at import time is out of scope by
|
|
673
|
+
// construction — Lexical has no modules with top-level side effects, and
|
|
674
|
+
// neither should a module that a build is told to inline from.
|
|
675
|
+
magicString.remove(statement.start, statement.end);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The offset just past a call's opening parenthesis, which is where its
|
|
681
|
+
* argument list begins — including any parentheses the first argument was
|
|
682
|
+
* written with, and any type arguments before it (`safeCast<T>(x)`).
|
|
683
|
+
*
|
|
684
|
+
* @param {string} code
|
|
685
|
+
* @param {any} node a call expression
|
|
686
|
+
* @returns {number}
|
|
687
|
+
*/
|
|
688
|
+
function argumentsStart(code, node) {
|
|
689
|
+
const typeArguments = node.typeArguments || node.typeParameters;
|
|
690
|
+
const from = Math.max(
|
|
691
|
+
node.callee.end,
|
|
692
|
+
typeArguments ? typeArguments.end : node.callee.end,
|
|
693
|
+
);
|
|
694
|
+
return code.indexOf('(', from) + 1;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Replace a call with the literal the factory would have returned, keeping
|
|
699
|
+
* the argument source text (and any nested edits within it) intact.
|
|
700
|
+
*
|
|
701
|
+
* @param {MagicString} magicString
|
|
702
|
+
* @param {string} code
|
|
703
|
+
* @param {any} node the call expression
|
|
704
|
+
* @param {{form: string}} spec
|
|
705
|
+
* @param {boolean} [statementStart] whether the call begins an expression
|
|
706
|
+
* statement, where an object literal would be read as a block
|
|
707
|
+
*/
|
|
708
|
+
function inlineCall(magicString, code, node, spec, statementStart) {
|
|
709
|
+
const comment = blockCommentBefore(code, node.start);
|
|
710
|
+
if (comment !== null && PURE_ANNOTATION_RE.test(comment.value)) {
|
|
711
|
+
// Left in front of a literal the annotation does nothing, and bundlers
|
|
712
|
+
// report it as misplaced.
|
|
713
|
+
magicString.remove(comment.start, node.start);
|
|
714
|
+
}
|
|
715
|
+
const args = node.arguments;
|
|
716
|
+
if (spec.form !== 'identity') {
|
|
717
|
+
// The argument list becomes the array literal, so the edit is on the
|
|
718
|
+
// call's own parentheses — `factory(` and its `)` — rather than on where
|
|
719
|
+
// the first and last arguments start and end. Those exclude parentheses
|
|
720
|
+
// an argument was written with (`configExtension(Ext, (cfg))`), which
|
|
721
|
+
// would be left behind unbalanced.
|
|
722
|
+
//
|
|
723
|
+
// Parenthesized so that the replacement binds exactly like the call it
|
|
724
|
+
// replaces, wherever that call was: a bare `[` at the start of an
|
|
725
|
+
// expression statement is a member access on whatever the previous line
|
|
726
|
+
// ended with, which is how ASI reads `f()\n[a, b].length`. Minifiers
|
|
727
|
+
// drop the parentheses again.
|
|
728
|
+
magicString.overwrite(
|
|
729
|
+
node.start,
|
|
730
|
+
argumentsStart(code, node),
|
|
731
|
+
'([',
|
|
732
|
+
CONTENT_ONLY,
|
|
733
|
+
);
|
|
734
|
+
magicString.overwrite(node.end - 1, node.end, '])', CONTENT_ONLY);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
// The call's own parentheses were doing two jobs: grouping the argument
|
|
738
|
+
// (`safeCast(a || b).c` must not become `a || b.c`) and, at the start of an
|
|
739
|
+
// expression statement, keeping an object literal from being read as a
|
|
740
|
+
// block. Keep them unless the argument reads the same without them.
|
|
741
|
+
const keepParens =
|
|
742
|
+
statementStart === true || !SELF_DELIMITING_TYPES.has(args[0].type);
|
|
743
|
+
if (keepParens) {
|
|
744
|
+
magicString.overwrite(node.start, args[0].start, '(', CONTENT_ONLY);
|
|
745
|
+
magicString.overwrite(args[0].end, node.end, ')', CONTENT_ONLY);
|
|
746
|
+
} else {
|
|
747
|
+
magicString.remove(node.start, args[0].start);
|
|
748
|
+
magicString.remove(args[0].end, node.end);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Caches for the files read while resolving relative imports. They live for
|
|
754
|
+
* the lifetime of the process, which is what a build wants; a watch-mode
|
|
755
|
+
* process that changes whether a declaration is marked side-effect free has
|
|
756
|
+
* to be restarted for that to take effect (the annotations do not change
|
|
757
|
+
* behavior, only what a bundler is allowed to drop).
|
|
758
|
+
*/
|
|
759
|
+
const resolvedFileCache = new Map();
|
|
760
|
+
const declarationCache = new Map();
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The form named by a declaration's `@lexical-inline` marker, if it has one.
|
|
764
|
+
*
|
|
765
|
+
* A marker naming a form this transform does not implement is an error
|
|
766
|
+
* rather than a marker that silently does nothing: the whole point of the
|
|
767
|
+
* marker is to say what a call may be replaced with, and a typo would leave
|
|
768
|
+
* the factory quietly un-inlined.
|
|
769
|
+
*
|
|
770
|
+
* @param {undefined | ReadonlyArray<any>} comments
|
|
771
|
+
* @param {undefined | string} filename the module the comments came from
|
|
772
|
+
* @returns {null | string}
|
|
773
|
+
*/
|
|
774
|
+
function inlineMarkerForm(comments, filename) {
|
|
775
|
+
if (comments) {
|
|
776
|
+
for (const comment of comments) {
|
|
777
|
+
const match = INLINE_MARKER_RE.exec(comment.value);
|
|
778
|
+
if (match) {
|
|
779
|
+
const form = match[1] || '';
|
|
780
|
+
if (!INLINE_FORMS.has(form)) {
|
|
781
|
+
const where = filename ? ` in ${filename}` : '';
|
|
782
|
+
throw new PureAnnotationsError(
|
|
783
|
+
`@lexical/compiler: unknown @lexical-inline form ` +
|
|
784
|
+
`${form ? `"${form}"` : '(none given)'}${where}. Write ` +
|
|
785
|
+
`\`@lexical-inline <form>\` with one of: ` +
|
|
786
|
+
`${Array.from(INLINE_FORMS).sort().join(', ')}.`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
return form;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* @param {undefined | ReadonlyArray<any>} comments
|
|
798
|
+
* @returns {boolean} true when one of the comments is a NO_SIDE_EFFECTS annotation
|
|
799
|
+
*/
|
|
800
|
+
function hasNoSideEffectsComment(comments) {
|
|
801
|
+
return (
|
|
802
|
+
comments !== undefined &&
|
|
803
|
+
comments !== null &&
|
|
804
|
+
comments.some(comment => NO_SIDE_EFFECTS_RE.test(comment.value))
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* The names this module declares (at module scope) with a NO_SIDE_EFFECTS
|
|
810
|
+
* annotation. Calls to these are safe to annotate wherever they appear,
|
|
811
|
+
* because the definition itself says so. The value is the form from the
|
|
812
|
+
* declaration's `@lexical-inline` marker, when it has one: purity alone is
|
|
813
|
+
* evidence enough to annotate a call, but replacing one requires knowing the
|
|
814
|
+
* shape of what the function returns.
|
|
815
|
+
*
|
|
816
|
+
* @param {any} program
|
|
817
|
+
* @param {undefined | string} filename the module being read, for errors
|
|
818
|
+
* @returns {Map<string, null | string>} declared name to its inline form
|
|
819
|
+
*/
|
|
820
|
+
function pureDeclaredNames(program, filename) {
|
|
821
|
+
/** @type {Map<string, null | string>} */
|
|
822
|
+
const names = new Map();
|
|
823
|
+
for (const statement of program.body) {
|
|
824
|
+
const declaration =
|
|
825
|
+
statement.type === 'ExportNamedDeclaration' && statement.declaration
|
|
826
|
+
? statement.declaration
|
|
827
|
+
: statement;
|
|
828
|
+
const comments = (statement.leadingComments || []).concat(
|
|
829
|
+
declaration === statement ? [] : declaration.leadingComments || [],
|
|
830
|
+
);
|
|
831
|
+
const declared = hasNoSideEffectsComment(comments);
|
|
832
|
+
const inlineForm = inlineMarkerForm(comments, filename);
|
|
833
|
+
if (
|
|
834
|
+
// An overloaded function carries its documentation — and so its
|
|
835
|
+
// annotation — on the first signature, which is a TSDeclareFunction;
|
|
836
|
+
// the implementation that follows is a bare FunctionDeclaration.
|
|
837
|
+
(declaration.type === 'FunctionDeclaration' ||
|
|
838
|
+
declaration.type === 'TSDeclareFunction') &&
|
|
839
|
+
declaration.id
|
|
840
|
+
) {
|
|
841
|
+
if (declared) {
|
|
842
|
+
names.set(declaration.id.name, inlineForm);
|
|
843
|
+
}
|
|
844
|
+
} else if (declaration.type === 'VariableDeclaration') {
|
|
845
|
+
for (const declarator of declaration.declarations) {
|
|
846
|
+
const declaratorComments = (declarator.leadingComments || []).concat(
|
|
847
|
+
(declarator.init && declarator.init.leadingComments) || [],
|
|
848
|
+
);
|
|
849
|
+
if (
|
|
850
|
+
declarator.id.type === 'Identifier' &&
|
|
851
|
+
(declared || hasNoSideEffectsComment(declaratorComments))
|
|
852
|
+
) {
|
|
853
|
+
names.set(
|
|
854
|
+
declarator.id.name,
|
|
855
|
+
inlineForm || inlineMarkerForm(declaratorComments, filename),
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return names;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Resolve a relative import specifier to a file on disk, trying the
|
|
866
|
+
* extensions a bundler would (including the `.js` specifier TypeScript uses
|
|
867
|
+
* for a `.ts` file).
|
|
868
|
+
*
|
|
869
|
+
* @param {string} filename the importing module
|
|
870
|
+
* @param {string} specifier a relative specifier
|
|
871
|
+
* @returns {null | string} the resolved path, or null when there is no such file
|
|
872
|
+
*/
|
|
873
|
+
function resolveRelativeSpecifier(filename, specifier) {
|
|
874
|
+
const base = path.resolve(path.dirname(filename), specifier);
|
|
875
|
+
const cached = resolvedFileCache.get(base);
|
|
876
|
+
if (cached !== undefined) {
|
|
877
|
+
return cached;
|
|
878
|
+
}
|
|
879
|
+
/** @type {Array<string>} */
|
|
880
|
+
const candidates = [];
|
|
881
|
+
const extension = path.extname(base);
|
|
882
|
+
if (extension !== '') {
|
|
883
|
+
candidates.push(base);
|
|
884
|
+
if (/^\.[cm]?js$/.test(extension)) {
|
|
885
|
+
const withoutExtension = base.slice(0, -extension.length);
|
|
886
|
+
candidates.push(
|
|
887
|
+
withoutExtension + extension.replace('js', 'ts'),
|
|
888
|
+
withoutExtension + '.tsx',
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
for (const candidate of RESOLVE_EXTENSIONS) {
|
|
893
|
+
candidates.push(base + candidate);
|
|
894
|
+
}
|
|
895
|
+
for (const candidate of RESOLVE_EXTENSIONS) {
|
|
896
|
+
candidates.push(path.join(base, `index${candidate}`));
|
|
897
|
+
}
|
|
898
|
+
let resolved = null;
|
|
899
|
+
for (const candidate of candidates) {
|
|
900
|
+
try {
|
|
901
|
+
if (fs.statSync(candidate).isFile()) {
|
|
902
|
+
resolved = candidate;
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
} catch (_err) {
|
|
906
|
+
// Not this candidate; try the next one.
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
resolvedFileCache.set(base, resolved);
|
|
910
|
+
return resolved;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The side-effect-free declarations of another file, parsed and cached.
|
|
915
|
+
*
|
|
916
|
+
* @param {string} filename
|
|
917
|
+
* @param {undefined | ReadonlyArray<any>} parserPlugins
|
|
918
|
+
* @returns {ReadonlyMap<string, null | string>}
|
|
919
|
+
*/
|
|
920
|
+
function pureDeclaredNamesOfFile(filename, parserPlugins) {
|
|
921
|
+
return declarationsOfFile(filename, parserPlugins).functions;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* What a module next to this one declares: the functions it says are
|
|
926
|
+
* side-effect free, and the objects it marks as pure namespaces. Parsed
|
|
927
|
+
* once per file for the life of the process — see the cache note above.
|
|
928
|
+
*
|
|
929
|
+
* @param {string} filename
|
|
930
|
+
* @param {undefined | ReadonlyArray<any>} parserPlugins
|
|
931
|
+
* @returns {{functions: ReadonlyMap<string, null | string>, namespaces: ReadonlySet<string>}}
|
|
932
|
+
*/
|
|
933
|
+
function declarationsOfFile(filename, parserPlugins) {
|
|
934
|
+
const cached = declarationCache.get(filename);
|
|
935
|
+
if (cached !== undefined) {
|
|
936
|
+
return cached;
|
|
937
|
+
}
|
|
938
|
+
/** @type {null | any} */
|
|
939
|
+
let program = null;
|
|
940
|
+
try {
|
|
941
|
+
program = parse(fs.readFileSync(filename, 'utf8'), {
|
|
942
|
+
plugins: parserPluginsFor(filename, parserPlugins),
|
|
943
|
+
sourceType: 'module',
|
|
944
|
+
}).program;
|
|
945
|
+
} catch (_err) {
|
|
946
|
+
// A file this cannot read or parse simply provides no evidence. A
|
|
947
|
+
// malformed marker inside a file it *can* read is a different matter:
|
|
948
|
+
// reading the declarations happens outside this catch so that its error
|
|
949
|
+
// reaches the build.
|
|
950
|
+
}
|
|
951
|
+
const declarations =
|
|
952
|
+
program === null
|
|
953
|
+
? {functions: new Map(), namespaces: new Set()}
|
|
954
|
+
: {
|
|
955
|
+
functions: pureDeclaredNames(program, filename),
|
|
956
|
+
namespaces: namespaceDeclaredNames(program),
|
|
957
|
+
};
|
|
958
|
+
declarationCache.set(filename, declarations);
|
|
959
|
+
return declarations;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* The local names in this module that refer to a side-effect-free factory,
|
|
964
|
+
* and may therefore have their module-scope calls annotated.
|
|
965
|
+
*
|
|
966
|
+
* A name qualifies when it is imported from a trusted Lexical package, when
|
|
967
|
+
* it is declared in this module with a NO_SIDE_EFFECTS annotation, or when
|
|
968
|
+
* it is imported from a relative module that declares it that way. A local
|
|
969
|
+
* helper that merely shares a name with one of the factories does not
|
|
970
|
+
* qualify, which is what keeps the transform from claiming that somebody
|
|
971
|
+
* else's `safeCast` or `createState` has no side effects.
|
|
972
|
+
*
|
|
973
|
+
* @param {any} program
|
|
974
|
+
* @param {ReadonlySet<string>} functions
|
|
975
|
+
* @param {TransformPureAnnotationsOptions} opts
|
|
976
|
+
* @returns {Map<string, {inline: undefined | any, name: string}>} local name
|
|
977
|
+
* to the factory it refers to, and the form it may be inlined with
|
|
978
|
+
*/
|
|
979
|
+
function collectFactoryNames(program, functions, opts) {
|
|
980
|
+
const sources = toRegExpArray(opts.sources, DEFAULT_SOURCES);
|
|
981
|
+
/** @type {Map<string, {inline: undefined | any, name: string}>} */
|
|
982
|
+
const names = new Map();
|
|
983
|
+
/**
|
|
984
|
+
* A call is only replaced by a literal when the shape of what the factory
|
|
985
|
+
* returns is known. An import from a Lexical package is taken on trust
|
|
986
|
+
* (this package ships with that Lexical); anything else has to say so
|
|
987
|
+
* itself with a `@lexical-inline` marker whose form matches the table.
|
|
988
|
+
*
|
|
989
|
+
* @param {string} local
|
|
990
|
+
* @param {string} name
|
|
991
|
+
* @param {null | string | undefined} markerForm
|
|
992
|
+
* @param {boolean} trusted
|
|
993
|
+
*/
|
|
994
|
+
function add(local, name, markerForm, trusted) {
|
|
995
|
+
const spec = INLINE_FACTORIES.get(name);
|
|
996
|
+
const inline =
|
|
997
|
+
spec !== undefined
|
|
998
|
+
? // One of Lexical's own: an import from a Lexical package is taken
|
|
999
|
+
// on trust, anything else has to agree with the table.
|
|
1000
|
+
trusted || markerForm === spec.form
|
|
1001
|
+
? spec
|
|
1002
|
+
: undefined
|
|
1003
|
+
: // A factory of your own: its marker is the only thing that says
|
|
1004
|
+
// what its calls may be replaced with, so it is the authority.
|
|
1005
|
+
markerForm
|
|
1006
|
+
? {form: markerForm}
|
|
1007
|
+
: undefined;
|
|
1008
|
+
names.set(local, {inline, name});
|
|
1009
|
+
}
|
|
1010
|
+
for (const [name, markerForm] of pureDeclaredNames(program, opts.filename)) {
|
|
1011
|
+
// A declaration in this module carries its own evidence, so it does not
|
|
1012
|
+
// have to be one of the known factory names: marking your own factory
|
|
1013
|
+
// `__NO_SIDE_EFFECTS__` is enough for the build to annotate its calls.
|
|
1014
|
+
// (A relatively imported one still has to be listed in `functions`, so
|
|
1015
|
+
// that a build does not parse every module it imports looking for one.)
|
|
1016
|
+
add(name, name, markerForm, false);
|
|
1017
|
+
}
|
|
1018
|
+
for (const statement of program.body) {
|
|
1019
|
+
if (statement.type !== 'ImportDeclaration') {
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
const source = statement.source.value;
|
|
1023
|
+
const trusted = matchesAny(sources, source);
|
|
1024
|
+
const relative =
|
|
1025
|
+
!trusted &&
|
|
1026
|
+
opts.relativeImports !== false &&
|
|
1027
|
+
typeof opts.filename === 'string' &&
|
|
1028
|
+
source.startsWith('.');
|
|
1029
|
+
if (!trusted && !relative) {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
for (const specifier of statement.specifiers) {
|
|
1033
|
+
if (
|
|
1034
|
+
specifier.type !== 'ImportSpecifier' ||
|
|
1035
|
+
specifier.imported.type !== 'Identifier'
|
|
1036
|
+
) {
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (trusted) {
|
|
1040
|
+
if (functions.has(specifier.imported.name)) {
|
|
1041
|
+
add(specifier.local.name, specifier.imported.name, null, true);
|
|
1042
|
+
}
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
const resolved = resolveRelativeSpecifier(
|
|
1046
|
+
/** @type {string} */ (opts.filename),
|
|
1047
|
+
source,
|
|
1048
|
+
);
|
|
1049
|
+
const declared =
|
|
1050
|
+
resolved === null
|
|
1051
|
+
? undefined
|
|
1052
|
+
: pureDeclaredNamesOfFile(resolved, opts.parserPlugins).get(
|
|
1053
|
+
specifier.imported.name,
|
|
1054
|
+
);
|
|
1055
|
+
if (declared !== undefined) {
|
|
1056
|
+
add(specifier.local.name, specifier.imported.name, declared, false);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return names;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* @param {string} code
|
|
1065
|
+
* @param {ReadonlySet<string>} functions
|
|
1066
|
+
* @param {ReadonlySet<string>} namespaces
|
|
1067
|
+
* @returns {boolean} true when the source mentions any of the names at all
|
|
1068
|
+
*/
|
|
1069
|
+
function mayNeedAnnotations(code, functions, namespaces) {
|
|
1070
|
+
if (
|
|
1071
|
+
code.includes('__NO_SIDE_EFFECTS__') ||
|
|
1072
|
+
code.includes('@lexical-pure-namespace') ||
|
|
1073
|
+
RELATIVE_IMPORT_RE.test(code)
|
|
1074
|
+
) {
|
|
1075
|
+
// Modules that may annotate calls for reasons other than the names in
|
|
1076
|
+
// the list: a declaration of their own, or a relatively imported
|
|
1077
|
+
// namespace, which is only known by reading that module.
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
for (const name of functions) {
|
|
1081
|
+
if (code.includes(name)) {
|
|
1082
|
+
return true;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
for (const name of namespaces) {
|
|
1086
|
+
if (code.includes(name)) {
|
|
1087
|
+
return true;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return false;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* @typedef {Object} TransformPureAnnotationsOptions
|
|
1095
|
+
* @property {string} [filename] Path of the module being transformed, used
|
|
1096
|
+
* to choose the TypeScript and JSX parser plugins.
|
|
1097
|
+
* @property {Iterable<string>} [functions] Names of the factories whose
|
|
1098
|
+
* module-scope calls are annotated. Defaults to PURE_FACTORY_FUNCTIONS.
|
|
1099
|
+
* @property {boolean} [inline] Replace calls to the factories whose result
|
|
1100
|
+
* is a trivial expression over their arguments with that expression,
|
|
1101
|
+
* rather than annotating them. Off by default: it assumes the Lexical
|
|
1102
|
+
* being built matches this package's version.
|
|
1103
|
+
* @property {ReadonlyArray<any>} [parserPlugins] Extra Babel parser
|
|
1104
|
+
* plugins, for syntax the defaults do not cover.
|
|
1105
|
+
* @property {boolean} [relativeImports] Set to false to skip reading
|
|
1106
|
+
* relatively imported modules to look for a NO_SIDE_EFFECTS declaration.
|
|
1107
|
+
* @property {Iterable<string>} [namespaces] Imported objects whose
|
|
1108
|
+
* method calls are side-effect free, defaults to PURE_NAMESPACES.
|
|
1109
|
+
* @property {boolean} [sourceMap] Set to false to skip source map generation.
|
|
1110
|
+
* @property {RegExp | ReadonlyArray<RegExp>} [sources] Module specifiers
|
|
1111
|
+
* whose exports are trusted to be the factories, defaults to `lexical` and
|
|
1112
|
+
* `@lexical/*`.
|
|
1113
|
+
* @property {boolean} [strict] Throw when a call evaluated inside one of the
|
|
1114
|
+
* definitions is not known to be side-effect free, since it pins the
|
|
1115
|
+
* definition into every bundle that imports the module.
|
|
1116
|
+
*/
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* @typedef {Object} PureAnnotationsResult
|
|
1120
|
+
* @property {number} count Number of annotations that were inserted.
|
|
1121
|
+
* @property {string} code
|
|
1122
|
+
* @property {number} inlined Number of calls that were replaced by the
|
|
1123
|
+
* literal they would have returned.
|
|
1124
|
+
* @property {any} [map]
|
|
1125
|
+
*/
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* The names this module declares with a `@lexical-pure-namespace` marker:
|
|
1129
|
+
* objects whose methods are side-effect free.
|
|
1130
|
+
*
|
|
1131
|
+
* @param {any} program
|
|
1132
|
+
* @returns {Set<string>}
|
|
1133
|
+
*/
|
|
1134
|
+
function namespaceDeclaredNames(program) {
|
|
1135
|
+
/** @type {Set<string>} */
|
|
1136
|
+
const names = new Set();
|
|
1137
|
+
/** @param {undefined | ReadonlyArray<any>} comments */
|
|
1138
|
+
const marked = comments =>
|
|
1139
|
+
(comments || []).some(comment => NAMESPACE_MARKER_RE.test(comment.value));
|
|
1140
|
+
for (const statement of program.body) {
|
|
1141
|
+
const declaration =
|
|
1142
|
+
statement.type === 'ExportNamedDeclaration' && statement.declaration
|
|
1143
|
+
? statement.declaration
|
|
1144
|
+
: statement;
|
|
1145
|
+
const outer = marked(statement.leadingComments);
|
|
1146
|
+
if (declaration.type !== 'VariableDeclaration') {
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
for (const declarator of declaration.declarations) {
|
|
1150
|
+
if (
|
|
1151
|
+
declarator.id.type === 'Identifier' &&
|
|
1152
|
+
(outer ||
|
|
1153
|
+
marked(declaration.leadingComments) ||
|
|
1154
|
+
marked(declarator.leadingComments) ||
|
|
1155
|
+
(declarator.init && marked(declarator.init.leadingComments)))
|
|
1156
|
+
) {
|
|
1157
|
+
names.add(declarator.id.name);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return names;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* The `@lexical-pure-namespace` names a relatively imported module declares.
|
|
1166
|
+
*
|
|
1167
|
+
* @param {string} filename
|
|
1168
|
+
* @param {undefined | ReadonlyArray<any>} parserPlugins
|
|
1169
|
+
* @returns {ReadonlySet<string>}
|
|
1170
|
+
*/
|
|
1171
|
+
function namespaceNamesOfFile(filename, parserPlugins) {
|
|
1172
|
+
return declarationsOfFile(filename, parserPlugins).namespaces;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* The local names in this module bound to one of the side-effect-free
|
|
1177
|
+
* namespaces, imported from a trusted Lexical package. A relative import is
|
|
1178
|
+
* not enough here: unlike a factory function, whose declaration can say
|
|
1179
|
+
* `__NO_SIDE_EFFECTS__` for itself, an object's methods are only known to
|
|
1180
|
+
* be pure by the table above.
|
|
1181
|
+
*
|
|
1182
|
+
* @param {any} program
|
|
1183
|
+
* @param {ReadonlySet<string>} namespaces
|
|
1184
|
+
* @param {TransformPureAnnotationsOptions} opts
|
|
1185
|
+
* @returns {Set<string>} local names
|
|
1186
|
+
*/
|
|
1187
|
+
function collectNamespaceNames(program, namespaces, opts) {
|
|
1188
|
+
/** @type {Set<string>} */
|
|
1189
|
+
const names = new Set();
|
|
1190
|
+
const sources = toRegExpArray(opts.sources, DEFAULT_SOURCES);
|
|
1191
|
+
for (const name of namespaceDeclaredNames(program)) {
|
|
1192
|
+
names.add(name);
|
|
1193
|
+
}
|
|
1194
|
+
for (const statement of program.body) {
|
|
1195
|
+
if (statement.type !== 'ImportDeclaration') {
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
const source = statement.source.value;
|
|
1199
|
+
const trusted = matchesAny(sources, source);
|
|
1200
|
+
const relative =
|
|
1201
|
+
!trusted &&
|
|
1202
|
+
opts.relativeImports !== false &&
|
|
1203
|
+
typeof opts.filename === 'string' &&
|
|
1204
|
+
source.startsWith('.');
|
|
1205
|
+
if (!trusted && !relative) {
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
/** @type {undefined | ReadonlySet<string>} */
|
|
1209
|
+
let declared;
|
|
1210
|
+
for (const specifier of statement.specifiers) {
|
|
1211
|
+
if (
|
|
1212
|
+
specifier.type !== 'ImportSpecifier' ||
|
|
1213
|
+
specifier.imported.type !== 'Identifier'
|
|
1214
|
+
) {
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
if (trusted) {
|
|
1218
|
+
if (namespaces.has(specifier.imported.name)) {
|
|
1219
|
+
names.add(specifier.local.name);
|
|
1220
|
+
}
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
if (declared === undefined) {
|
|
1224
|
+
const resolved = resolveRelativeSpecifier(
|
|
1225
|
+
/** @type {string} */ (opts.filename),
|
|
1226
|
+
source,
|
|
1227
|
+
);
|
|
1228
|
+
declared =
|
|
1229
|
+
resolved === null
|
|
1230
|
+
? new Set()
|
|
1231
|
+
: namespaceNamesOfFile(resolved, opts.parserPlugins);
|
|
1232
|
+
}
|
|
1233
|
+
if (declared.has(specifier.imported.name)) {
|
|
1234
|
+
names.add(specifier.local.name);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
// `const sel = selBase;` — an alias is the same object. One pass is
|
|
1239
|
+
// enough for the aliases these modules actually write.
|
|
1240
|
+
for (const statement of program.body) {
|
|
1241
|
+
if (statement.type !== 'VariableDeclaration') {
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
for (const declarator of statement.declarations) {
|
|
1245
|
+
if (
|
|
1246
|
+
declarator.id.type === 'Identifier' &&
|
|
1247
|
+
declarator.init &&
|
|
1248
|
+
declarator.init.type === 'Identifier' &&
|
|
1249
|
+
names.has(declarator.init.name)
|
|
1250
|
+
) {
|
|
1251
|
+
names.add(declarator.id.name);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return names;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Every call evaluated inside `node`'s arguments, in the order they appear.
|
|
1260
|
+
* Calls in a deferred position (a callback, a method body) are not: they do
|
|
1261
|
+
* not run when the module is initialized, so they pin nothing.
|
|
1262
|
+
*
|
|
1263
|
+
* @param {any} node a call expression
|
|
1264
|
+
* @param {(call: any) => void} visit
|
|
1265
|
+
*/
|
|
1266
|
+
function visitEvaluatedCalls(node, visit) {
|
|
1267
|
+
/** @param {any} value */
|
|
1268
|
+
const walk = value => {
|
|
1269
|
+
if (Array.isArray(value)) {
|
|
1270
|
+
value.forEach(walk);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (
|
|
1274
|
+
!value ||
|
|
1275
|
+
typeof value !== 'object' ||
|
|
1276
|
+
typeof value.type !== 'string' ||
|
|
1277
|
+
DEFERRED_TYPES.has(value.type)
|
|
1278
|
+
) {
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
if (
|
|
1282
|
+
value.type === 'CallExpression' ||
|
|
1283
|
+
value.type === 'NewExpression' ||
|
|
1284
|
+
value.type === 'TaggedTemplateExpression'
|
|
1285
|
+
) {
|
|
1286
|
+
visit(value);
|
|
1287
|
+
}
|
|
1288
|
+
for (const key of Object.keys(value)) {
|
|
1289
|
+
if (!SKIPPED_KEYS.has(key)) {
|
|
1290
|
+
walk(value[key]);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
node.arguments.forEach(walk);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* A call in the arguments of an extension, command, or rule definition is
|
|
1299
|
+
* evaluated when the module is initialized, and an unannotated one pins the
|
|
1300
|
+
* whole definition into every bundle that imports the module — the
|
|
1301
|
+
* definition is only droppable when everything it is built from is too.
|
|
1302
|
+
*
|
|
1303
|
+
* With `strict`, that is an error naming the call rather than a definition
|
|
1304
|
+
* that quietly stops tree-shaking.
|
|
1305
|
+
*
|
|
1306
|
+
* @param {string} code
|
|
1307
|
+
* @param {undefined | string} filename
|
|
1308
|
+
* @param {ReadonlyArray<any>} factoryCalls
|
|
1309
|
+
* @param {ReadonlySet<number>} covered offsets this transform annotates or
|
|
1310
|
+
* replaces
|
|
1311
|
+
*/
|
|
1312
|
+
function assertArgumentsArePure(code, filename, factoryCalls, covered) {
|
|
1313
|
+
/** @type {Array<string>} */
|
|
1314
|
+
const violations = [];
|
|
1315
|
+
/** @type {Set<number>} */
|
|
1316
|
+
const reported = new Set();
|
|
1317
|
+
for (const call of factoryCalls) {
|
|
1318
|
+
visitEvaluatedCalls(call, inner => {
|
|
1319
|
+
const callee = inner.callee || inner.tag;
|
|
1320
|
+
if (
|
|
1321
|
+
covered.has(inner.start) ||
|
|
1322
|
+
hasPureAnnotation(code, inner.start) ||
|
|
1323
|
+
reported.has(inner.start) ||
|
|
1324
|
+
(callee.type === 'Identifier' && PURE_GLOBALS.has(callee.name))
|
|
1325
|
+
) {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
reported.add(inner.start);
|
|
1329
|
+
const text = code.slice(callee.start, callee.end).replace(/\s+/g, ' ');
|
|
1330
|
+
const where = inner.loc
|
|
1331
|
+
? `${inner.loc.start.line}:${inner.loc.start.column + 1}`
|
|
1332
|
+
: '?';
|
|
1333
|
+
violations.push(
|
|
1334
|
+
` ${where} ${text}(...) inside ${code.slice(
|
|
1335
|
+
call.callee.start,
|
|
1336
|
+
call.callee.end,
|
|
1337
|
+
)}(...)`,
|
|
1338
|
+
);
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
if (violations.length > 0) {
|
|
1342
|
+
throw new PureAnnotationsError(
|
|
1343
|
+
`@lexical/compiler: ${violations.length} call(s) evaluated ` +
|
|
1344
|
+
`inside a definition in ${filename || '<unknown>'} are not known to ` +
|
|
1345
|
+
`be side-effect free, so the definition cannot be tree-shaken:\n` +
|
|
1346
|
+
`${violations.join('\n')}\n` +
|
|
1347
|
+
`Make the call lazy, or declare the function side-effect free ` +
|
|
1348
|
+
`(@__NO_SIDE_EFFECTS__ plus the transform's \`functions\`/` +
|
|
1349
|
+
`\`namespaces\` options) so that its calls are annotated too.`,
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* The local names in `code` whose module-scope calls this transform would
|
|
1356
|
+
* annotate — the same decision {@link transformPureAnnotations} makes, for
|
|
1357
|
+
* tooling that needs to ask about a module without rewriting it (Lexical's
|
|
1358
|
+
* `no-pure-annotation` ESLint rule removes a hand-written annotation only
|
|
1359
|
+
* for a name this returns, so that the build is guaranteed to put it back).
|
|
1360
|
+
*
|
|
1361
|
+
* `functions` are called directly, `namespaces` are the objects whose method
|
|
1362
|
+
* calls (`sel.tag('p').attr('x')`) are annotated.
|
|
1363
|
+
*
|
|
1364
|
+
* @param {string} code
|
|
1365
|
+
* @param {TransformPureAnnotationsOptions} [options]
|
|
1366
|
+
* @returns {{functions: Set<string>, namespaces: Set<string>}}
|
|
1367
|
+
*/
|
|
1368
|
+
export function pureCallNames(code, options) {
|
|
1369
|
+
const opts = options || {};
|
|
1370
|
+
const functions = new Set(opts.functions || PURE_FACTORY_FUNCTIONS);
|
|
1371
|
+
const ast = parse(code, {
|
|
1372
|
+
errorRecovery: false,
|
|
1373
|
+
plugins: parserPluginsFor(opts.filename, opts.parserPlugins),
|
|
1374
|
+
sourceFilename: opts.filename,
|
|
1375
|
+
sourceType: 'module',
|
|
1376
|
+
});
|
|
1377
|
+
return {
|
|
1378
|
+
functions: new Set(
|
|
1379
|
+
collectFactoryNames(ast.program, functions, opts).keys(),
|
|
1380
|
+
),
|
|
1381
|
+
namespaces: collectNamespaceNames(
|
|
1382
|
+
ast.program,
|
|
1383
|
+
new Set(opts.namespaces || PURE_NAMESPACES),
|
|
1384
|
+
opts,
|
|
1385
|
+
),
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Insert a `__PURE__` annotation before every module-scope call to one of the
|
|
1391
|
+
* side-effect-free Lexical factories that does not already have one.
|
|
1392
|
+
*
|
|
1393
|
+
* Returns `null` when the module needs no annotations, so a caller can pass
|
|
1394
|
+
* the original source through untouched.
|
|
1395
|
+
*
|
|
1396
|
+
* @param {string} code
|
|
1397
|
+
* @param {TransformPureAnnotationsOptions} [options]
|
|
1398
|
+
* @returns {null | PureAnnotationsResult}
|
|
1399
|
+
*/
|
|
1400
|
+
export function transformPureAnnotations(code, options) {
|
|
1401
|
+
const opts = options || {};
|
|
1402
|
+
const functions = new Set(opts.functions || PURE_FACTORY_FUNCTIONS);
|
|
1403
|
+
const namespaceSet = new Set(opts.namespaces || PURE_NAMESPACES);
|
|
1404
|
+
if (!mayNeedAnnotations(code, functions, namespaceSet)) {
|
|
1405
|
+
return null;
|
|
1406
|
+
}
|
|
1407
|
+
const ast = parse(code, {
|
|
1408
|
+
errorRecovery: false,
|
|
1409
|
+
plugins: parserPluginsFor(opts.filename, opts.parserPlugins),
|
|
1410
|
+
sourceFilename: opts.filename,
|
|
1411
|
+
sourceType: 'module',
|
|
1412
|
+
});
|
|
1413
|
+
// Only the calls whose callee resolves to a factory this module imported
|
|
1414
|
+
// from Lexical (or to a declaration that says it is side-effect free) are
|
|
1415
|
+
// annotated; a same-named local helper is left alone.
|
|
1416
|
+
const factoryNames = collectFactoryNames(ast.program, functions, opts);
|
|
1417
|
+
const namespaceNames = collectNamespaceNames(ast.program, namespaceSet, opts);
|
|
1418
|
+
if (factoryNames.size === 0 && namespaceNames.size === 0) {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
/** @type {Set<number>} */
|
|
1422
|
+
const annotated = new Set();
|
|
1423
|
+
/** @type {Array<[any, {form: string}, boolean]>} */
|
|
1424
|
+
const inlined = [];
|
|
1425
|
+
/** @type {Array<any>} */
|
|
1426
|
+
const factoryCalls = [];
|
|
1427
|
+
visitModuleScopeCalls(
|
|
1428
|
+
ast.program,
|
|
1429
|
+
{functions: factoryNames, namespaces: namespaceNames},
|
|
1430
|
+
(node, position) => {
|
|
1431
|
+
if (position.namespace === true) {
|
|
1432
|
+
// Every call in a chain starts at the same offset, and one
|
|
1433
|
+
// annotation there covers the whole chain.
|
|
1434
|
+
if (!hasPureAnnotation(code, node.start)) {
|
|
1435
|
+
annotated.add(node.start);
|
|
1436
|
+
}
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
factoryCalls.push(node);
|
|
1440
|
+
const factory = /** @type {{inline: undefined | any, name: string}} */ (
|
|
1441
|
+
factoryNames.get(node.callee.name)
|
|
1442
|
+
);
|
|
1443
|
+
const spec = opts.inline === true ? factory.inline : undefined;
|
|
1444
|
+
if (
|
|
1445
|
+
spec !== undefined &&
|
|
1446
|
+
!position.discarded &&
|
|
1447
|
+
canInlineCall(node, spec)
|
|
1448
|
+
) {
|
|
1449
|
+
inlined.push([node, spec, position.statementStart]);
|
|
1450
|
+
} else if (!hasPureAnnotation(code, node.start)) {
|
|
1451
|
+
annotated.add(node.start);
|
|
1452
|
+
}
|
|
1453
|
+
},
|
|
1454
|
+
);
|
|
1455
|
+
const offsets = Array.from(annotated);
|
|
1456
|
+
if (opts.strict === true) {
|
|
1457
|
+
const covered = new Set(annotated);
|
|
1458
|
+
for (const [node] of inlined) {
|
|
1459
|
+
covered.add(node.start);
|
|
1460
|
+
}
|
|
1461
|
+
assertArgumentsArePure(code, opts.filename, factoryCalls, covered);
|
|
1462
|
+
}
|
|
1463
|
+
if (offsets.length === 0 && inlined.length === 0) {
|
|
1464
|
+
return null;
|
|
1465
|
+
}
|
|
1466
|
+
const magicString = new MagicString(code);
|
|
1467
|
+
for (const offset of offsets) {
|
|
1468
|
+
magicString.appendLeft(offset, ANNOTATION);
|
|
1469
|
+
}
|
|
1470
|
+
for (const [node, spec, statementStart] of inlined) {
|
|
1471
|
+
inlineCall(magicString, code, node, spec, statementStart);
|
|
1472
|
+
}
|
|
1473
|
+
removeUnusedImports(
|
|
1474
|
+
magicString,
|
|
1475
|
+
code,
|
|
1476
|
+
ast.program,
|
|
1477
|
+
inlined.map(([node]) => node),
|
|
1478
|
+
);
|
|
1479
|
+
return {
|
|
1480
|
+
code: magicString.toString(),
|
|
1481
|
+
count: offsets.length,
|
|
1482
|
+
inlined: inlined.length,
|
|
1483
|
+
map:
|
|
1484
|
+
opts.sourceMap === false
|
|
1485
|
+
? undefined
|
|
1486
|
+
: magicString.generateMap({hires: 'boundary', source: opts.filename}),
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* @param {undefined | RegExp | ReadonlyArray<RegExp>} value
|
|
1492
|
+
* @param {ReadonlyArray<RegExp>} defaultValue
|
|
1493
|
+
* @returns {ReadonlyArray<RegExp>}
|
|
1494
|
+
*/
|
|
1495
|
+
function toRegExpArray(value, defaultValue) {
|
|
1496
|
+
if (value === undefined) {
|
|
1497
|
+
return defaultValue;
|
|
1498
|
+
}
|
|
1499
|
+
return Array.isArray(value) ? value : [/** @type {RegExp} */ (value)];
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* @param {ReadonlyArray<RegExp>} patterns
|
|
1504
|
+
* @param {string} id
|
|
1505
|
+
* @returns {boolean}
|
|
1506
|
+
*/
|
|
1507
|
+
function matchesAny(patterns, id) {
|
|
1508
|
+
return patterns.some(pattern => pattern.test(id));
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Strip the query string and hash a bundler may append to a module id
|
|
1513
|
+
* (e.g. `Foo.tsx?vue&type=script`).
|
|
1514
|
+
*
|
|
1515
|
+
* @param {string} id
|
|
1516
|
+
* @returns {string}
|
|
1517
|
+
*/
|
|
1518
|
+
function cleanModuleId(id) {
|
|
1519
|
+
return id.replace(/[?#][\s\S]*$/, '');
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* @typedef {Object} PureAnnotationsOptions
|
|
1524
|
+
* @property {'post' | 'pre'} [enforce] Vite plugin ordering, `'post'` by
|
|
1525
|
+
* default so the transform runs after TypeScript/JSX has been compiled away.
|
|
1526
|
+
* @property {RegExp | ReadonlyArray<RegExp>} [exclude] Module ids to skip.
|
|
1527
|
+
* @property {Iterable<string>} [functions] Names of the factories whose
|
|
1528
|
+
* module-scope calls are annotated. Defaults to PURE_FACTORY_FUNCTIONS.
|
|
1529
|
+
* @property {RegExp | ReadonlyArray<RegExp>} [include] Module ids to
|
|
1530
|
+
* transform, defaults to every .js/.jsx/.ts/.tsx (and .mjs/.cjs/.mts/.cts)
|
|
1531
|
+
* module.
|
|
1532
|
+
* @property {boolean} [inline] Replace calls to the factories whose result
|
|
1533
|
+
* is a trivial expression over their arguments with that expression,
|
|
1534
|
+
* rather than annotating them. Off by default: it assumes the Lexical
|
|
1535
|
+
* being built matches this package's version.
|
|
1536
|
+
* @property {ReadonlyArray<any>} [parserPlugins] Extra Babel parser
|
|
1537
|
+
* plugins, for syntax the defaults do not cover.
|
|
1538
|
+
* @property {boolean} [relativeImports] Set to false to skip reading
|
|
1539
|
+
* relatively imported modules to look for a NO_SIDE_EFFECTS declaration.
|
|
1540
|
+
* @property {Iterable<string>} [namespaces] Imported objects whose
|
|
1541
|
+
* method calls are side-effect free, defaults to PURE_NAMESPACES.
|
|
1542
|
+
* @property {boolean} [sourceMap] Set to false to skip source map generation.
|
|
1543
|
+
* @property {RegExp | ReadonlyArray<RegExp>} [sources] Module specifiers
|
|
1544
|
+
* whose exports are trusted to be the factories, defaults to `lexical` and
|
|
1545
|
+
* `@lexical/*`.
|
|
1546
|
+
* @property {boolean} [strict] Throw when a call evaluated inside one of the
|
|
1547
|
+
* definitions is not known to be side-effect free, since it pins the
|
|
1548
|
+
* definition into every bundle that imports the module.
|
|
1549
|
+
*/
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* A Vite/Rollup plugin that inserts the `__PURE__` annotations that let
|
|
1553
|
+
* bundlers tree-shake unused Lexical extension, command, and rule
|
|
1554
|
+
* definitions. Use it when building Lexical (or your own extensions) from
|
|
1555
|
+
* TypeScript source rather than from the published `dist` bundles, which
|
|
1556
|
+
* already carry the annotations.
|
|
1557
|
+
*
|
|
1558
|
+
* @param {PureAnnotationsOptions} [options]
|
|
1559
|
+
* @returns {any} the plugin object
|
|
1560
|
+
*/
|
|
1561
|
+
export function pureAnnotations(options) {
|
|
1562
|
+
const opts = options || {};
|
|
1563
|
+
const functions = new Set(opts.functions || PURE_FACTORY_FUNCTIONS);
|
|
1564
|
+
const include = toRegExpArray(opts.include, [DEFAULT_INCLUDE]);
|
|
1565
|
+
const exclude = toRegExpArray(opts.exclude, []);
|
|
1566
|
+
return {
|
|
1567
|
+
enforce: opts.enforce === undefined ? 'post' : opts.enforce,
|
|
1568
|
+
name: '@lexical/compiler',
|
|
1569
|
+
/**
|
|
1570
|
+
* @param {string} code
|
|
1571
|
+
* @param {string} id
|
|
1572
|
+
* @returns {null | {code: string, map: any}}
|
|
1573
|
+
*/
|
|
1574
|
+
transform(code, id) {
|
|
1575
|
+
const filename = cleanModuleId(id);
|
|
1576
|
+
if (!matchesAny(include, filename) || matchesAny(exclude, filename)) {
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
const dependency = NODE_MODULES_RE.test(filename);
|
|
1580
|
+
let result = null;
|
|
1581
|
+
try {
|
|
1582
|
+
result = transformPureAnnotations(code, {
|
|
1583
|
+
filename,
|
|
1584
|
+
functions,
|
|
1585
|
+
inline: opts.inline,
|
|
1586
|
+
namespaces: opts.namespaces,
|
|
1587
|
+
parserPlugins: opts.parserPlugins,
|
|
1588
|
+
relativeImports: opts.relativeImports,
|
|
1589
|
+
sourceMap: opts.sourceMap,
|
|
1590
|
+
sources: opts.sources,
|
|
1591
|
+
strict: opts.strict === true && !dependency,
|
|
1592
|
+
});
|
|
1593
|
+
} catch (err) {
|
|
1594
|
+
if (err instanceof PureAnnotationsError && !dependency) {
|
|
1595
|
+
// Not something to shrug off: the module says one thing and means
|
|
1596
|
+
// another, or a definition cannot be tree-shaken.
|
|
1597
|
+
throw err;
|
|
1598
|
+
}
|
|
1599
|
+
// A module this plugin cannot parse is a module it has nothing to
|
|
1600
|
+
// say about; warn rather than failing the consumer's build.
|
|
1601
|
+
if (this && typeof this.warn === 'function') {
|
|
1602
|
+
this.warn(
|
|
1603
|
+
`@lexical/compiler could not parse ${id}, no annotations were added: ${
|
|
1604
|
+
err instanceof Error ? err.message : String(err)
|
|
1605
|
+
}`,
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
return result === null
|
|
1611
|
+
? null
|
|
1612
|
+
: {
|
|
1613
|
+
code: result.code,
|
|
1614
|
+
map: result.map === undefined ? null : result.map,
|
|
1615
|
+
};
|
|
1616
|
+
},
|
|
1617
|
+
};
|
|
1618
|
+
}
|