Skip to content

Commit 0f1f73e

Browse files
committed
feat: Allow for pathlike aliases, à la *foo/42/bar
1 parent 5e5470a commit 0f1f73e

File tree

4 files changed

+120
-32
lines changed

4 files changed

+120
-32
lines changed

src/doc/Document.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -424,11 +424,14 @@ export class Document<T extends Node = Node> {
424424
mapAsMap: mapAsMap === true,
425425
mapKeyWarned: false,
426426
maxAliasCount: typeof maxAliasCount === 'number' ? maxAliasCount : 100,
427+
resolved: new WeakMap(),
427428
stringify
428429
}
429430
const res = toJS(this.contents, jsonArg ?? '', ctx)
430-
if (typeof onAnchor === 'function')
431-
for (const { count, res } of ctx.anchors.values()) onAnchor(res, count)
431+
if (typeof onAnchor === 'function') {
432+
for (const [node, { count }] of ctx.anchors)
433+
onAnchor(ctx.resolved.get(node), count)
434+
}
432435
return typeof reviver === 'function'
433436
? applyReviver(reviver, { '': res }, '', res)
434437
: res

src/nodes/Alias.ts

+50-21
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isAlias,
99
isCollection,
1010
isPair,
11+
isScalar,
1112
Node,
1213
NodeBase,
1314
Range
@@ -24,6 +25,8 @@ export declare namespace Alias {
2425
}
2526
}
2627

28+
const RESOLVE = Symbol('_resolve')
29+
2730
export class Alias extends NodeBase {
2831
source: string
2932

@@ -39,46 +42,72 @@ export class Alias extends NodeBase {
3942
})
4043
}
4144

