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/03_options.md b/docs/03_options.md index 923515a7..8ee44110 100644 --- a/docs/03_options.md +++ b/docs/03_options.md @@ -37,12 +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. | -| 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. | +| 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'`. @@ -95,6 +93,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/04_documents.md b/docs/04_documents.md index 45793d1a..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. | @@ -106,6 +105,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. | @@ -176,73 +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 | -| -------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------- | -| 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. | -| 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.anchors.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.anchors.createMergePair(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.anchors.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..afc9b5a2 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,27 +185,43 @@ String(doc) // - balloons: 99 ``` -#### `YAML.Document#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 -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, 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. + +

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 ffd10438..d2ba8534 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,31 +8,30 @@ 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, onError: (offset: number, message: string, warning?: boolean) => void ) { let coll: YAMLMap.Parsed | YAMLSeq.Parsed switch (token.type) { case 'block-map': { - coll = resolveBlockMap(CN, doc, token, anchor, onError) + coll = resolveBlockMap(CN, ctx, token, onError) break } case 'block-seq': { - coll = resolveBlockSeq(CN, doc, token, anchor, onError) + coll = resolveBlockSeq(CN, ctx, token, onError) break } case 'flow-collection': { - coll = resolveFlowCollection(CN, doc, token, anchor, onError) + coll = resolveFlowCollection(CN, ctx, token, onError) break } } @@ -48,13 +46,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,15 +61,12 @@ 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) 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 9b80ce16..22646760 100644 --- a/src/compose/compose-doc.ts +++ b/src/compose/compose-doc.ts @@ -1,24 +1,37 @@ 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 { 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' 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 ) { 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 = { + 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..33f1a909 100644 --- a/src/compose/compose-node.ts +++ b/src/compose/compose-node.ts @@ -1,12 +1,20 @@ -import type { Document } from '../doc/Document.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' 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 { + directives: Directives + options: Readonly>> + schema: Readonly +} + export interface Props { spaceBefore: boolean comment: string @@ -18,7 +26,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 +35,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 +43,18 @@ 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, tagName, onError) + if (anchor) { + node.anchor = anchor + } break case 'block-map': case 'block-seq': case 'flow-collection': - node = composeCollection(CN, doc, token, anchor, tagName, onError) + node = composeCollection(CN, ctx, token, tagName, onError) + if (anchor) { + node.anchor = anchor + } break default: console.log(token) @@ -55,7 +69,7 @@ export function composeNode( } export function composeEmptyNode( - doc: Document.Parsed, + ctx: ComposeContext, offset: number, before: Token[] | undefined, pos: number | null, @@ -68,26 +82,25 @@ export function composeEmptyNode( indent: -1, source: '' } - const node = composeScalar(doc, 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( - doc: Document.Parsed, + { 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) - if (!src) onError(offset, `Aliased anchor not found: ${name}`) - const alias = new Alias(src as Node) - + const alias = new Alias(source.substring(1)) const re = resolveEnd( end, offset + source.length, - doc.options.strict, + options.strict, onError ) alias.range = [offset, re.offset] diff --git a/src/compose/compose-scalar.ts b/src/compose/compose-scalar.ts index 3b0b4a0b..7991a933 100644 --- a/src/compose/compose-scalar.ts +++ b/src/compose/compose-scalar.ts @@ -1,32 +1,31 @@ -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' 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' +import type { ComposeContext } from './compose-node.js' export function composeScalar( - doc: Document.Parsed, + ctx: ComposeContext, token: FlowScalar | BlockScalar, - anchor: string | null, tagName: string | null, onError: (offset: number, message: string) => void ) { 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 +38,6 @@ export function composeScalar( if (tag.format) scalar.format = tag.format if (comment) scalar.comment = comment - if (anchor) doc.anchors.setAnchor(scalar, anchor) return scalar as Scalar.Parsed } 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/compose/resolve-block-map.ts b/src/compose/resolve-block-map.ts index 8be9bc12..feeb1ba4 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,17 @@ 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) for (const { start, key, sep, value } of items) { // key properties const keyProps = resolveProps( - doc, + ctx, start, true, 'explicit-key-ind', @@ -56,13 +53,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 +73,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 +83,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..ce7dc704 100644 --- a/src/compose/resolve-block-seq.ts +++ b/src/compose/resolve-block-seq.ts @@ -1,22 +1,19 @@ -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) for (const { start, value } of items) { const props = resolveProps( - doc, + ctx, start, true, 'seq-item-ind', @@ -38,8 +35,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..bee16f26 100644 --- a/src/compose/resolve-flow-collection.ts +++ b/src/compose/resolve-flow-collection.ts @@ -1,24 +1,21 @@ -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) let key: ParsedNode | null = null let value: ParsedNode | null = null @@ -55,14 +52,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 +75,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 +117,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 +136,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 +144,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 +162,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 +196,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 +217,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 diff --git a/src/doc/Anchors.ts b/src/doc/Anchors.ts deleted file mode 100644 index 1ab6e1e3..00000000 --- a/src/doc/Anchors.ts +++ /dev/null @@ -1,103 +0,0 @@ -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' - -export class Anchors { - map: Record = Object.create(null) - private prefix: string - - constructor(prefix: string) { - 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) - } - - /** - * 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) - } - - /** 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 f1806433..ed3cc238 100644 --- a/src/doc/Document.ts +++ b/src/doc/Document.ts @@ -10,14 +10,14 @@ 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 { CreateNodeOptions, defaultOptions, DocumentOptions, - Options, ParseOptions, SchemaOptions, ToJSOptions, @@ -26,7 +26,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 { anchorNames, createNodeAnchors, findNewAnchor } from './anchors.js' import { applyReviver } from './applyReviver.js' import { createNode, CreateNodeContext } from './createNode.js' import { Directives } from './directives.js' @@ -44,12 +44,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 @@ -82,15 +76,25 @@ 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 | undefined = undefined + let _replacer: Replacer | null = null if (typeof replacer === 'function' || Array.isArray(replacer)) { _replacer = replacer } else if (options === undefined && replacer) { @@ -100,7 +104,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() @@ -108,10 +111,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. */ @@ -124,53 +131,70 @@ export class Document { if (assertCollection(this.contents)) this.contents.addIn(path, value) } + /** + * 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) { + const prev = anchorNames(this) + node.anchor = + !name || prev.has(name) ? findNewAnchor(name || 'a', prev) : name + } + return new Alias(node.anchor) + } + /** * Convert any value into a `Node` using the current schema, recursively * turning objects into collections. */ + createNode(value: unknown, options?: CreateNodeOptions): Node createNode( value: unknown, - { 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 } - if (typeof keepUndefined !== 'boolean') - keepUndefined = !!this.options.keepUndefined - const aliasNodes: Alias[] = [] + + const { anchorPrefix, flow, keepUndefined, onTagObj, tag } = options || {} + const { onAnchor, setAnchors, sourceObjects } = createNodeAnchors( + this, + anchorPrefix || 'a' + ) 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 ?? false, + onAnchor, onTagObj, - prevObjects: new Map(), - replacer, - schema: this.schema + replacer: _replacer, + 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 } @@ -183,8 +207,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) } @@ -327,16 +351,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, @@ -345,8 +361,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..0c6bd405 --- /dev/null +++ b/src/doc/anchors.ts @@ -0,0 +1,80 @@ +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. */ +export function findNewAnchor(prefix: string, exclude: Set) { + 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/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/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..d30de960 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' @@ -42,27 +41,12 @@ 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. */ 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 * @@ -119,6 +103,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 @@ -133,15 +124,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 } @@ -295,9 +279,16 @@ export type ToStringOptions = { * Default: `'true'` */ trueStr?: string -} -export type Options = ParseOptions & DocumentOptions & SchemaOptions + /** + * 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 +} /** * `yaml` defines document-specific options in three places: as an argument of @@ -309,9 +300,7 @@ export type Options = ParseOptions & DocumentOptions & SchemaOptions export const defaultOptions: Required< Omit & Omit > = { - 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..946cee86 100644 --- a/src/public-api.ts +++ b/src/public-api.ts @@ -4,7 +4,14 @@ 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, + DocumentOptions, + ParseOptions, + SchemaOptions, + ToJSOptions, + ToStringOptions +} from './options.js' import { LineCounter } from './parse/line-counter.js' import { Parser } from './parse/parser.js' @@ -14,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) || @@ -34,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) @@ -64,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) @@ -102,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') { @@ -139,17 +151,43 @@ export function parse( */ export function stringify( value: any, - options?: Options & ToStringOptions + options?: DocumentOptions & + SchemaOptions & + ParseOptions & + CreateNodeOptions & + ToStringOptions ): string export function stringify( value: any, replacer?: Replacer | null, - options?: string | number | (Options & ToStringOptions) + options?: + | string + | number + | (DocumentOptions & + SchemaOptions & + ParseOptions & + CreateNodeOptions & + ToStringOptions) ): string + export function stringify( value: any, - replacer?: Replacer | (Options & ToStringOptions) | null, - options?: string | number | (Options & 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)) { @@ -164,8 +202,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/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..49f8c386 100644 --- a/src/test-events.ts +++ b/src/test-events.ts @@ -1,16 +1,19 @@ 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 { Options } from './options.js' +import type { Pair } from './nodes/Pair.js' +import type { Scalar } from './nodes/Scalar.js' import { parseAllDocuments } from './public-api.js' +import { visit } from './visit.js' const scalarChar: Record = { BLOCK_FOLDED: '>', @@ -20,10 +23,21 @@ 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) - 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'] @@ -70,11 +84,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 +113,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 729ee751..dcc04876 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,58 +29,80 @@ 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('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('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.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.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') }) +}) - test('errors', () => { +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('set tag on alias', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]') const node = doc.contents.items[0] - const alias = doc.anchors.createAlias(node, 'AA') - doc.contents.items.unshift(alias) - expect(() => String(doc)).toThrow('Alias node must be after source node') + const alias = doc.createAlias(node, 'AA') 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,15 +110,17 @@ 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) }) 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( @@ -150,31 +174,22 @@ 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('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.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,21 +198,19 @@ 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 merge = doc.anchors.createMergePair(alias) + 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' }]) 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.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/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/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]], diff --git a/tests/doc/types.js b/tests/doc/types.js index ef12680f..fde297fc 100644 --- a/tests/doc/types.js +++ b/tests/doc/types.js @@ -585,8 +585,8 @@ 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') - doc.addIn([1], doc.anchors.createMergePair(alias)) + 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([ { a: 'A', b: 'B' }, @@ -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([ 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' }, [{}, {}]] ]) })