diff --git a/src/compose/resolve-block-map.ts b/src/compose/resolve-block-map.ts index 759f0bd3..53b44137 100644 --- a/src/compose/resolve-block-map.ts +++ b/src/compose/resolve-block-map.ts @@ -102,7 +102,27 @@ export function resolveBlockMap( : composeEmptyNode(ctx, offset, sep, null, valueProps, onError) offset = valueNode.range[2] const pair = new Pair(keyNode, valueNode) - if (ctx.options.keepSourceTokens) pair.srcToken = collItem + if (ctx.options.keepSourceTokens) { + pair.srcToken = collItem + } + + // Check to see if the key tokens exist and have an indentation set. + // If so, we can compute the difference between them and preserve it + // if the preserveCollectionIndentation option is set. + const keyIndent: number | undefined = + key && 'indent' in key ? key.indent : undefined + const valueIndent: number | undefined = + value && 'indent' in value ? value.indent : undefined + + if ( + ctx.options.preserveCollectionIndentation && + keyIndent !== undefined && + valueIndent !== undefined && + valueIndent >= keyIndent + ) { + pair.srcIndentStep = valueIndent - keyIndent + } + map.items.push(pair) } else { // key with no value diff --git a/src/nodes/Pair.ts b/src/nodes/Pair.ts index b50c4636..b4c99e1b 100644 --- a/src/nodes/Pair.ts +++ b/src/nodes/Pair.ts @@ -29,6 +29,12 @@ export class Pair { /** The CST token that was composed into this pair. */ declare srcToken?: CollectionItem + /** + * The indentation step between key and value in the source, to be + * preserved during stringification. + */ + declare srcIndentStep?: number + constructor(key: K, value: V | null = null) { Object.defineProperty(this, NODE_TYPE, { value: PAIR }) this.key = key diff --git a/src/options.ts b/src/options.ts index 663d5305..3e4423b0 100644 --- a/src/options.ts +++ b/src/options.ts @@ -27,6 +27,14 @@ export type ParseOptions = { */ keepSourceTokens?: boolean + /** + * When parsing a document, stores indentation levels on each collection node, + * allowing them to be preserved when later stringified. + * + * Default: `false` + */ + preserveCollectionIndentation?: boolean + /** * If set, newlines will be tracked, to allow for `lineCounter.linePos(offset)` * to provide the `{ line, col }` positions within the input. diff --git a/src/stringify/stringifyPair.ts b/src/stringify/stringifyPair.ts index 70955fe2..b825764e 100644 --- a/src/stringify/stringifyPair.ts +++ b/src/stringify/stringifyPair.ts @@ -5,18 +5,39 @@ import { stringify, StringifyContext } from './stringify.js' import { addComment, stringifyComment } from './stringifyComment.js' export function stringifyPair( - { key, value }: Readonly, + { key, value, srcIndentStep }: Readonly, ctx: StringifyContext, onComment?: () => void, onChompKeep?: () => void ) { - const { + let { allNullValues, doc, indent, indentStep, options: { indentSeq, simpleKeys } } = ctx + if (srcIndentStep !== undefined) { + // If the pair originally had some indentation step, preserve that + // value. + if (srcIndentStep > 0 || (srcIndentStep === 0 && isSeq(value))) { + // Indentation can only be preserved if it's positive, or if it's 0 + // and the item to render is a seq, since: + + // foo: + // - a + // - b + // - c + + // is a valid seq with 0 indentStep. + + // Note that ctx.indentStep is *not* modified, so later indentations + // will still use the original indentStep if not being preserved from + // input. + indentStep = ' '.repeat(srcIndentStep) + } + } + let keyComment = (isNode(key) && key.comment) || null if (simpleKeys) { if (keyComment) { diff --git a/tests/preserve-indentation.ts b/tests/preserve-indentation.ts new file mode 100644 index 00000000..a4bf4353 --- /dev/null +++ b/tests/preserve-indentation.ts @@ -0,0 +1,203 @@ +import { parseDocument } from 'yaml' + +describe('preserveCollectionIndentation', () => { + // This sample document has very unusual indentation, which helps + // to ensure that it really does end up being preserved exactly. + const sample = ` +a: + b: + c: d + more: e + +big_indent: + - a + - b + - c +list_map1: + - foo: 1 + bar: 2 + qux: 3 + - foo: 4 + bar: 5 + qux: 6 +more: + # A comment goes here, + # which spans multiple lines. + + # Ideally, a blank line is still preserved as well, + # so that large comment blocks can be separated. + a: + - x # after the item + # between items + - y: + further: + indentation: + - is + - possible + - even: + tho: + it: + changes: a lot + - z + # after last +` + + test('preserveCollectionIndentation: toString() preserve document indentation', () => { + const document = parseDocument(sample, { + preserveCollectionIndentation: true + }) + const roundtrippedSource = document.toString() + + expect(roundtrippedSource.trim()).toEqual(sample.trim()) + }) + + // sample2 corresponds to the JSON `{"foo": ["a", "b", "c"]}` + // The Seq is not indented at all, and we can preserve that when + // parsing. + const sample2 = ` +foo: +- a +- b +- c +` + // However, when we edit the field to replace the list with an object, + // some indentation is now required. In this case, we default to the + // original 'indentStep' value (e.g. 2 in this case) because some + // level of indentation is required. + const sample3 = ` +foo: + a: 1 + b: 2 + c: 3 +` + + test('preserveCollectionIndentation: produces correct yaml when preserving indentation with edits', () => { + const document = parseDocument(sample2, { + preserveCollectionIndentation: true + }) + expect(document.toString().trim()).toEqual(sample2.trim()) + + // When replacing the item, we now need indentation, since otherwise + // the document has a different structure than it initially did. + document.set('foo', { a: 1, b: 2, c: 3 }) + expect(document.toString().trim()).toEqual(sample3.trim()) + }) + + const combined = ` +a: + b: + c: d + more: e + +big_indent: + - a + - b + - c +list_map1: + - foo: 1 + bar: 2 + qux: 3 + - foo: 4 + bar: 5 + qux: 6 +more: + # A comment goes here, + # which spans multiple lines. + + # Ideally, a blank line is still preserved as well, + # so that large comment blocks can be separated. + a: + - x # after the item + # between items + - y: + further: + indentation: + - is + - possible + - even: + tho: + it: + changes: a lot + - z + # after last +preservedDocument: + a: + b: + c: d + more: e + + big_indent: + - a + - b + - c + list_map1: + - foo: 1 + bar: 2 + qux: 3 + - foo: 4 + bar: 5 + qux: 6 + more: + # A comment goes here, + # which spans multiple lines. + + # Ideally, a blank line is still preserved as well, + # so that large comment blocks can be separated. + a: + - x # after the item + # between items + - y: + further: + indentation: + - is + - possible + - even: + tho: + it: + changes: a lot + - z + # after last +` + + test('preserveCollectionIndentation: documents with preserved indentation can be inserted into other documents', () => { + const document = parseDocument(sample, { + preserveCollectionIndentation: true + }) + + // In this new document, we purposefully skip preserving indentation: + const newDocument = parseDocument(sample, { + preserveCollectionIndentation: false + }) + + // The indentation-preserved document is inserted into the "main" document, + // and maintains its original indentation level. + newDocument.set('preservedDocument', document) + + expect(newDocument.toString().trim()).toEqual(combined.trim()) + }) + + const editingSample = ` +foo: + big_indent: 1 + again: 2 + more: 3 + `.trim() + + const editingSampleAfter = ` +foo: + big_indent: 1 + again: 2 + more: 3 + new: 4 +`.trim() + + test('preserveCollectionIndentation: adds new item preserving indentation', () => { + const document = parseDocument(editingSample, { + preserveCollectionIndentation: true + }) + + document.setIn(['foo', 'new'], 4) + + expect(document.toString().trim()).toEqual(editingSampleAfter) + }) +}) diff --git a/tests/yaml-test-suite.ts b/tests/yaml-test-suite.ts index b9517bbe..18aaff02 100644 --- a/tests/yaml-test-suite.ts +++ b/tests/yaml-test-suite.ts @@ -98,6 +98,20 @@ for (const dir of testDirs) { const docs2 = parseAllDocuments(src2, { resolveKnownTags: false }) testJsonMatch(docs2, json) }) + + test('stringify+re-parse when preserving indentation', () => { + const roundTripDocuments = parseAllDocuments(yaml, { + resolveKnownTags: false, + preserveCollectionIndentation: true + }) + testJsonMatch(roundTripDocuments, json) + + const src2 = + docs.map(doc => String(doc).replace(/\n$/, '')).join('\n...\n') + + '\n' + const docs2 = parseAllDocuments(src2, { resolveKnownTags: false }) + testJsonMatch(docs2, json) + }) } const outYaml = load('out.yaml')