@speclynx/apidom-traverse 1.12.2

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