@speclynx/apidom-traverse 4.0.2 → 4.0.3

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,490 @@
1
+ import { ApiDOMStructuredError } from '@speclynx/apidom-error';
2
+ import {
3
+ isElement,
4
+ isMemberElement,
5
+ isArrayElement,
6
+ isObjectElement,
7
+ cloneShallow,
8
+ type Element,
9
+ } from '@speclynx/apidom-datamodel';
10
+ import { isPromise } from 'ramda-adjunct';
11
+
12
+ import { Path } from './Path.ts';
13
+ import type { VisitorFn, VisitorResult } from './Path.ts';
14
+
15
+ /**
16
+ * Enter/leave visitor structure for a specific node type.
17
+ * @public
18
+ */
19
+ export interface NodeVisitor<TNode, TVisitor = unknown> {
20
+ enter?: VisitorFn<TNode, TVisitor>;
21
+ leave?: VisitorFn<TNode, TVisitor>;
22
+ }
23
+
24
+ // =============================================================================
25
+ // Default implementations for ApiDOM
26
+ // =============================================================================
27
+
28
+ /**
29
+ * Default node type getter - reads the `element` property and converts to Element class name.
30
+ * E.g., "string" -\> "StringElement", "openApi3_1" -\> "OpenApi3_1Element"
31
+ * @public
32
+ */
33
+ export const getNodeType = <TNode>(node: TNode): string => {
34
+ const type = (node as Element)?.element;
35
+ if (type === undefined || type === 'element') return 'Element';
36
+ return `${type.charAt(0).toUpperCase()}${type.slice(1)}Element`;
37
+ };
38
+
39
+ /**
40
+ * Alternative node type getter using primitive type.
41
+ * Returns the base element class name based on the node's primitive type.
42
+ * E.g., ContactElement (primitive='object') -\> "ObjectElement"
43
+ *
44
+ * Use this with `nodeTypeGetter` option when you want polymorphic behavior
45
+ * where specific elements fall back to their primitive type handlers.
46
+ * @public
47
+ */
48
+ export const getNodePrimitiveType = <TNode>(node: TNode): string => {
49
+ const type = (node as Element).primitive();
50
+ if (type === undefined || type === 'element') return 'Element';
51
+ return `${type.charAt(0).toUpperCase()}${type.slice(1)}Element`;
52
+ };
53
+
54
+ /**
55
+ * Default node predicate - checks if value is an ApiDOM Element.
56
+ * @public
57
+ */
58
+ export const isNode = <TNode>(value: unknown): value is TNode => isElement(value);
59
+
60
+ /**
61
+ * Default node clone function - creates a shallow clone of ApiDOM Elements.
62
+ * Uses cloneShallow from apidom-datamodel for proper handling of meta/attributes.
63
+ * @public
64
+ */
65
+ export const cloneNode = <TNode>(node: TNode): TNode => cloneShallow(node as Element) as TNode;
66
+
67
+ /**
68
+ * Default mutation function that handles ApiDOM structures.
69
+ * - MemberElement: sets parent.value
70
+ * - Arrays: sets parent[key]
71
+ * - Objects: sets parent[key] or deletes if null
72
+ * @public
73
+ */
74
+ export const mutateNode = <TNode>(parent: TNode, key: PropertyKey, value: TNode | null): void => {
75
+ if (isMemberElement(parent)) {
76
+ // MemberElement stores value in .value property
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ (parent as any).value = value;
79
+ } else if (Array.isArray(parent)) {
80
+ if (value === null) {
81
+ // For arrays, set to undefined (caller handles cleanup)
82
+ parent[key as number] = undefined as unknown as TNode;
83
+ } else {
84
+ parent[key as number] = value;
85
+ }
86
+ } else if (value === null) {
87
+ delete (parent as Record<PropertyKey, unknown>)[key];
88
+ } else {
89
+ (parent as Record<PropertyKey, unknown>)[key] = value;
90
+ }
91
+ };
92
+
93
+ /**
94
+ * Default function to get traversable keys for a node.
95
+ * Uses predicates to handle all ApiDOM element types automatically.
96
+ * @public
97
+ */
98
+ export const getNodeKeys = <TNode>(node: TNode): readonly string[] => {
99
+ if (isMemberElement(node)) return ['key', 'value'];
100
+ if (isArrayElement(node) || isObjectElement(node)) return ['content'];
101
+ return [];
102
+ };
103
+
104
+ // =============================================================================
105
+ // Visitor function resolution
106
+ // =============================================================================
107
+
108
+ /**
109
+ * Lookup by type with pipe-separated key support ("TypeA|TypeB").
110
+ * Optimized: no array allocations, uses indexOf + boundary checks.
111
+ */
112
+ const lookup = (record: Record<string, unknown>, type: string): unknown => {
113
+ // Fast path: exact match
114
+ if (record[type] !== undefined) return record[type];
115
+
116
+ // Slow path: check pipe-separated keys
117
+ const len = type.length;
118
+ for (const key in record) {
119
+ // Skip keys without pipe (most common case)
120
+ if (!key.includes('|')) continue;
121
+
122
+ const idx = key.indexOf(type);
123
+ if (idx === -1) continue;
124
+
125
+ // Verify it's a complete segment (bounded by | or string edges)
126
+ const before = idx === 0 || key[idx - 1] === '|';
127
+ const after = idx + len === key.length || key[idx + len] === '|';
128
+ if (before && after) return record[key];
129
+ }
130
+
131
+ return undefined;
132
+ };
133
+
134
+ /**
135
+ * Gets the appropriate visitor function for a node type and phase.
136
+ * Supports pipe-separated type keys like "TypeA|TypeB".
137
+ * @public
138
+ */
139
+ export const getVisitFn = <TNode>(
140
+ visitor: object,
141
+ type: string | undefined,
142
+ isLeaving: boolean,
143
+ ): VisitorFn<TNode> | null => {
144
+ if (type === undefined) return null;
145
+
146
+ const visitorRecord = visitor as Record<string, unknown>;
147
+ const phase = isLeaving ? 'leave' : 'enter';
148
+
149
+ // Pattern 1: { Type() {} } - shorthand for enter only
150
+ const typeVisitor = lookup(visitorRecord, type);
151
+ if (!isLeaving && typeof typeVisitor === 'function') {
152
+ return typeVisitor as VisitorFn<TNode>;
153
+ }
154
+
155
+ // Pattern 2: { Type: { enter, leave } }
156
+ if (typeVisitor != null) {
157
+ const phaseVisitor = (typeVisitor as NodeVisitor<TNode>)[phase];
158
+ if (typeof phaseVisitor === 'function') {
159
+ return phaseVisitor;
160
+ }
161
+ }
162
+
163
+ // Pattern 3: { enter() {}, leave() {} }
164
+ const genericVisitor = visitorRecord[phase];
165
+ if (typeof genericVisitor === 'function') {
166
+ return genericVisitor as VisitorFn<TNode>;
167
+ }
168
+
169
+ // Pattern 4: { enter: { Type() {} } }
170
+ if (genericVisitor != null) {
171
+ const typeInPhase = lookup(genericVisitor as Record<string, unknown>, type);
172
+ if (typeof typeInPhase === 'function') {
173
+ return typeInPhase as VisitorFn<TNode>;
174
+ }
175
+ }
176
+
177
+ return null;
178
+ };
179
+
180
+ // =============================================================================
181
+ // Visitor merging
182
+ // =============================================================================
183
+
184
+ /**
185
+ * Options for mergeVisitors.
186
+ * @public
187
+ */
188
+ export interface MergeVisitorsOptions<TNode> {
189
+ visitFnGetter?: typeof getVisitFn;
190
+ nodeTypeGetter?: (node: TNode) => string | undefined;
191
+ exposeEdits?: boolean;
192
+ }
193
+
194
+ /**
195
+ * Sync version of merged visitor.
196
+ * @public
197
+ */
198
+ export interface MergedVisitor<TNode> {
199
+ enter?: VisitorFn<TNode>;
200
+ leave?: VisitorFn<TNode>;
201
+ [key: string]: VisitorFn<TNode> | NodeVisitor<TNode> | undefined;
202
+ }
203
+
204
+ /**
205
+ * Async version of merged visitor.
206
+ * @public
207
+ */
208
+ export interface MergedVisitorAsync<TNode> {
209
+ enter?: VisitorFn<TNode>;
210
+ leave?: VisitorFn<TNode>;
211
+ [key: string]: VisitorFn<TNode> | NodeVisitor<TNode> | undefined;
212
+ }
213
+
214
+ /**
215
+ * Creates a new visitor instance which delegates to many visitors to run in
216
+ * parallel. Each visitor will be visited for each node before moving on.
217
+ *
218
+ * If a prior visitor edits a node, no following visitors will see that node.
219
+ * `exposeEdits=true` can be used to expose the edited node from the previous visitors.
220
+ * @public
221
+ */
222
+ export const mergeVisitors = <TNode>(
223
+ visitors: object[],
224
+ options: MergeVisitorsOptions<TNode> = {},
225
+ ): MergedVisitor<TNode> => {
226
+ const {
227
+ visitFnGetter = getVisitFn,
228
+ nodeTypeGetter = getNodeType as (node: TNode) => string | undefined,
229
+ exposeEdits = false,
230
+ } = options;
231
+
232
+ // Internal symbols for tracking visitor state
233
+ const internalSkipSymbol = Symbol('internal-skip');
234
+ const breakSymbol = Symbol('break');
235
+ // Tracks which visitors should be skipped for current subtree or permanently stopped
236
+ const skipping: (symbol | TNode)[] = new Array(visitors.length).fill(internalSkipSymbol);
237
+
238
+ return {
239
+ enter(path: Path<TNode>): VisitorResult<TNode> {
240
+ let currentNode = path.node;
241
+ let hasChanged = false;
242
+
243
+ for (let i = 0; i < visitors.length; i += 1) {
244
+ if (skipping[i] === internalSkipSymbol) {
245
+ const visitFn = visitFnGetter<TNode>(visitors[i], nodeTypeGetter(currentNode), false);
246
+
247
+ if (typeof visitFn === 'function') {
248
+ // Create a proxy path that tracks changes per-visitor
249
+ const proxyPath = createPathProxy(path, currentNode);
250
+ const result = visitFn.call(visitors[i], proxyPath);
251
+
252
+ // Check if the visitor is async
253
+ if (isPromise(result)) {
254
+ throw new ApiDOMStructuredError('Async visitor not supported in sync mode', {
255
+ visitor: visitors[i],
256
+ visitFn,
257
+ });
258
+ }
259
+
260
+ // Handle path-based control flow
261
+ if (proxyPath.shouldStop) {
262
+ skipping[i] = breakSymbol;
263
+ break;
264
+ }
265
+
266
+ if (proxyPath.shouldSkip) {
267
+ skipping[i] = currentNode;
268
+ }
269
+
270
+ if (proxyPath.removed) {
271
+ path.remove();
272
+ return undefined;
273
+ }
274
+
275
+ if (proxyPath._wasReplaced()) {
276
+ const replacement = proxyPath._getReplacementNode()!;
277
+ if (exposeEdits) {
278
+ currentNode = replacement;
279
+ hasChanged = true;
280
+ } else {
281
+ path.replaceWith(replacement);
282
+ return replacement;
283
+ }
284
+ } else if (result !== undefined) {
285
+ // Support return value replacement for backwards compatibility
286
+ if (exposeEdits) {
287
+ currentNode = result as TNode;
288
+ hasChanged = true;
289
+ } else {
290
+ path.replaceWith(result as TNode);
291
+ return result;
292
+ }
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ if (hasChanged) {
299
+ path.replaceWith(currentNode);
300
+ return currentNode;
301
+ }
302
+
303
+ return undefined;
304
+ },
305
+
306
+ leave(path: Path<TNode>): VisitorResult<TNode> {
307
+ const currentNode = path.node;
308
+
309
+ for (let i = 0; i < visitors.length; i += 1) {
310
+ if (skipping[i] === internalSkipSymbol) {
311
+ const visitFn = visitFnGetter<TNode>(visitors[i], nodeTypeGetter(currentNode), true);
312
+
313
+ if (typeof visitFn === 'function') {
314
+ // Create a proxy path for leave phase
315
+ const proxyPath = createPathProxy(path, currentNode);
316
+ const result = visitFn.call(visitors[i], proxyPath);
317
+
318
+ // Check if the visitor is async
319
+ if (isPromise(result)) {
320
+ throw new ApiDOMStructuredError('Async visitor not supported in sync mode', {
321
+ visitor: visitors[i],
322
+ visitFn,
323
+ });
324
+ }
325
+
326
+ // Handle path-based control flow
327
+ if (proxyPath.shouldStop) {
328
+ skipping[i] = breakSymbol;
329
+ break;
330
+ }
331
+
332
+ if (proxyPath.removed) {
333
+ path.remove();
334
+ return undefined;
335
+ }
336
+
337
+ if (proxyPath._wasReplaced()) {
338
+ const replacement = proxyPath._getReplacementNode()!;
339
+ path.replaceWith(replacement);
340
+ return replacement;
341
+ } else if (result !== undefined) {
342
+ path.replaceWith(result as TNode);
343
+ return result;
344
+ }
345
+ }
346
+ } else if (skipping[i] === currentNode) {
347
+ // Reset skip state when leaving the node that was skipped
348
+ skipping[i] = internalSkipSymbol;
349
+ }
350
+ }
351
+
352
+ return undefined;
353
+ },
354
+ };
355
+ };
356
+
357
+ /**
358
+ * Async version of mergeVisitors.
359
+ * @public
360
+ */
361
+ export const mergeVisitorsAsync = <TNode>(
362
+ visitors: object[],
363
+ options: MergeVisitorsOptions<TNode> = {},
364
+ ): MergedVisitorAsync<TNode> => {
365
+ const {
366
+ visitFnGetter = getVisitFn,
367
+ nodeTypeGetter = getNodeType as (node: TNode) => string | undefined,
368
+ exposeEdits = false,
369
+ } = options;
370
+
371
+ const internalSkipSymbol = Symbol('internal-skip');
372
+ const breakSymbol = Symbol('break');
373
+ const skipping: (symbol | TNode)[] = new Array(visitors.length).fill(internalSkipSymbol);
374
+
375
+ return {
376
+ async enter(path: Path<TNode>): Promise<void | TNode | undefined> {
377
+ let currentNode = path.node;
378
+ let hasChanged = false;
379
+
380
+ for (let i = 0; i < visitors.length; i += 1) {
381
+ if (skipping[i] === internalSkipSymbol) {
382
+ const visitFn = visitFnGetter<TNode>(visitors[i], nodeTypeGetter(currentNode), false);
383
+
384
+ if (typeof visitFn === 'function') {
385
+ const proxyPath = createPathProxy(path, currentNode);
386
+ const result = await visitFn.call(visitors[i], proxyPath);
387
+
388
+ if (proxyPath.shouldStop) {
389
+ skipping[i] = breakSymbol;
390
+ break;
391
+ }
392
+
393
+ if (proxyPath.shouldSkip) {
394
+ skipping[i] = currentNode;
395
+ }
396
+
397
+ if (proxyPath.removed) {
398
+ path.remove();
399
+ return undefined;
400
+ }
401
+
402
+ if (proxyPath._wasReplaced()) {
403
+ const replacement = proxyPath._getReplacementNode()!;
404
+ if (exposeEdits) {
405
+ currentNode = replacement;
406
+ hasChanged = true;
407
+ } else {
408
+ path.replaceWith(replacement);
409
+ return replacement;
410
+ }
411
+ } else if (result !== undefined) {
412
+ if (exposeEdits) {
413
+ currentNode = result as TNode;
414
+ hasChanged = true;
415
+ } else {
416
+ path.replaceWith(result as TNode);
417
+ return result;
418
+ }
419
+ }
420
+ }
421
+ }
422
+ }
423
+
424
+ if (hasChanged) {
425
+ path.replaceWith(currentNode);
426
+ return currentNode;
427
+ }
428
+
429
+ return undefined;
430
+ },
431
+
432
+ async leave(path: Path<TNode>): Promise<void | TNode | undefined> {
433
+ const currentNode = path.node;
434
+
435
+ for (let i = 0; i < visitors.length; i += 1) {
436
+ if (skipping[i] === internalSkipSymbol) {
437
+ const visitFn = visitFnGetter<TNode>(visitors[i], nodeTypeGetter(currentNode), true);
438
+
439
+ if (typeof visitFn === 'function') {
440
+ const proxyPath = createPathProxy(path, currentNode);
441
+
442
+ const result = await visitFn.call(visitors[i], proxyPath);
443
+
444
+ if (proxyPath.shouldStop) {
445
+ skipping[i] = breakSymbol;
446
+ break;
447
+ }
448
+
449
+ if (proxyPath.removed) {
450
+ path.remove();
451
+ return undefined;
452
+ }
453
+
454
+ if (proxyPath._wasReplaced()) {
455
+ const replacement = proxyPath._getReplacementNode()!;
456
+ path.replaceWith(replacement);
457
+ return replacement;
458
+ } else if (result !== undefined) {
459
+ path.replaceWith(result as TNode);
460
+ return result;
461
+ }
462
+ }
463
+ } else if (skipping[i] === currentNode) {
464
+ skipping[i] = internalSkipSymbol;
465
+ }
466
+ }
467
+
468
+ return undefined;
469
+ },
470
+ };
471
+ };
472
+
473
+ // Attach async version for promisify compatibility
474
+ (mergeVisitors as unknown as Record<symbol, unknown>)[Symbol.for('nodejs.util.promisify.custom')] =
475
+ mergeVisitorsAsync;
476
+
477
+ /**
478
+ * Creates a proxy Path that allows individual visitors to track their own
479
+ * control flow state without affecting the original Path.
480
+ * @internal
481
+ */
482
+ function createPathProxy<TNode>(originalPath: Path<TNode>, currentNode: TNode): Path<TNode> {
483
+ return new Path<TNode>(
484
+ currentNode,
485
+ originalPath.parent,
486
+ originalPath.parentPath,
487
+ originalPath.key,
488
+ originalPath.inList,
489
+ );
490
+ }