Skip to content

Commit

Permalink
feat: Use a proper tag for !!merge << keys (#580)
Browse files Browse the repository at this point in the history
  • Loading branch information
eemeli authored Oct 5, 2024
1 parent 5adbb60 commit 7a434f0
Show file tree
Hide file tree
Showing 18 changed files with 196 additions and 92 deletions.
19 changes: 10 additions & 9 deletions docs/06_custom_tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,16 @@ If including more than one custom tag from this set, make sure that the `'float'

These tags are a part of the YAML 1.1 [language-independent types](https://yaml.org/type/), but are not a part of any default YAML 1.2 schema.

| Identifier | YAML Type | JS Type | Description |
| ------------- | ----------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `'binary'` | [`!!binary`](https://yaml.org/type/binary.html) | `Uint8Array` | Binary data, represented in YAML as base64 encoded characters. |
| `'floatTime'` | [`!!float`](https://yaml.org/type/float.html) | `Number` | Sexagesimal floating-point number format, e.g. `190:20:30.15`. To stringify with this tag, the node `format` must be `'TIME'`. |
| `'intTime'` | [`!!int`](https://yaml.org/type/int.html) | `Number` | Sexagesimal integer number format, e.g. `190:20:30`. To stringify with this tag, the node `format` must be `'TIME'`. |
| `'omap'` | [`!!omap`](https://yaml.org/type/omap.html) | `Map` | Ordered sequence of key: value pairs without duplicates. Using `mapAsMap: true` together with this tag is not recommended, as it makes the parse → stringify loop non-idempotent. |
| `'pairs'` | [`!!pairs`](https://yaml.org/type/pairs.html) | `Array` | Ordered sequence of key: value pairs allowing duplicates. To create from JS, use `doc.createNode(array, { tag: '!!pairs' })`. |
| `'set'` | [`!!set`](https://yaml.org/type/set.html) | `Set` | Unordered set of non-equal values. |
| `'timestamp'` | [`!!timestamp`](https://yaml.org/type/timestamp.html) | `Date` | A point in time, e.g. `2001-12-15T02:59:43`. |
| Identifier | YAML Type | JS Type | Description |
| ------------- | ----------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `'binary'` | [`!!binary`](https://yaml.org/type/binary.html) | `Uint8Array` | Binary data, represented in YAML as base64 encoded characters. |
| `'floatTime'` | [`!!float`](https://yaml.org/type/float.html) | `Number` | Sexagesimal floating-point number format, e.g. `190:20:30.15`. To stringify with this tag, the node `format` must be `'TIME'`. |
| `'intTime'` | [`!!int`](https://yaml.org/type/int.html) | `Number` | Sexagesimal integer number format, e.g. `190:20:30`. To stringify with this tag, the node `format` must be `'TIME'`. |
| `'merge'` | [`!!merge`](https://yaml.org/type/merge.html) | `Symbol('<<')` | A `<<` merge key which allows one or more mappings to be merged with the current one. |
| `'omap'` | [`!!omap`](https://yaml.org/type/omap.html) | `Map` | Ordered sequence of key: value pairs without duplicates. Using `mapAsMap: true` together with this tag is not recommended, as it makes the parse → stringify loop non-idempotent. |
| `'pairs'` | [`!!pairs`](https://yaml.org/type/pairs.html) | `Array` | Ordered sequence of key: value pairs allowing duplicates. To create from JS, use `doc.createNode(array, { tag: '!!pairs' })`. |
| `'set'` | [`!!set`](https://yaml.org/type/set.html) | `Set` | Unordered set of non-equal values. |
| `'timestamp'` | [`!!timestamp`](https://yaml.org/type/timestamp.html) | `Date` | A point in time, e.g. `2001-12-15T02:59:43`. |

## Writing Custom Tags

Expand Down
1 change: 1 addition & 0 deletions src/compose/compose-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function composeDoc<
const opts = Object.assign({ _directives: directives }, options)
const doc = new Document(undefined, opts) as Document.Parsed<Contents, Strict>
const ctx: ComposeContext = {
atKey: false,
atRoot: true,
directives: doc.directives,
options: doc.options,
Expand Down
1 change: 1 addition & 0 deletions src/compose/compose-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { resolveEnd } from './resolve-end.js'
import { emptyScalarPosition } from './util-empty-scalar-position.js'

export interface ComposeContext {
atKey: boolean
atRoot: boolean
directives: Directives
options: Readonly<Required<Omit<ParseOptions, 'lineCounter'>>>
Expand Down
6 changes: 4 additions & 2 deletions src/compose/compose-scalar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,16 @@ function findScalarTagByName(
}

function findScalarTagByTest(
{ directives, schema }: ComposeContext,
{ atKey, directives, schema }: ComposeContext,
value: string,
token: FlowScalar,
onError: ComposeErrorHandler
) {
const tag =
(schema.tags.find(
tag => tag.default && tag.test?.test(value)
tag =>
(tag.default === true || (atKey && tag.default === 'key')) &&
tag.test?.test(value)
) as ScalarTag) || schema[SCALAR]

if (schema.compat) {
Expand Down
2 changes: 2 additions & 0 deletions src/compose/resolve-block-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,13 @@ export function resolveBlockMap(
}

// key value
ctx.atKey = true
const keyStart = keyProps.end
const keyNode = key
? composeNode(ctx, key, keyProps, onError)
: composeEmptyNode(ctx, keyStart, start, null, keyProps, onError)
if (ctx.schema.compat) flowIndentCheck(bm.indent, key, onError)
ctx.atKey = false

if (mapIncludes(ctx, map.items, keyNode))
onError(keyStart, 'DUPLICATE_KEY', 'Map keys must be unique')
Expand Down
1 change: 1 addition & 0 deletions src/compose/resolve-block-seq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function resolveBlockSeq(
const seq = new NodeClass(ctx.schema) as YAMLSeq

if (ctx.atRoot) ctx.atRoot = false
if (ctx.atKey) ctx.atKey = false
let offset = bs.offset
let commentEnd: number | null = null
for (const { start, value } of bs.items) {
Expand Down
3 changes: 3 additions & 0 deletions src/compose/resolve-flow-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function resolveFlowCollection(
coll.flow = true
const atRoot = ctx.atRoot
if (atRoot) ctx.atRoot = false
if (ctx.atKey) ctx.atKey = false

let offset = fc.offset + fc.start.source.length
for (let i = 0; i < fc.items.length; ++i) {
Expand Down Expand Up @@ -118,11 +119,13 @@ export function resolveFlowCollection(
// item is a key+value pair

// key value
ctx.atKey = true
const keyStart = props.end
const keyNode = key
? composeNode(ctx, key, props, onError)
: composeEmptyNode(ctx, keyStart, start, null, props, onError)
if (isBlock(key)) onError(keyNode.range, 'BLOCK_IN_FLOW', blockMsg)
ctx.atKey = false

// value properties
const valueProps = resolveProps(sep ?? [], {
Expand Down
6 changes: 1 addition & 5 deletions src/compose/util-map-includes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ export function mapIncludes(
typeof uniqueKeys === 'function'
? uniqueKeys
: (a: ParsedNode, b: ParsedNode) =>
a === b ||
(isScalar(a) &&
isScalar(b) &&
a.value === b.value &&
!(a.value === '<<' && ctx.schema.merge))
a === b || (isScalar(a) && isScalar(b) && a.value === b.value)
return items.some(pair => isEqual(pair.key, search))
}
4 changes: 2 additions & 2 deletions src/doc/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,13 +383,13 @@ export class Document<
case '1.1':
if (this.directives) this.directives.yaml.version = '1.1'
else this.directives = new Directives({ version: '1.1' })
opt = { merge: true, resolveKnownTags: false, schema: 'yaml-1.1' }
opt = { resolveKnownTags: false, schema: 'yaml-1.1' }
break
case '1.2':
case 'next':
if (this.directives) this.directives.yaml.version = version
else this.directives = new Directives({ version })
opt = { merge: false, resolveKnownTags: true, schema: 'core' }
opt = { resolveKnownTags: true, schema: 'core' }
break
case null:
if (this.directives) delete this.directives
Expand Down
12 changes: 11 additions & 1 deletion src/nodes/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Alias } from './Alias.js'
import { isDocument, NODE_TYPE } from './identity.js'
import type { Scalar } from './Scalar.js'
import { toJS, ToJSContext } from './toJS.js'
import type { YAMLMap } from './YAMLMap.js'
import type { MapLike, YAMLMap } from './YAMLMap.js'
import type { YAMLSeq } from './YAMLSeq.js'

export type Node<T = unknown> =
Expand Down Expand Up @@ -70,6 +70,16 @@ export abstract class NodeBase {
/** A fully qualified tag, if required */
declare tag?: string

/**
* Customize the way that a key-value pair is resolved.
* Used for YAML 1.1 !!merge << handling.
*/
declare addToJSMap?: (
ctx: ToJSContext | undefined,
map: MapLike,
value: unknown
) => void

/** A plain JS representation of this node */
abstract toJSON(): any

Expand Down
56 changes: 6 additions & 50 deletions src/nodes/addPairToJSMap.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import { warn } from '../log.js'
import { addMergeToJSMap, isMergeKey } from '../schema/yaml-1.1/merge.js'
import { createStringifyContext } from '../stringify/stringify.js'
import { isAlias, isMap, isNode, isScalar, isSeq } from './identity.js'
import { isNode } from './identity.js'
import type { Pair } from './Pair.js'
import { Scalar } from './Scalar.js'
import { toJS, ToJSContext } from './toJS.js'
import type { MapLike } from './YAMLMap.js'

const MERGE_KEY = '<<'

export function addPairToJSMap(
ctx: ToJSContext | undefined,
map: MapLike,
{ key, value }: Pair
) {
if (ctx?.doc.schema.merge && isMergeKey(key)) {
value = isAlias(value) ? value.resolve(ctx.doc) : value
if (isSeq(value)) for (const it of value.items) mergeToJSMap(ctx, map, it)
else if (Array.isArray(value))
for (const it of value) mergeToJSMap(ctx, map, it)
else mergeToJSMap(ctx, map, value)
} else {
if (isNode(key) && key.addToJSMap) key.addToJSMap(ctx, map, value)
// TODO: Should drop this special case for bare << handling
else if (isMergeKey(ctx, key)) addMergeToJSMap(ctx, map, value)
else {
const jsKey = toJS(key, '', ctx)
if (map instanceof Map) {
map.set(jsKey, toJS(value, jsKey, ctx))
Expand All @@ -41,45 +36,6 @@ export function addPairToJSMap(
return map
}

const isMergeKey = (key: unknown) =>
key === MERGE_KEY ||
(isScalar(key) &&
key.value === MERGE_KEY &&
(!key.type || key.type === Scalar.PLAIN))

// If the value associated with a merge key is a single mapping node, each of
// its key/value pairs is inserted into the current mapping, unless the key
// already exists in it. If the value associated with the merge key is a
// sequence, then this sequence is expected to contain mapping nodes and each
// of these nodes is merged in turn according to its order in the sequence.
// Keys in mapping nodes earlier in the sequence override keys specified in
// later mapping nodes. -- http://yaml.org/type/merge.html
function mergeToJSMap(
ctx: ToJSContext | undefined,
map: MapLike,
value: unknown
) {
const source = ctx && isAlias(value) ? value.resolve(ctx.doc) : value
if (!isMap(source))
throw new Error('Merge sources must be maps or map aliases')
const srcMap = source.toJSON(null, ctx, Map)
for (const [key, value] of srcMap) {
if (map instanceof Map) {
if (!map.has(key)) map.set(key, value)
} else if (map instanceof Set) {
map.add(key)
} else if (!Object.prototype.hasOwnProperty.call(map, key)) {
Object.defineProperty(map, key, {
value,
writable: true,
enumerable: true,
configurable: true
})
}
}
return map
}

function stringifyKey(
key: unknown,
jsKey: unknown,
Expand Down
4 changes: 1 addition & 3 deletions src/schema/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const sortMapEntriesByKey = (a: Pair<any>, b: Pair<any>) =>
export class Schema {
compat: Array<CollectionTag | ScalarTag> | null
knownTags: Record<string, CollectionTag | ScalarTag>
merge: boolean
name: string
sortMapEntries: ((a: Pair, b: Pair) => number) | null
tags: Array<CollectionTag | ScalarTag>
Expand All @@ -38,10 +37,9 @@ export class Schema {
: compat
? getTags(null, compat)
: null
this.merge = !!merge
this.name = (typeof schema === 'string' && schema) || 'core'
this.knownTags = resolveKnownTags ? coreKnownTags : {}
this.tags = getTags(customTags, this.name)
this.tags = getTags(customTags, this.name, merge)
this.toStringOptions = toStringDefaults ?? null

Object.defineProperty(this, MAP, { value: map })
Expand Down
37 changes: 26 additions & 11 deletions src/schema/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { int, intHex, intOct } from './core/int.js'
import { schema as core } from './core/schema.js'
import { schema as json } from './json/schema.js'
import { binary } from './yaml-1.1/binary.js'
import { merge } from './yaml-1.1/merge.js'
import { omap } from './yaml-1.1/omap.js'
import { pairs } from './yaml-1.1/pairs.js'
import { schema as yaml11 } from './yaml-1.1/schema.js'
Expand Down Expand Up @@ -36,6 +37,7 @@ const tagsByName = {
intOct,
intTime,
map,
merge,
null: nullTag,
omap,
pairs,
Expand All @@ -50,6 +52,7 @@ export type Tags = Array<ScalarTag | CollectionTag | TagId>

export const coreKnownTags = {
'tag:yaml.org,2002:binary': binary,
'tag:yaml.org,2002:merge': merge,
'tag:yaml.org,2002:omap': omap,
'tag:yaml.org,2002:pairs': pairs,
'tag:yaml.org,2002:set': set,
Expand All @@ -58,9 +61,17 @@ export const coreKnownTags = {

export function getTags(
customTags: SchemaOptions['customTags'] | undefined,
schemaName: string
schemaName: string,
addMergeTag?: boolean
) {
let tags: Tags | undefined = schemas.get(schemaName)
const schemaTags = schemas.get(schemaName)
if (schemaTags && !customTags) {
return addMergeTag && !schemaTags.includes(merge)
? schemaTags.concat(merge)
: schemaTags.slice()
}

let tags: Tags | undefined = schemaTags
if (!tags) {
if (Array.isArray(customTags)) tags = []
else {
Expand All @@ -79,14 +90,18 @@ export function getTags(
} else if (typeof customTags === 'function') {
tags = customTags(tags.slice())
}
if (addMergeTag) tags = tags.concat(merge)

return tags.map(tag => {
if (typeof tag !== 'string') return tag
const tagObj = tagsByName[tag]
if (tagObj) return tagObj
const keys = Object.keys(tagsByName)
.map(key => JSON.stringify(key))
.join(', ')
throw new Error(`Unknown custom tag "${tag}"; use one of ${keys}`)
})
return tags.reduce<(CollectionTag | ScalarTag)[]>((tags, tag) => {
const tagObj = typeof tag === 'string' ? tagsByName[tag] : tag
if (!tagObj) {
const tagName = JSON.stringify(tag)
const keys = Object.keys(tagsByName)
.map(key => JSON.stringify(key))
.join(', ')
throw new Error(`Unknown custom tag ${tagName}; use one of ${keys}`)
}
if (!tags.includes(tagObj)) tags.push(tagObj)
return tags
}, [])
}
10 changes: 6 additions & 4 deletions src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ interface TagBase {
createNode?: (schema: Schema, value: unknown, ctx: CreateNodeContext) => Node

/**
* If `true`, together with `test` allows for values to be stringified without
* an explicit tag. For most cases, it's unlikely that you'll actually want to
* use this, even if you first think you do.
* If `true`, allows for values to be stringified without
* an explicit tag together with `test`.
* If `'key'`, this only applies if the value is used as a mapping key.
* For most cases, it's unlikely that you'll actually want to use this,
* even if you first think you do.
*/
default?: boolean
default?: boolean | 'key'

/**
* If a tag has multiple forms that should be parsed and/or stringified
Expand Down
Loading

0 comments on commit 7a434f0

Please sign in to comment.