From f028c13f4c7adefe419b9bd92d2103f3b58a0fbd Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Mon, 24 Aug 2020 00:09:24 +0300 Subject: [PATCH 01/11] Add replacer argument for stringify, new Document & doc.createNode As replacer & options have different shapes, YAML.stringify and the Document constructor can still accept the options as the second argument. The behaviour of the replacer should match the JSON.stringify equivalent exactly for the values both support. Map is treated equivalently to an Object; others act like arrays. --- src/doc/Document.js | 24 +++++++-- src/index.js | 8 +-- src/tags/failsafe/map.js | 13 +++-- src/tags/failsafe/seq.js | 7 ++- src/tags/yaml-1.1/pairs.js | 5 +- src/tags/yaml-1.1/set.js | 7 ++- tests/doc/stringify.js | 108 ++++++++++++++++++++++++++++++++++++- 7 files changed, 156 insertions(+), 16 deletions(-) diff --git a/src/doc/Document.js b/src/doc/Document.js index 62e8f023..4efaa5bc 100644 --- a/src/doc/Document.js +++ b/src/doc/Document.js @@ -30,7 +30,17 @@ function assertCollection(contents) { export class Document { static defaults = documentOptions - constructor(value, options) { + constructor(value, replacer, options) { + if ( + options === undefined && + replacer && + typeof replacer === 'object' && + !Array.isArray(replacer) + ) { + options = replacer + replacer = undefined + } + this.options = Object.assign({}, defaultOptions, options) this.anchors = new Anchors(this.options.anchorPrefix) this.commentBefore = null @@ -48,7 +58,7 @@ export class Document { } else if (value instanceof CSTDocument) { this.parse(value) } else { - this.contents = this.createNode(value) + this.contents = this.createNode(value, { replacer }) } } @@ -62,8 +72,15 @@ export class Document { this.contents.addIn(path, value) } - createNode(value, { onTagObj, tag, wrapScalars } = {}) { + createNode(value, { onTagObj, replacer, tag, wrapScalars } = {}) { this.setSchema() + if (typeof replacer === 'function') value = replacer('', value) + else if (Array.isArray(replacer)) { + const keyToStr = v => + typeof v === 'number' || v instanceof String || v instanceof Number + const asStr = replacer.filter(keyToStr).map(String) + if (asStr.length > 0) replacer = replacer.concat(asStr) + } const aliasNodes = [] const ctx = { onAlias(source) { @@ -73,6 +90,7 @@ export class Document { }, onTagObj, prevObjects: new Map(), + replacer, schema: this.schema, wrapScalars: wrapScalars !== false } diff --git a/src/index.js b/src/index.js index e43aef5b..82abdf16 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,7 @@ function parseAllDocuments(src, options) { const stream = [] let prev for (const cstDoc of parseCST(src)) { - const doc = new Document(undefined, options) + const doc = new Document(undefined, null, options) doc.parse(cstDoc, prev) stream.push(doc) prev = doc @@ -18,7 +18,7 @@ function parseAllDocuments(src, options) { function parseDocument(src, options) { const cst = parseCST(src) - const doc = new Document(cst[0], options) + const doc = new Document(cst[0], null, options) if (cst.length > 1) { const errMsg = 'Source contains multiple documents; please use YAML.parseAllDocuments()' @@ -34,9 +34,9 @@ function parse(src, options) { return doc.toJSON() } -function stringify(value, options) { +function stringify(value, replacer, options) { if (value === undefined) return '\n' - return new Document(value, options).toString() + return new Document(value, replacer, options).toString() } export const YAML = { diff --git a/src/tags/failsafe/map.js b/src/tags/failsafe/map.js index 999f5971..917254e3 100644 --- a/src/tags/failsafe/map.js +++ b/src/tags/failsafe/map.js @@ -3,14 +3,17 @@ import { YAMLMap } from '../../ast/YAMLMap.js' import { resolveMap } from '../../resolve/resolveMap.js' function createMap(schema, obj, ctx) { + const { replacer } = ctx const map = new YAMLMap(schema) + const add = (key, value) => { + if (typeof replacer === 'function') value = replacer(key, value) + else if (Array.isArray(replacer) && !replacer.includes(key)) return + if (value !== undefined) map.items.push(createPair(key, value, ctx)) + } if (obj instanceof Map) { - for (const [key, value] of obj) map.items.push(createPair(key, value, ctx)) + for (const [key, value] of obj) add(key, value) } else if (obj && typeof obj === 'object') { - for (const key of Object.keys(obj)) { - const value = obj[key] - if (value !== undefined) map.items.push(createPair(key, value, ctx)) - } + for (const key of Object.keys(obj)) add(key, obj[key]) } if (typeof schema.sortMapEntries === 'function') { map.items.sort(schema.sortMapEntries) diff --git a/src/tags/failsafe/seq.js b/src/tags/failsafe/seq.js index b5d5cd93..241b3da8 100644 --- a/src/tags/failsafe/seq.js +++ b/src/tags/failsafe/seq.js @@ -3,9 +3,14 @@ import { createNode } from '../../doc/createNode.js' import { resolveSeq } from '../../resolve/resolveSeq.js' function createSeq(schema, obj, ctx) { + const { replacer } = ctx const seq = new YAMLSeq(schema) if (obj && obj[Symbol.iterator]) { - for (const it of obj) seq.items.push(createNode(it, null, ctx)) + let i = 0 + for (let it of obj) { + if (typeof replacer === 'function') it = replacer(String(i++), it) + seq.items.push(createNode(it, null, ctx)) + } } return seq } diff --git a/src/tags/yaml-1.1/pairs.js b/src/tags/yaml-1.1/pairs.js index db5cb1e8..6e4462af 100644 --- a/src/tags/yaml-1.1/pairs.js +++ b/src/tags/yaml-1.1/pairs.js @@ -31,9 +31,12 @@ export function parsePairs(doc, cst) { } export function createPairs(schema, iterable, ctx) { + const { replacer } = ctx const pairs = new YAMLSeq(schema) pairs.tag = 'tag:yaml.org,2002:pairs' - for (const it of iterable) { + let i = 0 + for (let it of iterable) { + if (typeof replacer === 'function') it = replacer(String(i++), it) let key, value if (Array.isArray(it)) { if (it.length === 2) { diff --git a/src/tags/yaml-1.1/set.js b/src/tags/yaml-1.1/set.js index b3f22e26..1eef8b1f 100644 --- a/src/tags/yaml-1.1/set.js +++ b/src/tags/yaml-1.1/set.js @@ -60,8 +60,13 @@ function parseSet(doc, cst) { } function createSet(schema, iterable, ctx) { + const { replacer } = ctx const set = new YAMLSet(schema) - for (const value of iterable) set.items.push(createPair(value, null, ctx)) + let i = 0 + for (let value of iterable) { + if (typeof replacer === 'function') value = replacer(String(i++), value) + set.items.push(createPair(value, null, ctx)) + } return set } diff --git a/tests/doc/stringify.js b/tests/doc/stringify.js index 308e2e39..5cab32e7 100644 --- a/tests/doc/stringify.js +++ b/tests/doc/stringify.js @@ -792,6 +792,112 @@ describe('undefined values', () => { ['b', undefined], ['c', 'C'] ]) - expect(YAML.stringify(map)).toBe('a: A\nb: null\nc: C\n') + expect(YAML.stringify(map)).toBe('a: A\nc: C\n') + }) +}) + +describe('replacer', () => { + test('empty array', () => { + const arr = [ + { a: 1, b: 2 }, + { a: 4, b: 5 } + ] + expect(YAML.stringify(arr, [])).toBe('- {}\n- {}\n') + }) + + test('Object, array of string', () => { + const arr = [ + { a: 1, b: 2 }, + { a: 4, b: 5 } + ] + expect(YAML.stringify(arr, ['a'])).toBe('- a: 1\n- a: 4\n') + }) + + test('Map, array of string', () => { + const map = new Map([ + ['a', 1], + ['b', 2], + [3, 4] + ]) + expect(YAML.stringify(map, ['a', '3'])).toBe('a: 1\n') + }) + + test('Object, array of number', () => { + const obj = { a: 1, b: 2, 3: 4, 5: 6 } + expect(YAML.stringify(obj, [3, 5])).toBe('"3": 4\n"5": 6\n') + }) + + test('Map, array of number', () => { + const map = new Map([ + ['a', 1], + ['3', 2], + [3, 4] + ]) + expect(YAML.stringify(map, [3])).toBe('"3": 2\n3: 4\n') + }) + + test('function as logger', () => { + const spy = jest.fn((key, value) => value) + const obj = { 1: 1, b: 2, c: [4] } + YAML.stringify(obj, spy) + expect(spy.mock.calls).toMatchObject([ + ['', obj], + ['1', 1], + ['b', 2], + ['c', [4]], + ['0', 4] + ]) + }) + + test('function as filter of Object entries', () => { + const obj = { 1: 1, b: 2, c: [4] } + const fn = (key, value) => (typeof value === 'number' ? undefined : value) + expect(YAML.stringify(obj, fn)).toBe('c:\n - null\n') + }) + + test('function as filter of Map entries', () => { + const map = new Map([ + [1, 1], + ['b', 2], + ['c', [4]] + ]) + const fn = (key, value) => (typeof value === 'number' ? undefined : value) + expect(YAML.stringify(map, fn)).toBe('c:\n - null\n') + }) + + test('function as transformer', () => { + const obj = { a: 1, b: 2, c: [3, 4] } + const fn = (key, value) => (typeof value === 'number' ? 2 * value : value) + expect(YAML.stringify(obj, fn)).toBe('a: 2\nb: 4\nc:\n - 6\n - 8\n') + }) + + test('createNode, !!set', () => { + 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 }) + expect(replacer.mock.calls).toMatchObject([ + ['', set], + ['0', 'a'], + ['1', 'b'], + ['2', 1], + ['3', [2]], + ['0', 2] + ]) + }) + + test('createNode, !!omap', () => { + 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' }) + expect(replacer.mock.calls).toMatchObject([ + ['', omap], + ['0', omap[0]], + ['1', omap[1]] + ]) }) }) From c3d860e7f4f9dc40f12bdc45681be1dffcb9641a Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 09:39:52 +0300 Subject: [PATCH 02/11] Use correct `this` in replacer calls --- src/doc/Document.js | 3 ++- src/tags/failsafe/map.js | 2 +- src/tags/failsafe/seq.js | 3 ++- src/tags/yaml-1.1/pairs.js | 3 ++- src/tags/yaml-1.1/set.js | 3 ++- tests/doc/stringify.js | 16 ++++++++++++++++ 6 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/doc/Document.js b/src/doc/Document.js index 4efaa5bc..9563aa08 100644 --- a/src/doc/Document.js +++ b/src/doc/Document.js @@ -74,7 +74,8 @@ export class Document { createNode(value, { onTagObj, replacer, tag, wrapScalars } = {}) { this.setSchema() - if (typeof replacer === 'function') value = replacer('', value) + if (typeof replacer === 'function') + value = replacer.call({ '': value }, '', value) else if (Array.isArray(replacer)) { const keyToStr = v => typeof v === 'number' || v instanceof String || v instanceof Number diff --git a/src/tags/failsafe/map.js b/src/tags/failsafe/map.js index 917254e3..bf18dc16 100644 --- a/src/tags/failsafe/map.js +++ b/src/tags/failsafe/map.js @@ -6,7 +6,7 @@ function createMap(schema, obj, ctx) { const { replacer } = ctx const map = new YAMLMap(schema) const add = (key, value) => { - if (typeof replacer === 'function') value = replacer(key, value) + if (typeof replacer === 'function') value = replacer.call(obj, key, value) else if (Array.isArray(replacer) && !replacer.includes(key)) return if (value !== undefined) map.items.push(createPair(key, value, ctx)) } diff --git a/src/tags/failsafe/seq.js b/src/tags/failsafe/seq.js index 241b3da8..9b855ff8 100644 --- a/src/tags/failsafe/seq.js +++ b/src/tags/failsafe/seq.js @@ -8,7 +8,8 @@ function createSeq(schema, obj, ctx) { if (obj && obj[Symbol.iterator]) { let i = 0 for (let it of obj) { - if (typeof replacer === 'function') it = replacer(String(i++), it) + if (typeof replacer === 'function') + it = replacer.call(obj, String(i++), it) seq.items.push(createNode(it, null, ctx)) } } diff --git a/src/tags/yaml-1.1/pairs.js b/src/tags/yaml-1.1/pairs.js index 6e4462af..50ae6817 100644 --- a/src/tags/yaml-1.1/pairs.js +++ b/src/tags/yaml-1.1/pairs.js @@ -36,7 +36,8 @@ export function createPairs(schema, iterable, ctx) { pairs.tag = 'tag:yaml.org,2002:pairs' let i = 0 for (let it of iterable) { - if (typeof replacer === 'function') it = replacer(String(i++), it) + if (typeof replacer === 'function') + it = replacer.call(iterable, String(i++), it) let key, value if (Array.isArray(it)) { if (it.length === 2) { diff --git a/src/tags/yaml-1.1/set.js b/src/tags/yaml-1.1/set.js index 1eef8b1f..c649ab4d 100644 --- a/src/tags/yaml-1.1/set.js +++ b/src/tags/yaml-1.1/set.js @@ -64,7 +64,8 @@ function createSet(schema, iterable, ctx) { const set = new YAMLSet(schema) let i = 0 for (let value of iterable) { - if (typeof replacer === 'function') value = replacer(String(i++), value) + if (typeof replacer === 'function') + value = replacer.call(iterable, String(i++), value) set.items.push(createPair(value, null, ctx)) } return set diff --git a/tests/doc/stringify.js b/tests/doc/stringify.js index 5cab32e7..c00544a6 100644 --- a/tests/doc/stringify.js +++ b/tests/doc/stringify.js @@ -847,6 +847,13 @@ describe('replacer', () => { ['c', [4]], ['0', 4] ]) + expect(spy.mock.instances).toMatchObject([ + { '': obj }, + obj, + obj, + obj, + obj.c + ]) }) test('function as filter of Object entries', () => { @@ -884,6 +891,14 @@ describe('replacer', () => { ['3', [2]], ['0', 2] ]) + expect(replacer.mock.instances).toMatchObject([ + { '': set }, + set, + set, + set, + set, + [2] + ]) }) test('createNode, !!omap', () => { @@ -899,5 +914,6 @@ describe('replacer', () => { ['0', omap[0]], ['1', omap[1]] ]) + expect(replacer.mock.instances).toMatchObject([{ '': omap }, omap, omap]) }) }) From 6d28ef22d88fbaabfd0e3d19a92cc6e31f7927ea Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 10:13:31 +0300 Subject: [PATCH 03/11] Rename toJSON() as toJS() internally BREAKING CHANGE: The `toJSON()` utility exported by 'yaml/util' is renamed as `toJS()`. --- docs/06_custom_tags.md | 2 +- src/ast/Alias.js | 4 ++-- src/ast/Collection.js | 3 +-- src/ast/Pair.js | 8 ++++---- src/ast/Scalar.js | 4 ++-- src/ast/YAMLSeq.js | 4 ++-- src/ast/index.js | 2 +- src/ast/toJS.js | 26 ++++++++++++++++++++++++++ src/ast/toJSON.js | 17 ----------------- src/doc/Document.js | 4 ++-- src/tags/yaml-1.1/omap.js | 8 ++++---- src/util.js | 2 +- util.d.ts | 6 +----- 13 files changed, 47 insertions(+), 43 deletions(-) create mode 100644 src/ast/toJS.js delete mode 100644 src/ast/toJSON.js diff --git a/docs/06_custom_tags.md b/docs/06_custom_tags.md index d5ae72ef..1f8d6cf5 100644 --- a/docs/06_custom_tags.md +++ b/docs/06_custom_tags.md @@ -132,7 +132,7 @@ import { parseSeq, // (doc, cstNode) => new YAMLSeq stringifyNumber, // (node) => string stringifyString, // (node, ctx, ...) => string - toJSON, // (value, arg, ctx) => any -- Recursively convert to plain JS + toJS, // (value, arg, ctx) => any -- Recursively convert to plain JS Type, // { [string]: string } -- Used as enum for node types YAMLReferenceError, YAMLSemanticError, YAMLSyntaxError, YAMLWarning } from 'yaml/util' diff --git a/src/ast/Alias.js b/src/ast/Alias.js index c604d0ae..4d1b9242 100644 --- a/src/ast/Alias.js +++ b/src/ast/Alias.js @@ -3,7 +3,7 @@ import { YAMLReferenceError } from '../errors.js' import { Collection } from './Collection.js' import { Node } from './Node.js' import { Pair } from './Pair.js' -import { toJSON } from './toJSON.js' +import { toJS } from './toJS.js' const getAliasCount = (node, anchors) => { if (node instanceof Alias) { @@ -52,7 +52,7 @@ export class Alias extends Node { } toJSON(arg, ctx) { - if (!ctx) return toJSON(this.source, arg, ctx) + if (!ctx) return toJS(this.source, arg, ctx) const { anchors, maxAliasCount } = ctx const anchor = anchors.get(this.source) /* istanbul ignore if */ diff --git a/src/ast/Collection.js b/src/ast/Collection.js index 9e40973d..42e0defc 100644 --- a/src/ast/Collection.js +++ b/src/ast/Collection.js @@ -108,8 +108,7 @@ export class Collection extends Node { } } - // overridden in implementations - /* istanbul ignore next */ + /* istanbul ignore next: overridden in implementations */ toJSON() { return null } diff --git a/src/ast/Pair.js b/src/ast/Pair.js index 70ef411c..e9e81865 100644 --- a/src/ast/Pair.js +++ b/src/ast/Pair.js @@ -5,7 +5,7 @@ import { Collection } from './Collection.js' import { Node } from './Node.js' import { Scalar } from './Scalar.js' import { YAMLSeq } from './YAMLSeq.js' -import { toJSON } from './toJSON.js' +import { toJS } from './toJS.js' const stringifyKey = (key, jsKey, ctx) => { if (jsKey === null) return '' @@ -57,15 +57,15 @@ export class Pair extends Node { } addToJSMap(ctx, map) { - const key = toJSON(this.key, '', ctx) + const key = toJS(this.key, '', ctx) if (map instanceof Map) { - const value = toJSON(this.value, key, ctx) + const value = toJS(this.value, key, ctx) map.set(key, value) } else if (map instanceof Set) { map.add(key) } else { const stringKey = stringifyKey(this.key, key, ctx) - map[stringKey] = toJSON(this.value, stringKey, ctx) + map[stringKey] = toJS(this.value, stringKey, ctx) } return map } diff --git a/src/ast/Scalar.js b/src/ast/Scalar.js index bf009730..66ac4f84 100644 --- a/src/ast/Scalar.js +++ b/src/ast/Scalar.js @@ -1,5 +1,5 @@ import { Node } from './Node.js' -import { toJSON } from './toJSON.js' +import { toJS } from './toJS.js' export const isScalarValue = value => !value || (typeof value !== 'function' && typeof value !== 'object') @@ -11,7 +11,7 @@ export class Scalar extends Node { } toJSON(arg, ctx) { - return ctx && ctx.keep ? this.value : toJSON(this.value, arg, ctx) + return ctx && ctx.keep ? this.value : toJS(this.value, arg, ctx) } toString() { diff --git a/src/ast/YAMLSeq.js b/src/ast/YAMLSeq.js index fe2f96a2..92fd2d51 100644 --- a/src/ast/YAMLSeq.js +++ b/src/ast/YAMLSeq.js @@ -1,6 +1,6 @@ import { Collection } from './Collection.js' import { Scalar, isScalarValue } from './Scalar.js' -import { toJSON } from './toJSON.js' +import { toJS } from './toJS.js' function asItemIndex(key) { let idx = key instanceof Scalar ? key.value : key @@ -45,7 +45,7 @@ export class YAMLSeq extends Collection { const seq = [] if (ctx && ctx.onCreate) ctx.onCreate(seq) let i = 0 - for (const item of this.items) seq.push(toJSON(item, String(i++), ctx)) + for (const item of this.items) seq.push(toJS(item, String(i++), ctx)) return seq } diff --git a/src/ast/index.js b/src/ast/index.js index fada7c24..c173eba6 100644 --- a/src/ast/index.js +++ b/src/ast/index.js @@ -7,4 +7,4 @@ export { Scalar } from './Scalar.js' export { YAMLMap, findPair } from './YAMLMap.js' export { YAMLSeq } from './YAMLSeq.js' -export { toJSON } from './toJSON.js' +export { toJS } from './toJS.js' diff --git a/src/ast/toJS.js b/src/ast/toJS.js new file mode 100644 index 00000000..b315387f --- /dev/null +++ b/src/ast/toJS.js @@ -0,0 +1,26 @@ +/** + * Recursively convert any node or its contents to native JavaScript + * + * @param value - The input value + * @param {string|null} arg - If `value` defines a `toJSON()` method, use this + * as its first argument + * @param ctx - Conversion context, originally set in Document#toJSON(). If + * `{ keep: true }` is not set, output should be suitable for JSON + * stringification. + */ +export function toJS(value, arg, ctx) { + if (Array.isArray(value)) return value.map((v, i) => toJS(v, String(i), ctx)) + if (value && typeof value.toJSON === 'function') { + const anchor = ctx && ctx.anchors && ctx.anchors.get(value) + if (anchor) + ctx.onCreate = res => { + anchor.res = res + delete ctx.onCreate + } + const res = value.toJSON(arg, ctx) + if (anchor && ctx.onCreate) ctx.onCreate(res) + return res + } + if (!(ctx && ctx.keep) && typeof value === 'bigint') return Number(value) + return value +} diff --git a/src/ast/toJSON.js b/src/ast/toJSON.js deleted file mode 100644 index 06af7995..00000000 --- a/src/ast/toJSON.js +++ /dev/null @@ -1,17 +0,0 @@ -export function toJSON(value, arg, ctx) { - if (Array.isArray(value)) - return value.map((v, i) => toJSON(v, String(i), ctx)) - if (value && typeof value.toJSON === 'function') { - const anchor = ctx && ctx.anchors && ctx.anchors.get(value) - if (anchor) - ctx.onCreate = res => { - anchor.res = res - delete ctx.onCreate - } - const res = value.toJSON(arg, ctx) - if (anchor && ctx.onCreate) ctx.onCreate(res) - return res - } - if ((!ctx || !ctx.keep) && typeof value === 'bigint') return Number(value) - return value -} diff --git a/src/doc/Document.js b/src/doc/Document.js index 9563aa08..0a090e2b 100644 --- a/src/doc/Document.js +++ b/src/doc/Document.js @@ -6,7 +6,7 @@ import { Scalar, collectionFromPath, isEmptyPath, - toJSON + toJS } from '../ast/index.js' import { Document as CSTDocument } from '../cst/Document' import { defaultTagPrefix } from '../constants.js' @@ -271,7 +271,7 @@ export class Document { { alias: [], aliasCount: 0, count: 1 } ]) ) - const res = toJSON(this.contents, arg, ctx) + const res = toJS(this.contents, arg, ctx) if (typeof onAnchor === 'function' && ctx.anchors) for (const { count, res } of ctx.anchors.values()) onAnchor(res, count) return res diff --git a/src/tags/yaml-1.1/omap.js b/src/tags/yaml-1.1/omap.js index 5f457b5b..97632f60 100644 --- a/src/tags/yaml-1.1/omap.js +++ b/src/tags/yaml-1.1/omap.js @@ -3,7 +3,7 @@ import { Pair } from '../../ast/Pair.js' import { Scalar } from '../../ast/Scalar.js' import { YAMLMap } from '../../ast/YAMLMap.js' import { YAMLSeq } from '../../ast/YAMLSeq.js' -import { toJSON } from '../../ast/toJSON.js' +import { toJS } from '../../ast/toJS.js' import { createPairs, parsePairs } from './pairs.js' export class YAMLOMap extends YAMLSeq { @@ -26,10 +26,10 @@ export class YAMLOMap extends YAMLSeq { for (const pair of this.items) { let key, value if (pair instanceof Pair) { - key = toJSON(pair.key, '', ctx) - value = toJSON(pair.value, key, ctx) + key = toJS(pair.key, '', ctx) + value = toJS(pair.value, key, ctx) } else { - key = toJSON(pair, '', ctx) + key = toJS(pair, '', ctx) } if (map.has(key)) throw new Error('Ordered maps must not include duplicate keys') diff --git a/src/util.js b/src/util.js index acd7133b..4cc92844 100644 --- a/src/util.js +++ b/src/util.js @@ -1,4 +1,4 @@ -export { findPair, toJSON } from './ast/index.js' +export { findPair, toJS } from './ast/index.js' export { resolveMap as parseMap } from './resolve/resolveMap.js' export { resolveSeq as parseSeq } from './resolve/resolveSeq.js' diff --git a/util.d.ts b/util.d.ts index b1355410..5364c26f 100644 --- a/util.d.ts +++ b/util.d.ts @@ -17,11 +17,7 @@ export function stringifyString( onChompKeep?: () => void ): string -export function toJSON( - value: any, - arg?: any, - ctx?: Schema.CreateNodeContext -): any +export function toJS(value: any, arg?: any, ctx?: Schema.CreateNodeContext): any export enum Type { ALIAS = 'ALIAS', From 329af83c558f0f686c343a211b24326fdf18a3d6 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 11:33:10 +0300 Subject: [PATCH 04/11] Add Document#toJS(), drop keepBlobsInJSON option --- docs/03_options.md | 4 +-- docs/04_documents.md | 18 ++++++++------ docs/05_content_nodes.md | 4 +-- index.d.ts | 30 ++++++++++++----------- src/ast/YAMLMap.js | 7 +++--- src/ast/toJS.js | 2 +- src/doc/Document.js | 37 ++++++++++++++-------------- src/index.js | 2 +- src/options.js | 1 - tests/doc/YAML-1.2.spec.js | 8 +++--- tests/doc/anchors.js | 10 ++++---- tests/doc/comments.js | 2 +- tests/doc/parse.js | 8 +++--- tests/doc/stringify.js | 2 +- tests/doc/types.js | 50 +++++++++++++++++++------------------- tests/properties.js | 1 - tests/typings.ts | 6 ++--- tests/yaml-test-suite.js | 6 ++--- 18 files changed, 100 insertions(+), 98 deletions(-) diff --git a/docs/03_options.md b/docs/03_options.md index 178613f6..972b0a9f 100644 --- a/docs/03_options.md +++ b/docs/03_options.md @@ -2,8 +2,9 @@ ```js YAML.defaultOptions -// { keepBlobsInJSON: true, +// { indent: 2, // keepNodeTypes: true, +// mapAsMap: false, // version: '1.2' } YAML.Document.defaults @@ -26,7 +27,6 @@ The `version` option value (`'1.2'` by default) may be overridden by any documen | customTags | `Tag[] ⎮ function` | Array of [additional tags](#custom-data-types) to include in the schema | | indent | `number` | The number of spaces to use when indenting code. By default `2`. | | indentSeq | `boolean` | Whether block sequences should be indented. By default `true`. | -| keepBlobsInJSON | `boolean` | Allow non-JSON JavaScript objects to remain in the `toJSON` output. Relevant with the YAML 1.1 `!!timestamp` and `!!binary` tags as well as BigInts. By default `true`. | | keepCstNodes | `boolean` | Include references in the AST to each node's corresponding CST node. By default `false`. | | keepNodeTypes | `boolean` | Store the original node type when parsing documents. By default `true`. | | mapAsMap | `boolean` | When outputting JS, use Map rather than Object to represent mappings. By default `false`. | diff --git a/docs/04_documents.md b/docs/04_documents.md index 83c03c05..77374c59 100644 --- a/docs/04_documents.md +++ b/docs/04_documents.md @@ -106,7 +106,8 @@ During stringification, a document with a true-ish `version` value will include | parse(cst) | `Document` | Parse a CST into this document. Mostly an internal method, modifying the document according to the contents of the parsed `cst`. Calling this multiple times on a Document is not recommended. | | setSchema(id?, customTags?) | `void` | Set the schema used by the document. `id` may either be a YAML version, or the identifier of a YAML 1.2 schema; if set, `customTags` should have the same shape as the similarly-named option. | | setTagPrefix(handle, prefix) | `void` | Set `handle` as a shorthand string for the `prefix` tag namespace. | -| toJSON() | `any` | A plain JavaScript representation of the document `contents`. | +| toJS(options?) | `any` | A plain JavaScript representation of the document `contents`. | +| toJSON() | `any` | A JSON representation of the document `contents`. | | toString() | `string` | A YAML representation of the document. | ```js @@ -123,14 +124,15 @@ In addition to the above, the document object also provides the same **accessor To define a tag prefix to use when stringifying, use **`setTagPrefix(handle, prefix)`** rather than setting a value directly in `tagPrefixes`. This will guarantee that the `handle` is valid (by throwing an error), and will overwrite any previous definition for the `handle`. Use an empty `prefix` value to remove a prefix. +#### `Document#toJS()`, `Document#toJSON()` and `Document#toString()` + ```js const src = '1969-07-21T02:56:15Z' const doc = YAML.parseDocument(src, { customTags: ['timestamp'] }) -doc.toJSON() +doc.toJS() // Date { 1969-07-21T02:56:15.000Z } -doc.options.keepBlobsInJSON = false doc.toJSON() // '1969-07-21T02:56:15.000Z' @@ -138,7 +140,9 @@ String(doc) // '1969-07-21T02:56:15\n' ``` -For a plain JavaScript representation of the document, **`toJSON()`** is your friend. By default the values wrapped in scalar nodes will not be forced to JSON, so e.g. a `!!timestamp` will remain a `Date` in the output. To change this behaviour and enforce JSON values only, set the [`keepBlobsInJSON` option](#options) to `false`. +For a plain JavaScript representation of the document, **`toJS()`** is your friend. Its output may include `Map` and `Set` collections (e.g. if the `mapAsMap` option is true) and complex scalar values like `Date` for `!!timestamp`, but all YAML nodes will be resolved. For a representation consisting only of JSON values, use **`toJSON()`**. + +Use `toJS({ mapAsMap, onAnchor })` to explicitly set the `mapAsMap` option, or to define a callback `(value: any, count: number) => void` for each aliased anchor in the document. Conversely, to stringify a document as YAML, use **`toString()`**. This will also be called by `String(doc)`. This method will throw if the `errors` array is not empty. @@ -174,7 +178,7 @@ String(doc) const alias = doc.anchors.createAlias(doc.get(0, true), 'AA') // Alias { source: YAMLMap { items: [ [Pair] ] } } doc.add(alias) -doc.toJSON() +doc.toJS() // [ { a: 'A' }, { b: 'B' }, { a: 'A' } ] String(doc) // [ &AA { a: A }, { b: &a2 B }, *AA ] @@ -184,14 +188,14 @@ const merge = doc.anchors.createMergePair(alias) // key: Scalar { value: '<<' }, // value: YAMLSeq { items: [ [Alias] ] } } doc.addIn([1], merge) -doc.toJSON() +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.add(doc.anchors.createAlias(doc.get(1, true))) -doc.toJSON() // [RangeError: Maximum call stack size exceeded] +doc.toJS() // [RangeError: Maximum call stack size exceeded] String(doc) // [ // &AA { a: A }, diff --git a/docs/05_content_nodes.md b/docs/05_content_nodes.md index e24c6d04..bd87d937 100644 --- a/docs/05_content_nodes.md +++ b/docs/05_content_nodes.md @@ -14,7 +14,7 @@ class Node { spaceBefore: ?boolean, // a blank line before this node and its commentBefore tag: ?string, // a fully qualified tag, if required - toJSON(): any // a plain JS representation of this node + toJSON(): any // a plain JS or JSON representation of this node } ``` @@ -209,7 +209,7 @@ it has: - of values `) -doc.toJSON() +doc.toJS() // { 'it has': [ 'an array', 'of values' ] } doc.commentBefore diff --git a/index.d.ts b/index.d.ts index e7fa0fba..12200def 100644 --- a/index.d.ts +++ b/index.d.ts @@ -44,13 +44,6 @@ export interface Options extends Schema.Options { * Default: `true` */ indentSeq?: boolean - /** - * Allow non-JSON JavaScript objects to remain in the `toJSON` output. - * Relevant with the YAML 1.1 `!!timestamp` and `!!binary` tags as well as BigInts. - * - * Default: `true` - */ - keepBlobsInJSON?: boolean /** * Include references in the AST to each node's corresponding CST node. * @@ -276,7 +269,7 @@ export class Document extends Collection { * not set as it may be influenced by parsed directives; call this with no * arguments to set it manually, or with arguments to change the schema used * by the document. - **/ + */ setSchema( id?: Options['version'] | Schema.Name, customTags?: (Schema.TagId | Schema.Tag)[] @@ -286,13 +279,22 @@ export class Document extends Collection { /** * A plain JavaScript representation of the document `contents`. * + * @param mapAsMap - Use Map rather than Object to represent mappings. + * Overrides values set in Document or global options. + * @param onAnchor - If defined, called with the resolved `value` and + * reference `count` for each anchor in the document. + */ + toJS(opt?: { + mapAsMap?: boolean + onAnchor?: (value: any, count: number) => void + }): any + /** + * A JSON representation of the document `contents`. + * * @param arg Used by `JSON.stringify` to indicate the array index or property - * name. If its value is a `string` and the document `contents` has a scalar - * value, the `keepBlobsInJSON` option has no effect. - * @param onAnchor If defined, called with the resolved `value` and reference - * `count` for each anchor in the document. - * */ - toJSON(arg?: string, onAnchor?: (value: any, count: number) => void): any + * name. + */ + toJSON(arg?: string): any /** A YAML representation of the document. */ toString(): string } diff --git a/src/ast/YAMLMap.js b/src/ast/YAMLMap.js index d45c5ff4..e9eb32c7 100644 --- a/src/ast/YAMLMap.js +++ b/src/ast/YAMLMap.js @@ -57,10 +57,9 @@ export class YAMLMap extends Collection { } /** - * @param {*} arg ignored - * @param {*} ctx Conversion context, originally set in Document#toJSON() - * @param {Class} Type If set, forces the returned collection type - * @returns {*} Instance of Type, Map, or Object + * @param ctx - Conversion context, originally set in Document#toJS() + * @param {Class} Type - If set, forces the returned collection type + * @returns Instance of Type, Map, or Object */ toJSON(_, ctx, Type) { const map = Type ? new Type() : ctx && ctx.mapAsMap ? new Map() : {} diff --git a/src/ast/toJS.js b/src/ast/toJS.js index b315387f..c9ef9cc5 100644 --- a/src/ast/toJS.js +++ b/src/ast/toJS.js @@ -4,7 +4,7 @@ * @param value - The input value * @param {string|null} arg - If `value` defines a `toJSON()` method, use this * as its first argument - * @param ctx - Conversion context, originally set in Document#toJSON(). If + * @param ctx - Conversion context, originally set in Document#toJS(). If * `{ keep: true }` is not set, output should be suitable for JSON * stringification. */ diff --git a/src/doc/Document.js b/src/doc/Document.js index 0a090e2b..d3aed29b 100644 --- a/src/doc/Document.js +++ b/src/doc/Document.js @@ -250,33 +250,32 @@ export class Document { } } - toJSON(arg, onAnchor) { - const { keepBlobsInJSON, mapAsMap, maxAliasCount } = this.options - const keep = - keepBlobsInJSON && - (typeof arg !== 'string' || !(this.contents instanceof Scalar)) + toJS({ json, jsonArg, mapAsMap, onAnchor } = {}) { + const anchorNodes = Object.values(this.anchors.map).map(node => [ + node, + { alias: [], aliasCount: 0, count: 1 } + ]) + const anchors = anchorNodes.length > 0 ? new Map(anchorNodes) : null const ctx = { + anchors, doc: this, indentStep: ' ', - keep, - mapAsMap: keep && !!mapAsMap, - maxAliasCount, + keep: !json, + mapAsMap: + typeof mapAsMap === 'boolean' ? mapAsMap : !!this.options.mapAsMap, + maxAliasCount: this.options.maxAliasCount, stringify // Requiring directly in Pair would create circular dependencies } - const anchorNames = Object.keys(this.anchors.map) - if (anchorNames.length > 0) - ctx.anchors = new Map( - anchorNames.map(name => [ - this.anchors.map[name], - { alias: [], aliasCount: 0, count: 1 } - ]) - ) - const res = toJS(this.contents, arg, ctx) - if (typeof onAnchor === 'function' && ctx.anchors) - for (const { count, res } of ctx.anchors.values()) onAnchor(res, count) + const res = toJS(this.contents, jsonArg || '', ctx) + if (typeof onAnchor === 'function' && anchors) + for (const { count, res } of anchors.values()) onAnchor(res, count) return res } + toJSON(jsonArg, onAnchor) { + return this.toJS({ json: true, jsonArg, mapAsMap: false, onAnchor }) + } + toString() { if (this.errors.length > 0) throw new Error('Document with errors cannot be stringified') diff --git a/src/index.js b/src/index.js index 82abdf16..86ce01b4 100644 --- a/src/index.js +++ b/src/index.js @@ -31,7 +31,7 @@ function parse(src, options) { const doc = parseDocument(src, options) doc.warnings.forEach(warning => warn(warning)) if (doc.errors.length > 0) throw doc.errors[0] - return doc.toJSON() + return doc.toJS() } function stringify(value, replacer, options) { diff --git a/src/options.js b/src/options.js index 190f2cef..ef86758a 100644 --- a/src/options.js +++ b/src/options.js @@ -14,7 +14,6 @@ export const defaultOptions = { indentSeq: true, keepCstNodes: false, keepNodeTypes: true, - keepBlobsInJSON: true, mapAsMap: false, maxAliasCount: 100, prettyErrors: true, diff --git a/tests/doc/YAML-1.2.spec.js b/tests/doc/YAML-1.2.spec.js index 69bdd9f1..e38255df 100644 --- a/tests/doc/YAML-1.2.spec.js +++ b/tests/doc/YAML-1.2.spec.js @@ -1059,7 +1059,7 @@ bar`, } ] const docs = YAML.parseAllDocuments(src, { customTags }) - expect(docs.map(d => d.toJSON())).toMatchObject(['private', 'global']) + expect(docs.map(d => d.toJS())).toMatchObject(['private', 'global']) } }, @@ -1126,7 +1126,7 @@ bar`, resolve: (doc, node) => 'light:' + node.strValue } const docs = YAML.parseAllDocuments(src, { customTags: [tag] }) - expect(docs.map(d => d.toJSON())).toMatchObject([ + expect(docs.map(d => d.toJS())).toMatchObject([ 'light:fluorescent', 'light:green' ]) @@ -1908,7 +1908,7 @@ for (const section in spec) { test(name, () => { const { src, tgt, errors, special, warnings } = spec[section][name] const documents = YAML.parseAllDocuments(src) - const json = documents.map(doc => doc.toJSON()) + const json = documents.map(doc => doc.toJS()) const docErrors = documents.map(doc => doc.errors.map(err => err.message) ) @@ -1933,7 +1933,7 @@ for (const section in spec) { if (!errors) { const src2 = documents.map(doc => String(doc)).join('\n...\n') const documents2 = YAML.parseAllDocuments(src2) - const json2 = documents2.map(doc => doc.toJSON()) + const json2 = documents2.map(doc => doc.toJS()) trace: name, '\nIN\n' + src, '\nJSON\n' + JSON.stringify(json, null, ' '), diff --git a/tests/doc/anchors.js b/tests/doc/anchors.js index 1b6083b4..dc9ddc9e 100644 --- a/tests/doc/anchors.js +++ b/tests/doc/anchors.js @@ -33,7 +33,7 @@ test('circular reference', () => { const { items } = doc.contents expect(items).toHaveLength(2) expect(items[1].source).toBe(doc.contents) - const res = doc.toJSON() + const res = doc.toJS() expect(res[1]).toBe(res) expect(String(doc)).toBe(src) }) @@ -66,7 +66,7 @@ describe('create', () => { const doc = YAML.parseDocument('[{ a: A }, { b: B }]') const alias = doc.anchors.createAlias(doc.contents.items[0], 'AA') doc.contents.items.push(alias) - expect(doc.toJSON()).toMatchObject([{ a: 'A' }, { b: 'B' }, { a: 'A' }]) + expect(doc.toJS()).toMatchObject([{ a: 'A' }, { b: 'B' }, { a: 'A' }]) expect(String(doc)).toMatch('[ &AA { a: A }, { b: B }, *AA ]\n') }) @@ -151,7 +151,7 @@ describe('merge <<', () => { const [a, b] = doc.contents.items const merge = doc.anchors.createMergePair(a) b.items.push(merge) - expect(doc.toJSON()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) + expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) expect(String(doc)).toBe('[ &a1 { a: A }, { b: B, <<: *a1 } ]\n') }) @@ -161,7 +161,7 @@ describe('merge <<', () => { const alias = doc.anchors.createAlias(a, 'AA') const merge = doc.anchors.createMergePair(alias) b.items.push(merge) - expect(doc.toJSON()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) + expect(doc.toJS()).toMatchObject([{ a: 'A' }, { a: 'A', b: 'B' }]) expect(String(doc)).toBe('[ &AA { a: A }, { b: B, <<: *AA } ]\n') }) @@ -280,7 +280,7 @@ y: const doc = YAML.parseDocument(src, { merge: true }) expect(doc.errors).toHaveLength(0) expect(doc.warnings).toHaveLength(0) - expect(() => doc.toJSON()).toThrow('Maximum call stack size exceeded') + expect(() => doc.toJS()).toThrow('Maximum call stack size exceeded') expect(String(doc)).toBe(src) }) }) diff --git a/tests/doc/comments.js b/tests/doc/comments.js index 66290257..d95f972a 100644 --- a/tests/doc/comments.js +++ b/tests/doc/comments.js @@ -616,7 +616,7 @@ map: key3: value3` const doc = YAML.parseDocument(src) expect(doc.errors).toHaveLength(0) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ map: { foo0: { key2: 'value2' }, foo2: { key3: 'value3' } } }) }) diff --git a/tests/doc/parse.js b/tests/doc/parse.js index e8e0452c..65fbe419 100644 --- a/tests/doc/parse.js +++ b/tests/doc/parse.js @@ -241,7 +241,7 @@ aliases: 'utf8' ) const doc = YAML.parseDocument(src) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ aliases: [ { restore_cache: { keys: ['v1-yarn-cache'] } }, { save_cache: { key: 'v1-yarn-cache', paths: ['~/.cache/yarn'] } }, @@ -344,7 +344,7 @@ aliases: - d` const docs = YAML.parseAllDocuments(src) expect(docs[0].errors).toHaveLength(0) - expect(docs[0].toJSON()).toMatchObject(['a', { b: ['c'] }, 'd']) + expect(docs[0].toJS()).toMatchObject(['a', { b: ['c'] }, 'd']) }) }) @@ -602,11 +602,11 @@ describe('handling complex keys', () => { }) }) -test('Document.toJSON(null, onAnchor)', () => { +test('Document.toJS({ onAnchor })', () => { const src = 'foo: &a [&v foo]\nbar: *a\nbaz: *a\n' const doc = YAML.parseDocument(src) const onAnchor = jest.fn() - const res = doc.toJSON(null, onAnchor) + const res = doc.toJS({ onAnchor }) expect(onAnchor.mock.calls).toMatchObject([ [res.foo, 3], ['foo', 1] diff --git a/tests/doc/stringify.js b/tests/doc/stringify.js index c00544a6..450979f9 100644 --- a/tests/doc/stringify.js +++ b/tests/doc/stringify.js @@ -411,7 +411,7 @@ describe('eemeli/yaml#85', () => { const doc = YAML.parseDocument(str) const str2 = String(doc) expect(str2).toMatch(/^foo:\n {2}\[\n {4}bar/) - expect(YAML.parse(str2)).toMatchObject(doc.toJSON()) + expect(YAML.parse(str2)).toMatchObject(doc.toJS()) }) }) diff --git a/tests/doc/types.js b/tests/doc/types.js index e5e8505c..3632823e 100644 --- a/tests/doc/types.js +++ b/tests/doc/types.js @@ -29,7 +29,7 @@ describe('json schema', () => { "option": TruE` const doc = YAML.parseDocument(src, { schema: 'json' }) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ canonical: true, answer: false, logical: null, @@ -48,7 +48,7 @@ describe('json schema', () => { "not a number": .NaN` const doc = YAML.parseDocument(src, { schema: 'json' }) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ canonical: 685230.15, fixed: 685230.15, 'negative infinity': null, @@ -70,7 +70,7 @@ describe('json schema', () => { "hexadecimal": 0x0A74AE` const doc = YAML.parseDocument(src, { schema: 'json' }) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ canonical: 685230, decimal: -685230, octal: null, @@ -92,7 +92,7 @@ describe('json schema', () => { ~: 'null key'` const doc = YAML.parseDocument(src, { schema: 'json' }) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ empty: null, canonical: null, english: null, @@ -114,7 +114,7 @@ logical: True option: TruE\n` const doc = YAML.parseDocument(src) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ canonical: true, answer: false, logical: true, @@ -133,7 +133,7 @@ negative infinity: -.inf not a number: .NaN` const doc = YAML.parseDocument(src) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ canonical: 685230.15, fixed: 685230.15, 'negative infinity': Number.NEGATIVE_INFINITY, @@ -152,7 +152,7 @@ octal: 0o2472256 hexadecimal: 0x0A74AE` const doc = YAML.parseDocument(src) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ canonical: 685230, decimal: 685230, octal: 685230, @@ -171,7 +171,7 @@ english: null ~: null key` const doc = YAML.parseDocument(src) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ empty: null, canonical: null, english: null, @@ -190,14 +190,14 @@ one: 1 2: two { 3: 4 }: many\n` const doc = YAML.parseDocument(src) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ one: 1, '2': 'two', '{ 3: 4 }': 'many' }) expect(doc.errors).toHaveLength(0) doc.contents.items[2].key = { 3: 4 } - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ one: 1, '2': 'two', '{"3":4}': 'many' @@ -210,7 +210,7 @@ one: 1 2: two { 3: 4 }: many\n` const doc = YAML.parseDocument(src, { mapAsMap: true }) - expect(doc.toJSON()).toMatchObject( + expect(doc.toJS()).toMatchObject( new Map([ ['one', 1], [2, 'two'], @@ -219,7 +219,7 @@ one: 1 ) expect(doc.errors).toHaveLength(0) doc.contents.items[2].key = { 3: 4 } - expect(doc.toJSON()).toMatchObject( + expect(doc.toJS()).toMatchObject( new Map([ ['one', 1], [2, 'two'], @@ -283,7 +283,7 @@ logical: True option: on` const doc = YAML.parseDocument(src, { version: '1.1' }) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ canonical: true, answer: false, logical: true, @@ -306,7 +306,7 @@ negative infinity: -.inf not a number: .NaN` const doc = YAML.parseDocument(src) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ canonical: 685230.15, exponential: 685230.15, fixed: 685230.15, @@ -335,7 +335,7 @@ binary: 0b1010_0111_0100_1010_1110 sexagesimal: 190:20:30` const doc = YAML.parseDocument(src) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ canonical: 685230, decimal: 685230, octal: 685230, @@ -362,7 +362,7 @@ english: null ~: null key` const doc = YAML.parseDocument(src) - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ empty: null, canonical: null, english: null, @@ -386,7 +386,7 @@ space separated: 2001-12-14 21:59:43.10 -5 no time zone (Z): 2001-12-15 2:59:43.10 date (00:00:00Z): 2002-12-14` - const doc = YAML.parseDocument(src, { keepBlobsInJSON: false }) + const doc = YAML.parseDocument(src) doc.contents.items.forEach(item => { expect(item.value.value).toBeInstanceOf(Date) }) @@ -428,8 +428,8 @@ date (00:00:00Z): 2002-12-14\n`) { key: { value: 'b' }, value: { value: 2 } }, { key: { value: 'a' }, value: { value: 3 } } ]) - expect(doc.toJSON()).toBeInstanceOf(Array) - expect(doc.toJSON()).toMatchObject([{ a: 1 }, { b: 2 }, { a: 3 }]) + expect(doc.toJS()).toBeInstanceOf(Array) + expect(doc.toJS()).toMatchObject([{ a: 1 }, { b: 2 }, { a: 3 }]) expect(String(doc)).toBe(src) }) @@ -456,8 +456,8 @@ date (00:00:00Z): 2002-12-14\n`) test(name, () => { const doc = YAML.parseDocument(src, { version: '1.1' }) expect(doc.contents).toBeInstanceOf(YAMLOMap) - expect(doc.toJSON()).toBeInstanceOf(Map) - expect(doc.toJSON()).toMatchObject( + expect(doc.toJS()).toBeInstanceOf(Map) + expect(doc.toJS()).toMatchObject( new Map([ ['a', 1], ['b', 2], @@ -513,8 +513,8 @@ date (00:00:00Z): 2002-12-14\n`) test(name, () => { const doc = YAML.parseDocument(src, { version: '1.1' }) expect(doc.contents).toBeInstanceOf(YAMLSet) - expect(doc.toJSON()).toBeInstanceOf(Set) - expect(doc.toJSON()).toMatchObject(new Set(['a', 'b', 'c'])) + expect(doc.toJS()).toBeInstanceOf(Set) + expect(doc.toJS()).toMatchObject(new Set(['a', 'b', 'c'])) expect(String(doc)).toBe(src) }) @@ -623,7 +623,7 @@ perl: !perl/Text::Tabs {}` const doc = YAML.parseDocument(src) expect(doc.version).toBe('1.0') - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ number: 123, string: '123', pool: { number: 8 }, @@ -654,7 +654,7 @@ invoice: !domain.tld,2002/^invoice const doc = YAML.parseDocument(src) expect(doc.version).toBe('1.0') - expect(doc.toJSON()).toMatchObject({ + expect(doc.toJS()).toMatchObject({ invoice: { customers: [{ family: 'Dumars', given: 'Chris' }] } }) expect(String(doc)).toBe(`%YAML:1.0 diff --git a/tests/properties.js b/tests/properties.js index 719979e7..745b7523 100644 --- a/tests/properties.js +++ b/tests/properties.js @@ -16,7 +16,6 @@ describe('properties', () => { const yamlArbitrary = fc.anything({ key: key, values: values }) const optionsArbitrary = fc.record( { - keepBlobsInJSON: fc.boolean(), keepCstNodes: fc.boolean(), keepNodeTypes: fc.boolean(), mapAsMap: fc.constant(false), diff --git a/tests/typings.ts b/tests/typings.ts index b5d3ba2f..e67c1f5f 100644 --- a/tests/typings.ts +++ b/tests/typings.ts @@ -60,7 +60,7 @@ String(doc) const alias = anchors.createAlias(a, 'AA') seq.items.push(alias) const refs = new Map() -doc.toJSON(null, (value, count) => refs.set(value, count)) +doc.toJS({ onAnchor: (value, count) => refs.set(value, count) }) // [ { a: 'A' }, { b: 'B' }, { a: 'A' } ] String(doc) // [ &AA { a: A }, { b: &a2 B }, *AA ] @@ -69,14 +69,14 @@ refs const merge = anchors.createMergePair(alias) b.items.push(merge) -doc.toJSON() +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.items.push(anchors.createAlias(b)) -doc.toJSON() // [RangeError: Maximum call stack size exceeded] +doc.toJS() // [RangeError: Maximum call stack size exceeded] String(doc) // [ // &AA { a: A }, diff --git a/tests/yaml-test-suite.js b/tests/yaml-test-suite.js index 8f75a8a1..e1012389 100644 --- a/tests/yaml-test-suite.js +++ b/tests/yaml-test-suite.js @@ -10,7 +10,7 @@ const testDirs = fs const matchJson = (docs, json) => { if (!json) return - const received = docs[0] ? docs.map(doc => doc.toJSON()) : null + const received = docs[0] ? docs.map(doc => doc.toJS()) : null const expected = docs.length > 1 ? json @@ -103,9 +103,9 @@ testDirs.forEach(dir => { if (outYaml) { test('out.yaml', () => { const resDocs = YAML.parseAllDocuments(yaml, { mapAsMap: true }) - const resJson = resDocs.map(doc => doc.toJSON()) + const resJson = resDocs.map(doc => doc.toJS()) const expDocs = YAML.parseAllDocuments(outYaml, { mapAsMap: true }) - const expJson = expDocs.map(doc => doc.toJSON()) + const expJson = expDocs.map(doc => doc.toJS()) expect(resJson).toMatchObject(expJson) }) } From e6a9ff710e87d626f37fad7fdec32d713c2d3b91 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 17:28:29 +0300 Subject: [PATCH 05/11] Add JSON reviver support to YAML.parse() and doc.toJS() --- index.d.ts | 51 +++++++++++--- src/doc/Document.js | 7 +- src/doc/applyReviver.js | 42 ++++++++++++ src/index.js | 9 ++- tests/doc/parse.js | 146 ++++++++++++++++++++++++++++++++++++++++ tests/typings.ts | 11 +-- 6 files changed, 250 insertions(+), 16 deletions(-) create mode 100644 src/doc/applyReviver.js diff --git a/index.d.ts b/index.d.ts index 12200def..ca53e213 100644 --- a/index.d.ts +++ b/index.d.ts @@ -25,6 +25,9 @@ export { default as parseCST } from './parse-cst' */ export const defaultOptions: Options +type Replacer = any[] | ((key: any, value: any) => boolean) +type Reviver = (key: any, value: any) => any + export interface Options extends Schema.Options { /** * Default prefix for anchors. @@ -199,6 +202,26 @@ export namespace scalarOptions { } } +export interface CreateNodeOptions { + /** + * 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. + */ + tag?: string + /** + * Wrap plain values in `Scalar` objects. + * + * Default: `true` + */ + wrapScalars?: boolean +} + export class Document extends Collection { cstNode?: CST.Document /** @@ -206,6 +229,7 @@ export class Document extends Collection { * in a Node container. */ constructor(value?: any, options?: Options) + constructor(value: any, replacer: null | Replacer, options?: Options) tag: never directivesEndMarker?: boolean type: Type.DOCUMENT @@ -239,15 +263,10 @@ export class Document extends Collection { /** * Convert any value into a `Node` using the current schema, recursively * turning objects into collections. - * - * @param options Use `tag` to specify the collection type, e.g. `"!!omap"`. - * Note that this requires the corresponding tag to be available in this - * document's schema. If `wrapScalars` is not `false`, also wraps plain - * values in `Scalar` objects. */ createNode( value: any, - options?: { tag?: string; wrapScalars?: boolean } + { replacer, tag, wrapScalars }?: CreateNodeOptions ): Node /** * Convert a key and a value into a `Pair` using the current schema, @@ -283,10 +302,12 @@ export class Document extends Collection { * Overrides values set in Document or global options. * @param onAnchor - If defined, called with the resolved `value` and * reference `count` for each anchor in the document. + * @param reviver - A function that may filter or modify the output JS value */ toJS(opt?: { mapAsMap?: boolean onAnchor?: (value: any, count: number) => void + reviver?: Reviver }): any /** * A JSON representation of the document `contents`. @@ -400,14 +421,28 @@ export function parseAllDocuments( * support you should use `YAML.parseAllDocuments`. May throw on error, and may * log warnings using `console.warn`. * - * @param str A string with YAML formatting. + * @param str - A string with YAML formatting. + * @param reviver - A reviver function, as in `JSON.parse()` * @returns The value will match the type of the root value of the parsed YAML * document, so Maps become objects, Sequences arrays, and scalars result in * nulls, booleans, numbers and strings. */ export function parse(str: string, options?: Options): any +export function parse( + str: string, + reviver: null | Reviver, + options?: Options +): any /** - * @returns Will always include \n as the last character, as is expected of YAML documents. + * Stringify a value as a YAML document. + * + * @param replacer - A replacer array or function, as in `JSON.stringify()` + * @returns Will always include `\n` as the last character, as is expected of YAML documents. */ export function stringify(value: any, options?: Options): string +export function stringify( + value: any, + replacer: null | Replacer, + options?: Options +): string diff --git a/src/doc/Document.js b/src/doc/Document.js index d3aed29b..524ba35f 100644 --- a/src/doc/Document.js +++ b/src/doc/Document.js @@ -17,6 +17,7 @@ import { stringify } from '../stringify/stringify.js' import { Anchors } from './Anchors.js' import { Schema } from './Schema.js' +import { applyReviver } from './applyReviver.js' import { createNode } from './createNode.js' import { listTagNames } from './listTagNames.js' import { parseContents } from './parseContents.js' @@ -250,7 +251,7 @@ export class Document { } } - toJS({ json, jsonArg, mapAsMap, onAnchor } = {}) { + toJS({ json, jsonArg, mapAsMap, onAnchor, reviver } = {}) { const anchorNodes = Object.values(this.anchors.map).map(node => [ node, { alias: [], aliasCount: 0, count: 1 } @@ -269,7 +270,9 @@ export class Document { const res = toJS(this.contents, jsonArg || '', ctx) if (typeof onAnchor === 'function' && anchors) for (const { count, res } of anchors.values()) onAnchor(res, count) - return res + return typeof reviver === 'function' + ? applyReviver(reviver, { '': res }, '', res) + : res } toJSON(jsonArg, onAnchor) { diff --git a/src/doc/applyReviver.js b/src/doc/applyReviver.js new file mode 100644 index 00000000..5aa94912 --- /dev/null +++ b/src/doc/applyReviver.js @@ -0,0 +1,42 @@ +/** + * Applies the JSON.parse reviver algorithm as defined in the ECMA-262 spec, + * in section 24.5.1.1 "Runtime Semantics: InternalizeJSONProperty" of the + * 2021 edition: https://tc39.es/ecma262/#sec-json.parse + * + * Includes extensions for handling Map and Set objects. + */ +export function applyReviver(reviver, obj, key, val) { + if (val && typeof val === 'object') { + if (Array.isArray(val)) { + for (let i = 0, len = val.length; i < len; ++i) { + const v0 = val[i] + const v1 = applyReviver(reviver, val, String(i), v0) + if (v1 === undefined) delete val[i] + else if (v1 !== v0) val[i] = v1 + } + } else if (val instanceof Map) { + for (const k of Array.from(val.keys())) { + const v0 = val.get(k) + const v1 = applyReviver(reviver, val, k, v0) + if (v1 === undefined) val.delete(k) + else if (v1 !== v0) val.set(k, v1) + } + } else if (val instanceof Set) { + for (const v0 of Array.from(val)) { + const v1 = applyReviver(reviver, val, v0, v0) + if (v1 === undefined) val.delete(v0) + else if (v1 !== v0) { + val.delete(v0) + val.add(v1) + } + } + } else { + for (const [k, v0] of Object.entries(val)) { + const v1 = applyReviver(reviver, val, k, v0) + if (v1 === undefined) delete val[k] + else if (v1 !== v0) val[k] = v1 + } + } + } + return reviver.call(obj, key, val) +} diff --git a/src/index.js b/src/index.js index 86ce01b4..7a873ce6 100644 --- a/src/index.js +++ b/src/index.js @@ -27,11 +27,16 @@ function parseDocument(src, options) { return doc } -function parse(src, options) { +function parse(src, reviver, options) { + if (options === undefined && reviver && typeof reviver === 'object') { + options = reviver + reviver = undefined + } + const doc = parseDocument(src, options) doc.warnings.forEach(warning => warn(warning)) if (doc.errors.length > 0) throw doc.errors[0] - return doc.toJS() + return doc.toJS({ reviver }) } function stringify(value, replacer, options) { diff --git a/tests/doc/parse.js b/tests/doc/parse.js index 65fbe419..db78b830 100644 --- a/tests/doc/parse.js +++ b/tests/doc/parse.js @@ -612,3 +612,149 @@ test('Document.toJS({ onAnchor })', () => { ['foo', 1] ]) }) + +describe('reviver', () => { + test('MDN exemple', () => { + const reviver = jest.fn((key, value) => value) + const src = '{"1": 1, "2": 2, "3": {"4": 4, "5": {"6": 6}}}' + const obj = JSON.parse(src) + YAML.parse(src, reviver) + expect(reviver.mock.calls).toMatchObject([ + ['1', 1], + ['2', 2], + ['4', 4], + ['6', 6], + ['5', { 6: 6 }], + ['3', obj[3]], + ['', obj] + ]) + expect(reviver.mock.instances).toMatchObject([ + obj, + obj, + obj[3], + obj[3][5], + obj[3], + obj, + { '': obj } + ]) + }) + + test('modify values', () => { + const reviver = jest.fn((key, value) => + typeof value === 'number' ? 2 * value : value + ) + const src = '{"1": 1, "2": 2, "3": {"4": 4, "5": {"6": 6}}}' + expect(YAML.parse(src, reviver)).toMatchObject({ + 1: 2, + 2: 4, + 3: { 4: 8, 5: { 6: 12 } } + }) + }) + + test('remove values', () => { + const reviver = jest.fn((key, value) => + key !== '' && key % 2 === 0 ? undefined : value + ) + const src = '{"1": 1, "2": 2, "3": {"4": 4, "5": {"6": 6}}}' + expect(YAML.parse(src, reviver)).toMatchObject({ + 1: 1, + 3: { 5: {} } + }) + }) + + test('add values to this', () => { + const reviver = jest.fn(function (key, value) { + expect(key).not.toBe('9') + this[9] = 9 + return value + }) + const src = '{"1": 1, "2": 2, "3": {"4": 4, "5": {"6": 6}}}' + expect(YAML.parse(src, reviver)).toMatchObject({ + 1: 1, + 2: 2, + 3: { 4: 4, 5: { 6: 6, 9: 9 }, 9: 9 }, + 9: 9 + }) + }) + + test('!!set', () => { + const these = [] + const reviver = jest.fn(function (key, value) { + these.push(Array.from(key === '' ? this[''] : this)) + if (key === 2) return undefined + if (key === 8) return 10 + return value + }) + const src = '!!set { 2, 4, 6, 8 }' + const set = YAML.parse(src, reviver) + expect(set).toBeInstanceOf(Set) + expect(Array.from(set)).toMatchObject([4, 6, 10]) + expect(reviver.mock.calls).toMatchObject([ + [2, 2], + [4, 4], + [6, 6], + [8, 8], + ['', {}] + ]) + expect(these).toMatchObject([ + [2, 4, 6, 8], + [4, 6, 8], + [4, 6, 8], + [4, 6, 8], + [4, 6, 10] + ]) + }) + + test('!!omap', () => { + const these = [] + const reviver = jest.fn(function (key, value) { + these.push(Array.from(key === '' ? this[''] : this)) + if (key === 2) return undefined + if (key === 8) return 10 + return value + }) + const src = '!!omap [ 2: 3, 4: 5, 6: 7, 8: 9 ]' + const map = YAML.parse(src, reviver) + expect(map).toBeInstanceOf(Map) + expect(Array.from(map)).toMatchObject([ + [4, 5], + [6, 7], + [8, 10] + ]) + expect(reviver.mock.calls).toMatchObject([ + [2, 3], + [4, 5], + [6, 7], + [8, 9], + ['', map] + ]) + expect(these).toMatchObject([ + [ + [2, 3], + [4, 5], + [6, 7], + [8, 9] + ], + [ + [4, 5], + [6, 7], + [8, 9] + ], + [ + [4, 5], + [6, 7], + [8, 9] + ], + [ + [4, 5], + [6, 7], + [8, 9] + ], + [ + [4, 5], + [6, 7], + [8, 10] + ] + ]) + }) +}) diff --git a/tests/typings.ts b/tests/typings.ts index e67c1f5f..aae031ea 100644 --- a/tests/typings.ts +++ b/tests/typings.ts @@ -6,7 +6,7 @@ import { YAMLMap, YAMLSeq, Pair } from '../types' YAML.parse('3.14159') // 3.14159 -YAML.parse('[ true, false, maybe, null ]\n') +YAML.parse('[ true, false, maybe, null ]\n', { version: '1.2' }) // [ true, false, 'maybe', null ] const file = `# file.yml @@ -16,7 +16,7 @@ YAML: yaml: - A complete JavaScript implementation - https://www.npmjs.com/package/yaml` -YAML.parse(file) +YAML.parse(file, (key, value) => value) // { YAML: // [ 'A human-readable data serialization language', // 'https://en.wikipedia.org/wiki/YAML' ], @@ -27,14 +27,17 @@ YAML.parse(file) YAML.stringify(3.14159) // '3.14159\n' -YAML.stringify([true, false, 'maybe', null]) +YAML.stringify([true, false, 'maybe', null], { version: '1.2' }) // `- true // - false // - maybe // - null // ` -YAML.stringify({ number: 3, plain: 'string', block: 'two\nlines\n' }) +YAML.stringify( + { number: 3, plain: 'string', block: 'two\nlines\n' }, + (key, value) => value +) // `number: 3 // plain: string // block: > From 8f5de1db586889242bb90d93ba2b5fa685680041 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 17:28:47 +0300 Subject: [PATCH 06/11] Update docs for resolver & reviver --- README.md | 12 ++++++------ docs/01_intro.md | 12 ++++++------ docs/02_parse_stringify.md | 8 ++++---- docs/04_documents.md | 6 +++--- docs/05_content_nodes.md | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 50580bb6..07d207d1 100644 --- a/README.md +++ b/README.md @@ -28,21 +28,21 @@ const YAML = require('yaml') ### Parse & Stringify -- [`YAML.parse(str, options): value`](https://eemeli.org/yaml/#yaml-parse) -- [`YAML.stringify(value, options): string`](https://eemeli.org/yaml/#yaml-stringify) +- [`YAML.parse(str, reviver?, options?): value`](https://eemeli.org/yaml/#yaml-parse) +- [`YAML.stringify(value, replacer?, options?): string`](https://eemeli.org/yaml/#yaml-stringify) ### YAML Documents - [`YAML.defaultOptions`](https://eemeli.org/yaml/#options) - [`YAML.Document`](https://eemeli.org/yaml/#yaml-documents) - - [`constructor(value, options)`](https://eemeli.org/yaml/#creating-documents) + - [`constructor(value, replacer?, options?)`](https://eemeli.org/yaml/#creating-documents) - [`defaults`](https://eemeli.org/yaml/#options) - - [`#createNode(value, options): Node`](https://eemeli.org/yaml/#creating-nodes) + - [`#createNode(value, options?): Node`](https://eemeli.org/yaml/#creating-nodes) - [`#anchors`](https://eemeli.org/yaml/#working-with-anchors) - [`#contents`](https://eemeli.org/yaml/#content-nodes) - [`#errors`](https://eemeli.org/yaml/#errors) -- [`YAML.parseAllDocuments(str, options): YAML.Document[]`](https://eemeli.org/yaml/#parsing-documents) -- [`YAML.parseDocument(str, options): YAML.Document`](https://eemeli.org/yaml/#parsing-documents) +- [`YAML.parseAllDocuments(str, options?): YAML.Document[]`](https://eemeli.org/yaml/#parsing-documents) +- [`YAML.parseDocument(str, options?): YAML.Document`](https://eemeli.org/yaml/#parsing-documents) ```js import { Pair, YAMLMap, YAMLSeq } from 'yaml/types' diff --git a/docs/01_intro.md b/docs/01_intro.md index 7da6ed10..d6f1cf1e 100644 --- a/docs/01_intro.md +++ b/docs/01_intro.md @@ -33,21 +33,21 @@ import YAML from 'yaml' const YAML = require('yaml') ``` -- [`YAML.parse(str, options): value`](#yaml-parse) -- [`YAML.stringify(value, options): string`](#yaml-stringify) +- [`YAML.parse(str, reviver?, options?): value`](#yaml-parse) +- [`YAML.stringify(value, replacer?, options?): string`](#yaml-stringify)

Documents

- [`YAML.defaultOptions`](#options) - [`YAML.Document`](#documents) - - [`constructor(value, options)`](#creating-documents) + - [`constructor(value, replacer?, options?)`](#creating-documents) - [`defaults`](#options) - - [`#createNode(value, options): Node`](#creating-nodes) + - [`#createNode(value, options?): Node`](#creating-nodes) - [`#anchors`](#working-with-anchors) - [`#contents`](#content-nodes) - [`#errors`](#errors) -- [`YAML.parseAllDocuments(str, options): YAML.Document[]`](#parsing-documents) -- [`YAML.parseDocument(str, options): YAML.Document`](#parsing-documents) +- [`YAML.parseAllDocuments(str, options?): YAML.Document[]`](#parsing-documents) +- [`YAML.parseDocument(str, options?): YAML.Document`](#parsing-documents) ```js import { Pair, YAMLMap, YAMLSeq } from 'yaml/types' diff --git a/docs/02_parse_stringify.md b/docs/02_parse_stringify.md index ad66ff9d..933adae7 100644 --- a/docs/02_parse_stringify.md +++ b/docs/02_parse_stringify.md @@ -34,9 +34,9 @@ YAML.parse(file) // 'https://www.npmjs.com/package/yaml' ] } ``` -#### `YAML.parse(str, options = {}): any` +#### `YAML.parse(str, reviver?, options = {}): any` -`str` should be a string with YAML formatting. See [Options](#options) for more information on the second parameter, an optional configuration object. +`str` should be a string with YAML formatting. If defined, the `reviver` function follows the [JSON implementation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#Using_the_reviver_parameter). See [Options](#options) for more information on the last argument, an optional configuration object. The returned value will match the type of the root value of the parsed YAML document, so Maps become objects, Sequences arrays, and scalars result in nulls, booleans, numbers and strings. @@ -65,9 +65,9 @@ YAML.stringify({ number: 3, plain: 'string', block: 'two\nlines\n' }) // ` ``` -#### `YAML.stringify(value, options = {}): string` +#### `YAML.stringify(value, replacer?, options = {}): string` -`value` can be of any type. The returned string will always include `\n` as the last character, as is expected of YAML documents. See [Options](#options) for more information on the second parameter, an optional configuration object. +`value` can be of any type. The returned string will always include `\n` as the last character, as is expected of YAML documents. If defined, the `replacer` array or function follows the [JSON implementation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter). See [Options](#options) for more information on the last argument, an optional configuration object. As strings in particular may be represented in a number of different styles, the simplest option for the value in question will always be chosen, depending mostly on the presence of escaped or control characters and leading & trailing whitespace. diff --git a/docs/04_documents.md b/docs/04_documents.md index 77374c59..b561f2d9 100644 --- a/docs/04_documents.md +++ b/docs/04_documents.md @@ -61,9 +61,9 @@ The `contents` of a parsed document will always consist of `Scalar`, `Map`, `Seq ## Creating Documents -#### `new YAML.Document(value, options = {})` +#### `new YAML.Document(value, replacer?, options = {})` -Creates a new document. If `value` is defined, the document `contents` are initialised with that value, wrapped recursively in appropriate [content nodes](#content-nodes). If `value` is `undefined`, the document's `contents` and `schema` are initialised as `null`. See [Options](#options) for more information on the second parameter. +Creates a new document. If `value` is defined, the document `contents` are initialised with that value, wrapped recursively in appropriate [content nodes](#content-nodes). If `value` is `undefined`, the document's `contents` and `schema` are initialised as `null`. If defined, a `replacer` may filter or modify the initial document contents, following the same algorithm as the [JSON implementation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter). See [Options](#options) for more information on the last argument. | Member | Type | Description | | ------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -142,7 +142,7 @@ String(doc) For a plain JavaScript representation of the document, **`toJS()`** is your friend. Its output may include `Map` and `Set` collections (e.g. if the `mapAsMap` option is true) and complex scalar values like `Date` for `!!timestamp`, but all YAML nodes will be resolved. For a representation consisting only of JSON values, use **`toJSON()`**. -Use `toJS({ mapAsMap, onAnchor })` to explicitly set the `mapAsMap` option, or to define a callback `(value: any, count: number) => void` for each aliased anchor in the document. +Use `toJS({ mapAsMap, onAnchor, reviver })` to explicitly set the `mapAsMap` option, define an `onAnchor` callback `(value: any, count: number) => void` for each aliased anchor in the document, or to apply a [reviver function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#Using_the_reviver_parameter) to the output. Conversely, to stringify a document as YAML, use **`toString()`**. This will also be called by `String(doc)`. This method will throw if the `errors` array is not empty. diff --git a/docs/05_content_nodes.md b/docs/05_content_nodes.md index bd87d937..bf7fcde5 100644 --- a/docs/05_content_nodes.md +++ b/docs/05_content_nodes.md @@ -166,7 +166,7 @@ String(doc) 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 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. If `options.wrapScalars` is undefined or `true`, plain values are wrapped in `Scalar` objects. +Use `options.replacer` to apply a replacer array or function, following the [JSON implementation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter). 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. If `options.wrapScalars` is undefined or `true`, plain values are wrapped in `Scalar` objects. As a possible side effect, this method may add entries to the document's [`anchors`](#working-with-anchors) From 4242c086c6df67d63e19bc51b92683ddcf8c6426 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 18:08:45 +0300 Subject: [PATCH 07/11] Encode strings with unpaired surrogate code points as double-quoted This effectively implements this ECMA-262 proposal: https://github.com/tc39/proposal-well-formed-stringify --- src/stringify/stringifyString.js | 5 +++-- tests/doc/stringify.js | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/stringify/stringifyString.js b/src/stringify/stringifyString.js index 1916f9c5..fc0af359 100644 --- a/src/stringify/stringifyString.js +++ b/src/stringify/stringifyString.js @@ -286,8 +286,9 @@ export function stringifyString(item, ctx, onComment, onChompKeep) { item = Object.assign({}, item, { value }) } if (type !== Type.QUOTE_DOUBLE) { - // force double quotes on control characters - if (/[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(value)) type = Type.QUOTE_DOUBLE + // force double quotes on control characters & unpaired surrogates + if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u{D800}-\u{DFFF}]/u.test(value)) + type = Type.QUOTE_DOUBLE } const _stringify = _type => { diff --git a/tests/doc/stringify.js b/tests/doc/stringify.js index 450979f9..11c2c5de 100644 --- a/tests/doc/stringify.js +++ b/tests/doc/stringify.js @@ -197,6 +197,28 @@ describe('timestamp-like string (YAML 1.1)', () => { } }) +// https://github.com/tc39/proposal-well-formed-stringify +describe('unpaired surrogate pairs', () => { + test('𝌆', () => { + expect(YAML.stringify('𝌆')).toBe('𝌆\n') + }) + test('\uD834\uDF06', () => { + expect(YAML.stringify('\uD834\uDF06')).toBe('𝌆\n') + }) + test('😀', () => { + expect(YAML.stringify('😀')).toBe('😀\n') + }) + test('\uD83D\uDE00', () => { + expect(YAML.stringify('\uD83D\uDE00')).toBe('😀\n') + }) + test('\uDF06\uD834', () => { + expect(YAML.stringify('\uDF06\uD834')).toBe('"\\udf06\\ud834"\n') + }) + test('\uDEAD', () => { + expect(YAML.stringify('\uDEAD')).toBe('"\\udead"\n') + }) +}) + describe('circular references', () => { test('parent at root', () => { const map = { foo: 'bar' } From d4b759e67a3960a89384f9bcb4246da22b6d13b2 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 19:11:03 +0300 Subject: [PATCH 08/11] Allow third arg of YAML.stringify to be a number or string setting indent --- docs/02_parse_stringify.md | 2 +- index.d.ts | 2 +- src/index.js | 5 +++++ tests/doc/stringify.js | 13 +++++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/02_parse_stringify.md b/docs/02_parse_stringify.md index 933adae7..380ecef2 100644 --- a/docs/02_parse_stringify.md +++ b/docs/02_parse_stringify.md @@ -67,7 +67,7 @@ YAML.stringify({ number: 3, plain: 'string', block: 'two\nlines\n' }) #### `YAML.stringify(value, replacer?, options = {}): string` -`value` can be of any type. The returned string will always include `\n` as the last character, as is expected of YAML documents. If defined, the `replacer` array or function follows the [JSON implementation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter). See [Options](#options) for more information on the last argument, an optional configuration object. +`value` can be of any type. The returned string will always include `\n` as the last character, as is expected of YAML documents. If defined, the `replacer` array or function follows the [JSON implementation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter). See [Options](#options) for more information on the last argument, an optional configuration object. For JSON compatibility, using a number or a string as the `options` value will set the `indent` option accordingly. As strings in particular may be represented in a number of different styles, the simplest option for the value in question will always be chosen, depending mostly on the presence of escaped or control characters and leading & trailing whitespace. diff --git a/index.d.ts b/index.d.ts index ca53e213..a845d973 100644 --- a/index.d.ts +++ b/index.d.ts @@ -444,5 +444,5 @@ export function stringify(value: any, options?: Options): string export function stringify( value: any, replacer: null | Replacer, - options?: Options + options?: number | string | Options ): string diff --git a/src/index.js b/src/index.js index 7a873ce6..dedf8c03 100644 --- a/src/index.js +++ b/src/index.js @@ -41,6 +41,11 @@ function parse(src, reviver, options) { function stringify(value, replacer, options) { if (value === undefined) return '\n' + if (typeof options === 'string') options = options.length + if (typeof options === 'number') { + const indent = Math.round(options) + options = indent < 1 ? undefined : indent > 8 ? { indent: 8 } : { indent } + } return new Document(value, replacer, options).toString() } diff --git a/tests/doc/stringify.js b/tests/doc/stringify.js index 11c2c5de..eb70510b 100644 --- a/tests/doc/stringify.js +++ b/tests/doc/stringify.js @@ -939,3 +939,16 @@ describe('replacer', () => { expect(replacer.mock.instances).toMatchObject([{ '': omap }, omap, omap]) }) }) + +describe('YAML.stringify options as scalar', () => { + test('number', () => { + expect(YAML.stringify({ foo: 'bar\nfuzz' }, null, 1)).toBe( + 'foo: |-\n bar\n fuzz\n' + ) + }) + test('string', () => { + expect(YAML.stringify({ foo: 'bar\nfuzz' }, null, '123')).toBe( + 'foo: |-\n bar\n fuzz\n' + ) + }) +}) From 10bf80acf9bfdf3e1b2c83dcedf477e93b742bd4 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 19:30:35 +0300 Subject: [PATCH 09/11] Fix replacer call on Set to use value as key, rather than index --- src/tags/failsafe/seq.js | 6 ++++-- src/tags/yaml-1.1/set.js | 3 +-- tests/doc/stringify.js | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/tags/failsafe/seq.js b/src/tags/failsafe/seq.js index 9b855ff8..cd78efdf 100644 --- a/src/tags/failsafe/seq.js +++ b/src/tags/failsafe/seq.js @@ -8,8 +8,10 @@ function createSeq(schema, obj, ctx) { if (obj && obj[Symbol.iterator]) { let i = 0 for (let it of obj) { - if (typeof replacer === 'function') - it = replacer.call(obj, String(i++), it) + if (typeof replacer === 'function') { + const key = obj instanceof Set ? it : String(i++) + it = replacer.call(obj, key, it) + } seq.items.push(createNode(it, null, ctx)) } } diff --git a/src/tags/yaml-1.1/set.js b/src/tags/yaml-1.1/set.js index c649ab4d..11ba81d4 100644 --- a/src/tags/yaml-1.1/set.js +++ b/src/tags/yaml-1.1/set.js @@ -62,10 +62,9 @@ function parseSet(doc, cst) { function createSet(schema, iterable, ctx) { const { replacer } = ctx const set = new YAMLSet(schema) - let i = 0 for (let value of iterable) { if (typeof replacer === 'function') - value = replacer.call(iterable, String(i++), value) + value = replacer.call(iterable, value, value) set.items.push(createPair(value, null, ctx)) } return set diff --git a/tests/doc/stringify.js b/tests/doc/stringify.js index eb70510b..5c9a6ff6 100644 --- a/tests/doc/stringify.js +++ b/tests/doc/stringify.js @@ -907,10 +907,10 @@ describe('replacer', () => { doc.createNode(set, { replacer }) expect(replacer.mock.calls).toMatchObject([ ['', set], - ['0', 'a'], - ['1', 'b'], - ['2', 1], - ['3', [2]], + ['a', 'a'], + ['b', 'b'], + [1, 1], + [[2], [2]], ['0', 2] ]) expect(replacer.mock.instances).toMatchObject([ From 43fd2848c6fc5ab7b1f5f67f69fd941efa78e131 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 20:10:52 +0300 Subject: [PATCH 10/11] Skip unpaired surrogate pair tests on Node.js < 12 --- tests/doc/stringify.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/doc/stringify.js b/tests/doc/stringify.js index 5c9a6ff6..8d8cc003 100644 --- a/tests/doc/stringify.js +++ b/tests/doc/stringify.js @@ -198,7 +198,7 @@ describe('timestamp-like string (YAML 1.1)', () => { }) // https://github.com/tc39/proposal-well-formed-stringify -describe('unpaired surrogate pairs', () => { +describe('unpaired surrogate pairs of Unicode code points', () => { test('𝌆', () => { expect(YAML.stringify('𝌆')).toBe('𝌆\n') }) @@ -211,10 +211,12 @@ describe('unpaired surrogate pairs', () => { test('\uD83D\uDE00', () => { expect(YAML.stringify('\uD83D\uDE00')).toBe('😀\n') }) - test('\uDF06\uD834', () => { + + const maybe = process.version < 'v12' ? test.skip : test + maybe('\uDF06\uD834', () => { expect(YAML.stringify('\uDF06\uD834')).toBe('"\\udf06\\ud834"\n') }) - test('\uDEAD', () => { + maybe('\uDEAD', () => { expect(YAML.stringify('\uDEAD')).toBe('"\\udead"\n') }) }) From ef1e4f5850b05e0c79f4678a0eb89ca796ecf266 Mon Sep 17 00:00:00 2001 From: Eemeli Aro Date: Sun, 30 Aug 2020 23:51:57 +0300 Subject: [PATCH 11/11] Update playground --- playground | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground b/playground index e6e37865..e2751a6d 160000 --- a/playground +++ b/playground @@ -1 +1 @@ -Subproject commit e6e378652a365ba961d05971c1e25cbf85d9d207 +Subproject commit e2751a6d13a22e7feeacc91d2e00a0735a116d93