From 0532e7d23fff763f07ce166bef0f3b0906f26597 Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Tue, 23 Jun 2026 00:15:54 +0300 Subject: [PATCH 1/4] Add finalizers for immutable collection tags --- CHANGELOG.md | 6 ++ docs/migrate_v4_to_v5.md | 22 +++++- examples/custom_tags_immutable.mjs | 44 ++++++++++++ src/parser/constructor.ts | 86 +++++++++++++++++++---- src/tag.ts | 62 +++++++++------- test/core/tags/custom.test.mjs | 109 ++++++++++++++++++++++++++++- 6 files changed, 287 insertions(+), 42 deletions(-) create mode 100644 examples/custom_tags_immutable.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c61f233..1f9c3ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- Collection tags can finalize an incrementally populated carrier into a + different result value. + + ## [5.0.0] - 2026-06-20 ### Added - Added named exports for schemas, tags, parser events and AST utilities. diff --git a/docs/migrate_v4_to_v5.md b/docs/migrate_v4_to_v5.md index 1fa017a2..7d6524d0 100644 --- a/docs/migrate_v4_to_v5.md +++ b/docs/migrate_v4_to_v5.md @@ -195,5 +195,23 @@ const pointTag = defineSequenceTag('!point', { }) ``` -This only sketches the shape. For the full method set (mapping `addPair`, scalar -`resolve`, styling, etc.) see [examples/custom_tags.mjs](../examples/custom_tags.mjs). +If the result cannot be populated incrementally, collect its contents in a +temporary carrier and add `finalize`: + +```js +const pointTag = defineSequenceTag('!point', { + create: () => [], + addItem: (items, value) => { items.push(value) }, + finalize: items => new ImmutablePoint(...items), + identify: value => value instanceof ImmutablePoint, + represent: point => [point.x, point.y] +}) +``` + +An anchored tag with a temporary carrier cannot recursively alias itself, +because its result does not exist until `finalize` returns. Such input throws. + +This only sketches the shape. See +[examples/custom_tags.mjs](../examples/custom_tags.mjs) for the full method set +and [examples/custom_tags_immutable.mjs](../examples/custom_tags_immutable.mjs) +for carrier finalization and its recursive-alias limitation. diff --git a/examples/custom_tags_immutable.mjs b/examples/custom_tags_immutable.mjs new file mode 100644 index 00000000..c471a41d --- /dev/null +++ b/examples/custom_tags_immutable.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { CORE_SCHEMA, defineSequenceTag, dump, load } from 'js-yaml' + +// Immutable values cannot be populated item by item. Build a mutable carrier +// first, then turn the completed carrier into the final value. +class ImmutablePoint { + constructor (coordinates) { + this.coordinates = Object.freeze([...coordinates]) + Object.freeze(this) + } +} + +const schema = CORE_SCHEMA.withTags(defineSequenceTag('!point', { + create: () => [], + addItem: (carrier, item) => { carrier.push(item) }, + finalize: carrier => { + if (carrier.length !== 2) throw new Error('!point expects exactly 2 coordinates') + return new ImmutablePoint(carrier) + }, + identify: value => value instanceof ImmutablePoint, + represent: point => point.coordinates +})) + +const source = ` +point: &point !point [10, 20] +samePoint: *point +` + +const value = load(source, { schema }) + +assert.deepStrictEqual(value.point, new ImmutablePoint([10, 20])) +assert.strictEqual(value.samePoint, value.point) +assert.equal(dump(value.point, { schema }), '!point\n- 10\n- 20\n') +assert.throws( + () => load('!point [10]', { schema }), + /!point expects exactly 2 coordinates/ +) + +// A recursive alias needs the final object before finalize() can create it, so +// recursive aliases are intentionally rejected for tags that use finalize(). +assert.throws( + () => load('&point !point [*point]', { schema }), + /recursive alias "point" is not supported for tag !point because it uses finalize\(\)/ +) diff --git a/src/parser/constructor.ts b/src/parser/constructor.ts index f9c805a7..5056becf 100644 --- a/src/parser/constructor.ts +++ b/src/parser/constructor.ts @@ -21,7 +21,7 @@ import { type ScalarTagDefinition, type SequenceTagDefinition } from '../tag.ts' -import { throwErrorAt } from '../common/exception.ts' +import { YAMLException, throwErrorAt } from '../common/exception.ts' import { tagNameFull } from '../common/tagname.ts' const NO_RANGE = -1 @@ -37,7 +37,8 @@ interface SequenceFrame { kind: 'sequence' position: number value: any - tag: SequenceTagDefinition + tag: SequenceTagDefinition + anchor: Anchor | null index: number // True when this sequence is the source list of a `<<` merge (`<<: [...]`). // Each element is validated as a mapping on arrival; the materialized list is @@ -49,7 +50,8 @@ interface MappingFrame { kind: 'mapping' position: number value: any - tag: MappingTagDefinition + tag: MappingTagDefinition + anchor: Anchor | null key: unknown keyPosition: number hasKey: boolean @@ -60,11 +62,17 @@ interface MappingFrame { type Frame = DocumentFrame | SequenceFrame | MappingFrame -type AnyTag = ScalarTagDefinition | SequenceTagDefinition | MappingTagDefinition +type AnyTag = ScalarTagDefinition | SequenceTagDefinition | MappingTagDefinition + +interface ValueAndTag { + value: unknown + tag: AnyTag +} interface Anchor { value: unknown tag: AnyTag + isValueFinal: boolean } interface ConstructorOptions { @@ -105,6 +113,25 @@ function throwError (state: ConstructorState, message: string): never { throwErrorAt(state.source, state.position, message, state.filename) } +function finalizeCollection ( + state: ConstructorState, + position: number, + tag: SequenceTagDefinition | MappingTagDefinition, + carrier: unknown +) { + try { + return tag.finalize(carrier) + } catch (error) { + if (error instanceof YAMLException) throw error + throwErrorAt( + state.source, + position, + error instanceof Error ? error.message : String(error), + state.filename + ) + } +} + function lookupTag ( exact: Record, prefix: readonly T[], @@ -136,7 +163,7 @@ function findExplicitTag explicit tag`) } - return { value: collectionTagDef.create(tagName), tag: collectionTagDef } + const carrier = collectionTagDef.create(tagName) + const value = collectionTagDef.carrierIsResult + ? carrier + : finalizeCollection(state, state.position, collectionTagDef, carrier) + return { value, tag: collectionTagDef } } throwError(state, `unknown scalar tag !<${tagName}>`) @@ -213,13 +244,13 @@ function collectionTag } // A merge source must be a mapping; every mapping tag exposes the read side. -function isMappingTag (tag: AnyTag): tag is MappingTagDefinition { +function isMappingTag (tag: AnyTag): tag is MappingTagDefinition { return tag.nodeKind === 'mapping' } // Fold the keys of one mapping source into the target frame, honoring merge // precedence: an already-present key (explicit or from an earlier source) wins. -function mergeKeys (state: ConstructorState, frame: MappingFrame, source: unknown, sourceTag: MappingTagDefinition) { +function mergeKeys (state: ConstructorState, frame: MappingFrame, source: unknown, sourceTag: MappingTagDefinition) { for (const sourceKey of sourceTag.keys(source)) { if (frame.tag.has(frame.value, sourceKey)) continue @@ -300,10 +331,24 @@ function addValue (state: ConstructorState, value: unknown, tag: AnyTag) { } } -function storeAnchor (state: ConstructorState, event: ScalarEvent | SequenceEvent | MappingEvent, value: unknown, tag: AnyTag) { +function storeAnchor ( + state: ConstructorState, + event: ScalarEvent | SequenceEvent | MappingEvent, + value: unknown, + tag: AnyTag, + isValueFinal: boolean +): Anchor | null { if (event.anchorStart !== NO_RANGE) { - state.anchors.set(state.source.slice(event.anchorStart, event.anchorEnd), { value, tag }) + const anchor = { + value, + tag, + isValueFinal + } + state.anchors.set(state.source.slice(event.anchorStart, event.anchorEnd), anchor) + return anchor } + + return null } function constructFromEvents (events: Event[], options: ConstructorOptions): unknown[] { @@ -335,7 +380,7 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk case EVENT_SCALAR: { const { value, tag } = constructScalar(state, event) - storeAnchor(state, event, value, tag) + storeAnchor(state, event, value, tag, true) addValue(state, value, tag) break } @@ -350,7 +395,7 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk 'sequence' ) const value = definition.tag.create(definition.tagName) - storeAnchor(state, event, value, definition.tag) + const anchor = storeAnchor(state, event, value, definition.tag, definition.tag.carrierIsResult) // `<<: [...]` — the parent mapping is waiting on a merge key, so this // sequence is a list of merge sources: its elements must be mappings. @@ -360,7 +405,7 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk parent.hasKey && parent.key === MERGE_KEY state.frames.push({ - kind: 'sequence', position: state.position, value, tag: definition.tag, index: 0, merge + kind: 'sequence', position: state.position, value, tag: definition.tag, anchor, index: 0, merge }) break } @@ -375,12 +420,13 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk 'mapping' ) const value = definition.tag.create(definition.tagName) - storeAnchor(state, event, value, definition.tag) + const anchor = storeAnchor(state, event, value, definition.tag, definition.tag.carrierIsResult) state.frames.push({ kind: 'mapping', position: state.position, value, tag: definition.tag, + anchor, key: undefined, keyPosition: state.position, hasKey: false, @@ -395,6 +441,9 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk if (!anchor) { throwError(state, `unidentified alias "${name}"`) } + if (!anchor.isValueFinal) { + throwError(state, `recursive alias "${name}" is not supported for tag ${anchor.tag.tagName} because it uses finalize()`) + } addValue(state, anchor.value, anchor.tag) break } @@ -405,7 +454,14 @@ function constructFromEvents (events: Event[], options: ConstructorOptions): unk if (frame.kind === 'document') { state.documents.push(frame.value) } else { - addValue(state, frame.value, frame.tag) + const value = frame.tag.carrierIsResult + ? frame.value + : finalizeCollection(state, frame.position, frame.tag, frame.value) + if (frame.anchor) { + frame.anchor.value = value + frame.anchor.isValueFinal = true + } + addValue(state, value, frame.tag) } break } diff --git a/src/tag.ts b/src/tag.ts index 544d5d0e..151636e8 100644 --- a/src/tag.ts +++ b/src/tag.ts @@ -28,34 +28,38 @@ interface ScalarTagDefinition { representTagName: RepresentTagNameFn | null } -interface SequenceTagDefinition { +interface SequenceTagDefinition { tagName: string nodeKind: 'sequence' implicit: false matchByTagPrefix: boolean - create: (tagName: string) => Container - addItem: (container: Container, item: unknown, index: number) => void | string + create: (tagName: string) => Carrier + addItem: (carrier: Carrier, item: unknown, index: number) => void | string + finalize: (carrier: Carrier) => Result + carrierIsResult: boolean identify: IdentifyFn | null represent: SequenceRepresent representTagName: RepresentTagNameFn | null } -interface MappingTagDefinition { +interface MappingTagDefinition { tagName: string nodeKind: 'mapping' implicit: false matchByTagPrefix: boolean - create: (tagName: string) => Container + create: (tagName: string) => Carrier // Writes a pair. Returns '' on success, a non-empty error message otherwise // (key does not fit the representation, value rejected, ...). Always a string // so the hot path never allocates an exception wrapper. - addPair: (container: Container, key: unknown, value: unknown) => string + addPair: (carrier: Carrier, key: unknown, value: unknown) => string // Read side, mirrors `Map` — defining a representation means defining how to // read it back. `has` is the hot dedup probe (membership without fetching the // value); `keys`/`get` are used only on the cold merge path (`<<`). - has: (container: Container, key: unknown) => boolean - keys: (container: Container) => Iterable - get: (container: Container, key: unknown) => unknown + has: (carrier: Carrier, key: unknown) => boolean + keys: (result: Result) => Iterable + get: (result: Result, key: unknown) => unknown + finalize: (carrier: Carrier) => Result + carrierIsResult: boolean identify: IdentifyFn | null represent: MappingRepresent representTagName: RepresentTagNameFn | null @@ -63,8 +67,8 @@ interface MappingTagDefinition { type TagDefinition = | ScalarTagDefinition - | SequenceTagDefinition - | MappingTagDefinition + | SequenceTagDefinition + | MappingTagDefinition interface ScalarTagOptions { implicit?: boolean @@ -94,20 +98,22 @@ type RepresentOptions = representTagName?: RepresentTagNameFn | null }) -type SequenceTagOptions = { +type SequenceTagOptions = { matchByTagPrefix?: boolean - create: SequenceTagDefinition['create'] - addItem: SequenceTagDefinition['addItem'] -} & RepresentOptions, SequenceRepresent> + create: SequenceTagDefinition['create'] + addItem: SequenceTagDefinition['addItem'] + finalize?: SequenceTagDefinition['finalize'] +} & RepresentOptions, SequenceRepresent> -type MappingTagOptions = { +type MappingTagOptions = { matchByTagPrefix?: boolean - create: MappingTagDefinition['create'] - addPair: MappingTagDefinition['addPair'] - has: MappingTagDefinition['has'] - keys: MappingTagDefinition['keys'] - get: MappingTagDefinition['get'] -} & RepresentOptions, MappingRepresent> + create: MappingTagDefinition['create'] + addPair: MappingTagDefinition['addPair'] + has: MappingTagDefinition['has'] + keys: MappingTagDefinition['keys'] + get: MappingTagDefinition['get'] + finalize?: MappingTagDefinition['finalize'] +} & RepresentOptions, MappingRepresent> function defineScalarTag (tagName: string, options: ScalarTagOptions): ScalarTagDefinition { return { @@ -123,7 +129,9 @@ function defineScalarTag (tagName: string, options: ScalarTagOptions (tagName: string, options: SequenceTagOptions): SequenceTagDefinition { +function defineSequenceTag (tagName: string, options: SequenceTagOptions): SequenceTagDefinition { + const carrierIsResult = options.finalize === undefined + return { tagName, nodeKind: 'sequence', @@ -131,13 +139,17 @@ function defineSequenceTag (tagName: string, options: SequenceTagOpti matchByTagPrefix: options.matchByTagPrefix ?? false, create: options.create, addItem: options.addItem, + finalize: options.finalize ?? (carrier => carrier as unknown as Result), + carrierIsResult, identify: options.identify ?? null, represent: options.represent ?? (data => data as ArrayLike), representTagName: options.representTagName ?? null } } -function defineMappingTag (tagName: string, options: MappingTagOptions): MappingTagDefinition { +function defineMappingTag (tagName: string, options: MappingTagOptions): MappingTagDefinition { + const carrierIsResult = options.finalize === undefined + return { tagName, nodeKind: 'mapping', @@ -148,6 +160,8 @@ function defineMappingTag (tagName: string, options: MappingTagOption has: options.has, keys: options.keys, get: options.get, + finalize: options.finalize ?? (carrier => carrier as unknown as Result), + carrierIsResult, identify: options.identify ?? null, represent: options.represent ?? (data => data as Map), representTagName: options.representTagName ?? null diff --git a/test/core/tags/custom.test.mjs b/test/core/tags/custom.test.mjs index c4169477..e6fd882f 100644 --- a/test/core/tags/custom.test.mjs +++ b/test/core/tags/custom.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import util from 'node:util' -import { dump, load, CORE_SCHEMA, defineMappingTag, defineScalarTag } from 'js-yaml' +import { dump, load, CORE_SCHEMA, YAMLException, defineMappingTag, defineScalarTag, defineSequenceTag } from 'js-yaml' function Tag1 (parameters) { this.x = parameters.x @@ -167,4 +167,111 @@ describe('tags', () => { 'test: ! bar\n' ) }) + + it('finalizes a sequence carrier into an immutable value', () => { + class ImmutableSequence { + constructor (items) { + this.items = Object.freeze([...items]) + Object.freeze(this) + } + } + + const tag = defineSequenceTag('!immutable', { + create: () => [], + addItem: (carrier, item) => { carrier.push(item) }, + finalize: carrier => new ImmutableSequence(carrier), + identify: value => value instanceof ImmutableSequence, + represent: value => value.items + }) + const immutableSchema = CORE_SCHEMA.withTags(tag) + const value = load('{ original: &a !immutable [one, two], alias: *a }', { schema: immutableSchema }) + + assert.ok(value.original instanceof ImmutableSequence) + assert.deepStrictEqual(value.original.items, ['one', 'two']) + assert.strictEqual(value.alias, value.original) + assert.deepStrictEqual(load('!immutable', { schema: immutableSchema }), new ImmutableSequence([])) + assert.equal(dump(value.original, { schema: immutableSchema }), '!immutable\n- one\n- two\n') + }) + + it('finalizes a mapping carrier before exposing it as a value', () => { + class ImmutableMapping { + constructor (entries) { + this.entries = new Map(entries) + Object.freeze(this) + } + } + + const immutableSchema = CORE_SCHEMA.withTags(defineMappingTag('!immutable', { + create: () => new Map(), + addPair: (carrier, key, value) => { carrier.set(key, value); return '' }, + has: (carrier, key) => carrier.has(key), + keys: result => result.entries.keys(), + get: (result, key) => result.entries.get(key), + finalize: carrier => new ImmutableMapping(carrier), + identify: value => value instanceof ImmutableMapping, + represent: value => value.entries + })) + const value = load('!immutable { one: 1, two: 2 }', { schema: immutableSchema }) + + assert.ok(value instanceof ImmutableMapping) + assert.deepStrictEqual([...value.entries], [['one', 1], ['two', 2]]) + assert.equal(dump(value, { schema: immutableSchema }), '!immutable\none: 1\ntwo: 2\n') + }) + + it('rejects recursive aliases when the carrier is not the result', () => { + const immutableSchema = CORE_SCHEMA.withTags(defineSequenceTag('!immutable', { + create: () => [], + addItem: (carrier, item) => { carrier.push(item) }, + finalize: carrier => Object.freeze([...carrier]), + identify: Array.isArray, + represent: value => value + })) + + assert.throws( + () => load('&a !immutable [*a]', { schema: immutableSchema }), + /recursive alias "a" is not supported for tag !immutable because it uses finalize\(\)/ + ) + assert.throws( + () => load('&a !immutable [&b [*a]]', { schema: immutableSchema }), + /recursive alias "a" is not supported for tag !immutable because it uses finalize\(\)/ + ) + }) + + it('reports finalize errors at the collection start', () => { + const immutableSchema = CORE_SCHEMA.withTags(defineSequenceTag('!immutable', { + create: () => [], + addItem: (carrier, item) => { carrier.push(item) }, + finalize: () => { throw new Error('cannot create immutable value') }, + identify: Array.isArray, + represent: value => value + })) + + assert.throws( + () => load('root:\n !immutable [one]', { schema: immutableSchema, filename: 'example.yml' }), + error => { + assert.ok(error instanceof YAMLException) + assert.equal(error.reason, 'cannot create immutable value') + assert.equal(error.mark.name, 'example.yml') + assert.equal(error.mark.line, 1) + assert.equal(error.mark.column, 2) + return true + } + ) + }) + + it('keeps recursive aliases for tags whose carrier is the result', () => { + const value = load('&a [*a]') + + assert.strictEqual(value[0], value) + }) + + it('does not call the placeholder finalizer when the carrier is the result', () => { + const tag = defineSequenceTag('!identity', { + create: () => [], + addItem: (carrier, item) => { carrier.push(item) } + }) + tag.finalize = () => { throw new Error('placeholder finalizer was called') } + + assert.deepStrictEqual(load('!identity [one]', { schema: CORE_SCHEMA.withTags(tag) }), ['one']) + }) }) From a1eaa2bce1ce5738d46a918b1f3a228b9fa0bdbd Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Tue, 23 Jun 2026 00:53:24 +0300 Subject: [PATCH 2/4] Fix quote style options and restore forceQuotes --- CHANGELOG.md | 7 ++++- README.md | 6 ++-- docs/migrate_v4_to_v5.md | 2 +- src/ast/presenter.ts | 32 ++++++++++----------- test/core/dump-fuzzy.test.mjs | 3 +- test/core/units/dump-options.test.mjs | 18 ++++++++++-- test/core/units/dump-scalar-styles.test.mjs | 5 +++- 7 files changed, 48 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9c3ab5..4120b2b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [5.1.0] - 2026-06-23 ### Added - Collection tags can finalize an incrementally populated carrier into a different result value. +### Changed +- [breaking] `quoteStyle` now selects the preferred quote style; use the + restored `forceQuotes` option to force quoting non-key strings. + ## [5.0.0] - 2026-06-20 ### Added @@ -641,6 +645,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - First public release +[5.1.0]: https://github.com/nodeca/js-yaml/compare/5.0.0...5.1.0 [5.0.0]: https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0 [4.2.0]: https://github.com/nodeca/js-yaml/compare/4.1.1...4.2.0 [4.1.1]: https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1 diff --git a/README.md b/README.md index 761eaeb2..14c93f51 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,10 @@ options: width. - `noRefs` _(default: `false`)_ - if `true`, don't convert duplicate objects into references; inline them instead. -- `quoteStyle` _(`auto`, `single`, or `double`, default: `auto`)_ - force quotes - to single/double, or select the most suitable. +- `quoteStyle` _(`single` or `double`, default: `single`)_ - quoting style to use + when a string needs quotes. +- `forceQuotes` _(default: `false`)_ - if `true`, quote all non-key strings, + using `quoteStyle`. - `flowBracketPadding` _(default: `false`)_ - add spaces inside flow collection brackets, `{a: 1}` => `{ a: 1 }`. - `flowSkipCommaSpace` _(default: `false`)_ - omit the space after commas in diff --git a/docs/migrate_v4_to_v5.md b/docs/migrate_v4_to_v5.md index 7d6524d0..8c39c256 100644 --- a/docs/migrate_v4_to_v5.md +++ b/docs/migrate_v4_to_v5.md @@ -131,7 +131,7 @@ options. | `replacer` | removed — patch the data before `dump()` | | `noCompatMode` | select the schema whose scalar rules apply | | `condenseFlow` | `flowSkipCommaSpace`, `flowSkipColonSpace`, `quoteFlowKeys` | -| `quotingType`, `forceQuotes` | `quoteStyle: 'single'` or `quoteStyle: 'double'` | +| `quotingType` | `quoteStyle: 'single'` or `quoteStyle: 'double'` | | `noArrayIndent` | `seqNoIndent` | ### Replacing `styles` diff --git a/src/ast/presenter.ts b/src/ast/presenter.ts index 8210c427..cafea13d 100644 --- a/src/ast/presenter.ts +++ b/src/ast/presenter.ts @@ -68,7 +68,8 @@ interface PresenterOptions { flowSkipCommaSpace?: boolean flowSkipColonSpace?: boolean quoteFlowKeys?: boolean - quoteStyle?: 'auto' | 'single' | 'double' + quoteStyle?: 'single' | 'double' + forceQuotes?: boolean tagBeforeAnchor?: boolean } @@ -82,7 +83,8 @@ const DEFAULT_PRESENTER_OPTIONS: Required> = { flowSkipCommaSpace: false, flowSkipColonSpace: false, quoteFlowKeys: false, - quoteStyle: 'auto', + quoteStyle: 'single', + forceQuotes: false, tagBeforeAnchor: false } @@ -360,10 +362,8 @@ type ScalarStyleId = // STYLE_LITERAL => no lines are suitable for folding (or lineWidth is -1). // STYLE_FOLDED => a line > lineWidth and can be folded (and lineWidth != -1). function chooseScalarStyle (state: PresenterState, string: string, layout: ReturnType, - singleLineOnly: boolean, inblock: boolean): ScalarStyleId { + singleLineOnly: boolean, forceQuote: boolean, inblock: boolean): ScalarStyleId { const { blockIndent, lineWidth } = layout - // quoteStyle !== 'auto' forces quoting: suppress plain and block styles. - const forceQuote = state.quoteStyle !== 'auto' let i let char = 0 let prevChar = -1 // -1 = no previous character yet (see isPlainSafe) @@ -477,23 +477,23 @@ function resolveScalarStyle (state: PresenterState, node: ScalarNode, const string = node.value if (string.length === 0) { - // An empty scalar carries no text to resolve, so a bare plain one reads - // back as the implicit tag. That round-trips only if an explicit tag is - // printed (disambiguating it) or the implicit tag already matches; else - // quote it. Without a property a plain empty would vanish entirely. - if (state.quoteStyle === 'auto' && - (node.style.tagged || resolveImplicitTag(state, string) === node.tag)) return STYLE_PLAIN + // An empty scalar is safe when its tag is explicit or resolves back to the + // node tag (notably, the default null representation). A real empty string + // does neither and therefore remains quoted. + if (node.style.tagged || resolveImplicitTag(state, string) === node.tag) return STYLE_PLAIN return state.quoteStyle === 'double' ? STYLE_DOUBLE : STYLE_SINGLE } - const style = chooseScalarStyle(state, string, layout, singleLineOnly, inblock) + // v4's forceQuotes deliberately excluded keys. Keys are still quoted when + // syntax or tag resolution requires it, using quoteStyle as the preference. + const style = chooseScalarStyle( + state, string, layout, singleLineOnly, state.forceQuotes && !iskey, inblock) // Plain writes no tag, so it round-trips only if the bare text resolves back // to the node's tag (or the tag gets printed explicitly). Else downgrade. - // `chooseScalarStyle` can return plain only in auto mode, so downgrade to - // the default quote style here. + // Downgrade to the preferred quote style here. if (style === STYLE_PLAIN && !node.style.tagged && resolveImplicitTag(state, string) !== node.tag) { - return STYLE_SINGLE + return state.quoteStyle === 'double' ? STYLE_DOUBLE : STYLE_SINGLE } return style } @@ -695,7 +695,7 @@ function writeFlowMapping (state: PresenterState, level: number, node: MappingNo let pairBuffer = '' if (result !== '') pairBuffer += `,${!state.flowSkipCommaSpace ? ' ' : ''}` - const keyText = writeNode(state, level, key, {}) + const keyText = writeNode(state, level, key, { iskey: true }) const explicitPair = keyText.length > 1024 if (explicitPair) { diff --git a/test/core/dump-fuzzy.test.mjs b/test/core/dump-fuzzy.test.mjs index 252c14ee..61ba92fa 100644 --- a/test/core/dump-fuzzy.test.mjs +++ b/test/core/dump-fuzzy.test.mjs @@ -24,7 +24,8 @@ const dumpOptionsArbitrary = fc.record({ indent: fc.integer({ min: 1, max: 80 }), seqNoIndent: fc.boolean(), seqInlineFirst: fc.boolean(), - quoteStyle: fc.constantFrom('auto', 'single', 'double'), + quoteStyle: fc.constantFrom('single', 'double'), + forceQuotes: fc.boolean(), flowBracketPadding: fc.boolean(), flowSkipCommaSpace: fc.boolean(), flowSkipColonSpace: fc.boolean(), diff --git a/test/core/units/dump-options.test.mjs b/test/core/units/dump-options.test.mjs index 11dc7169..16d54ac6 100644 --- a/test/core/units/dump-options.test.mjs +++ b/test/core/units/dump-options.test.mjs @@ -72,10 +72,22 @@ describe('dump options', () => { assert(!dump(value).includes('\n ')) }) - it('quoteStyle — forces a scalar quoting style', () => { + it('quoteStyle — selects the style when quotes are needed', () => { assert.equal(dump('hello'), 'hello\n') - assert.equal(dump('hello', { quoteStyle: 'single' }), "'hello'\n") - assert.equal(dump('hello', { quoteStyle: 'double' }), '"hello"\n') + assert.equal(dump('null', { quoteStyle: 'single' }), "'null'\n") + assert.equal(dump('null', { quoteStyle: 'double' }), '"null"\n') + }) + + it('forceQuotes — quotes non-key strings using quoteStyle', () => { + assert.equal(dump({ hello: 'world' }, { forceQuotes: true }), "hello: 'world'\n") + assert.equal( + dump({ hello: 'world' }, { forceQuotes: true, quoteStyle: 'double' }), + 'hello: "world"\n' + ) + assert.equal( + dump({ hello: 'world' }, { flowLevel: 0, forceQuotes: true }), + "{hello: 'world'}\n" + ) }) it('flowLevel — switches to flow style from the given depth', () => { diff --git a/test/core/units/dump-scalar-styles.test.mjs b/test/core/units/dump-scalar-styles.test.mjs index 98469da9..34de757e 100644 --- a/test/core/units/dump-scalar-styles.test.mjs +++ b/test/core/units/dump-scalar-styles.test.mjs @@ -103,7 +103,10 @@ describe('Scalar style dump:', () => { it('emits astral (surrogate-pair) characters as a single code point', () => { // Printable, so kept verbatim — but the pair must advance as one code // point both when choosing the style and when escaping. - assert.strictEqual(dump('\u{1F600}', { quoteStyle: 'double' }), '"\u{1F600}"\n') + assert.strictEqual( + dump('\u{1F600}', { quoteStyle: 'double', forceQuotes: true }), + '"\u{1F600}"\n' + ) }) it('quotes an empty scalar with double quotes under quoteStyle: double', () => { From 53b22be4fe05ea668b2420b142b424d360f6e2cf Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Tue, 23 Jun 2026 00:57:25 +0300 Subject: [PATCH 3/4] Fix constructor coverage --- test/core/tags/custom.test.mjs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/core/tags/custom.test.mjs b/test/core/tags/custom.test.mjs index e6fd882f..2a759b6d 100644 --- a/test/core/tags/custom.test.mjs +++ b/test/core/tags/custom.test.mjs @@ -259,6 +259,34 @@ describe('tags', () => { ) }) + it('preserves YAMLException thrown by finalize', () => { + const expected = new YAMLException('cannot create immutable value') + const immutableSchema = CORE_SCHEMA.withTags(defineSequenceTag('!immutable', { + create: () => [], + addItem: () => {}, + finalize: () => { throw expected }, + identify: Array.isArray, + represent: value => value + })) + + assert.throws(() => load('!immutable []', { schema: immutableSchema }), error => error === expected) + }) + + it('reports non-Error values thrown by finalize', () => { + const immutableSchema = CORE_SCHEMA.withTags(defineSequenceTag('!immutable', { + create: () => [], + addItem: () => {}, + finalize: () => { throw 'cannot create immutable value' }, // eslint-disable-line no-throw-literal + identify: Array.isArray, + represent: value => value + })) + + assert.throws( + () => load('!immutable []', { schema: immutableSchema }), + error => error instanceof YAMLException && error.reason === 'cannot create immutable value' + ) + }) + it('keeps recursive aliases for tags whose carrier is the result', () => { const value = load('&a [*a]') From f1e45cd201de162cc388a5175717eddf0743d367 Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Tue, 23 Jun 2026 00:59:11 +0300 Subject: [PATCH 4/4] 5.1.0 released --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c2c56bb..c44df6c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "js-yaml", - "version": "5.0.0", + "version": "5.1.0", "description": "YAML 1.2 parser and serializer", "keywords": [ "yaml",