diff --git a/README.md b/README.md index b7205373..7e7e79cd 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ class TomlFormat { bracketSpacing: boolean inlineTableStart?: number truncateZeroTimeInDates: boolean + useTabsForIndentation?: boolean + indentWidth: number minimumDecimals?: number leadingBom: boolean updateOrder?: boolean @@ -160,4 +162,4 @@ const toml = stringify({ ``` -See the [formatting reference](https://github.com/DecimalTurn/toml-patch/blob/v3.0.4/docs/Formatting.md) for the complete list of options, auto-detection behavior, `updateOrder` and more examples. \ No newline at end of file +See the [formatting reference](https://github.com/DecimalTurn/toml-patch/blob/v3.0.4/docs/Formatting.md) for the complete list of options, auto-detection behavior, `updateOrder` and more examples. diff --git a/docs/PLAN-Indentation.md b/docs/PLAN-Indentation.md new file mode 100644 index 00000000..ff8984dc --- /dev/null +++ b/docs/PLAN-Indentation.md @@ -0,0 +1,337 @@ +# Plan: style-aware indentation + +## Goal + +When `patch()` creates or relocates multiline TOML content, it should preserve the indentation style that the document already uses. A document using two spaces and a document using four spaces should produce different, predictable output. Tabs should remain tabs, and value content inside multiline strings must never be treated as structural indentation. + +The main rule is: + +> Use the nearest surviving sibling as the strongest evidence. Use document-level formatting only when the local container has no surviving row from which to infer its style. + +This keeps local formatting intact without forcing every container in a mixed-style document into one global layout. + +## Current behavior + +The writer already handles the common case well: + +- A new first row in a multiline array or inline table copies the column of the next surviving row. +- Existing rows keep their own columns when they are edited or moved. +- `useTabsForIndentation` is auto-detected. +- The empty-container fallback now uses `TomlFormat.indentWidth`. + +The current implementation adds a document-level `indentWidth` with a default of two columns and detects a smallest observed space indentation. This is a useful first step, but the detector and propagation rules need the cases below before the option should be considered complete. + +## Where `indentWidth` is used + +`patch()` resolves the format against the format detected from the existing document. An explicit `indentWidth` wins over detection. The resolved value is stored as internal metadata on the patch root and is available to the writer when it inserts generated nodes. + +For multiline inline arrays and inline tables, the writer checks indentation in this order: + +1. If a following row exists, copy that row's absolute column. +2. If the container has a surviving row, use the container's existing local row style for additions. +3. If the container is empty and has no row to copy, calculate a starting column from the closing delimiter and the resolved `indentWidth`. + +This means `indentWidth` controls generated rows only when local row evidence is unavailable. It does not reindent existing rows, moved value-content lines, or leading whitespace inside multiline strings. A removed row can also leave its column as local evidence when the same container is populated again in the same patch. + +Generated nested inline containers follow the same rule. If a sibling container provides a row template, its relative row indentation wins. If the generated container has no template row, `indentWidth` supplies the indentation step. The setting is therefore most visible when populating an empty multiline array or inline table, including an empty nested container. + +Block table and array-of-tables insertion uses line and sibling positions rather than the inline-container fallback above. Their new rows should inherit the surrounding block row style; `indentWidth` should not be described as a global reformatter for those rows. + +## Design principles + +1. **Preserve local evidence first.** A sibling row, an adjacent comment row, or an enclosing container's established row style outranks the global default. +2. **Separate structure from value text.** Leading spaces in multiline basic/literal string content are data, not indentation. +3. **Treat tabs as a style, not as four or eight spaces.** Structural tab indentation should be copied as tabs. A tab-width setting must not be invented unless the API has a clear need for it. +4. **Do not normalize unrelated formatting.** A patch should not rewrite all indentation merely because one new row was added. +5. **Keep semantic validity independent from presentation.** Every indentation decision must still round-trip through `parse()`. +6. **Make ambiguity conservative.** If the document has conflicting indentation evidence, use the nearest applicable evidence and fall back to the documented default when no local evidence exists. +7. **Keep public API growth deliberate.** `indentWidth` is useful for generated content and explicit caller control, but it should not become a promise that every existing indentation style can be represented by one global number. + +## Where indentation matters + +### 1. Multiline inline arrays + +Cover all of these forms: + +- scalar rows: + + ```toml + values = [ + 1, + 2, + ] + ``` + +- arrays nested inside arrays; +- inline tables as array elements; +- multiline strings as array elements; +- arrays whose closing bracket shares the final row; +- arrays whose first row shares the opening-bracket line; +- empty arrays that are later populated; +- removing the first row and moving a survivor into its slot; +- prepending, appending, and inserting in the middle; +- replacing an entire array with a differently sized or differently shaped array. + +The first-row path is especially important because it has no previous sibling. If there is a following row, copy its column. If the array is empty, infer the row column from the closing bracket, the enclosing row, and the detected indentation unit. + +### 2. Multiline inline tables + +Cover: + +- ordinary key-value rows; +- dotted keys inside the table; +- nested inline tables; +- nested multiline arrays; +- comments before, after, and between rows; +- trailing commas and no trailing commas; +- braces sharing the first or last row; +- empty tables repopulated after deletion; +- replacing a dotted key or multiline array with an inline table; +- replacing every row in a table while preserving the table's row indentation. + +Inline-table rows often live inside a `KeyValue` and may be wrapped in `InlineItem`, so indentation inference must not assume that the immediate CST parent is a block container. + +### 3. Table and array-of-tables bodies + +Block table rows are not inline-container rows, but new rows still need style preservation: + +- `[table]` bodies; +- `[[array]]` bodies; +- nested tables and array-of-tables; +- empty table headers that are later populated; +- new rows after removing all old rows; +- rows added while a sibling section is being removed or reordered; +- comments owned by the row or header; +- dotted key rows inside a table body. + +The existing block insertion code uses line positions and leading-line counts. The indentation plan should keep those concerns separate from inline-container column positioning, then add a shared local-indentation helper only where both paths need it. + +### 4. Dotted keys and structural replacements + +Structural edits are where generated nodes lose the original row context. Cover: + +- dotted key to scalar; +- dotted key to array; +- dotted key to inline table; +- dotted key to nested object; +- implicit table to scalar or array; +- array-of-tables to scalar, array, or object; +- replacements under an existing `[table]` or `[[array]]`; +- replacements where the old value was multiline and the new value is single-line; +- replacements where the new value is multiline and the old value was single-line. + +Generated replacement values should inherit the indentation context of the row they replace. If the replacement expands into multiple rows, the first row should use the replaced row's column and subsequent rows should use the replacement container's local indentation policy. + +### 5. Comments + +Comments affect both physical positions and ownership: + +- comments inside multiline arrays/tables may be hoisted into the enclosing container; +- a comment may be the only remaining item near an empty container; +- comments can sit on the opening or closing delimiter line; +- a blank line severs ownership but not necessarily indentation style; +- reordering must move comments with their owned rows without changing their columns; +- a new row must not use a comment's column as its row indentation unless the comment is clearly a same-container structural row. + +Comment columns are evidence only when the comment is structurally associated with the container. Pinned prose comments must not determine the indentation width. + +### 6. Tabs and mixed styles + +Support these cases explicitly: + +- all structural rows use tabs; +- tabs are used for outer levels and spaces for an inner inline container; +- spaces are used for outer levels and tabs for an inner container; +- a document contains both tabs and spaces because it was assembled from different sources; +- multiline string content begins with tabs or spaces that are part of the value; +- `useTabsForIndentation: true` is explicitly supplied; +- `useTabsForIndentation: false` is explicitly supplied against a tab-indented source. + +When the caller explicitly supplies `useTabsForIndentation`, it controls newly generated structural indentation. Existing untouched and relocated value-content lines remain unchanged. When the option is not supplied, local source style should win over the auto-detected document default. + +### 7. Multiline strings + +Multiline string lines are never structural rows. Tests must prove that indentation detection and tab conversion do not change: + +- leading spaces in `"""` content; +- leading tabs in `'''` content; +- blank content lines; +- closing delimiter indentation; +- line-continuation backslash formatting; +- strings nested inside arrays and inline tables. + +The detector should inspect CST node types and source locations rather than scanning every indented source line indiscriminately. + +## Proposed model + +### Document-level format + +Keep these format properties distinct: + +- `useTabsForIndentation`: whether generated structural indentation uses tabs; +- `indentWidth`: the number of spaces per structural level when spaces are used; +- `newLine`, `trailingNewline`, `trailingComma`, and `bracketSpacing`: independent formatting choices. + +`indentWidth` should: + +- default to `2`; +- accept a positive integer when supplied explicitly; +- be auto-detected only when the caller did not supply it; +- be passed through `resolveTomlFormat()` without changing existing constructor argument behavior; +- be documented as affecting generated multiline structure, not string content. + +Avoid adding `tabWidth` unless a real output requirement appears. Tabs are structural characters and should be copied as tabs rather than converted through a visual tab-stop calculation. + +### Detection + +Detection should operate on CST-backed structural rows: + +1. Walk multiline `InlineArray`, `InlineTable`, `Table`, and `TableArray` containers. +2. For each child row that starts on a later source line, read only the source line's leading structural whitespace. +3. Exclude multiline string content and delimiter-only lines from the sample set. +4. For spaces, collect positive indentation deltas between a container row and its child rows. +5. Choose the smallest repeated positive delta as the document indentation unit. +6. For tabs, record tab style separately and use a logical width of one level. +7. If evidence is absent or contradictory, use the default width of two spaces. + +The detector should not simply take the minimum absolute leading-space count. A document may contain a top-level table body at four spaces and an inline table nested beneath a four-space row at eight spaces. The useful value is the repeated delta between structural levels, not the smallest absolute column. + +### Local context + +Introduce a small internal context object rather than passing several independent values through writer calls. It should eventually carry: + +- indentation token or style (`spaces` or `tabs`); +- indentation unit (`indentWidth` for spaces, one logical level for tabs); +- enclosing container start column; +- known sibling row column; +- whether the current row is structural or string content. + +The context should be attached to the patch root or passed explicitly to generated-node helpers. WeakMap root metadata is acceptable for the current writer architecture, but it should remain an internal implementation detail. + +## Operation matrix + +Every operation below needs at least one two-space, four-space, and tab fixture where the operation creates a new row: + +| Operation | Array | Inline table | Table body | AOT body | Comments | Structural replacement | +|---|---:|---:|---:|---:|---:|---:| +| edit existing value | yes | yes | yes | yes | yes | no | +| prepend | yes | yes | yes | yes | yes | no | +| append | yes | yes | yes | yes | yes | no | +| middle insert | yes | yes | yes | yes | yes | no | +| remove first | yes | yes | yes | yes | yes | no | +| remove middle | yes | yes | yes | yes | yes | no | +| remove all then add | yes | yes | yes | yes | yes | no | +| move/reorder | yes | yes | yes | yes | yes | no | +| replace whole value | yes | yes | yes | yes | yes | yes | +| truncate dotted key | no | yes | yes | yes | yes | yes | +| expand dotted key | no | yes | yes | yes | yes | yes | + +## Testing plan + +### Unit tests + +Add focused tests for: + +- `TomlFormat` defaults and validation; +- two-space, four-space, six-space, and tab detection; +- no indentation evidence; +- nested indentation where the smallest absolute indent is not the unit; +- conflicting indentation styles; +- explicit `indentWidth` override; +- explicit tab override; +- CST reuse in auto-detection; +- malformed input fallback. + +### Writer tests + +Test `calculateInlinePositioning()` through public writer operations for: + +- empty multiline array; +- empty multiline inline table; +- first-row insertion with a following row; +- first-row insertion with only comments remaining; +- closing delimiter on its own line; +- closing delimiter sharing the last row; +- bracket-line first item; +- nested containers. + +### Patch regressions + +Keep full-output assertions and parse round trips for the fuzz cases that motivated this work: + +- seed 30330 and its alternatives; +- seed 61827 and its alternatives; +- empty multiline arrays; +- structural dotted-key replacements; +- multiline arrays containing multiline strings; +- comments hoisted from inline containers; +- `updateOrder: true` combined with structural replacement. + +### Property and fuzz checks + +Extend the fuzz comparison to normalize only the known line-ending behavior. Indentation must remain part of the exact-output comparison for style-preservation cases. Every generated patch should satisfy: + +```ts +expect(parse(patch(source, updated, format))).toEqual(updated); +``` + +For style-aware cases, also compare the expected structural indentation token and level rather than only checking that the output parses. + +## Implementation phases + +### Phase 1: stabilize the format contract + +- Document `indentWidth` and its default. +- Validate explicit values. +- Preserve constructor and partial-format compatibility. +- Add detection and fallback tests. + +### Phase 2: centralize local inference + +- Extract sibling-row and delimiter-column inference into one internal helper. +- Make arrays and inline tables use the same helper. +- Keep comments and multiline string content out of the inference sample. +- Add nested-container and mixed-style tests. + +### Phase 3: propagate context through generated nodes + +- Attach the resolved indentation context to patch and parseJS roots. +- Ensure `regenerateValue()` receives the same context as the replaced row. +- Ensure generated nested tables, arrays, and inline tables inherit the correct parent context. +- Verify structural replacements do not revert to the global default. + +### Phase 4: audit block insertion and reordering + +- Review `insertOnNewLine()` separately from `insertInline()`. +- Check table and AOT additions after removals. +- Check `updateOrder` moves with comments and multiline children. +- Ensure horizontal shifts never touch multiline string content. + +### Phase 5: fuzz and compatibility sweep + +- Run the full fuzz corpus and targeted seeds. +- Run TOML spec tests and browser tests. +- Check generated declaration output. +- Review bundle size and public API documentation. +- Decide whether `indentWidth` should remain public or become an internal auto-detected field after observing real callers. + +## Open questions + +1. Should a single global `indentWidth` remain the public model, or should only the auto-detected document default be public while local container styles stay internal? +2. When a document intentionally mixes two-space table bodies and four-space inline tables, should new empty containers inherit the nearest container delta rather than the document-wide minimum? +3. Should explicit `indentWidth` override only generated rows, or also reindent moved existing rows? The safer default is generated rows only. +4. Should inconsistent source indentation produce a warning, or should patch remain silent and choose the nearest stable evidence? +5. Do callers need an explicit indentation token such as `indent: ' '` for unusual styles, or is a width plus tab mode enough? + +## Completion criteria + +This plan is complete when: + +- all operation-matrix cases preserve local indentation; +- two-space, four-space, and tab documents pass exact-output regressions; +- multiline string content is byte-for-byte preserved unless its value changes; +- structural replacements inherit the replaced row's style; +- ambiguous documents have documented fallback behavior; +- `parse(patch(...))` round-trips all supported cases; +- the public format contract and generated declarations are tested; +- no unrelated formatting is rewritten by a patch. \ No newline at end of file diff --git a/src/__tests__/patch.dotted-key-spacing.test.ts b/src/__tests__/patch.dotted-key-spacing.test.ts new file mode 100644 index 00000000..f377ff0d --- /dev/null +++ b/src/__tests__/patch.dotted-key-spacing.test.ts @@ -0,0 +1,66 @@ +import patch from '../patch'; +import { parse } from '../'; +import dedent from 'dedent'; + +/* The TOML spec allows extraneous spacing between the key and the dot + for dotted keys. This is not recommended, but it is allowed. + + When adding a new dotted key to an existing TOML document, the spacing and overall + style should be preserved. There might be cases where the appropriate spacing + is not obvious, but the patcher should try to preserve the existing spacing as + much as possible. + + //TODO: add more tests with space before and or after the dot. Try with many spaces. + // Also try tests where the table name is a dotted key with spacing, and + // the new table should then have a similar spacing (to be confirmed). + +*/ +describe('patching dotted keys with extraneous spacing', () => { + + test.fails('editing a key with spacing preserves the spacing', () => { + const src = dedent` + fruit. color = "yellow" + `; + + const obj = parse(src) as any; + //Edit fruit.color to "green" + obj.fruit.color = "green"; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toEqual(dedent` + fruit. color = "green" + `); + }); + + test.fails('adding a new dotted key with spacing to an existing dotted key with spacing', () => { + const src = dedent` + fruit. color = "yellow" + `; + + const obj = parse(src) as any; + //Add fruit.flavor to the object + obj.fruit.flavor = "banana"; + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toEqual(dedent` + fruit. color = "yellow" + fruit. flavor = "banana" + `); + }); + + test.fails('single key added to indented single key with spacing', () => { + // fruit. color = "yellow" # same as fruit.color + //fruit . flavor = "banana" # same as fruit.flavor + const src = [ + ' fruit. color = "yellow"', + ].join('\n') + const obj = parse(src) as any; + //Add fruit.flavor to the object + obj.fruit.flavor = "banana"; + expect(patch(src, obj, { indentWidth: 4 })).toEqual([ + ' fruit. color = "yellow"', + ' fruit. flavor = "banana"', + ].join('\n')); + }); + +}); \ No newline at end of file diff --git a/src/__tests__/patch.fuzz.test.ts b/src/__tests__/patch.fuzz.test.ts index ea5cb3b1..8b252e39 100644 --- a/src/__tests__/patch.fuzz.test.ts +++ b/src/__tests__/patch.fuzz.test.ts @@ -311,8 +311,8 @@ test('collapsing a multiline-array dotted key into an inline table (seed 30330 a a5 = [1] h.z = { - b.x = true, - b.y = "tail" + b.x = true, + b.y = "tail", } `); }); @@ -2401,7 +2401,8 @@ test('moving a multiline literal past duplicate scalars while removing the head const result = patch(src, obj); expect(parse(result)).toEqual(obj); expect(result).toEqual(dedent` - o4s = [false, 30325, false, "changed", false, "x", 'y'] + o4s = [false, 30325, false, ''' + changed''', false, "x", 'y'] `); }); diff --git a/src/__tests__/patch.indentation.test.ts b/src/__tests__/patch.indentation.test.ts new file mode 100644 index 00000000..fdda4584 --- /dev/null +++ b/src/__tests__/patch.indentation.test.ts @@ -0,0 +1,1095 @@ +import patch from '../patch'; +import { parse, TomlFormat } from '../'; +import dedent from 'dedent'; + + +describe('indentation at the root level', () => { + + test('single key added to indented single key', () => { + const src = [ + ' key1 = "test"', + ].join('\n') + + const obj = parse(src) as any; + //Add key2 to the object + obj.key2 = "new-value"; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toEqual([ + ' key1 = "test"', + ' key2 = "new-value"', + ].join('\n')); + }); + + // The indentation of the new key should match the last sibling key in the container + test('single key added to a few keys with inconsistent indentation', () => { + const src = [ + ' key1 = "test1"', + ' key2 = "test2"', + ].join('\n') + + const obj = parse(src) as any; + obj.key3 = "new-value"; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toEqual([ + ' key1 = "test1"', + ' key2 = "test2"', + ' key3 = "new-value"', + ].join('\n')); + }); + + test('single key added to indented single key with dotted keys', () => { + const src = [ + ' fruit.color = "yellow"', + ].join('\n') + const obj = parse(src) as any; + //Add fruit.flavor to the object + obj.fruit.flavor = "banana"; + expect(patch(src, obj)).toEqual([ + ' fruit.color = "yellow"', + ' fruit.flavor = "banana"', + ].join('\n')); + }); + + test('considers root indentation for table', () => { + const src = [ + ' [server]', + ' key1 = "value1"', + ].join('\n'); + const obj = parse(src) as any; + obj.client = {}; + obj.client.port = 8080; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + ' [server]', + ' key1 = "value1"', + '', + ' [client]', + ' port = 8080', + ].join('\n')); + + }); + +}); + +describe('tab indentation', () => { + + test('detects tabs when adding a root-level sibling key', () => { + const src = [ + '\tkey1 = "value1"', + ].join('\n'); + const obj = parse(src) as any; + obj.key2 = 'value2'; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '\tkey1 = "value1"', + '\tkey2 = "value2"', + ].join('\n')); + }); + + test('preserves tabs at each nested multiline-array level', () => { + const src = [ + 'values = [', + '\t[', + '\t\t1,', + '\t\t2,', + '\t],', + ']', + ].join('\n'); + const obj = parse(src) as any; + obj.values.push([3, 4]); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + '\t[', + '\t\t1,', + '\t\t2,', + '\t],', + '\t[', + '\t\t3,', + '\t\t4,', + '\t],', + ']', + ].join('\n')); + }); + + test('uses tabs when explicitly populating an empty multiline inline table', () => { + const src = [ + 'config = {', + '}', + ].join('\n'); + const obj = parse(src) as any; + obj.config.host = 'localhost'; + + const result = patch(src, obj, { useTabsForIndentation: true, indentWidth: 4 }); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'config = {', + '\thost = "localhost"', + '}', + ].join('\n')); + }); + + // The expected outcome of this test is not really ideal. Clearly if a document + // is using a mixed of tabs and spaces, ideally the patch operation would leave things + // as they are, but the current implementation of the patcher will enforce only one + // type of indentation. This is a limitation of the current implementation and could be + // improved in the future, but we are being honest, mixed indentation is an abomination + // and people should be grateful that we fix it for them. However, the fact that we + // replace one space by a tab can be a bit surprising and introduce big shifts in the + // document. But hey, if that's whats needed to get the person's attention on the fact + // that they are using mixed indentation, then so be it. We can always improve this in + // the future if people complain. + test('preserves an indented comment with mixed indentation', () => { + const src = [ + '[server]', + ' # managed by the platform', + '\thost = "localhost"', + ].join('\n'); + const obj = parse(src) as any; + obj.server.port = 8080; + + const fmt = TomlFormat.autoDetectFormat(src); + + expect(fmt.useTabsForIndentation).toBe(true); + expect(fmt.indentWidth).toBe(1); + + const result = patch(src, obj, fmt); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '[server]', + '\t\t# managed by the platform', + '\thost = "localhost"', + '\tport = 8080', + ].join('\n')); + }); + + test('preserves an indented comment with space indentation', () => { + const src = [ + '[server]', + '\t# managed by the platform', + ' host = "localhost"', + ' ip = "127.0.0.1"', + ].join('\n'); + const obj = parse(src) as any; + obj.server.port = 8080; + + const fmt = TomlFormat.autoDetectFormat(src); + + expect(fmt.useTabsForIndentation).toBe(false); + expect(fmt.indentWidth).toBe(4); + + const result = patch(src, obj, fmt); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '[server]', + ' # managed by the platform', + ' host = "localhost"', + ' ip = "127.0.0.1"', + ' port = 8080', + ].join('\n')); + }); + +}); + +describe('indentation edge cases', () => { + + test('ignores leading blank and comment lines when detecting root indentation', () => { + const src = [ + '', + '# application settings', + ' name = "app"', + ].join('\n'); + const obj = parse(src) as any; + obj.version = '1.0'; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '', + '# application settings', + ' name = "app"', + ' version = "1.0"', + ].join('\n')); + }); + + test('preserves CRLF when adding a tab-indented table row', () => { + const src = [ + '[server]', + '\thost = "localhost"', + ].join('\r\n'); + const obj = parse(src) as any; + obj.server.port = 8080; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '[server]', + '\thost = "localhost"', + '\tport = 8080', + ].join('\r\n')); + }); + + //Delete then re-add a key to a table should preserve indentation style + test('deletes then re-adds a key to a table preserving indentation', () => { + const existing = [ + 'tbl-1 = {', + ' only = 1,', + '}', + ].join('\n'); + + const value = parse(existing); + delete value['tbl-1'].only; + value['tbl-1'].new = 2; + const patched = patch(existing, value); + + expect(patched).toEqual([ + 'tbl-1 = {', + ' new = 2,', + '}', + ].join('\n')); + }); + + test('deletes then re-adds a key to a table preserving indentation 2', () => { + const existing = [ + 'tbl-1 = {', + ' only = 1', + '}', + ].join('\n'); + + const value = parse(existing); + delete value['tbl-1'].only; + value['tbl-1'].new = 2; + const patched = patch(existing, value); + + expect(patched).toEqual([ + 'tbl-1 = {', + ' new = 2', + '}', + ].join('\n')); + }); + + test('deletes then re-adds a value to an array preserving indentation', () => { + const existing = [ + 'tbl-1 = [', + ' "2"', + ']', + ].join('\n'); + + const value = parse(existing); + delete value['tbl-1'][0]; + value['tbl-1'].push("3"); + const patched = patch(existing, value); + + expect(patched).toEqual([ + 'tbl-1 = [', + ' "3"', + ']', + ].join('\n')); + }); + + test('deletes then re-adds a value to an array preserving indentation 2', () => { + const existing = [ + 'tbl-1 = [', + ' "2"', + ']', + ].join('\n'); + + const value = parse(existing); + delete value['tbl-1'][0]; + value['tbl-1'].push("3"); + const patched = patch(existing, value); + + expect(patched).toEqual([ + 'tbl-1 = [', + ' "3"', + ']', + ].join('\n')); + }); + + //Delete then re-add a key to a table should preserve indentation style + test('deletes then re-adds a key to a table preserving local indentation', () => { + const existing = [ + ' tbl-1 = {', + ' only = 1,', + ' }', + ].join('\n'); + + const value = parse(existing); + delete value['tbl-1'].only; + value['tbl-1'].new = 2; + const patched = patch(existing, value); + + expect(patched).toEqual([ + ' tbl-1 = {', + ' new = 2,', + ' }', + ].join('\n')); + }); + + test('deletes then re-adds an element to an array preserving local indentation', () => { + const existing = [ + ' tbl-1 = [', + ' "2"', + ' ]', + ].join('\n'); + + const value = parse(existing); + delete value['tbl-1'][0]; + value['tbl-1'].push("3"); + const patched = patch(existing, value); + + expect(patched).toEqual([ + ' tbl-1 = [', + ' "3"', + ' ]', + ].join('\n')); + }); + + test('deletes then re-adds an element to an array preserving local indentation even with format override', () => { + const existing = [ + ' tbl-1 = [', + ' "2"', + ' ]', + ].join('\n'); + + const value = parse(existing); + delete value['tbl-1'][0]; + value['tbl-1'].push("3"); + + const fmt = TomlFormat.autoDetectFormat(existing); + fmt.indentWidth = 2; + const patched = patch(existing, value, fmt); + + expect(patched).toEqual([ + ' tbl-1 = [', + ' "3"', + ' ]', + ].join('\n')); + }); + +}); + +describe('human-edited indentation', () => { + + test('adds a key to a table with one-space indentation', () => { + const src = [ + '[server]', + ' host = "localhost"', + ].join('\n'); + const obj = parse(src) as any; + obj.server.port = 8080; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '[server]', + ' host = "localhost"', + ' port = 8080', + ].join('\n')); + }); + + test('matches the last table row after an indentation jump', () => { + const src = [ + '[server]', + ' host = "localhost"', + ' port = 8080', + ].join('\n'); + const obj = parse(src) as any; + obj.server.timeout = 30; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '[server]', + ' host = "localhost"', + ' port = 8080', + ' timeout = 30', + ].join('\n')); + }); + + test('keeps four-space indentation for a new dotted table key', () => { + const src = [ + '[database]', + ' connection.host = "localhost"', + ].join('\n'); + const obj = parse(src) as any; + obj.database.connection.port = 5432; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '[database]', + ' connection.host = "localhost"', + ' connection.port = 5432', + ].join('\n')); + }); + + test('does not use an indented comment as the table row style if there is one key', () => { + const src = [ + '[server]', + ' # managed by the platform', + ' host = "localhost"', + ].join('\n'); + const obj = parse(src) as any; + obj.server.port = 8080; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '[server]', + ' # managed by the platform', + ' host = "localhost"', + ' port = 8080', + ].join('\n')); + }); + + test('does not use an indented comment as the table row style even when only a comment is present', () => { + const src = [ + '[server]', + ' # managed by the platform', + ].join('\n'); + const obj = parse(src) as any; + obj.server.port = 8080; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + '[server]', + ' # managed by the platform', + '', + 'port = 8080', + ].join('\n')); + }); + + + /* + This test is currently skipped because we currently don't support + adding indented keys to a table that has no keys. The patcher currently + uses the indentation of the last key in the table to determine the + indentation of new keys, but if there are no keys, it defaults to no indentation. This is a limitation + of the current implementation and could be improved in the future. + + Since this practical scenario is not common, we can skip this test for now. + If we want to support this in the future, we can revisit this test and implement + the necessary logic in the patcher to handle this case. + + */ + + test.skip('considers root indentation and intra-table indentation separately', () => { + const src = [ + ' [server]', + ' key1 = "value1"', + ].join('\n'); + const obj = parse(src) as any; + obj.client = {}; + obj.client.port = 8080; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + ' [server]', + ' key1 = "value1"', + '', + ' [client]', + ' port = 8080', + ].join('\n')); + + }); + + test('preserves indentation when adding a root key before a section', () => { + const src = [ + ' name = "app"', + '', + '[server]', + 'host = "localhost"', + ].join('\n'); + const obj = parse(src) as any; + obj.version = "1.0"; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + ' name = "app"', + ' version = "1.0"', + '', + '[server]', + 'host = "localhost"', + ].join('\n')); + }); + + test('does not reindent multiline string content when adding a sibling key', () => { + const src = [ + 'description = """', + ' first line', + ' second line', + '"""', + ].join('\n'); + const obj = parse(src) as any; + obj.title = "example"; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'description = """', + ' first line', + ' second line', + '"""', + 'title = "example"', + ].join('\n')); + }); + + test('adds a row to a multiline inline table using its existing row column', () => { + const src = [ + 'config = {', + ' host = "localhost",', + '}', + ].join('\n'); + const obj = parse(src) as any; + obj.config.port = 8080; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'config = {', + ' host = "localhost",', + ' port = 8080,', + '}', + ].join('\n')); + }); + + test('populates an empty multiline inline table below an indented closing brace', () => { + const src = [ + 'config = {', + ' }', + ].join('\n'); + const obj = parse(src) as any; + obj.config.host = "localhost"; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'config = {', + ' host = "localhost"', + ' }', + ].join('\n')); + }); + + test('preserves four-space rows when replacing a dotted inline-table value', () => { + const src = [ + 'config = {', + ' service.host = "localhost",', + ' service.port = 80,', + '}', + ].join('\n'); + const obj = parse(src) as any; + obj.config.service = { secure: true }; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'config = {', + ' service.secure = true,', + '}', + ].join('\n')); + }); + + test('using indent when no elements are in the multiline inline table', () => { + const src = [ + 'config = {', + '}', + ].join('\n'); + const fmt = { indentWidth: 4 }; + const obj = parse(src) as any; + obj.config.service = true; + + const result = patch(src, obj, fmt); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'config = {', + ' service = true', + '}', + ].join('\n')); + }); + + test('using indent when no elements are in the multiline array', () => { + const src = [ + 'config = [', + ']', + ].join('\n'); + const fmt = { indentWidth: 4 }; + const obj = parse(src) as any; + obj.config.push(true); + + const result = patch(src, obj, fmt); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'config = [', + ' true', + ']', + ].join('\n')); + }); + + test('using indent when no elements are in the multiline array (default indent)', () => { + const src = [ + 'config = [', + ']', + ].join('\n'); + const obj = parse(src) as any; + obj.config.push(true); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'config = [', + ' true', + ']', + ].join('\n')); + }); + + +}); + +describe('nested multiline arrays', () => { + + test('preserves each nesting level when adding an outer array element', () => { + const src = [ + 'values = [', + ' [', + ' 1,', + ' 2,', + ' ],', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values.push([3, 4]); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' [', + ' 1,', + ' 2,', + ' ],', + ' [', + ' 3,', + ' 4,', + ' ],', + ']' + ].join('\n')); + }); + + test('preserves four-space nesting when adding an outer array element', () => { + const src = [ + 'values = [', + ' [', + ' 1,', + ' 2,', + ' ],', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values.push([3, 4]); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' [', + ' 1,', + ' 2,', + ' ],', + ' [', + ' 3,', + ' 4,', + ' ],', + ']' + ].join('\n')); + }); + + test('does not reindent multiline string content inside a new nested array', () => { + const src = [ + 'values = [', + ' [', + ' """', + ' first line', + ' second line', + ' """,', + ' ],', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values.push(['new value']); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' [', + ' """', + ' first line', + ' second line', + ' """,', + ' ],', + ' [', + ' "new value",', + ' ],', + ']' + ].join('\n')); + }); + + test('preserves each nesting level when adding a nested array row', () => { + const src = [ + 'values = [', + ' [', + ' 1,', + ' 2,', + ' ],', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values[0].push(3); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' [', + ' 1,', + ' 2,', + ' 3,', + ' ],', + ']' + ].join('\n')); + }); + + test('preserves four-space nesting when adding a nested array row', () => { + const src = [ + 'values = [', + ' [', + ' 1,', + ' 2,', + ' ],', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values[0].push(3); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' [', + ' 1,', + ' 2,', + ' 3,', + ' ],', + ']' + ].join('\n')); + }); + + test('does not reindent multiline string content inside an existing nested array', () => { + const src = [ + 'values = [', + ' [', + ' """', + ' first line', + ' second line', + ' """,', + ' ],', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values[0].push('new value'); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' [', + ' """', + ' first line', + ' second line', + ' """,', + ' "new value",', + ' ],', + ']' + ].join('\n')); + }); + + test('does not reindent basic multiline string content inside an existing nested array', () => { + const src = [ + 'values = [', + ' [', + ' """', + ' first line', + ' second line""",', + ' ],', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values[0][0] += '\n new value\n'; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' [', + ' """', + ' first line', + ' second line', + ' new value', + '""",', + ' ],', + ']' + ].join('\n')); + }); + + test('mixed indentation with nested array still favors the closest indentation level', () => { + const src = [ + 'values = [', + ' [', + ' "test1",', + ' "test2",', + ' ],', + ']' + ].join('\n'); + const obj = parse(src) as any; + + // Add another value to the array + obj.values[0].push('test3'); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' [', + ' "test1",', + ' "test2",', + ' "test3",', + ' ],', + ']' + ].join('\n')); + }); + + test('mixed indentation with nested array still favors the closest indentation level 2', () => { + const src = [ + 'values = [', + ' [', + ' ],', + ']' + ].join('\n'); + const obj = parse(src) as any; + + // Add another value to the array + obj.values[0].push('test3'); + + const fmt = { indentWidth: 4 }; + + const result = patch(src, obj, fmt); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' [', + ' "test3"', + ' ],', + ']' + ].join('\n')); + }); + +}); + +describe('nested multiline inline tables', () => { + + test('uses indentWidth when populating an empty nested inline table', () => { + const src = [ + 'config = {', + ' inner = {', + ' },', + '}', + ].join('\n'); + const obj = parse(src) as any; + obj.config.inner.value = true; + + const result = patch(src, obj, { indentWidth: 4 }); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'config = {', + ' inner = {', + ' value = true', + ' },', + '}', + ].join('\n')); + }); + + test('mixed indentation with nested inline table favors the closest indentation level (sibling)', () => { + const src = [ + 'values = [', + ' {', + ' name = "first",', + ' enabled = true,', + ' },', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values[0].active = false; + + const fmt = { indentWidth: 2 }; + + const result = patch(src, obj, fmt); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' {', + ' name = "first",', + ' enabled = true,', + ' active = false,', + ' },', + ']' + ].join('\n')); + }); + + test('preserves each nesting level when adding an outer array element', () => { + const src = [ + 'values = [', + ' {', + ' name = "first",', + ' enabled = true,', + ' },', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values.push({ name: 'second', enabled: false }); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' {', + ' name = "first",', + ' enabled = true,', + ' },', + ' {', + ' name = "second",', + ' enabled = false,', + ' },', + ']' + ].join('\n')); + }); + + test('preserves four-space nesting when adding an outer array element', () => { + const src = [ + 'values = [', + ' {', + ' name = "first",', + ' enabled = true,', + ' },', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values.push({ name: 'second', enabled: false }); + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' {', + ' name = "first",', + ' enabled = true,', + ' },', + ' {', + ' name = "second",', + ' enabled = false,', + ' },', + ']' + ].join('\n')); + }); + + test('will use indentWidth for empty multiline nested inline table', () => { + const src = [ + 'values = [', + ' {', + ' },', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values[0].name = 'new'; + obj.values[0].enabled = false; + + const fmt = { indentWidth: 2 }; + + const result = patch(src, obj, fmt); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' {', + ' name = "new",', + ' enabled = false', + ' },', + ']' + ].join('\n')); + }); + + // This is currently marked as failing since we haven't implemented a formatting + // optins that would allow to indicate that a new table should be written as multiline. + // We could introduce a formatting option like `preferMultiline` (bolean) that would + // allow to specify that a new inline table (or array) should be written in multiline format. + test.fails('will write table as multiline if already nested', () => { + const src = [ + 'values = [', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values.push({}); + obj.values[0].name = 'new'; + obj.values[0].enabled = false; + + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' {', + ' name = "new",', + ' enabled = false', + ' },', + ']' + ].join('\n')); + }); + + test.fails('will use indentWidth for new multiline nested inline table?', () => { + const src = [ + 'values = [', + ']' + ].join('\n'); + const obj = parse(src) as any; + obj.values.push({}); + obj.values[0].name = 'new'; + obj.values[0].enabled = false; + + const fmt = { indentWidth: 2 }; + + const result = patch(src, obj, fmt); + expect(parse(result)).toEqual(obj); + expect(result).toBe([ + 'values = [', + ' {', + ' name = "new",', + ' enabled = false', + ' },', + ']' + ].join('\n')); + }); + +}); + diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index 3ea19531..68a5fe42 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -3327,8 +3327,7 @@ describe('TOML v1.1 multiline inline tables - edit operations (newline.toml spec ` + '\n'); }); - - test('should delete the only key from a multiline inline table and leave it empty', () => { + test('should delete the only key from a multiline inline table and leave it empty and preserve multi-line formatting', () => { const existing = dedent` tbl-1 = { only = 1, @@ -3339,7 +3338,27 @@ describe('TOML v1.1 multiline inline tables - edit operations (newline.toml spec delete value['tbl-1'].only; const patched = patch(existing, value); - expect(patched).toEqual('tbl-1 = {}\n'); + expect(patched).toEqual(dedent` + tbl-1 = { + } + ` + '\n'); + }); + + test('should delete the only element from a multiline inline array and leave it empty and preserve multi-line formatting', () => { + const existing = dedent` + tbl-1 = [ + "1" + ] + ` + '\n'; + + const value = parse(existing); + value['tbl-1'].splice(0, 1); + const patched = patch(existing, value); + + expect(patched).toEqual(dedent` + tbl-1 = [ + ] + ` + '\n'); }); test('should delete a nested inline table key leaving empty nested table', () => { @@ -10136,8 +10155,7 @@ dKk''', 903e-66, '+R1B~LG;', true, ['xp %D', 'z'], 0b101, 162759] }); -//WIP -test.fails('multiline empty array', () => { +test('multiline empty array', () => { const src = dedent` [metadata] version = "1" @@ -10165,3 +10183,61 @@ test.fails('multiline empty array', () => { `); }); +test('multiline empty array uses the existing indentation level', () => { + const src = dedent` + [root] + dependencies = [ + ] + ` + '\n'; + + const obj = parse(src) as any; + obj.root.dependencies = ['new-dependency']; + + expect(patch(src, obj)).toEqual(dedent` + [root] + dependencies = [ + "new-dependency" + ] + ` + '\n'); +}); + +test('multiline empty array accepts an explicit indentation width', () => { + const src = 'dependencies = [\n]\n'; + const obj = parse(src) as any; + obj.dependencies = ['new-dependency']; + + const result = patch(src, obj, { indentWidth: 4 }); + expect(parse(result)).toEqual(obj); + expect(result).toEqual(dedent` + dependencies = [ + "new-dependency" + ] + ` + '\n'); +}); + + + + // For comment ownership, we need to ensure that a new key is added with + // an empty line before it if the previous key has a comment. + // This is to ensure that the comment is not associated with the new key. + + // TODO: make sure to implement the granular comment ownership logic in the patch + // function to handle this case correctly. + + test('adding a new key after a comment with an empty line', () => { + const src = dedent` + [server] + # managed by the platform + ` + '\n'; + const obj = parse(src) as any; + obj.server.port = 8080; + + const result = patch(src, obj); + expect(parse(result)).toEqual(obj); + expect(result).toEqual(dedent` + [server] + # managed by the platform + + port = 8080 + ` + '\n'); + }); diff --git a/src/__tests__/toml-format.test.ts b/src/__tests__/toml-format.test.ts index e3d9e4c0..5ba9682c 100644 --- a/src/__tests__/toml-format.test.ts +++ b/src/__tests__/toml-format.test.ts @@ -1,8 +1,9 @@ -import { TomlFormat, detectNewline, countTrailingNewlines, validateFormatObject, resolveTomlFormat } from '../toml-format'; +import { TomlFormat, detectNewline, countTrailingNewlines, validateFormatObject, resolveTomlFormat, detectTabsForIndentation } from '../toml-format'; import { patch, stringify } from '../index'; import parseTOML from '../parse-toml'; import toTOML from '../to-toml'; import { stripLeadingBom } from '../decode-utf8'; +import dedent from 'dedent'; function autoDetectFormat(toml: string) { return TomlFormat.autoDetectFormatWithCst(toml, parseTOML(stripLeadingBom(toml))); @@ -45,6 +46,7 @@ describe('TomlFormat comprehensive tests', () => { expect(format.trailingNewline).toBe(1); expect(format.trailingComma).toBe(false); expect(format.bracketSpacing).toBe(true); + expect(format.indentWidth).toBe(2); }); test('should use default when newLine is undefined', () => { @@ -453,6 +455,39 @@ data = "test"`; expect(format.trailingComma).toBe(true); // Should detect from multiple trailing commas }); + + test('should detect four-space indentation from single key', () => { + const toml = ' singleKey = 1\n'; + expect(autoDetectFormat(toml).indentWidth).toBe(4); + }); + + test('should detect four-space indentation from multiline rows', () => { + const toml = dedent` + table = { + key1 = 1, + key2 = 2, + } + `; + + expect(autoDetectFormat(toml).indentWidth).toBe(4); + }); + + test('should detect one-column indentation for tabs', () => { + const toml = 'table = {\n\tkey = 1,\n}\n'; + const format = autoDetectFormat(toml); + + expect(format.useTabsForIndentation).toBe(true); + expect(format.indentWidth).toBe(1); + }); + + test('should prefer tabs when tab and space evidence is equal', () => { + const toml = '[server]\n\tport = 8080\n host = "localhost"\n'; + + expect(detectTabsForIndentation(toml)).toBe(true); + expect(autoDetectFormat(toml).useTabsForIndentation).toBe(true); + expect(autoDetectFormat(toml).indentWidth).toBe(1); + }); + test('should reuse an existing parse tree when auto-detecting format', () => { const toml = 'title = "Cached"\narray = ["a", "b", ]\n'; const cst = Array.from(parseTOML(toml)); @@ -567,6 +602,15 @@ describe('validateFormatObject', () => { expect(validateFormatObject({ useTabsForIndentation: true })).toEqual({ useTabsForIndentation: true }); }); + test('accepts unset indentWidth values', () => { + expect(validateFormatObject({ indentWidth: undefined })).toEqual({ indentWidth: undefined }); + expect(validateFormatObject({ indentWidth: null })).toEqual({ indentWidth: null }); + }); + + test('accepts positive integer indentWidth', () => { + expect(validateFormatObject({ indentWidth: 4 })).toEqual({ indentWidth: 4 }); + }); + test('accepts boolean updateOrder', () => { expect(validateFormatObject({ updateOrder: true })).toEqual({ updateOrder: true }); }); @@ -634,6 +678,11 @@ describe('validateFormatObject', () => { expect(() => validateFormatObject({ useTabsForIndentation: 'yes' })).toThrow(TypeError); }); + test.each([0, -1, 1.5, '4'])('rejects invalid indentWidth value %p', (indentWidth) => { + expect(() => validateFormatObject({ indentWidth })).toThrow(TypeError); + expect(() => validateFormatObject({ indentWidth })).toThrow(/indentWidth/); + }); + test('rejects non-boolean updateOrder', () => { expect(() => validateFormatObject({ updateOrder: 'yes' })).toThrow(TypeError); expect(() => validateFormatObject({ updateOrder: 'yes' })).toThrow(/updateOrder/); diff --git a/src/inline-layout.ts b/src/inline-layout.ts new file mode 100644 index 00000000..bb62c52e --- /dev/null +++ b/src/inline-layout.ts @@ -0,0 +1,64 @@ +import { + InlineArray, + InlineTable, + InlineItem, + TreeNode, + isInlineArray, + isInlineTable, + isInlineItem +} from './cst'; +import { clonePosition } from './location'; +import { shiftNode } from './writer'; + +function hasOneItemPerLine(container: InlineArray | InlineTable): boolean { + return container.items.length > 0 + && container.loc.end.line - container.loc.start.line + 1 > container.items.length; +} + +function isInlineContainer(node: TreeNode): node is InlineArray | InlineTable { + return isInlineArray(node) || isInlineTable(node); +} + +export function prepareInsertedNestedInlineContainer( + parent: InlineArray | InlineTable, + child: TreeNode, + indentWidth: number +): void { + if (!isInlineItem(child) || !isInlineContainer(child.item) || !hasOneItemPerLine(parent)) return; + + const template = (parent.items as InlineItem[]).find(item => + isInlineContainer(item.item) + && item.item.type === child.item.type + && hasOneItemPerLine(item.item) + ); + if (!template || !isInlineContainer(template.item)) return; + + const childContainer = child.item; + const templateContainer = template.item; + const rowIndent = templateContainer.items.length > 0 + ? templateContainer.items[0].loc.start.column - templateContainer.loc.start.column + : indentWidth; + const firstRow = templateContainer.items.length > 0 + ? templateContainer.items[0].loc.start.line - templateContainer.loc.start.line + : 1; + const closingRows = templateContainer.items.length > 0 + ? templateContainer.loc.end.line - templateContainer.items[templateContainer.items.length - 1].loc.end.line + : 1; + const startLine = childContainer.loc.start.line; + const startColumn = childContainer.loc.start.column; + + let nextLine = startLine + firstRow; + for (const item of childContainer.items) { + shiftNode(item, { + lines: nextLine - item.loc.start.line, + columns: startColumn + rowIndent - item.loc.start.column + }); + nextLine = item.loc.end.line + 1; + } + + childContainer.loc.end = { + line: nextLine - 1 + closingRows, + column: templateContainer.loc.end.column + }; + child.loc = { start: clonePosition(childContainer.loc.start), end: clonePosition(childContainer.loc.end) }; +} \ No newline at end of file diff --git a/src/parse-js.ts b/src/parse-js.ts index 79eb53cb..91336df8 100644 --- a/src/parse-js.ts +++ b/src/parse-js.ts @@ -15,7 +15,8 @@ import { import { TomlFormat } from './toml-format'; import { formatTopLevel, formatEmptyLines, formatNestedTablesMultiline } from './formatter'; import { isObject, isString, isBigInt, isInteger, isFloat, isBoolean, isDate, isTemporal } from './utils'; -import { insert, applyWrites, applyBracketSpacing, applyTrailingComma, markStringifyRoot } from './writer'; +import { insert, applyWrites, applyBracketSpacing, applyTrailingComma, markStringifyRoot, setRootIndentWidth } from './writer'; +import { prepareInsertedNestedInlineContainer } from './inline-layout'; /** * Parses a JavaScript object into a CST Document, applying formatting options from TomlFormat. @@ -29,6 +30,7 @@ export default function parseJS(value: any, format: TomlFormat = TomlFormat.defa const document = generateDocument(); // Enable stringify fast paths in the writer — no comments, no removals. markStringifyRoot(document); + setRootIndentWidth(document, format.indentWidth); for (const item of walkObject(value, format)) { insert(document, document, item); } @@ -87,10 +89,12 @@ function walkValue(value: any, format: TomlFormat): Value { function walkInlineArray(value: Array, format: TomlFormat): InlineArray { const inline_array = generateInlineArray(); + setRootIndentWidth(inline_array, format.indentWidth); for (const element of value) { const item = walkValue(element, format); const inline_array_item = generateInlineItem(item); + prepareInsertedNestedInlineContainer(inline_array, inline_array_item, format.indentWidth); insert(inline_array, inline_array, inline_array_item); } applyBracketSpacing(inline_array, inline_array, format.bracketSpacing); @@ -105,6 +109,7 @@ function walkInlineTable(value: object, format: TomlFormat): InlineTable | Value if (!isObject(value)) return walkValue(value, format); const inline_table = generateInlineTable(); + setRootIndentWidth(inline_table, format.indentWidth); for (const item of walkObject(value, format)) { const inline_table_item = generateInlineItem(item); diff --git a/src/patch.ts b/src/patch.ts index b2cafd88..6bc777c5 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -35,7 +35,7 @@ import { import diff, { Change, ChangeType, Move, isAdd, isEdit, isRemove, isMove, isRename } from './diff'; import findByPath, { tryFindByPath, findParent, Path } from './find-by-path'; import { last, isInteger, arraysEqual, isTemporal, temporalToTomlString, isObject, stableStringify } from './utils'; -import { insert, replace, remove, applyWrites, applyBracketSpacing, hasInlineContainerNeedingTighten, deleteInlineContainerNeedingTighten, shiftNode, recalcContainerEnd, addExitOffset, markDirty, getPendingEnterOffsets, getExitOffsets } from './writer'; +import { insert, replace, remove, applyWrites, applyBracketSpacing, hasInlineContainerNeedingTighten, deleteInlineContainerNeedingTighten, shiftNode, recalcContainerEnd, addExitOffset, markDirty, getPendingEnterOffsets, getExitOffsets, setRootIndentWidth, setInlineIndentColumn } from './writer'; import { removeMember, moveInlineElement, findHostContainer, resolveSlots } from './comment-ownership'; import { applyKeyOrderMoves } from './update-order'; import { generateInlineItem, generateTable, generateTableArray, generateString, generateKey, generateKeyValue } from './generate'; @@ -54,6 +54,7 @@ import { import { getSpan } from './location'; import { stripLeadingBom, UTF8_BOM } from './decode-utf8'; import traverse from './traverse'; +import { prepareInsertedNestedInlineContainer } from './inline-layout'; /** * Applies modifications to a TOML document by comparing an existing TOML string with updated JavaScript data. @@ -241,6 +242,7 @@ function normalizeAotEntryComments(doc: Document): void { export function patchCst(existing_cst: CST, updated: any, format: TomlFormat): { tomlString: string; document: Document } { const items = [...existing_cst]; + updated = compactSparseArrays(updated); // Auto-detect Temporal in the updated JS object so that the internal // toJS() diff uses Temporal objects when the user provides them. @@ -265,6 +267,7 @@ export function patchCst(existing_cst: CST, updated: any, format: TomlFormat): { loc: { start: { line: 1, column: 0 }, end: { line: endLine, column: endColumn } }, items }; + setRootIndentWidth(existing_document, format.indentWidth); // Certain formatting options should not be applied to the updated document during patching, because it would // override the existing formatting too aggressively. For example, preferNestedTablesMultiline would @@ -376,6 +379,34 @@ function hasTemporal(value: any, seen: WeakSet = new WeakSet()): boolean return Object.values(value).some(child => hasTemporal(child, seen)); } +function compactSparseArrays(value: any): any { + if (Array.isArray(value)) { + let changed = false; + const compacted: any[] = []; + for (let index = 0; index < value.length; index++) { + if (!Object.prototype.hasOwnProperty.call(value, index)) { + changed = true; + continue; + } + const child = compactSparseArrays(value[index]); + changed ||= child !== value[index]; + compacted.push(child); + } + return changed ? compacted : value; + } + if (!isObject(value)) return value; + + let normalized = value; + for (const key of Object.keys(value)) { + const child = compactSparseArrays(value[key]); + if (child !== value[key]) { + if (normalized === value) normalized = { ...value }; + normalized[key] = child; + } + } + return normalized; +} + function reorder(changes: Change[]): Change[] { //Reorder deletions among themselves to avoid index issues when removing // multiple array elements. Remove higher indices first so earlier indices @@ -1079,6 +1110,15 @@ function applyChanges(original: Document, updated: Document, changes: Change[], // flush be paid just-in-time there rather than after every insertion — a patch touching // many containers once each (the common shape) then pays nothing. const insertedInlineContainers = new Set(); + const removedInlineTrailingCommas = new WeakMap(); + + function trailingCommaForAddedItem( + container: InlineArray | InlineTable, + detected: boolean + ): boolean { + const carried = removedInlineTrailingCommas.get(container); + return carried ?? detected; + } // Object-key Moves (updateOrder) are only collected here, not applied — they're relayed // out in one batch at the very end, after every other structural change in this patch has @@ -1285,6 +1325,10 @@ function applyChanges(original: Document, updated: Document, changes: Change[], } } + if (isInlineArray(parent) || isInlineTable(parent)) { + prepareInsertedNestedInlineContainer(parent, child, format.indentWidth); + } + if (isInlineArray(parent)) { const rowNode = tryFindByPath(original, parent_path); const rowContainer = tryFindByPath(original, parent_path.slice(0, -1)); @@ -1319,7 +1363,10 @@ function applyChanges(original: Document, updated: Document, changes: Change[], if (isTableArray(parent) || isInlineArray(parent) || isDocument(parent)) { // Special handling for InlineArray: preserve original trailing comma format if (isInlineArray(parent)) { - const originalHadTrailingCommas = arrayHadTrailingCommas(parent); + const originalHadTrailingCommas = trailingCommaForAddedItem( + parent, + arrayHadTrailingCommas(parent) + ); // If this is an InlineItem being added to an array, check its comma setting if (isInlineItem(child)) { // The child comes from the updated document with global format applied @@ -1473,7 +1520,10 @@ function applyChanges(original: Document, updated: Document, changes: Change[], } // Special handling for adding KeyValue to InlineTable // Preserve original trailing comma format - const originalHadTrailingCommas = tableHadTrailingCommas(parent); + const originalHadTrailingCommas = trailingCommaForAddedItem( + parent, + tableHadTrailingCommas(parent) + ); // InlineTable items must be wrapped in InlineItem if (isKeyValue(child)) { const inlineItem = generateInlineItem(child); @@ -1543,7 +1593,12 @@ function applyChanges(original: Document, updated: Document, changes: Change[], restoredKeySegments = true; } } - insert(original, parent, childToInsert); + const leadingLines = (isTable(parent) || isTableArray(parent)) + && parent.items.length > 0 + && parent.items.every(isComment) + ? 2 + : undefined; + insert(original, parent, childToInsert, undefined, undefined, undefined, leadingLines); if (restoredKeySegments) restoredInsertContainers.add(parent); } } @@ -2262,6 +2317,13 @@ function applyChanges(original: Document, updated: Document, changes: Change[], // string inside a nested array), preserve the existing item's comma // flag so the replacement doesn't introduce an unwanted trailing comma. if (isInlineItem(existing) && isInlineItem(replacement)) { + if (isString(existing.item) && isString(replacement.item)) { + preserveFormatting(existing.item, replacement.item); + replacement.loc = { + start: { ...replacement.item.loc.start }, + end: { ...replacement.item.loc.end } + }; + } replacement.comma = existing.comma; } @@ -2558,6 +2620,14 @@ function applyChanges(original: Document, updated: Document, changes: Change[], ? (parent.items as TreeNode[]).indexOf(node) : -1; const removedInlineComma = isInlineItem(node) ? (node as InlineItem).comma : undefined; + if (isInlineItem(node) && (isInlineArray(parent) || isInlineTable(parent))) { + setInlineIndentColumn(parent, node.loc.start.column); + } + if (isInlineItem(node) && (isInlineArray(parent) || isInlineTable(parent)) && + containerItemIndex === parent.items.length - 1 && + removedInlineComma !== undefined) { + removedInlineTrailingCommas.set(parent, removedInlineComma); + } // The bracket gap of a multiline inline container, captured BEFORE the // removal: removeMember flushes pending offsets for multiline inline // containers, so the post-removal fixup below can no longer measure diff --git a/src/to-toml.ts b/src/to-toml.ts index 6b9b9f69..d905b485 100644 --- a/src/to-toml.ts +++ b/src/to-toml.ts @@ -26,7 +26,11 @@ import { hasItems, hasItem, isKeyValue, - isInlineItem + isInlineItem, + isTable, + isTableArray, + isInlineTable, + isInlineArray } from './cst'; import { Location } from './location'; import { SPACE } from './tokenizer'; @@ -237,6 +241,25 @@ export function toTOMLCursor(cst: CST, format: TomlFormat): string { }; for (const root of roots) collectComments(root); + // Inline containers that are direct values of block-level key-values + // (Document, Table or TableArray). When such a container is emptied, its + // multiline closing bracket is preserved. Containers nested inside another + // inline container tighten to a single line instead. + const blockLevelInlineContainers = new WeakSet(); + const markBlockLevelInlineContainers = (items: TreeNode[]): void => { + for (const item of items) { + if (isKeyValue(item)) { + const value = item.value; + if (isInlineTable(value) || isInlineArray(value)) { + blockLevelInlineContainers.add(value); + } + } else if (isTable(item) || isTableArray(item)) { + markBlockLevelInlineContainers((item as Table | TableArray).items as TreeNode[]); + } + } + }; + markBlockLevelInlineContainers(roots); + const indentation = (width: number): string => (format.useTabsForIndentation ? '\t' : SPACE).repeat(width); @@ -257,7 +280,11 @@ export function toTOMLCursor(cst: CST, format: TomlFormat): string { append(format.newLine.repeat(targetLine - line)); if (targetColumn > 0) append(indentation(targetColumn)); } else if (targetLine === line && targetColumn > column) { - append(SPACE.repeat(targetColumn - column)); + // At the start of a line the gap up to the node is leading indentation, + // so it must follow the document's tab/spaces preference rather than + // always padding with spaces (a tab-indented first line would otherwise + // be re-emitted with a space). + append(column === 0 ? indentation(targetColumn) : SPACE.repeat(targetColumn - column)); } else if (chunks.length > 0 && (targetLine < line || (targetLine === line && targetColumn < column))) { append(format.newLine); @@ -456,10 +483,7 @@ export function toTOMLCursor(cst: CST, format: TomlFormat): string { if (source && container.range) { const original = source.slice(container.range[0], container.range[1]); const lastNewline = Math.max(original.lastIndexOf('\n'), original.lastIndexOf('\r')); - // Only preserve the original multiline closing bracket while the - // container still has items. Once emptied, the writer tightens it to - // a single line, and the deleted first child must not re-add a line. - if (lastNewline !== -1 && container.items.length > 0 && originalFirstChildStartedAfterOpener(container)) { + if (lastNewline !== -1 && blockLevelInlineContainers.has(container) && originalFirstChildStartedAfterOpener(container)) { const closingIndent = original.slice(lastNewline + 1, -1).match(/^[\t ]*/)?.[0] ?? ''; append(format.newLine + closingIndent); } diff --git a/src/toml-format.ts b/src/toml-format.ts index 6cdb39dc..1fe8e9a5 100644 --- a/src/toml-format.ts +++ b/src/toml-format.ts @@ -9,6 +9,7 @@ export const DEFAULT_BRACKET_SPACING = true; export const DEFAULT_INLINE_TABLE_START = 1; export const DEFAULT_TRUNCATE_ZERO_TIME_IN_DATES = false; export const DEFAULT_USE_TABS_FOR_INDENTATION = false; +export const DEFAULT_INDENT_WIDTH = 2; export const DEFAULT_MINIMUM_DECIMALS = 0; export const DEFAULT_LEADING_BOM = false; export const DEFAULT_UPDATE_ORDER = false; @@ -233,8 +234,73 @@ export function detectTabsForIndentation(str: string): boolean { } } - // Prefer tabs if we see more tabs than spaces - return tabCount > spaceCount; + // Prefer tabs when they are at least as common as spaces, provided there is tab evidence. + if (tabCount > 0) { + return tabCount >= spaceCount; + } + return false; // default to spaces if no evidence +} +/* + Detect the indentation width (number of spaces) used in the existing TOML by examining the CST. + + This function already assumes that the TOML document does not use tabs for indentation. + Do not use this function if tabs are used for indentation! + + It looks for the first indented line in the CST that is part of a nested + structure (like a table or array) and measures the number of leading spaces + to determine the indentation width. +*/ +function countLeadingSpaces(line: string): number { + let count = 0; + while (count < line.length && line[count] === ' ') count++; + return count; +} + +export function detectIndentWidth(tomlString: string, syntaxTree?: Iterable): number { + const lines = tomlString.split(/\r?\n/); + const widths: number[] = []; + const containers = new Set(['Table', 'TableArray', 'InlineTable', 'InlineArray']); + const rowNodes = new Set(['Table', 'TableArray', 'InlineTable', 'InlineArray', 'KeyValue', 'InlineItem', 'Comment']); + + const visit = (node: any, containerStartLine?: number): void => { + if (!node || typeof node !== 'object') return; + + if ( + containerStartLine !== undefined && + rowNodes.has(node.type) && + node.loc?.start?.line > containerStartLine + ) { + const line = lines[node.loc.start.line - 1] ?? ''; + const leadingSpaces = countLeadingSpaces(line); + if (leadingSpaces > 0) widths.push(leadingSpaces); + } + + const nextContainerStartLine = containers.has(node.type) && node.loc?.end?.line > node.loc?.start?.line + ? node.loc.start.line + : containerStartLine; + + if (Array.isArray(node.items)) { + for (const item of node.items) visit(item, nextContainerStartLine); + } + if (node.item) visit(node.item, nextContainerStartLine); + if (node.value) visit(node.value, nextContainerStartLine); + }; + + const nodes = Array.isArray(syntaxTree) + ? syntaxTree + : Array.from(syntaxTree ?? []); + for (const node of nodes) visit(node); + + if (widths.length > 0) return Math.min(...widths); + + // A document with only an indented root key has no nested CST row from which to + // infer the width. Use its content indentation as a fallback, ignoring blank lines + // and comments so a banner cannot become the detected indent. + const rootIndentWidths = lines + .filter(line => line.trim().length > 0 && !line.trimStart().startsWith('#')) + .map(countLeadingSpaces) + .filter(width => width > 0); + return rootIndentWidths.length > 0 ? Math.min(...rootIndentWidths) : DEFAULT_INDENT_WIDTH; } /** @@ -295,6 +361,8 @@ export function validateFormatObject(format: any): any { ? null : `expected non-negative integer or undefined, got ${typeof v}`, truncateZeroTimeInDates: isBool, useTabsForIndentation: isBool, + indentWidth: v => v == null || (typeof v === 'number' && Number.isInteger(v) && v > 0) + ? null : `expected positive integer or undefined, got ${typeof v}`, minimumDecimals: v => v == null || (typeof v === 'number' && Number.isInteger(v) && v >= 0) ? null : `expected non-negative integer or undefined, got ${typeof v}`, updateOrder: isBool, @@ -364,6 +432,7 @@ export function resolveTomlFormat(format: Partial | TomlFormat | und validatedFormat.minimumDecimals ?? fallbackFormat.minimumDecimals, validatedFormat.leadingBom ?? fallbackFormat.leadingBom, validatedFormat.updateOrder ?? fallbackFormat.updateOrder, + validatedFormat.indentWidth ?? fallbackFormat.indentWidth, ); } } else { @@ -456,6 +525,12 @@ export class TomlFormat { */ useTabsForIndentation?: boolean; + /** + * The number of columns used for one indentation level in generated multiline values. + * This is auto-detected when patching and defaults to two columns. + */ + indentWidth: number; + /** * The minimum number of decimal places to use when serializing JS numbers as TOML floats. * When greater than 0, plain JS integer values are serialized as TOML floats padded with @@ -499,7 +574,8 @@ export class TomlFormat { useTabsForIndentation?: boolean, minimumDecimals?: number, leadingBom?: boolean, - updateOrder?: boolean + updateOrder?: boolean, + indentWidth?: number ) { // Use provided values or fall back to defaults this.newLine = newLine == null ? DEFAULT_NEWLINE : normalizeNewLine(newLine); @@ -509,6 +585,7 @@ export class TomlFormat { this.inlineTableStart = inlineTableStart ?? DEFAULT_INLINE_TABLE_START; this.truncateZeroTimeInDates = truncateZeroTimeInDates ?? DEFAULT_TRUNCATE_ZERO_TIME_IN_DATES; this.useTabsForIndentation = useTabsForIndentation ?? DEFAULT_USE_TABS_FOR_INDENTATION; + this.indentWidth = this.useTabsForIndentation ? 1 : indentWidth ?? DEFAULT_INDENT_WIDTH; this.minimumDecimals = minimumDecimals ?? DEFAULT_MINIMUM_DECIMALS; this.leadingBom = leadingBom ?? DEFAULT_LEADING_BOM; this.updateOrder = updateOrder ?? DEFAULT_UPDATE_ORDER; @@ -540,7 +617,8 @@ export class TomlFormat { DEFAULT_USE_TABS_FOR_INDENTATION, DEFAULT_MINIMUM_DECIMALS, DEFAULT_LEADING_BOM, - DEFAULT_UPDATE_ORDER + DEFAULT_UPDATE_ORDER, + DEFAULT_INDENT_WIDTH ); } @@ -582,6 +660,7 @@ export class TomlFormat { format.leadingBom = hasLeadingBom(tomlString); // Strip the BOM before other formatting detection to avoid interference. const tomlContent = stripLeadingBom(tomlString); + let cstNodes: any[] = []; // Detect line ending style format.newLine = detectNewline(tomlContent); @@ -592,7 +671,7 @@ export class TomlFormat { // Get TOML syntax tree to detect comma and bracket spacing usage patterns try { // Materialize only when needed so we can traverse the same CST twice. - const cstNodes = Array.isArray(syntaxTree) + cstNodes = Array.isArray(syntaxTree) ? syntaxTree : Array.from(syntaxTree ?? parseTOML(tomlContent)); format.trailingComma = detectTrailingComma(cstNodes); @@ -606,6 +685,14 @@ export class TomlFormat { // Detect if tabs are used for indentation format.useTabsForIndentation = detectTabsForIndentation(tomlContent); + if (format.useTabsForIndentation) { + // If tabs are used, indentWidth is effectively 1 (one tab character) + format.indentWidth = 1; + } else { + // Otherwise, detect the number of spaces used for indentation + format.indentWidth = detectIndentWidth(tomlContent, cstNodes); + } + // inlineTableStart uses default value since auto-detection would require // complex analysis of nested table formatting preferences diff --git a/src/writer.ts b/src/writer.ts index ef0ffcca..c7bca912 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -33,6 +33,7 @@ import { Span, getSpan, clonePosition } from './location'; import { last } from './utils'; import traverse from './traverse'; import { getCommaSpace } from './inline-comma-space'; +import { DEFAULT_INDENT_WIDTH } from './toml-format'; import { markMutation, markTreeDirty } from './cst-source'; //////////////////////////////////////// @@ -57,6 +58,17 @@ const dirty_roots: WeakSet = new WeakSet(); // inserts are always sequential — letting us skip patch-only code paths. const stringifyRoots: WeakSet = new WeakSet(); +const rootIndentWidths: WeakMap = new WeakMap(); +const inlineIndentColumns: WeakMap = new WeakMap(); + +export function setRootIndentWidth(root: Root, indentWidth: number): void { + rootIndentWidths.set(root, indentWidth); +} + +export function setInlineIndentColumn(container: InlineArray | InlineTable, column: number): void { + inlineIndentColumns.set(container, column); +} + /** Mark a root as being built by parseJS — enables stringify fast paths. */ export function markStringifyRoot(root: Root): void { stringifyRoots.add(root); @@ -205,7 +217,7 @@ export function insert(root: Root, parent: TreeNode, child: TreeNode, index?: nu let shift: Span; let offset: Span; if (isInlineArray(parent) || isInlineTable(parent)) { - ({ shift, offset } = insertInline(parent, child as InlineItem, index)); + ({ shift, offset } = insertInline(parent, child as InlineItem, index, rootIndentWidths.get(root) ?? DEFAULT_INDENT_WIDTH)); } else if (forceInline && isDocument(parent)) { ({ shift, offset } = insertInlineAtRoot(parent, child, index)); } else { @@ -427,6 +439,7 @@ function calculateInlinePositioning( hasSeparatingCommaBefore?: boolean; hasSeparatingCommaAfter?: boolean; hasTrailingComma?: boolean; + indentWidth?: number; } = {} ): { shift: Span; offset: Span } { @@ -439,7 +452,8 @@ function calculateInlinePositioning( isLastElement = false, hasSeparatingCommaBefore = false, hasSeparatingCommaAfter = false, - hasTrailingComma = false + hasTrailingComma = false, + indentWidth = DEFAULT_INDENT_WIDTH } = options; // Store preceding node @@ -471,7 +485,14 @@ function calculateInlinePositioning( const following = (parent.items as TreeNode[]).find( (item, i) => i > index && !isComment(item) ); - if (following) start.column = following.loc.start.column; + if (following) { + start.column = following.loc.start.column; + } else if (parent.loc.end.line > parent.loc.start.line) { + const preservedColumn = (isInlineArray(parent) || isInlineTable(parent)) + ? inlineIndentColumns.get(parent) + : undefined; + start.column = preservedColumn ?? parent.loc.end.column - 1 + indentWidth; + } } let leading_lines = 0; @@ -542,7 +563,8 @@ function commaSpaceOf(container: InlineArray | InlineTable): number { function insertInline( parent: InlineArray | InlineTable, child: InlineItem, - index: number + index: number, + indentWidth: number ): { shift: Span; offset: Span } { if (!isInlineItem(child)) { throw new Error(`Incompatible child type "${(child as TreeNode).type}"`); @@ -607,7 +629,8 @@ function insertInline( isLastElement: is_last, hasSeparatingCommaBefore: has_separating_comma_before, hasSeparatingCommaAfter: has_separating_comma_after, - hasTrailingComma: has_trailing_comma + hasTrailingComma: has_trailing_comma, + indentWidth }); }