42-
/**
43-
* Resolve the value of this alias within `doc`, finding the last
44-
* instance of the `source` anchor before this node.
45-
*/
46-
resolve(doc: Document): Scalar | YAMLMap | YAMLSeq | undefined {
45+
[RESOLVE](doc: Document) {
4746
let found: Scalar | YAMLMap | YAMLSeq | undefined = undefined
47+
// @ts-expect-error - TS doesn't notice the assignment in the visitor
48+
let root: Node & { anchor: string } = undefined
49+
const pathLike = this.source.includes('/')
4850
visit(doc, {
4951
Node: (_key: unknown, node: Node) => {
5052
if (node === this) return visit.BREAK
51-
if (node.anchor === this.source) found = node
53+
const { anchor } = node
54+
if (anchor === this.source) {
55+
found = node
56+
} else if (
57+
doc.directives?.yaml.version === 'next' &&
58+
anchor &&
59+
pathLike &&
60+
this.source.startsWith(anchor + '/') &&
61+
(!root || root.anchor.length <= anchor.length)
62+
) {
63+
root = node as Node & { anchor: string }
64+
}
5265
}
5366
})
54-
return found
67+
if (found) return { node: found, root: found }
68+
69+
if (isCollection(root)) {
70+
const parts = this.source.substring(root.anchor.length + 1).split('/')
71+
const node = root.getIn(parts, true)
72+
if (isCollection(node) || isScalar(node)) return { node, root }
73+
}
74+
75+
return { node: undefined, root }
76+
}
77+
78+
/**
79+
* Resolve the value of this alias within `doc`, finding the last
80+
* instance of the `source` anchor before this node.
81+
*/
82+
resolve(doc: Document): Scalar | YAMLMap | YAMLSeq | undefined {
83+
return this[RESOLVE](doc).node
5584
}
5685

5786
toJSON(_arg?: unknown, ctx?: ToJSContext) {
5887
if (!ctx) return { source: this.source }
59-
const { anchors, doc, maxAliasCount } = ctx
60-
const source = this.resolve(doc)
61-
if (!source) {
88+
const { anchors, doc, maxAliasCount, resolved } = ctx
89+
const { node, root } = this[RESOLVE](doc)
90+
if (!node) {
6291
const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}`
6392
throw new ReferenceError(msg)
6493
}
65-
const data = anchors.get(source)
66-
/* istanbul ignore if */
67-
if (!data || data.res === undefined) {
68-
const msg = 'This should not happen: Alias anchor was not resolved?'
69-
throw new ReferenceError(msg)
70-
}
94+
7195
if (maxAliasCount >= 0) {
96+
const data = anchors.get(root)
97+
if (!data) {
98+
const msg = 'This should not happen: Alias anchor was not resolved?'
99+
throw new ReferenceError(msg)
100+
}
72101
data.count += 1
73-
if (data.aliasCount === 0)
74-
data.aliasCount = getAliasCount(doc, source, anchors)
102+
data.aliasCount ||= getAliasCount(doc, root, anchors)
75103
if (data.count * data.aliasCount > maxAliasCount) {
76104
const msg =
77105
'Excessive alias count indicates a resource exhaustion attack'
78106
throw new ReferenceError(msg)
79107
}
80108
}
81-
return data.res
109+
110+
return resolved.get(node)
82111
}
83112

84113
toString(
@@ -105,8 +134,8 @@ function getAliasCount(
105134
anchors: ToJSContext['anchors']
106135
): number {
107136
if (isAlias(node)) {
108-
const source = node.resolve(doc)
109-
const anchor = anchors && source && anchors.get(source)
137+
const { root } = node[RESOLVE](doc)
138+
const anchor = root && anchors?.get(root)
110139
return anchor ? anchor.count * anchor.aliasCount : 0
111140
} else if (isCollection(node)) {
112141
let count = 0

src/nodes/toJS.ts

+10-9
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { hasAnchor, Node } from './Node.js'
55
export interface AnchorData {
66
aliasCount: number
77
count: number
8-
res: unknown
98
}
109

1110
export interface ToJSContext {
@@ -16,6 +15,7 @@ export interface ToJSContext {
1615
mapKeyWarned: boolean
1716
maxAliasCount: number
1817
onCreate?: (res: unknown) => void
18+
resolved: WeakMap<Node, unknown>
1919

2020
/** Requiring this directly in Pair would create circular dependencies */
2121
stringify: typeof stringify
@@ -35,16 +35,17 @@ export function toJS(value: any, arg: string | null, ctx?: ToJSContext): any {
3535
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
3636
if (Array.isArray(value)) return value.map((v, i) => toJS(v, String(i), ctx))
3737
if (value && typeof value.toJSON === 'function') {
38-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
39-
if (!ctx || !hasAnchor(value)) return value.toJSON(arg, ctx)
40-
const data: AnchorData = { aliasCount: 0, count: 1, res: undefined }
41-
ctx.anchors.set(value, data)
42-
ctx.onCreate = res => {
43-
data.res = res
44-
delete ctx.onCreate
38+
if (ctx) {
39+
if (hasAnchor(value)) ctx.anchors.set(value, { aliasCount: 0, count: 1 })
40+
ctx.onCreate = res => {
41+
ctx.onCreate = undefined
42+
ctx.resolved.set(value, res)
43+
}
4544
}
45+
46+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
4647
const res = value.toJSON(arg, ctx)
47-
if (ctx.onCreate) ctx.onCreate(res)
48+
if (ctx?.onCreate) ctx.onCreate(res)
4849
return res
4950
}
5051
if (typeof value === 'bigint' && !ctx?.keep) return Number(value)

tests/next.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { parse, parseDocument } from 'yaml'
2+
import { source } from './_utils'
3+
4+
describe('relative-path alias', () => {
5+
test('resolves a map value by key', () => {
6+
const src = source`
7+
- &a { foo: 1 }
8+
- *a/foo
9+
`
10+
expect(parse(src, { version: 'next' })).toEqual([{ foo: 1 }, 1])
11+
})
12+
13+
test('resolves a sequence value by index', () => {
14+
const src = source`
15+
- &a [ 2, 4, 8 ]
16+
- *a/1
17+
`
18+
expect(parse(src, { version: 'next' })).toEqual([[2, 4, 8], 4])
19+
})
20+
21+
test('resolves a deeper value', () => {
22+
const src = source`
23+
- &a { foo: [1, 42] }
24+
- *a/foo/1
25+
`
26+
expect(parse(src, { version: 'next' })).toEqual([{ foo: [1, 42] }, 42])
27+
})
28+
29+
test('resolves to an equal value', () => {
30+
const src = source`
31+
- &a { foo: [42] }
32+
- *a/foo
33+
`
34+
const res = parse(src, { version: 'next' })
35+
expect(res[1]).toBe(res[0].foo)
36+
})
37+
38+
test('does not resolve an alias value', () => {
39+
const src = source`
40+
- &a { foo: *a }
41+
- *a/foo
42+
`
43+
const doc = parseDocument(src, { version: 'next' })
44+
expect(() => doc.toJS()).toThrow(ReferenceError)
45+
})
46+
47+
test('does not resolve a later value', () => {
48+
const src = source`
49+
- *a/foo
50+
- &a { foo: 1 }
51+
`
52+
const doc = parseDocument(src, { version: 'next' })
53+
expect(() => doc.toJS()).toThrow(ReferenceError)
54+
})
55+
})

0 commit comments

Comments
 (0)