-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathCollectionConfiguration.mts
585 lines (510 loc) · 20 KB
/
CollectionConfiguration.mts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
/**
* @module source/CollectionConfiguration.mjs
* @file
*
* This defines a data structure for configuring a composite of Maps and Sets.
*/
import AcornInterface from "./generatorTools/AcornInterface.mjs";
import CollectionType from "./generatorTools/CollectionType.mjs";
import ConfigurationData from "./generatorTools/ConfigurationData.mjs";
import ConfigurationStateMachine from "./generatorTools/ConfigurationStateMachine.mjs";
/** @readonly */
const PREDEFINED_TYPES: Map<string, string> = new Map([
["Map", "Strong/Map"],
["WeakMap", "Weak/Map"],
["Set", "Strong/Set"],
["WeakSet", "Weak/Set"],
["OneToOne", "OneToOne/Map"],
]);
import type { MapOrSetType } from "./generatorTools/CollectionType.mjs";
type outerType = MapOrSetType | "OneToOne";
type innerType = "Set" | null;
type ArgumentValidator<T> = (arg: T) => false | void;
type OneToOneBaseString = "WeakMap" | "composite-collection/WeakStrongMap" | "composite-collection/WeakWeakMap";
/**
* @typedef CollectionTypeOptions
* @property {string?} jsDocType A JSDoc-printable type for the argument.
* @property {string?} tsType A TypeScript type for the argument.
* @property {Function?} argumentValidator A method to use for testing the argument.
*/
class CollectionTypeOptions {
jsDocType= "";
tsType = "";
argumentValidator: ArgumentValidator<unknown> = (arg) => void(arg);
}
/**
* @typedef {object} oneToOneOptions
* @property {string?} pathToBaseModule Indicates the import line for the base module's location.
* If this property isn't present, the CodeGenerator will
* create a complete inline copy of the base collection in the
* one-to-one map module.
*/
class oneToOneOptions {
pathToBaseModule?: string;
}
/** @typedef {string} identifier */
/**
* A configuration manager for a single composite collection.
*
* @public
*/
export default class CollectionConfiguration {
/** @type {ConfigurationData} @constant */
#configurationData: ConfigurationData;
/** @type {ConfigurationStateMachine} */
#stateMachine: ConfigurationStateMachine;
// #region static validation of argument properties
/**
* Validate a string argument.
*
* @param {string} argumentName The name of the argument.
* @param {string} value The argument value.
*/
static #stringArg(argumentName: string, value: string) : void
{
if ((typeof value !== "string") || (value.length === 0))
throw new Error(`${argumentName} must be a non-empty string!`);
}
/**
* Validate an identifier as one we can safely inject into templates.
*
* @param {string} argName The name of the argument in this function's caller. Used to describe exceptions.
* @param {identifier} identifier The identifier to insert into the generated code.
* @throws
*/
static #identifierArg(argName: string, identifier: string) : void
{
CollectionConfiguration.#stringArg(argName, identifier);
if (identifier !== identifier.trim())
throw new Error(argName + " must not have leading or trailing whitespace!");
/* A little explanation is in order. Simply put, the compiler will need a set of variable names it can define
which should only minimally reduce the set of variable names the user may need. A double underscore at the
start and the end of the argument name isn't too much to ask - and why would you have that for a function
argument name anyway?
*/
if (/^__.*__$/.test(identifier))
throw new Error("This module reserves variable names starting and ending with a double underscore for itself.");
if (!AcornInterface.isIdentifier(identifier)) {
throw new Error(`"${identifier}" is not a valid JavaScript identifier!`);
}
}
/**
* Verify a lambda function of one argument may be inserted directly in to generated code.
*
* @param {string} argumentName The name of the function.
* @param {Function} callback The function.
* @param {string} singleParamName The argument name to check.
* @param {boolean} mayOmit True if the function may be omitted.
* @returns {string} The body of the function.
*/
static #validatorArg(
argumentName: string,
callback: ArgumentValidator<unknown>,
singleParamName: string,
mayOmit = false
) : string
{
if (typeof callback !== "function")
throw new Error(`${argumentName} must be a function${mayOmit ? " or omitted" : ""}!`);
const [source, params, body] = AcornInterface.getNormalFunctionAST(callback);
if ((params.length !== 1) || (params[0].name !== singleParamName))
throw new Error(`${argumentName} must be a function with a single argument, "${singleParamName}"!`);
return source.substring(body.start, body.end + 1);
}
static #jsdocField(argumentName: string, value: string) : void
{
CollectionConfiguration.#stringArg(argumentName, value);
if (value.includes("*/"))
throw new Error(argumentName + " contains a comment that would end the JSDoc block!");
}
// #endregion static validation of argument properties
// #region The primary CollectionConfiguration public API (excluding OneToOne and lock())
/**
* @param {string} className The name of the class to define.
* @param {string} outerType One of "Map", "WeakMap", "Set", "WeakSet", "OneToOne".
* @param {string?} innerType Either "Set" or null.
*/
constructor(
className: string,
outerType: outerType,
innerType: innerType = null
)
{
/* This is a defensive measure for one-to-one configurations, where the base configuration must be for a WeakMap. */
if (new.target !== CollectionConfiguration)
throw new Error("You cannot subclass CollectionConfiguration!");
CollectionConfiguration.#identifierArg("className", className);
if (className.startsWith("Readonly"))
throw new Error(`You can't start a class name with "Readonly"!`);
if (PREDEFINED_TYPES.has(className))
throw new Error(`You can't override the ${className} primordial!`);
let template = PREDEFINED_TYPES.get(outerType);
if (!template)
throw new Error(`outerType must be one of ${Array.from(PREDEFINED_TYPES.keys()).join(", ")}!`);
switch (innerType) {
case null:
break;
case "Set":
if (!outerType.endsWith("Map"))
throw new Error("outerType must be a Map or WeakMap when an innerType is not null!");
template += "OfStrongSets";
break;
/*
There can't be a map of weak sets, because it's unclear when we would
hold references to the map keys. Try it as a thought experiment: add
two such sets, then delete one. Should the map keys be held?
What about after garbage collection removes the other set?
*/
default:
throw new Error("innerType must be a Set, or null!");
}
if (template.includes("MapOf")) {
this.#stateMachine = ConfigurationStateMachine.MapOfSets();
this.#stateMachine.doStateTransition("startMapOfSets");
}
else if (outerType.endsWith("Map")) {
this.#stateMachine = ConfigurationStateMachine.Map();
this.#stateMachine.doStateTransition("startMap");
}
else if (outerType.endsWith("Set")) {
this.#stateMachine = ConfigurationStateMachine.Set();
this.#stateMachine.doStateTransition("startSet");
}
else if (outerType === "OneToOne") {
this.#stateMachine = ConfigurationStateMachine.OneToOne();
this.#stateMachine.doStateTransition("startOneToOne");
}
else {
throw new Error("Internal error, not reachable");
}
this.#configurationData = new ConfigurationData(className, template);
this.#configurationData.setConfiguration(this);
Reflect.preventExtensions(this);
}
/** @type {string} @package */
get currentState() : string
{
return this.#stateMachine.currentState;
}
/**
* A file overview to feed into the generated module.
*
* @type {string} fileOverview The overview.
* @public
*/
setFileOverview(fileOverview: string) : void
{
return this.#stateMachine.catchErrorState(() => {
if (!this.#stateMachine.doStateTransition("fileOverview")) {
this.#throwIfLocked();
throw new Error("You may only define the file overview at the start of the configuration!");
}
CollectionConfiguration.#stringArg("fileOverview", fileOverview);
this.#configurationData.setFileOverview(fileOverview);
});
}
/**
* Set the module import lines.
*
* @param {string} lines The JavaScript code to inject.
* @returns {void}
*/
importLines(lines: string) : void
{
return this.#stateMachine.catchErrorState(() => {
if (!this.#stateMachine.doStateTransition("importLines")) {
this.#throwIfLocked();
throw new Error("You may only define import lines at the start of the configuration or immediately after the file overview!");
}
CollectionConfiguration.#stringArg("lines", lines);
this.#configurationData.importLines = lines.toString().trim();
});
}
/**
* Define a map key.
*
* @param {identifier} argumentName The key name.
* @param {string} description The key description for JSDoc.
* @param {boolean} holdWeak True if the collection should hold values for this key as weak references.
* @param {CollectionTypeOptions?} options Options for configuring generated code.
* @returns {void}
*/
addMapKey(
argumentName: string,
description: string,
holdWeak: boolean,
options: Partial<CollectionTypeOptions> = {}
) : void
{
return this.#stateMachine.catchErrorState(() => {
if (!this.#stateMachine.doStateTransition("mapKeys")) {
this.#throwIfLocked();
throw new Error("You must define map keys before calling .addSetElement(), .setValueType() or .lock()!");
}
const {
jsDocType = holdWeak ? "object" : "*",
tsType = holdWeak ? "object" : "unknown",
argumentValidator = null,
} = options;
this.#validateKey(
argumentName,
holdWeak,
jsDocType,
tsType,
description,
argumentValidator
);
if (holdWeak && !this.#configurationData.collectionTemplate.startsWith("Weak/Map"))
throw new Error("Strong maps cannot have weak map keys!");
const validatorSource = (argumentValidator !== null) ?
CollectionConfiguration.#validatorArg(
"argumentValidator",
argumentValidator,
argumentName,
true
) :
null;
const collectionType = new CollectionType(
argumentName,
holdWeak ? "WeakMap" : "Map",
jsDocType,
tsType,
description,
validatorSource
);
this.#configurationData.defineArgument(collectionType);
});
}
/**
* Define a set key.
*
* @param {identifier} argumentName The key name.
* @param {string} description The key description for JSDoc.
* @param {boolean} holdWeak True if the collection should hold values for this key as weak references.
* @param {CollectionTypeOptions?} options Options for configuring generated code.
* @returns {void}
*/
addSetKey(
argumentName: string,
description: string,
holdWeak: boolean,
options: Partial<CollectionTypeOptions> = {}
) : void
{
return this.#stateMachine.catchErrorState(() => {
if (!this.#stateMachine.doStateTransition("setElements")) {
this.#throwIfLocked();
throw new Error("You must define set keys before calling .setValueType() or .lock()!");
}
const {
jsDocType = holdWeak ? "object" : "*",
tsType = holdWeak ? "object" : "unknown",
argumentValidator = null,
} = options;
this.#validateKey(
argumentName,
holdWeak,
jsDocType,
tsType,
description,
argumentValidator
);
if (holdWeak && !/Weak\/?Set/.test(this.#configurationData.collectionTemplate))
throw new Error("Strong sets cannot have weak set keys!");
const validatorSource = (argumentValidator !== null) ?
CollectionConfiguration.#validatorArg(
"argumentValidator",
argumentValidator,
argumentName,
true
) :
null;
const collectionType = new CollectionType(
argumentName,
holdWeak ? "WeakSet" : "Set",
jsDocType,
tsType,
description,
validatorSource
);
this.#configurationData.defineArgument(collectionType);
});
}
#validateKey(
argumentName: string,
holdWeak: boolean,
jsDocType: string,
tsType: string,
description: string,
argumentValidator: ArgumentValidator<unknown> | null
) : void
{
CollectionConfiguration.#identifierArg("argumentName", argumentName);
CollectionConfiguration.#jsdocField("jsDocType", jsDocType);
CollectionConfiguration.#stringArg("tsType", tsType);
if (/^__.*__$/.test(tsType))
throw new Error("This module reserves variable names starting and ending with a double underscore for itself.");
CollectionConfiguration.#jsdocField("description", description);
if (argumentValidator !== null) {
CollectionConfiguration.#validatorArg(
"argumentValidator",
argumentValidator,
argumentName,
true
);
}
if (this.#configurationData.parameterToTypeMap.has(argumentName))
throw new Error(`Argument name "${argumentName}" has already been defined!`);
if ((argumentName === "value") &&
!this.#configurationData.collectionTemplate.includes("Set"))
throw new Error(`The argument name "value" is reserved!`);
if (typeof holdWeak !== "boolean")
throw new Error("holdWeak must be true or false!");
}
/**
* Define the value type for .set(), .add() calls.
*
* @param {string} description The description of the value.
* @param {CollectionTypeOptions?} options Options for configuring generated code.
* @returns {void}
*/
setValueType(
description: string,
options: Partial<CollectionTypeOptions> = {}
) : void
{
return this.#stateMachine.catchErrorState(() => {
if (!this.#stateMachine.doStateTransition("hasValueFilter")) {
this.#throwIfLocked();
if (this.#stateMachine.currentState === "hasValueFilter")
throw new Error("You can only set the value type once!");
throw new Error("You can only call .setValueType() directly after calling .addMapKey()!");
}
const {
jsDocType = "*",
tsType = "unknown",
argumentValidator = null,
} = options;
CollectionConfiguration.#stringArg("type", jsDocType);
CollectionConfiguration.#stringArg("description", description);
CollectionConfiguration.#identifierArg("tsType", tsType);
let validatorSource = null;
if (argumentValidator) {
validatorSource = CollectionConfiguration.#validatorArg(
"validator", argumentValidator, "value", true
);
}
this.#configurationData.valueType = new CollectionType(
"value", "Map", jsDocType, tsType, description, validatorSource
);
});
}
// #endregion The actual CollectionConfiguration public API.
// #region One-to-one map configuration and static helpers.
/**
* Configure this one-to-one map definition.
*
* @param {CollectionConfiguration | string} base The underlying collection's configuration.
* @param {identifier} key The weak key name to reserve in the base collection for the one-to-one map's use.
* @param {oneToOneOptions?} options For configuring the layout of the one-to-one module and dependencies.
* @async
* @returns {Promise<void>}
*/
configureOneToOne(
base: CollectionConfiguration | OneToOneBaseString,
key: string,
options: oneToOneOptions = {}
) : Promise<void>
{
return this.#stateMachine.catchErrorAsync(async () => {
if (!this.#stateMachine.doStateTransition("configureOneToOne")) {
throw new Error("configureOneToOne can only be used for OneToOne collections, and exactly once!");
}
CollectionConfiguration.#identifierArg("privateKeyName", key);
let configData, retrievedBase;
if (base instanceof CollectionConfiguration) {
if (base.currentState !== "locked") {
/* We dare not modify the base configuration lest other code use it to generate a different file. */
throw new Error("The base configuration must be locked!");
}
configData = ConfigurationData.cloneData(base) as ConfigurationData;
if ((configData.collectionTemplate === "Weak/Map") ||
((configData.collectionTemplate === "Solo/Map") && (configData.weakMapKeys.length > 0))) {
retrievedBase = base;
CollectionConfiguration.#oneToOneLockedPrivateKey(base, key);
}
}
else if (typeof base === "string") {
retrievedBase = await CollectionConfiguration.#getOneToOneBaseByString(base, key);
}
if (!retrievedBase) {
throw new Error("The base configuration must be a WeakMap CollectionConfiguration, 'WeakMap', 'composite-collection/WeakStrongMap', or 'composite-collection/WeakWeakMap'!");
}
this.#configurationData.setOneToOne(key, retrievedBase, options);
});
}
static async #getOneToOneBaseByString(
baseConfiguration: OneToOneBaseString,
privateKeyName: string
) : Promise<CollectionConfiguration | symbol | null>
{
if (baseConfiguration === "WeakMap") {
return ConfigurationData.WeakMapConfiguration;
}
if (baseConfiguration === "composite-collection/WeakStrongMap") {
const config = (await import("./exports/WeakStrongMap.mjs")).default;
CollectionConfiguration.#oneToOneLockedPrivateKey(config, privateKeyName);
return config;
}
if (baseConfiguration === "composite-collection/WeakWeakMap") {
const config = (await import("./exports/WeakWeakMap.mjs")).default;
CollectionConfiguration.#oneToOneLockedPrivateKey(config, privateKeyName);
return config;
}
return null;
}
static #oneToOneLockedPrivateKey(
baseConfiguration: CollectionConfiguration,
privateKeyName: string
) : void
{
const data = ConfigurationData.cloneData(baseConfiguration) as ConfigurationData;
const weakKeys = data.weakMapKeys;
if (weakKeys.includes(privateKeyName))
return;
const names = weakKeys.map(name => `"${name}"`).join(", ");
throw new Error(`Invalid weak key name for the base configuration. Valid names are ${names}.`);
}
// #endregion One-to-one map configuration and static helpers.
lock() : void
{
return this.#stateMachine.catchErrorState(() => {
if (!this.#stateMachine.doStateTransition("locked"))
throw new Error("You must define a map key or set element first!");
if (this.#configurationData.collectionTemplate === "OneToOne/Map")
return;
if (this.#configurationData.collectionTemplate.startsWith("Weak/Map") && !this.#configurationData.weakMapKeys.length)
throw new Error("A weak map keyset must have at least one weak key!");
if (/Weak\/?Set/.test(this.#configurationData.collectionTemplate) && !this.#configurationData.weakSetElements.length)
throw new Error("A weak set keyset must have at least one weak key!");
let argCount = this.#configurationData.parameterToTypeMap.size;
if (argCount === 0) {
if (!this.#configurationData.valueType)
throw new Error("State machine error: we should have some steps now!");
argCount++;
}
if (argCount === 1) {
// Use a solo collection template.
this.#configurationData.collectionTemplate = this.#configurationData.collectionTemplate.replace(/^\w+/g, "Solo");
}
});
}
#throwIfLocked() : void
{
if (this.#stateMachine.currentState === "locked") {
throw new Error("You have already locked this configuration!");
}
}
}
Object.freeze(CollectionConfiguration);
Object.freeze(CollectionConfiguration.prototype);