From 00209b6b9ed376b213e3d950e9cc0bc6f019e875 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sat, 1 Aug 2026 06:53:29 +0200 Subject: [PATCH 1/4] presenter: treat a tab-indented line in a folded scalar as more-indented (#780) YAML 1.2.2 8.1.3 defines a more-indented ("spaced text") line by production [175] s-nb-spaced-text, whose leading [33] s-white is a space *or a tab*; the breaks around such a line are preserved verbatim ([177] b-l-spaced) rather than folded. getBlockValue() in the parser already tests for both (0x20 or 0x09), but the presenter tested only for a space, at four sites in foldBlockScalar() and foldLine(). It therefore doubled breaks the parser keeps literally, adding one \n per adjacency: parseEvents/eventsToAst/present of k: > detected emitted a blank line before "detected", so the value went from "\t\ndetected\n" to "\t\n\ndetected\n". A tab line between two folded lines cost two extra \n. Extract the predicate as isMoreIndented() and use it for the prevMoreIndented seed, the per-line test and the foldLine guard; the fold-point regexp becomes / [^ \t]/ so a break is never placed before a tab either (breaking at the space in "aaa... \tzzz" moved the tab to the start of the next line). Enumerating every two-line block-scalar body over a 7-line alphabet in both block styles gave 84 parseable sources: 12 changed value through present(), all folded style, all involving a tab; literal style was clean (0/42). All 12 were confirmed against PyYAML 6.0.3 and ruamel.yaml 0.19.1 reading the emitted bytes, and all 12 round-trip after this change. The public dump() API never enters this path (it quotes tab-containing strings), so it was unaffected before and after. --- src/ast/presenter.ts | 19 +++++++++++++------ test/core/ast/presenter.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/ast/presenter.ts b/src/ast/presenter.ts index 6a424b87..819ee91f 100644 --- a/src/ast/presenter.ts +++ b/src/ast/presenter.ts @@ -523,7 +523,7 @@ function blockHeader (string: string, indentPerLevel: number) { // a blank line (two breaks). Encode each run of p literal `\n` as p+1 breaks and // indent the following content line so the continuation isn't read as a new node // (a bare break would yield invalid "deficient indentation" output). -// `foldBlockScalar` can't be reused here: it treats a leading space as a +// `foldBlockScalar` can't be reused here: it treats a leading white space as a // "more-indented" line and suppresses the doubling, which a flow scalar must not. function encodeFlowBreaks (string: string, indent: number) { let nextLF = string.indexOf('\n') @@ -552,6 +552,13 @@ function dropEndingNewline (string: string) { return string[string.length - 1] === '\n' ? string.slice(0, -1) : string } +// A more-indented line is one starting with white space: YAML 1.2.2 [175] +// s-nb-spaced-text, whose [33] s-white is a space *or a tab*. Matches the +// parser's test in getBlockValue(). +function isMoreIndented (char: string | undefined) { + return char === ' ' || char === '\t' +} + // Note: a long line without a suitable break point will exceed the width limit. // Pre-conditions: every char in str isPrintable, str.length > 0, width > 0. function foldBlockScalar (string: string, width: number) { @@ -567,7 +574,7 @@ function foldBlockScalar (string: string, width: number) { lineRe.lastIndex = nextLF let result = foldLine(string.slice(0, nextLF), width) // If we haven't reached the first content line yet, don't add an extra \n. - let prevMoreIndented = string[0] === '\n' || string[0] === ' ' + let prevMoreIndented = string[0] === '\n' || isMoreIndented(string[0]) let moreIndented // rest of the lines @@ -576,7 +583,7 @@ function foldBlockScalar (string: string, width: number) { const prefix = match[1] const line = match[2] - moreIndented = (line[0] === ' ') + moreIndented = isMoreIndented(line[0]) result += prefix + ((!prevMoreIndented && !moreIndented && line !== '') ? '\n' : '') + foldLine(line, width) @@ -591,10 +598,10 @@ function foldBlockScalar (string: string, width: number) { // otherwise settles for the shortest line over the limit. // NB. More-indented lines *cannot* be folded, as that would add an extra \n. function foldLine (line: string, width: number) { - if (line === '' || line[0] === ' ') return line + if (line === '' || isMoreIndented(line[0])) return line - // Since a more-indented line adds a \n, breaks can't be followed by a space. - const breakRe = / [^ ]/g // note: the match index will always be <= length-2. + // Since a more-indented line adds a \n, breaks can't be followed by white space. + const breakRe = / [^ \t]/g // note: the match index will always be <= length-2. let match // start is an inclusive index. end, curr, and next are exclusive. let start = 0 diff --git a/test/core/ast/presenter.test.mjs b/test/core/ast/presenter.test.mjs index e151f99d..a157d959 100644 --- a/test/core/ast/presenter.test.mjs +++ b/test/core/ast/presenter.test.mjs @@ -128,6 +128,32 @@ describe('ast presenter', () => { assert.equal(present(documents, { schema: CORE_SCHEMA }), '[{a: [1, 2], b: "x\\ny"}]\n') }) + it('keeps a tab-indented line in a folded scalar more-indented', () => { + const longTab = `\t${'word '.repeat(20).trim()}` + const spacedTab = `${'a'.repeat(90)} \tzzz` + + // [source, value] — every source is already the canonical rendering of its + // value, so re-presenting it must reproduce it byte for byte. + const cases = [ + // tab-indented line first, last, and between two folded lines + ['k: >\n \t\n detected\n', '\t\ndetected\n'], + ['k: >\n detected\n \tdeep\n', 'detected\n\tdeep\n'], + ['k: >\n a\n \tdeep\n b\n', 'a\n\tdeep\nb\n'], + // over the line width, but a more-indented line is never folded + [`k: >\n ${longTab}\n tail\n`, `${longTab}\ntail\n`], + // folding here would start the next line with a tab, changing the value + [`k: >\n ${spacedTab}\n`, `${spacedTab}\n`], + // controls: literal style and space indentation were always correct + ['k: |\n a\n \tdeep\n b\n', 'a\n\tdeep\nb\n'], + ['k: >\n a\n deep\n b\n', 'a\n deep\nb\n'] + ] + + for (const [source, value] of cases) { + assert.deepEqual(load(source, { schema: CORE_SCHEMA }), { k: value }) + assert.equal(presentParsed(source), source) + } + }) + it('propagates seqNoIndent to nested sequences', () => { const documents = jsToAst([{ items: [{ a: 1 }] }], CORE_SCHEMA) From c3bd7caa5054382d9d9f30055bb02cdac5c9f107 Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Sat, 1 Aug 2026 09:09:13 +0300 Subject: [PATCH 2/4] Polish previous commit, #780 --- src/ast/presenter.ts | 8 ++-- test/core/ast/presenter.test.mjs | 70 ++++++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/ast/presenter.ts b/src/ast/presenter.ts index 819ee91f..e73d7f9c 100644 --- a/src/ast/presenter.ts +++ b/src/ast/presenter.ts @@ -406,7 +406,7 @@ function chooseScalarStyle (state: PresenterState, string: string, layout: Retur hasFoldableLine = hasFoldableLine || // Foldable line = too long, and not more-indented. (i - previousLineBreak - 1 > lineWidth && - string[previousLineBreak + 1] !== ' ') + !isMoreIndented(string[previousLineBreak + 1])) previousLineBreak = i } } else if (!isPrintable(char)) { @@ -418,7 +418,7 @@ function chooseScalarStyle (state: PresenterState, string: string, layout: Retur // in case the end is missing a \n hasFoldableLine = hasFoldableLine || (shouldTrackWidth && (i - previousLineBreak - 1 > lineWidth && - string[previousLineBreak + 1] !== ' ')) + !isMoreIndented(string[previousLineBreak + 1]))) } // Although every style can represent \n without escaping, prefer block styles // for multiline, since they're more readable and they don't add empty lines. @@ -555,7 +555,7 @@ function dropEndingNewline (string: string) { // A more-indented line is one starting with white space: YAML 1.2.2 [175] // s-nb-spaced-text, whose [33] s-white is a space *or a tab*. Matches the // parser's test in getBlockValue(). -function isMoreIndented (char: string | undefined) { +function isMoreIndented (char: string) { return char === ' ' || char === '\t' } @@ -583,7 +583,7 @@ function foldBlockScalar (string: string, width: number) { const prefix = match[1] const line = match[2] - moreIndented = isMoreIndented(line[0]) + moreIndented = line !== '' && isMoreIndented(line[0]) result += prefix + ((!prevMoreIndented && !moreIndented && line !== '') ? '\n' : '') + foldLine(line, width) diff --git a/test/core/ast/presenter.test.mjs b/test/core/ast/presenter.test.mjs index a157d959..9bfd284b 100644 --- a/test/core/ast/presenter.test.mjs +++ b/test/core/ast/presenter.test.mjs @@ -128,21 +128,15 @@ describe('ast presenter', () => { assert.equal(present(documents, { schema: CORE_SCHEMA }), '[{a: [1, 2], b: "x\\ny"}]\n') }) - it('keeps a tab-indented line in a folded scalar more-indented', () => { - const longTab = `\t${'word '.repeat(20).trim()}` - const spacedTab = `${'a'.repeat(90)} \tzzz` + // [source, value] — every source is already the canonical rendering of its + // value, so re-presenting it must reproduce it byte for byte. - // [source, value] — every source is already the canonical rendering of its - // value, so re-presenting it must reproduce it byte for byte. + it('keeps a tab-indented line in a folded scalar more-indented', () => { const cases = [ // tab-indented line first, last, and between two folded lines ['k: >\n \t\n detected\n', '\t\ndetected\n'], ['k: >\n detected\n \tdeep\n', 'detected\n\tdeep\n'], ['k: >\n a\n \tdeep\n b\n', 'a\n\tdeep\nb\n'], - // over the line width, but a more-indented line is never folded - [`k: >\n ${longTab}\n tail\n`, `${longTab}\ntail\n`], - // folding here would start the next line with a tab, changing the value - [`k: >\n ${spacedTab}\n`, `${spacedTab}\n`], // controls: literal style and space indentation were always correct ['k: |\n a\n \tdeep\n b\n', 'a\n\tdeep\nb\n'], ['k: >\n a\n deep\n b\n', 'a\n deep\nb\n'] @@ -154,6 +148,64 @@ describe('ast presenter', () => { } }) + it('never folds a tab-indented line over the width limit', () => { + const longTab = `\t${'word '.repeat(20).trim()}` + const source = `k: >\n ${longTab}\n tail\n` + + assert.deepEqual(load(source, { schema: CORE_SCHEMA }), { k: `${longTab}\ntail\n` }) + assert.equal(presentParsed(source), source) + }) + + it('does not fold a folded scalar at a space before a tab', () => { + // Folding here would start the next line with a tab, changing the value. + const spacedTab = `${'a'.repeat(90)} \tzzz` + const source = `k: >\n ${spacedTab}\n` + + assert.deepEqual(load(source, { schema: CORE_SCHEMA }), { k: `${spacedTab}\n` }) + assert.equal(presentParsed(source), source) + }) + + // The presenter re-derives the block header and the fold points from the value, + // so a slip there silently changes the value instead of the formatting. + // Bytes aren't asserted — a source may legitimately use a form the presenter + // wouldn't pick (`>2` vs `>`); values have no such exceptions. + // Alphabet = one char per branch of getBlockValue(); the stretched copy is what + // pushes lines over the width limit, the only place folding happens. + it('preserves block scalar values through an AST round-trip', () => { + let bodies = [''] + let sweep = [] + + for (let length = 0; length < 4; length++) { + bodies = bodies.flatMap(body => ['a', ' ', '\t', '\n'].map(char => body + char)) + sweep = sweep.concat(bodies) + } + + sweep = sweep.concat(sweep.map(body => body.replaceAll('a', 'a'.repeat(90)))) + + let checked = 0 + + for (const body of sweep) { + for (const header of ['|', '|-', '|+', '>', '>-', '>+']) { + const indented = body.split('\n').map(line => line === '' ? '' : ` ${line}`).join('\n') + const source = `k: ${header}\n${indented}\n` + let value + + // Not every generated body makes a valid scalar. + try { + value = load(source, { schema: CORE_SCHEMA }) + } catch { + continue + } + + checked++ + assert.deepEqual(load(presentParsed(source), { schema: CORE_SCHEMA }), value, JSON.stringify(source)) + } + } + + // Guards against the sweep quietly emptying out and passing on nothing. + assert.ok(checked > 1000, `only ${checked} sources parsed`) + }) + it('propagates seqNoIndent to nested sequences', () => { const documents = jsToAst([{ items: [{ a: 1 }] }], CORE_SCHEMA) From 94e766d0ab99118be1e90b3212b9f7744083ffd4 Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Sat, 1 Aug 2026 09:17:08 +0300 Subject: [PATCH 3/4] Update changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22847c76..119300e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. 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). +## [5.2.3] - 2026-08-01 + +### Fixed +- Prevent prototype fallback when resolving tags and mapping entries, #782. +- Resolve `!!timestamp` years 0000-0099 correctly, #775. +- Preserve implicit null mapping values before document markers and reject + unpaired mapping event streams, #784. +- Preserve folded scalar values with tab-indented lines when round-tripping a + parsed AST through `present()`; `dump()` and loading are unaffected, #780. + + ## [5.2.2] - 2026-07-24 ### Fixed @@ -682,6 +693,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - First public release +[5.2.3]: https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3 [5.2.2]: https://github.com/nodeca/js-yaml/compare/5.2.1...5.2.2 [5.2.1]: https://github.com/nodeca/js-yaml/compare/5.2.0...5.2.1 [5.2.0]: https://github.com/nodeca/js-yaml/compare/5.1.0...5.2.0 From 6740445e7bf0ec701f14226e1dfa7ef50f7068cc Mon Sep 17 00:00:00 2001 From: Vitaly Puzrin Date: Sat, 1 Aug 2026 09:18:48 +0300 Subject: [PATCH 4/4] 5.2.3 released --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2808f296..27493efa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "js-yaml", - "version": "5.2.2", + "version": "5.2.3", "description": "YAML 1.2 parser and serializer", "keywords": [ "yaml",