From 1399a38e94dcaf02a6863b78275485b1e30fc010 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Mon, 15 Mar 2021 18:24:54 +0200 Subject: [PATCH 1/8] Drop doc.anchors.createMergePair() BREAKING CHANGE: With the earlier drop of Merge as a separate class, createMergePair() became just a convenience wrapper with confusing behaviour. For the sake of simplicity, it's easier to drop it completely and replace calling code with: ```diff -doc.anchors.createMergePair(node) +doc.createPair('<<', doc.anchors.createAlias(node)) ``` --- docs/04_documents.md | 3 +-- src/doc/Anchors.ts | 25 +------------------------ tests/doc/anchors.js | 16 +++++++--------- tests/doc/types.js | 2 +- 4 files changed, 10 insertions(+), 36 deletions(-) diff --git a/docs/04_documents.md b/docs/04_documents.md index 45793d1a..85f3cc04 100644 --- a/docs/04_documents.md +++ b/docs/04_documents.md @@ -188,7 +188,6 @@ A description of [alias and merge nodes](#alias-nodes) is included in the next s | Method | Returns | Description | | -------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------- | | 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. | -| createMergePair(...Node) | `Pair` | Create a new merge pair with the given source nodes. Non-`Alias` sources will be automatically wrapped. | | getName(node: Node) | `string?` | The anchor name associated with `node`, if set. | | getNames() | `string[]` | List of all defined anchor names. | | getNode(name: string) | `Node?` | The node associated with the anchor `name`, if set. | @@ -215,7 +214,7 @@ String(doc) // [ &AA { a: A }, { b: &a2 B }, *AA ] doc.setSchema('1.2', { merge: true }) -const merge = doc.anchors.createMergePair(alias) +const merge = doc.createPair('<<', alias) // Pair { // key: Scalar { value: '<<' }, // value: Alias { source: YAMLMap { ... } } } diff --git a/src/doc/Anchors.ts b/src/doc/Anchors.ts index 1ab6e1e3..c20573e7 100644 --- a/src/doc/Anchors.ts +++ b/src/doc/Anchors.ts @@ -1,8 +1,5 @@ import { Alias } from '../nodes/Alias.js' -import { isAlias, isCollection, isMap, isScalar, Node } from '../nodes/Node.js' -import { Pair } from '../nodes/Pair.js' -import { Scalar } from '../nodes/Scalar.js' -import { YAMLSeq } from '../nodes/YAMLSeq.js' +import { isCollection, isScalar, Node } from '../nodes/Node.js' export class Anchors { map: Record = Object.create(null) @@ -21,26 +18,6 @@ export class Anchors { return new Alias(node) } - /** - * Create a new merge `Pair` with the given source nodes. - * Non-`Alias` sources will be automatically wrapped. - */ - createMergePair(...sources: Node[]) { - const key = new Scalar('<<') - const items = sources.map(s => { - if (isAlias(s)) { - if (isMap(s.source)) return s - } else if (isMap(s)) { - return this.createAlias(s) - } - throw new Error('Merge sources must be Map nodes or their Aliases') - }) - if (items.length === 1) return new Pair(key, items[0]) - const seq = new YAMLSeq() - seq.items = items - return new Pair(key, seq) - } - /** The anchor name associated with `node`, if set. */ getName(node: Node) { return Object.keys(this.map).find(a => this.map[a] === node) diff --git a/tests/doc/anchors.js b/tests/doc/anchors.js index 729ee751..54d72054 100644 --- a/tests/doc/anchors.js +++ b/tests/doc/anchors.js @@ -170,11 +170,11 @@ describe('merge <<', () => { }) }) - describe('doc.anchors.createMergePair', () => { + describe('create', () => { test('simple case', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]', { merge: true }) const [a, b] = doc.contents.items - const merge = doc.anchors.createMergePair(a) + const merge = doc.createPair('<<', doc.anchors.createAlias(a)) b.items.push(merge) expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) expect(String(doc)).toBe('[ &a1 { a: A }, { b: B, <<: *a1 } ]\n') @@ -184,20 +184,18 @@ describe('merge <<', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]', { merge: true }) const [a, b] = doc.contents.items const alias = doc.anchors.createAlias(a, 'AA') - const merge = doc.anchors.createMergePair(alias) + const merge = doc.createPair('<<', alias) b.items.push(merge) expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) expect(String(doc)).toBe('[ &AA { a: A }, { b: B, <<: *AA } ]\n') }) test('require map node', () => { - const exp = 'Merge sources must be Map nodes or their Aliases' const doc = YAML.parseDocument('[{ a: A }, { b: B }]', { merge: true }) - const [a] = doc.contents.items - const merge = doc.anchors.createMergePair(a) - expect(() => doc.anchors.createMergePair(merge)).toThrow(exp) - const alias = doc.anchors.createAlias(a.items[0].value) - expect(() => doc.anchors.createMergePair(alias)).toThrow(exp) + const alias = doc.anchors.createAlias(doc.getIn([0, 'a'], true)) + doc.addIn([1], doc.createPair('<<', alias)) + expect(String(doc)).toBe('[ { a: &a1 A }, { b: B, <<: *a1 } ]\n') + expect(() => doc.toJS()).toThrow('Merge sources must be map aliases') }) }) diff --git a/tests/doc/types.js b/tests/doc/types.js index ef12680f..3d7fe008 100644 --- a/tests/doc/types.js +++ b/tests/doc/types.js @@ -586,7 +586,7 @@ date (00:00:00Z): 2002-12-14\n`) const src = '- { a: A, b: B }\n- { b: X }\n' const doc = YAML.parseDocument(src, { version: '1.1' }) const alias = doc.anchors.createAlias(doc.get(0), 'a') - doc.addIn([1], doc.anchors.createMergePair(alias)) + doc.addIn([1], doc.createPair('<<', alias)) expect(doc.toString()).toBe('- &a { a: A, b: B }\n- { b: X, <<: *a }\n') expect(doc.toJS()).toMatchObject([ { a: 'A', b: 'B' }, From 0dae2dd44db4eed38aba1bbfb8e46ae2d9a550cc Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Mon, 15 Mar 2021 18:33:01 +0200 Subject: [PATCH 2/8] Move createAlias() from doc.anchors to doc itself BREAKING CHANGE: All of the node-creating methods are now collected on the Document, making the API easier to use and understand. ```diff -doc.anchors.createAlias(node) +doc.createAlias(node) ``` --- README.md | 2 +- docs/01_intro.md | 2 +- docs/04_documents.md | 20 ++++++++++---------- src/doc/Anchors.ts | 10 ---------- src/doc/Document.ts | 9 +++++++++ tests/doc/anchors.js | 14 +++++++------- tests/doc/types.js | 4 ++-- 7 files changed, 30 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 7b62d9e2..49e71d1d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ const YAML = require('yaml') - [`new Scalar(value)`](https://eemeli.org/yaml/#scalar-values) - [`new YAMLMap()`](https://eemeli.org/yaml/#collections) - [`new YAMLSeq()`](https://eemeli.org/yaml/#collections) -- [`doc.anchors.createAlias(node, name?): Alias`](https://eemeli.org/yaml/#working-with-anchors) +- [`doc.createAlias(node, name?): Alias`](https://eemeli.org/yaml/#working-with-anchors) - [`doc.createNode(value, options?): Node`](https://eemeli.org/yaml/#creating-nodes) - [`doc.createPair(key, value): Pair`](https://eemeli.org/yaml/#creating-nodes) - [`visit(node, visitor)`](https://eemeli.org/yaml/#modifying-nodes) diff --git a/docs/01_intro.md b/docs/01_intro.md index a1eda100..713b284f 100644 --- a/docs/01_intro.md +++ b/docs/01_intro.md @@ -85,7 +85,7 @@ import { - [`new Scalar(value)`](#scalar-values) - [`new YAMLMap()`](#collections) - [`new YAMLSeq()`](#collections) -- [`doc.anchors.createAlias(node, name?): Alias`](#working-with-anchors) +- [`doc.createAlias(node, name?): Alias`](#working-with-anchors) - [`doc.createNode(value, options?): Node`](#creating-nodes) - [`doc.createPair(key, value): Pair`](#creating-nodes) - [`visit(node, visitor)`](#modifying-nodes) diff --git a/docs/04_documents.md b/docs/04_documents.md index 85f3cc04..7b5c1629 100644 --- a/docs/04_documents.md +++ b/docs/04_documents.md @@ -106,6 +106,7 @@ Although `parseDocument()` and `parseAllDocuments()` will leave it with `YAMLMap | Method | Returns | Description | | ------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| 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. | | setSchema(version, options?) | `void` | Change the YAML version and schema used by the document. `version` must be either `'1.1'` or `'1.2'`; accepts all Schema options. | @@ -185,14 +186,13 @@ A description of [alias and merge nodes](#alias-nodes) is included in the next s #### `Document#anchors` -| Method | Returns | Description | -| -------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------- | -| 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. | -| getName(node: Node) | `string?` | The anchor name associated with `node`, if set. | -| getNames() | `string[]` | List of all defined anchor names. | -| getNode(name: string) | `Node?` | The node associated with the anchor `name`, if set. | -| newName(prefix: string) | `string` | Find an available anchor name with the given `prefix` and a numerical suffix. | -| setAnchor(node?: Node, name?: string) | `string?` | Associate an anchor with `node`. If `name` is empty, a new name will be generated. | +| Method | Returns | Description | +| ------------------------------------- | ---------- | ---------------------------------------------------------------------------------- | +| getName(node: Node) | `string?` | The anchor name associated with `node`, if set. | +| getNames() | `string[]` | List of all defined anchor names. | +| getNode(name: string) | `Node?` | The node associated with the anchor `name`, if set. | +| newName(prefix: string) | `string` | Find an available anchor name with the given `prefix` and a numerical suffix. | +| setAnchor(node?: Node, name?: string) | `string?` | Associate an anchor with `node`. If `name` is empty, a new name will be generated. | ```js const src = '[{ a: A }, { b: B }]' @@ -205,7 +205,7 @@ doc.anchors.getNode('a2') String(doc) // [ { a: A }, { b: &a2 B } ] -const alias = doc.anchors.createAlias(doc.get(0, true), 'AA') +const alias = doc.createAlias(doc.get(0, true), 'AA') // Alias { source: YAMLMap { items: [ [Pair] ] } } doc.add(alias) doc.toJS() @@ -225,7 +225,7 @@ String(doc) // [ &AA { a: A }, { b: &a2 B, <<: *AA }, *AA ] // This creates a circular reference -merge.value = doc.anchors.createAlias(doc.get(1, true)) +merge.value = doc.createAlias(doc.get(1, true)) doc.toJS() // [RangeError: Maximum call stack size exceeded] String(doc) // [ diff --git a/src/doc/Anchors.ts b/src/doc/Anchors.ts index c20573e7..926d7e08 100644 --- a/src/doc/Anchors.ts +++ b/src/doc/Anchors.ts @@ -1,4 +1,3 @@ -import { Alias } from '../nodes/Alias.js' import { isCollection, isScalar, Node } from '../nodes/Node.js' export class Anchors { @@ -9,15 +8,6 @@ export class Anchors { this.prefix = prefix } - /** - * Create a new `Alias` node, adding the required anchor for `node`. - * If `name` is empty, a new anchor name will be generated. - */ - createAlias(node: Node, name?: string) { - this.setAnchor(node, name) - return new Alias(node) - } - /** The anchor name associated with `node`, if set. */ getName(node: Node) { return Object.keys(this.map).find(a => this.map[a] === node) diff --git a/src/doc/Document.ts b/src/doc/Document.ts index f1806433..3d4e1adf 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -124,6 +124,15 @@ export class Document { if (assertCollection(this.contents)) this.contents.addIn(path, value) } + /** + * Create a new `Alias` node, adding the required anchor for `node`. + * If `name` is empty, a new anchor name will be generated. + */ + createAlias(node: Node, name?: string) { + this.anchors.setAnchor(node, name) + return new Alias(node) + } + /** * Convert any value into a `Node` using the current schema, recursively * turning objects into collections. diff --git a/tests/doc/anchors.js b/tests/doc/anchors.js index 54d72054..eab90cd6 100644 --- a/tests/doc/anchors.js +++ b/tests/doc/anchors.js @@ -63,9 +63,9 @@ describe('create', () => { expect(doc.anchors.getNames()).toMatchObject(['AA', 'a2', 'AA1']) }) - test('doc.anchors.createAlias', () => { + test('doc.createAlias', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]') - const alias = doc.anchors.createAlias(doc.contents.items[0], 'AA') + const alias = doc.createAlias(doc.contents.items[0], 'AA') doc.contents.items.push(alias) expect(doc.toJS()).toMatchObject([{ a: 'A' }, { b: 'B' }, { a: 'A' }]) expect(String(doc)).toMatch('[ &AA { a: A }, { b: B }, *AA ]\n') @@ -74,7 +74,7 @@ describe('create', () => { test('errors', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]') const node = doc.contents.items[0] - const alias = doc.anchors.createAlias(node, 'AA') + const alias = doc.createAlias(node, 'AA') doc.contents.items.unshift(alias) expect(() => String(doc)).toThrow('Alias node must be after source node') expect(() => { @@ -96,7 +96,7 @@ describe('__proto__ as anchor name', () => { test('create/stringify', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]') - const alias = doc.anchors.createAlias(doc.contents.items[0], '__proto__') + const alias = doc.createAlias(doc.contents.items[0], '__proto__') doc.contents.items.push(alias) expect(doc.toJSON()).toMatchObject([{ a: 'A' }, { b: 'B' }, { a: 'A' }]) expect(String(doc)).toMatch( @@ -174,7 +174,7 @@ describe('merge <<', () => { test('simple case', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]', { merge: true }) const [a, b] = doc.contents.items - const merge = doc.createPair('<<', doc.anchors.createAlias(a)) + const merge = doc.createPair('<<', doc.createAlias(a)) b.items.push(merge) expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) expect(String(doc)).toBe('[ &a1 { a: A }, { b: B, <<: *a1 } ]\n') @@ -183,7 +183,7 @@ describe('merge <<', () => { test('merge pair of an alias', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]', { merge: true }) const [a, b] = doc.contents.items - const alias = doc.anchors.createAlias(a, 'AA') + const alias = doc.createAlias(a, 'AA') const merge = doc.createPair('<<', alias) b.items.push(merge) expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) @@ -192,7 +192,7 @@ describe('merge <<', () => { test('require map node', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]', { merge: true }) - const alias = doc.anchors.createAlias(doc.getIn([0, 'a'], true)) + const alias = doc.createAlias(doc.getIn([0, 'a'], true)) doc.addIn([1], doc.createPair('<<', alias)) expect(String(doc)).toBe('[ { a: &a1 A }, { b: B, <<: *a1 } ]\n') expect(() => doc.toJS()).toThrow('Merge sources must be map aliases') diff --git a/tests/doc/types.js b/tests/doc/types.js index 3d7fe008..fde297fc 100644 --- a/tests/doc/types.js +++ b/tests/doc/types.js @@ -585,7 +585,7 @@ date (00:00:00Z): 2002-12-14\n`) test('explicit creation', () => { const src = '- { a: A, b: B }\n- { b: X }\n' const doc = YAML.parseDocument(src, { version: '1.1' }) - const alias = doc.anchors.createAlias(doc.get(0), 'a') + const alias = doc.createAlias(doc.get(0), 'a') doc.addIn([1], doc.createPair('<<', alias)) expect(doc.toString()).toBe('- &a { a: A, b: B }\n- { b: X, <<: *a }\n') expect(doc.toJS()).toMatchObject([ @@ -597,7 +597,7 @@ date (00:00:00Z): 2002-12-14\n`) test('creation by duck typing', () => { const src = '- { a: A, b: B }\n- { b: X }\n' const doc = YAML.parseDocument(src, { version: '1.1' }) - const alias = doc.anchors.createAlias(doc.get(0), 'a') + const alias = doc.createAlias(doc.get(0), 'a') doc.addIn([1], doc.createPair('<<', alias)) expect(doc.toString()).toBe('- &a { a: A, b: B }\n- { b: X, <<: *a }\n') expect(doc.toJS()).toMatchObject([ From 3a0af0c16b0c04131f969a564d4d140703ca6bcf Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sat, 20 Mar 2021 18:48:57 +0200 Subject: [PATCH 3/8] refactor: Replace Document with ComposeContext in compose functions --- src/compose/compose-collection.ts | 19 +++++++------- src/compose/compose-doc.ts | 18 ++++++++++--- src/compose/compose-node.ts | 35 +++++++++++++++----------- src/compose/compose-scalar.ts | 16 ++++++------ src/compose/resolve-block-map.ts | 23 ++++++++--------- src/compose/resolve-block-seq.ts | 15 ++++++----- src/compose/resolve-flow-collection.ts | 27 ++++++++++---------- src/compose/resolve-props.ts | 8 +++--- 8 files changed, 86 insertions(+), 75 deletions(-) diff --git a/src/compose/compose-collection.ts b/src/compose/compose-collection.ts index ffd10438..af94d376 100644 --- a/src/compose/compose-collection.ts +++ b/src/compose/compose-collection.ts @@ -1,4 +1,3 @@ -import type { Document } from '../doc/Document.js' import { isMap, isNode, ParsedNode } from '../nodes/Node.js' import { Scalar } from '../nodes/Scalar.js' import type { YAMLMap } from '../nodes/YAMLMap.js' @@ -9,14 +8,14 @@ import type { FlowCollection } from '../parse/tokens.js' import { CollectionTag } from '../schema/types.js' -import type { ComposeNode } from './compose-node.js' +import type { ComposeContext, ComposeNode } from './compose-node.js' import { resolveBlockMap } from './resolve-block-map.js' import { resolveBlockSeq } from './resolve-block-seq.js' import { resolveFlowCollection } from './resolve-flow-collection.js' export function composeCollection( CN: ComposeNode, - doc: Document.Parsed, + ctx: ComposeContext, token: BlockMap | BlockSequence | FlowCollection, anchor: string | null, tagName: string | null, @@ -25,15 +24,15 @@ export function composeCollection( let coll: YAMLMap.Parsed | YAMLSeq.Parsed switch (token.type) { case 'block-map': { - coll = resolveBlockMap(CN, doc, token, anchor, onError) + coll = resolveBlockMap(CN, ctx, token, anchor, onError) break } case 'block-seq': { - coll = resolveBlockSeq(CN, doc, token, anchor, onError) + coll = resolveBlockSeq(CN, ctx, token, anchor, onError) break } case 'flow-collection': { - coll = resolveFlowCollection(CN, doc, token, anchor, onError) + coll = resolveFlowCollection(CN, ctx, token, anchor, onError) break } } @@ -48,13 +47,13 @@ export function composeCollection( } const expType = isMap(coll) ? 'map' : 'seq' - let tag = doc.schema.tags.find( + let tag = ctx.schema.tags.find( t => t.collection === expType && t.tag === tagName ) as CollectionTag | undefined if (!tag) { - const kt = doc.schema.knownTags[tagName] + const kt = ctx.schema.knownTags[tagName] if (kt && kt.collection === expType) { - doc.schema.tags.push(Object.assign({}, kt, { default: false })) + ctx.schema.tags.push(Object.assign({}, kt, { default: false })) tag = kt } else { onError(coll.range[0], `Unresolved tag: ${tagName}`, true) @@ -63,7 +62,7 @@ export function composeCollection( } } - const res = tag.resolve(coll, msg => onError(coll.range[0], msg), doc.options) + const res = tag.resolve(coll, msg => onError(coll.range[0], msg), ctx.options) const node = isNode(res) ? (res as ParsedNode) : (new Scalar(res) as Scalar.Parsed) diff --git a/src/compose/compose-doc.ts b/src/compose/compose-doc.ts index 9b80ce16..707cf6bb 100644 --- a/src/compose/compose-doc.ts +++ b/src/compose/compose-doc.ts @@ -2,7 +2,11 @@ import type { Directives } from '../doc/directives.js' import { Document } from '../doc/Document.js' import type { Options } from '../options.js' import type * as Tokens from '../parse/tokens.js' -import { composeEmptyNode, composeNode } from './compose-node.js' +import { + ComposeContext, + composeEmptyNode, + composeNode +} from './compose-node.js' import { resolveEnd } from './resolve-end.js' import { resolveProps } from './resolve-props.js' @@ -14,11 +18,17 @@ export function composeDoc( ) { const opts = Object.assign({ directives }, options) const doc = new Document(undefined, opts) as Document.Parsed - const props = resolveProps(doc, start, true, 'doc-start', offset, onError) + const ctx: ComposeContext = { + anchors: doc.anchors, + directives: doc.directives, + options: doc.options, + schema: doc.schema + } + const props = resolveProps(ctx, start, true, 'doc-start', offset, onError) if (props.found) doc.directives.marker = true doc.contents = value - ? composeNode(doc, value, props, onError) - : composeEmptyNode(doc, offset + props.length, start, null, props, onError) + ? composeNode(ctx, value, props, onError) + : composeEmptyNode(ctx, offset + props.length, start, null, props, onError) const re = resolveEnd(end, doc.contents.range[1], false, onError) if (re.comment) doc.comment = re.comment diff --git a/src/compose/compose-node.ts b/src/compose/compose-node.ts index d40ec0c6..0b58461f 100644 --- a/src/compose/compose-node.ts +++ b/src/compose/compose-node.ts @@ -1,12 +1,22 @@ -import type { Document } from '../doc/Document.js' +import { Anchors } from '../doc/Anchors.js' +import type { Directives } from '../doc/directives.js' import { Alias } from '../nodes/Alias.js' import type { Node, ParsedNode } from '../nodes/Node.js' +import type { ParseOptions } from '../options.js' import type { FlowScalar, Token } from '../parse/tokens.js' +import type { Schema } from '../schema/Schema.js' import { composeCollection } from './compose-collection.js' import { composeScalar } from './compose-scalar.js' import { resolveEnd } from './resolve-end.js' import { emptyScalarPosition } from './util-empty-scalar-position.js' +export interface ComposeContext { + anchors: Anchors + directives: Directives + options: Readonly>> + schema: Readonly +} + export interface Props { spaceBefore: boolean comment: string @@ -18,7 +28,7 @@ const CN = { composeNode, composeEmptyNode } export type ComposeNode = typeof CN export function composeNode( - doc: Document.Parsed, + ctx: ComposeContext, token: Token, props: Props, onError: (offset: number, message: string, warning?: boolean) => void @@ -27,7 +37,7 @@ export function composeNode( let node: ParsedNode switch (token.type) { case 'alias': - node = composeAlias(doc, token, onError) + node = composeAlias(ctx, token, onError) if (anchor || tagName) onError(token.offset, 'An alias node must not specify any properties') break @@ -35,12 +45,12 @@ export function composeNode( case 'single-quoted-scalar': case 'double-quoted-scalar': case 'block-scalar': - node = composeScalar(doc, token, anchor, tagName, onError) + node = composeScalar(ctx, token, anchor, tagName, onError) break case 'block-map': case 'block-seq': case 'flow-collection': - node = composeCollection(CN, doc, token, anchor, tagName, onError) + node = composeCollection(CN, ctx, token, anchor, tagName, onError) break default: console.log(token) @@ -55,7 +65,7 @@ export function composeNode( } export function composeEmptyNode( - doc: Document.Parsed, + ctx: ComposeContext, offset: number, before: Token[] | undefined, pos: number | null, @@ -68,28 +78,23 @@ export function composeEmptyNode( indent: -1, source: '' } - const node = composeScalar(doc, token, anchor, tagName, onError) + const node = composeScalar(ctx, token, anchor, tagName, onError) if (spaceBefore) node.spaceBefore = true if (comment) node.comment = comment return node } function composeAlias( - doc: Document.Parsed, + { anchors, options }: ComposeContext, { offset, source, end }: FlowScalar, onError: (offset: number, message: string, warning?: boolean) => void ) { const name = source.substring(1) - const src = doc.anchors.getNode(name) + const src = anchors.getNode(name) if (!src) onError(offset, `Aliased anchor not found: ${name}`) const alias = new Alias(src as Node) - const re = resolveEnd( - end, - offset + source.length, - doc.options.strict, - onError - ) + const re = resolveEnd(end, offset + source.length, options.strict, onError) alias.range = [offset, re.offset] if (re.comment) alias.comment = re.comment return alias as Alias.Parsed diff --git a/src/compose/compose-scalar.ts b/src/compose/compose-scalar.ts index 3b0b4a0b..9cadbada 100644 --- a/src/compose/compose-scalar.ts +++ b/src/compose/compose-scalar.ts @@ -1,4 +1,3 @@ -import type { Document } from '../doc/Document.js' import type { Schema } from '../schema/Schema.js' import { isScalar, SCALAR } from '../nodes/Node.js' import { Scalar } from '../nodes/Scalar.js' @@ -6,9 +5,10 @@ import type { BlockScalar, FlowScalar } from '../parse/tokens.js' import type { ScalarTag } from '../schema/types.js' import { resolveBlockScalar } from './resolve-block-scalar.js' import { resolveFlowScalar } from './resolve-flow-scalar.js' +import type { ComposeContext } from './compose-node.js' export function composeScalar( - doc: Document.Parsed, + ctx: ComposeContext, token: FlowScalar | BlockScalar, anchor: string | null, tagName: string | null, @@ -17,16 +17,16 @@ export function composeScalar( const { offset } = token const { value, type, comment, length } = token.type === 'block-scalar' - ? resolveBlockScalar(token, doc.options.strict, onError) - : resolveFlowScalar(token, doc.options.strict, onError) + ? resolveBlockScalar(token, ctx.options.strict, onError) + : resolveFlowScalar(token, ctx.options.strict, onError) const tag = tagName - ? findScalarTagByName(doc.schema, value, tagName, onError) - : findScalarTagByTest(doc.schema, value, token.type === 'scalar') + ? findScalarTagByName(ctx.schema, value, tagName, onError) + : findScalarTagByTest(ctx.schema, value, token.type === 'scalar') let scalar: Scalar try { - const res = tag.resolve(value, msg => onError(offset, msg), doc.options) + const res = tag.resolve(value, msg => onError(offset, msg), ctx.options) scalar = isScalar(res) ? res : new Scalar(res) } catch (error) { onError(offset, error.message) @@ -39,7 +39,7 @@ export function composeScalar( if (tag.format) scalar.format = tag.format if (comment) scalar.comment = comment - if (anchor) doc.anchors.setAnchor(scalar, anchor) + if (anchor) ctx.anchors.setAnchor(scalar, anchor) return scalar as Scalar.Parsed } diff --git a/src/compose/resolve-block-map.ts b/src/compose/resolve-block-map.ts index 8be9bc12..b7beb2d7 100644 --- a/src/compose/resolve-block-map.ts +++ b/src/compose/resolve-block-map.ts @@ -1,8 +1,7 @@ -import type { Document } from '../doc/Document.js' import { Pair } from '../nodes/Pair.js' import { YAMLMap } from '../nodes/YAMLMap.js' import type { BlockMap } from '../parse/tokens.js' -import type { ComposeNode } from './compose-node.js' +import type { ComposeContext, ComposeNode } from './compose-node.js' import { resolveProps } from './resolve-props.js' import { containsNewline } from './util-contains-newline.js' @@ -10,19 +9,19 @@ const startColMsg = 'All mapping items must start at the same column' export function resolveBlockMap( { composeNode, composeEmptyNode }: ComposeNode, - doc: Document.Parsed, + ctx: ComposeContext, { indent, items, offset }: BlockMap, anchor: string | null, onError: (offset: number, message: string, warning?: boolean) => void ) { const start = offset - const map = new YAMLMap(doc.schema) - if (anchor) doc.anchors.setAnchor(map, anchor) + const map = new YAMLMap(ctx.schema) + if (anchor) ctx.anchors.setAnchor(map, anchor) for (const { start, key, sep, value } of items) { // key properties const keyProps = resolveProps( - doc, + ctx, start, true, 'explicit-key-ind', @@ -56,13 +55,13 @@ export function resolveBlockMap( // key value const keyStart = offset const keyNode = key - ? composeNode(doc, key, keyProps, onError) - : composeEmptyNode(doc, offset, start, null, keyProps, onError) + ? composeNode(ctx, key, keyProps, onError) + : composeEmptyNode(ctx, offset, start, null, keyProps, onError) offset = keyNode.range[1] // value properties const valueProps = resolveProps( - doc, + ctx, sep || [], !key || key.type === 'block-scalar', 'map-value-ind', @@ -76,7 +75,7 @@ export function resolveBlockMap( if (value?.type === 'block-map' && !valueProps.hasNewline) onError(offset, 'Nested mappings are not allowed in compact mappings') if ( - doc.options.strict && + ctx.options.strict && keyProps.start < valueProps.found.offset - 1024 ) onError( @@ -86,8 +85,8 @@ export function resolveBlockMap( } // value value const valueNode = value - ? composeNode(doc, value, valueProps, onError) - : composeEmptyNode(doc, offset, sep, null, valueProps, onError) + ? composeNode(ctx, value, valueProps, onError) + : composeEmptyNode(ctx, offset, sep, null, valueProps, onError) offset = valueNode.range[1] map.items.push(new Pair(keyNode, valueNode)) } else { diff --git a/src/compose/resolve-block-seq.ts b/src/compose/resolve-block-seq.ts index 8832e2f6..9eb585f3 100644 --- a/src/compose/resolve-block-seq.ts +++ b/src/compose/resolve-block-seq.ts @@ -1,22 +1,21 @@ -import type { Document } from '../doc/Document.js' import { YAMLSeq } from '../nodes/YAMLSeq.js' import type { BlockSequence } from '../parse/tokens.js' -import type { ComposeNode } from './compose-node.js' +import type { ComposeContext, ComposeNode } from './compose-node.js' import { resolveProps } from './resolve-props.js' export function resolveBlockSeq( { composeNode, composeEmptyNode }: ComposeNode, - doc: Document.Parsed, + ctx: ComposeContext, { items, offset }: BlockSequence, anchor: string | null, onError: (offset: number, message: string, warning?: boolean) => void ) { const start = offset - const seq = new YAMLSeq(doc.schema) - if (anchor) doc.anchors.setAnchor(seq, anchor) + const seq = new YAMLSeq(ctx.schema) + if (anchor) ctx.anchors.setAnchor(seq, anchor) for (const { start, value } of items) { const props = resolveProps( - doc, + ctx, start, true, 'seq-item-ind', @@ -38,8 +37,8 @@ export function resolveBlockSeq( } } const node = value - ? composeNode(doc, value, props, onError) - : composeEmptyNode(doc, offset, start, null, props, onError) + ? composeNode(ctx, value, props, onError) + : composeEmptyNode(ctx, offset, start, null, props, onError) offset = node.range[1] seq.items.push(node) } diff --git a/src/compose/resolve-flow-collection.ts b/src/compose/resolve-flow-collection.ts index f683ce2a..1022311b 100644 --- a/src/compose/resolve-flow-collection.ts +++ b/src/compose/resolve-flow-collection.ts @@ -1,24 +1,23 @@ -import type { Document } from '../doc/Document.js' import { isNode, isPair, ParsedNode } from '../nodes/Node.js' import { Pair } from '../nodes/Pair.js' import { YAMLMap } from '../nodes/YAMLMap.js' import { YAMLSeq } from '../nodes/YAMLSeq.js' import type { FlowCollection, SourceToken, Token } from '../parse/tokens.js' -import type { ComposeNode } from './compose-node.js' +import type { ComposeContext, ComposeNode } from './compose-node.js' import { resolveEnd } from './resolve-end.js' import { containsNewline } from './util-contains-newline.js' export function resolveFlowCollection( { composeNode, composeEmptyNode }: ComposeNode, - doc: Document.Parsed, + ctx: ComposeContext, fc: FlowCollection, _anchor: string | null, onError: (offset: number, message: string, warning?: boolean) => void ) { const isMap = fc.start.source === '{' - const coll = isMap ? new YAMLMap(doc.schema) : new YAMLSeq(doc.schema) + const coll = isMap ? new YAMLMap(ctx.schema) : new YAMLSeq(ctx.schema) coll.flow = true - if (_anchor) doc.anchors.setAnchor(coll, _anchor) + if (_anchor) ctx.anchors.setAnchor(coll, _anchor) let key: ParsedNode | null = null let value: ParsedNode | null = null @@ -55,14 +54,14 @@ export function resolveFlowCollection( if (value) { if (hasComment) value.comment = comment } else { - value = composeEmptyNode(doc, offset, fc.items, pos, getProps(), onError) + value = composeEmptyNode(ctx, offset, fc.items, pos, getProps(), onError) } if (isMap || atExplicitKey) { coll.items.push(key ? new Pair(key, value) : new Pair(value)) } else { const seq = coll as YAMLSeq if (key) { - const map = new YAMLMap(doc.schema) + const map = new YAMLMap(ctx.schema) map.flow = true map.items.push(new Pair(key, value)) seq.items.push(map) @@ -78,7 +77,7 @@ export function resolveFlowCollection( hasSpace = true break case 'comment': { - if (doc.options.strict && !hasSpace) + if (ctx.options.strict && !hasSpace) onError( offset, 'Comments must be separated from other tokens by white space characters' @@ -120,7 +119,7 @@ export function resolveFlowCollection( break case 'tag': { if (tagName) onError(offset, 'A node can have at most one tag') - const tn = doc.directives.tagName(token.source, m => onError(offset, m)) + const tn = ctx.directives.tagName(token.source, m => onError(offset, m)) if (tn) tagName = tn atLineStart = false atValueEnd = false @@ -139,7 +138,7 @@ export function resolveFlowCollection( if (key) { if (value) { onError(offset, 'Missing {} around pair used as mapping key') - const map = new YAMLMap(doc.schema) + const map = new YAMLMap(ctx.schema) map.flow = true map.items.push(new Pair(key, value)) map.range = [key.range[0], value.range[1]] @@ -147,7 +146,7 @@ export function resolveFlowCollection( value = null } // else explicit key } else if (value) { - if (doc.options.strict) { + if (ctx.options.strict) { const slMsg = 'Implicit keys of flow sequence pairs need to be on a single line' if (nlAfterValueInSeq) onError(offset, slMsg) @@ -165,7 +164,7 @@ export function resolveFlowCollection( key = value value = null } else { - key = composeEmptyNode(doc, offset, fc.items, i, getProps(), onError) + key = composeEmptyNode(ctx, offset, fc.items, i, getProps(), onError) } if (hasComment) { key.comment = comment @@ -199,7 +198,7 @@ export function resolveFlowCollection( default: { if (value) onError(offset, 'Missing , between flow collection items') if (!isMap && !key && !atExplicitKey) seqKeyToken = token - value = composeNode(doc, token, getProps(), onError) + value = composeNode(ctx, token, getProps(), onError) offset = value.range[1] atLineStart = false isSourceToken = false @@ -220,7 +219,7 @@ export function resolveFlowCollection( } if (ce) offset += ce.source.length if (ee.length > 0) { - const end = resolveEnd(ee, offset, doc.options.strict, onError) + const end = resolveEnd(ee, offset, ctx.options.strict, onError) if (end.comment) coll.comment = comment offset = end.offset } diff --git a/src/compose/resolve-props.ts b/src/compose/resolve-props.ts index 33e7dc3d..3006b42f 100644 --- a/src/compose/resolve-props.ts +++ b/src/compose/resolve-props.ts @@ -1,8 +1,8 @@ -import type { Document } from '../doc/Document.js' import type { SourceToken } from '../parse/tokens.js' +import type { ComposeContext } from './compose-node.js' export function resolveProps( - doc: Document.Parsed, + ctx: ComposeContext, tokens: SourceToken[], startOnNewline: boolean, indicator: @@ -35,7 +35,7 @@ export function resolveProps( hasSpace = true break case 'comment': { - if (doc.options.strict && !hasSpace) + if (ctx.options.strict && !hasSpace) onError( offset + length, 'Comments must be separated from other tokens by white space characters' @@ -64,7 +64,7 @@ export function resolveProps( break case 'tag': { if (tagName) onError(offset + length, 'A node can have at most one tag') - const tn = doc.directives.tagName(token.source, msg => + const tn = ctx.directives.tagName(token.source, msg => onError(offset, msg) ) if (tn) tagName = tn From f9e7625f17c2d3c3bbfd81eb66bdb3800367aea8 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 21 Mar 2021 22:01:33 +0200 Subject: [PATCH 4/8] feat!: Make anchor resolution lazy BREAKING CHANGE: - The `source` of an alias is changed from a direct reference to the aliased node to its `anchor` string representation instead. - The `anchors` member of the Document class is dropped. - Anchors are now stored in the `anchor` property of Scalar and Collection nodes. - Assigning the same anchor to multiple nodes will no longer automatically rename an earlier anchor with the same name. --- docs/04_documents.md | 69 ---------------- docs/05_content_nodes.md | 57 +++++++++---- src/compose/compose-collection.ts | 10 +-- src/compose/compose-doc.ts | 1 - src/compose/compose-node.ts | 34 +++++--- src/compose/compose-scalar.ts | 4 +- src/compose/resolve-block-map.ts | 2 - src/compose/resolve-block-seq.ts | 2 - src/compose/resolve-flow-collection.ts | 2 - src/doc/Anchors.ts | 70 ---------------- src/doc/Document.ts | 81 +++++++----------- src/doc/anchors.ts | 89 ++++++++++++++++++++ src/doc/createNode.ts | 40 ++++----- src/nodes/Alias.ts | 95 +++++++++++++-------- src/nodes/Collection.ts | 9 +- src/nodes/Node.ts | 3 + src/nodes/Scalar.ts | 6 +- src/nodes/addPairToJSMap.ts | 9 +- src/nodes/toJS.ts | 27 +++--- src/options.ts | 16 ++++ src/stringify/stringify.ts | 23 ++++-- src/stringify/stringifyPair.ts | 2 +- src/test-events.ts | 28 +++++-- src/util.ts | 2 +- tests/doc/anchors.js | 109 ++++++++++++------------- tests/doc/createNode.js | 62 ++++++++------ tests/doc/errors.js | 1 - tests/visit.ts | 4 +- 28 files changed, 447 insertions(+), 410 deletions(-) delete mode 100644 src/doc/Anchors.ts create mode 100644 src/doc/anchors.ts diff --git a/docs/04_documents.md b/docs/04_documents.md index 7b5c1629..d5b6e99b 100644 --- a/docs/04_documents.md +++ b/docs/04_documents.md @@ -75,7 +75,6 @@ See [Options](#options) for more information on the last argument. | Member | Type | Description | | ------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| anchors | [`Anchors`](#anchors) | Anchors associated with the document's nodes; also provides alias & merge node creators. | | commentBefore | `string?` | A comment at the very beginning of the document. If not empty, separated from the rest of the document by a blank line or the doc-start indicator when stringified. | | comment | `string?` | A comment at the end of the document. If not empty, separated from the rest of the document by a blank line when stringified. | | contents | [`Node`](#content-nodes) `⎮ any` | The document contents. | @@ -177,71 +176,3 @@ See the section on [custom tags](#writing-custom-tags) for more on this topic. `doc.contents.yaml` determines if an explicit `%YAML` directive should be included in the output, and what version it should use. If changing the version after the document's creation, you'll probably want to use `doc.setSchema()` as it will also update the schema accordingly. - -## Working with Anchors - -A description of [alias and merge nodes](#alias-nodes) is included in the next section. - -
- -#### `Document#anchors` - -| Method | Returns | Description | -| ------------------------------------- | ---------- | ---------------------------------------------------------------------------------- | -| getName(node: Node) | `string?` | The anchor name associated with `node`, if set. | -| getNames() | `string[]` | List of all defined anchor names. | -| getNode(name: string) | `Node?` | The node associated with the anchor `name`, if set. | -| newName(prefix: string) | `string` | Find an available anchor name with the given `prefix` and a numerical suffix. | -| setAnchor(node?: Node, name?: string) | `string?` | Associate an anchor with `node`. If `name` is empty, a new name will be generated. | - -```js -const src = '[{ a: A }, { b: B }]' -const doc = parseDocument(src) -doc.anchors.setAnchor(doc.getIn([0, 'a'], true)) // 'a1' -doc.anchors.setAnchor(doc.getIn([1, 'b'], true)) // 'a2' -doc.anchors.setAnchor(null, 'a1') // 'a1' -doc.anchors.getNode('a2') -// { value: 'B', range: [ 16, 18 ], type: 'PLAIN' } -String(doc) -// [ { a: A }, { b: &a2 B } ] - -const alias = doc.createAlias(doc.get(0, true), 'AA') -// Alias { source: YAMLMap { items: [ [Pair] ] } } -doc.add(alias) -doc.toJS() -// [ { a: 'A' }, { b: 'B' }, { a: 'A' } ] -String(doc) -// [ &AA { a: A }, { b: &a2 B }, *AA ] - -doc.setSchema('1.2', { merge: true }) -const merge = doc.createPair('<<', alias) -// Pair { -// key: Scalar { value: '<<' }, -// value: Alias { source: YAMLMap { ... } } } -doc.addIn([1], merge) -doc.toJS() -// [ { a: 'A' }, { b: 'B', a: 'A' }, { a: 'A' } ] -String(doc) -// [ &AA { a: A }, { b: &a2 B, <<: *AA }, *AA ] - -// This creates a circular reference -merge.value = doc.createAlias(doc.get(1, true)) -doc.toJS() // [RangeError: Maximum call stack size exceeded] -String(doc) -// [ -// &AA { a: A }, -// &a1 { b: &a2 B, <<: *a1 }, -// *AA -// ] -``` - -You should make sure to only add alias and merge nodes to the document after the nodes to which they refer, or the document's YAML stringification will fail. - -It is valid to have an anchor associated with a node even if it has no aliases. -`yaml` will not allow you to associate the same name with more than one node, even though this is allowed by the YAML spec (all but the last instance will have numerical suffixes added). -To add or reassign an anchor, use **`setAnchor(node, name)`**. -The second parameter is optional, and if left out either the pre-existing anchor name of the node will be used, or a new one generated. -To remove an anchor, use `setAnchor(null, name)`. -The function will return the new anchor's name, or `null` if both of its arguments are `null`. - -While the `merge` option needs to be true to parse merge pairs as such, this is not required during stringification. diff --git a/docs/05_content_nodes.md b/docs/05_content_nodes.md index 4417dee2..f52bf536 100644 --- a/docs/05_content_nodes.md +++ b/docs/05_content_nodes.md @@ -2,19 +2,23 @@ After parsing, the `contents` value of each `YAML.Document` is the root of an [Abstract Syntax Tree](https://en.wikipedia.org/wiki/Abstract_syntax_tree) of nodes representing the document (or `null` for an empty document). +Both scalar and collection values may have an `anchor` associated with them; this is rendered in the string representation with a `&` prefix, so e.g. in `foo: &aa bar`, the value `bar` has the anchor `aa`. +Anchors are used by [Alias nodes](#alias-nodes) to allow for the same value to be used in multiple places in the document. +It is valid to have an anchor associated with a node even if it has no aliases. + ## Scalar Values ```js class NodeBase { - comment?: string, // a comment on or immediately after this - commentBefore?: string, // a comment before this - range?: [number, number], + comment?: string // a comment on or immediately after this + commentBefore?: string // a comment before this + range?: [number, number] // the [start, end] range of characters of the source parsed // into this node (undefined for pairs or if not parsed) - spaceBefore?: boolean, + spaceBefore?: boolean // a blank line before this node and its commentBefore - tag?: string, // a fully qualified tag, if required - toJSON(): any // a plain JS or JSON representation of this node + tag?: string // a fully qualified tag, if required + toJSON(): any // a plain JS or JSON representation of this node } ``` @@ -22,12 +26,13 @@ For scalar values, the `tag` will not be set unless it was explicitly defined in ```js class Scalar extends NodeBase { - format?: 'BIN' | 'HEX' | 'OCT' | 'TIME' | undefined, + anchor?: string // an anchor associated with this node + format?: 'BIN' | 'HEX' | 'OCT' | 'TIME' | undefined // By default (undefined), numbers use decimal notation. // The YAML 1.2 core schema only supports 'HEX' and 'OCT'. type?: 'BLOCK_FOLDED' | 'BLOCK_LITERAL' | 'PLAIN' | - 'QUOTE_DOUBLE' | 'QUOTE_SINGLE' | undefined, + 'QUOTE_DOUBLE' | 'QUOTE_SINGLE' | undefined value: T } ``` @@ -42,12 +47,13 @@ On the other hand, `!!int` and `!!float` stringifiers will take `format` into ac ```js class Pair extends NodeBase { - key: K, // When parsed, key and value are always + key: K // When parsed, key and value are always value: V // Node or null, but can be set to anything } class Collection extends NodeBase { - flow?: boolean // use flow style when stringifying this + anchor?: string // an anchor associated with this node + flow?: boolean // use flow style when stringifying this schema?: Schema addIn(path: Iterable, value: unknown): void deleteIn(path: Iterable): boolean @@ -131,7 +137,8 @@ Note that for `addIn` the path argument points to the collection rather than the ```js class Alias extends NodeBase { - source: Scalar | YAMLMap | YAMLSeq + source: string + resolve(doc: Document): Scalar | YAMLMap | YAMLSeq | undefined } const obj = YAML.parse('[ &x { X: 42 }, Y, *x ]') @@ -146,7 +153,9 @@ YAML.stringify(obj) // - *a1 ``` -`Alias` nodes provide a way to include a single node in multiple places in a document; the `source` of an alias node must be a preceding node in the document. Circular references are fully supported, and where possible the JS representation of alias nodes will be the actual source object. +`Alias` nodes provide a way to include a single node in multiple places in a document; the `source` of an alias node must be a preceding anchor in the document. +Circular references are fully supported, and where possible the JS representation of alias nodes will be the actual source object. +For ease of use, alias nodes also provide a `resolve(doc)` method to dreference its source node. When nodes are constructed from JS structures (e.g. during `YAML.stringify()`), multiple references to the same object will result in including an autogenerated anchor at its first instance, and alias nodes to that anchor at later references. @@ -176,7 +185,7 @@ String(doc) // - balloons: 99 ``` -#### `YAML.Document#createNode(value, options?): Node` +#### `doc.createNode(value, options?): Node` To create a new node, use the `createNode(value, options?)` document method. This will recursively wrap any input with appropriate `Node` containers. @@ -190,13 +199,29 @@ Note that this requires the corresponding tag to be available in the document's [replacer]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter -As a possible side effect, this method may add entries to the document's [`anchors`](#working-with-anchors). - The primary purpose of this method is to enable attaching comments or other metadata to a value, or to otherwise exert more fine-grained control over the stringified output. To that end, you'll need to assign its return value to the `contents` of a document (or somewhere within said contents), as the document's schema is required for YAML string output. If you're not interested in working with such metadata, document `contents` may also include non-`Node` values at any level. -

new YAMLMap(), new YAMLSeq(), doc.createPair(key, value)

+

doc.createAlias(node, name?): Alias

+ +```js +const alias = doc.createAlias(doc.get(1, true), 'foo') +doc.add(alias) +String(doc) +// - some # A commented item +// - &foo values +// - balloons: 99 +// - *foo +``` + +Create a new `Alias` node, adding the required anchor for `node`. +If `name` is empty, a new anchor name will be generated. +If `node` already has an anchor, that will be used instead of `name`. + +You should make sure to only add alias nodes to the document after the nodes to which they refer, or the document's YAML stringification will fail. + +

new YAMLMap(), new YAMLSeq(), doc.createPair(key, value): Pair

```js import { Document, YAMLSeq } from 'yaml' diff --git a/src/compose/compose-collection.ts b/src/compose/compose-collection.ts index af94d376..d2ba8534 100644 --- a/src/compose/compose-collection.ts +++ b/src/compose/compose-collection.ts @@ -17,22 +17,21 @@ export function composeCollection( CN: ComposeNode, ctx: ComposeContext, token: BlockMap | BlockSequence | FlowCollection, - anchor: string | null, tagName: string | null, onError: (offset: number, message: string, warning?: boolean) => void ) { let coll: YAMLMap.Parsed | YAMLSeq.Parsed switch (token.type) { case 'block-map': { - coll = resolveBlockMap(CN, ctx, token, anchor, onError) + coll = resolveBlockMap(CN, ctx, token, onError) break } case 'block-seq': { - coll = resolveBlockSeq(CN, ctx, token, anchor, onError) + coll = resolveBlockSeq(CN, ctx, token, onError) break } case 'flow-collection': { - coll = resolveFlowCollection(CN, ctx, token, anchor, onError) + coll = resolveFlowCollection(CN, ctx, token, onError) break } } @@ -69,8 +68,5 @@ export function composeCollection( node.range = coll.range node.tag = tagName if (tag?.format) (node as Scalar).format = tag.format - if (anchor && node !== coll) { - // FIXME: handle anchor for non-failsafe collections - } return node } diff --git a/src/compose/compose-doc.ts b/src/compose/compose-doc.ts index 707cf6bb..3c6c1c0c 100644 --- a/src/compose/compose-doc.ts +++ b/src/compose/compose-doc.ts @@ -19,7 +19,6 @@ export function composeDoc( const opts = Object.assign({ directives }, options) const doc = new Document(undefined, opts) as Document.Parsed const ctx: ComposeContext = { - anchors: doc.anchors, directives: doc.directives, options: doc.options, schema: doc.schema diff --git a/src/compose/compose-node.ts b/src/compose/compose-node.ts index 0b58461f..33f1a909 100644 --- a/src/compose/compose-node.ts +++ b/src/compose/compose-node.ts @@ -1,7 +1,6 @@ -import { Anchors } from '../doc/Anchors.js' import type { Directives } from '../doc/directives.js' import { Alias } from '../nodes/Alias.js' -import type { Node, ParsedNode } from '../nodes/Node.js' +import type { ParsedNode } from '../nodes/Node.js' import type { ParseOptions } from '../options.js' import type { FlowScalar, Token } from '../parse/tokens.js' import type { Schema } from '../schema/Schema.js' @@ -11,7 +10,6 @@ import { resolveEnd } from './resolve-end.js' import { emptyScalarPosition } from './util-empty-scalar-position.js' export interface ComposeContext { - anchors: Anchors directives: Directives options: Readonly>> schema: Readonly @@ -45,12 +43,18 @@ export function composeNode( case 'single-quoted-scalar': case 'double-quoted-scalar': case 'block-scalar': - node = composeScalar(ctx, token, anchor, tagName, onError) + node = composeScalar(ctx, token, tagName, onError) + if (anchor) { + node.anchor = anchor + } break case 'block-map': case 'block-seq': case 'flow-collection': - node = composeCollection(CN, ctx, token, anchor, tagName, onError) + node = composeCollection(CN, ctx, token, tagName, onError) + if (anchor) { + node.anchor = anchor + } break default: console.log(token) @@ -78,23 +82,27 @@ export function composeEmptyNode( indent: -1, source: '' } - const node = composeScalar(ctx, token, anchor, tagName, onError) + const node = composeScalar(ctx, token, tagName, onError) + if (anchor) { + node.anchor = anchor + } if (spaceBefore) node.spaceBefore = true if (comment) node.comment = comment return node } function composeAlias( - { anchors, options }: ComposeContext, + { options }: ComposeContext, { offset, source, end }: FlowScalar, onError: (offset: number, message: string, warning?: boolean) => void ) { - const name = source.substring(1) - const src = anchors.getNode(name) - if (!src) onError(offset, `Aliased anchor not found: ${name}`) - const alias = new Alias(src as Node) - - const re = resolveEnd(end, offset + source.length, options.strict, onError) + const alias = new Alias(source.substring(1)) + const re = resolveEnd( + end, + offset + source.length, + options.strict, + onError + ) alias.range = [offset, re.offset] if (re.comment) alias.comment = re.comment return alias as Alias.Parsed diff --git a/src/compose/compose-scalar.ts b/src/compose/compose-scalar.ts index 9cadbada..7991a933 100644 --- a/src/compose/compose-scalar.ts +++ b/src/compose/compose-scalar.ts @@ -1,7 +1,7 @@ -import type { Schema } from '../schema/Schema.js' import { isScalar, SCALAR } from '../nodes/Node.js' import { Scalar } from '../nodes/Scalar.js' import type { BlockScalar, FlowScalar } from '../parse/tokens.js' +import type { Schema } from '../schema/Schema.js' import type { ScalarTag } from '../schema/types.js' import { resolveBlockScalar } from './resolve-block-scalar.js' import { resolveFlowScalar } from './resolve-flow-scalar.js' @@ -10,7 +10,6 @@ import type { ComposeContext } from './compose-node.js' export function composeScalar( ctx: ComposeContext, token: FlowScalar | BlockScalar, - anchor: string | null, tagName: string | null, onError: (offset: number, message: string) => void ) { @@ -39,7 +38,6 @@ export function composeScalar( if (tag.format) scalar.format = tag.format if (comment) scalar.comment = comment - if (anchor) ctx.anchors.setAnchor(scalar, anchor) return scalar as Scalar.Parsed } diff --git a/src/compose/resolve-block-map.ts b/src/compose/resolve-block-map.ts index b7beb2d7..feeb1ba4 100644 --- a/src/compose/resolve-block-map.ts +++ b/src/compose/resolve-block-map.ts @@ -11,12 +11,10 @@ export function resolveBlockMap( { composeNode, composeEmptyNode }: ComposeNode, ctx: ComposeContext, { indent, items, offset }: BlockMap, - anchor: string | null, onError: (offset: number, message: string, warning?: boolean) => void ) { const start = offset const map = new YAMLMap(ctx.schema) - if (anchor) ctx.anchors.setAnchor(map, anchor) for (const { start, key, sep, value } of items) { // key properties diff --git a/src/compose/resolve-block-seq.ts b/src/compose/resolve-block-seq.ts index 9eb585f3..ce7dc704 100644 --- a/src/compose/resolve-block-seq.ts +++ b/src/compose/resolve-block-seq.ts @@ -7,12 +7,10 @@ export function resolveBlockSeq( { composeNode, composeEmptyNode }: ComposeNode, ctx: ComposeContext, { items, offset }: BlockSequence, - anchor: string | null, onError: (offset: number, message: string, warning?: boolean) => void ) { const start = offset const seq = new YAMLSeq(ctx.schema) - if (anchor) ctx.anchors.setAnchor(seq, anchor) for (const { start, value } of items) { const props = resolveProps( ctx, diff --git a/src/compose/resolve-flow-collection.ts b/src/compose/resolve-flow-collection.ts index 1022311b..bee16f26 100644 --- a/src/compose/resolve-flow-collection.ts +++ b/src/compose/resolve-flow-collection.ts @@ -11,13 +11,11 @@ export function resolveFlowCollection( { composeNode, composeEmptyNode }: ComposeNode, ctx: ComposeContext, fc: FlowCollection, - _anchor: string | null, onError: (offset: number, message: string, warning?: boolean) => void ) { const isMap = fc.start.source === '{' const coll = isMap ? new YAMLMap(ctx.schema) : new YAMLSeq(ctx.schema) coll.flow = true - if (_anchor) ctx.anchors.setAnchor(coll, _anchor) let key: ParsedNode | null = null let value: ParsedNode | null = null diff --git a/src/doc/Anchors.ts b/src/doc/Anchors.ts deleted file mode 100644 index 926d7e08..00000000 --- a/src/doc/Anchors.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { isCollection, isScalar, Node } from '../nodes/Node.js' - -export class Anchors { - map: Record = Object.create(null) - private prefix: string - - constructor(prefix: string) { - this.prefix = prefix - } - - /** The anchor name associated with `node`, if set. */ - getName(node: Node) { - return Object.keys(this.map).find(a => this.map[a] === node) - } - - /** List of all defined anchor names. */ - getNames() { - return Object.keys(this.map) - } - - /** The node associated with the anchor `name`, if set. */ - getNode(name: string) { - return this.map[name] - } - - /** - * Find an available anchor name with the given `prefix` and a - * numerical suffix. - */ - newName(prefix?: string) { - if (!prefix) prefix = this.prefix - const names = Object.keys(this.map) - for (let i = 1; true; ++i) { - const name = `${prefix}${i}` - if (!names.includes(name)) return name - } - } - - /** - * Associate an anchor with `node`. If `name` is empty, a new name will be generated. - * To remove an anchor, use `setAnchor(null, name)`. - */ - setAnchor(node: Node | null, name?: string) { - const { map } = this - if (!node) { - if (!name) return null - delete map[name] - return name - } - - if (!isScalar(node) && !isCollection(node)) - throw new Error('Anchors may only be set for Scalar, Seq and Map nodes') - if (name) { - if (/[\x00-\x19\s,[\]{}]/.test(name)) - throw new Error( - 'Anchor names must not contain whitespace or control characters' - ) - const prevNode = map[name] - if (prevNode && prevNode !== node) map[this.newName(name)] = prevNode - } - - const prevName = Object.keys(map).find(a => map[a] === node) - if (prevName) { - if (!name || prevName === name) return prevName - delete map[prevName] - } else if (!name) name = this.newName() - map[name] = node - return name - } -} diff --git a/src/doc/Document.ts b/src/doc/Document.ts index 3d4e1adf..3d636d0b 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -10,7 +10,8 @@ import { ParsedNode } from '../nodes/Node.js' import { Pair } from '../nodes/Pair.js' -import { toJS, ToJSAnchorValue, ToJSContext } from '../nodes/toJS.js' +import type { Scalar } from '../nodes/Scalar.js' +import { toJS, ToJSContext } from '../nodes/toJS.js' import type { YAMLMap } from '../nodes/YAMLMap.js' import type { YAMLSeq } from '../nodes/YAMLSeq.js' import { @@ -26,7 +27,7 @@ import { import { Schema } from '../schema/Schema.js' import { stringify } from '../stringify/stringify.js' import { stringifyDocument } from '../stringify/stringifyDocument.js' -import { Anchors } from './Anchors.js' +import { createNodeAnchors, findNewAnchor } from './anchors.js' import { applyReviver } from './applyReviver.js' import { createNode, CreateNodeContext } from './createNode.js' import { Directives } from './directives.js' @@ -44,12 +45,6 @@ export declare namespace Document { export class Document { readonly [NODE_TYPE]: symbol - /** - * Anchors associated with the document's nodes; - * also provides alias & merge node creators. - */ - anchors: Anchors - /** A comment before this Document */ commentBefore: string | null = null @@ -100,7 +95,6 @@ export class Document { const opt = Object.assign({}, defaultOptions, options) this.options = opt - this.anchors = new Anchors(this.options.anchorPrefix) let { version } = opt if (options?.directives) { this.directives = options.directives.atDocument() @@ -126,11 +120,14 @@ export class Document { /** * Create a new `Alias` node, adding the required anchor for `node`. - * If `name` is empty, a new anchor name will be generated. + * If `name` is empty, a new anchor name will be generated. If `node` + * already has an anchor, that will be used instead of `name`. */ - createAlias(node: Node, name?: string) { - this.anchors.setAnchor(node, name) - return new Alias(node) + createAlias(node: Scalar | YAMLMap | YAMLSeq, name?: string): Alias { + if (!node.anchor) { + node.anchor = name || findNewAnchor(this.options.anchorPrefix, this) + } + return new Alias(node.anchor) } /** @@ -139,7 +136,14 @@ export class Document { */ createNode( value: unknown, - { flow, keepUndefined, onTagObj, replacer, tag }: CreateNodeOptions = {} + { + anchorPrefix, + flow, + keepUndefined, + onTagObj, + replacer, + tag + }: CreateNodeOptions = {} ): Node { if (typeof replacer === 'function') value = replacer.call({ '': value }, '', value) @@ -149,37 +153,22 @@ export class Document { const asStr = replacer.filter(keyToStr).map(String) if (asStr.length > 0) replacer = replacer.concat(asStr) } - if (typeof keepUndefined !== 'boolean') - keepUndefined = !!this.options.keepUndefined - const aliasNodes: Alias[] = [] + + const { onAnchor, setAnchors, sourceObjects } = createNodeAnchors( + this, + anchorPrefix || this.options.anchorPrefix + ) const ctx: CreateNodeContext = { - keepUndefined, - onAlias(source) { - // These get fixed later in createNode() - const alias = new Alias((source as unknown) as Node) - aliasNodes.push(alias) - return alias - }, + keepUndefined: keepUndefined ?? this.options.keepUndefined, + onAnchor, onTagObj, - prevObjects: new Map(), replacer, - schema: this.schema + schema: this.schema, + sourceObjects } - const node = createNode(value, tag, ctx) - for (const alias of aliasNodes) { - // With circular references, the source node is only resolved after all of - // its child nodes are. This is why anchors are set only after all of the - // nodes have been created. - alias.source = (alias.source as any).node as Node - let name = this.anchors.getName(alias.source) - if (!name) { - name = this.anchors.newName() - this.anchors.map[name] = alias.source - } - } if (flow && isCollection(node)) node.flow = true - + setAnchors() return node } @@ -336,16 +325,8 @@ export class Document { onAnchor, reviver }: ToJSOptions & { json?: boolean; jsonArg?: string | null } = {}) { - const anchorNodes = Object.values(this.anchors.map).map( - node => - [node, { alias: [], aliasCount: 0, count: 1 }] as [ - Node, - ToJSAnchorValue - ] - ) - const anchors = anchorNodes.length > 0 ? new Map(anchorNodes) : null const ctx: ToJSContext = { - anchors, + anchors: new Map(), doc: this, keep: !json, mapAsMap: mapAsMap === true, @@ -354,8 +335,8 @@ export class Document { stringify } const res = toJS(this.contents, jsonArg || '', ctx) - if (typeof onAnchor === 'function' && anchors) - for (const { count, res } of anchors.values()) onAnchor(res, count) + if (typeof onAnchor === 'function') + for (const { count, res } of ctx.anchors.values()) onAnchor(res, count) return typeof reviver === 'function' ? applyReviver(reviver, { '': res }, '', res) : res diff --git a/src/doc/anchors.ts b/src/doc/anchors.ts new file mode 100644 index 00000000..d7effbe6 --- /dev/null +++ b/src/doc/anchors.ts @@ -0,0 +1,89 @@ +import { isCollection, isScalar, Node } from '../nodes/Node.js' +import type { Scalar } from '../nodes/Scalar.js' +import type { YAMLMap } from '../nodes/YAMLMap.js' +import type { YAMLSeq } from '../nodes/YAMLSeq.js' +import { visit } from '../visit.js' +import { CreateNodeContext } from './createNode.js' +import type { Document } from './Document.js' + +/** + * Verify that the input string is a valid anchor. + * + * Will throw on errors. + */ +export function anchorIsValid(anchor: string): true { + if (/[\x00-\x19\s,[\]{}]/.test(anchor)) { + const sa = JSON.stringify(anchor) + const msg = `Anchor must not contain whitespace or control characters: ${sa}` + throw new Error(msg) + } + return true +} + +export function anchorNames(root: Document | Node) { + const anchors = new Set() + const addNode = (_key: unknown, node: Scalar | YAMLMap | YAMLSeq) => { + if (node.anchor) anchors.add(node.anchor) + } + visit(root, { Scalar: addNode, Map: addNode, Seq: addNode }) + return anchors +} + +/** + * Find a new anchor name with the given `prefix` and a one-indexed suffix. + * + * The second argument may either be a YAML Document, or a Set of strings + * against which generated anchors are tested; this is intended to allow for + * caching, should multiple new anchors be needed within a single operation. + */ +export function findNewAnchor(prefix: string, doc: Document): string +export function findNewAnchor(prefix: string, cache: Set): string +export function findNewAnchor(prefix: string, cache: Document | Set) { + const exclude = cache instanceof Set ? cache : anchorNames(cache) + for (let i = 1; true; ++i) { + const name = `${prefix}${i}` + if (!exclude.has(name)) return name + } +} + +export function createNodeAnchors(doc: Document, prefix: string) { + const aliasObjects: unknown[] = [] + const sourceObjects: CreateNodeContext['sourceObjects'] = new Map() + let prevAnchors: Set | null = null + + return { + onAnchor(source: unknown) { + aliasObjects.push(source) + if (!prevAnchors) prevAnchors = anchorNames(doc) + const anchor = findNewAnchor(prefix, prevAnchors) + prevAnchors.add(anchor) + return anchor + }, + + /** + * With circular references, the source node is only resolved after all + * of its child nodes are. This is why anchors are set only after all of + * the nodes have been created. + */ + setAnchors() { + for (const source of aliasObjects) { + const ref = sourceObjects.get(source) + if ( + typeof ref === 'object' && + ref.anchor && + (isScalar(ref.node) || isCollection(ref.node)) + ) { + ref.node.anchor = ref.anchor + } else { + const error = new Error( + 'Failed to resolve repeated object (this should not happen)' + ) as Error & { source: unknown } + error.source = source + throw error + } + } + }, + + sourceObjects + } +} diff --git a/src/doc/createNode.ts b/src/doc/createNode.ts index dd100cd2..b2a0f2bc 100644 --- a/src/doc/createNode.ts +++ b/src/doc/createNode.ts @@ -1,4 +1,4 @@ -import type { Alias } from '../nodes/Alias.js' +import { Alias } from '../nodes/Alias.js' import { isNode, isPair, MAP, Node, SEQ } from '../nodes/Node.js' import { Scalar } from '../nodes/Scalar.js' import type { YAMLMap } from '../nodes/YAMLMap.js' @@ -8,16 +8,11 @@ import type { Replacer } from './Document.js' const defaultTagPrefix = 'tag:yaml.org,2002:' -export interface CreateNodeAliasRef { - node: Node | undefined - value: unknown -} - export interface CreateNodeContext { keepUndefined?: boolean - onAlias(source: CreateNodeAliasRef): Alias + onAnchor(source: unknown): string onTagObj?: (tagObj: ScalarTag | CollectionTag) => void - prevObjects: Map + sourceObjects: Map replacer?: Replacer schema: Schema } @@ -57,7 +52,22 @@ export function createNode( value = value.valueOf() } - const { onAlias, onTagObj, prevObjects, schema } = ctx + const { onAnchor, onTagObj, schema, sourceObjects } = ctx + + // Detect duplicate references to the same object & use Alias nodes for all + // after first. The `ref` wrapper allows for circular references to resolve. + let ref: { anchor: string | null; node: Node | null } | undefined = undefined + if (value && typeof value === 'object') { + ref = sourceObjects.get(value) + if (ref) { + if (!ref.anchor) ref.anchor = onAnchor(value) + return new Alias(ref.anchor) + } else { + ref = { anchor: null, node: null } + sourceObjects.set(value, ref) + } + } + if (tagName && tagName.startsWith('!!')) tagName = defaultTagPrefix + tagName.slice(2) @@ -78,21 +88,11 @@ export function createNode( delete ctx.onTagObj } - // Detect duplicate references to the same object & use Alias nodes for all - // after first. The `ref` wrapper allows for circular references to resolve. - const ref: CreateNodeAliasRef = { value: undefined, node: undefined } - if (value && typeof value === 'object') { - const prev = prevObjects.get(value) - if (prev) return onAlias(prev) - ref.value = value - prevObjects.set(value, ref) - } - const node = tagObj?.createNode ? tagObj.createNode(ctx.schema, value, ctx) : new Scalar(value) if (tagName) node.tag = tagName - ref.node = node + if (ref) ref.node = node return node } diff --git a/src/nodes/Alias.ts b/src/nodes/Alias.ts index 130b9ba2..9814f95c 100644 --- a/src/nodes/Alias.ts +++ b/src/nodes/Alias.ts @@ -1,6 +1,12 @@ -import { StringifyContext } from '../stringify/stringify.js' +import { anchorIsValid } from '../doc/anchors' +import type { Document } from '../doc/Document' +import type { StringifyContext } from '../stringify/stringify.js' +import { visit } from '../visit' import { ALIAS, isAlias, isCollection, isPair, Node, NodeBase } from './Node.js' -import { toJS, ToJSContext } from './toJS.js' +import type { Scalar } from './Scalar' +import type { ToJSContext } from './toJS.js' +import type { YAMLMap } from './YAMLMap' +import type { YAMLSeq } from './YAMLSeq' export declare namespace Alias { interface Parsed extends Alias { @@ -8,10 +14,12 @@ export declare namespace Alias { } } -export class Alias extends NodeBase { - source: T +export class Alias extends NodeBase { + source: string - constructor(source: T) { + declare anchor?: never + + constructor(source: string) { super(ALIAS) this.source = source Object.defineProperty(this, 'tag', { @@ -21,61 +29,84 @@ export class Alias extends NodeBase { }) } - toJSON(arg?: unknown, ctx?: ToJSContext) { - if (!ctx) - return toJS(this.source, typeof arg === 'string' ? arg : null, ctx) - const { anchors, maxAliasCount } = ctx - const anchor = anchors && anchors.get(this.source) + /** + * Resolve the value of this alias within `doc`, finding the last + * instance of the `source` anchor before this node. + */ + resolve(doc: Document): Scalar | YAMLMap | YAMLSeq | undefined { + let found: Scalar | YAMLMap | YAMLSeq | undefined = undefined + const find = (_key: unknown, node: Node) => { + if (node === this) return visit.BREAK + if (node.anchor === this.source) found = node + } + visit(doc, { Alias: find, Scalar: find, Map: find, Seq: find }) + return found + } + + toJSON(_arg?: unknown, ctx?: ToJSContext) { + if (!ctx) return { source: this.source } + const { anchors, doc, maxAliasCount } = ctx + const source = this.resolve(doc) + if (!source) { + const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}` + throw new ReferenceError(msg) + } + const data = anchors.get(source) /* istanbul ignore if */ - if (!anchor || anchor.res === undefined) { + if (!data || data.res === undefined) { const msg = 'This should not happen: Alias anchor was not resolved?' throw new ReferenceError(msg) } if (maxAliasCount >= 0) { - anchor.count += 1 - if (anchor.aliasCount === 0) - anchor.aliasCount = getAliasCount(this.source, anchors) - if (anchor.count * anchor.aliasCount > maxAliasCount) { + data.count += 1 + if (data.aliasCount === 0) + data.aliasCount = getAliasCount(doc, source, anchors) + if (data.count * data.aliasCount > maxAliasCount) { const msg = 'Excessive alias count indicates a resource exhaustion attack' throw new ReferenceError(msg) } } - return anchor.res + return data.res } - // Only called when stringifying an alias mapping key while constructing - // Object output. toString( - { anchors, doc, implicitKey, inStringifyKey }: StringifyContext, + ctx?: StringifyContext, _onComment?: () => void, _onChompKeep?: () => void ) { - let anchor = Object.keys(anchors).find(a => anchors[a] === this.source) - if (!anchor && inStringifyKey) - anchor = doc.anchors.getName(this.source) || doc.anchors.newName() - if (anchor) return `*${anchor}${implicitKey ? ' ' : ''}` - const msg = doc.anchors.getName(this.source) - ? 'Alias node must be after source node' - : 'Source node not found for alias node' - throw new Error(`${msg} [${this.range}]`) + const src = `*${this.source}` + if (ctx) { + anchorIsValid(this.source) + if (ctx.options.verifyAliasOrder && !ctx.anchors.has(this.source)) { + const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}` + throw new Error(msg) + } + if (ctx.implicitKey) return `${src} ` + } + return src } } -function getAliasCount(node: unknown, anchors: ToJSContext['anchors']): number { +function getAliasCount( + doc: Document, + node: unknown, + anchors: ToJSContext['anchors'] +): number { if (isAlias(node)) { - const anchor = anchors && anchors.get(node.source) + const source = node.resolve(doc) + const anchor = anchors && source && anchors.get(source) return anchor ? anchor.count * anchor.aliasCount : 0 } else if (isCollection(node)) { let count = 0 for (const item of node.items) { - const c = getAliasCount(item, anchors) + const c = getAliasCount(doc, item, anchors) if (c > count) count = c } return count } else if (isPair(node)) { - const kc = getAliasCount(node.key, anchors) - const vc = getAliasCount(node.value, anchors) + const kc = getAliasCount(doc, node.key, anchors) + const vc = getAliasCount(doc, node.value, anchors) return Math.max(kc, vc) } return 1 diff --git a/src/nodes/Collection.ts b/src/nodes/Collection.ts index 7ce5389b..b40547e3 100644 --- a/src/nodes/Collection.ts +++ b/src/nodes/Collection.ts @@ -27,11 +27,11 @@ export function collectionFromPath( } } return createNode(v, undefined, { - onAlias() { + onAnchor() { throw new Error('Repeated objects are not supported here') }, - prevObjects: new Map(), - schema + schema, + sourceObjects: new Map() }) } @@ -49,6 +49,9 @@ export abstract class Collection extends NodeBase { declare items: unknown[] + /** An optional anchor on this node. Used by alias nodes. */ + declare anchor?: string + /** * If true, stringify this and all child nodes using flow rather than * block styles. diff --git a/src/nodes/Node.ts b/src/nodes/Node.ts index e5d72a52..e47d3677 100644 --- a/src/nodes/Node.ts +++ b/src/nodes/Node.ts @@ -62,6 +62,9 @@ export function isNode(node: any): node is Node { return false } +export const hasAnchor = (node: unknown): node is Scalar | YAMLMap | YAMLSeq => + (isScalar(node) || isCollection(node)) && !!node.anchor + export abstract class NodeBase { readonly [NODE_TYPE]: symbol diff --git a/src/nodes/Scalar.ts b/src/nodes/Scalar.ts index e731c303..1f978018 100644 --- a/src/nodes/Scalar.ts +++ b/src/nodes/Scalar.ts @@ -28,7 +28,8 @@ export class Scalar extends NodeBase { value: T - declare type?: Scalar.Type + /** An optional anchor on this node. Used by alias nodes. */ + declare anchor?: string /** * By default (undefined), numbers use decimal notation. @@ -43,6 +44,9 @@ export class Scalar extends NodeBase { /** Set during parsing to the source string value */ declare source?: string + /** The scalar style used for the node's string representation */ + declare type?: Scalar.Type + constructor(value: T) { super(SCALAR) this.value = value diff --git a/src/nodes/addPairToJSMap.ts b/src/nodes/addPairToJSMap.ts index c2a25a0c..ed6cceef 100644 --- a/src/nodes/addPairToJSMap.ts +++ b/src/nodes/addPairToJSMap.ts @@ -63,9 +63,9 @@ function mergeToJSMap( | Record, value: unknown ) { - if (!isAlias(value) || !isMap(value.source)) - throw new Error('Merge sources must be map aliases') - const srcMap = value.source.toJSON(null, ctx, Map) + const source = ctx && isAlias(value) ? value.resolve(ctx.doc) : null + if (!isMap(source)) throw new Error('Merge sources must be 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) @@ -92,6 +92,9 @@ function stringifyKey( if (typeof jsKey !== 'object') return String(jsKey) if (isNode(key) && ctx && ctx.doc) { const strCtx = createStringifyContext(ctx.doc, {}) + strCtx.anchors = new Set() + for (const node of ctx.anchors.keys()) + strCtx.anchors.add(node.anchor as 'string') strCtx.inFlow = true strCtx.inStringifyKey = true const strKey = key.toString(strCtx) diff --git a/src/nodes/toJS.ts b/src/nodes/toJS.ts index ce368d1d..c519b150 100644 --- a/src/nodes/toJS.ts +++ b/src/nodes/toJS.ts @@ -1,16 +1,15 @@ import type { Document } from '../doc/Document.js' import type { stringify } from '../stringify/stringify.js' -import type { Node } from './Node.js' +import { hasAnchor, Node } from './Node.js' -export interface ToJSAnchorValue { - alias: string[] +export interface AnchorData { aliasCount: number count: number - res?: unknown + res: unknown } export interface ToJSContext { - anchors: Map | null + anchors: Map doc: Document keep: boolean mapAsMap: boolean @@ -35,17 +34,17 @@ export interface ToJSContext { export function toJS(value: any, arg: string | null, ctx?: ToJSContext): any { if (Array.isArray(value)) return value.map((v, i) => toJS(v, String(i), ctx)) if (value && typeof value.toJSON === 'function') { - if (!ctx) return value.toJSON(arg) - const anchor = ctx.anchors && ctx.anchors.get(value) - if (anchor) - ctx.onCreate = res => { - anchor.res = res - delete ctx.onCreate - } + if (!ctx || !hasAnchor(value)) return value.toJSON(arg, ctx) + const data: AnchorData = { aliasCount: 0, count: 1, res: undefined } + ctx.anchors.set(value, data) + ctx.onCreate = res => { + data.res = res + delete ctx.onCreate + } const res = value.toJSON(arg, ctx) - if (anchor && ctx.onCreate) ctx.onCreate(res) + if (ctx.onCreate) ctx.onCreate(res) return res } - if (!(ctx && ctx.keep) && typeof value === 'bigint') return Number(value) + if (typeof value === 'bigint' && !(ctx && ctx.keep)) return Number(value) return value } diff --git a/src/options.ts b/src/options.ts index c24ce1e4..7779ee77 100644 --- a/src/options.ts +++ b/src/options.ts @@ -119,6 +119,13 @@ export type SchemaOptions = { } export type CreateNodeOptions = { + /** + * Default prefix for anchors. + * + * Default: `'a'`, resulting in anchors `a1`, `a2`, etc. + */ + anchorPrefix?: string + /** Force the top-level collection node to use flow style. */ flow?: boolean @@ -295,6 +302,15 @@ export type ToStringOptions = { * Default: `'true'` */ trueStr?: string + + /** + * The anchor used by an alias must be defined before the alias node. As it's + * possible for the document to be modified manually, the order may be + * verified during stringification. + * + * Default: `'true'` + */ + verifyAliasOrder?: boolean } export type Options = ParseOptions & DocumentOptions & SchemaOptions diff --git a/src/stringify/stringify.ts b/src/stringify/stringify.ts index a23d6214..8a96827a 100644 --- a/src/stringify/stringify.ts +++ b/src/stringify/stringify.ts @@ -1,5 +1,13 @@ +import { anchorIsValid } from '../doc/anchors.js' import type { Document } from '../doc/Document.js' -import { isAlias, isNode, isPair, isScalar, Node } from '../nodes/Node.js' +import { + isAlias, + isCollection, + isNode, + isPair, + isScalar, + Node +} from '../nodes/Node.js' import type { Scalar } from '../nodes/Scalar.js' import type { ToStringOptions } from '../options.js' import type { CollectionTag, ScalarTag } from '../schema/types.js' @@ -8,7 +16,7 @@ import { stringifyString } from './stringifyString.js' export type StringifyContext = { actualString?: boolean allNullValues?: boolean - anchors: Record + anchors: Set doc: Document forceBlockIndent?: boolean implicitKey?: boolean @@ -24,7 +32,7 @@ export const createStringifyContext = ( doc: Document, options: ToStringOptions ): StringifyContext => ({ - anchors: Object.create(null), + anchors: new Set(), doc, indent: '', indentStep: @@ -43,7 +51,8 @@ export const createStringifyContext = ( nullStr: 'null', simpleKeys: false, singleQuote: false, - trueStr: 'true' + trueStr: 'true', + verifyAliasOrder: true }, options ) @@ -83,9 +92,9 @@ function stringifyProps( { anchors, doc }: StringifyContext ) { const props = [] - const anchor = doc.anchors.getName(node) - if (anchor) { - anchors[anchor] = node + const anchor = (isScalar(node) || isCollection(node)) && node.anchor + if (anchor && anchorIsValid(anchor)) { + anchors.add(anchor) props.push(`&${anchor}`) } if (node.tag) { diff --git a/src/stringify/stringifyPair.ts b/src/stringify/stringifyPair.ts index 1c9d7c9f..49b14a4e 100644 --- a/src/stringify/stringifyPair.ts +++ b/src/stringify/stringifyPair.ts @@ -105,7 +105,7 @@ export function stringifyPair( isSeq(value) && !value.flow && !value.tag && - !doc.anchors.getName(value) + !value.anchor ) { // If indentSeq === false, consider '- ' as part of indentation where possible ctx.indent = ctx.indent.substr(2) diff --git a/src/test-events.ts b/src/test-events.ts index df816a3d..034e6f36 100644 --- a/src/test-events.ts +++ b/src/test-events.ts @@ -1,16 +1,20 @@ import { Document } from './doc/Document.js' import { isAlias, + isCollection, isMap, isNode, isPair, + isScalar, isSeq, + Node, ParsedNode } from './nodes/Node.js' -import { Pair } from './nodes/Pair.js' -import { Scalar } from './nodes/Scalar.js' +import type { Pair } from './nodes/Pair.js' +import type { Scalar } from './nodes/Scalar.js' import type { Options } from './options.js' import { parseAllDocuments } from './public-api.js' +import { visit } from './visit.js' const scalarChar: Record = { BLOCK_FOLDED: '>', @@ -20,6 +24,18 @@ const scalarChar: Record = { QUOTE_SINGLE: "'" } +function anchorExists(doc: Document, anchor: string): boolean { + let found = false + const find = (_key: unknown, node: Node) => { + if (node.anchor === anchor) { + found = true + return visit.BREAK + } + } + visit(doc, { Scalar: find, Map: find, Seq: find }) + return found +} + // test harness for yaml-test-suite event tests export function testEvents(src: string, options?: Options) { const opt = Object.assign({ version: '1.2' }, options) @@ -70,11 +86,11 @@ function addEvents( if (errPos !== -1 && isNode(node) && node.range[0] >= errPos) throw new Error() let props = '' - let anchor = isNode(node) ? doc.anchors.getName(node) : undefined + let anchor = isScalar(node) || isCollection(node) ? node.anchor : undefined if (anchor) { if (/\d$/.test(anchor)) { const alt = anchor.replace(/\d$/, '') - if (doc.anchors.getNode(alt)) anchor = alt + if (anchorExists(doc, alt)) anchor = alt } props = ` &${anchor}` } @@ -99,10 +115,10 @@ function addEvents( addEvents(events, doc, errPos, node.value) events.push('-MAP') } else if (isAlias(node)) { - let alias = doc.anchors.getName(node.source) + let alias = node.source if (alias && /\d$/.test(alias)) { const alt = alias.replace(/\d$/, '') - if (doc.anchors.getNode(alt)) alias = alt + if (anchorExists(doc, alt)) alias = alt } events.push(`=ALI${props} *${alias}`) } else { diff --git a/src/util.ts b/src/util.ts index 3bbcf56d..6ba8320e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,6 @@ export { debug, LogLevelId, warn } from './log.js' export { findPair } from './nodes/YAMLMap.js' -export { toJS, ToJSAnchorValue, ToJSContext } from './nodes/toJS.js' +export { toJS, ToJSContext } from './nodes/toJS.js' export { foldFlowLines } from './stringify/foldFlowLines' export { stringifyNumber } from './stringify/stringifyNumber.js' export { stringifyString } from './stringify/stringifyString.js' diff --git a/tests/doc/anchors.js b/tests/doc/anchors.js index eab90cd6..9154a6b9 100644 --- a/tests/doc/anchors.js +++ b/tests/doc/anchors.js @@ -4,9 +4,10 @@ test('basic', () => { const src = `- &a 1\n- *a\n` const doc = YAML.parseDocument(src) expect(doc.errors).toHaveLength(0) - const { items } = doc.contents - expect(items).toMatchObject([{ value: 1 }, { source: { value: 1 } }]) - expect(items[1].source).toBe(items[0]) + expect(doc.contents.items).toMatchObject([ + { anchor: 'a', value: 1 }, + { source: 'a' } + ]) expect(String(doc)).toBe(src) }) @@ -14,14 +15,13 @@ test('re-defined anchor', () => { const src = '- &a 1\n- &a 2\n- *a\n' const doc = YAML.parseDocument(src) expect(doc.errors).toHaveLength(0) - const { items } = doc.contents - expect(items).toMatchObject([ - { value: 1 }, - { value: 2 }, - { source: { value: 2 } } + expect(doc.contents.items).toMatchObject([ + { anchor: 'a', value: 1 }, + { anchor: 'a', value: 2 }, + { source: 'a' } ]) - expect(items[2].source).toBe(items[1]) - expect(String(doc)).toBe('- &a1 1\n- &a 2\n- *a\n') + expect(doc.toJS()).toMatchObject([1, 2, 2]) + expect(String(doc)).toBe('- &a 1\n- &a 2\n- *a\n') }) test('circular reference', () => { @@ -29,38 +29,23 @@ test('circular reference', () => { const doc = YAML.parseDocument(src) expect(doc.errors).toHaveLength(0) expect(doc.warnings).toHaveLength(0) - const { items } = doc.contents - expect(items).toHaveLength(2) - expect(items[1].source).toBe(doc.contents) + expect(doc.contents).toMatchObject({ + anchor: 'a', + items: [{ value: 1 }, { source: 'a' }] + }) const res = doc.toJS() expect(res[1]).toBe(res) expect(String(doc)).toBe(src) }) describe('create', () => { - test('doc.anchors.setAnchor', () => { + test('node.anchor', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]') - const [a, b] = doc.contents.items - expect(doc.anchors.setAnchor(null, null)).toBe(null) - expect(doc.anchors.setAnchor(a, 'XX')).toBe('XX') - expect(doc.anchors.setAnchor(a, 'AA')).toBe('AA') - expect(doc.anchors.setAnchor(a, 'AA')).toBe('AA') - expect(doc.anchors.setAnchor(a)).toBe('AA') - expect(doc.anchors.setAnchor(a.items[0].value)).toBe('a1') - expect(doc.anchors.setAnchor(b.items[0].value)).toBe('a2') - expect(doc.anchors.setAnchor(null, 'a1')).toBe('a1') - expect(doc.anchors.getName(a)).toBe('AA') - expect(doc.anchors.getNode('a2').value).toBe('B') - expect(String(doc)).toBe('[ &AA { a: A }, { b: &a2 B } ]\n') - expect(() => doc.anchors.setAnchor(a.items[0])).toThrow( - 'Anchors may only be set for Scalar, Seq and Map nodes' - ) - expect(() => doc.anchors.setAnchor(a, 'A A')).toThrow( - 'Anchor names must not contain whitespace or control characters' - ) - expect(doc.anchors.setAnchor(a.items[0].value, 'AA')).toBe('AA') - expect(String(doc)).toBe('[ &AA1 { a: &AA A }, { b: &a2 B } ]\n') - expect(doc.anchors.getNames()).toMatchObject(['AA', 'a2', 'AA1']) + doc.get(0).anchor = 'AA' + doc.getIn([0, 'a'], true).anchor = 'a' + doc.getIn([0, 'a'], true).anchor = '' + doc.getIn([1, 'b'], true).anchor = 'BB' + expect(String(doc)).toBe('[ &AA { a: A }, { b: &BB B } ]\n') }) test('doc.createAlias', () => { @@ -70,17 +55,34 @@ describe('create', () => { expect(doc.toJS()).toMatchObject([{ a: 'A' }, { b: 'B' }, { a: 'A' }]) expect(String(doc)).toMatch('[ &AA { a: A }, { b: B }, *AA ]\n') }) +}) + +describe('errors', () => { + test('invalid anchor characters', () => { + const doc = YAML.parseDocument('[{ a: A }, { b: B }]') + doc.get(0).anchor = 'A A' + expect(() => String(doc)).toThrow( + 'Anchor must not contain whitespace or control characters: "A A"' + ) + }) - test('errors', () => { + test('set tag on alias', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]') const node = doc.contents.items[0] const alias = doc.createAlias(node, 'AA') - doc.contents.items.unshift(alias) - expect(() => String(doc)).toThrow('Alias node must be after source node') expect(() => { alias.tag = 'tag:yaml.org,2002:alias' }).toThrow('Alias nodes cannot have tags') }) + + test('alias before anchor', () => { + const doc = YAML.parseDocument('[{ a: A }, { b: B }]') + const alias = doc.createAlias(doc.get(0), 'AA') + doc.contents.items.unshift(alias) + expect(() => String(doc)).toThrow( + 'Unresolved alias (the anchor must be set before the alias): AA' + ) + }) }) describe('__proto__ as anchor name', () => { @@ -88,9 +90,11 @@ describe('__proto__ as anchor name', () => { const src = `- &__proto__ 1\n- *__proto__\n` const doc = YAML.parseDocument(src) expect(doc.errors).toHaveLength(0) - const { items } = doc.contents - expect(items).toMatchObject([{ value: 1 }, { source: { value: 1 } }]) - expect(items[1].source).toBe(items[0]) + expect(doc.contents.items).toMatchObject([ + { anchor: '__proto__', value: 1 }, + { source: '__proto__' } + ]) + expect(doc.toJS()).toMatchObject([1, 1]) expect(String(doc)).toBe(src) }) @@ -150,24 +154,15 @@ describe('merge <<', () => { } }) - test('YAML.parseAllDocuments', () => { + test('YAML.parseDocument', () => { const doc = YAML.parseDocument(src, { merge: true }) - expect(doc.contents.items).toHaveLength(8) - expect(Object.keys(doc.anchors.map)).toMatchObject([ - 'CENTER', - 'LEFT', - 'BIG', - 'SMALL' + expect( + doc.contents.items.slice(5).map(it => it.items[0].value) + ).toMatchObject([ + { source: 'CENTER' }, + { items: [{ source: 'CENTER' }, { source: 'BIG' }] }, + { items: [{ source: 'BIG' }, { source: 'LEFT' }, { source: 'SMALL' }] } ]) - doc.contents.items.slice(5).forEach(({ items }) => { - const merge = items[0] - expect(YAML.isPair(merge)).toBe(true) - if (YAML.isSeq(merge.value)) - merge.value.items.forEach(({ source }) => { - expect(source).toBeInstanceOf(YAML.YAMLMap) - }) - else expect(merge.value.source).toBeInstanceOf(YAML.YAMLMap) - }) }) describe('create', () => { diff --git a/tests/doc/createNode.js b/tests/doc/createNode.js index 46dd4a77..6989be93 100644 --- a/tests/doc/createNode.js +++ b/tests/doc/createNode.js @@ -1,4 +1,5 @@ import { Document, Scalar, YAMLMap, YAMLSeq } from 'yaml' +import { source } from '../_utils' let doc beforeEach(() => { @@ -300,57 +301,64 @@ describe('circular references', () => { test('parent at root', () => { const map = { foo: 'bar' } map.map = map - const doc = new Document(null) - expect(doc.createNode(map)).toMatchObject({ + const doc = new Document(map) + expect(doc.contents).toMatchObject({ + anchor: 'a1', items: [ { key: { value: 'foo' }, value: { value: 'bar' } }, { key: { value: 'map' }, - value: { - source: { - items: [{ key: { value: 'foo' } }, { key: { value: 'map' } }] - } - } + value: { source: 'a1' } } ] }) - expect(doc.anchors.map).toMatchObject({ - a1: { items: [{ key: { value: 'foo' } }, { key: { value: 'map' } }] } - }) + expect(doc.toString()).toBe(source` + &a1 + foo: bar + map: *a1 + `) }) test('ancestor at root', () => { const baz = {} const map = { foo: { bar: { baz } } } baz.map = map - const doc = new Document(null) - const node = doc.createNode(map) - expect(node.getIn(['foo', 'bar', 'baz', 'map'])).toMatchObject({ - source: { items: [{ key: { value: 'foo' } }] } - }) - expect(doc.anchors.map).toMatchObject({ - a1: { items: [{ key: { value: 'foo' } }] } + const doc = new Document(map) + expect(doc.getIn(['foo', 'bar', 'baz', 'map'])).toMatchObject({ + source: 'a1' }) + expect(doc.toString()).toBe(source` + &a1 + foo: + bar: + baz: + map: *a1 + `) }) test('sibling sequences', () => { const one = ['one'] const two = ['two'] const seq = [one, two, one, one, two] - const doc = new Document(null) - expect(doc.createNode(seq)).toMatchObject({ + const doc = new Document(seq) + expect(doc.contents).toMatchObject({ items: [ { items: [{ value: 'one' }] }, { items: [{ value: 'two' }] }, - { source: { items: [{ value: 'one' }] } }, - { source: { items: [{ value: 'one' }] } }, - { source: { items: [{ value: 'two' }] } } + { source: 'a1' }, + { source: 'a1' }, + { source: 'a2' } ] }) - expect(doc.anchors.map).toMatchObject({ - a1: { items: [{ value: 'one' }] }, - a2: { items: [{ value: 'two' }] } - }) + expect(doc.toString()).toBe(source` + - &a1 + - one + - &a2 + - two + - *a1 + - *a1 + - *a2 + `) }) test('further relatives', () => { @@ -363,6 +371,6 @@ describe('circular references', () => { expect(source).toMatchObject({ items: [{ key: { value: 'a' }, value: { value: 1 } }] }) - expect(alias.source).toBe(source) + expect(alias.source).toBe(source.anchor) }) }) diff --git a/tests/doc/errors.js b/tests/doc/errors.js index bc61c81c..16f01acd 100644 --- a/tests/doc/errors.js +++ b/tests/doc/errors.js @@ -288,7 +288,6 @@ describe('invalid options', () => { test('broken document with comment before first node', () => { const doc = YAML.parseDocument('#c\n*x\nfoo\n', { prettyErrors: false }) expect(doc.errors).toMatchObject([ - { message: 'Aliased anchor not found: x' }, { message: 'Unexpected scalar at node end' } ]) }) diff --git a/tests/visit.ts b/tests/visit.ts index 5083732e..a0b7046f 100644 --- a/tests/visit.ts +++ b/tests/visit.ts @@ -33,8 +33,8 @@ test('Alias', () => { visit(doc, { Map: fn, Pair: fn, Seq: fn, Alias: fn, Scalar: fn }) expect(fn.mock.calls).toMatchObject([ [null, coll, [{}]], - [0, { type: 'PLAIN', value: 1 }, [{}, {}]], - [1, { source: { value: 1 } }, [{}, {}]] + [0, { type: 'PLAIN', value: 1, anchor: 'a' }, [{}, {}]], + [1, { source: 'a' }, [{}, {}]] ]) }) From 733d8d881781c17502be23a728d631d53c991221 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sat, 27 Mar 2021 13:31:37 +0200 Subject: [PATCH 5/8] feat!: Refactor & document createNode options BREAKING CHANGE: Previously, `replacer` was passed as an option to createNode(). Now it's an optional second argument, matching the API used by the Document constructor. --- docs/03_options.md | 12 +++++++++- docs/05_content_nodes.md | 9 ++++--- src/doc/Document.ts | 51 ++++++++++++++++++++++++---------------- src/options.ts | 21 ++--------------- src/public-api.ts | 18 ++++++++------ tests/doc/stringify.js | 8 +++---- 6 files changed, 63 insertions(+), 56 deletions(-) diff --git a/docs/03_options.md b/docs/03_options.md index 923515a7..c387baa3 100644 --- a/docs/03_options.md +++ b/docs/03_options.md @@ -40,7 +40,6 @@ Used by: `parse()`, `parseDocument()`, `parseAllDocuments()`, `stringify()`, `ne | Name | Type | Default | Description | | ------------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | anchorPrefix | `string` | `'a'` | Default prefix for anchors, resulting in anchors `a1`, `a2`, ... by default. | -| keepUndefined | `boolean` | `false` | Keep `undefined` object values when creating mappings and return a Scalar node when stringifying `undefined`. | | logLevel | `'warn' ⎮ 'error' ⎮` `'silent'` | `'warn'` | Control the verbosity of `parse()`. Set to `'error'` to silence warnings, and to `'silent'` to also silence most errors (not recommended). | | version | `'1.1' ⎮ '1.2'` | `'1.2'` | The YAML version used by documents without a `%YAML` directive. | @@ -95,6 +94,17 @@ mergeResult.target **Merge** keys are a [YAML 1.1 feature](http://yaml.org/type/merge.html) that is not a part of the 1.2 spec. To use a merge key, assign an alias node or an array of alias nodes as the value of a `<<` key in a mapping. +## CreateNode Options + +Used by: `stringify()`, `new Document()`, `doc.createNode()`, and `doc.createPair()` + +| Name | Type | Default | Description | +| ------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| anchorPrefix | `string` | `'a'` | Default prefix for anchors, resulting in anchors `a1`, `a2`, ... by default. | +| flow | `boolean` | `false` | Force the top-level collection node to use flow style. | +| keepUndefined | `boolean` | `false` | Keep `undefined` object values when creating mappings and return a Scalar node when stringifying `undefined`. | +| tag | `string` | | Specify the top-level collection type, e.g. `"!!omap"`. Note that this requires the corresponding tag to be available in this document's schema. | + ## ToJS Options ```js diff --git a/docs/05_content_nodes.md b/docs/05_content_nodes.md index f52bf536..e25ac70f 100644 --- a/docs/05_content_nodes.md +++ b/docs/05_content_nodes.md @@ -185,17 +185,16 @@ String(doc) // - balloons: 99 ``` -#### `doc.createNode(value, options?): Node` +#### `doc.createNode(value, replacer?, options?): Node` To create a new node, use the `createNode(value, options?)` document method. This will recursively wrap any input with appropriate `Node` containers. Generic JS `Object` values as well as `Map` and its descendants become mappings, while arrays and other iterable objects result in sequences. With `Object`, entries that have an `undefined` value are dropped. -To force flow styling on a collection, use `options.flow = true` -Use `options.replacer` to apply a replacer array or function, following the [JSON implementation][replacer]. -To specify the collection type, set `options.tag` to its identifying string, e.g. `"!!omap"`. -Note that this requires the corresponding tag to be available in the document's schema. +Use a `replacer` to apply a replacer array or function, following the [JSON implementation][replacer]. +To force flow styling on a collection, use the `flow: true` option. +For all available options, see the [CreateNode Options](#createnode-options) section. [replacer]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter diff --git a/src/doc/Document.ts b/src/doc/Document.ts index 3d636d0b..67419585 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -85,7 +85,7 @@ export class Document { options?: Options ) { Object.defineProperty(this, NODE_TYPE, { value: DOC }) - let _replacer: Replacer | undefined = undefined + let _replacer: Replacer | null = null if (typeof replacer === 'function' || Array.isArray(replacer)) { _replacer = replacer } else if (options === undefined && replacer) { @@ -102,10 +102,14 @@ export class Document { } else this.directives = new Directives({ version }) this.setSchema(version, options) - this.contents = - value === undefined - ? null - : ((this.createNode(value, { replacer: _replacer }) as unknown) as T) + if (value === undefined) this.contents = null + else { + this.contents = (this.createNode( + value, + _replacer, + options + ) as unknown) as T + } } /** Adds a value to the document. */ @@ -134,35 +138,42 @@ export class Document { * Convert any value into a `Node` using the current schema, recursively * turning objects into collections. */ + createNode(value: unknown, options?: CreateNodeOptions): Node createNode( value: unknown, - { - anchorPrefix, - flow, - keepUndefined, - onTagObj, - replacer, - tag - }: CreateNodeOptions = {} + replacer: Replacer | CreateNodeOptions | null, + options?: CreateNodeOptions + ): Node + createNode( + value: unknown, + replacer?: Replacer | CreateNodeOptions | null, + options?: CreateNodeOptions ): Node { - if (typeof replacer === 'function') + let _replacer: Replacer | undefined = undefined + if (typeof replacer === 'function') { value = replacer.call({ '': value }, '', value) - else if (Array.isArray(replacer)) { + _replacer = replacer + } else if (Array.isArray(replacer)) { const keyToStr = (v: unknown) => typeof v === 'number' || v instanceof String || v instanceof Number const asStr = replacer.filter(keyToStr).map(String) if (asStr.length > 0) replacer = replacer.concat(asStr) + _replacer = replacer + } else if (options === undefined && replacer) { + options = replacer + replacer = undefined } + const { anchorPrefix, flow, keepUndefined, onTagObj, tag } = options || {} const { onAnchor, setAnchors, sourceObjects } = createNodeAnchors( this, - anchorPrefix || this.options.anchorPrefix + anchorPrefix || 'a' ) const ctx: CreateNodeContext = { - keepUndefined: keepUndefined ?? this.options.keepUndefined, + keepUndefined: keepUndefined ?? false, onAnchor, onTagObj, - replacer, + replacer: _replacer, schema: this.schema, sourceObjects } @@ -181,8 +192,8 @@ export class Document { value: unknown, options: CreateNodeOptions = {} ) { - const k = this.createNode(key, options) as K - const v = this.createNode(value, options) as V + const k = this.createNode(key, null, options) as K + const v = this.createNode(value, null, options) as V return new Pair(k, v) } diff --git a/src/options.ts b/src/options.ts index 7779ee77..4d4f3795 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,6 +1,5 @@ import type { Reviver } from './doc/applyReviver.js' import type { Directives } from './doc/directives.js' -import type { Replacer } from './doc/Document.js' import type { LogLevelId } from './log.js' import type { Pair } from './nodes/Pair.js' import type { Scalar } from './nodes/Scalar.js' @@ -55,14 +54,6 @@ export type DocumentOptions = { */ directives?: Directives - /** - * Keep `undefined` object values when creating mappings and return a Scalar - * node when calling `YAML.stringify(undefined)`, rather than `undefined`. - * - * Default: `false` - */ - keepUndefined?: boolean - /** * Control the logging level during parsing * @@ -140,15 +131,8 @@ export type CreateNodeOptions = { onTagObj?: (tagObj: ScalarTag | CollectionTag) => void /** - * Filter or modify values while creating a node. - * - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter - */ - replacer?: Replacer - - /** - * Specify the collection type, e.g. `"!!omap"`. Note that this requires the - * corresponding tag to be available in this document's schema. + * Specify the top-level collection type, e.g. `"!!omap"`. Note that this + * requires the corresponding tag to be available in this document's schema. */ tag?: string } @@ -327,7 +311,6 @@ export const defaultOptions: Required< > = { anchorPrefix: 'a', intAsBigInt: false, - keepUndefined: false, logLevel: 'warn', prettyErrors: true, strict: true, diff --git a/src/public-api.ts b/src/public-api.ts index 1e6d8d15..16fd7dbd 100644 --- a/src/public-api.ts +++ b/src/public-api.ts @@ -4,7 +4,12 @@ import { Document, Replacer } from './doc/Document.js' import { prettifyError, YAMLParseError } from './errors.js' import { warn } from './log.js' import type { ParsedNode } from './nodes/Node.js' -import type { Options, ToJSOptions, ToStringOptions } from './options.js' +import type { + CreateNodeOptions, + Options, + ToJSOptions, + ToStringOptions +} from './options.js' import { LineCounter } from './parse/line-counter.js' import { Parser } from './parse/parser.js' @@ -139,17 +144,17 @@ export function parse( */ export function stringify( value: any, - options?: Options & ToStringOptions + options?: Options & CreateNodeOptions & ToStringOptions ): string export function stringify( value: any, replacer?: Replacer | null, - options?: string | number | (Options & ToStringOptions) + options?: string | number | (Options & CreateNodeOptions & ToStringOptions) ): string export function stringify( value: any, - replacer?: Replacer | (Options & ToStringOptions) | null, - options?: string | number | (Options & ToStringOptions) + replacer?: Replacer | (Options & CreateNodeOptions & ToStringOptions) | null, + options?: string | number | (Options & CreateNodeOptions & ToStringOptions) ) { let _replacer: Replacer | null = null if (typeof replacer === 'function' || Array.isArray(replacer)) { @@ -164,8 +169,7 @@ export function stringify( options = indent < 1 ? undefined : indent > 8 ? { indent: 8 } : { indent } } if (value === undefined) { - const { keepUndefined } = - options || (replacer as Options & ToStringOptions) || {} + const { keepUndefined } = options || (replacer as CreateNodeOptions) || {} if (!keepUndefined) return undefined } return new Document(value, _replacer, options).toString(options) diff --git a/tests/doc/stringify.js b/tests/doc/stringify.js index 4c6e7985..14f988bc 100644 --- a/tests/doc/stringify.js +++ b/tests/doc/stringify.js @@ -942,10 +942,10 @@ describe('replacer', () => { }) test('createNode, !!set', () => { - const replacer = jest.fn((key, value) => value) + const replacer = jest.fn((_key, value) => value) const doc = new YAML.Document(null, { customTags: ['set'] }) const set = new Set(['a', 'b', 1, [2]]) - doc.createNode(set, { replacer }) + doc.createNode(set, replacer) expect(replacer.mock.calls).toMatchObject([ ['', set], ['a', 'a'], @@ -965,13 +965,13 @@ describe('replacer', () => { }) test('createNode, !!omap', () => { - const replacer = jest.fn((key, value) => value) + const replacer = jest.fn((_key, value) => value) const doc = new YAML.Document(null, { customTags: ['omap'] }) const omap = [ ['a', 1], [1, 'a'] ] - doc.createNode(omap, { replacer, tag: '!!omap' }) + doc.createNode(omap, replacer, { tag: '!!omap' }) expect(replacer.mock.calls).toMatchObject([ ['', omap], ['0', omap[0]], From 9be9080e5dc69e4ca445aaf27e5b53e3b86af9a2 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sat, 27 Mar 2021 14:04:58 +0200 Subject: [PATCH 6/8] refactor: Drop the catch-all Options type --- src/compose/compose-doc.ts | 8 ++++-- src/compose/composer.ts | 16 ++++++++--- src/doc/Document.ts | 19 +++++++++---- src/index.ts | 3 +- src/options.ts | 2 -- src/public-api.ts | 57 ++++++++++++++++++++++++++++++-------- src/test-events.ts | 6 ++-- 7 files changed, 81 insertions(+), 30 deletions(-) diff --git a/src/compose/compose-doc.ts b/src/compose/compose-doc.ts index 3c6c1c0c..22646760 100644 --- a/src/compose/compose-doc.ts +++ b/src/compose/compose-doc.ts @@ -1,6 +1,10 @@ import type { Directives } from '../doc/directives.js' import { Document } from '../doc/Document.js' -import type { Options } from '../options.js' +import type { + DocumentOptions, + ParseOptions, + SchemaOptions +} from '../options.js' import type * as Tokens from '../parse/tokens.js' import { ComposeContext, @@ -11,7 +15,7 @@ import { resolveEnd } from './resolve-end.js' import { resolveProps } from './resolve-props.js' export function composeDoc( - options: Options, + options: ParseOptions & DocumentOptions & SchemaOptions, directives: Directives, { offset, start, value, end }: Tokens.Document, onError: (offset: number, message: string, warning?: boolean) => void diff --git a/src/compose/composer.ts b/src/compose/composer.ts index 5590353b..8175bf87 100644 --- a/src/compose/composer.ts +++ b/src/compose/composer.ts @@ -2,7 +2,12 @@ import { Directives } from '../doc/directives.js' import { Document } from '../doc/Document.js' import { YAMLParseError, YAMLWarning } from '../errors.js' import { isCollection } from '../nodes/Node.js' -import { defaultOptions, Options } from '../options.js' +import { + defaultOptions, + DocumentOptions, + ParseOptions, + SchemaOptions +} from '../options.js' import type { Token } from '../parse/tokens.js' import { composeDoc } from './compose-doc.js' import { resolveEnd } from './resolve-end.js' @@ -38,7 +43,7 @@ function parsePrelude(prelude: string[]) { * Compose a stream of CST nodes into a stream of YAML Documents. * * ```ts - * const options: Options = { ... } + * const options = { ... } * const docs: Document.Parsed[] = [] * const composer = new Composer(doc => docs.push(doc), options) * const parser = new Parser(composer.next) @@ -50,13 +55,16 @@ export class Composer { private directives: Directives private doc: Document.Parsed | null = null private onDocument: (doc: Document.Parsed) => void - private options: Options + private options: ParseOptions & DocumentOptions & SchemaOptions private atDirectives = false private prelude: string[] = [] private errors: YAMLParseError[] = [] private warnings: YAMLWarning[] = [] - constructor(onDocument: Composer['onDocument'], options: Options = {}) { + constructor( + onDocument: Composer['onDocument'], + options: ParseOptions & DocumentOptions & SchemaOptions = {} + ) { this.directives = new Directives({ version: options?.version || defaultOptions.version }) diff --git a/src/doc/Document.ts b/src/doc/Document.ts index 67419585..8fbbd1bf 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -18,7 +18,6 @@ import { CreateNodeOptions, defaultOptions, DocumentOptions, - Options, ParseOptions, SchemaOptions, ToJSOptions, @@ -77,12 +76,22 @@ export class Document { * @param value - The initial value for the document, which will be wrapped * in a Node container. */ - constructor(value?: any, options?: Options) - constructor(value: any, replacer: null | Replacer, options?: Options) + constructor( + value?: any, + options?: DocumentOptions & SchemaOptions & ParseOptions & CreateNodeOptions + ) + constructor( + value: any, + replacer: null | Replacer, + options?: DocumentOptions & SchemaOptions & ParseOptions & CreateNodeOptions + ) constructor( value?: unknown, - replacer?: Replacer | Options | null, - options?: Options + replacer?: + | Replacer + | (DocumentOptions & SchemaOptions & ParseOptions & CreateNodeOptions) + | null, + options?: DocumentOptions & SchemaOptions & ParseOptions & CreateNodeOptions ) { Object.defineProperty(this, NODE_TYPE, { value: DOC }) let _replacer: Replacer | null = null diff --git a/src/index.ts b/src/index.ts index 92111ca4..dbb9c043 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,8 @@ export { YAMLSeq } from './nodes/YAMLSeq.js' export { CreateNodeOptions, defaultOptions, - Options, + DocumentOptions, + ParseOptions, SchemaOptions, ToJSOptions, ToStringOptions diff --git a/src/options.ts b/src/options.ts index 4d4f3795..2deddd22 100644 --- a/src/options.ts +++ b/src/options.ts @@ -297,8 +297,6 @@ export type ToStringOptions = { verifyAliasOrder?: boolean } -export type Options = ParseOptions & DocumentOptions & SchemaOptions - /** * `yaml` defines document-specific options in three places: as an argument of * parse, create and stringify calls, in the values of `YAML.defaultOptions`, diff --git a/src/public-api.ts b/src/public-api.ts index 16fd7dbd..946cee86 100644 --- a/src/public-api.ts +++ b/src/public-api.ts @@ -6,7 +6,9 @@ import { warn } from './log.js' import type { ParsedNode } from './nodes/Node.js' import type { CreateNodeOptions, - Options, + DocumentOptions, + ParseOptions, + SchemaOptions, ToJSOptions, ToStringOptions } from './options.js' @@ -19,7 +21,7 @@ export interface EmptyStream empty: true } -function parseOptions(options: Options | undefined) { +function parseOptions(options: ParseOptions | undefined) { const prettyErrors = !options || options.prettyErrors !== false const lineCounter = (options && options.lineCounter) || @@ -39,7 +41,7 @@ function parseOptions(options: Options | undefined) { */ export function parseAllDocuments( source: string, - options?: Options + options?: ParseOptions & DocumentOptions & SchemaOptions ): Document.Parsed[] | EmptyStream { const { lineCounter, prettyErrors } = parseOptions(options) @@ -69,7 +71,7 @@ export function parseAllDocuments( /** Parse an input string into a single YAML.Document */ export function parseDocument( source: string, - options?: Options + options?: ParseOptions & DocumentOptions & SchemaOptions ) { const { lineCounter, prettyErrors } = parseOptions(options) @@ -107,17 +109,22 @@ export function parseDocument( * document, so Maps become objects, Sequences arrays, and scalars result in * nulls, booleans, numbers and strings. */ -export function parse(src: string, options?: Options & ToJSOptions): any +export function parse( + src: string, + options?: ParseOptions & DocumentOptions & SchemaOptions & ToJSOptions +): any export function parse( src: string, reviver: Reviver, - options?: Options & ToJSOptions + options?: ParseOptions & DocumentOptions & SchemaOptions & ToJSOptions ): any export function parse( src: string, - reviver?: Reviver | (Options & ToJSOptions), - options?: Options & ToJSOptions + reviver?: + | Reviver + | (ParseOptions & DocumentOptions & SchemaOptions & ToJSOptions), + options?: ParseOptions & DocumentOptions & SchemaOptions & ToJSOptions ) { let _reviver: Reviver | undefined = undefined if (typeof reviver === 'function') { @@ -144,17 +151,43 @@ export function parse( */ export function stringify( value: any, - options?: Options & CreateNodeOptions & ToStringOptions + options?: DocumentOptions & + SchemaOptions & + ParseOptions & + CreateNodeOptions & + ToStringOptions ): string export function stringify( value: any, replacer?: Replacer | null, - options?: string | number | (Options & CreateNodeOptions & ToStringOptions) + options?: + | string + | number + | (DocumentOptions & + SchemaOptions & + ParseOptions & + CreateNodeOptions & + ToStringOptions) ): string + export function stringify( value: any, - replacer?: Replacer | (Options & CreateNodeOptions & ToStringOptions) | null, - options?: string | number | (Options & CreateNodeOptions & ToStringOptions) + replacer?: + | Replacer + | (DocumentOptions & + SchemaOptions & + ParseOptions & + CreateNodeOptions & + ToStringOptions) + | null, + options?: + | string + | number + | (DocumentOptions & + SchemaOptions & + ParseOptions & + CreateNodeOptions & + ToStringOptions) ) { let _replacer: Replacer | null = null if (typeof replacer === 'function' || Array.isArray(replacer)) { diff --git a/src/test-events.ts b/src/test-events.ts index 034e6f36..49f8c386 100644 --- a/src/test-events.ts +++ b/src/test-events.ts @@ -12,7 +12,6 @@ import { } from './nodes/Node.js' import type { Pair } from './nodes/Pair.js' import type { Scalar } from './nodes/Scalar.js' -import type { Options } from './options.js' import { parseAllDocuments } from './public-api.js' import { visit } from './visit.js' @@ -37,9 +36,8 @@ function anchorExists(doc: Document, anchor: string): boolean { } // test harness for yaml-test-suite event tests -export function testEvents(src: string, options?: Options) { - const opt = Object.assign({ version: '1.2' }, options) - const docs = parseAllDocuments(src, opt) +export function testEvents(src: string) { + const docs = parseAllDocuments(src) const errDoc = docs.find(doc => doc.errors.length > 0) const error = errDoc ? errDoc.errors[0].message : null const events = ['+STR'] From ea7aba7e22667fe5ca3de670554d964b4ce0f5f0 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sat, 27 Mar 2021 14:10:26 +0200 Subject: [PATCH 7/8] feat!: Drop aliasPrefix from DocumentOptions BREAKING CHANGE: The behaviour of doc.createAlias() changes a bit; now it checks that any anchor name that it assigns to the target node is unique in the document. --- docs/03_options.md | 9 ++++----- docs/05_content_nodes.md | 7 ++++--- src/doc/Document.ts | 16 +++++++++++----- src/doc/anchors.ts | 13 ++----------- src/options.ts | 8 -------- tests/doc/anchors.js | 7 +++++-- 6 files changed, 26 insertions(+), 34 deletions(-) diff --git a/docs/03_options.md b/docs/03_options.md index c387baa3..8ee44110 100644 --- a/docs/03_options.md +++ b/docs/03_options.md @@ -37,11 +37,10 @@ Document options are relevant for operations on the `Document` object, which mak Used by: `parse()`, `parseDocument()`, `parseAllDocuments()`, `stringify()`, `new Composer()`, and `new Document()` -| Name | Type | Default | Description | -| ------------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| anchorPrefix | `string` | `'a'` | Default prefix for anchors, resulting in anchors `a1`, `a2`, ... by default. | -| logLevel | `'warn' ⎮ 'error' ⎮` `'silent'` | `'warn'` | Control the verbosity of `parse()`. Set to `'error'` to silence warnings, and to `'silent'` to also silence most errors (not recommended). | -| version | `'1.1' ⎮ '1.2'` | `'1.2'` | The YAML version used by documents without a `%YAML` directive. | +| Name | Type | Default | Description | +| -------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| logLevel | `'warn' ⎮ 'error' ⎮` `'silent'` | `'warn'` | Control the verbosity of `parse()`. Set to `'error'` to silence warnings, and to `'silent'` to also silence most errors (not recommended). | +| version | `'1.1' ⎮ '1.2'` | `'1.2'` | The YAML version used by documents without a `%YAML` directive. | By default, the library will emit warnings as required by the YAML spec during parsing. If you'd like to silence these, set the `logLevel` option to `'error'`. diff --git a/docs/05_content_nodes.md b/docs/05_content_nodes.md index e25ac70f..afc9b5a2 100644 --- a/docs/05_content_nodes.md +++ b/docs/05_content_nodes.md @@ -214,9 +214,10 @@ String(doc) // - *foo ``` -Create a new `Alias` node, adding the required anchor for `node`. -If `name` is empty, a new anchor name will be generated. -If `node` already has an anchor, that will be used instead of `name`. +Create a new `Alias` node, ensuring that the target `node` has the required anchor. +If `node` already has an anchor, `name` is ignored. +Otherwise, the `node.anchor` value will be set to `name`, or if an anchor with that name is already present in the document, `name` will be used as a prefix for a new unique anchor. +If `name` is undefined, the generated anchor will use 'a' as a prefix. You should make sure to only add alias nodes to the document after the nodes to which they refer, or the document's YAML stringification will fail. diff --git a/src/doc/Document.ts b/src/doc/Document.ts index 8fbbd1bf..ed3cc238 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -26,7 +26,7 @@ import { import { Schema } from '../schema/Schema.js' import { stringify } from '../stringify/stringify.js' import { stringifyDocument } from '../stringify/stringifyDocument.js' -import { createNodeAnchors, findNewAnchor } from './anchors.js' +import { anchorNames, createNodeAnchors, findNewAnchor } from './anchors.js' import { applyReviver } from './applyReviver.js' import { createNode, CreateNodeContext } from './createNode.js' import { Directives } from './directives.js' @@ -132,13 +132,19 @@ export class Document { } /** - * Create a new `Alias` node, adding the required anchor for `node`. - * If `name` is empty, a new anchor name will be generated. If `node` - * already has an anchor, that will be used instead of `name`. + * Create a new `Alias` node, ensuring that the target `node` has the required anchor. + * + * If `node` already has an anchor, `name` is ignored. + * Otherwise, the `node.anchor` value will be set to `name`, + * or if an anchor with that name is already present in the document, + * `name` will be used as a prefix for a new unique anchor. + * If `name` is undefined, the generated anchor will use 'a' as a prefix. */ createAlias(node: Scalar | YAMLMap | YAMLSeq, name?: string): Alias { if (!node.anchor) { - node.anchor = name || findNewAnchor(this.options.anchorPrefix, this) + const prev = anchorNames(this) + node.anchor = + !name || prev.has(name) ? findNewAnchor(name || 'a', prev) : name } return new Alias(node.anchor) } diff --git a/src/doc/anchors.ts b/src/doc/anchors.ts index d7effbe6..0c6bd405 100644 --- a/src/doc/anchors.ts +++ b/src/doc/anchors.ts @@ -29,17 +29,8 @@ export function anchorNames(root: Document | Node) { return anchors } -/** - * Find a new anchor name with the given `prefix` and a one-indexed suffix. - * - * The second argument may either be a YAML Document, or a Set of strings - * against which generated anchors are tested; this is intended to allow for - * caching, should multiple new anchors be needed within a single operation. - */ -export function findNewAnchor(prefix: string, doc: Document): string -export function findNewAnchor(prefix: string, cache: Set): string -export function findNewAnchor(prefix: string, cache: Document | Set) { - const exclude = cache instanceof Set ? cache : anchorNames(cache) +/** Find a new anchor name with the given `prefix` and a one-indexed suffix. */ +export function findNewAnchor(prefix: string, exclude: Set) { for (let i = 1; true; ++i) { const name = `${prefix}${i}` if (!exclude.has(name)) return name diff --git a/src/options.ts b/src/options.ts index 2deddd22..d30de960 100644 --- a/src/options.ts +++ b/src/options.ts @@ -41,13 +41,6 @@ export type ParseOptions = { } export type DocumentOptions = { - /** - * Default prefix for anchors. - * - * Default: `'a'`, resulting in anchors `a1`, `a2`, etc. - */ - anchorPrefix?: string - /** * Used internally by Composer. If set and includes an explicit version, * that overrides the `version` option. @@ -307,7 +300,6 @@ export type ToStringOptions = { export const defaultOptions: Required< Omit & Omit > = { - anchorPrefix: 'a', intAsBigInt: false, logLevel: 'warn', prettyErrors: true, diff --git a/tests/doc/anchors.js b/tests/doc/anchors.js index 9154a6b9..4cb9aa65 100644 --- a/tests/doc/anchors.js +++ b/tests/doc/anchors.js @@ -50,10 +50,13 @@ describe('create', () => { test('doc.createAlias', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]') - const alias = doc.createAlias(doc.contents.items[0], 'AA') + const alias = doc.createAlias(doc.get(0), 'AA') doc.contents.items.push(alias) expect(doc.toJS()).toMatchObject([{ a: 'A' }, { b: 'B' }, { a: 'A' }]) - expect(String(doc)).toMatch('[ &AA { a: A }, { b: B }, *AA ]\n') + const alias2 = doc.createAlias(doc.get(1), 'AA') + expect(doc.get(1).anchor).toBe('AA1') + expect(alias2.source).toBe('AA1') + expect(String(doc)).toMatch('[ &AA { a: A }, &AA1 { b: B }, *AA ]\n') }) }) From f12ff899fda4b48073a1b8b30a59b0729a11e083 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sat, 27 Mar 2021 15:22:39 +0200 Subject: [PATCH 8/8] test: Add now-successful tests for anchors on tagged collections --- tests/doc/anchors.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/doc/anchors.js b/tests/doc/anchors.js index 4cb9aa65..dcc04876 100644 --- a/tests/doc/anchors.js +++ b/tests/doc/anchors.js @@ -38,6 +38,23 @@ test('circular reference', () => { expect(String(doc)).toBe(src) }) +describe('anchor on tagged collection', () => { + test('!!set', () => { + const res = YAML.parse('- &a !!set { 1, 2 }\n- *a\n') + expect(Array.from(res[0])).toMatchObject([1, 2]) + expect(res[1]).toBe(res[0]) + }) + + test('!!omap', () => { + const res = YAML.parse('- &a !!omap [ 1: 1, 2: 2 ]\n- *a\n') + expect(Array.from(res[0])).toMatchObject([ + [1, 1], + [2, 2] + ]) + expect(res[1]).toBe(res[0]) + }) +}) + describe('create', () => { test('node.anchor', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]')