diff --git a/yaml/_dumper_state.ts b/yaml/_dumper_state.ts index 9f7207e40e97..72b2270081e4 100644 --- a/yaml/_dumper_state.ts +++ b/yaml/_dumper_state.ts @@ -376,7 +376,10 @@ function blockHeader(string: string, indentPerLevel: number): string { return `${indentIndicator}${chomp}\n`; } -function getDuplicateObjects(root: unknown): unknown[] { +function getDuplicateObjects( + root: unknown, + walkMapsAndSets: boolean, +): unknown[] { const seenObjects = new Set(); const duplicateObjects = new Set(); const queue = [root]; @@ -389,8 +392,15 @@ function getDuplicateObjects(root: unknown): unknown[] { continue; } seenObjects.add(value); - const children = Array.isArray(value) ? value : Object.values(value); - queue.push(...children); + if (Array.isArray(value)) { + queue.push(...value); + } else if (walkMapsAndSets && value instanceof Map) { + queue.push(...value.keys(), ...value.values()); + } else if (walkMapsAndSets && value instanceof Set) { + queue.push(...value); + } else { + queue.push(...Object.values(value)); + } } return [...duplicateObjects]; @@ -453,6 +463,12 @@ export interface DumperStateOptions { * for non-printable characters. (default: "'") */ quoteStyle?: "'" | '"'; + /** + * If true, `Map`s stringify as YAML mappings in insertion order and `Set`s + * as `!!set`. Internal-only: absent from the public `StringifyOptions` + * types; the unstable module sets it unconditionally. (default: false) + */ + serializeMapsAndSets?: boolean; } export class DumperState { @@ -471,6 +487,7 @@ export class DumperState { usedDuplicates: Set = new Set(); styleMap: Map = new Map(); quoteStyle: "'" | '"'; + serializeMapsAndSets: boolean; constructor({ schema = DEFAULT_SCHEMA, @@ -485,6 +502,7 @@ export class DumperState { compatMode = true, condenseFlow = false, quoteStyle = "'", + serializeMapsAndSets = false, }: DumperStateOptions) { this.indent = Math.max(1, indent); this.arrayIndent = arrayIndent; @@ -499,6 +517,7 @@ export class DumperState { this.implicitTypes = schema.implicitTypes; this.explicitTypes = schema.explicitTypes; this.quoteStyle = quoteStyle; + this.serializeMapsAndSets = serializeMapsAndSets; } // Note: line breaking/folding is implemented for only the folded style. @@ -711,6 +730,95 @@ export class DumperState { return results.length ? prefix + results.join(separator) : "{}"; // Empty mapping if no valid pairs. } + stringifyFlowMap( + map: Map, + { level }: { level: number }, + ): string { + const separator = this.condenseFlow ? ":" : ": "; + + const results = []; + for (const [key, value] of map) { + const keyString = this.stringifyNode(key, { + level, + block: false, + compact: false, + isKey: true, + }); + if (keyString === null) continue; // Skip this pair because of invalid key. + + const valueString = this.stringifyNode(value, { + level, + block: false, + compact: false, + isKey: false, + }); + if (valueString === null) continue; // Skip this pair because of invalid value. + + // The `condenseFlow` quote is only sound for string keys; quoting any + // other key would change its parsed type. + const quote = this.condenseFlow && typeof key === "string" ? '"' : ""; + const keyPrefix = keyString.length > 1024 ? "? " : ""; + results.push( + quote + keyPrefix + keyString + quote + separator + valueString, + ); + } + + return `{${results.join(", ")}}`; + } + + // Entries are emitted in insertion order; `sortKeys` deliberately does not + // apply (its callback contract is string-keyed). + stringifyBlockMap( + map: Map, + { tag, level, compact }: { + tag: string | null; + level: number; + compact: boolean; + }, + ): string { + const separator = generateNextLine(this.indent, level); + + const results = []; + + for (const [key, value] of map) { + // A collection key can collapse to a single line in compact form, + // which would be ambiguous inline; force it onto its own lines in + // explicit `? key` form. + const complexKey = isObject(key); + const keyString = this.stringifyNode(key, { + level: level + 1, + block: true, + compact: !complexKey, + isKey: true, + }); + if (keyString === null) continue; // Skip this pair because of invalid key. + + const explicitPair = (tag !== null && tag !== "?") || + complexKey || (keyString.length > 1024); + + const valueString = this.stringifyNode(value, { + level: level + 1, + block: true, + compact: explicitPair, + isKey: false, + }); + if (valueString === null) continue; // Skip this pair because of invalid value. + + let pairBuffer = ""; + if (explicitPair) { + pairBuffer += keyString.charCodeAt(0) === LINE_FEED ? "?" : "? "; + } + pairBuffer += keyString; + if (explicitPair) pairBuffer += separator; + pairBuffer += valueString.charCodeAt(0) === LINE_FEED ? ":" : ": "; + pairBuffer += valueString; + results.push(pairBuffer); + } + + const prefix = compact ? "" : separator; + return results.length ? prefix + results.join(separator) : "{}"; // Empty mapping if no valid pairs. + } + getTypeRepresentation(type: Type, value: unknown) { if (!type.represent) return value; const style = this.styleMap.get(type.tag) ?? @@ -752,7 +860,7 @@ export class DumperState { isKey: boolean; }): string | null { const result = this.detectType(value); - const tag = result.tag; + let tag = result.tag; value = result.value; if (block) { @@ -800,6 +908,37 @@ export class DumperState { return stringifyValue(value, tag); } + if ( + this.serializeMapsAndSets && + (value instanceof Map || value instanceof Set) + ) { + let map: Map; + if (value instanceof Set) { + // `!!set` only resolves when every value is null, so members must + // become null-valued keys for the output to round-trip. + tag = "tag:yaml.org,2002:set"; + map = new Map([...value].map((member) => [member, null])); + compact = false; + } else { + map = value; + } + if (block && map.size !== 0) { + let string = this.stringifyBlockMap(map, { tag, level, compact }); + if (duplicate) string = `&ref_${duplicateIndex}${string}`; + // A tagged block mapping starts on the next line; joining with a + // space would leave trailing whitespace after the tag. + if ( + tag !== null && tag !== "?" && string.charCodeAt(0) === LINE_FEED + ) { + return `!<${tag}>${string}`; + } + return stringifyValue(string, tag); + } + let string = this.stringifyFlowMap(map, { level }); + if (duplicate) string = `&ref_${duplicateIndex} ${string}`; + return stringifyValue(string, tag); + } + if (block && Object.keys(value).length !== 0) { value = this.stringifyBlockMapping(value, { tag, level, compact }); if (duplicate) value = `&ref_${duplicateIndex}${value}`; @@ -817,7 +956,7 @@ export class DumperState { stringify(value: unknown): string { if (this.useAnchors) { - this.duplicates = getDuplicateObjects(value); + this.duplicates = getDuplicateObjects(value, this.serializeMapsAndSets); this.usedDuplicates = new Set(); } diff --git a/yaml/_loader_state.ts b/yaml/_loader_state.ts index b358198cedf0..dd25911411bd 100644 --- a/yaml/_loader_state.ts +++ b/yaml/_loader_state.ts @@ -66,8 +66,53 @@ export interface LoaderStateOptions { allowDuplicateKeys?: boolean; /** function to call on warning messages. */ onWarning?(error: YamlSyntaxError): void; + /** if true, mappings are constructed as `Map`s with typed keys. */ + useMaps?: boolean; } +type MappingContainer = Record | Map; + +// Hidden protocol over the two mapping representations. Kept private so a +// future public tag seam can expose it without a rewrite. +interface MappingAdapter { + create(): MappingContainer; + has(container: MappingContainer, key: unknown): boolean; + set(container: MappingContainer, key: unknown, value: unknown): void; + entries(container: MappingContainer): Iterable<[unknown, unknown]>; +} + +const OBJECT_ADAPTER: MappingAdapter = { + create: () => ({}), + has: (container, key) => Object.hasOwn(container, key as string), + set(container, key, value) { + // `Object.defineProperty` is significantly slower than direct + // assignment in V8. Direct assignment produces an identical descriptor + // (writable/enumerable/configurable) for ordinary keys; the only + // sensitive case is `__proto__`, where direct assignment would mutate + // the prototype chain instead of creating an own property. + if (key === "__proto__") { + Object.defineProperty(container, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + } else { + (container as Record)[key as string] = value; + } + }, + entries: (container) => Object.entries(container as Record), +}; + +const MAP_ADAPTER: MappingAdapter = { + create: () => new Map(), + has: (container, key) => (container as Map).has(key), + set(container, key, value) { + (container as Map).set(key, value); + }, + entries: (container) => (container as Map).entries(), +}; + const ESCAPED_HEX_LENGTHS = new Map([ [0x78, 2], // x [0x75, 4], // u @@ -211,10 +256,17 @@ interface State { tag: string | null; anchor: string | null; kind: KindType | null; - result: unknown[] | Record | string | null; + result: + | unknown[] + | Record + | Map + | string + | null; } export class LoaderState { #scanner: Scanner; + #mapping: MappingAdapter; + useMaps: boolean; lineIndent = 0; lineStart = 0; line = 0; @@ -233,11 +285,14 @@ export class LoaderState { schema = DEFAULT_SCHEMA, onWarning, allowDuplicateKeys = false, + useMaps = false, }: LoaderStateOptions, ) { this.#scanner = new Scanner(input); this.onWarning = onWarning; this.allowDuplicateKeys = allowDuplicateKeys; + this.useMaps = useMaps; + this.#mapping = useMaps ? MAP_ADAPTER : OBJECT_ADAPTER; this.implicitTypes = schema.implicitTypes; this.typeMap = schema.typeMap; @@ -428,72 +483,65 @@ export class LoaderState { if (detected) return { tag, anchor, kind: "sequence", result }; } mergeMappings( - destination: Record, - source: Record, - overridableKeys: Set, + destination: MappingContainer, + source: unknown, + overridableKeys: Set, ) { - if (!isObject(source)) { + if (!isObject(source) || (this.useMaps && !(source instanceof Map))) { throw this.#createError( "Cannot merge mappings: the provided source object is unacceptable", ); } - for (const [key, value] of Object.entries(source)) { - if (Object.hasOwn(destination, key)) continue; - // `Object.defineProperty` is significantly slower than direct - // assignment in V8. Direct assignment produces an identical descriptor - // (writable/enumerable/configurable) for ordinary keys; the only - // sensitive case is `__proto__`, where direct assignment would mutate - // the prototype chain instead of creating an own property. - if (key === "__proto__") { - Object.defineProperty(destination, key, { - value, - writable: true, - enumerable: true, - configurable: true, - }); - } else { - destination[key] = value; - } + for (const [key, value] of this.#mapping.entries(source)) { + if (this.#mapping.has(destination, key)) continue; + this.#mapping.set(destination, key, value); overridableKeys.add(key); } } storeMappingPair( - result: Record, - overridableKeys: Set, + result: MappingContainer, + overridableKeys: Set, keyTag: string | null, - keyNode: Record | unknown[] | string | null, + keyNode: unknown, valueNode: unknown, startLine?: number, startPos?: number, - ): Record { - // The output is a plain object here, so keys can only be strings. - // We need to convert keyNode to a string, but doing so can hang the process - // (deeply nested arrays that explode exponentially using aliases). - if (Array.isArray(keyNode)) { - keyNode = Array.prototype.slice.call(keyNode); - - for (let index = 0; index < keyNode.length; index++) { - if (Array.isArray(keyNode[index])) { - throw this.#createError( - "Cannot store mapping pair: nested arrays are not supported inside keys", - ); - } + ): MappingContainer { + let key: unknown; + if (this.useMaps) { + // Map keys keep their parsed types; complex keys stay structural. + key = keyNode; + } else { + // The output is a plain object here, so keys can only be strings. + // We need to convert keyNode to a string, but doing so can hang the + // process (deeply nested arrays that explode exponentially using + // aliases). + if (Array.isArray(keyNode)) { + keyNode = Array.prototype.slice.call(keyNode) as unknown[]; + + for (let index = 0; index < (keyNode as unknown[]).length; index++) { + if (Array.isArray((keyNode as unknown[])[index])) { + throw this.#createError( + "Cannot store mapping pair: nested arrays are not supported inside keys", + ); + } - if (typeof keyNode === "object" && isPlainObject(keyNode[index])) { - keyNode[index] = "[object Object]"; + if (isPlainObject((keyNode as unknown[])[index])) { + (keyNode as unknown[])[index] = "[object Object]"; + } } } - } - // Avoid code execution in load() via toString property - // (still use its own toString for arrays, timestamps, - // and whatever user schema extensions happen to have @@toStringTag) - if (typeof keyNode === "object" && isPlainObject(keyNode)) { - keyNode = "[object Object]"; - } + // Avoid code execution in load() via toString property + // (still use its own toString for arrays, timestamps, + // and whatever user schema extensions happen to have @@toStringTag) + if (typeof keyNode === "object" && isPlainObject(keyNode)) { + keyNode = "[object Object]"; + } - keyNode = String(keyNode); + key = String(keyNode); + } if (keyTag === "tag:yaml.org,2002:merge") { if (Array.isArray(valueNode)) { @@ -505,35 +553,20 @@ export class LoaderState { this.mergeMappings(result, valueNode[index], overridableKeys); } } else { - this.mergeMappings( - result, - valueNode as Record, - overridableKeys, - ); + this.mergeMappings(result, valueNode, overridableKeys); } } else { if ( !this.allowDuplicateKeys && - !overridableKeys.has(keyNode) && - Object.hasOwn(result, keyNode) + !overridableKeys.has(key) && + this.#mapping.has(result, key) ) { this.line = startLine || this.line; this.#scanner.position = startPos || this.#scanner.position; throw this.#createError("Cannot store mapping pair: duplicated key"); } - // See `mergeMappings` above for why `Object.defineProperty` is kept - // only for the `__proto__` key. - if (keyNode === "__proto__") { - Object.defineProperty(result, keyNode, { - value: valueNode, - writable: true, - enumerable: true, - configurable: true, - }); - } else { - result[keyNode] = valueNode; - } - overridableKeys.delete(keyNode); + this.#mapping.set(result, key, valueNode); + overridableKeys.delete(key); } return result; @@ -879,13 +912,14 @@ export class LoaderState { let ch = this.#scanner.peek(); let terminator: number; let isMapping = true; - let result = {}; + let result: unknown[] | MappingContainer; if (ch === LEFT_SQUARE_BRACKET) { terminator = RIGHT_SQUARE_BRACKET; isMapping = false; result = []; } else if (ch === LEFT_CURLY_BRACKET) { terminator = RIGHT_CURLY_BRACKET; + result = this.#mapping.create(); } else { return; } @@ -903,7 +937,7 @@ export class LoaderState { let isPair = false; let following = 0; let line = 0; - const overridableKeys = new Set(); + const overridableKeys = new Set(); while (ch !== 0) { this.skipSeparationSpace(true, nodeIndent); @@ -964,16 +998,16 @@ export class LoaderState { if (isMapping) { this.storeMappingPair( - result as Record, + result as MappingContainer, overridableKeys, keyTag, keyNode, valueNode, ); } else if (isPair) { - (result as Record[]).push( + (result as unknown[]).push( this.storeMappingPair( - {}, + this.#mapping.create(), overridableKeys, keyTag, keyNode, @@ -1169,8 +1203,8 @@ export class LoaderState { nodeIndent: number, flowIndent: number, ): State | void { - const result = {}; - const overridableKeys = new Set(); + const result = this.#mapping.create(); + const overridableKeys = new Set(); let allowCompact = false; let line: number; diff --git a/yaml/_type/omap.ts b/yaml/_type/omap.ts index effeebc39c5f..38d0191c1b13 100644 --- a/yaml/_type/omap.ts +++ b/yaml/_type/omap.ts @@ -6,9 +6,19 @@ import type { Type } from "../_type.ts"; import { isPlainObject } from "../_utils.ts"; -function resolveYamlOmap(data: Record[]): boolean { - const objectKeys = new Set(); +// `Map` entries mean the loader runs with `useMaps`; the plain-object +// branches must stay byte-identical for the legacy loader. +function resolveYamlOmap(data: unknown[]): boolean { + const objectKeys = new Set(); for (const object of data) { + if (object instanceof Map) { + if (object.size !== 1) return false; + for (const key of object.keys()) { + if (objectKeys.has(key)) return false; + objectKeys.add(key); + } + continue; + } if (!isPlainObject(object)) return false; const keys = Object.keys(object); if (keys.length !== 1) return false; @@ -20,11 +30,21 @@ function resolveYamlOmap(data: Record[]): boolean { return true; } -export const omap: Type<"sequence", Record[]> = { +export const omap: Type< + "sequence", + Record[] | Map +> = { tag: "tag:yaml.org,2002:omap", kind: "sequence", resolve: resolveYamlOmap, - construct(data) { - return data; + construct(data: unknown[]) { + if (data.some((it) => it instanceof Map)) { + const result = new Map(); + for (const object of data as Map[]) { + for (const [key, value] of object) result.set(key, value); + } + return result; + } + return data as Record[]; }, }; diff --git a/yaml/_type/pairs.ts b/yaml/_type/pairs.ts index e0047e789237..cdacd39c3adb 100644 --- a/yaml/_type/pairs.ts +++ b/yaml/_type/pairs.ts @@ -6,16 +6,29 @@ import type { Type } from "../_type.ts"; import { isPlainObject } from "../_utils.ts"; -function resolveYamlPairs(data: unknown[][]): boolean { +// `Map` entries mean the loader runs with `useMaps`; the plain-object +// branches must stay byte-identical for the legacy loader. +function resolveYamlPairs(data: unknown[] | null): boolean { if (data === null) return true; - return data.every((it) => isPlainObject(it) && Object.keys(it).length === 1); + return data.every((it) => + it instanceof Map + ? it.size === 1 + : isPlainObject(it) && Object.keys(it).length === 1 + ); } export const pairs: Type<"sequence"> = { tag: "tag:yaml.org,2002:pairs", - construct(data: Record[] | null): [string, unknown][] { - // Converts an array of objects into an array of key-value pairs. - return data?.flatMap(Object.entries) ?? []; + construct( + data: (Record | Map)[] | null, + ): [unknown, unknown][] { + // Converts an array of single-pair mappings into an array of key-value + // pairs. + return data?.flatMap((it) => + it instanceof Map + ? [...it.entries()] + : Object.entries(it) as [unknown, unknown][] + ) ?? []; }, kind: "sequence", resolve: resolveYamlPairs, diff --git a/yaml/_type/set.ts b/yaml/_type/set.ts index 87d585cc3bcc..c3703a430bbf 100644 --- a/yaml/_type/set.ts +++ b/yaml/_type/set.ts @@ -5,13 +5,27 @@ import type { Type } from "../_type.ts"; -export const set: Type<"mapping", Record> = { +// A `Map` input means the loader runs with `useMaps`; the plain-object +// branches must stay byte-identical for the legacy loader. +export const set: Type< + "mapping", + Record | Set +> = { tag: "tag:yaml.org,2002:set", kind: "mapping", - construct: (data: Record): Record => - data !== null ? data : {}, - resolve: (data: Record): boolean => { + construct: ( + data: Record | Map | null, + ): Record | Set => { + if (data instanceof Map) return new Set(data.keys()); + return data !== null ? data : {}; + }, + resolve: ( + data: Record | Map | null, + ): boolean => { if (data === null) return true; - return Object.values(data).every((it) => it === null); + const values = data instanceof Map + ? [...data.values()] + : Object.values(data); + return values.every((it) => it === null); }, }; diff --git a/yaml/parse_test.ts b/yaml/parse_test.ts index c9ecd3da6a4f..1f869dd3b20d 100644 --- a/yaml/parse_test.ts +++ b/yaml/parse_test.ts @@ -1226,3 +1226,185 @@ Deno.test({ ); }, }); + +Deno.test("unstableParse() with useMaps parses mappings into Maps with typed keys", () => { + // The exact example from https://github.com/denoland/std/issues/7283 + assertEquals( + unstableParse( + `milestones: + 3: A3 + 6: D4 + 9: G3`, + { useMaps: true }, + ), + new Map([[ + "milestones", + new Map([[3, "A3"], [6, "D4"], [9, "G3"]]), + ]]), + ); + // Keys resolve through the schema like values + const map = unstableParse( + "3: number\ntrue: boolean\n2001-07-23: date\nnull: nothing", + { useMaps: true }, + ) as Map; + assertEquals([...map.keys()].slice(0, 2), [3, true]); + assertInstanceOf([...map.keys()][2], Date); + assertEquals([...map.keys()][3], null); + // Flow mappings too + assertEquals( + unstableParse("{3: A3, b: 2}", { useMaps: true }), + new Map([[3, "A3"], ["b", 2]]), + ); + // An explicit pair inside a flow sequence becomes a single-pair Map + assertEquals( + unstableParse("[? a : 1, plain]", { useMaps: true }), + [new Map([["a", 1]]), "plain"], + ); + // Entries preserve document order + assertEquals( + [...(unstableParse("b: 1\na: 2\nc: 3", { useMaps: true }) as Map< + unknown, + unknown + >).keys()], + ["b", "a", "c"], + ); +}); + +Deno.test("unstableParse() with useMaps keeps number and string keys distinct", () => { + assertEquals( + unstableParse('2: technically\n"2": valid', { useMaps: true }), + new Map([[2, "technically"], ["2", "valid"]]), + ); +}); + +Deno.test("unstableParse() with useMaps handles complex mapping keys", () => { + assertEquals( + unstableParse( + `? - Detroit Tigers + - Chicago cubs +: - 2001-07-23`, + { useMaps: true }, + ), + new Map([[["Detroit Tigers", "Chicago cubs"], [new Date("2001-07-23")]]]), + ); + // Nested arrays inside keys are legal in this mode (no + // "nested arrays are not supported inside keys" error) + assertEquals( + unstableParse("? - [ foo ]\n: bar", { useMaps: true }), + new Map([[[["foo"]], "bar"]]), + ); + // Mapping keys stay structural instead of becoming "[object Object]" + assertEquals( + unstableParse("? { foo: bar }\n: baz", { useMaps: true }), + new Map([[new Map([["foo", "bar"]]), "baz"]]), + ); +}); + +Deno.test("unstableParse() with useMaps detects duplicate keys", () => { + assertThrows( + () => unstableParse("3: a\n3: b", { useMaps: true }), + YamlSyntaxError, + "Cannot store mapping pair: duplicated key", + ); + // Last one wins, in the original position, when duplicates are allowed + assertEquals( + unstableParse("a: 1\nb: 2\na: 3", { + useMaps: true, + allowDuplicateKeys: true, + }), + new Map([["a", 3], ["b", 2]]), + ); + // Structurally equal complex keys are distinct Map keys... + assertEquals( + (unstableParse("? [1, 2]\n: a\n? [1, 2]\n: b", { + useMaps: true, + }) as Map).size, + 2, + ); + // ...but the same aliased node is a duplicate + assertThrows( + () => unstableParse("? &k [1, 2]\n: a\n? *k\n: b", { useMaps: true }), + YamlSyntaxError, + "Cannot store mapping pair: duplicated key", + ); +}); + +Deno.test("unstableParse() with useMaps handles merge keys", () => { + // Explicit keys override merged ones without a duplicate error, + // keeping the merged position + assertEquals( + unstableParse("base: &b\n a: 1\n b: 2\nmerged:\n <<: *b\n b: 3", { + useMaps: true, + }), + new Map([ + ["base", new Map([["a", 1], ["b", 2]])], + ["merged", new Map([["a", 1], ["b", 3]])], + ]), + ); + // Sequence of merge sources; earlier entries win + assertEquals( + (unstableParse("a: &a {x: 1, y: 0}\nb: &b {y: 2}\nc:\n <<: [*a, *b]", { + useMaps: true, + }) as Map>).get("c"), + new Map([["x", 1], ["y", 0]]), + ); + assertThrows( + () => unstableParse("<<: 5\nok: 1", { useMaps: true }), + YamlSyntaxError, + "Cannot merge mappings: the provided source object is unacceptable", + ); +}); + +Deno.test("unstableParse() with useMaps handles anchors, aliases and cycles", () => { + const map = unstableParse("a: &x\n k: 1\nb: *x", { + useMaps: true, + }) as Map; + assertInstanceOf(map.get("a"), Map); + assert(map.get("a") === map.get("b")); + // A mapping may alias itself; the Map's identity is captured by the anchor + const cycle = unstableParse("&root\nself: *root", { + useMaps: true, + }) as Map; + assert(cycle.get("self") === cycle); +}); + +Deno.test("unstableParse() with useMaps treats `__proto__` as an ordinary key", () => { + const map = unstableParse("__proto__:\n polluted: true", { + useMaps: true, + }) as Map; + assert(map.has("__proto__")); + assertEquals(map.get("__proto__"), new Map([["polluted", true]])); + assertEquals(({} as { polluted?: unknown }).polluted, undefined); + // Merge path too + const merged = unstableParse("<<:\n __proto__:\n polluted: true\nok: 1", { + useMaps: true, + }) as Map; + assert(merged.has("__proto__")); + assertEquals(({} as { polluted?: unknown }).polluted, undefined); +}); + +Deno.test("unstableParse() with useMaps constructs Set, Map and pairs from !!set, !!omap and !!pairs", () => { + assertEquals( + unstableParse("!!set\n? 1\n? two", { useMaps: true }), + new Set([1, "two"]), + ); + assertEquals( + unstableParse("!!omap\n- Mark: 65\n- 3: three", { useMaps: true }), + new Map([["Mark", 65], [3, "three"]]), + ); + assertThrows( + () => unstableParse("!!omap\n- 3: a\n- 3: b", { useMaps: true }), + YamlSyntaxError, + "Cannot resolve a node", + ); + // Each omap entry must be a single-pair mapping + assertThrows( + () => unstableParse("!!omap\n- a: 1\n b: 2", { useMaps: true }), + YamlSyntaxError, + "Cannot resolve a node", + ); + assertEquals( + unstableParse("!!pairs\n- Mark: 65\n- Mark: 66", { useMaps: true }), + [["Mark", 65], ["Mark", 66]], + ); +}); diff --git a/yaml/stringify_test.ts b/yaml/stringify_test.ts index 36d698162fe2..ef178694929b 100644 --- a/yaml/stringify_test.ts +++ b/yaml/stringify_test.ts @@ -9,6 +9,7 @@ import { type ImplicitType, stringify as unstableStringify, } from "./unstable_stringify.ts"; +import { parse as unstableParse } from "./unstable_parse.ts"; import { compare, parse } from "@std/semver"; Deno.test({ @@ -908,3 +909,158 @@ tags: ); }, }); + +Deno.test("unstableStringify() stringifies Maps as mappings in insertion order", () => { + assertEquals( + unstableStringify( + new Map([[3, "A3"], ["b", 2], [true, null]]), + ), + "3: A3\nb: 2\ntrue: null\n", + ); + // `sortKeys` does not apply to Map entries: insertion order is the contract + assertEquals( + unstableStringify(new Map([["b", 1], ["a", 2]]), { sortKeys: true }), + "b: 1\na: 2\n", + ); + assertEquals(unstableStringify(new Map()), "{}\n"); + // Nested in plain objects and emitted in flow style when requested + assertEquals( + unstableStringify({ m: new Map([[1, "a"]]) }), + "m:\n 1: a\n", + ); + assertEquals( + unstableStringify({ m: new Map([[1, "a"]]) }, { flowLevel: 0 }), + "{m: {1: a}}\n", + ); +}); + +Deno.test("unstableStringify() stringifies complex Map keys in explicit key form", () => { + assertEquals( + unstableStringify( + new Map([ + [["a", "b"], 1], + [new Map([["x", 1]]), "mk"], + ]), + ), + "?\n - a\n - b\n: 1\n?\n x: 1\n: mk\n", + ); +}); + +Deno.test("unstableStringify() stringifies Map keys in flow style", () => { + assertEquals( + unstableStringify( + new Map([[["a", "b"], 1], [3, "x"]]), + { flowLevel: 0 }, + ), + "{[a, b]: 1, 3: x}\n", + ); + // `condenseFlow` quotes string keys only; quoting other keys would change + // their parsed type + assertEquals( + unstableStringify( + new Map([[3, "a"], ["k", "v"]]), + { flowLevel: 0, condenseFlow: true }, + ), + '{3:a, "k":v}\n', + ); +}); + +Deno.test("unstableStringify() stringifies Sets as !!set mappings", () => { + assertEquals( + unstableStringify(new Set(["a", 2])), + "!\n? a\n: null\n? 2\n: null\n", + ); + assertEquals( + unstableStringify(new Set()), + "! {}\n", + ); +}); + +Deno.test("unstableStringify() anchors duplicate Maps and objects inside Maps", () => { + const shared = new Map([["k", 1]]); + assertEquals( + unstableStringify({ x: shared, y: shared }), + "x: &ref_0\n k: 1\n'y': *ref_0\n", + ); + // Duplicate detection walks Map keys as well as values + const object = { v: 1 }; + assertEquals( + unstableStringify( + new Map([[object, "first"], ["other", object]]), + ), + "? &ref_0\n v: 1\n: first\nother: *ref_0\n", + ); + // Anchors work in flow style too + assertEquals( + unstableStringify({ x: shared, y: shared }, { flowLevel: 0 }), + "{x: &ref_0 {k: 1}, 'y': *ref_0}\n", + ); +}); + +Deno.test("unstableStringify() applies the skipInvalid doctrine to Map entries", () => { + assertThrows( + () => unstableStringify(new Map([["fn", () => {}]])), + TypeError, + "Cannot stringify function", + ); + assertEquals( + unstableStringify(new Map([["fn", () => {}]]), { skipInvalid: true }), + "{}\n", + ); + assertEquals( + unstableStringify( + new Map([[() => {}, 1], ["ok", 2]]), + { skipInvalid: true }, + ), + "ok: 2\n", + ); + // Invalid keys and values are skipped per pair in flow style too + assertEquals( + unstableStringify( + new Map([[() => {}, 1], ["k", () => {}], [1, "ok"]]), + { skipInvalid: true, flowLevel: 0 }, + ), + "{1: ok}\n", + ); +}); + +Deno.test("unstableStringify() puts Map keys over 1024 characters in explicit key form", () => { + const longKey = "x".repeat(1025); + assertEquals( + unstableStringify(new Map([[longKey, 1]])), + `? ${longKey}\n: 1\n`, + ); + assertEquals( + unstableStringify(new Map([[longKey, 1]]), { flowLevel: 0 }), + `{? ${longKey}: 1}\n`, + ); +}); + +Deno.test("unstableStringify() round-trips Maps and Sets with unstableParse()", () => { + const source = new Map([ + [3, "A3"], + ["3", "S3"], + [["a", "b"], new Set([1, "two"])], + [new Map([[true, null]]), [1, 2, 3]], + ]); + assertEquals( + unstableParse(unstableStringify(source), { useMaps: true }), + source, + ); + // And starting from YAML text + const yaml = `milestones: + 3: A3 + 6: D4 + 9: G3 +`; + assertEquals( + unstableStringify(unstableParse(yaml, { useMaps: true })), + yaml, + ); +}); + +Deno.test("stringify() still stringifies Maps as empty mappings", () => { + // The stable stringify is unchanged in this mode; only the unstable one + // understands Maps. + assertEquals(stringify(new Map([["a", 1]])), "{}\n"); +}); diff --git a/yaml/unstable_parse.ts b/yaml/unstable_parse.ts index 856e8268d625..df45dc6baf2b 100644 --- a/yaml/unstable_parse.ts +++ b/yaml/unstable_parse.ts @@ -18,6 +18,20 @@ export type ParseOptions = StableParseOptions & { * Extra types to be added to the schema. */ extraTypes?: ImplicitType[]; + /** + * If `true`, YAML mappings are parsed into {@linkcode Map}s instead of + * plain objects, and keys keep their parsed types instead of being coerced + * to strings: `3:` yields the number `3`, `"3":` the string `"3"`. Applies + * at every depth; entries preserve document order. `!!set` becomes a + * {@linkcode Set}, `!!omap` a `Map`, and `!!pairs` an array of + * `[key, value]` pairs. Duplicate keys compare like `Map` keys + * (SameValueZero), so `3` and `"3"` are distinct keys. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @default {false} + */ + useMaps?: boolean; }; function sanitizeInput(input: string) { diff --git a/yaml/unstable_stringify.ts b/yaml/unstable_stringify.ts index 99eb6bec2ca0..6fd34b9d1b4d 100644 --- a/yaml/unstable_stringify.ts +++ b/yaml/unstable_stringify.ts @@ -31,6 +31,14 @@ export type StringifyOptions = StableStringifyOptions & { /** * Converts a JavaScript object or value to a YAML document string. * + * Unlike the stable {@linkcode https://jsr.io/@std/yaml/doc/stringify/~/stringify | stringify}, + * `Map`s are stringified as YAML mappings in insertion order (`sortKeys` does + * not apply to `Map` entries; its callback is string-keyed) and `Set`s as + * `!!set` mappings. This is the counterpart of the `useMaps` option of + * {@linkcode https://jsr.io/@std/yaml/doc/unstable-parse/~/parse | parse}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @example Usage * ```ts * import { stringify } from "@std/yaml/stringify"; @@ -54,6 +62,7 @@ export function stringify( const state = new DumperState({ ...options, schema: getSchema(options.schema, options.extraTypes), + serializeMapsAndSets: true, }); return state.stringify(data); }