fix(cst): keep a block scalar's content indent in setScalarValue

setScalarValue reconstructed a block scalar's content indent as token.indent
plus one step, but token.indent is the indent of the node, not of its
contents. Contents sitting further in than one step were pulled back, and
without afterKey they lost their indentation entirely, which for a block
scalar in a collection changes what the document means rather than just how
it looks.

resolveBlockScalar already measures the real content indent when reading: an
explicit indentation indicator counts from the node indent, and otherwise the
indent is that of the first non-empty line. Measure the same way before
stringifying, and fall back to the previous behaviour when a block scalar has
no contents to measure.

Fixes #349
This commit is contained in:
dualfroz committed 2026-09-03 14:16:12 +02:00
1 parent 6744b4a7a9
commit 0717920c8d
2 files changed
+106

No files matched your search

+34
View File
@@ -180,6 +180,16 @@ export function setScalarValue(
default: default:
type = 'PLAIN' type = 'PLAIN'
} }
if (
token.type === 'block-scalar' &&
(type === 'BLOCK_FOLDED' || type === 'BLOCK_LITERAL')
) {
// A block scalar's contents are not necessarily indented by exactly one
// step past its own indent; the extra indentation is not semantic, but it
// is the author's, so re-use it rather than silently re-indenting.
const contentIndent = blockScalarContentIndent(token)
if (contentIndent !== null) indent = contentIndent
}
const source = stringifyString( const source = stringifyString(
{ type, value } as Scalar, { type, value } as Scalar,
{ {
@@ -205,6 +215,30 @@ export function setScalarValue(
} }
} }
/**
* Determine the indent of a block scalar's current contents, matching how
* `resolveBlockScalar()` reads it back: an explicit indentation indicator in
* the header counts from the token's own indent, and otherwise the contents
* are indented by however much the first non-empty line is.
*
* @returns `null` if the token has no contents to infer an indent from.
*/
function blockScalarContentIndent(token: BlockScalar): number | null {
const header = token.props[0]
if (header?.type === 'block-scalar-header') {
const match = /\d/.exec(header.source)
const explicit = match ? Number(match[0]) : 0
if (explicit > 0) return token.indent + explicit
}
for (const line of token.source.split('\n')) {
let indent = 0
while (line[indent] === ' ') indent += 1
const content = line.substring(indent)
if (content !== '' && content !== '\r') return indent
}
return null
}
function setBlockScalarValue(token: Token, source: string) { function setBlockScalarValue(token: Token, source: string) {
const he = source.indexOf('\n') const he = source.indexOf('\n')
const head = source.substring(0, he) const head = source.substring(0, he)
+72
View File
@@ -201,3 +201,75 @@ test('Line comment before unindented block-seq in block-map (#525)', () => {
const [doc] = Array.from(new Parser().parse(src)) const [doc] = Array.from(new Parser().parse(src))
expect(CST.stringify(doc)).toBe(src) expect(CST.stringify(doc)).toBe(src)
}) })
describe('CST.setScalarValue block scalar indent (#349)', () => {
function setBlockScalar(src: string, value: string, afterKey = true) {
const doc = cstDoc(src)
let found: CST.BlockScalar | null = null
CST.visit(doc, item => {
if (item.value?.type === 'block-scalar') {
found = item.value
CST.setScalarValue(item.value, value, {
afterKey: afterKey && !!item.key
})
}
})
expect(found).not.toBe(null)
return {
src: CST.stringify(doc),
token: found as unknown as CST.BlockScalar
}
}
test('keeps content indented by more than one step', () => {
const src = 'key:\n |\n line one\n line two\n'
expect(setBlockScalar(src, 'new one\nnew two\n').src).toBe(
'key:\n |\n new one\n new two\n'
)
})
test('keeps content indent when not marked as being after a key', () => {
const src = 'key: |\n line one\n'
expect(setBlockScalar(src, 'new one\n', false).src).toBe(
'key: |\n new one\n'
)
})
test('keeps the indent of a nested block scalar', () => {
const src = 'a:\n b: |\n deep\n'
expect(setBlockScalar(src, 'new\n').src).toBe('a:\n b: |\n new\n')
})
test('keeps the indent given by an explicit indentation indicator', () => {
const src = 'key: |2\n line one\n'
expect(setBlockScalar(src, 'new one\n').src).toBe('key: |\n new one\n')
})
test('keeps the indent when the first content line is empty', () => {
const src = 'key: |\n\n line one\n'
expect(setBlockScalar(src, 'new one\n').src).toBe('key: |\n new one\n')
})
test('leaves an indent of one step alone', () => {
const src = 'key: |\n line one\n'
expect(setBlockScalar(src, 'new one\n').src).toBe('key: |\n new one\n')
})
test('resolves back to the value that was set', () => {
const src = 'key:\n |\n line one\n line two\n'
const { token } = setBlockScalar(src, 'new one\nnew two\n')
expect(CST.resolveAsScalar(token).value).toBe('new one\nnew two\n')
})
test('still indents a block scalar replacing a flow scalar', () => {
const doc = cstDoc('key: "old"\n')
CST.visit(doc, item => {
if (item.value && CST.isScalar(item.value))
CST.setScalarValue(item.value, 'new one\nnew two\n', {
afterKey: !!item.key,
type: 'BLOCK_LITERAL'
})
})
expect(CST.stringify(doc)).toBe('key: |\n new one\n new two\n')
})
})