diff --git a/src/parse/cst-scalar.ts b/src/parse/cst-scalar.ts index ad1ebf0..80312aa 100644 --- a/src/parse/cst-scalar.ts +++ b/src/parse/cst-scalar.ts @@ -180,6 +180,16 @@ export function setScalarValue( default: 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( { 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) { const he = source.indexOf('\n') const head = source.substring(0, he) diff --git a/tests/cst.ts b/tests/cst.ts index f2ccced..6162af2 100644 --- a/tests/cst.ts +++ b/tests/cst.ts @@ -201,3 +201,75 @@ test('Line comment before unindented block-seq in block-map (#525)', () => { const [doc] = Array.from(new Parser().parse(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') + }) +})