@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.
- package/dist/bin/sindri.cjs +496 -124
- package/package.json +1 -1
- package/src/Sindri/Ast/Abstract/AstReader.ts +71 -5
- package/src/Sindri/Ast/Data/Result/ListenerAttributeResult.ts +8 -1
- package/src/Sindri/Ast/Data/Result/ListenerProviderResult.ts +6 -0
- package/src/Sindri/Ast/ListenerAttributeReader.ts +42 -4
- package/src/Sindri/Ast/ListenerProviderReader.ts +70 -7
- package/src/Sindri/Ast/RouteProviderReader.ts +41 -7
- package/src/Sindri/Ast/Throwable/Exception/AstUnreadableListenerException.ts +11 -0
- package/src/Sindri/Ast/Throwable/Exception/AstUnreadableRouteException.ts +11 -0
- package/src/Sindri/Constant/SindriInfo.ts +2 -2
- package/src/Sindri/Generate/Abstract/GenerateDataFromAst.ts +23 -10
- package/src/Sindri/Generator/Ast/Event/AstEventDataFileGenerator.ts +288 -13
- package/src/Sindri/Generator/Event/Contract/EventDataFileGeneratorContract.ts +16 -2
- package/src/Sindri/Generator/Throwable/Exception/GeneratorUnreadableListenerException.ts +11 -0
|
@@ -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:
|
|
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(
|
|
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
|
-
|
|
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(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
100
|
-
|
|
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:
|
|
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(
|
|
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 {}
|