@doweljs/vite 0.0.0-stage → 0.2.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.
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Static analysis of a module's dowel declarations.
3
+ *
4
+ * Only top-level `const X = service(...)` / `slot(...)()` declarations are
5
+ * recognized. That is deliberate: a dependency graph declared at the top
6
+ * level of a module is what makes hot replacement and devtools possible without running
7
+ * the code.
8
+ */
9
+
10
+ import type { ESTree } from 'vite'
11
+ import { parseSync } from 'vite'
12
+
13
+ /** A reference a static graph can follow: a local identifier, or a member of a namespace import (`ns.Name`). */
14
+ export interface Ref {
15
+ readonly local: string
16
+ readonly member: string | null
17
+ }
18
+
19
+ interface DepRef {
20
+ readonly key: string
21
+ /** What the dependency refers to; `null` when the edge is kept but cannot be resolved statically. */
22
+ readonly ref: Ref | null
23
+ readonly optional: boolean
24
+ }
25
+
26
+ export interface Declaration {
27
+ readonly local: string
28
+ readonly kind: 'slot' | 'service' | 'scope' | 'execution'
29
+ readonly name: string
30
+ /** The scope name when it is known statically: `root` for a slot or scope declared without one. */
31
+ readonly scope: string | null
32
+ /** A slot's or service's `scope` option, or a scope's parent. */
33
+ readonly scopeRef: Ref | null
34
+ /** Whether a slot is a multi-binding; null when its options cannot be read. */
35
+ readonly many: boolean | null
36
+ readonly deps: readonly DepRef[]
37
+ /** Offsets of the declaring call in the code this plugin received, after earlier transforms. */
38
+ readonly start: number
39
+ readonly end: number
40
+ }
41
+
42
+ interface ImportRef {
43
+ readonly source: string
44
+ /** Exported name in the source module (`default` for default imports). */
45
+ readonly imported: string
46
+ }
47
+
48
+ export interface ModuleAnalysis {
49
+ readonly declarations: readonly Declaration[]
50
+ /** Local name to the import it comes from, for resolving dependency references. */
51
+ readonly imports: ReadonlyMap<string, ImportRef>
52
+ /** Exported name to local name. */
53
+ readonly exports: ReadonlyMap<string, string>
54
+ /** Exported name to what it re-exports: `export { Db } from '../db'`, `export * as ns from`. */
55
+ readonly reexports: ReadonlyMap<string, ImportRef>
56
+ /** Sources of `export * from` statements, searched in order for names not exported otherwise. */
57
+ readonly stars: readonly string[]
58
+ /**
59
+ * True when every export is a dowel declaration, so the module can accept
60
+ * its own hot updates without leaving importers with stale values.
61
+ */
62
+ readonly selfContained: boolean
63
+ /** True when the module calls `container()`, which makes it an app root. */
64
+ readonly buildsContainer: boolean
65
+ }
66
+
67
+ type Node = ESTree.Node
68
+
69
+ /** The names a module may import from dowel, mapped from their local aliases. */
70
+ type DowelImports = ReadonlyMap<string, DowelExport>
71
+
72
+ const DOWEL_EXPORTS = ['service', 'slot', 'optional', 'container', 'scope', 'execution'] as const
73
+ type DowelExport = (typeof DOWEL_EXPORTS)[number]
74
+
75
+ const isDowelExport = (name: string): name is DowelExport =>
76
+ (DOWEL_EXPORTS as readonly string[]).includes(name)
77
+
78
+ /** Where a declaration lives: a statically known scope name, or a reference to a scope. */
79
+ type Placement = Pick<Declaration, 'scope' | 'scopeRef'>
80
+
81
+ const REEXPORT = /\bexport\s*(?:\*|\{[^}]*\})\s*(?:as\s+\w+\s*)?from\b/
82
+
83
+ /** Cheap pre-check so most modules never reach the parser: they neither use dowel nor re-export. */
84
+ export function mayDeclare(code: string): boolean {
85
+ return code.includes('@doweljs/core') || REEXPORT.test(code)
86
+ }
87
+
88
+ export function analyze(id: string, code: string): ModuleAnalysis | null {
89
+ const { program, errors } = parseSync(id, code)
90
+ if (errors.length > 0) return null
91
+
92
+ const dowel = collectDowelImports(program)
93
+ const declarations: Declaration[] = []
94
+ const imports = new Map<string, ImportRef>()
95
+ const exports = new Map<string, string>()
96
+ const reexports = new Map<string, ImportRef>()
97
+ const stars: string[] = []
98
+ // Whether the module exports anything besides its dowel declarations.
99
+ let mixed = false
100
+
101
+ for (const statement of program.body) {
102
+ switch (statement.type) {
103
+ case 'ImportDeclaration':
104
+ for (const specifier of statement.specifiers) {
105
+ const imported =
106
+ specifier.type === 'ImportSpecifier'
107
+ ? moduleExportName(specifier.imported)
108
+ : specifier.type === 'ImportDefaultSpecifier'
109
+ ? 'default'
110
+ : '*'
111
+ imports.set(specifier.local.name, { source: statement.source.value, imported })
112
+ }
113
+ break
114
+ case 'VariableDeclaration':
115
+ declarations.push(...declarationsOf(statement, dowel))
116
+ break
117
+ case 'ExportNamedDeclaration': {
118
+ const { declaration } = statement
119
+ if (declaration?.type === 'VariableDeclaration') {
120
+ const found = declarationsOf(declaration, dowel)
121
+ declarations.push(...found)
122
+ for (const declarator of declaration.declarations) {
123
+ for (const name of boundNames(declarator.id)) {
124
+ exports.set(name, name)
125
+ if (!found.some((d) => d.local === name)) mixed = true
126
+ }
127
+ }
128
+ } else if (declaration !== null) {
129
+ mixed = true
130
+ } else if (statement.source !== null) {
131
+ mixed = true
132
+ for (const specifier of statement.specifiers) {
133
+ reexports.set(moduleExportName(specifier.exported), {
134
+ source: statement.source.value,
135
+ imported: moduleExportName(specifier.local),
136
+ })
137
+ }
138
+ } else {
139
+ for (const specifier of statement.specifiers) {
140
+ exports.set(moduleExportName(specifier.exported), moduleExportName(specifier.local))
141
+ }
142
+ }
143
+ break
144
+ }
145
+ case 'ExportDefaultDeclaration':
146
+ if (statement.declaration.type === 'Identifier') {
147
+ exports.set('default', statement.declaration.name)
148
+ } else {
149
+ mixed = true
150
+ }
151
+ break
152
+ case 'ExportAllDeclaration':
153
+ mixed = true
154
+ if (statement.exported === null) stars.push(statement.source.value)
155
+ else {
156
+ reexports.set(moduleExportName(statement.exported), {
157
+ source: statement.source.value,
158
+ imported: '*',
159
+ })
160
+ }
161
+ break
162
+ default:
163
+ break
164
+ }
165
+ }
166
+
167
+ if (dowel.size === 0 && reexports.size === 0 && stars.length === 0) return null
168
+
169
+ const locals = new Set(declarations.map((d) => d.local))
170
+ for (const local of exports.values()) if (!locals.has(local)) mixed = true
171
+
172
+ const containerLocal = [...dowel].find(([, name]) => name === 'container')?.[0]
173
+ const buildsContainer = containerLocal !== undefined && callsIdentifier(program, containerLocal)
174
+
175
+ return {
176
+ declarations,
177
+ imports,
178
+ exports,
179
+ reexports,
180
+ stars,
181
+ selfContained: !mixed && !buildsContainer,
182
+ buildsContainer,
183
+ }
184
+ }
185
+
186
+ /** `Name` or `ns.Name`, the references a static graph can follow. */
187
+ function refOf(node: Node): Ref | null {
188
+ const value = unwrap(node)
189
+ if (value.type === 'Identifier') return { local: value.name, member: null }
190
+ if (
191
+ value.type === 'MemberExpression' &&
192
+ !value.computed &&
193
+ value.object.type === 'Identifier' &&
194
+ value.property.type === 'Identifier'
195
+ ) {
196
+ return { local: value.object.name, member: value.property.name }
197
+ }
198
+ return null
199
+ }
200
+
201
+ /**
202
+ * The properties of an options object literal by key: empty when there are
203
+ * no options, null when the analysis cannot read all of them (not a literal,
204
+ * or a literal with spread or computed properties).
205
+ */
206
+ function optionsOf(node: Node | undefined): Map<string, Node> | null {
207
+ if (node === undefined) return new Map()
208
+ const options = unwrap(node)
209
+ if (options.type !== 'ObjectExpression') return null
210
+ const properties = new Map<string, Node>()
211
+ for (const property of options.properties) {
212
+ if (property.type !== 'Property' || property.computed) return null
213
+ const key = propertyKey(property.key)
214
+ if (key === null) return null
215
+ properties.set(key, unwrap(property.value))
216
+ }
217
+ return properties
218
+ }
219
+
220
+ /** `true` or `false` when the option is absent or a boolean literal; null when it cannot be read. */
221
+ function literalFlag(node: Node | undefined): boolean | null {
222
+ if (node === undefined) return false
223
+ return node.type === 'Literal' && typeof node.value === 'boolean' ? node.value : null
224
+ }
225
+
226
+ /** A scope argument: absent (the default), a reference to resolve, or an expression that cannot be followed. */
227
+ function scopeArgument(node: Node | undefined, fallback: string | null): Placement {
228
+ if (node === undefined) return { scope: fallback, scopeRef: null }
229
+ return { scope: null, scopeRef: refOf(node) }
230
+ }
231
+
232
+ function collectDowelImports(program: ESTree.Program): DowelImports {
233
+ const result = new Map<string, DowelExport>()
234
+ for (const statement of program.body) {
235
+ if (statement.type !== 'ImportDeclaration' || statement.source.value !== '@doweljs/core')
236
+ continue
237
+ for (const specifier of statement.specifiers) {
238
+ if (specifier.type !== 'ImportSpecifier') continue
239
+ const imported = moduleExportName(specifier.imported)
240
+ if (isDowelExport(imported)) result.set(specifier.local.name, imported)
241
+ }
242
+ }
243
+ return result
244
+ }
245
+
246
+ function declarationsOf(statement: ESTree.VariableDeclaration, dowel: DowelImports): Declaration[] {
247
+ const found: Declaration[] = []
248
+ for (const declarator of statement.declarations) {
249
+ if (declarator.id.type !== 'Identifier' || declarator.init === null) continue
250
+ const init = unwrap(declarator.init)
251
+ if (init.type !== 'CallExpression') continue
252
+ const declaration =
253
+ parseService(declarator.id.name, init, dowel) ??
254
+ parseSlot(declarator.id.name, init, dowel) ??
255
+ parseScope(declarator.id.name, init, dowel) ??
256
+ parseExecution(declarator.id.name, init, dowel)
257
+ if (declaration !== null) found.push(declaration)
258
+ }
259
+ return found
260
+ }
261
+
262
+ /** `service('Name', deps?, factory, options?)` */
263
+ function parseService(
264
+ local: string,
265
+ call: ESTree.CallExpression,
266
+ dowel: DowelImports,
267
+ ): Declaration | null {
268
+ if (call.callee.type !== 'Identifier' || dowel.get(call.callee.name) !== 'service') return null
269
+ const [nameArg, second, third, fourth] = call.arguments
270
+ const name = stringValue(nameArg)
271
+ if (name === null) return null
272
+ const hasDeps = second?.type === 'ObjectExpression'
273
+ const options = hasDeps ? fourth : third
274
+ const deps: DepRef[] = []
275
+ if (hasDeps) {
276
+ for (const property of second.properties) {
277
+ if (property.type !== 'Property' || property.computed) continue
278
+ const key = propertyKey(property.key)
279
+ if (key === null) continue
280
+ const value = unwrap(property.value)
281
+ const isOptional =
282
+ value.type === 'CallExpression' &&
283
+ value.callee.type === 'Identifier' &&
284
+ dowel.get(value.callee.name) === 'optional'
285
+ const target = isOptional ? value.arguments[0] : value
286
+ deps.push({
287
+ key,
288
+ ref: target === undefined || target.type === 'SpreadElement' ? null : refOf(target),
289
+ optional: isOptional,
290
+ })
291
+ }
292
+ }
293
+ return {
294
+ local,
295
+ kind: 'service',
296
+ name,
297
+ ...scopeArgument(optionsOf(options)?.get('scope'), null),
298
+ many: false,
299
+ deps,
300
+ start: call.start,
301
+ end: call.end,
302
+ }
303
+ }
304
+
305
+ /** `slot('Name', options?)()` */
306
+ function parseSlot(
307
+ local: string,
308
+ call: ESTree.CallExpression,
309
+ dowel: DowelImports,
310
+ ): Declaration | null {
311
+ const inner = unwrap(call.callee)
312
+ if (inner.type !== 'CallExpression') return null
313
+ if (inner.callee.type !== 'Identifier' || dowel.get(inner.callee.name) !== 'slot') return null
314
+ const [nameArg, optionsArg] = inner.arguments
315
+ const name = stringValue(nameArg)
316
+ if (name === null) return null
317
+ const options = optionsOf(optionsArg)
318
+ return {
319
+ local,
320
+ kind: 'slot',
321
+ name,
322
+ // Options the analysis cannot read leave the scope unknown.
323
+ ...(options === null
324
+ ? { scope: null, scopeRef: null }
325
+ : scopeArgument(options.get('scope'), 'root')),
326
+ many: options === null ? null : literalFlag(options.get('many')),
327
+ deps: [],
328
+ start: call.start,
329
+ end: call.end,
330
+ }
331
+ }
332
+
333
+ /** `scope('name', Parent?)` */
334
+ function parseScope(
335
+ local: string,
336
+ call: ESTree.CallExpression,
337
+ dowel: DowelImports,
338
+ ): Declaration | null {
339
+ if (call.callee.type !== 'Identifier' || dowel.get(call.callee.name) !== 'scope') return null
340
+ const name = stringValue(call.arguments[0])
341
+ if (name === null) return null
342
+ const parent = call.arguments[1]
343
+ return {
344
+ local,
345
+ kind: 'scope',
346
+ name,
347
+ ...scopeArgument(parent?.type === 'SpreadElement' ? undefined : parent, 'root'),
348
+ many: false,
349
+ deps: [],
350
+ start: call.start,
351
+ end: call.end,
352
+ }
353
+ }
354
+
355
+ /** `execution('Name', { scope: S, entry: E, inputs: { key: Slot } })` */
356
+ function parseExecution(
357
+ local: string,
358
+ call: ESTree.CallExpression,
359
+ dowel: DowelImports,
360
+ ): Declaration | null {
361
+ if (call.callee.type !== 'Identifier' || dowel.get(call.callee.name) !== 'execution') return null
362
+ const name = stringValue(call.arguments[0])
363
+ if (name === null) return null
364
+ // Options it cannot read still declare the execution; its scope and dependencies stay unknown.
365
+ const options = optionsOf(call.arguments[1]) ?? new Map<string, Node>()
366
+ const scope = options.get('scope')
367
+ const entry = options.get('entry')
368
+ const inputs = options.get('inputs')
369
+ const deps: DepRef[] = []
370
+ if (entry !== undefined) deps.push({ key: 'entry', ref: refOf(entry), optional: false })
371
+ // Keyed as at runtime, by the key each run passes the input's value under.
372
+ for (const [key, input] of optionsOf(inputs) ?? []) {
373
+ deps.push({ key, ref: refOf(input), optional: false })
374
+ }
375
+ return {
376
+ local,
377
+ kind: 'execution',
378
+ name,
379
+ // Known once its scope reference resolves: the parent of the scope its runs open containers for.
380
+ scope: null,
381
+ scopeRef: scope === undefined ? null : refOf(scope),
382
+ many: false,
383
+ deps,
384
+ start: call.start,
385
+ end: call.end,
386
+ }
387
+ }
388
+
389
+ /** Strips TypeScript wrappers that may survive transpilation, such as `satisfies`. */
390
+ function unwrap(node: Node): Node {
391
+ let current = node
392
+ for (;;) {
393
+ switch (current.type) {
394
+ case 'ParenthesizedExpression':
395
+ case 'TSAsExpression':
396
+ case 'TSSatisfiesExpression':
397
+ case 'TSNonNullExpression':
398
+ case 'TSTypeAssertion':
399
+ current = current.expression
400
+ continue
401
+ default:
402
+ return current
403
+ }
404
+ }
405
+ }
406
+
407
+ function stringValue(node: Node | undefined): string | null {
408
+ if (node === undefined) return null
409
+ const value = unwrap(node)
410
+ if (value.type === 'Literal' && typeof value.value === 'string') return value.value
411
+ if (value.type === 'TemplateLiteral' && value.expressions.length === 0) {
412
+ return value.quasis[0]?.value.cooked ?? null
413
+ }
414
+ return null
415
+ }
416
+
417
+ function propertyKey(key: Node): string | null {
418
+ if (key.type === 'Identifier') return key.name
419
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value
420
+ return null
421
+ }
422
+
423
+ function moduleExportName(node: ESTree.ModuleExportName): string {
424
+ return node.type === 'Identifier' ? node.name : String(node.value)
425
+ }
426
+
427
+ function boundNames(pattern: ESTree.BindingPattern): string[] {
428
+ switch (pattern.type) {
429
+ case 'Identifier':
430
+ return [pattern.name]
431
+ case 'ObjectPattern':
432
+ return pattern.properties.flatMap((property) =>
433
+ property.type === 'RestElement'
434
+ ? boundNames(property.argument)
435
+ : boundNames(property.value),
436
+ )
437
+ case 'ArrayPattern':
438
+ return pattern.elements.flatMap((element) =>
439
+ element === null
440
+ ? []
441
+ : element.type === 'RestElement'
442
+ ? boundNames(element.argument)
443
+ : boundNames(element),
444
+ )
445
+ case 'AssignmentPattern':
446
+ return boundNames(pattern.left)
447
+ default:
448
+ return []
449
+ }
450
+ }
451
+
452
+ /** Whether any call expression in the tree calls the given identifier. */
453
+ function callsIdentifier(root: Node, name: string): boolean {
454
+ const stack: unknown[] = [root]
455
+ while (stack.length > 0) {
456
+ const current = stack.pop()
457
+ if (typeof current !== 'object' || current === null) continue
458
+ if (Array.isArray(current)) {
459
+ stack.push(...current)
460
+ continue
461
+ }
462
+ const node = current as Node
463
+ if (
464
+ node.type === 'CallExpression' &&
465
+ node.callee.type === 'Identifier' &&
466
+ node.callee.name === name
467
+ ) {
468
+ return true
469
+ }
470
+ for (const [key, value] of Object.entries(node)) {
471
+ if (key !== 'parent' && typeof value === 'object' && value !== null) stack.push(value)
472
+ }
473
+ }
474
+ return false
475
+ }