Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add clone() methods to Document, Directives, Schema and all Nodes #304

Merged
merged 1 commit into from
Sep 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions docs/04_documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,16 @@ Although `parseDocument()` and `parseAllDocuments()` will leave it with `YAMLMap

## Document Methods

| 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. |
| toJS(options?) | `any` | A plain JavaScript representation of the document `contents`. |
| toJSON() | `any` | A JSON representation of the document `contents`. |
| toString(options?) | `string` | A YAML representation of the document. |
| Method | Returns | Description |
| ------------------------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| clone() | `Document` | Create a deep copy of this Document and its contents. Custom Node values that inherit from `Object` still refer to their original instances. |
| 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. |
| toJS(options?) | `any` | A plain JavaScript representation of the document `contents`. |
| toJSON() | `any` | A JSON representation of the document `contents`. |
| toString(options?) | `string` | A YAML representation of the document. |

```js
const doc = parseDocument('a: 1\nb: [2, 3]\n')
Expand Down
11 changes: 9 additions & 2 deletions docs/05_content_nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ class NodeBase {
// included in their respective ranges.
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
clone(): NodeBase // a copy of this node
toJSON(): any // a plain JS or JSON representation of this node
}
```

Expand Down Expand Up @@ -58,6 +59,7 @@ class Collection extends NodeBase {
flow?: boolean // use flow style when stringifying this
schema?: Schema
addIn(path: Iterable<unknown>, value: unknown): void
clone(schema?: Schema): NodeBase // a deep copy of this collection
deleteIn(path: Iterable<unknown>): boolean
getIn(path: Iterable<unknown>, keepScalar?: boolean): unknown
hasIn(path: Iterable<unknown>): boolean
Expand Down Expand Up @@ -194,6 +196,11 @@ 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.

If `value` is already a `Node` instance, it will be directly returned.
To create a copy of a node, use instead the `node.clone()` method.
For collections, the method accepts a single `Schema` argument,
which allows overwriting the original's `schema` value.

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.
Expand Down
24 changes: 24 additions & 0 deletions src/doc/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { collectionFromPath, isEmptyPath } from '../nodes/Collection.js'
import {
DOC,
isCollection,
isNode,
isScalar,
Node,
NODE_TYPE,
Expand Down Expand Up @@ -124,6 +125,29 @@ export class Document<T = unknown> {
}
}

/**
* Create a deep copy of this Document and its contents.
*
* Custom Node values that inherit from `Object` still refer to their original instances.
*/
clone(): Document<T> {
const copy: Document<T> = Object.create(Document.prototype, {
[NODE_TYPE]: { value: DOC }
})
copy.commentBefore = this.commentBefore
copy.comment = this.comment
copy.errors = this.errors.slice()
copy.warnings = this.warnings.slice()
copy.options = Object.assign({}, this.options)
copy.directives = this.directives.clone()
copy.schema = this.schema.clone()
copy.contents = isNode(this.contents)
? (this.contents.clone(copy.schema) as unknown as T)
: this.contents
if (this.range) copy.range = this.range.slice() as Document['range']
return copy
}

/** Adds a value to the document. */
add(value: any) {
if (assertCollection(this.contents)) this.contents.add(value)
Expand Down
6 changes: 6 additions & 0 deletions src/doc/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export class Directives {
this.tags = Object.assign({}, Directives.defaultTags, tags)
}

clone(): Directives {
const copy = new Directives(this.yaml, this.tags)
copy.marker = this.marker
return copy
}

/**
* During parsing, get a Directives instance for the current document and
* update the stream state according to the current version's spec.
Expand Down
27 changes: 26 additions & 1 deletion src/nodes/Collection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { createNode } from '../doc/createNode.js'
import type { Schema } from '../schema/Schema.js'
import { isCollection, isPair, isScalar, NodeBase, NODE_TYPE } from './Node.js'
import {
isCollection,
isNode,
isPair,
isScalar,
NodeBase,
NODE_TYPE
} from './Node.js'

export function collectionFromPath(
schema: Schema,
Expand Down Expand Up @@ -62,6 +69,24 @@ export abstract class Collection extends NodeBase {
})
}

/**
* Create a copy of this collection.
*
* @param schema - If defined, overwrites the original's schema
*/
clone(schema?: Schema): Collection {
const copy: Collection = Object.create(
Object.getPrototypeOf(this),
Object.getOwnPropertyDescriptors(this)
)
if (schema) copy.schema = schema
copy.items = copy.items.map(it =>
isNode(it) || isPair(it) ? it.clone(schema) : it
)
if (this.range) copy.range = this.range.slice() as NodeBase['range']
return copy
}

/** Adds a value to the collection. */
abstract add(value: unknown): void

Expand Down
10 changes: 10 additions & 0 deletions src/nodes/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,14 @@ export abstract class NodeBase {
constructor(type: symbol) {
Object.defineProperty(this, NODE_TYPE, { value: type })
}

/** Create a copy of this node. */
clone(): NodeBase {
const copy: NodeBase = Object.create(
Object.getPrototypeOf(this),
Object.getOwnPropertyDescriptors(this)
)
if (this.range) copy.range = this.range.slice() as NodeBase['range']
return copy
}
}
14 changes: 11 additions & 3 deletions src/nodes/Pair.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { createNode, CreateNodeContext } from '../doc/createNode.js'
import { StringifyContext } from '../stringify/stringify.js'
import type { Schema } from '../schema/Schema.js'
import type { StringifyContext } from '../stringify/stringify.js'
import { stringifyPair } from '../stringify/stringifyPair.js'
import { addPairToJSMap } from './addPairToJSMap.js'
import { NODE_TYPE, PAIR } from './Node.js'
import { ToJSContext } from './toJS.js'
import { isNode, NODE_TYPE, PAIR } from './Node.js'
import type { ToJSContext } from './toJS.js'

export function createPair(
key: unknown,
Expand All @@ -30,6 +31,13 @@ export class Pair<K = unknown, V = unknown> {
this.value = value
}

clone(schema?: Schema): Pair<K, V> {
let { key, value } = this
if (isNode(key)) key = key.clone(schema) as unknown as K
if (isNode(value)) value = value.clone(schema) as unknown as V
return new Pair(key, value)
}

toJSON(_?: unknown, ctx?: ToJSContext): ReturnType<typeof addPairToJSMap> {
const pair = ctx && ctx.mapAsMap ? new Map() : {}
return addPairToJSMap(ctx, pair, this)
Expand Down
9 changes: 9 additions & 0 deletions src/schema/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,13 @@ export class Schema {
this.sortMapEntries =
sortMapEntries === true ? sortMapEntriesByKey : sortMapEntries || null
}

clone(): Schema {
const copy = Object.create(
Schema.prototype,
Object.getOwnPropertyDescriptors(this)
)
copy.tags = this.tags.slice()
return copy
}
}
8 changes: 5 additions & 3 deletions src/stringify/stringifyString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
} from './foldFlowLines.js'
import type { StringifyContext } from './stringify.js'

interface StringifyScalar extends Scalar {
interface StringifyScalar {
value: string
comment?: string | null
type?: string
}

const getFoldOptions = (ctx: StringifyContext): FoldOptions => ({
Expand Down Expand Up @@ -318,9 +320,9 @@ export function stringifyString(
onChompKeep?: () => void
) {
const { implicitKey, inFlow } = ctx
const ss: Scalar<string> =
const ss: StringifyScalar =
typeof item.value === 'string'
? (item as Scalar<string>)
? (item as StringifyScalar)
: Object.assign({}, item, { value: String(item.value) })

let { type } = item
Expand Down
74 changes: 74 additions & 0 deletions tests/clone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { isAlias, isScalar, parseDocument, Scalar, visit } from 'yaml'
import { source } from './_utils'

describe('doc.clone()', () => {
test('has expected members', () => {
const doc = parseDocument('foo: bar')
const copy = doc.clone()
expect(copy).toMatchObject({
comment: null,
commentBefore: null,
errors: [],
warnings: []
})
})

test('has expected methods', () => {
const doc = parseDocument('foo: bar')
const copy = doc.clone()
expect(copy.toString()).toBe('foo: bar\n')
expect(copy.toJS()).toEqual({ foo: 'bar' })

const node = copy.createNode(42)
expect(isScalar(node)).toBe(true)
expect(node).toMatchObject({ value: 42 })

const alias = copy.createAlias(node as Scalar, 'foo')
expect(isAlias(alias)).toBe(true)
expect(alias).toMatchObject({ source: 'foo' })
})

test('has separate contents from original', () => {
const doc = parseDocument('foo: bar')
const copy = doc.clone()
copy.set('foo', 'fizz')
expect(doc.get('foo')).toBe('bar')
expect(copy.get('foo')).toBe('fizz')
})

test('has separate directives from original', () => {
const doc = parseDocument('foo: bar')
const copy = doc.clone()
copy.directives.yaml.explicit = true
expect(copy.toString()).toBe(source`
%YAML 1.2
---
foo: bar
`)
expect(doc.toString()).toBe('foo: bar\n')
})

test('handles anchors & aliases', () => {
const src = source`
foo: &foo FOO
bar: *foo
`
const doc = parseDocument(src)
const copy = doc.clone()
expect(copy.toString()).toBe(src)

visit(doc, {
Alias(_, it) {
if (it.source === 'foo') it.source = 'x'
},
Node(_, it) {
if (it.anchor === 'foo') it.anchor = 'x'
}
})
expect(doc.toString()).toBe(source`
foo: &x FOO
bar: *x
`)
expect(copy.toString()).toBe(src)
})
})