@valkyrjaio/sindri 26.6.65 → 26.6.66

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.
@@ -10,6 +10,8 @@ import { ts } from 'ts-morph';
10
10
 
11
11
  import { AstFileGenerator } from '../../Abstract/AstFileGenerator.ts';
12
12
  import type { EventDataFileGeneratorContract } from '../../Event/Contract/EventDataFileGeneratorContract.ts';
13
+ import { GeneratorUnreadableListenerException } from '../../Throwable/Exception/GeneratorUnreadableListenerException.ts';
14
+
13
15
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
14
16
 
15
17
  export class AstEventDataFileGenerator extends AstFileGenerator implements EventDataFileGeneratorContract {
@@ -28,6 +30,21 @@ export class AstEventDataFileGenerator extends AstFileGenerator implements Event
28
30
  ListenerContract: '@valkyrjaio/valkyrja/Event/Data/Contract/ListenerContract.ts',
29
31
  };
30
32
 
33
+ /** The builders `ListenerContract` declares. */
34
+ protected static readonly LISTENER_BUILDERS: readonly string[] = ['withEventId', 'withName', 'withHandler'];
35
+
36
+ /** The builder that replaces a listener's event id. */
37
+ protected static readonly BUILDER_EVENT_ID = 'withEventId';
38
+
39
+ /** The builder that replaces a listener's name. */
40
+ protected static readonly BUILDER_NAME = 'withName';
41
+
42
+ /** The position of the event id in a `Listener` construction. */
43
+ protected static readonly ARGUMENT_EVENT_ID = 0;
44
+
45
+ /** The position of the listener name in a `Listener` construction. */
46
+ protected static readonly ARGUMENT_NAME = 1;
47
+
31
48
  public classImportMap: Record<string, string> = {};
32
49
 
33
50
  private readonly printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
@@ -44,21 +61,60 @@ export class AstEventDataFileGenerator extends AstFileGenerator implements Event
44
61
  directory: string,
45
62
  className: string,
46
63
  namespace: string,
