Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/01_intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ import {
- [`new YAMLSeq()`](#collections)
- [`doc.createAlias(node, name?): Alias`](#creating-nodes)
- [`doc.createNode(value, options?): Node`](#creating-nodes)
- [`doc.createPair(key, value): Pair`](#creating-nodes)
- [`doc.createPair(key, value, options?): Pair`](#creating-nodes)
- [`doc.createMergePair(value, options?): Pair`](#creating-nodes)
- [`visit(node, visitor)`](#finding-and-modifying-nodes)
- [`visitAsync(node, visitor)`](#finding-and-modifying-nodes)

Expand Down
1 change: 1 addition & 0 deletions docs/04_documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ which is expected to always contain a `YAMLMap`, `YAMLSeq`, or `Scalar` value.
| createAlias(node: Node, name?: string) | `Alias` | Create a new `Alias` node, adding the required anchor for `node`. If `name` is empty, a new anchor name will be generated. |
| createNode(value, options?) | `Node` | Recursively wrap any input with appropriate `Node` containers. See [Creating Nodes](#creating-nodes) for more information. |
| createPair(key, value, options?) | `Pair` | Recursively wrap `key` and `value` into a `Pair` object. See [Creating Nodes](#creating-nodes) for more information. |
| createMergePair(value,&nbsp;options?) | `Pair` | Create a YAML 1.1 `<<` merge `Pair` object. See [Creating Nodes](#creating-nodes) for more information. |
| get(key) | `Node ⎮ Pair` | Returns top-level collection item at `key`, or `undefined` if not found. |
| getPair(key) | `Pair` | Returns top-level collection pair at `key`, or `undefined` if not found. |
| set(key) | `void` | Sets a value in this document's top-level collection. For `!!set`, `value` is ignored. |
Expand Down
10 changes: 9 additions & 1 deletion docs/05_content_nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,15 @@ To construct a `YAMLSeq` or `YAMLMap`, use `new Document()` or `doc.createNode()

Once created, normal array operations may be used to modify the `items` array.
New `Pair` objects may created either by importing the class from `yaml` and using its `new Pair(key, value)` constructor, or by using the `doc.createPair(key, value, options?)` method.
The latter will recursively wrap the `key` and `value` as nodes, and accepts the same options as `doc.createNode()`
The latter will recursively wrap the `key` and `value` as nodes, and accepts the same options as `doc.createNode()`.

#### `doc.createMergePair(value, options?): Pair`

To create YAML 1.1 `<<` merge keys, use `doc.createMergePair(value, options?)` on a Document that supports them,
such as one created with `{ merge: true }` or `{ schema: 'yaml-1.1' }` as options,
or parsed from a YAML 1.1 document source.

Keys that are otherwise created with the string value `<<` will be quoted to disambiguate them from merge keys.

## Finding and Modifying Nodes

Expand Down
27 changes: 27 additions & 0 deletions src/doc/Document.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { YAMLError, YAMLWarning } from '../errors.ts'
import { Alias } from '../nodes/Alias.ts'
import { isNode } from '../nodes/identity.ts'
import { Pair } from '../nodes/Pair.ts'
import type { Scalar } from '../nodes/Scalar.ts'
import { ToJSContext } from '../nodes/toJS.ts'
Expand All @@ -16,6 +17,7 @@ import type {
ToStringOptions
} from '../options.ts'
import { Schema } from '../schema/Schema.ts'
import { MergeKey } from '../schema/yaml-1.1/merge.ts'
import { stringifyDocument } from '../stringify/stringifyDocument.ts'
import { anchorNames, findNewAnchor } from './anchors.ts'
import { applyReviver } from './applyReviver.ts'
Expand Down Expand Up @@ -220,6 +222,31 @@ export class Document<Value extends DocValue = DocValue> {
return pair
}

/**
* Create a YAML 1.1 `<<` merge pair with the provided value.
*
* Throws if the current schema does not support merge keys,
* or if the value has an invalid shape.
*/
createMergePair(
value: unknown,
options?: CreateNodeOptions
): Pair<MergeKey, Alias | YAMLMap | YAMLSeq<Alias | YAMLMap>> {
if (this.schema.tags.every(tag => tag.tag !== 'tag:yaml.org,2002:merge'))
throw new Error('Merge tags are not supported in this Document')
const v =
!options && isNode(value) ? value : this.createNode(value, options)
if (
v instanceof Alias ||
v instanceof YAMLMap ||
(v instanceof YAMLSeq &&
v.every(a => a instanceof Alias || a instanceof YAMLMap))
)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return new Pair<any, any>(new MergeKey(), v)
throw new Error('Invalid merge pair value')
}

/**
* Returns item at `key`, or `undefined` if not found.
*/
Expand Down
9 changes: 4 additions & 5 deletions src/nodes/Pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { stringifyPair } from '../stringify/stringifyPair.ts'
import { addPairToJSMap } from './addPairToJSMap.ts'
import type { ToJSContext } from './toJS.ts'
import type { Node, NodeOf, Primitive } from './types.ts'
import type { MapLike } from './YAMLMap.ts'

export class Pair<
K extends Primitive | Node = Primitive | Node,
Expand All @@ -32,12 +33,10 @@ export class Pair<
return new Pair(key, value)
}

toJS(
doc: Document<DocValue>,
ctx: ToJSContext
): ReturnType<typeof addPairToJSMap> {
toJS(doc: Document<DocValue>, ctx: ToJSContext): MapLike {
const pair = ctx.mapAsMap ? new Map() : {}
return addPairToJSMap(doc, ctx, pair, this, true)
addPairToJSMap(doc, ctx, pair, this, true)
return pair
}

toString(
Expand Down
2 changes: 1 addition & 1 deletion src/nodes/Scalar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class Scalar<T = unknown> implements NodeBase {
*/
declare addToJSMap?: (
doc: Document<DocValue>,
ctx: ToJSContext | undefined,
ctx: ToJSContext,
map: MapLike,
value: unknown,
isPlainObject: boolean
Expand Down
12 changes: 4 additions & 8 deletions src/nodes/addPairToJSMap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Document, DocValue } from '../doc/Document.ts'
import { warn } from '../log.ts'
import { addMergeToJSMap, isMergeKey } from '../schema/yaml-1.1/merge.ts'
import { createStringifyContext } from '../stringify/stringify.ts'
import type { Pair } from './Pair.ts'
import type { ToJSContext } from './toJS.ts'
Expand All @@ -13,12 +12,10 @@ export function addPairToJSMap(
map: MapLike,
{ key, value }: Pair,
isPlainObject: boolean
): MapLike {
if ('addToJSMap' in key) key.addToJSMap?.(doc, ctx, map, value, isPlainObject)
// TODO: Should drop this special case for bare << handling
else if (isMergeKey(doc, key))
addMergeToJSMap(doc, ctx, map, value, isPlainObject)
else {
): void {
if ('addToJSMap' in key && typeof key.addToJSMap === 'function') {
key.addToJSMap(doc, ctx, map, value, isPlainObject)
} else {
const jsKey = key.toJS(doc, ctx)
if (map instanceof Map) {
map.set(jsKey, value ? value.toJS(doc, ctx) : value)
Expand All @@ -41,7 +38,6 @@ export function addPairToJSMap(
else map[stringKey] = jsValue
}
}
return map
}

function stringifyKey(
Expand Down
2 changes: 1 addition & 1 deletion src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export interface CollectionTag extends TagBase {
createNode: (nc: NodeCreator, value: unknown) => Node

/**
* The `Node` child class that implements this tag.
* The class that implements this tag.
* If set, used to select this tag when stringifying.
*/
nodeClass?: {
Expand Down
29 changes: 16 additions & 13 deletions src/schema/yaml-1.1/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,32 @@ const MERGE_KEY = '<<'

export const merge: ScalarTag & {
identify(value: unknown): boolean
resolve(): MergeKey
test: (value: string) => boolean
} = {
identify: value =>
value === MERGE_KEY ||
(typeof value === 'symbol' && value.description === MERGE_KEY),
typeof value === 'symbol' && value.description === MERGE_KEY,
default: 'key',
tag: 'tag:yaml.org,2002:merge',
test: str => str === MERGE_KEY,
resolve: () =>
Object.assign(new Scalar(Symbol(MERGE_KEY)), {
addToJSMap: addMergeToJSMap
}),
resolve: () => new MergeKey(),
stringify: () => MERGE_KEY
}

export const isMergeKey = (doc: Document<DocValue>, key: unknown): boolean =>
(merge.identify(key) ||
(key instanceof Scalar &&
(!key.type || key.type === Scalar.PLAIN) &&
merge.identify(key.value))) &&
Boolean(doc.schema.tags.some(tag => tag.tag === merge.tag && tag.default))
/**
* A YAML 1.1 `!!merge` key
*
* Using a unique symbol as the value allows for
* multiple instances in a map.
*/
export class MergeKey extends Scalar<symbol> {
constructor() {
super(Symbol(MERGE_KEY))
this.addToJSMap = addToJSMap
}
}

export function addMergeToJSMap(
function addToJSMap(
doc: Document<DocValue>,
ctx: ToJSContext,
map: MapLike,
Expand Down
30 changes: 9 additions & 21 deletions tests/doc/anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,30 +304,18 @@ describe('merge <<', () => {
const doc = parseDocument<YAMLSeq<YAMLMap>>('[{ a: A }, { b: B }]', {
merge: true
})
const [a, b] = doc.value
const merge = doc.createPair('<<', doc.createAlias(a))
b.set(merge)
expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }])
expect(String(doc)).toBe('[ &a1 { a: A }, { b: B, <<: *a1 } ]\n')
const merge = doc.createMergePair({ c: 'C' })
doc.value[1].set(merge)
expect(doc.toJS()).toMatchObject([{ a: 'A' }, { b: 'B', c: 'C' }])
expect(String(doc)).toBe('[ { a: A }, { b: B, <<: { c: C } } ]\n')
})

test('using customTags:["merge"]', () => {
const doc = parseDocument<YAMLSeq<YAMLMap>>('[{ a: A }, { b: B }]', {
customTags: ['merge']
})
const [a, b] = doc.value
const merge = doc.createPair('<<', doc.createAlias(a))
b.set(merge)
expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }])
expect(String(doc)).toBe('[ &a1 { a: A }, { b: B, <<: *a1 } ]\n')
})

test('symbol value', () => {
const doc = parseDocument<YAMLSeq<YAMLMap>>('[{ a: A }, { b: B }]', {
merge: true
})
const [a, b] = doc.value
const merge = doc.createPair(Symbol('<<'), doc.createAlias(a))
const merge = doc.createMergePair(doc.createAlias(a))
b.set(merge)
expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }])
expect(String(doc)).toBe('[ &a1 { a: A }, { b: B, <<: *a1 } ]\n')
Expand All @@ -339,7 +327,7 @@ describe('merge <<', () => {
})
const [a, b] = doc.value
const alias = doc.createAlias(a, 'AA')
const merge = doc.createPair('<<', alias)
const merge = doc.createMergePair(alias)
b.set(merge)
expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }])
expect(() => doc.toJS({ maxAliasCount: 0 })).toThrow(ReferenceError)
Expand All @@ -355,7 +343,7 @@ describe('merge <<', () => {
const b = doc.get(2) as YAMLMap
seq.anchor = 'SEQ'
seq.push(doc.createAlias(a, 'AA'))
b.set(doc.createPair('<<', doc.createAlias(seq)))
b.set(doc.createMergePair(doc.createAlias(seq)))

expect(doc.toJS()).toMatchObject([
{ a: 'A' },
Expand All @@ -369,14 +357,14 @@ describe('merge <<', () => {
})

test('require map node', () => {
const doc = parseDocument<YAMLSeq<YAMLMap<string, any>>>(
const doc = parseDocument<YAMLSeq<YAMLMap<any, any>>>(
'[{ a: A }, { b: B }]',
{
merge: true
}
)
const alias = doc.createAlias(doc.get(0)!.get('a'))
doc.get(1)!.set(doc.createPair('<<', alias))
doc.get(1)!.set(doc.createMergePair(alias))
expect(String(doc)).toBe('[ { a: &a1 A }, { b: B, <<: *a1 } ]\n')
expect(() => doc.toJS()).toThrow(
'Merge sources must be maps or map aliases'
Expand Down
37 changes: 28 additions & 9 deletions tests/doc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -926,10 +926,15 @@ date (00:00:00Z): 2002-12-14\n`)
})
})

describe('!!merge', () => {
describe.for([
{ merge: true },
{ merge: true, resolveKnownTags: false },
{ schema: 'yaml-1.1' as const },
{ version: '1.1' as const }
])('!!merge with %o', options => {
test('alias', () => {
const src = '- &a { a: A, b: B }\n- { <<: *a, b: X }\n'
const doc = parseDocument(src, { version: '1.1' })
const doc = parseDocument(src, options)
expect(doc.toJS()).toMatchObject([
{ a: 'A', b: 'B' },
{ a: 'A', b: 'X' }
Expand All @@ -940,7 +945,7 @@ date (00:00:00Z): 2002-12-14\n`)
test('alias sequence', () => {
const src =
'- &a { a: A, b: B, c: C }\n- &b { a: X }\n- { <<: [ *b, *a ], b: X }\n'
const doc = parseDocument(src, { version: '1.1' })
const doc = parseDocument(src, options)
expect(doc.toJS()).toMatchObject([
{ a: 'A', b: 'B' },
{ a: 'X' },
Expand All @@ -951,27 +956,41 @@ date (00:00:00Z): 2002-12-14\n`)

test('explicit creation', () => {
const src = '- { a: A, b: B }\n- { b: X }\n'
const doc = parseDocument<YAMLSeq<YAMLMap>>(src, { version: '1.1' })
const doc = parseDocument<YAMLSeq<YAMLMap>>(src, options)
const alias = doc.createAlias(doc.get(0)!, 'a')
doc.get(1)!.set(doc.createPair('<<', alias))
doc.get(1)!.set(doc.createMergePair(alias))
expect(doc.toString()).toBe('- &a { a: A, b: B }\n- { b: X, <<: *a }\n')
expect(doc.toJS()).toMatchObject([
{ a: 'A', b: 'B' },
{ a: 'A', b: 'X' }
])
})

test('creation by duck typing', () => {
test('Creation by duck typing results in quoted "<<" key', () => {
const src = '- { a: A, b: B }\n- { b: X }\n'
const doc = parseDocument<YAMLSeq<YAMLMap>>(src, { version: '1.1' })
const doc = parseDocument<YAMLSeq<YAMLMap>>(src, options)
const alias = doc.createAlias(doc.get(0)!, 'a')
doc.get(1)!.set(doc.createPair('<<', alias))
expect(doc.toString()).toBe('- &a { a: A, b: B }\n- { b: X, <<: *a }\n')
expect(doc.toString()).toBe('- &a { a: A, b: B }\n- { b: X, "<<": *a }\n')
expect(doc.toJS()).toMatchObject([
{ a: 'A', b: 'B' },
{ a: 'A', b: 'X' }
{ b: 'X', '<<': { a: 'A' } }
])
})

test('Using an explicit !!str tag on << makes it not a merge key', () => {
const src = '!!str <<: { x: 1 }'
const doc = parseDocument<YAMLSeq<YAMLMap>>(src, options)
expect(doc.toJS()).toMatchObject({ '<<': { x: 1 } })
expect(doc.toString()).toBe('!!str "<<": { x: 1 }\n')
})

test('An explicit !!merge tag is retained', () => {
const src = '!!merge <<: { x: 1 }'
const doc = parseDocument<YAMLSeq<YAMLMap>>(src, options)
expect(doc.toJS()).toMatchObject({ x: 1 })
expect(doc.toString()).toBe('!!merge <<: { x: 1 }\n')
})
})
})

Expand Down
Loading