mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
feat: create new @pnpm/yaml.document-sync package (#10405)
This commit is contained in:
5
.changeset/kind-moments-do.md
Normal file
5
.changeset/kind-moments-do.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@pnpm/yaml.document-sync": major
|
||||
---
|
||||
|
||||
Initial version.
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -771,6 +771,9 @@ catalogs:
|
||||
write-yaml-file:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0
|
||||
yaml:
|
||||
specifier: ^2.8.1
|
||||
version: 2.8.1
|
||||
yaml-tag:
|
||||
specifier: 1.1.0
|
||||
version: 1.1.0
|
||||
@@ -9210,6 +9213,16 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 0.29.12
|
||||
|
||||
yaml/document-sync:
|
||||
dependencies:
|
||||
yaml:
|
||||
specifier: 'catalog:'
|
||||
version: 2.8.1
|
||||
devDependencies:
|
||||
'@pnpm/yaml.document-sync':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
|
||||
packages:
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
|
||||
@@ -40,6 +40,7 @@ packages:
|
||||
- store/*
|
||||
- text/*
|
||||
- workspace/*
|
||||
- yaml/*
|
||||
- '!**/example/**'
|
||||
- '!**/test/**'
|
||||
- '!resolving/local-resolver/example-package/**'
|
||||
@@ -315,6 +316,7 @@ catalog:
|
||||
write-json5-file: ^3.1.0
|
||||
write-package: 7.2.0
|
||||
write-yaml-file: ^5.0.0
|
||||
yaml: ^2.8.1
|
||||
yaml-tag: 1.1.0
|
||||
yazl: ^3.3.1
|
||||
|
||||
|
||||
169
yaml/document-sync/README.md
Normal file
169
yaml/document-sync/README.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# @pnpm/yaml.document-sync
|
||||
|
||||
> Update a YAML document to match the contents of an in-memory object.
|
||||
|
||||
<!--@shields('npm')-->
|
||||
[](https://www.npmjs.com/package/@pnpm/yaml.document-sync)
|
||||
<!--/@-->
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
pnpm add @pnpm/yaml.document-sync
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Given a "_source_" document such as:
|
||||
|
||||
```yaml
|
||||
foo:
|
||||
bar:
|
||||
# 25 is better than 24
|
||||
baz: 25
|
||||
|
||||
qux:
|
||||
# He was number 1
|
||||
- 1
|
||||
```
|
||||
|
||||
And a "_target_" object in-memory:
|
||||
|
||||
```ts
|
||||
import fs from 'node:fs'
|
||||
import { patchDocument } from '@pnpm/yaml.document-sync'
|
||||
import yaml from 'yaml'
|
||||
|
||||
const source = await fs.promises.readFile('source.yaml', 'utf8')
|
||||
const document = yaml.parseDocument(source)
|
||||
|
||||
const target = {
|
||||
foo: { bar: { baz: 25 } },
|
||||
qux: [1, 2, 3]
|
||||
}
|
||||
|
||||
patchDocument(document, target)
|
||||
```
|
||||
|
||||
The `patchDocument` function will mutate `document` to match the `target`'s contents, retaining comments along the way. In the example above, the final rendered document will be:
|
||||
|
||||
```yaml
|
||||
foo:
|
||||
bar:
|
||||
# 25 is better than 24
|
||||
baz: 25
|
||||
|
||||
qux:
|
||||
# He was number 1
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
```
|
||||
|
||||
## Purpose
|
||||
|
||||
This package is useful when your codebase:
|
||||
|
||||
1. Uses the [yaml](https://www.npmjs.com/package/yaml) library.
|
||||
2. Calls `.toJSON()` on the parse result and performs changes to it.
|
||||
3. Needs to "sync" those changes back to the source document.
|
||||
|
||||
Instead of this package, consider performing mutations directly on the `yaml.Document` returned from `yaml.parseDocument()` instead. Directly modifying will be faster and more accurate. This package exists as a workaround for codebases that make changes to a JSON object instead and need to reconcile changes back into the source `yaml.Document`.
|
||||
|
||||
## Caveats
|
||||
|
||||
There are several cases where comment preservation is inherently ambiguous. If the caveats outlined below are problematic, consider modifying the source `yaml.Document` before running the patch function in this package.
|
||||
|
||||
### Key Renames
|
||||
|
||||
For example, renames of a key are ambiguous. Given:
|
||||
|
||||
```yaml
|
||||
- foo:
|
||||
# Test
|
||||
bar: 1
|
||||
```
|
||||
|
||||
And a target object to match:
|
||||
|
||||
```json
|
||||
{
|
||||
"baz": {
|
||||
"bar": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The comment on `bar` won't be retained.
|
||||
|
||||
### List Reconciliation
|
||||
|
||||
For simple lists (e.g. lists with only primitives), items will be uniquely matched using their contents. However, updates to complex lists are inherently ambiguous.
|
||||
|
||||
For example, given a source list with objects as elements:
|
||||
|
||||
```yaml
|
||||
- foo: 1
|
||||
# Comment
|
||||
- bar: 2
|
||||
```
|
||||
|
||||
And a target:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "foo": 1 },
|
||||
{ "baz": 3 },
|
||||
{ "bar": 2 }
|
||||
]
|
||||
```
|
||||
|
||||
The result will erase the comment:
|
||||
|
||||
```yaml
|
||||
- foo: 1
|
||||
- baz: 3
|
||||
- bar: 2
|
||||
```
|
||||
|
||||
It's not trivial to detect that the object with `bar` as a field moved down. Detecting this case would require a diffing algorithm, which would be best effort anyway.
|
||||
|
||||
Virtual DOM libraries such as React have the same problem. In React, list elements need to specify a `key` prop to uniquely identify each item. This library may take a similar approach in the future if needed. This is not a problem for primitive lists since their values can be compared using simple equality checks.
|
||||
|
||||
### Aliases
|
||||
|
||||
Given:
|
||||
|
||||
```yaml
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
|
||||
bar: *config
|
||||
```
|
||||
|
||||
And a target object:
|
||||
|
||||
```json
|
||||
{
|
||||
"foo": [1, 2],
|
||||
"bar": [1, 2, 3]
|
||||
}
|
||||
```
|
||||
|
||||
For correctness, the YAML alias needs to be removed.
|
||||
|
||||
```yaml
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
|
||||
bar:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
47
yaml/document-sync/package.json
Normal file
47
yaml/document-sync/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@pnpm/yaml.document-sync",
|
||||
"version": "1000.0.0-0",
|
||||
"description": "Update a YAML document to match the contents of an in-memory object.",
|
||||
"keywords": [
|
||||
"pnpm",
|
||||
"pnpm11",
|
||||
"patch",
|
||||
"yaml"
|
||||
],
|
||||
"license": "MIT",
|
||||
"funding": "https://opencollective.com/pnpm",
|
||||
"repository": "https://github.com/pnpm/pnpm/tree/main/yaml/document-sync",
|
||||
"homepage": "https://github.com/pnpm/pnpm/tree/main/yaml/document-sync#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/pnpm/pnpm/issues"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": "./lib/index.js"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"!*.map"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "pnpm run compile && pnpm run _test",
|
||||
"compile": "tsc --build && pnpm run lint --fix",
|
||||
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"prepublishOnly": "pnpm run compile",
|
||||
"_test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/yaml.document-sync": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "@pnpm/jest-config"
|
||||
}
|
||||
}
|
||||
1
yaml/document-sync/src/index.ts
Normal file
1
yaml/document-sync/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { patchDocument } from './patchDocument.js'
|
||||
247
yaml/document-sync/src/patchDocument.ts
Normal file
247
yaml/document-sync/src/patchDocument.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import yaml from 'yaml'
|
||||
|
||||
export interface PatchDocumentOptions {
|
||||
/**
|
||||
* Updating aliases is inherently ambiguous since they're not a concept in
|
||||
* JSON. The default is to unwrap and remove aliases since that's the most
|
||||
* correct behavior.
|
||||
*
|
||||
* Aliases can be configured to "follow", which updates the anchor node.
|
||||
* However, this can result in surprising behavior when values in the target
|
||||
* JSON between the anchor and the alias don't match.
|
||||
*
|
||||
* @default 'unwrap'
|
||||
*/
|
||||
readonly aliases?: 'unwrap' | 'follow'
|
||||
}
|
||||
|
||||
interface PatchContext extends PatchDocumentOptions {
|
||||
readonly document: yaml.Document
|
||||
readonly aliases: 'unwrap' | 'follow'
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively update a YAML document (in-place) to match the contents of a
|
||||
* target value.
|
||||
*
|
||||
* Comments are preserved on a best-effort basis. There are several cases where
|
||||
* ambiguity arises. See this package's README.md for details.
|
||||
*/
|
||||
export function patchDocument (document: yaml.Document, target: unknown, options?: PatchDocumentOptions): void {
|
||||
// Documents with errors can't be stringified and have unpredictable ASTs.
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error('Document with errors cannot be patched')
|
||||
}
|
||||
|
||||
document.contents = patchNode(document.contents, target, {
|
||||
document,
|
||||
aliases: options?.aliases ?? 'unwrap',
|
||||
})
|
||||
}
|
||||
|
||||
function patchNode (node: yaml.Node | null | undefined, target: unknown, ctx: PatchContext): yaml.Node | null {
|
||||
if (node == null) {
|
||||
return ctx.document.createNode(target)
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (yaml.isAlias(node)) {
|
||||
return patchAlias(node, target, ctx)
|
||||
}
|
||||
|
||||
if (yaml.isScalar(node)) {
|
||||
return patchScalar(node, target, ctx)
|
||||
}
|
||||
|
||||
if (yaml.isMap(node)) {
|
||||
return patchMap(node, target, ctx)
|
||||
}
|
||||
|
||||
if (yaml.isSeq(node)) {
|
||||
return patchSeq(node, target, ctx)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _never: never = node
|
||||
throw new Error('Unrecognized yaml node: ' + String(node))
|
||||
}
|
||||
|
||||
function patchAlias (alias: yaml.Alias, target: unknown, ctx: PatchContext): yaml.Node | null {
|
||||
const resolved = alias.resolve(ctx.document)
|
||||
|
||||
// This should only happen if the document was corrupted after it was parsed.
|
||||
// Unresolved aliases should fail at the parsing stage.
|
||||
if (resolved == null) {
|
||||
throw new Error('Failed to resolve yaml alias: ' + alias.source)
|
||||
}
|
||||
|
||||
switch (ctx.aliases) {
|
||||
case 'follow': {
|
||||
// This can result in surprising behavior since the anchor node will end up
|
||||
// with the contents of the last encountered alias. The default is to
|
||||
// "unwrap" for this reason.
|
||||
patchNode(resolved, target, ctx)
|
||||
return alias
|
||||
}
|
||||
|
||||
case 'unwrap': {
|
||||
const copy = resolved.clone() as typeof resolved
|
||||
copy.anchor = undefined
|
||||
patchNode(copy, target, ctx)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function patchScalar (scalar: yaml.Scalar, target: unknown, ctx: PatchContext): yaml.Node {
|
||||
if (scalar.value === target) {
|
||||
return scalar
|
||||
}
|
||||
|
||||
if (typeof target === 'boolean' || typeof target === 'string' || typeof target === 'number') {
|
||||
scalar.value = target
|
||||
return scalar
|
||||
}
|
||||
|
||||
return ctx.document.createNode(target)
|
||||
}
|
||||
|
||||
function patchMap (map: yaml.YAMLMap, target: unknown, ctx: PatchContext): yaml.Node | null {
|
||||
if (!isRecord(target)) {
|
||||
return ctx.document.createNode(target)
|
||||
}
|
||||
|
||||
// Intentionally return null on empty maps as well. This recursively clears
|
||||
// empty maps in the final document.
|
||||
if (target == null || Object.keys(target).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mapKeyToExistingPair = new Map<string, yaml.Pair>()
|
||||
|
||||
for (const pair of map.items) {
|
||||
// We can't update non-node types. Pairs should only contain values that are
|
||||
// non-nodes if the yaml document was modified manually after parsing.
|
||||
if (!yaml.isScalar(pair.key) || typeof pair.key.value !== 'string') {
|
||||
throw new Error('Encountered unexpected non-node value: ' + String(pair.key))
|
||||
}
|
||||
|
||||
mapKeyToExistingPair.set(pair.key.value, pair)
|
||||
}
|
||||
|
||||
map.items = Object.entries(target)
|
||||
.map(([key, value]) => {
|
||||
const existingPair = mapKeyToExistingPair.get(key)
|
||||
|
||||
if (existingPair == null) {
|
||||
return ctx.document.createPair(key, value)
|
||||
}
|
||||
|
||||
if (!yaml.isNode(existingPair.value)) {
|
||||
throw new Error('Encountered unexpected non-node value: ' + String(existingPair.value))
|
||||
}
|
||||
|
||||
existingPair.value = patchNode(existingPair.value, value, ctx)
|
||||
return existingPair
|
||||
})
|
||||
.filter((pair) => pair.value != null)
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
function patchSeq (seq: yaml.YAMLSeq, target: unknown, ctx: PatchContext): yaml.Node {
|
||||
if (!Array.isArray(target)) {
|
||||
return ctx.document.createNode(target)
|
||||
}
|
||||
|
||||
// Primitive lists can benefit from a more correct reconciliation process
|
||||
// since it's possible to uniquely identify items.
|
||||
//
|
||||
// Reconciling lists with objects is more complex since we don't know which
|
||||
// objects in the source list semantically correspond to the same object in
|
||||
// the target list. This is the same problem that virtual DOM frameworks (e.g.
|
||||
// React) have. We have to go by indexes in the complex case. If solving this
|
||||
// problem becomes important in the future, it may be worth making callers to
|
||||
// pass in a getKeyForNode() function.
|
||||
return isPrimitiveList(target)
|
||||
? patchSeqPrimitive(seq, target)
|
||||
: patchSeqComplex(seq, target, ctx)
|
||||
}
|
||||
|
||||
function patchSeqPrimitive (seq: yaml.YAMLSeq, target: Array<boolean | number | string | null | undefined>): yaml.Node {
|
||||
// Keep track of existing nodes to reuse when building up the final list from
|
||||
// the target list. These nodes will have comments attached to them, so it's
|
||||
// important to reuse them when possible.
|
||||
const valueToNodesMap = new Map<boolean | number | string, yaml.Scalar[]>()
|
||||
|
||||
for (const item of seq.items) {
|
||||
if (item != null && !yaml.isNode(item)) {
|
||||
throw new Error('Encountered unexpected non-node value: ' + String(item))
|
||||
}
|
||||
|
||||
// We know all items in the target list are scalars. If there's a non-scalar
|
||||
// in the source list, it needs to be removed. Skip over this item so it's
|
||||
// not added to the final list.
|
||||
if (!yaml.isScalar(item) || !isPrimitive(item.value) || item.value == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nodeList = valueToNodesMap.get(item.value) ?? []
|
||||
nodeList.push(item)
|
||||
|
||||
valueToNodesMap.set(item.value, nodeList)
|
||||
}
|
||||
|
||||
seq.items = target.filter(item => item != null).map((item): yaml.Scalar => {
|
||||
const existingNodesList = valueToNodesMap.get(item)
|
||||
const firstExistingItem = existingNodesList?.shift()
|
||||
|
||||
// If the list is now empty as a result of removing the first item, clean up
|
||||
// the map.
|
||||
if (existingNodesList?.length === 0) {
|
||||
valueToNodesMap.delete(item)
|
||||
}
|
||||
|
||||
return firstExistingItem ?? new yaml.Scalar(item)
|
||||
})
|
||||
|
||||
return seq
|
||||
}
|
||||
|
||||
function patchSeqComplex (seq: yaml.YAMLSeq, target: unknown[], ctx: PatchContext): yaml.Node {
|
||||
const nextItems: yaml.Node[] = []
|
||||
|
||||
for (let i = 0; i < Math.max(seq.items.length, target.length); i++) {
|
||||
const existingItem = seq.items[i]
|
||||
const targetItem = target[i]
|
||||
|
||||
if (existingItem != null && !yaml.isNode(existingItem)) {
|
||||
throw new Error('Encountered unexpected non-node value: ' + String(existingItem))
|
||||
}
|
||||
|
||||
const nextItem = patchNode(existingItem, targetItem, ctx)
|
||||
if (nextItem == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
nextItems.push(nextItem)
|
||||
}
|
||||
|
||||
seq.items = nextItems
|
||||
return seq
|
||||
}
|
||||
|
||||
function isRecord (value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isPrimitiveList (arr: unknown[]) {
|
||||
return arr.every(isPrimitive)
|
||||
}
|
||||
|
||||
function isPrimitive (value: unknown) {
|
||||
return value == null || typeof value === 'boolean' || typeof value === 'string' || typeof value === 'number'
|
||||
}
|
||||
772
yaml/document-sync/test/patchDocument.test.ts
Normal file
772
yaml/document-sync/test/patchDocument.test.ts
Normal file
@@ -0,0 +1,772 @@
|
||||
import { patchDocument } from '@pnpm/yaml.document-sync'
|
||||
import yaml from 'yaml'
|
||||
|
||||
describe('patchNode', () => {
|
||||
it('throws error when document has errors', () => {
|
||||
const raw = `\
|
||||
foo:
|
||||
bar: 1
|
||||
- 2
|
||||
`
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
expect(() => {
|
||||
patchDocument(document, {})
|
||||
}).toThrow('Document with errors cannot be patched')
|
||||
})
|
||||
|
||||
it('throws error when encountering unknown node at top-level', () => {
|
||||
const raw = `\
|
||||
- 1
|
||||
- 2
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw);
|
||||
|
||||
// Inserting a raw value that should definitely be wrong.
|
||||
(document.contents as unknown as number) = 3
|
||||
|
||||
expect(() => {
|
||||
patchDocument(document, [1, 2, 3])
|
||||
}).toThrow('Unrecognized yaml node')
|
||||
})
|
||||
|
||||
it('empties document when target is null', () => {
|
||||
const raw = `\
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
`
|
||||
const document = yaml.parseDocument(raw)
|
||||
patchDocument(document, null)
|
||||
|
||||
expect(document.contents).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scalar', () => {
|
||||
it('updates nested scalar', () => {
|
||||
const raw = `\
|
||||
foo:
|
||||
bar:
|
||||
# This comment on baz should be preserved when changing it.
|
||||
baz: 1
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
json.foo.bar.baz = 2
|
||||
|
||||
patchDocument(document, json)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo:
|
||||
bar:
|
||||
# This comment on baz should be preserved when changing it.
|
||||
baz: 2
|
||||
`)
|
||||
})
|
||||
|
||||
it('changes from a scalar to a different type', () => {
|
||||
const raw = `\
|
||||
foo:
|
||||
bar:
|
||||
# This comment on baz should be preserved when changing it.
|
||||
baz: 1
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
json.foo.bar.baz = [1, 2]
|
||||
|
||||
patchDocument(document, json)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo:
|
||||
bar:
|
||||
# This comment on baz should be preserved when changing it.
|
||||
baz:
|
||||
- 1
|
||||
- 2
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reformat string quotes', () => {
|
||||
const raw = `\
|
||||
foo:
|
||||
bar:
|
||||
baz: [ '1', "2" ]
|
||||
qux:
|
||||
- "1"
|
||||
- '2'
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
json.foo.quux = 3
|
||||
|
||||
patchDocument(document, json)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo:
|
||||
bar:
|
||||
baz: [ '1', "2" ]
|
||||
qux:
|
||||
- "1"
|
||||
- '2'
|
||||
quux: 3
|
||||
`)
|
||||
})
|
||||
|
||||
describe('map', () => {
|
||||
it('adds new items to a map and preserves comment', () => {
|
||||
const raw = `\
|
||||
foo:
|
||||
bar:
|
||||
# Comment 1
|
||||
baz: 1
|
||||
# Comment 2
|
||||
qux: 2
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
json.foo.quux = 3
|
||||
|
||||
patchDocument(document, json)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo:
|
||||
bar:
|
||||
# Comment 1
|
||||
baz: 1
|
||||
# Comment 2
|
||||
qux: 2
|
||||
quux: 3
|
||||
`)
|
||||
})
|
||||
|
||||
it('adds new items to a map and handles comment immediately after map definition', () => {
|
||||
const raw = `\
|
||||
items:
|
||||
# Comment on items in map
|
||||
b: 2
|
||||
# Comment on d
|
||||
d: 4
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
patchDocument(document, { items: { a: 1, b: 2, c: 3, d: 4 } })
|
||||
|
||||
// The yaml library unfortunately parses the first comment as a property on
|
||||
// the "items" map rather than a property on the "b" field. So the newly
|
||||
// added "a" field is added below the comment.
|
||||
//
|
||||
// This isn't incorrect, but most of the time users probably associate the
|
||||
// comment as a part of the immediately succeeding field. Let's encode the
|
||||
// behavior as a test for now. The behavior may change in a future version
|
||||
// of the yaml library.
|
||||
expect(document.toString()).toBe(`\
|
||||
items:
|
||||
# Comment on items in map
|
||||
a: 1
|
||||
b: 2
|
||||
c: 3
|
||||
# Comment on d
|
||||
d: 4
|
||||
`)
|
||||
})
|
||||
|
||||
it('removes item from map and preserves comment', () => {
|
||||
const raw = `\
|
||||
foo:
|
||||
bar:
|
||||
# Comment 1
|
||||
baz: 1
|
||||
# Comment 2
|
||||
qux: 2
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
delete json.foo.bar.baz
|
||||
|
||||
patchDocument(document, json)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo:
|
||||
# Comment 2
|
||||
qux: 2
|
||||
`)
|
||||
})
|
||||
|
||||
it('changes from a map to a different type', () => {
|
||||
const raw = `\
|
||||
foo:
|
||||
bar:
|
||||
baz: 1
|
||||
qux: 2
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
// Change foo.bar to be a list instead.
|
||||
json.foo.bar = [1, 2, 3]
|
||||
|
||||
patchDocument(document, json)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo:
|
||||
bar:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
qux: 2
|
||||
`)
|
||||
})
|
||||
|
||||
it('uses key order from target map', () => {
|
||||
const raw = `\
|
||||
# a
|
||||
a: 1
|
||||
# b
|
||||
b: 2
|
||||
# c
|
||||
c: 3
|
||||
# d
|
||||
d: 4
|
||||
# e
|
||||
e: 5
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
const target = {
|
||||
d: 4,
|
||||
c: 3,
|
||||
a: 1,
|
||||
e: 5,
|
||||
b: 2,
|
||||
}
|
||||
|
||||
patchDocument(document, target)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
# d
|
||||
d: 4
|
||||
# c
|
||||
c: 3
|
||||
# a
|
||||
a: 1
|
||||
# e
|
||||
e: 5
|
||||
# b
|
||||
b: 2
|
||||
`)
|
||||
})
|
||||
|
||||
it('throws error when encountering unknown key node in map', () => {
|
||||
const raw = `\
|
||||
foo: 1
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const contents = document.contents as yaml.YAMLMap
|
||||
|
||||
// The key here should also be wrapped around a scalar constructor.
|
||||
contents.items.push(new yaml.Pair('bar', new yaml.Scalar('2')))
|
||||
|
||||
expect(() => {
|
||||
patchDocument(document, { foo: 1, bar: 2 })
|
||||
}).toThrow('Encountered unexpected non-node value: bar')
|
||||
})
|
||||
|
||||
it('throws error when encountering unknown value node in map', () => {
|
||||
const raw = `\
|
||||
foo: 1
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const contents = document.contents as yaml.YAMLMap
|
||||
|
||||
// The value here should also be wrapped around a scalar constructor.
|
||||
contents.items.push(new yaml.Pair(new yaml.Scalar('bar'), 2))
|
||||
|
||||
expect(() => {
|
||||
patchDocument(document, { foo: 1, bar: 2 })
|
||||
}).toThrow('Encountered unexpected non-node value: 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('list', () => {
|
||||
it('adds new items to a list and preserves comment', () => {
|
||||
const raw = `\
|
||||
foo:
|
||||
bar:
|
||||
baz:
|
||||
- 1
|
||||
# Comment
|
||||
- 2
|
||||
- 3
|
||||
qux: 2
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
json.foo.bar.baz.push(4)
|
||||
|
||||
patchDocument(document, json)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo:
|
||||
bar:
|
||||
baz:
|
||||
- 1
|
||||
# Comment
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
qux: 2
|
||||
`)
|
||||
})
|
||||
|
||||
it('removes items from a list along with its comment', () => {
|
||||
const raw = `\
|
||||
list:
|
||||
- 1
|
||||
# Comment
|
||||
- 2
|
||||
- 3
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
delete json.list[1]
|
||||
|
||||
patchDocument(document, json)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
list:
|
||||
- 1
|
||||
- 3
|
||||
`)
|
||||
})
|
||||
|
||||
it('removes items from a list but preserves comments below', () => {
|
||||
const raw = `\
|
||||
- 1
|
||||
- 2
|
||||
# Comment on 3
|
||||
- 3
|
||||
# Comment on 4
|
||||
- 4
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
patchDocument(document, [1, 3, 4])
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
- 1
|
||||
# Comment on 3
|
||||
- 3
|
||||
# Comment on 4
|
||||
- 4
|
||||
`)
|
||||
})
|
||||
|
||||
it('updates items in a list that contain duplicates', () => {
|
||||
const raw = `\
|
||||
# Comment on first instance of 1
|
||||
- 1
|
||||
- 2
|
||||
# Comment on second instance of 1
|
||||
- 1
|
||||
# Comment on 4
|
||||
- 4
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
patchDocument(document, [1, 3, 4, 1])
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
# Comment on first instance of 1
|
||||
- 1
|
||||
- 3
|
||||
# Comment on 4
|
||||
- 4
|
||||
# Comment on second instance of 1
|
||||
- 1
|
||||
`)
|
||||
})
|
||||
|
||||
// Similar to the test above, but make sure the presence of a complex object
|
||||
// doesn't cause the list reconciler to fall back a different code path that
|
||||
// won't handle primitives efficiently.
|
||||
it('removes items from a list but preserves comments below when source list has complex object', () => {
|
||||
const raw = `\
|
||||
- 1
|
||||
- {}
|
||||
- 2
|
||||
# Comment on 3
|
||||
- 3
|
||||
# Comment on 4
|
||||
- 4
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
patchDocument(document, [1, 3, 4])
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
- 1
|
||||
# Comment on 3
|
||||
- 3
|
||||
# Comment on 4
|
||||
- 4
|
||||
`)
|
||||
})
|
||||
|
||||
it('updates items in a complex list', () => {
|
||||
const raw = `\
|
||||
# Comment on foo
|
||||
- foo: 1
|
||||
# Comment on qux
|
||||
- qux: 2
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
patchDocument(document, [{ foo: 1 }, { bar: 2 }, { qux: 3 }])
|
||||
|
||||
// It's unfortunately very difficult (and inherently ambiguous) to keep the
|
||||
// comment on qux in the right place. This is because the complex list item
|
||||
// reconciler is index based and doesn't know qux shifted down one element.
|
||||
//
|
||||
// It's especially difficult to tell where the comment on qux should be when
|
||||
// its value changes too like in this example (qux: 2 -> 3).
|
||||
expect(document.toString()).toBe(`\
|
||||
# Comment on foo
|
||||
- foo: 1
|
||||
# Comment on qux
|
||||
- bar: 2
|
||||
- qux: 3
|
||||
`)
|
||||
})
|
||||
|
||||
it('updates items in primitive list with holes', () => {
|
||||
const raw = `\
|
||||
- 1
|
||||
- 2
|
||||
# Comment on 3
|
||||
- 3
|
||||
# Comment on 4
|
||||
- 4
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
patchDocument(document, [1, 3, null, undefined, 4])
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
- 1
|
||||
# Comment on 3
|
||||
- 3
|
||||
# Comment on 4
|
||||
- 4
|
||||
`)
|
||||
})
|
||||
|
||||
it('updates items in complex list with holes', () => {
|
||||
const raw = `\
|
||||
- foo: 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
patchDocument(document, [{ foo: 1 }, 3, null, undefined, 4, 5])
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
- foo: 1
|
||||
- 3
|
||||
- 4
|
||||
- 5
|
||||
`)
|
||||
})
|
||||
|
||||
// This may not be the desired behavior in every case. It's inherently
|
||||
// ambiguous and depends on whether the comment written applies to the newly
|
||||
// added item.
|
||||
it('changes item in list and removes comment', () => {
|
||||
const raw = `\
|
||||
- 1
|
||||
# Comment on 2
|
||||
- 2
|
||||
- 5
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
|
||||
patchDocument(document, [1, 3, 4, 5])
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
- 1
|
||||
- 3
|
||||
- 4
|
||||
- 5
|
||||
`)
|
||||
})
|
||||
|
||||
it('changes from a list to a different type', () => {
|
||||
const raw = `\
|
||||
foo:
|
||||
bar:
|
||||
- 1
|
||||
- 2
|
||||
qux: 2
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
json.foo.bar = { baz: 1 }
|
||||
|
||||
patchDocument(document, json)
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo:
|
||||
bar:
|
||||
baz: 1
|
||||
qux: 2
|
||||
`)
|
||||
})
|
||||
|
||||
it('throws error when encountering unknown node in primitive list', () => {
|
||||
const raw = `\
|
||||
- 1
|
||||
- 2
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const contents = document.contents as yaml.YAMLSeq
|
||||
|
||||
// The correct way to modify the AST would be:
|
||||
//
|
||||
// content.items.push(new yaml.Scalar(3))
|
||||
//
|
||||
// Inserting the raw raw value should cause the patch function to throw.
|
||||
contents.items.push(3)
|
||||
|
||||
expect(() => {
|
||||
patchDocument(document, [1, 2, 3])
|
||||
}).toThrow('Encountered unexpected non-node value: 3')
|
||||
})
|
||||
|
||||
it('throws error when encountering unknown node in complex list', () => {
|
||||
const raw = `\
|
||||
- foo: 1
|
||||
- bar: 2
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const contents = document.contents as yaml.YAMLSeq
|
||||
|
||||
// The correct way to modify the AST would be:
|
||||
//
|
||||
// content.items.push(new yaml.Scalar(3))
|
||||
//
|
||||
// Inserting the raw raw value should cause the patch function to throw.
|
||||
contents.items.push({ qux: 3 })
|
||||
|
||||
expect(() => {
|
||||
patchDocument(document, [{ foo: 1 }, { bar: 2 }, { qux: 3 }])
|
||||
}).toThrow('Encountered unexpected non-node value: [object Object]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('alias', () => {
|
||||
it('updates aliases in original location when alias=follow', () => {
|
||||
const raw = `\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
|
||||
bar: *config
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
// When aliases are used, the toJSON function will reuse the same object. We
|
||||
// have to create a new list to get a representative test.
|
||||
json.bar = [...json.bar, 3]
|
||||
|
||||
patchDocument(document, json, { aliases: 'follow' })
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
|
||||
bar: *config
|
||||
`)
|
||||
})
|
||||
|
||||
it('removes alias when alias=unwrap', () => {
|
||||
const raw = `\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
|
||||
bar: *config
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
// When aliases are used, the toJSON function will reuse the same object. We
|
||||
// have to create a new list to get a representative test.
|
||||
json.bar = [...json.bar, 3]
|
||||
|
||||
patchDocument(document, json, { aliases: 'unwrap' })
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
|
||||
bar:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
`)
|
||||
})
|
||||
|
||||
it('updates anchor nodes when alias=follow', () => {
|
||||
const raw = `\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
|
||||
bar: *config
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
// When aliases are used, the toJSON function will reuse the same object. We
|
||||
// have to create a new list to get a representative test.
|
||||
json.bar = [...json.bar, 3]
|
||||
|
||||
patchDocument(document, json, { aliases: 'follow' })
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
|
||||
bar: *config
|
||||
`)
|
||||
})
|
||||
|
||||
it('alias unwraps correctly when modifying anchor node', () => {
|
||||
const raw = `\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
|
||||
bar: *config
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
// When aliases are used, the toJSON function will reuse the same object. We
|
||||
// have to create a new list to get a representative test.
|
||||
json.foo = [...json.foo, 3]
|
||||
|
||||
patchDocument(document, json, { aliases: 'unwrap' })
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
|
||||
bar:
|
||||
- 1
|
||||
- 2
|
||||
`)
|
||||
})
|
||||
|
||||
// It's not completely clear what to do in this case. The library uses the value of the last encounter.
|
||||
it('updates anchor and alias nodes with conflicting values when alias=follow', () => {
|
||||
const raw = `\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
|
||||
bar: *config
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
// When aliases are used, the toJSON function will reuse the same object. We
|
||||
// have to create a new list to get a representative test.
|
||||
json.foo = [...json.foo, 3]
|
||||
json.bar = [...json.bar, 4]
|
||||
|
||||
patchDocument(document, json, { aliases: 'follow' })
|
||||
|
||||
expect(document.toString()).toBe(`\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
- 4
|
||||
|
||||
bar: *config
|
||||
`)
|
||||
})
|
||||
|
||||
it('throws explicit error when encountering unresolved alias', () => {
|
||||
const raw = `\
|
||||
foo: &config
|
||||
- 1
|
||||
- 2
|
||||
|
||||
bar: *config
|
||||
`
|
||||
|
||||
const document = yaml.parseDocument(raw)
|
||||
const json = document.toJSON()
|
||||
|
||||
const contents = document.contents as yaml.YAMLMap
|
||||
const foo = contents.get('foo') as yaml.YAMLSeq
|
||||
foo.anchor = undefined
|
||||
|
||||
// When aliases are used, the toJSON function will reuse the same object. We
|
||||
// have to create a new list to get a representative test.
|
||||
json.bar = [...json.bar, 3]
|
||||
|
||||
expect(() => {
|
||||
patchDocument(document, json)
|
||||
}).toThrow('Failed to resolve yaml alias: config')
|
||||
})
|
||||
})
|
||||
18
yaml/document-sync/test/tsconfig.json
Normal file
18
yaml/document-sync/test/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "../node_modules/.test.lib",
|
||||
"rootDir": "..",
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"../../../__typings__/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
12
yaml/document-sync/tsconfig.json
Normal file
12
yaml/document-sync/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@pnpm/tsconfig",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
8
yaml/document-sync/tsconfig.lint.json
Normal file
8
yaml/document-sync/tsconfig.lint.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user