47
- listeners: Record<string, ts.Expression>,
64
+ listeners: ReadonlyMap<string, ts.Expression>,
65
+ ): GenerateStatus {
66
+ return this.generateMergedFile(directory, className, namespace, listeners, []);
67
+ }
68
+
69
+ /**
70
+ * Generate the event data file from the decorated listeners and the listeners a provider
71
+ * declares by hand.
72
+ */
73
+ public generateMergedFile(
74
+ directory: string,
75
+ className: string,
76
+ namespace: string,
77
+ listeners: ReadonlyMap<string, ts.Expression>,
78
+ listenerExprs: readonly ts.Expression[],
48
79
  ): GenerateStatus {
80
+ const factories = new Map<string, ts.Expression>();
81
+ const events = new Map<string, string[]>();
82
+
83
+ for (const [name, listener] of listeners) {
84
+ this.fileListener(
85
+ factories,
86
+ events,
87
+ this.stringSource(name),
88
+ this.readListenerKeys(listener).eventId,
89
+ listener,
90
+ );
91
+ }
92
+
93
+ for (const listener of listenerExprs) {
94
+ const keys = this.readListenerKeys(listener);
95
+
96
+ // The index is the only way the collection reaches a listener, so an entry the index
97
+ // cannot hold would be one nothing dispatches.
98
+ if (keys.name === undefined || keys.eventId === undefined) {
99
+ continue;
100
+ }
101
+
102
+ this.fileListener(factories, events, keys.name, keys.eventId, listener);
103
+ }
104
+
49
105
  const userImportsBlock = this.buildUserImportsBlock(this.classImportMap, [
50
106
  ...Object.keys(AstEventDataFileGenerator.FRAMEWORK_IMPORTS),
51
107
  ...Object.keys(AstEventDataFileGenerator.LISTENER_IMPORTS),
52
108
  ...Object.keys(AstEventDataFileGenerator.LISTENER_TYPE_IMPORTS),
53
109
  ]);
54
110
 
55
- const contents = this.generateClassContents(listeners);
111
+ const contents = this.generateClassContents(factories, events);
56
112
 
57
113
  // The Listener / ListenerContract imports are only referenced by the
58
114
  // generated listener closures — omit them when there are no listeners
59
115
  // so empty event data does not carry unused (and unresolved) imports.
60
116
  const listenerImports =
61
- Object.keys(listeners).length > 0
117
+ factories.size > 0
62
118
  ? [
63
119
  ...this.buildFrameworkImportLines(AstEventDataFileGenerator.LISTENER_IMPORTS),
64
120
  ...this.buildFrameworkImportLines(AstEventDataFileGenerator.LISTENER_TYPE_IMPORTS, true),
@@ -82,22 +138,241 @@ export class AstEventDataFileGenerator extends AstFileGenerator implements Event
82
138
  return this.writeFile(directory, className, data);
83
139
  }
84
140
 
85
- public generateClassContents(listeners: Record<string, ts.Expression>): string {
86
- const entries = Object.entries(listeners);
87
-
88
- if (entries.length === 0) {
141
+ public generateClassContents(
142
+ factories: ReadonlyMap<string, ts.Expression>,
143
+ events: ReadonlyMap<string, readonly string[]>,
144
+ ): string {
145
+ if (factories.size === 0) {
89
146
  return 'super();';
90
147
  }
91
148
 
92
- const lines: string[] = [];
149
+ return [
150
+ 'super(',
151
+ ...this.wrapMapLines(this.buildEventIndexLines(events)),
152
+ ...this.wrapMapLines(this.buildListenerLines(factories)),
153
+ ' );',
154
+ ].join('\n');
155
+ }
156
+
157
+ /**
158
+ * File one listener into the listener map and the event index, the way
159
+ * `ListenerCollection.addListener` files it.
160
+ *
161
+ * @param name The source text of the key the listener map files the listener under
162
+ * @param eventId The source text of the key the event index files the name under, or nothing
163
+ * when the listener names no event id a key can read
164
+ */
165
+ protected fileListener(
166
+ factories: Map<string, ts.Expression>,
167
+ events: Map<string, string[]>,
168
+ name: string,
169
+ eventId: string | undefined,
170
+ listener: ts.Expression,
171
+ ): void {
172
+ factories.set(name, listener);
173
+
174
+ if (eventId === undefined) {
175
+ return;
176
+ }
177
+
178
+ const names = events.get(eventId);
179
+
180
+ if (names === undefined) {
181
+ events.set(eventId, [name]);
182
+
183
+ return;
184
+ }
185
+
186
+ if (!names.includes(name)) {
187
+ names.push(name);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Read the event id and the name the listener an expression declares ends up with.
193
+ *
194
+ * The chain is walked from the outermost call inward, so the first `withEventId` and the first
195
+ * `withName` it reaches are the last ones the framework applies.
196
+ */
197
+ protected readListenerKeys(listener: ts.Expression): { eventId: string | undefined; name: string | undefined } {
198
+ let eventIdArgument: ts.Expression | undefined = undefined;
199
+ let nameArgument: ts.Expression | undefined = undefined;
200
+ let eventIdReplaced = false;
201
+ let nameReplaced = false;
202
+ this.requireNoTypeBinding(listener);
203
+
204
+ let current = this.readValueExpression(listener);
205
+
206
+ while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) {
207
+ const builder = current.expression.name.text;
208
+
209
+ if (!AstEventDataFileGenerator.LISTENER_BUILDERS.includes(builder)) {
210
+ throw new GeneratorUnreadableListenerException(
211
+ `Cannot read the listener '${this.printExpr(current)}': '${builder}' is not a listener builder.`,
212
+ );
213
+ }
214
+
215
+ if (builder === AstEventDataFileGenerator.BUILDER_EVENT_ID && !eventIdReplaced) {
216
+ eventIdReplaced = true;
217
+ eventIdArgument = current.arguments[0];
218
+ }
219
+
220
+ if (builder === AstEventDataFileGenerator.BUILDER_NAME && !nameReplaced) {
221
+ nameReplaced = true;
222
+ nameArgument = current.arguments[0];
223
+ }
224
+
225
+ current = this.readValueExpression(current.expression.expression);
226
+ }
227
+
228
+ if (!ts.isNewExpression(current)) {
229
+ return { eventId: undefined, name: undefined };
230
+ }
231
+
232
+ const args = current.arguments ?? [];
233
+
234
+ if (!eventIdReplaced) {
235
+ eventIdArgument = args[AstEventDataFileGenerator.ARGUMENT_EVENT_ID];
236
+ }
237
+
238
+ if (!nameReplaced) {
239
+ nameArgument = args[AstEventDataFileGenerator.ARGUMENT_NAME];
240
+ }
241
+
242
+ return { eventId: this.keySource(eventIdArgument), name: this.keySource(nameArgument) };
243
+ }
244
+
245
+ /**
246
+ * Read the source text of one key an argument names, which is nothing when the generated file
247
+ * cannot name that key before the application runs.
248
+ */
249
+ protected keySource(argument: ts.Expression | undefined): string | undefined {
250
+ if (argument === undefined) {
251
+ return undefined;
252
+ }
253
+
254
+ const value = this.readValueExpression(argument);
255
+
256
+ if (ts.isStringLiteral(value)) {
257
+ return this.stringSource(value.text);
258
+ }
259
+
260
+ if (!ts.isPropertyAccessExpression(value) || !ts.isIdentifier(value.expression)) {
261
+ return undefined;
262
+ }
93
263
 
94
- for (const [key, valueExpr] of entries) {
95
- const printed = this.printer.printNode(ts.EmitHint.Unspecified, valueExpr, this.dummySourceFile);
96
- lines.push(` ['${key}']: (): ListenerContract => ${printed},`);
264
+ // The file emits the reference, so a constant it does not import reaches it as an
265
+ // unbound name.
266
+ if (!Object.hasOwn(this.classImportMap, value.expression.text)) {
267
+ throw new GeneratorUnreadableListenerException(
268
+ `Cannot read the listener key '${this.printExpr(value)}': the generated data imports no '${value.expression.text}'.`,
269
+ );
97
270
  }
98
271
 
99
- return ['super(', ' {},', ' {', ...lines, ' },', ' );'].join(
100
- '\n ',
272
+ return this.printExpr(value);
273
+ }
274
+
275
+ /** Build the lines of the event index, which files each listener name under its event id. */
276
+ protected buildEventIndexLines(events: ReadonlyMap<string, readonly string[]>): string[] {
277
+ return [...events].map(([eventId, names]) => ` [${eventId}]: [${names.join(', ')}],`);
278
+ }
279
+
280
+ /** Build the lines of the listener map, which holds a factory for each listener name. */
281
+ protected buildListenerLines(factories: ReadonlyMap<string, ts.Expression>): string[] {
282
+ return [...factories].map(
283
+ ([name, listener]) => ` [${name}]: (): ListenerContract => ${this.printExpr(listener)},`,
101
284
  );
102
285
  }
286
+
287
+ /** Wrap the lines of one generated map in its braces, and collapse an empty map to `{}`. */
288
+ protected wrapMapLines(lines: readonly string[]): string[] {
289
+ if (lines.length === 0) {
290
+ return [' {},'];
291
+ }
292
+
293
+ return [' {', ...lines, ' },'];
294
+ }
295
+
296
+ /** Render a string as the source text of a string literal, with the escapes it needs. */
297
+ protected stringSource(value: string): string {
298
+ return this.printer.printNode(
299
+ ts.EmitHint.Unspecified,
300
+ ts.factory.createStringLiteral(value, true),
301
+ this.dummySourceFile,
302
+ );
303
+ }
304
+
305
+ /** Print an expression to source. */
306
+ protected printExpr(expr: ts.Expression): string {
307
+ return this.printNodeText(expr);
308
+ }
309
+
310
+ /** Print a node to source, through its own text when it carries a source position. */
311
+ protected printNodeText(node: ts.Node): string {
312
+ return node.pos >= 0
313
+ ? node.getText()
314
+ : this.printer.printNode(ts.EmitHint.Unspecified, node, this.dummySourceFile);
315
+ }
316
+
317
+ /** Reach the value node a wrapped expression holds, through the parentheses and the casts. */
318
+ protected readValueExpression(expression: ts.Expression): ts.Expression {
319
+ if (
320
+ ts.isParenthesizedExpression(expression) ||
321
+ ts.isAsExpression(expression) ||
322
+ ts.isSatisfiesExpression(expression)
323
+ ) {
324
+ return this.readValueExpression(expression.expression);
325
+ }
326
+
327
+ return expression;
328
+ }
329
+
330
+ /**
331
+ * Stop when any part of a listener names a binding in a type, which the generated file carries
332
+ * no import for.
333
+ */
334
+ protected requireNoTypeBinding(listener: ts.Expression, node: ts.Node = listener): void {
335
+ if (ts.isTypeNode(node) && this.namesBinding(node)) {
336
+ throw new GeneratorUnreadableListenerException(
337
+ `Cannot read the listener '${this.printNodeText(listener)}': the generated data cannot import the type '${this.printNodeText(node)}'; declare the listener without it.`,
338
+ );
339
+ }
340
+
341
+ ts.forEachChild(node, (child: ts.Node): void => {
342
+ this.requireNoTypeBinding(listener, child);
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Whether a type names a binding that the file holding it has to resolve. `const` is a reserved
348
+ * word, so it names none.
349
+ */
350
+ protected namesBinding(node: ts.Node): boolean {
351
+ if (ts.isImportTypeNode(node) || ts.isQualifiedName(node)) {
352
+ return true;
353
+ }
354
+
355
+ if (ts.isIdentifier(node)) {
356
+ return node.text !== 'const';
357
+ }
358
+
359
+ const declaredName = this.declaredName(node);
360
+ let names = false;
361
+
362
+ ts.forEachChild(node, (child: ts.Node): void => {
363
+ names = names || (child !== declaredName && this.namesBinding(child));
364
+ });
365
+
366
+ return names;
367
+ }
368
+
369
+ /**
370
+ * The name a declaration spells out as one identifier, which declares a binding rather than
371
+ * naming one. Every other name form can hold an expression, so it is read.
372
+ */
373
+ protected declaredName(node: ts.Node): ts.Node | undefined {
374
+ const name: ts.Node | undefined = 'name' in node ? (node.name as ts.Node | undefined) : undefined;
375
+
376
+ return name !== undefined && ts.isIdentifier(name) ? name : undefined;
377
+ }
103
378
  }
@@ -11,12 +11,26 @@ import type { ts } from 'ts-morph';
11
11
  import type { GenerateStatus } from '../../Enum/GenerateStatus.ts';
12
12
 
13
13
  export interface EventDataFileGeneratorContract {
14
+ /** Map of class name → module specifier for the imports the data cache references. */
15
+ classImportMap: Record<string, string>;
16
+
14
17
  generateFile(
15
18
  directory: string,
16
19
  className: string,
17
20
  namespace: string,
18
- listeners: Record<string, ts.Expression>,
21
+ listeners: ReadonlyMap<string, ts.Expression>,
22
+ ): GenerateStatus;
23
+
24
+ generateMergedFile(
25
+ directory: string,
26
+ className: string,
27
+ namespace: string,
28
+ listeners: ReadonlyMap<string, ts.Expression>,
29
+ listenerExprs: readonly ts.Expression[],
19
30
  ): GenerateStatus;
20
31
 
21
- generateClassContents(listeners: Record<string, ts.Expression>): string;
32
+ generateClassContents(
33
+ factories: ReadonlyMap<string, ts.Expression>,
34
+ events: ReadonlyMap<string, readonly string[]>,
35
+ ): string;
22
36
  }
@@ -0,0 +1,11 @@
1
+ /*
2
+ * This file is part of the Sindri package.
3
+ *
4
+ * Copyright (c) 2016-present Melech Mizrachi
5
+ *
6
+ * Released under the MIT License. See LICENSE.md for details.
7
+ */
8
+
9
+ import { GeneratorInvalidArgumentException } from './Abstract/GeneratorInvalidArgumentException.ts';
10
+
11
+ export class GeneratorUnreadableListenerException extends GeneratorInvalidArgumentException {}