@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.
package/src/Path.mjs ADDED
@@ -0,0 +1,338 @@
1
+ import { isMemberElement, isArrayElement, isStringElement } from '@speclynx/apidom-datamodel';
2
+ import { compile as compileJSONPointer } from '@speclynx/apidom-json-pointer';
3
+ import { NormalizedPath } from '@speclynx/apidom-json-path';
4
+
5
+ /**
6
+ * Possible return values from a visitor function.
7
+ * @public
8
+ */
9
+
10
+ /**
11
+ * Visitor function signature - receives a Path object.
12
+ * @public
13
+ */
14
+
15
+ /**
16
+ * Path represents a node's position in the tree during traversal.
17
+ * Inspired by Babel's NodePath API.
18
+ * @public
19
+ */
20
+ export class Path {
21
+ /**
22
+ * The current AST node.
23
+ */
24
+ node;
25
+
26
+ /**
27
+ * The key of this node in its parent.
28
+ * `undefined` for the root node.
29
+ */
30
+ key;
31
+
32
+ /**
33
+ * The index if this node is in an array.
34
+ * Same as `key` when parent property is an array, `undefined` otherwise.
35
+ */
36
+ index;
37
+
38
+ /**
39
+ * The parent node.
40
+ * `undefined` for the root node.
41
+ */
42
+ parent;
43
+
44
+ /**
45
+ * The parent Path.
46
+ * `null` for the root node.
47
+ */
48
+ parentPath;
49
+
50
+ /**
51
+ * Whether this node is inside an array in the parent.
52
+ */
53
+ inList;
54
+
55
+ /**
56
+ * Internal state for traversal control.
57
+ */
58
+ #shouldSkip = false;
59
+ #shouldStop = false;
60
+ #removed = false;
61
+ #replaced = false;
62
+ #replacementNode;
63
+ #stale = false;
64
+ constructor(node, parent, parentPath, key, inList) {
65
+ this.node = node;
66
+ this.parent = parent;
67
+ this.parentPath = parentPath;
68
+ this.key = key;
69
+ this.index = inList && typeof key === 'number' ? key : undefined;
70
+ this.inList = inList;
71
+ }
72
+
73
+ // ===========================================================================
74
+ // Traversal state
75
+ // ===========================================================================
76
+
77
+ /**
78
+ * Whether skip() was called on this path.
79
+ */
80
+ get shouldSkip() {
81
+ return this.#shouldSkip;
82
+ }
83
+
84
+ /**
85
+ * Whether stop() was called on this path.
86
+ */
87
+ get shouldStop() {
88
+ return this.#shouldStop;
89
+ }
90
+
91
+ /**
92
+ * Whether this node was removed.
93
+ */
94
+ get removed() {
95
+ return this.#removed;
96
+ }
97
+
98
+ // ===========================================================================
99
+ // Ancestry
100
+ // ===========================================================================
101
+
102
+ /**
103
+ * Returns true if this is the root path.
104
+ */
105
+ isRoot() {
106
+ return this.parentPath === null;
107
+ }
108
+
109
+ /**
110
+ * Get the depth of this path (0 for root).
111
+ */
112
+ get depth() {
113
+ let depth = 0;
114
+ let current = this.parentPath;
115
+ while (current !== null) {
116
+ depth += 1;
117
+ current = current.parentPath;
118
+ }
119
+ return depth;
120
+ }
121
+
122
+ /**
123
+ * Get all ancestor paths from immediate parent to root.
124
+ */
125
+ getAncestry() {
126
+ const ancestry = [];
127
+ let current = this.parentPath;
128
+ while (current !== null) {
129
+ ancestry.push(current);
130
+ current = current.parentPath;
131
+ }
132
+ return ancestry;
133
+ }
134
+
135
+ /**
136
+ * Get all ancestor nodes from immediate parent to root.
137
+ */
138
+ getAncestorNodes() {
139
+ return this.getAncestry().map(p => p.node);
140
+ }
141
+
142
+ /**
143
+ * Get the semantic path from root as an array of keys.
144
+ * Returns logical document keys (property names, array indices) rather than
145
+ * internal ApiDOM structure keys.
146
+ *
147
+ * @example
148
+ * // For a path to $.paths['/pets'].get in an OpenAPI document:
149
+ * ```
150
+ * path.getPathKeys(); // => ['paths', '/pets', 'get']
151
+ * ```
152
+ */
153
+ getPathKeys() {
154
+ const keys = [];
155
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
156
+ let current = this;
157
+ while (current !== null && current.key !== undefined) {
158
+ const {
159
+ key,
160
+ parent,
161
+ parentPath
162
+ } = current;
163
+ if (isMemberElement(parent) && key === 'value') {
164
+ // Inside MemberElement.value → push the member's key
165
+ if (!isStringElement(parent.key)) {
166
+ throw new TypeError('MemberElement.key must be a StringElement');
167
+ }
168
+ keys.unshift(parent.key.toValue());
169
+ } else if (isArrayElement(parentPath?.node) && typeof key === 'number') {
170
+ // Inside ArrayElement → push the numeric index
171
+ keys.unshift(key);
172
+ }
173
+ current = current.parentPath;
174
+ }
175
+ return keys;
176
+ }
177
+
178
+ /**
179
+ * Format path as RFC 6901 JSON Pointer or RFC 9535 Normalized JSONPath.
180
+ *
181
+ * @param pathFormat - Output format: "jsonpointer" (default) or "jsonpath"
182
+ * @returns JSONPointer string like "/paths/~1pets/get/responses/200"
183
+ * or Normalized JSONPath like "$['paths']['/pets']['get']['responses']['200']"
184
+ *
185
+ * @example
186
+ * ```
187
+ * // JSON Pointer examples:
188
+ * path.formatPath(); // "" (root)
189
+ * path.formatPath(); // "/info"
190
+ * path.formatPath(); // "/paths/~1pets/get"
191
+ * path.formatPath(); // "/paths/~1users~1{id}/parameters/0"
192
+ * ```
193
+ *
194
+ * @example
195
+ * ```
196
+ * // JSONPath examples:
197
+ * path.formatPath('jsonpath'); // "$" (root)
198
+ * path.formatPath('jsonpath'); // "$['info']"
199
+ * path.formatPath('jsonpath'); // "$['paths']['/pets']['get']"
200
+ * path.formatPath('jsonpath'); // "$['paths']['/users/{id}']['parameters'][0]"
201
+ * ```
202
+ */
203
+ formatPath(pathFormat = 'jsonpointer') {
204
+ const parts = this.getPathKeys();
205
+
206
+ // Root node
207
+ if (parts.length === 0) {
208
+ return pathFormat === 'jsonpath' ? '$' : '';
209
+ }
210
+ if (pathFormat === 'jsonpath') {
211
+ // RFC 9535 Normalized JSONPath
212
+ return NormalizedPath.from(parts);
213
+ }
214
+
215
+ // RFC 6901 JSON Pointer
216
+ return compileJSONPointer(parts);
217
+ }
218
+
219
+ /**
220
+ * Find the closest ancestor path that satisfies the predicate.
221
+ */
222
+ findParent(predicate) {
223
+ let current = this.parentPath;
224
+ while (current !== null) {
225
+ if (predicate(current)) {
226
+ return current;
227
+ }
228
+ current = current.parentPath;
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * Find the closest path (including this one) that satisfies the predicate.
235
+ */
236
+ find(predicate) {
237
+ if (predicate(this)) {
238
+ return this;
239
+ }
240
+ return this.findParent(predicate);
241
+ }
242
+
243
+ // ===========================================================================
244
+ // Nested traversal
245
+ // ===========================================================================
246
+
247
+ /**
248
+ * Traverse into the current node with a new visitor.
249
+ * Populated by the traversal module to avoid circular imports.
250
+ */
251
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
252
+
253
+ /**
254
+ * Async version of traverse.
255
+ */
256
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
257
+
258
+ // ===========================================================================
259
+ // Traversal control
260
+ // ===========================================================================
261
+
262
+ /**
263
+ * Skip traversing the children of this node.
264
+ */
265
+ skip() {
266
+ this.#shouldSkip = true;
267
+ }
268
+
269
+ /**
270
+ * Stop all traversal completely.
271
+ */
272
+ stop() {
273
+ this.#shouldStop = true;
274
+ }
275
+
276
+ // ===========================================================================
277
+ // Modification
278
+ // ===========================================================================
279
+
280
+ /**
281
+ * Replace this node with a new node.
282
+ */
283
+ replaceWith(replacement) {
284
+ if (this.#stale) {
285
+ console.warn('Warning: replaceWith() called on a stale Path. ' + 'This path belongs to a node whose visit has already completed. ' + 'The replacement will have no effect. ' + "To replace a parent node, do so from the parent's own visitor.");
286
+ }
287
+ this.#replaced = true;
288
+ this.#replacementNode = replacement;
289
+ this.node = replacement;
290
+ }
291
+
292
+ /**
293
+ * Remove this node from the tree.
294
+ */
295
+ remove() {
296
+ if (this.#stale) {
297
+ console.warn('Warning: remove() called on a stale Path. ' + 'This path belongs to a node whose visit has already completed. ' + 'The removal will have no effect. ' + "To remove a parent node, do so from the parent's own visitor.");
298
+ }
299
+ this.#removed = true;
300
+ }
301
+
302
+ // ===========================================================================
303
+ // Internal methods for traversal engine
304
+ // ===========================================================================
305
+
306
+ /**
307
+ * @internal
308
+ */
309
+ _getReplacementNode() {
310
+ return this.#replacementNode;
311
+ }
312
+
313
+ /**
314
+ * @internal
315
+ */
316
+ _wasReplaced() {
317
+ return this.#replaced;
318
+ }
319
+
320
+ /**
321
+ * @internal
322
+ */
323
+ _reset() {
324
+ this.#shouldSkip = false;
325
+ this.#shouldStop = false;
326
+ this.#removed = false;
327
+ this.#replaced = false;
328
+ this.#replacementNode = undefined;
329
+ }
330
+
331
+ /**
332
+ * Mark this path as stale (visit completed).
333
+ * @internal
334
+ */
335
+ _markStale() {
336
+ this.#stale = true;
337
+ }
338
+ }
package/src/Path.ts ADDED
@@ -0,0 +1,368 @@
1
+ import {
2
+ isMemberElement,
3
+ isArrayElement,
4
+ isStringElement,
5
+ type Element,
6
+ } from '@speclynx/apidom-datamodel';
7
+ import { compile as compileJSONPointer } from '@speclynx/apidom-json-pointer';
8
+ import { NormalizedPath } from '@speclynx/apidom-json-path';
9
+
10
+ /**
11
+ * Possible return values from a visitor function.
12
+ * @public
13
+ */
14
+ export type VisitorResult<TNode> = void | undefined | TNode | Promise<void | undefined | TNode>;
15
+
16
+ /**
17
+ * Visitor function signature - receives a Path object.
18
+ * @public
19
+ */
20
+ export type VisitorFn<TNode, TVisitor = unknown> = (
21
+ this: TVisitor,
22
+ path: Path<TNode>,
23
+ ) => VisitorResult<TNode>;
24
+
25
+ /**
26
+ * Path represents a node's position in the tree during traversal.
27
+ * Inspired by Babel's NodePath API.
28
+ * @public
29
+ */
30
+ export class Path<TNode = Element> {
31
+ /**
32
+ * The current AST node.
33
+ */
34
+ public node: TNode;
35
+
36
+ /**
37
+ * The key of this node in its parent.
38
+ * `undefined` for the root node.
39
+ */
40
+ public readonly key: PropertyKey | undefined;
41
+
42
+ /**
43
+ * The index if this node is in an array.
44
+ * Same as `key` when parent property is an array, `undefined` otherwise.
45
+ */
46
+ public readonly index: number | undefined;
47
+
48
+ /**
49
+ * The parent node.
50
+ * `undefined` for the root node.
51
+ */
52
+ public readonly parent: TNode | undefined;
53
+
54
+ /**
55
+ * The parent Path.
56
+ * `null` for the root node.
57
+ */
58
+ public readonly parentPath: Path<TNode> | null;
59
+
60
+ /**
61
+ * Whether this node is inside an array in the parent.
62
+ */
63
+ public readonly inList: boolean;
64
+
65
+ /**
66
+ * Internal state for traversal control.
67
+ */
68
+ #shouldSkip: boolean = false;
69
+ #shouldStop: boolean = false;
70
+ #removed: boolean = false;
71
+ #replaced: boolean = false;
72
+ #replacementNode: TNode | undefined;
73
+ #stale: boolean = false;
74
+
75
+ constructor(
76
+ node: TNode,
77
+ parent: TNode | undefined,
78
+ parentPath: Path<TNode> | null,
79
+ key: PropertyKey | undefined,
80
+ inList: boolean,
81
+ ) {
82
+ this.node = node;
83
+ this.parent = parent;
84
+ this.parentPath = parentPath;
85
+ this.key = key;
86
+ this.index = inList && typeof key === 'number' ? key : undefined;
87
+ this.inList = inList;
88
+ }
89
+
90
+ // ===========================================================================
91
+ // Traversal state
92
+ // ===========================================================================
93
+
94
+ /**
95
+ * Whether skip() was called on this path.
96
+ */
97
+ get shouldSkip(): boolean {
98
+ return this.#shouldSkip;
99
+ }
100
+
101
+ /**
102
+ * Whether stop() was called on this path.
103
+ */
104
+ get shouldStop(): boolean {
105
+ return this.#shouldStop;
106
+ }
107
+
108
+ /**
109
+ * Whether this node was removed.
110
+ */
111
+ get removed(): boolean {
112
+ return this.#removed;
113
+ }
114
+
115
+ // ===========================================================================
116
+ // Ancestry
117
+ // ===========================================================================
118
+
119
+ /**
120
+ * Returns true if this is the root path.
121
+ */
122
+ isRoot(): boolean {
123
+ return this.parentPath === null;
124
+ }
125
+
126
+ /**
127
+ * Get the depth of this path (0 for root).
128
+ */
129
+ get depth(): number {
130
+ let depth = 0;
131
+ let current: Path<TNode> | null = this.parentPath;
132
+ while (current !== null) {
133
+ depth += 1;
134
+ current = current.parentPath;
135
+ }
136
+ return depth;
137
+ }
138
+
139
+ /**
140
+ * Get all ancestor paths from immediate parent to root.
141
+ */
142
+ getAncestry(): Path<TNode>[] {
143
+ const ancestry: Path<TNode>[] = [];
144
+ let current: Path<TNode> | null = this.parentPath;
145
+ while (current !== null) {
146
+ ancestry.push(current);
147
+ current = current.parentPath;
148
+ }
149
+ return ancestry;
150
+ }
151
+
152
+ /**
153
+ * Get all ancestor nodes from immediate parent to root.
154
+ */
155
+ getAncestorNodes(): TNode[] {
156
+ return this.getAncestry().map((p) => p.node);
157
+ }
158
+
159
+ /**
160
+ * Get the semantic path from root as an array of keys.
161
+ * Returns logical document keys (property names, array indices) rather than
162
+ * internal ApiDOM structure keys.
163
+ *
164
+ * @example
165
+ * // For a path to $.paths['/pets'].get in an OpenAPI document:
166
+ * ```
167
+ * path.getPathKeys(); // => ['paths', '/pets', 'get']
168
+ * ```
169
+ */
170
+ getPathKeys(): PropertyKey[] {
171
+ const keys: PropertyKey[] = [];
172
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
173
+ let current: Path<TNode> | null = this;
174
+
175
+ while (current !== null && current.key !== undefined) {
176
+ const { key, parent, parentPath } = current;
177
+
178
+ if (isMemberElement(parent) && key === 'value') {
179
+ // Inside MemberElement.value → push the member's key
180
+ if (!isStringElement(parent.key)) {
181
+ throw new TypeError('MemberElement.key must be a StringElement');
182
+ }
183
+ keys.unshift(parent.key.toValue() as PropertyKey);
184
+ } else if (isArrayElement(parentPath?.node) && typeof key === 'number') {
185
+ // Inside ArrayElement → push the numeric index
186
+ keys.unshift(key);
187
+ }
188
+
189
+ current = current.parentPath;
190
+ }
191
+
192
+ return keys;
193
+ }
194
+
195
+ /**
196
+ * Format path as RFC 6901 JSON Pointer or RFC 9535 Normalized JSONPath.
197
+ *
198
+ * @param pathFormat - Output format: "jsonpointer" (default) or "jsonpath"
199
+ * @returns JSONPointer string like "/paths/~1pets/get/responses/200"
200
+ * or Normalized JSONPath like "$['paths']['/pets']['get']['responses']['200']"
201
+ *
202
+ * @example
203
+ * ```
204
+ * // JSON Pointer examples:
205
+ * path.formatPath(); // "" (root)
206
+ * path.formatPath(); // "/info"
207
+ * path.formatPath(); // "/paths/~1pets/get"
208
+ * path.formatPath(); // "/paths/~1users~1{id}/parameters/0"
209
+ * ```
210
+ *
211
+ * @example
212
+ * ```
213
+ * // JSONPath examples:
214
+ * path.formatPath('jsonpath'); // "$" (root)
215
+ * path.formatPath('jsonpath'); // "$['info']"
216
+ * path.formatPath('jsonpath'); // "$['paths']['/pets']['get']"
217
+ * path.formatPath('jsonpath'); // "$['paths']['/users/{id}']['parameters'][0]"
218
+ * ```
219
+ */
220
+ formatPath(pathFormat: 'jsonpointer' | 'jsonpath' = 'jsonpointer'): string {
221
+ const parts = this.getPathKeys();
222
+
223
+ // Root node
224
+ if (parts.length === 0) {
225
+ return pathFormat === 'jsonpath' ? '$' : '';
226
+ }
227
+
228
+ if (pathFormat === 'jsonpath') {
229
+ // RFC 9535 Normalized JSONPath
230
+ return NormalizedPath.from(parts as (string | number)[]);
231
+ }
232
+
233
+ // RFC 6901 JSON Pointer
234
+ return compileJSONPointer(parts as string[]);
235
+ }
236
+
237
+ /**
238
+ * Find the closest ancestor path that satisfies the predicate.
239
+ */
240
+ findParent(predicate: (path: Path<TNode>) => boolean): Path<TNode> | null {
241
+ let current: Path<TNode> | null = this.parentPath;
242
+ while (current !== null) {
243
+ if (predicate(current)) {
244
+ return current;
245
+ }
246
+ current = current.parentPath;
247
+ }
248
+ return null;
249
+ }
250
+
251
+ /**
252
+ * Find the closest path (including this one) that satisfies the predicate.
253
+ */
254
+ find(predicate: (path: Path<TNode>) => boolean): Path<TNode> | null {
255
+ if (predicate(this)) {
256
+ return this;
257
+ }
258
+ return this.findParent(predicate);
259
+ }
260
+
261
+ // ===========================================================================
262
+ // Nested traversal
263
+ // ===========================================================================
264
+
265
+ /**
266
+ * Traverse into the current node with a new visitor.
267
+ * Populated by the traversal module to avoid circular imports.
268
+ */
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
270
+ declare traverse: (visitor: any, options?: any) => TNode;
271
+
272
+ /**
273
+ * Async version of traverse.
274
+ */
275
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
+ declare traverseAsync: (visitor: any, options?: any) => Promise<TNode>;
277
+
278
+ // ===========================================================================
279
+ // Traversal control
280
+ // ===========================================================================
281
+
282
+ /**
283
+ * Skip traversing the children of this node.
284
+ */
285
+ skip(): void {
286
+ this.#shouldSkip = true;
287
+ }
288
+
289
+ /**
290
+ * Stop all traversal completely.
291
+ */
292
+ stop(): void {
293
+ this.#shouldStop = true;
294
+ }
295
+
296
+ // ===========================================================================
297
+ // Modification
298
+ // ===========================================================================
299
+
300
+ /**
301
+ * Replace this node with a new node.
302
+ */
303
+ replaceWith(replacement: TNode): void {
304
+ if (this.#stale) {
305
+ console.warn(
306
+ 'Warning: replaceWith() called on a stale Path. ' +
307
+ 'This path belongs to a node whose visit has already completed. ' +
308
+ 'The replacement will have no effect. ' +
309
+ "To replace a parent node, do so from the parent's own visitor.",
310
+ );
311
+ }
312
+ this.#replaced = true;
313
+ this.#replacementNode = replacement;
314
+ this.node = replacement;
315
+ }
316
+
317
+ /**
318
+ * Remove this node from the tree.
319
+ */
320
+ remove(): void {
321
+ if (this.#stale) {
322
+ console.warn(
323
+ 'Warning: remove() called on a stale Path. ' +
324
+ 'This path belongs to a node whose visit has already completed. ' +
325
+ 'The removal will have no effect. ' +
326
+ "To remove a parent node, do so from the parent's own visitor.",
327
+ );
328
+ }
329
+ this.#removed = true;
330
+ }
331
+
332
+ // ===========================================================================
333
+ // Internal methods for traversal engine
334
+ // ===========================================================================
335
+
336
+ /**
337
+ * @internal
338
+ */
339
+ _getReplacementNode(): TNode | undefined {
340
+ return this.#replacementNode;
341
+ }
342
+
343
+ /**
344
+ * @internal
345
+ */
346
+ _wasReplaced(): boolean {
347
+ return this.#replaced;
348
+ }
349
+
350
+ /**
351
+ * @internal
352
+ */
353
+ _reset(): void {
354
+ this.#shouldSkip = false;
355
+ this.#shouldStop = false;
356
+ this.#removed = false;
357
+ this.#replaced = false;
358
+ this.#replacementNode = undefined;
359
+ }
360
+
361
+ /**
362
+ * Mark this path as stale (visit completed).
363
+ * @internal
364
+ */
365
+ _markStale(): void {
366
+ this.#stale = true;
367
+ }
368
+ }