diff --git a/.vscode/launch.json b/.vscode/launch.json index 5de4cdf..18240b5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -58,7 +58,9 @@ "name": "Debug Current Test File", "program": "${workspaceFolder}/node_modules/jest/bin/jest", "args": [ - "${fileBasename}" + "${fileBasename}", + "--config", + "${workspaceFolder}/jest.config.json" ], "cwd": "${fileDirname}", "console": "integratedTerminal", diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4d392..8d26899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Patching: Editing a multiline basic string that uses a line ending backslash now preserves the original line-break structure and indentation ([#131](https://github.com/DecimalTurn/toml-patch/pull/131)). +- Patching: When the new value cannot be faithfully represented with a line ending backslash (e.g., values with leading or trailing whitespace), the format falls back to a regular multiline basic string to preserve content integrity ([#131](https://github.com/DecimalTurn/toml-patch/pull/131)). + ## [1.0.7] - 2026-04-08 ### Fixed diff --git a/demo.html b/demo.html index 1d68011..f521142 100644 --- a/demo.html +++ b/demo.html @@ -4,6 +4,7 @@ toml-patch — Interactive Demo + + + + +
+

toml-patch

+

Patch, parse, and stringify TOML while preserving comments and formatting

+
+ +
+ + +
+ + + +
+ + +
+
+ + +
+
+
+
Input TOML
+
+
+
+
Output JSON
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
Input JSON
+
+
+
+
Output TOML
+
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
Existing Document TOML
+
+
+
+
Updated Data JSON
+
+
+
+
Patched Result TOML
+
+
+
+
+ + +
+
+
+ + +
+

Get Started

+

Install via npm or use directly in the browser with unpkg.

+
+ $ npm install @decimalturn/toml-patch +
+

Or use in the browser with a module script:

+
+ <script type="module">
+   import * as TOML from
+     'https://unpkg.com/@decimalturn/toml-patch';
+ </script> +
+

+ Key features: comment & formatting preservation during patches, + full TOML v1.1 support, zero dependencies, works in Node.js and browsers. +

+
+ +
+ + + + + + + + +
+
+
JS Expression → Editor
+

Enter a JS expression that returns a string. Press Ctrl+Enter to apply. Executes JS — use only with trusted input.

+ +
+
+ + +
+
+
+ + + diff --git a/jest.config.json b/jest.config.json index 3b4b9bf..e45b76b 100644 --- a/jest.config.json +++ b/jest.config.json @@ -2,7 +2,7 @@ "testEnvironment": "node", "preset": "ts-jest", "testRegex": "/__tests__/(?!__js__).*\\.[jt]sx?$", - "testPathIgnorePatterns": ["/worktrees/"], + "testPathIgnorePatterns": ["/worktrees/", "/submodules/"], "snapshotFormat": { "escapeString": true, "printBasicPrototype": true diff --git a/jest.config.mjs b/jest.js.config.mjs similarity index 100% rename from jest.config.mjs rename to jest.js.config.mjs diff --git a/specs.config.cjs b/jest.specs.config.cjs similarity index 100% rename from specs.config.cjs rename to jest.specs.config.cjs diff --git a/package.json b/package.json index 202b59194..5ad7a77 100644 --- a/package.json +++ b/package.json @@ -37,18 +37,19 @@ "scripts": { "dev": "pnpm run typecheck && pnpm run build && npm-run-all2 --parallel test:all specs", "test": "jest --config jest.config.json", - "test:js": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.mjs", + "test:js": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.js.config.mjs", "test:playwright": "playwright test", "playwright:install": "playwright install --with-deps chromium", "test:all": "pnpm run test && pnpm run test:js && pnpm run test:playwright", "typecheck": "tsc", - "specs": "jest --config specs.config.cjs", + "specs": "jest --config jest.specs.config.cjs", "benchmark": "npm-run-all2 bench:*", "benchmark:ci": "npm-run-all2 \"bench:* -- --sample --versions latest\"", "bench:parse": "node benchmark/parse-benchmark.mjs", "bench:stringify": "node benchmark/stringify-benchmark.mjs", "profile": "node benchmark/profile.mjs", "build": "rimraf dist && rollup -c", + "build:demo": "node scripts/build-demo.mjs", "prepublishOnly": "pnpm run build", "lint": "oxlint", "lint:fix": "oxlint --fix" diff --git a/scripts/build-demo.mjs b/scripts/build-demo.mjs new file mode 100644 index 0000000..a77494d --- /dev/null +++ b/scripts/build-demo.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * Generates demo.html from dev_demo.html by replacing the local + * ./dist/toml-patch.js import with the unpkg CDN URL and updating + * the footer link accordingly. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +const src = join(root, 'dev_demo.html'); +const dest = join(root, 'demo.html'); + +let html = readFileSync(src, 'utf-8'); + +// Replace the commented-out unpkg line + local import with just the unpkg import +html = html.replace( + /(\s*)\/\/ import \* as TOML from 'https:\/\/unpkg\.com\/@decimalturn\/toml-patch';\n\s*import \* as TOML from '\.\/dist\/toml-patch\.js';/, + "$1import * as TOML from 'https://unpkg.com/@decimalturn/toml-patch';" +); + +// Update the footer: remove the local build link, activate the unpkg link +html = html.replace( + /·\s*\s*\n\s*·\s*Loaded from local build<\/a>/, + '· $1' +); + +writeFileSync(dest, html, 'utf-8'); +console.log(`demo.html written from dev_demo.html`); diff --git a/src/__tests__/generate.test.ts b/src/__tests__/generate.test.ts index 0532ee6..099ba01 100644 --- a/src/__tests__/generate.test.ts +++ b/src/__tests__/generate.test.ts @@ -116,4 +116,36 @@ describe('generateString', () => { expect(result.value).toBe('Triple """quotes"""'); }); }); + + describe('endLocation for multiline strings', () => { + // The end column matters when something follows the closing """ on the same line + // (e.g. inside a TOML v1.1 multiline inline table: `a = """…\nfoo""", b = "x"`). + // A wrong column causes subsequent nodes on that line to be shifted by the wrong + // amount in the writer, corrupting the patched output. + + test('should set end column to last line length for no-leading-newline MLBS', () => { + // existingRaw: """short\nlonger text""" — closing """ is NOT on its own line. + // Old code always used column: 3 (delimiter length). Correct value is + // len("longer text\"\"\"") = 14, so when patched to a shorter value the + // column delta would be off by (14 - 3) = 11. + const existingRaw = '"""short\n' + 'longer text"""'; + const node = generateString('a\nb', existingRaw); + // New raw: """a\nb""" → last line is 'b"""', length = 4 + expect(node.raw).toEqual('"""a\nb"""'); + expect(node.loc.end.line).toEqual(2); + expect(node.loc.end.column).toEqual(4); // 'b"""'.length = 4, NOT 3 + }); + + test('should set end column to delimiter length when """ closes on its own line', () => { + // existingRaw: """\ncontent\n""" — closing """ IS on its own line. + // Here column: 3 IS correct (the last line is just '"""'). + // The value must end with \n so the generated raw also ends \n""" (closing on its own line). + const existingRaw = '"""\n' + 'content\n' + '"""'; + const node = generateString('new content\n', existingRaw); + // New raw: """\nnew content\n""" → last line is '"""', length = 3 + expect(node.raw).toEqual('"""\nnew content\n"""'); + expect(node.loc.end.line).toEqual(3); + expect(node.loc.end.column).toEqual(3); // '"""'.length = 3 + }); + }); }); diff --git a/src/__tests__/patch.ls.test.ts b/src/__tests__/patch.ls.test.ts new file mode 100644 index 0000000..e101c0c --- /dev/null +++ b/src/__tests__/patch.ls.test.ts @@ -0,0 +1,78 @@ +import patch from '../patch'; +import { parse } from '../'; +import dedent from 'dedent'; + +/** + * Tests for patching literal strings + */ + +describe('literal strings', () => { + const existing = dedent` + [paths] + output = 'C:\Users\Alice\Documents\Reports' + `; + + test('existing should parse as expected', () => { + const obj = parse(existing); + expect(obj.paths.output).toEqual('C:\\Users\\Alice\\Documents\\Reports'); + }); + + test('should preserve single-quote style when changing a subdirectory', () => { + const obj = parse(existing); + obj.paths.output = 'C:\\Users\\Bob\\Documents\\Reports'; + const patched = patch(existing, obj); + + expect(patched).toEqual( + dedent` + [paths] + output = 'C:\Users\Bob\Documents\Reports' + ` + ); + expect(parse(patched).paths.output).toEqual('C:\\Users\\Bob\\Documents\\Reports'); + }); + + test('should preserve single-quote style when clearing the value to an empty string', () => { + const obj = parse(existing); + obj.paths.output = ''; + const patched = patch(existing, obj); + + expect(patched).toEqual( + dedent` + [paths] + output = '' + ` + ); + expect(parse(patched).paths.output).toEqual(''); + }); + + test('should convert to multiline literal string when the new value contains a single quote', () => { + const obj = parse(existing); + obj.paths.output = "C:\\Users\\Alice's Documents\\Reports"; + const patched = patch(existing, obj); + + // Single-line literal strings cannot contain ' — should fall back to MLLS on one line + expect(patched).toEqual( + dedent` + [paths] + output = '''C:\Users\Alice's Documents\Reports''' + ` + ); + expect(parse(patched).paths.output).toEqual("C:\\Users\\Alice's Documents\\Reports"); + }); + + test('should fallback to basic string when triple single quotes are present', () => { + const obj = parse(existing); + obj.paths.output = "C:\\Users\\Alice'''s Documents\\Reports"; + const patched = patch(existing, obj); + + // Single-line literal strings cannot contain ' — should fall back to MLLS on one line + expect(patched).toEqual( + dedent` + [paths] + output = "C:\\Users\\Alice'''s Documents\\Reports" + ` + ); + expect(parse(patched).paths.output).toEqual("C:\\Users\\Alice'''s Documents\\Reports"); + }); + +}); \ No newline at end of file diff --git a/src/__tests__/patch.mlbs.leb.test.ts b/src/__tests__/patch.mlbs.leb.test.ts new file mode 100644 index 0000000..6ceea28 --- /dev/null +++ b/src/__tests__/patch.mlbs.leb.test.ts @@ -0,0 +1,1454 @@ +import patch from '../patch'; +import { parse } from '..'; +import { LocalDate, LocalTime, LocalDateTime, OffsetDateTime } from '../parse-toml'; +import { example } from '../__fixtures__'; +import dedent from 'dedent'; + +/** + * Tests for patching multiline basic strings (MLBS) with line ending backslashes (LEB)) + */ + + +describe('Regex value built inside MLBS with LEB', () => { + + // Fixture inspiration: https://github.com/eth-easl/mixtera/blob/c861dd886065a9161e20654cd61998c713f7d5c7/black.toml + const existing = + 'regexValue = """\\' + '\n' + + ' .*/*\\\\.pyi|\\' + '\n' + + ' .*/*\\\\_grpc.py|\\' + '\n' + + ' .*/*\\\\_pb2.py|\\' + '\n' + + ' .*/benchmark/.*|\\' + '\n' + + ' .*/build/.*\\' + '\n' + + '"""' + '\n'; + + test('should allow small edits and preserve style', () => { + const obj = parse(existing); + obj['regexValue'] = obj['regexValue'].replace("benchmark", "benchmarks"); + + const patched = patch(existing, obj); + + expect(patched).toEqual( + 'regexValue = """\\' + '\n' + + ' .*/*\\\\.pyi|\\' + '\n' + + ' .*/*\\\\_grpc.py|\\' + '\n' + + ' .*/*\\\\_pb2.py|\\' + '\n' + + ' .*/benchmarks/.*|\\' + '\n' + + ' .*/build/.*\\' + '\n' + + '"""' + '\n' + ); + expect(parse(patched)['regexValue']).toEqual(obj['regexValue']); + }); + + test('should allow row deletion and preserve style', () => { + const obj = parse(existing); + // Reminder: you don't need to worry about '\\' in the string content when doing replacements. + // these are LEB (line ending backslash) and will be removed during TOML parsing as well as all the whitepsace after it. + obj['regexValue'] = obj['regexValue'].replace('.*/benchmark/.*|' , ""); + + const patched = patch(existing, obj); + + expect(patched).toEqual( + 'regexValue = """\\' + '\n' + + ' .*/*\\\\.pyi|\\' + '\n' + + ' .*/*\\\\_grpc.py|\\' + '\n' + + ' .*/*\\\\_pb2.py|\\' + '\n' + + ' .*/build/.*\\' + '\n' + + '"""' + '\n' + ); + expect(parse(patched)['regexValue']).toEqual(obj['regexValue']); + }); + + test('should allow row insertion and preserve style', () => { + const obj = parse(existing); + obj['regexValue'] = obj['regexValue'].replace('.*/benchmark/.*|' , ".*/benchmark/.*|.*/subdir/.*|"); + + const patched = patch(existing, obj); + + expect(patched).toEqual( + 'regexValue = """\\' + '\n' + + ' .*/*\\\\.pyi|\\' + '\n' + + ' .*/*\\\\_grpc.py|\\' + '\n' + + ' .*/*\\\\_pb2.py|\\' + '\n' + + ' .*/benchmark/.*|\\' + '\n' + + ' .*/subdir/.*|\\' + '\n' + + ' .*/build/.*\\' + '\n' + + '"""' + '\n' + ); + expect(parse(patched)['regexValue']).toEqual(obj['regexValue']); + }); + +}); + +describe('List of items built inside MLBS with LEB', () => { + + const existing = + 'myList = """\\' + '\n' + + ' I like \\' + '\n' + + ' cats, \\' + '\n' + + ' dogs, \\' + '\n' + + ' and birds.\\' + '\n' + + '"""' + '\n'; + + test('should allow small edits and preserve style', () => { + const obj = parse(existing); + obj['myList'] = obj['myList'].replace("cats", "turtles"); + + const patched = patch(existing, obj); + + expect(patched).toEqual( + 'myList = """\\' + '\n' + + ' I like \\' + '\n' + + ' turtles, \\' + '\n' + + ' dogs, \\' + '\n' + + ' and birds.\\' + '\n' + + '"""' + '\n' + ); + expect(parse(patched)['myList']).toEqual(obj['myList']); + }); + + test('should allow row deletion and preserve style', () => { + const obj = parse(existing); + obj['myList'] = obj['myList'].replace('dogs, ' , ""); + + const patched = patch(existing, obj); + + expect(patched).toEqual( + 'myList = """\\' + '\n' + + ' I like \\' + '\n' + + ' cats, \\' + '\n' + + ' and birds.\\' + '\n' + + '"""' + '\n' + ); + expect(parse(patched)['myList']).toEqual(obj['myList']); + }); + + test('should allow row insertion and preserve style', () => { + const obj = parse(existing); + obj['myList'] = obj['myList'].replace('dogs, ' , 'dogs, turtles, '); + + const patched = patch(existing, obj); + + expect(patched).toEqual( + 'myList = """\\' + '\n' + + ' I like \\' + '\n' + + ' cats, \\' + '\n' + + ' dogs, \\' + '\n' + + ' turtles, \\' + '\n' + + ' and birds.\\' + '\n' + + '"""' + '\n' + ); + expect(parse(patched)['myList']).toEqual(obj['myList']); + }); + +}); + + + +test('should preserve line-continuation in multiline basic strings - Same length', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The swift brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' The swift brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The swift brown fox jumps over the lazy dog.'); +}); + +test('should preserve line-continuation in multiline basic strings - Slightly smaller length, second line preserved intact', () => { + // "quick" → "slow" frees 2 chars on line 1 but the second line must not change. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The slow brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // Line 2 "jumps over the lazy dog." is unchanged and must stay verbatim. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' The slow brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The slow brown fox jumps over the lazy dog.'); +}); + +test('should preserve line-continuation in multiline basic strings - bigger length causing small overflow', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The superfast brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' The superfast brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The superfast brown fox jumps over the lazy dog.'); +}); + +test('should preserve line-continuation in multiline basic strings - even bigger length causing big overflow', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + // 19 chars + ' jumps over the lazy dog."""\n'; // 24 chars + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The superduperultrafast brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // "superduperultrafast" forces a mid-line split; the unchanged second line is preserved. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' The superduperultrafast \\' + '\n' + + ' brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The superduperultrafast brown fox jumps over the lazy dog.'); +}); + +test('should not treat even number of trailing backslashes as line-continuation', () => { + // A line ending with \\ is two literal backslashes, not a continuation. + // The segment parser must count trailing backslashes and only flag odd counts. + const existing = + '[cfg]\n' + + 'path = """\\' + '\n' + + ' C:\\\\Users\\\\Alice \\' + '\n' + + ' is cool."""\n'; + + const value = parse(existing); + // \\ in raw TOML basic string = one literal backslash, so the decoded value is: + // "C:\Users\Alice is cool." + expect(value.cfg.path).toEqual('C:\\Users\\Alice is cool.'); + + value.cfg.path = 'C:\\Users\\Bob is cool.'; + const patched = patch(existing, value); + + // The double-backslash lines are literal backslashes (even count = not continuation). + // "Alice" → "Bob" frees space on line 1; line 2 "is cool." must stay verbatim. + expect(patched).toEqual( + '[cfg]\n' + + 'path = """\\' + '\n' + + ' C:\\\\Users\\\\Bob \\' + '\n' + + ' is cool."""\n' + ); + expect(parse(patched).cfg.path).toEqual('C:\\Users\\Bob is cool.'); +}); + +test('should preserve empty line between content lines in line-continuation multiline basic strings', () => { + // A blank line between continuation segments is consumed by TOML's line-continuation + // whitespace trimming, but the raw format should be preserved after patching. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + // Blank line is whitespace consumed by line-continuation, so decoded value has no gap + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The swift brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // The empty line between the two content segments is preserved + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' The swift brown fox \\' + '\n' + + '\n' + + ' jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The swift brown fox jumps over the lazy dog.'); +}); + +test('should preserve whitespace-only blank line between content lines in line-continuation multiline basic strings', () => { + // A whitespace-only (e.g. " ") line should also round-trip with its original spaces. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' \n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The swift brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // The whitespace-only blank line is preserved with its original spaces + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' The swift brown fox \\' + '\n' + + ' \n' + + ' jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The swift brown fox jumps over the lazy dog.'); +}); + +test('should not treat backslash in literal multiline string as line-continuation when converting to basic', () => { + // If the new value contains ''' the string is converted from literal (''') to basic ("""). + // Backslashes in the original literal body were literal characters — line-continuation + // detection must be gated on the original delimiter, not the post-conversion isLiteral flag. + const existing = + '[cfg]\n' + + "path = '''\n" + + "C:\\Users\\Alice\n" + + "'''\n"; + + const value = parse(existing); + // In a literal string backslashes are verbatim, not escape sequences + expect(value.cfg.path).toEqual('C:\\Users\\Alice\n'); + + // New value contains ''' so conversion to basic multiline """ is required + value.cfg.path = "uses '''triple''' quotes\n"; + const patched = patch(existing, value); + + // Converts to basic string; single quotes need no escaping in basic strings; + // no line-continuation logic is applied (original was literal, not basic) + expect(patched).toEqual( + '[cfg]\n' + + "path = \"\"\"\n" + + "uses '''triple''' quotes\n" + + "\"\"\"\n" + ); + expect(parse(patched).cfg.path).toEqual("uses '''triple''' quotes\n"); +}); + +test('should preserve multiple consecutive spaces between words in line-continuation multiline strings', () => { + // Multiple spaces between words are preserved because the tokenizer splits on + // space runs (/\S+| +/g) and appends the run as trailing WS before the backslash + // when the next word doesn't fit. TOML preserves trailing WS before line-continuation. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + // New value contains double spaces between words + value.description.text = 'The quick brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // Double spaces are preserved: the space run at each line-break point becomes + // trailing whitespace before the backslash. Decoded: "The quick brown fox jumps over the lazy dog." + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy \\' + '\n' + + ' dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The quick brown fox jumps over the lazy dog.'); +}); + +test('should handle patching line-continuation multiline string to empty string', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + value.description.text = ''; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' """\n' + ); + expect(parse(patched).description.text).toEqual(''); +}); + +test('should handle patching line-continuation multiline string to a single character', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + value.description.text = 'x'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' x"""\n' + ); + expect(parse(patched).description.text).toEqual('x'); +}); + +test('should handle patching line-continuation multiline string to a single word', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + value.description.text = 'Hello'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' Hello"""\n' + ); + expect(parse(patched).description.text).toEqual('Hello'); +}); + +test('should handle patching line-continuation multiline string with no whitespace in new value', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + // A long string with no spaces — cannot break at word boundaries, so it stays on one line + value.description.text = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' abcdefghijklmnopqrstuvwxyz0123456789"""\n' + ); + expect(parse(patched).description.text).toEqual('abcdefghijklmnopqrstuvwxyz0123456789'); +}); + +test('should handle patching line-continuation multiline string with a single very long word exceeding maxLength', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' Hello \\' + '\n' + + ' world."""\n'; + + const value = parse(existing); + // "Supercalifragilisticexpialidocious" is 34 chars — far exceeds maxLength of 5 + value.description.text = 'Supercalifragilisticexpialidocious rest'; + const patched = patch(existing, value); + + // The word overflows maxLength but must still be emitted (at least one word per line) + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' Supercalifragilisticexpialidocious \\' + '\n' + + ' rest"""\n' + ); + expect(parse(patched).description.text).toEqual('Supercalifragilisticexpialidocious rest'); +}); + +test('should handle patching line-continuation multiline string where original value is all whitespace', () => { + // The original decoded value is just spaces (consumed by line-continuation trimming) + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' \\' + '\n' + + ' """\n'; + + const value = parse(existing); + // Line-continuation trims all whitespace, so the decoded value is empty + expect(value.description.text).toEqual(''); + + value.description.text = 'Hello world'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' Hello \\' + '\n' + + ' world"""\n' + ); + expect(parse(patched).description.text).toEqual('Hello world'); +}); + +test('should handle patching line-continuation multiline string to all whitespace', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + // New value is only spaces — cannot be represented in line-continuation format + // because line-continuation trims all whitespace. Falls back to regular multiline. + value.description.text = ' '; + const patched = patch(existing, value); + + // Falls back to regular multiline format to preserve the whitespace value + expect(patched).toEqual( + '[description]\n' + + 'text = """ """\n' + ); + // Verify round-trip: parsing the patched output should recover the whitespace value + expect(parse(patched)).toEqual({ description: { text: ' ' } }); +}); + +// Mixed line ending backslash + literal newline tests. +// A multiline basic string may have some lines with a line ending backslash and other +// lines without one. The lines that lack a backslash contribute a literal newline to +// the decoded value. Since the decoded value therefore contains a '\n', +// detectLineContinuation correctly returns false and the formatter falls back to a +// regular multiline string — preserving content even though the structural formatting +// is not preserved. + +test('should preserve line ending backslash with literal line break for mixed LC/literal-newline source', () => { + // """\ opening, middle line has backslash, last content line does NOT. + // The decoded value therefore contains a literal newline. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog\n' + + ' and this was just white space."""\n'; + + const value = parse(existing); + // Line ending backslash joins first two lines; third line starts after literal \n + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog\n and this was just white space.'); + + value.description.text = 'Hello world\nand goodbye world'; + const patched = patch(existing, value); + + // Original style contains a literal newline in source, so preserve it literally + // instead of encoding as a \n escape. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' Hello world\n' + + 'and goodbye world"""\n' + ); + expect(parse(patched).description.text).toEqual('Hello world\nand goodbye world'); +}); + +test('should preserve literal line break when only opening line has continuation marker', () => { + // """\ is immediately followed by content lines WITHOUT backslashes. + // Only the opening backslash trims the first newline; the rest are literal newlines. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + // Opening backslash trims to first content line, then literal newline appears + expect(value.description.text).toEqual('The quick brown fox\n jumps over the lazy dog.'); + + value.description.text = 'A swift brown fox\njumps high.'; + const patched = patch(existing, value); + + // Preserve the opening LC marker and keep the semantic newline as a literal + // source line break instead of a \n escape. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' A swift brown fox\n' + + 'jumps high."""\n' + ); + expect(parse(patched).description.text).toEqual('A swift brown fox\njumps high.'); +}); + +test('should preserve line ending backslash for """ format regardless of leading whitespace in new value', () => { + // The opening is """ (no backslash), so first content line's indent is part of the + // decoded value. newFirstIndent is derived from the new value's own leading whitespace, + // stripped before packing and reattached by reassembly — so any leading whitespace is + // supported without falling back. + const existing = + '[description]\n' + + 'text = """\n' + + ' The quick brown fox \\\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual(' The quick brown fox jumps over the lazy dog.'); + + // Same 2-space indent — LC format preserved + value.description.text = ' The swift brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\n' + + ' The swift brown fox \\\n' + + ' jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual(' The swift brown fox jumps over the lazy dog.'); +}); + +test('should preserve line ending backslash for """ format when new value has different leading whitespace', () => { + // newFirstIndent is derived from the new value itself, so the first line's structural + // indent adapts to match the new value's leading whitespace. + const existing = + '[description]\n' + + 'text = """\n' + + ' The quick brown fox \\\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual(' The quick brown fox jumps over the lazy dog.'); + + // New value has no leading spaces — newFirstIndent = "", first line gets no indent + value.description.text = 'The swift brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // LC format preserved, first indent removed to match the new value + expect(patched).toEqual( + '[description]\n' + + 'text = """\n' + + 'The swift brown fox \\\n' + + ' jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The swift brown fox jumps over the lazy dog.'); +}); + +test('should preserve line ending backslash for """ format when first segment has no indent', () => { + // Same structure as above, but the new value does NOT start with spaces AND the + // original first segment has no indent. rebuildLineContinuation preserves LC. + const existing = + '[description]\n' + + 'text = """\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The swift brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // LC format preserved — first segment has no indent, no leading-space issue + expect(patched).toEqual( + '[description]\n' + + 'text = """\n' + + 'The swift brown fox \\\n' + + 'jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The swift brown fox jumps over the lazy dog.'); +}); + +test('should preserve line ending backslash for multi-paragraph string with blank line between paragraphs', () => { + // Each paragraph's text lines use LC to join them — no real newline within a paragraph. + // A blank line (no backslash) between the two blocks is a literal \n\n in the decoded value. + // Note: uses `"""\` (backslash opening), not `"""`, so no leading-indent issue. + const existing = + '[doc]\n' + + 'text = """\\' + '\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog.\n' + + '\n' + + 'The second paragraph \\\n' + + 'also has some content."""\n'; + + const decoded = parse(existing).doc.text; + expect(decoded).toEqual('The quick brown fox jumps over the lazy dog.\n\nThe second paragraph also has some content.'); + + const value = parse(existing); + value.doc.text = 'A swift brown fox jumps over the lazy dog.\n\nThe second paragraph is different now.'; + const patched = patch(existing, value); + + // Paragraph style detected from original — paragraph breaks become actual blank TOML lines. + // Each paragraph is word-packed independently, keeping "dog." on the correct paragraph. + expect(patched).toEqual( + '[doc]\n' + + 'text = """\\' + '\n' + + 'A swift brown fox jumps \\\n' + + 'over the lazy dog.\n' + + '\n' + + 'The second paragraph is \\\n' + + 'different now."""\n' + ); + expect(parse(patched).doc.text).toEqual('A swift brown fox jumps over the lazy dog.\n\nThe second paragraph is different now.'); +}); + +test('should collapse multi-paragraph LC string to one paragraph when new value has no newlines', () => { + const existing = + '[doc]\n' + + 'text = """\\' + '\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog.\n' + + '\n' + + 'The second paragraph \\\n' + + 'also has some content."""\n'; + + const value = parse(existing); + value.doc.text = 'A swift brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // No \n\n in new value — blank-line paragraph style not triggered; single-group pack. + // The blank line from the original is dropped since it has no corresponding paragraph break. + expect(patched).toEqual( + '[doc]\n' + + 'text = """\\' + '\n' + + 'A swift brown fox jumps \\\n' + + 'over the lazy dog."""\n' + ); + expect(parse(patched).doc.text).toEqual('A swift brown fox jumps over the lazy dog.'); +}); + +test('should expand multi-paragraph LC string to three paragraphs when new value has two newlines', () => { + const existing = + '[doc]\n' + + 'text = """\\' + '\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog.\n' + + '\n' + + 'The second paragraph \\\n' + + 'also has some content."""\n'; + + const value = parse(existing); + value.doc.text = 'First paragraph content.\n\nSecond paragraph content.\n\nThird paragraph content.'; + const patched = patch(existing, value); + + // Three paragraphs separated by blank lines. Each paragraph is packed independently. + // "First paragraph content." fits on one line so it has no backslash (paragraph end). + // "Second paragraph content." splits into two LC lines then a blank. + // "Third paragraph content." fits on one line as the global tail. + expect(patched).toEqual( + '[doc]\n' + + 'text = """\\' + '\n' + + 'First paragraph content.\n' + + '\n' + + 'Second paragraph \\\n' + + 'content.\n' + + '\n' + + 'Third paragraph content."""\n' + ); + expect(parse(patched).doc.text).toEqual('First paragraph content.\n\nSecond paragraph content.\n\nThird paragraph content.'); +}); + + + +test('should preserve literal single line break style when original uses real line breaks', () => { + const existing = + '[description]\n' + + 'text = """\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog.\n' + + '\n' + + 'And then what?\n' + + 'Nothing, really."""\n'; + + const value = parse(existing); + value.description.text = 'The quick brown fox jumps over the lazy dog.\n\nAnd then what?\nNothing, really, but you know.'; + const patched = patch(existing, value); + + // Original style uses real line breaks (including a single line break after '?'). + // Preserve that style and avoid introducing \n escapes when a literal line break can + // represent the same value naturally. + expect(patched).toEqual( + '[description]\n' + + 'text = """\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog.\n' + + '\n' + + 'And then what?\n' + + 'Nothing, really, but you \\\n' + + 'know."""\n' + ); + expect(parse(patched).description.text).toEqual( + 'The quick brown fox jumps over the lazy dog.\n\nAnd then what?\nNothing, really, but you know.' + ); +}); + +test('should preserve spaced blank line and avoid orphan continuation line when patching quick to swift', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\\n' + + ' jumps over the lazy dog.\n' + + ' \n' + + ' Then it jumped into the river."""\n'; + + const value = parse(existing); + value.description.text = value.description.text.replace('quick', 'swift'); + const patched = patch(existing, value); + + // Keep paragraph style and whitespace-only separator line from original. The second + // logical line should not create an orphan ` \\` continuation line. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' The swift brown fox jumps over \\\n' + + ' the lazy dog.\n' + + ' \n' + + ' Then it jumped into the river."""\n' + ); + expect(parse(patched).description.text).toEqual('The swift brown fox jumps over the lazy dog.\n \n Then it jumped into the river.'); +}); + +test('should handle massive underflow from many segments to one word', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' one \\' + '\n' + + ' two \\' + '\n' + + ' three \\' + '\n' + + ' four \\' + '\n' + + ' five."""\n'; + + const value = parse(existing); + value.description.text = 'hi.'; + const patched = patch(existing, value); + + // Collapses to a single content line + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' hi."""\n' + ); + expect(parse(patched).description.text).toEqual('hi.'); +}); + +test('should handle massive overflow from one segment to many words', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' hi."""\n'; + + const value = parse(existing); + // maxLength is 2 ("hi" = 2 chars), so each word gets its own line + value.description.text = 'aa bb cc dd ee ff'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' aa \\' + '\n' + + ' bb \\' + '\n' + + ' cc \\' + '\n' + + ' dd \\' + '\n' + + ' ee \\' + '\n' + + ' ff"""\n' + ); + expect(parse(patched).description.text).toEqual('aa bb cc dd ee ff'); +}); + +test('should fall back to regular multiline when patching line-continuation string with leading whitespace', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + // Leading spaces cannot survive line-continuation format because the `"""\` + // continuation trims all whitespace (indent + content) on the first content line. + value.description.text = ' hello world'; + const patched = patch(existing, value); + + // Falls back to regular multiline to preserve the leading spaces + expect(patched).toEqual( + '[description]\n' + + 'text = """ hello world"""\n' + ); + expect(parse(patched).description.text).toEqual(' hello world'); +}); + +test('should fall back to regular multiline when patching line-continuation string with trailing whitespace mismatch', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + // Original tail has trailingWs = '' (no trailing space before """). + // Adding trailing spaces to the new value would be lost because the reassembly + // inherits the original tail's trailingWs, silently dropping the trailing spaces. + value.description.text = 'hello world '; + const patched = patch(existing, value); + + // Falls back to regular multiline to preserve the trailing spaces + expect(patched).toEqual( + '[description]\n' + + 'text = """hello world """\n' + ); + expect(parse(patched).description.text).toEqual('hello world '); +}); + +test('should fall back to regular multiline when patching line-continuation string with both leading and trailing whitespace', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + value.description.text = ' hello world '; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """ hello world """\n' + ); + expect(parse(patched).description.text).toEqual(' hello world '); +}); + +test('should fall back to regular multiline when patching line-continuation string with a single space', () => { + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + value.description.text = ' '; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """ """\n' + ); + expect(parse(patched).description.text).toEqual(' '); +}); + +test('should preserve line-continuation when trailing space count matches original tail', () => { + // The original tail segment has trailingWs = ' ' (one space before \). + // A new value that also ends with exactly one space should stay in + // line-continuation format because the tail's trailingWs matches. + const existing = + 'tbl = {\n' + + ' val = """\\' + '\n' + + ' Hello \\' + '\n' + + ' """\n' + + '}\n'; + + const value = parse(existing); + expect(value.tbl.val).toEqual('Hello '); + + value.tbl.val = 'Goodbye '; + const patched = patch(existing, value); + + // Stays in line-continuation format + expect(patched).toEqual( + 'tbl = {\n' + + ' val = """\\' + '\n' + + ' Goodbye \\' + '\n' + + ' """\n' + + '}\n' + ); + expect(parse(patched).tbl.val).toEqual('Goodbye '); +}); + +test('should fall back when removing trailing space from value that originally had one', () => { + // Original value has a trailing space ("Hello "). Patching to a value without + // trailing space would still inject the original tail's trailingWs, corrupting the value. + const existing = + 'tbl = {\n' + + ' val = """\\' + '\n' + + ' Hello \\' + '\n' + + ' """\n' + + '}\n'; + + const value = parse(existing); + expect(value.tbl.val).toEqual('Hello '); + + value.tbl.val = 'Goodbye'; + const patched = patch(existing, value); + + // Falls back to regular multiline — otherwise tail trailingWs would add a space + expect(patched).toEqual( + 'tbl = {\n' + + ' val = """Goodbye"""\n' + + '}\n' + ); + expect(parse(patched).tbl.val).toEqual('Goodbye'); +}); + +test('should preserve earlier lines when only the end of a line-continuation string is removed', () => { + // Removing the last word "Sup" from a 3-line LC string should keep lines 1 and 2 + // exactly as they were, not re-flow the whole string. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog. \\\n' + + 'Sup"""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog. Sup'); + + value.description.text = 'The quick brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // Line 1 ("The quick brown fox \") must stay unchanged. + // Line 2 loses its trailing space and backslash, becoming the tail. + // "Sup" is removed entirely. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The quick brown fox jumps over the lazy dog.'); +}); + +test('should preserve earlier lines when the last word of a continuation string is replaced', () => { + // Replacing just the last word keeps the unchanged leading lines intact. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog.\\\n' + + 'Sup"""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.Sup'); + + value.description.text = 'The quick brown fox jumps over the lazy dog.End'; + const patched = patch(existing, value); + + // Line 1 ("The quick brown fox \") stays unchanged. + // "Sup" on line 3 is replaced with "End". + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + 'The quick brown fox \\\n' + + 'jumps over the lazy dog.\\\n' + + 'End"""\n' + ); + expect(parse(patched).description.text).toEqual('The quick brown fox jumps over the lazy dog.End'); +}); + +// Underflow tests — guard against words from later (longer) lines being pulled up into +// earlier (shorter) lines when a minimal change is made, caused by maxLength being +// measured from the longest line in the original string. + +test('should not reflow later lines when replacing a same-length word on a short first line', () => { + // maxLength is determined by the long second line ("The quick brown fox." = 20). + // Line 1 is intentionally kept short by the original author. + // Swapping "Hi" for same-length "Yo" should only touch line 1. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + 'Hi \\' + '\n' + + 'The quick brown fox."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('Hi The quick brown fox.'); + + value.description.text = 'Yo The quick brown fox.'; + const patched = patch(existing, value); + + // Only the first word changes — "The quick brown fox." must stay on line 2 unchanged. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + 'Yo \\' + '\n' + + 'The quick brown fox."""\n' + ); + expect(parse(patched).description.text).toEqual('Yo The quick brown fox.'); +}); + +test('should not reflow later lines when adding one character to a short first word', () => { + // maxLength is 22 (from the long second line "very long line indeed." = 22). + // Adding one char to "A" (→ "An") should only affect line 1 — not pull words + // from line 2 up to fill the now-slightly-larger available space on line 1. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + 'A \\' + '\n' + + 'very long line indeed."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('A very long line indeed.'); + + value.description.text = 'An very long line indeed.'; + const patched = patch(existing, value); + + // Only the first word changes — the second line must remain untouched. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + 'An \\' + '\n' + + 'very long line indeed."""\n' + ); + expect(parse(patched).description.text).toEqual('An very long line indeed.'); +}); + +test('should not pull words from line 3 when shrinking a word on line 2', () => { + // maxLength is 24 (from the third line "jumps over the lazy dog." = 24). + // Prefix preservation keeps line 1 intact. The remainder ("red fox ...") would + // be repacked at maxLength=24, absorbing words from line 3 onto line 2. + // Replacing "brown" (5 chars) with "red" (3 chars) should only change line 2. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + 'The quick \\' + '\n' + + 'brown fox \\' + '\n' + + 'jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The quick red fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // Line 1 preserved, only "brown" → "red" on line 2, line 3 untouched. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + 'The quick \\' + '\n' + + 'red fox \\' + '\n' + + 'jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The quick red fox jumps over the lazy dog.'); +}); + +test('should not absorb words from line 3 when swapping a same-length word on line 2', () => { + // maxLength is 15 (from the third line "fox jumps over." = 15). + // "green fox jumps" (15) fits exactly in maxLength, so the greedy packer would + // absorb "fox jumps" from line 3 onto line 2, leaving only "over." alone. + // "brown" → "green" is an exact same-length swap — no lines should reflow. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + 'The quick \\' + '\n' + + 'brown \\' + '\n' + + 'fox jumps over."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over.'); + + value.description.text = 'The quick green fox jumps over.'; + const patched = patch(existing, value); + + // Line 1 preserved, only "brown" → "green" on line 2, line 3 untouched. + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + 'The quick \\' + '\n' + + 'green \\' + '\n' + + 'fox jumps over."""\n' + ); + expect(parse(patched).description.text).toEqual('The quick green fox jumps over.'); +}); + + +describe('NL issues', () => { + + test('should preserve bare LF in value when patching a CRLF document', () => { + // The document uses CRLF. The caller supplies a value with bare LF newlines. + // The value is preserved as-is: \n in the value is encoded as the TOML \n + // escape sequence, which always decodes back to \n regardless of the file's + // structural line ending. The TOML structure itself stays CRLF. + const existing = + '[description]' + '\r\n' + + 'text = """\\' + '\r\n' + + ' The quick brown fox \\' + '\r\n' + + ' jumps over the lazy dog."""' + '\r\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'Hello\nworld'; + const patched = patch(existing, value); + + // Structural newlines are CRLF; the \n in the value becomes the TOML \n escape. + expect(patched).not.toMatch(/(? { + // The document uses LF. The caller supplies a value with CRLF newlines. + // The value is preserved as-is: \r\n in the value is encoded as the TOML + // \r\n escape sequence pair, which always decodes back to \r\n. The TOML + // structure itself stays LF. + const existing = + '[description]\n' + + 'text = """\\\n' + + ' The quick brown fox \\\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'Hello\r\nworld'; + const patched = patch(existing, value); + + // Structural newlines are LF; the \r\n in the value becomes the \r\n TOML escapes. + expect(patched).not.toContain('\r\n'); + expect(patched).toEqual( + '[description]\n' + + 'text = """\\\n' + + ' Hello\\r\\nworld"""\n' + ); + // Decoded value preserves the original \r\n. + expect(parse(patched).description.text).toEqual('Hello\r\nworld'); + }); + + test('should normalize mixed line endings in the document itself (CRLF doc, bare LF inside LC string body)', () => { + // A TOML file with mixed line endings: the document is CRLF but the LC string + // body contains bare LF lines. The patcher normalises existingRaw to the + // detected document newline before parsing segments, so the patched output + // uses CRLF consistently and the LC structure is preserved. + const existing = + '[description]\r\n' + + 'text = """\\' + '\r\n' + + ' The quick brown fox \\' + '\n' + // bare LF — mixed! + ' jumps over the lazy dog."""\r\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The slow brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // Output is fully CRLF and the second line is preserved verbatim. + expect(patched).not.toMatch(/(? { + // A TOML file where the LC opening line uses CRLF but the rest of the document + // uses bare LF. After normalization the output is fully LF. + const existing = + '[description]\n' + + 'text = """\\' + '\r\n' + // CRLF after opening — mixed! + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('The quick brown fox jumps over the lazy dog.'); + + value.description.text = 'The slow brown fox jumps over the lazy dog.'; + const patched = patch(existing, value); + + // Output is fully LF and the second line is preserved verbatim. + expect(patched).not.toContain('\r\n'); + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' The slow brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n' + ); + expect(parse(patched).description.text).toEqual('The slow brown fox jumps over the lazy dog.'); + }); + +}); + +// Edge cases that exercise boundary conditions in the packing and reassembly logic. +// These specifically guard against regressions if assumptions about dead code paths +// inside rebuildLineContinuation are ever invalidated. + +test('should handle value starting with a tab character in line-continuation format', () => { + // Tabs are escaped to \\t before reaching the packing loop, so the escaped value + // starts with a backslash (\\), not a space. This tests that the indent regex + // handles non-tab/space first characters correctly and that the leading-space + // guard doesn't misfire on escaped whitespace characters. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + value.description.text = '\thello world'; + const patched = patch(existing, value); + + // Tab is escaped to \t in basic strings, so it stays in line-continuation format + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' \\thello world"""\n' + ); + expect(parse(patched).description.text).toEqual('\thello world'); +}); + +test('should handle value with escaped backslash at the very start in line-continuation format', () => { + // The escaped value starts with "\\\\" (doubled backslash), not whitespace. + // Tests that the indent regex correctly parses lines starting with non-indent chars. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + const value = parse(existing); + value.description.text = '\\start and end\\'; + const patched = patch(existing, value); + + // Backslashes are doubled in basic strings; no leading space so stays in line-continuation + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' \\\\start and end\\\\"""\n' + ); + expect(parse(patched).description.text).toEqual('\\start and end\\'); +}); + +test('should repack many words across multiple lines without corrupting spaces', () => { + // Tests the packing loop boundary: after each inner-loop break, the next + // outer iteration must land on a word token (not a space). With many words + // being repacked across many lines, this exercises the token pointer advancement + // across multiple break-and-resume cycles. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' abcdef \\' + '\n' + + ' ghijkl."""\n'; + + const value = parse(existing); + // maxLength is 6, so each word pair gets its own line + value.description.text = 'aa bb cc dd ee ff gg hh ii jj'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' aa bb \\' + '\n' + + ' cc dd \\' + '\n' + + ' ee ff \\' + '\n' + + ' gg hh \\' + '\n' + + ' ii jj"""\n' + ); + expect(parse(patched).description.text).toEqual('aa bb cc dd ee ff gg hh ii jj'); +}); + +test('should preserve trailing space in value when tail prototype has matching trailing space', () => { + // The Original tail segment has trailingWs = ' ' (1 space before \). + // New value also ends with exactly 1 space — must survive round-trip. + // This directly tests that the tail trailing-WS assignment path works correctly + // after the removal of the redundant /\s$/ suppression check. + const existing = + '[cfg]\n' + + 'val = """\\' + '\n' + + ' word1 \\' + '\n' + + ' word2 \\' + '\n' + + ' word3 \\' + '\n' + + ' """\n'; + + const value = parse(existing); + // Decoded: "word1 word2 word3 " (trailing space from last segment's trailingWs) + expect(value.cfg.val).toEqual('word1 word2 word3 '); + + value.cfg.val = 'aaa bbb ccc ddd '; + const patched = patch(existing, value); + + // Must stay in line-continuation format AND preserve the trailing space. + // maxLength is 5 (from "word1"), so each word gets its own line. + expect(patched).toEqual( + '[cfg]\n' + + 'val = """\\' + '\n' + + ' aaa \\' + '\n' + + ' bbb \\' + '\n' + + ' ccc \\' + '\n' + + ' ddd \\' + '\n' + + ' """\n' + ); + expect(parse(patched).cfg.val).toEqual('aaa bbb ccc ddd '); +}); + +test('should handle double spaces at line boundaries during repacking', () => { + // Double-space runs at a break point become trailing whitespace before the + // backslash. Tests that the packing loop handles space-run tokens at the exact + // boundary where a break occurs, and that the reassembly doesn't double-add + // whitespace or corrupt the content. + const existing = + '[description]\n' + + 'text = """\\' + '\n' + + ' abcd \\' + '\n' + + ' efgh."""\n'; + + const value = parse(existing); + // maxLength is 4, double spaces between each word + value.description.text = 'aa bb cc dd'; + const patched = patch(existing, value); + + expect(patched).toEqual( + '[description]\n' + + 'text = """\\' + '\n' + + ' aa \\' + '\n' + + ' bb \\' + '\n' + + ' cc \\' + '\n' + + ' dd"""\n' + ); + expect(parse(patched).description.text).toEqual('aa bb cc dd'); +}); + +// Content integrity invariant: for ANY value, patching and then parsing must +// recover exactly the value that was set. This catches silent data corruption +// regardless of which internal format (line-continuation vs regular multiline) +// is chosen by the formatter. +describe('line-continuation content integrity', () => { + const lineContinuationDoc = + '[description]\n' + + 'text = """\\' + '\n' + + ' The quick brown fox \\' + '\n' + + ' jumps over the lazy dog."""\n'; + + test.each([ + ['simple words', 'hello world'], + ['leading space', ' hello world'], + ['trailing space', 'hello world '], + ['leading and trailing spaces', ' hello world '], + ['multiple leading spaces', ' hello world'], + ['multiple trailing spaces', 'hello world '], + ['all spaces', ' '], + ['single space', ' '], + ['empty string', ''], + ['single character', 'x'], + ['tab character', '\thello'], + ['escaped backslash', 'C:\\Users\\Alice'], + ['triple quotes', 'uses """triple""" quotes'], + ['very long value', 'a '.repeat(100).trim()], + ['no spaces at all', 'abcdefghijklmnopqrstuvwxyz'], + ['double spaces between words', 'hello world foo'], + ['only non-breaking content', '!!@@##$$%%'], + ])('round-trips correctly: %s', (_label, newValue) => { + const value = parse(lineContinuationDoc); + value.description.text = newValue; + const patched = patch(lineContinuationDoc, value); + + // THE INVARIANT: parsed content must exactly match what was set + expect(parse(patched).description.text).toEqual(newValue); + }); +}); diff --git a/src/__tests__/patch.mlbs.test.ts b/src/__tests__/patch.mlbs.test.ts new file mode 100644 index 0000000..7db358b --- /dev/null +++ b/src/__tests__/patch.mlbs.test.ts @@ -0,0 +1,139 @@ +import patch from '../patch'; +import { parse } from '../'; +import { LocalDate, LocalTime, LocalDateTime, OffsetDateTime } from '../parse-toml'; +import { example } from '../__fixtures__'; +import dedent from 'dedent'; + + +// Specific tests for literal multiline strings (''') - testing literal behavior +describe('literal multiline strings - specific behavior', () => { + test('should preserve literal multiline string without escaping backslashes', () => { + const existing = dedent` + [package] + name = "example" + path = ''' + C:\\Users\\Example\\Path + ''' + version = "1.0.0" + ` + '\n'; + + const obj = parse(existing); + // When setting a JavaScript string with single backslashes + obj.package.path = "D:\\Data\\Files"; + const patched = patch(existing, obj); + + // In literal strings, backslashes are NOT doubled - they remain as single backslashes + const expectedOutput = dedent` + [package] + name = "example" + path = ''' + D:\Data\Files''' + version = "1.0.0" + ` + '\n'; + + expect(patched).toEqual(expectedOutput); + }); + + test('should handle literal multiline string with actual newlines vs escape sequences', () => { + const existing = dedent` + [package] + name = "example" + text = ''' + Old text + ''' + version = "1.0.0" + ` + '\n'; + + const obj = parse(existing); + // Setting a JavaScript string that contains the characters \ and n + obj.package.text = "Line with \\n literal backslash-n"; + const patched = patch(existing, obj); + + // Literal strings show backslash-n as actual characters (not newline) + // In the template we need to escape the backslash as \\n + const expectedOutput = `[package] +name = "example" +text = ''' +Line with \\n literal backslash-n''' +version = "1.0.0" +`; + + expect(patched).toEqual(expectedOutput); + }); + + test('should handle literal multiline string with triple quotes in content', () => { + const existing = dedent` + [package] + name = "example" + text = '''Old text''' + version = "1.0.0" + ` + '\n'; + + const obj = parse(existing); + // Literal strings cannot contain ''' so it should convert to basic string + obj.package.text = "Text with ''' quotes"; + const patched = patch(existing, obj); + + // Should convert from literal (''') to basic (""") + // Note: ''' doesn't need escaping in basic strings, only """ needs escaping + const expectedOutput = `[package] +name = "example" +text = """Text with ''' quotes""" +version = "1.0.0" +`; + + expect(patched).toEqual(expectedOutput); + }); + + test('should NOT add leading newline when converting literal string with newline in middle', () => { + const existing = dedent` + [package] + name = "example" + text = '''line one + line two''' + version = "1.0.0" + ` + '\n'; + + const obj = parse(existing); + // Change value to require conversion to basic string + obj.package.text = "has ''' quotes"; + const patched = patch(existing, obj); + + // Should convert to basic string WITHOUT leading newline + // (the original literal string had newline in content, not at the start) + const expectedOutput = `[package] +name = "example" +text = """has ''' quotes""" +version = "1.0.0" +`; + + expect(patched).toEqual(expectedOutput); + }); + + test('should preserve leading newline when converting literal string with leading newline', () => { + const existing = dedent` + [package] + name = "example" + text = ''' + Old text + ''' + version = "1.0.0" + ` + '\n'; + + const obj = parse(existing); + // Change value to require conversion to basic string + obj.package.text = "New with ''' quotes"; + const patched = patch(existing, obj); + + // Should convert to basic string WITH leading newline + const expectedOutput = `[package] +name = "example" +text = """ +New with ''' quotes""" +version = "1.0.0" +`; + + expect(patched).toEqual(expectedOutput); + }); +}); + diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index db04420..74850db 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -362,6 +362,95 @@ test('should preserve multiline string with actual multiple lines', () => { expect(patched).toEqual(expectedOutput); }); +test('should collapse mlbs with leading newline and multiple content lines to single-line value', () => { + // Original has leading newline ("""\n) and three lines of content. + // New value has no newlines at all, so the generated raw has ONE embedded newline + // (the preserved leading newline) and the else-branch of endLocation is NOT reached — + // the multiline branch fires with lineCount=1, endLocation={ line:2, column:3 }. + const existing = dedent` + [package] + name = "example" + description = """ + First line + Second line + Third line""" + version = "1.0.0" + ` + '\n'; + + const obj = parse(existing); + obj.package.description = "single line value"; + const patched = patch(existing, obj); + + expect(patched).toEqual(dedent` + [package] + name = "example" + description = """ + single line value""" + version = "1.0.0" + ` + '\n'); + + expect(parse(patched).package.description).toEqual("single line value"); +}); + +test('should collapse mlbs without leading newline and multiple content lines to single-line value', () => { + // Original has NO leading newline ("""content) and multiple lines via embedded literal newlines. + // New value has no newlines, so raw becomes """single line value""" with NO \n at all. + // This hits the else-branch: endLocation = { line: 1, column: raw.length }. + // column: raw.length is correct here — the closing """ is part of the same line, + // not on its own line, so column: 3 would be wrong. + const existing = + '[package]\n' + + 'name = "example"\n' + + 'description = """First line\n' + + 'Second line\n' + + 'Third line"""\n' + + 'version = "1.0.0"\n'; + + const obj = parse(existing); + expect(obj.package.description).toEqual("First line\nSecond line\nThird line"); + + obj.package.description = "single line value"; + const patched = patch(existing, obj); + + expect(patched).toEqual( + '[package]\n' + + 'name = "example"\n' + + 'description = """single line value"""\n' + + 'version = "1.0.0"\n' + ); + + expect(parse(patched).package.description).toEqual("single line value"); +}); + +test('should patch mlbs without leading newline to another multi-line value (end-column correctness)', () => { + // Original has content on the same line as the opening """ (no leading newline). + // New value also has a newline, so raw = """Hello\nWorld""". The closing """ shares + // the last line with "World", so loc.end.column must be len("World\"\"\"") = 8, + // not 3. A wrong column would shift the following key-value to the wrong position. + const existing = + '[package]\n' + + 'name = "example"\n' + + 'description = """First line\n' + + 'Second line"""\n' + + 'version = "1.0.0"\n'; + + const obj = parse(existing); + expect(obj.package.description).toEqual("First line\nSecond line"); + + obj.package.description = "Hello\nWorld"; + const patched = patch(existing, obj); + + expect(patched).toEqual( + '[package]\n' + + 'name = "example"\n' + + 'description = """Hello\n' + + 'World"""\n' + + 'version = "1.0.0"\n' + ); + + expect(parse(patched).package.description).toEqual("Hello\nWorld"); +}); + test('should preserve multiline string with trailing newline in content', () => { const existing = dedent` [package] @@ -575,6 +664,7 @@ test('should preserve multiline string with only newlines', () => { expect(patched).toEqual(expectedOutput); }); + // Parameterized tests for both basic (""") and literal (''') multiline strings describe('multiline strings - both basic and literal', () => { test.each([ @@ -679,138 +769,6 @@ describe('multiline strings - both basic and literal', () => { }); }); -// Specific tests for literal multiline strings (''') - testing literal behavior -describe('literal multiline strings - specific behavior', () => { - test('should preserve literal multiline string without escaping backslashes', () => { - const existing = dedent` - [package] - name = "example" - path = ''' - C:\\Users\\Example\\Path - ''' - version = "1.0.0" - ` + '\n'; - - const obj = parse(existing); - // When setting a JavaScript string with single backslashes - obj.package.path = "D:\\Data\\Files"; - const patched = patch(existing, obj); - - // In literal strings, backslashes are NOT doubled - they remain as single backslashes - const expectedOutput = dedent` - [package] - name = "example" - path = ''' - D:\Data\Files''' - version = "1.0.0" - ` + '\n'; - - expect(patched).toEqual(expectedOutput); - }); - - test('should handle literal multiline string with actual newlines vs escape sequences', () => { - const existing = dedent` - [package] - name = "example" - text = ''' - Old text - ''' - version = "1.0.0" - ` + '\n'; - - const obj = parse(existing); - // Setting a JavaScript string that contains the characters \ and n - obj.package.text = "Line with \\n literal backslash-n"; - const patched = patch(existing, obj); - - // Literal strings show backslash-n as actual characters (not newline) - // In the template we need to escape the backslash as \\n - const expectedOutput = `[package] -name = "example" -text = ''' -Line with \\n literal backslash-n''' -version = "1.0.0" -`; - - expect(patched).toEqual(expectedOutput); - }); - - test('should handle literal multiline string with triple quotes in content', () => { - const existing = dedent` - [package] - name = "example" - text = '''Old text''' - version = "1.0.0" - ` + '\n'; - - const obj = parse(existing); - // Literal strings cannot contain ''' so it should convert to basic string - obj.package.text = "Text with ''' quotes"; - const patched = patch(existing, obj); - - // Should convert from literal (''') to basic (""") - // Note: ''' doesn't need escaping in basic strings, only """ needs escaping - const expectedOutput = `[package] -name = "example" -text = """Text with ''' quotes""" -version = "1.0.0" -`; - - expect(patched).toEqual(expectedOutput); - }); - - test('should NOT add leading newline when converting literal string with newline in middle', () => { - const existing = dedent` - [package] - name = "example" - text = '''line one - line two''' - version = "1.0.0" - ` + '\n'; - - const obj = parse(existing); - // Change value to require conversion to basic string - obj.package.text = "has ''' quotes"; - const patched = patch(existing, obj); - - // Should convert to basic string WITHOUT leading newline - // (the original literal string had newline in content, not at the start) - const expectedOutput = `[package] -name = "example" -text = """has ''' quotes""" -version = "1.0.0" -`; - - expect(patched).toEqual(expectedOutput); - }); - - test('should preserve leading newline when converting literal string with leading newline', () => { - const existing = dedent` - [package] - name = "example" - text = ''' - Old text - ''' - version = "1.0.0" - ` + '\n'; - - const obj = parse(existing); - // Change value to require conversion to basic string - obj.package.text = "New with ''' quotes"; - const patched = patch(existing, obj); - - // Should convert to basic string WITH leading newline - const expectedOutput = `[package] -name = "example" -text = """ -New with ''' quotes""" -version = "1.0.0" -`; - - expect(patched).toEqual(expectedOutput); - }); -}); - test('should patch example with removal of an array element', () => { const existing = dedent` @@ -1512,6 +1470,74 @@ test('should handle mixed line endings consistently', () => { expect(countTrailingCRLF(patched)).toBe(2); }); +test('should normalize bare LF in new value to CRLF when document uses CRLF', () => { + // A CRLF document patched with a multiline value that uses bare '\n' must not + // produce mixed line endings in the output — the '\n' must be upgraded to '\r\n'. + const existing = '[description]\r\ntext = """\r\nFirst line\r\nSecond line\r\n"""\r\n'; + + const value = parse(existing); + expect(value.description.text).toEqual('First line\r\nSecond line\r\n'); + + // New value supplied with bare LF (as a JS developer would naturally write) + value.description.text = 'Hello world\nand goodbye world\n'; + const patched = patch(existing, value); + + // Output must use CRLF throughout — no bare LF in the patched document + expect(patched).not.toContain('\r\r\n'); // no double-CR + expect(patched.split('\r\n').join('').includes('\n')).toBe(false); // no leftover bare LF + expect(patched).toEqual('[description]\r\ntext = """\r\nHello world\r\nand goodbye world\r\n"""\r\n'); + expect(parse(patched).description.text).toEqual('Hello world\r\nand goodbye world\r\n'); +}); + +test('should normalize CRLF in new value to LF when document uses LF', () => { + // A LF document patched with a value containing '\r\n' must normalize to bare '\n'. + const existing = '[description]\ntext = """\nFirst line\nSecond line\n"""\n'; + + const value = parse(existing); + value.description.text = 'Hello world\r\nand goodbye world\r\n'; + const patched = patch(existing, value); + + expect(patched).not.toContain('\r\n'); + expect(patched).toEqual('[description]\ntext = """\nHello world\nand goodbye world\n"""\n'); + expect(parse(patched).description.text).toEqual('Hello world\nand goodbye world\n'); +}); + +test('should keep literal \\n and \\r\\n sequences while normalizing real newlines to CRLF', () => { + const existing = '[description]\r\ntext = """\r\nFirst line\r\n"""\r\n'; + + const value = parse(existing); + value.description.text = 'literal \\n and literal \\r\\n plus real\nline\r\nend'; + const patched = patch(existing, value); + + expect(patched).toContain('literal \\\\n and literal \\\\r\\\\n plus real'); + expect(patched).toEqual( + '[description]\r\n' + + 'text = """\r\n' + + 'literal \\\\n and literal \\\\r\\\\n plus real\r\n' + + 'line\r\n' + + 'end"""\r\n' + ); + expect(parse(patched).description.text).toEqual('literal \\n and literal \\r\\n plus real\r\nline\r\nend'); +}); + +test('should keep literal \\n and \\r\\n sequences while normalizing real newlines to LF', () => { + const existing = '[description]\ntext = """\nFirst line\n"""\n'; + + const value = parse(existing); + value.description.text = 'literal \\n and literal \\r\\n plus real\r\nline\nend'; + const patched = patch(existing, value); + + expect(patched).toContain('literal \\\\n and literal \\\\r\\\\n plus real'); + expect(patched).toEqual( + '[description]\n' + + 'text = """\n' + + 'literal \\\\n and literal \\\\r\\\\n plus real\n' + + 'line\n' + + 'end"""\n' + ); + expect(parse(patched).description.text).toEqual('literal \\n and literal \\r\\n plus real\nline\nend'); +}); + test('should respect quoted keys when parsing', () => { const toml = dedent` [dog] @@ -2808,6 +2834,41 @@ test('should remove everything leaving empty document', () => { describe('TOML v1.1 multiline inline tables - edit operations (newline.toml spec)', () => { + test('should correctly shift a sibling key when patching a no-leading-newline MLBS in a multiline inline table', () => { + // Regression test for the generateString endLocation column bug. + // + // When a MLBS has NO leading newline, its closing """ shares a line with content + // (e.g. `a = """line1\nlonger text""", b = "x"`). The old code always stored + // column: 3 (the delimiter length) as the end column for any MLBS with newlines. + // The correct value is the actual last-line length. + // + // A wrong column means the writer computes the wrong shift delta for `b = "x"`, + // which is on the same line as the closing """. Here the MLBS last line shortens + // from len('longer text"""') = 14 to len('b"""') = 4 — a delta of -10. With the + // bug, the delta was 3 - 14 = -11 (off by one), shifting `b` one column too far + // to the left and corrupting the output. + const existing = + 'tbl = {' + '\n' + + ' a = """short' + '\n' + + 'longer text""", b = "x"' + '\n' + + '}' + '\n'; + + const obj = parse(existing); + expect(obj.tbl.a).toEqual('short\nlonger text'); + expect(obj.tbl.b).toEqual('x'); + + obj.tbl.a = 'a\nb'; + const patched = patch(existing, obj); + + expect(patched).toEqual( + 'tbl = {' + '\n' + + ' a = """a' + '\n' + + 'b""", b = "x"' + '\n' + + '}' + '\n' + ); + expect(parse(patched).tbl.b).toEqual('x'); + }); + test('should edit a value in a simple trailing-comma multiline inline table', () => { const existing = dedent` trailing-comma-1 = { @@ -2930,22 +2991,123 @@ describe('TOML v1.1 multiline inline tables - edit operations (newline.toml spec }); test('should edit a value in an inline table that contains a multiline string value', () => { - // tbl-2 from newline.toml: inline table whose value is a multiline string - const existing = dedent` - tbl-2 = { - k = """Hello""" - } - ` + '\n'; + // Verifies that preserveFormatting preserves the structural suffix of a multiline string: + // the line-continuation backslash and the closing indent must be preserved. + // + // Note: dedent eats `\` sequences (its raw-string cleanup regex), so these + // strings are written with explicit concatenation to control every character exactly. + // + // The TOML ` Hello \ ` encodes value ` Hello ` + // (8 spaces + "Hello " — the `\` is trimmed as a line continuation). + const existing = + 'tbl-2 = {\n' + + ' k = """\\\n' + + ' Hello \\\n' + + ' """\n' + + '}\n'; const value = parse(existing); - value['tbl-2'].k = 'Goodbye'; + // Sanity-check: line continuation trims backslash+newline+indent, leaving the trailing space. + expect(value['tbl-2'].k).toEqual('Hello '); + + value['tbl-2'].k = 'Goodbye '; const patched = patch(existing, value); - expect(patched).toEqual(dedent` - tbl-2 = { - k = """Goodbye""" - } - ` + '\n'); + expect(patched).toEqual( + 'tbl-2 = {\n' + + ' k = """\\\n' + + ' Goodbye \\\n' + + ' """\n' + + '}\n' + ); + expect(parse(patched)['tbl-2'].k).toEqual('Goodbye '); + }); + + test('should edit a value in an inline table that contains a multiline string value 2', () => { + const existing = + 'tbl-2 = {\n' + + ' k = """\\\n' + + ' Hello \\\n' + + ' World.\\\n' + + ' """\n' + + '}\n'; + + const value = parse(existing); + // The `\` sequences are line continuations: they trim the backslash, + // newline and following whitespace, joining everything into one value. + expect(value['tbl-2'].k).toEqual('Hello World.'); + + value['tbl-2'].k = 'Bonjour World.'; + const patched = patch(existing, value); + + expect(patched).toEqual( + 'tbl-2 = {\n' + + ' k = """\\\n' + + ' Bonjour \\\n' + + ' World.\\\n' + + ' """\n' + + '}\n' + ); + expect(parse(patched)['tbl-2'].k).toEqual('Bonjour World.'); + }); + + test('should edit a value in an inline table that contains a multiline string value 3', () => { + // Uses """\n (leading newline) format — NOT """\\ (leading line-continuation). + // The body contains line-continuation backslashes with blank lines and mixed indentation. + const existing = + 'tbl-2 = {\n' + + ' k = """\n' + + 'The quick brown \\\n' + + '\n' + + '\n' + + ' fox jumps over \\\n' + + ' the lazy dog."""\n' + + '}\n'; + + const value = parse(existing); + // Line-continuation trims `\`, newline(s) and following whitespace: + // "The quick brown " + "fox jumps over " + "the lazy dog." + expect(value['tbl-2'].k).toEqual('The quick brown fox jumps over the lazy dog.'); + + value['tbl-2'].k = 'The quick brown cat jumps over the lazy dog.'; + const patched = patch(existing, value); + + expect(patched).toEqual( + 'tbl-2 = {\n' + + ' k = """\n' + + 'The quick brown \\\n' + + '\n' + + '\n' + + ' cat jumps over \\\n' + + ' the lazy dog."""\n' + + '}\n' + ); + expect(parse(patched)['tbl-2'].k).toEqual('The quick brown cat jumps over the lazy dog.'); + }); + + test('should edit a value in an inline table that contains a multiline string value 4', () => { + // Uses """content (no newline after delimiter) with line-continuation in the body. + const existing = + 'tbl-2 = {\n' + + ' k = """The quick brown \\\n' + + ' fox jumps over \\\n' + + ' the lazy dog."""\n' + + '}\n'; + + const value = parse(existing); + expect(value['tbl-2'].k).toEqual('The quick brown fox jumps over the lazy dog.'); + + value['tbl-2'].k = 'The quick brown cat jumps over the lazy dog.'; + const patched = patch(existing, value); + + expect(patched).toEqual( + 'tbl-2 = {\n' + + ' k = """The quick brown \\\n' + + ' cat jumps over \\\n' + + ' the lazy dog."""\n' + + '}\n' + ); + expect(parse(patched)['tbl-2'].k).toEqual('The quick brown cat jumps over the lazy dog.'); }); test('should preserve no-trailing-newline-before-brace format when editing', () => { @@ -3614,3 +3776,4 @@ describe('undefined handling in patch', () => { ` + '\n'); }); }); + diff --git a/src/__tests__/writer.test.ts b/src/__tests__/writer.test.ts index cab8811..ae9fdcb 100644 --- a/src/__tests__/writer.test.ts +++ b/src/__tests__/writer.test.ts @@ -214,7 +214,7 @@ describe('formatMultilineStringReplacement', () => { // generateString returns base location (not adjusted to existing position - that's done by shiftNode in replace) expect(result.loc.start).toEqual({ line: 1, column: 0 }); expect(result.loc.end.line).toBe(4); // base line (1) + 3 newlines - expect(result.loc.end.column).toBe(3); // length of delimiter + expect(result.loc.end.column).toBe(8); // last line: 'value"""' (length 8) }); test('should handle location tracking when value changes from short to long', () => { @@ -253,7 +253,7 @@ describe('formatMultilineStringReplacement', () => { // generateString returns base location and counts CRLF as line breaks expect(result.loc.start).toEqual({ line: 1, column: 0 }); expect(result.loc.end.line).toBe(3); // base line (1) + 2 CRLF - expect(result.loc.end.column).toBe(3); + expect(result.loc.end.column).toBe(8); // last line: 'value"""' (length 8) }); }); }); diff --git a/src/generate.ts b/src/generate.ts index df4581e..ea439b3 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -21,7 +21,8 @@ import { import { zero, cloneLocation, clonePosition } from './location'; import { LocalDate } from './parse-toml'; import { shiftNode } from './writer'; -import { isMultilineString } from './utils'; +import { isBasicString, isLiteralString, isMultilineString } from './utils'; +import { detectLineContinuation, rebuildLineContinuation } from './line-ending-backslash'; /** * Generates a new TOML document node. @@ -139,6 +140,7 @@ export function generateKey(value: string[]): Key { value }; } + /** * Generates a new String node, preserving multiline format if existingRaw is provided. * @@ -147,8 +149,29 @@ export function generateKey(value: string[]): Key { * @returns A new String node. */ export function generateString(value: string, existingRaw?: string): String { - let raw: string; - + let raw = ''; + + + + + + if (existingRaw && isBasicString(existingRaw)) { + return generateBasicString(value); + } + + if (existingRaw && isLiteralString(existingRaw)) { + if (!value.includes("'")) { + return generateLiteralString(value); + } + // Value contains a single quote — single-line literal strings cannot contain '. + // Fall back to MLLS ('''value''') unless the value also contains ''', in which + // case we must use a basic string. + if (!value.includes("'''")) { + return generateMultilineLiteralString(value); + } + return generateBasicString(value); + } + if (existingRaw && isMultilineString(existingRaw)) { // Preserve multiline format let isLiteral = existingRaw.startsWith("'''"); @@ -164,14 +187,22 @@ export function generateString(value: string, existingRaw?: string): String { const newlineChar = existingRaw.includes('\r\n') ? '\r\n' : '\n'; const hasLeadingNewline = existingRaw.startsWith(`${delimiter}${newlineChar}`) || ((existingRaw.startsWith("'''\n") || existingRaw.startsWith("'''\r\n")) && !isLiteral); - + + // The value's newlines are preserved as-is when using the LC escape-sequence path + // (where newlines are encoded as TOML \n / \r\n escapes, not embedded literally). + // The fallback paths embed newlines as literal source text, so they must normalize + // to the document's line ending to keep the file structurally consistent. + const normalizedValue = value.replace(/\r?\n/g, newlineChar); + let escaped: string; if (isLiteral) { // Literal strings: no escaping needed (we already checked for ''' above) - escaped = value; + escaped = normalizedValue; } else { - // Basic multiline strings: escape backslashes, control characters, and triple quotes - escaped = value + // Basic multiline strings: escape backslashes, control characters, and triple quotes. + // Build escapedRaw from normalizedValue (for fallback literal paths) and + // escapedOriginal from value (for the LC path which uses TOML escape sequences). + escaped = normalizedValue .replace(/\\/g, '\\\\') // Escape backslashes first .replace(/\x08/g, '\\b') // Backspace (U+0008) .replace(/\f/g, '\\f') // Form feed (U+000C) @@ -184,11 +215,36 @@ export function generateString(value: string, existingRaw?: string): String { // Escape triple quotes safely: two literal quotes + escaped quote .replace(/"""/g, '""\\\"'); } + + // For the LC path, escape value without newline normalization so the LC function + // can encode them as TOML escape sequences (\n, \r\n) and preserve the exact value. + const escapedOriginal = isLiteral ? value : value + .replace(/\\/g, '\\\\') + .replace(/\x08/g, '\\b') + .replace(/\f/g, '\\f') + .replace(/\t/g, '\\t') + .replace(/[\x00-\x07\x0B\x0E-\x1F\x7F]/g, (char) => { + const code = char.charCodeAt(0); + return '\\u' + code.toString(16).padStart(4, '0').toUpperCase(); + }) + .replace(/"""/g, '""\\\"'); - // Format with or without leading newline based on original - if (hasLeadingNewline) { + // Detect line-continuation backslashes anywhere in the multiline string body. + // Line-continuation is only meaningful in basic (""") strings, not literal ('''). + // `rebuildLineContinuation` handles newlines in `escaped` internally: it either + // splits on double-newlines (paragraph style) or encodes them as \n escape sequences. + const hasLineContinuation = detectLineContinuation(existingRaw, newlineChar); + + // Generate the replacement raw string, preserving the structural format of the existing raw. + if (hasLineContinuation) { + const rebuilt = rebuildLineContinuation(existingRaw, escapedOriginal, newlineChar); + if (rebuilt !== null) { + raw = rebuilt; + } + } + if (!raw && hasLeadingNewline) { raw = `${delimiter}${newlineChar}${escaped}${delimiter}`; - } else { + } else if (!raw) { raw = `${delimiter}${escaped}${delimiter}`; } } else { @@ -197,19 +253,24 @@ export function generateString(value: string, existingRaw?: string): String { // Calculate proper end location for multiline strings let endLocation; - if (raw.includes('\r\n') || (raw.includes('\n') && !raw.includes('\r\n'))) { + if (raw.includes('\n')) { const newlineChar = raw.includes('\r\n') ? '\r\n' : '\n'; - const lineCount = (raw.match(new RegExp(newlineChar === '\r\n' ? '\\r\\n' : '\\n', 'g')) || []).length; - - if (lineCount > 0) { - endLocation = { - line: 1 + lineCount, - column: 3 // length of delimiter (""" or ''') - }; - } else { - endLocation = { line: 1, column: raw.length }; - } + const lines = raw.split(newlineChar); + const lastLine = lines[lines.length - 1]; + endLocation = { + line: lines.length, + // Use the actual last line length: when """ closes on its own line this + // equals 3 (the delimiter), but when content precedes the closing """ + // (no-leading-newline format, e.g. """Hello\nWorld""") it's larger. + column: lastLine.length + }; + } else { + // Covers both regular basic strings (e.g. "hello") and mlbs whose generated raw + // contains no newline — e.g. """single line value""" produced when the original + // had no leading newline and the new value itself has no newlines. In that case + // the entire raw string sits on one line, so column = raw.length is correct. + // (column: 3 would be wrong here — that only applies when """ closes on its own line.) endLocation = { line: 1, column: raw.length }; } @@ -221,6 +282,56 @@ export function generateString(value: string, existingRaw?: string): String { }; } +function generateBasicString(value: string): String { + const raw = JSON.stringify(value); + return { + type: NodeType.String, + loc: { start: zero(), end: { line: 1, column: raw.length } }, + raw, + value + }; +} + +function generateLiteralString(value: string): String { + const raw = `'${value}'`; + return { + type: NodeType.String, + loc: { start: zero(), end: { line: 1, column: raw.length } }, + raw, + value + }; +} + +function generateMultilineBasicString(value: string): String { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/\x08/g, '\\b') + .replace(/\f/g, '\\f') + .replace(/\t/g, '\\t') + .replace(/[\x00-\x07\x0B\x0E-\x1F\x7F]/g, (char) => { + const code = char.charCodeAt(0); + return '\\u' + code.toString(16).padStart(4, '0').toUpperCase(); + }) + .replace(/"""/g, '""\\\"'); + const raw = `"""${escaped}"""`; + return { + type: NodeType.String, + loc: { start: zero(), end: { line: 1, column: raw.length } }, + raw, + value + }; +} + +function generateMultilineLiteralString(value: string): String { + const raw = `'''${value}'''`; + return { + type: NodeType.String, + loc: { start: zero(), end: { line: 1, column: raw.length } }, + raw, + value + }; +} + /** * Generates a new Integer node. * @@ -229,7 +340,6 @@ export function generateString(value: string, existingRaw?: string): String { */ export function generateInteger(value: number): Integer { const raw = value.toString(); - return { type: NodeType.Integer, loc: { start: zero(), end: { line: 1, column: raw.length } }, diff --git a/src/line-ending-backslash.ts b/src/line-ending-backslash.ts new file mode 100644 index 0000000..697fc81 --- /dev/null +++ b/src/line-ending-backslash.ts @@ -0,0 +1,578 @@ +/** + * Line ending backslash (line-continuation) handling for basic multiline strings. + * + * In TOML, a backslash at the end of a line inside a `"""` string acts as a + * line-continuation marker: the backslash, the following newline and any + * leading whitespace on the next line are all trimmed, effectively joining + * the two lines together. + * + * Literal multiline strings (`'''`) do NOT support this — backslashes there + * are always literal characters. + * + * This module provides: + * - `detectLineContinuation` — checks whether an existing raw string uses + * line-continuation formatting. + * - `rebuildLineContinuation` — rebuilds the raw TOML string with a new + * escaped value while preserving the original line-continuation layout. + */ + +interface Segment { + indent: string; + content: string; + trailingWs: string; + hasBackslash: boolean; + isBlank: boolean; +} + +/** + * Detects whether an existing raw basic multiline string (`"""`) uses + * line-continuation backslashes. + * + * Detection is gated on the original raw delimiter: if the existing raw was + * a literal string (`'''`), backslashes were literal characters and must + * never be treated as line-continuation markers — even when converting to a + * basic string because the new value contains `'''`. + * + * The function only inspects `existingRaw` — it does not check the new value. + * Compatibility with the new value (e.g. newline handling) is determined inside + * `rebuildLineContinuation`. + * + * @param existingRaw - The full raw TOML string including delimiters. + * @param newlineChar - The newline character used in the document (`'\n'` or `'\r\n'`). + * @returns `true` if the existing raw string uses line-continuation formatting. + */ +export function detectLineContinuation( + existingRaw: string, + newlineChar: string +): boolean { + if (!existingRaw.startsWith('"""')) return false; + + const innerContent = existingRaw.slice(3, existingRaw.length - 3); + return innerContent.split(newlineChar).some(line => { + const m = line.match(/(\\+)$/); + return m !== null && m[1].length % 2 === 1; + }); +} + +/** + * Rebuilds a basic multiline string (`"""`) that uses line ending backslash + * (line-continuation) formatting. + * + * Handles all three opening formats: + * - `"""\content\indent"""` (leading line-continuation) + * - `"""content\..."""` (leading newline) + * - `"""content\..."""` (no leading newline) + * + * Strategy: + * 1. Detect the opening format and where the body starts. + * 2. Parse body lines into segments (indent, content, trailing whitespace, + * backslash flag), preserving blank lines. + * 3. Measure the max content length from content lines. + * 4. Detect "blank-line paragraph style": if the original uses blank lines to + * separate paragraphs (non-backslash content line followed by a blank line), + * split the new value on double-newlines and pack each paragraph separately, + * rejoined by blank lines. + * 5. Otherwise, encode newlines as `\n` escape sequences and pack as a single group. + * 6. Reassemble with the original opening format, per-line whitespace and + * backslash placement. + * + * @param existingRaw - The full raw TOML string including delimiters (`"""`). + * @param escaped - The new value already escaped for a basic multiline string + * (backslashes doubled, control characters replaced with escape sequences). + * May contain real newline characters; this function handles their encoding. + * @param newlineChar - The newline character to use (`'\n'` or `'\r\n'`). + * @returns The reconstructed raw TOML string, or `null` if the value cannot be + * represented in line-continuation format (e.g. values with leading spaces or + * a trailing space count that doesn't match the original layout). + */ +export function rebuildLineContinuation( + existingRaw: string, + escaped: string, + newlineChar: string +): string | null { + // Normalize existingRaw to the document's line ending so mixed-ending source + // files are handled consistently. Replace all CRLF first, then any remaining + // bare LF, then re-introduce the correct sequence. + existingRaw = existingRaw.replace(/\r\n/g, '\n').replace(/\n/g, newlineChar); + + // Line-continuation is only valid in basic multiline strings. + const delimiter = '"""'; + // Determine the opening format and where the body starts + let bodyStart: number; + let openingPrefix: string; + + if (existingRaw.startsWith(`${delimiter}\\${newlineChar}`)) { + // """\ format — delimiter followed by line-continuation + bodyStart = delimiter.length + 1 + newlineChar.length; + openingPrefix = `${delimiter}\\${newlineChar}`; + } else if (existingRaw.startsWith(`${delimiter}${newlineChar}`)) { + // """ format — delimiter followed by newline + bodyStart = delimiter.length + newlineChar.length; + openingPrefix = `${delimiter}${newlineChar}`; + } else { + // """content format — no newline after delimiter + bodyStart = delimiter.length; + openingPrefix = delimiter; + } + + const bodyEnd = existingRaw.length - delimiter.length; + const body = existingRaw.slice(bodyStart, bodyEnd); + const rawLines = body.split(newlineChar); + + // Determine closing format: does the closing delimiter sit on its own line? + const lastLine = rawLines[rawLines.length - 1]; + const hasClosingIndent = rawLines.length > 1 && /^[\t ]*$/.test(lastLine); + const closingIndent = hasClosingIndent ? lastLine : ''; + const bodyLines = hasClosingIndent ? rawLines.slice(0, -1) : rawLines; + + const segments: Segment[] = bodyLines.map(line => { + if (/^[\t ]*$/.test(line)) { + // Preserve the original whitespace-only line so it round-trips faithfully + return { indent: line, content: '', trailingWs: '', hasBackslash: false, isBlank: true }; + } + + let stripped = line; + // Count trailing backslashes: an odd count means the last one is a line-continuation + // marker; an even count means they are all literal escaped backslashes. + let backslashCount = 0; + for (let i = stripped.length - 1; i >= 0 && stripped[i] === '\\'; i--) { + backslashCount++; + } + const hasBackslash = backslashCount % 2 === 1; + if (hasBackslash) { + stripped = stripped.slice(0, -1); + } + + const indent = stripped.match(/^[\t ]*/)?.[0] ?? ''; + const afterIndent = stripped.slice(indent.length); + const trailingMatch = afterIndent.match(/([\t ]+)$/); + const trailingWs = trailingMatch ? trailingMatch[1] : ''; + const content = afterIndent.slice(0, afterIndent.length - trailingWs.length); + + return { indent, content, trailingWs, hasBackslash, isBlank: false }; + }); + + const contentSegments = segments.filter(s => !s.isBlank); + + // Measure max content width across all content lines (including the tail). + // Width = content + trailing whitespace, because the trailing space is part of the + // visible line before the line-continuation backslash. + const maxLength = Math.max(...contentSegments.map(s => s.content.length + s.trailingWs.length), 1); + + // Determine which segment prototype to use for extra lines beyond the original count. + // When the opening is `"""\`, the continuation backslash is part of the opening + // prefix (not the body), so continuationSegs and even contentSegments can be empty. + const continuationSegs = contentSegments.filter(s => s.hasBackslash); + const defaultProto: Segment = { indent: '', trailingWs: '', hasBackslash: false, content: '', isBlank: false }; + const contProto = continuationSegs[continuationSegs.length - 1] + ?? contentSegments[contentSegments.length - 1] + ?? defaultProto; + const tailProto = contentSegments[contentSegments.length - 1] ?? contProto; + + // Record blank groups between consecutive content segments. + // blankGroups[i] = the original blank lines (preserving their content) between + // contentSegments[i] and [i+1]. + const blankGroups: string[][] = []; + { + let blanks: string[] = []; + let ci = 0; + for (const seg of segments) { + if (seg.isBlank) { + blanks.push(seg.indent); + } else { + if (ci > 0) blankGroups.push(blanks); + blanks = []; + ci++; + } + } + } + + // Detect literal line-break style in the original source: a non-tail content segment + // without a trailing backslash means the next newline is semantic (not line-continuation). + // When present, we prefer preserving real source line breaks from the new value and avoid + // turning them into \n escapes where possible. + const hasLiteralLineBreakStyle = contentSegments.some((seg, i) => + !seg.hasBackslash && i < contentSegments.length - 1 + ); + + const isLeadingNewlineOpening = openingPrefix === `${delimiter}${newlineChar}`; + // In `"""` mode, derive the first-line indent from the value's own leading + // whitespace so packing sees only "content". In `"""\` mode there is no + // structural indent, so any leading space in the value must be rejected. + const newFirstIndent = isLeadingNewlineOpening && contentSegments.length > 0 + ? escaped.match(/^[\t ]*/)?.[0] ?? '' + : ''; + + // Helper: greedy word-packer. Splits content at whitespace boundaries to honour + // maxLength, appending the whitespace run as trailing ws before the line-continuation. + const packContent = (input: string): string[] => { + const tokens = input.match(/\S+| +/g) ?? []; + const lines: string[] = []; + let ti = 0; + while (ti < tokens.length) { + let line = tokens[ti++]; // always take at least one word token + while (ti < tokens.length && tokens[ti][0] === ' ') { + const spaceTok = tokens[ti]; + const nextWord = tokens[ti + 1]; + if (!nextWord) { + // Trailing space with no following word — drop it + ti++; + break; + } + if (line.length + spaceTok.length + nextWord.length <= maxLength) { + line += spaceTok + nextWord; + ti += 2; + } else { + // Next word doesn't fit; append the space run so it becomes trailing + // whitespace before the line-continuation backslash (preserved by TOML). + line += spaceTok; + ti++; + break; + } + } + lines.push(line); + } + if (lines.length === 0) lines.push(''); + return lines; + }; + + // Flat list of packed content strings and a set tracking logical line boundaries + // (last packed line of a non-last logical line — no backslash). + const newContentLines: string[] = []; + // Reassembly buffer — populated either by the prefix-preservation early path or + // by the main reassembly loop below. + const rebuiltLines: string[] = []; + const paraEndIndices = new Set(); + const logicalLineStartIndices = new Set(); + const logicalLineStartIndentByIndex = new Map(); + let isLiteralLineBreakPath = false; + + // Guard: only use the literal-break path when the value's newlines are exactly + // newlineChar. A CRLF value in an LF doc would have `escaped.includes('\n')` true + // (since \r\n contains \n), but splitting on bare '\n' would leave stray \r at + // the end of lines. So for LF docs, require the value has no \r\n sequences. + const canUseLiteralBreak = hasLiteralLineBreakStyle && escaped.includes(newlineChar) && + (newlineChar === '\r\n' || !escaped.includes('\r\n')); + + if (canUseLiteralBreak) { + // ── Literal line-break path ──────────────────────────────────────────────── + // Keep actual source newlines from the new value. Each logical line is packed + // independently and boundaries are emitted as real line breaks (no backslash). + // Since value newlines are not normalized, the decoded value preserves whatever + // newline sequence was in the original JS value. + isLiteralLineBreakPath = true; + const logicalLines = escaped.split(newlineChar); + + // Guard: first logical line's content must not start with a space after stripping + // the structural first-line indent. + const firstLineInput = logicalLines[0].slice(newFirstIndent.length); + if (firstLineInput.length > 0 && firstLineInput[0] === ' ') return null; + + // Guard: final logical line's trailing space count must match tailProto. + const lastLineInput = logicalLines[logicalLines.length - 1]; + if (lastLineInput.length > 0) { + const trailingSpaces = lastLineInput.length - lastLineInput.trimEnd().length; + if (trailingSpaces !== tailProto.trailingWs.length) return null; + } + + for (let li = 0; li < logicalLines.length; li++) { + const rawLine = logicalLines[li]; + const adjusted = li === 0 ? rawLine.slice(newFirstIndent.length) : rawLine; + const logicalLineIndent = adjusted.match(/^[\t ]*/)?.[0] ?? ''; + const logicalLineContent = adjusted.slice(logicalLineIndent.length); + const packedLines = packContent(logicalLineContent); + const startIdx = newContentLines.length; + logicalLineStartIndices.add(startIdx); + if (li > 0) { + // For literal line boundaries, preserve the next logical line's leading spaces + // as actual source indentation on that boundary line. + logicalLineStartIndentByIndex.set(startIdx, logicalLineIndent); + } + for (const l of packedLines) newContentLines.push(l); + if (li < logicalLines.length - 1) { + // Mark the last packed line of this non-last logical line. + paraEndIndices.add(startIdx + packedLines.length - 1); + } + } + } else { + // ── Single-group path ──────────────────────────────────────────────────────── + // Encode newlines as TOML escape sequences so the decoded value exactly preserves + // whatever newline sequence was in the original JS value. Encode \r\n first so + // that the subsequent bare-\n pass doesn't double-encode the \n half of CRLF. + const packInput = escaped + .replace(/\r\n/g, '\\r\\n') + .replace(/\n/g, '\\n') + .slice(newFirstIndent.length); + + // Guard: leading space would be silently consumed by LC indent mechanics. + if (packInput.length > 0) { + if (packInput[0] === ' ') return null; + const trailingSpaces = packInput.length - packInput.trimEnd().length; + if (trailingSpaces !== tailProto.trailingWs.length) return null; + } + + // ── Prefix + suffix preservation ──────────────────────────────────────────── + // Identify unchanged leading and trailing segments so only the genuinely changed + // middle slice needs to be repacked. This prevents a word swap in the middle (or + // even at the very start) from pulling words out of later unchanged lines due to + // the global maxLength being measured from the longest line in the string. + if (!hasLiteralLineBreakStyle) { + // Prefix: count segments from the start whose content+trailingWs appears + // verbatim at the beginning of packInput. + let preservedCount = 0; + let consumedChars = 0; + // Only consider segments up to (but not including) the tail segment. + for (let k = 0; k < contentSegments.length - 1; k++) { + const seg = contentSegments[k]; + if (!seg.hasBackslash) break; // non-continuation inner segment — stop + const contrib = seg.content + seg.trailingWs; + if (packInput.slice(consumedChars, consumedChars + contrib.length) === contrib) { + preservedCount++; + consumedChars += contrib.length; + } else { + break; + } + } + + // Suffix: working backwards from the tail, count segments whose content+trailingWs + // appears verbatim at the END of packInput (not overlapping the prefix). + // Structural segments with no content (e.g. a blank closing-indent segment) are + // not matchable — they would always match the empty string and corrupt the output. + let suffixCount = 0; + let suffixChars = 0; + for (let k = contentSegments.length - 1; k >= preservedCount; k--) { + const seg = contentSegments[k]; + const contrib = seg.content + seg.trailingWs; + if (contrib.length === 0) break; // structural segment — stop + const start = packInput.length - suffixChars - contrib.length; + if (start >= consumedChars && packInput.slice(start, start + contrib.length) === contrib) { + suffixCount++; + suffixChars += contrib.length; + } else { + break; + } + } + + // ── Prefix + middle + suffix early return ────────────────────────────── + // Used when the suffix is non-empty. Pack only the changed middle section + // using a local width budget derived from the original middle segments, so + // the middle lines cannot absorb words that belong to the unchanged suffix. + if (suffixCount > 0) { + const middleInput = packInput.slice(consumedChars, packInput.length - suffixChars); + // Guard: middle must not start with a space. + if (middleInput.length === 0 || middleInput[0] !== ' ') { + // 1. Emit preserved prefix lines verbatim (all have continuation backslash). + for (let i = 0; i < preservedCount; i++) { + const seg = contentSegments[i]; + rebuiltLines.push(`${seg.indent}${seg.content}${seg.trailingWs}\\`); + if (i < preservedCount - 1 && i < blankGroups.length && blankGroups[i].length > 0) { + for (const bl of blankGroups[i]) rebuiltLines.push(bl); + } + } + + // 2. Pack and emit the middle section (all lines need continuation backslash + // because the suffix follows). + if (middleInput.length > 0) { + const midLines = packContent(middleInput); + const joinedMid = midLines.join(''); + const hasEncodedNewlines = /(? 0 && !hasEncodedNewlines) { + const bridgeIdx = preservedCount - 1; + if (bridgeIdx < blankGroups.length && blankGroups[bridgeIdx].length > 0) { + for (const bl of blankGroups[bridgeIdx]) rebuiltLines.push(bl); + } + } + for (let j = 0; j < midLines.length; j++) { + const newContent = midLines[j]; + const origIdx = preservedCount + j; + const origSeg = origIdx < contentSegments.length ? contentSegments[origIdx] : null; + const indent = origSeg + ? (isLeadingNewlineOpening && origIdx === 0 ? newFirstIndent : origSeg.indent) + : contProto.indent; + const trailing = newContent.length > 0 && /\s$/.test(newContent) ? '' : (middleInput.endsWith(' ') ? ' ' : ''); + rebuiltLines.push(`${indent}${newContent}${trailing}\\`); + if (j < midLines.length - 1 && !hasEncodedNewlines) { + const bgIdx = preservedCount + j; + if (bgIdx < blankGroups.length && blankGroups[bgIdx].length > 0) { + for (const bl of blankGroups[bgIdx]) rebuiltLines.push(bl); + } + } + } + // Blank group between the last middle line and the first suffix segment. + const midToSuffixBgIdx = contentSegments.length - suffixCount - 1; + if (!hasEncodedNewlines && + midToSuffixBgIdx >= 0 && midToSuffixBgIdx < blankGroups.length && + blankGroups[midToSuffixBgIdx].length > 0) { + for (const bl of blankGroups[midToSuffixBgIdx]) rebuiltLines.push(bl); + } + } else if (preservedCount > 0) { + // Empty middle: blank group bridging last prefix and first suffix. + const bridgeIdx = preservedCount - 1; + if (bridgeIdx < blankGroups.length && blankGroups[bridgeIdx].length > 0) { + for (const bl of blankGroups[bridgeIdx]) rebuiltLines.push(bl); + } + } + + // 3. Emit preserved suffix lines verbatim. + const suffixStart = contentSegments.length - suffixCount; + for (let i = suffixStart; i < contentSegments.length; i++) { + const seg = contentSegments[i]; + const isTail = i === contentSegments.length - 1; + // Blank group between consecutive suffix segments (not the middle-to-first-suffix + // bridge, which was handled above). + if (i > suffixStart) { + const bgIdx = i - 1; + if (bgIdx < blankGroups.length && blankGroups[bgIdx].length > 0) { + for (const bl of blankGroups[bgIdx]) rebuiltLines.push(bl); + } + } + rebuiltLines.push(isTail + ? `${seg.indent}${seg.content}${seg.trailingWs}${seg.hasBackslash && seg.content !== '' ? '\\' : ''}` + : `${seg.indent}${seg.content}${seg.trailingWs}\\`); + } + + const useClosingIndentEarly = hasClosingIndent && !(tailProto.hasBackslash && tailProto.content === ''); + if (useClosingIndentEarly) { + return `${openingPrefix}${rebuiltLines.join(newlineChar)}${newlineChar}${closingIndent}${delimiter}`; + } + return `${openingPrefix}${rebuiltLines.join(newlineChar)}${delimiter}`; + } + } + + // ── Prefix-only early return ──────────────────────────────────────────── + // Used when the suffix is empty but the prefix is non-empty. Packs the + // remaining (changed) tail portion at the global maxLength. + const remainingInput = packInput.slice(consumedChars); + if ( + preservedCount > 0 && + (consumedChars === packInput.length || + (remainingInput.length > 0 && remainingInput[0] !== ' ')) + ) { + // Emit preserved original continuation lines verbatim. + for (let i = 0; i < preservedCount; i++) { + const seg = contentSegments[i]; + rebuiltLines.push(`${seg.indent}${seg.content}${seg.trailingWs}\\`); + // Emit blank groups that fall between preserved segments. + if (i < preservedCount - 1 && i < blankGroups.length && blankGroups[i].length > 0) { + for (const bl of blankGroups[i]) rebuiltLines.push(bl); + } + } + + if (consumedChars === packInput.length) { + // The new content ends exactly at the last preserved segment boundary. + // Convert the last preserved line from continuation to tail. + const lastSeg = contentSegments[preservedCount - 1]; + const backslash = tailProto.hasBackslash && lastSeg.content !== '' ? '\\' : ''; + rebuiltLines[rebuiltLines.length - 1] = + `${lastSeg.indent}${lastSeg.content}${tailProto.trailingWs}${backslash}`; + } else { + // Pack only the remaining (changed) portion of the content. + const newPackedLines = packContent(remainingInput); + const joinedNew = newPackedLines.join(''); + const hasEncodedNewlines = /(? 0) { + for (const bl of blankGroups[bridgeIdx]) rebuiltLines.push(bl); + } + for (let j = 0; j < newPackedLines.length; j++) { + const isLastNew = j === newPackedLines.length - 1; + const newContent = newPackedLines[j]; + const origIdx = preservedCount + j; + const origSeg = origIdx < contentSegments.length ? contentSegments[origIdx] : null; + const indent = origSeg + ? (isLeadingNewlineOpening && origIdx === 0 ? newFirstIndent : origSeg.indent) + : (isLastNew ? tailProto.indent : contProto.indent); + let trailing: string; + let backslash: string; + if (isLastNew) { + trailing = tailProto.trailingWs; + backslash = tailProto.hasBackslash && tailProto.content !== '' ? '\\' : ''; + } else { + trailing = newContent.length > 0 && /\s$/.test(newContent) ? '' : ' '; + backslash = '\\'; + } + rebuiltLines.push(`${indent}${newContent}${trailing}${backslash}`); + // Emit blank groups for the new lines (index offset by preservedCount). + if (!isLastNew) { + const bgIdx = preservedCount + j; + if (!hasEncodedNewlines && bgIdx < blankGroups.length && blankGroups[bgIdx].length > 0) { + for (const bl of blankGroups[bgIdx]) rebuiltLines.push(bl); + } + } + } + } + + const useClosingIndentEarly = hasClosingIndent && !(tailProto.hasBackslash && tailProto.content === ''); + if (useClosingIndentEarly) { + return `${openingPrefix}${rebuiltLines.join(newlineChar)}${newlineChar}${closingIndent}${delimiter}`; + } + return `${openingPrefix}${rebuiltLines.join(newlineChar)}${delimiter}`; + } + } + + const packed = packContent(packInput); + for (const l of packed) newContentLines.push(l); + } + + // Reassemble lines with correct indentation, trailing whitespace and backslash placement. + for (let i = 0; i < newContentLines.length; i++) { + const isGlobalLast = i === newContentLines.length - 1; + const isParaEnd = paraEndIndices.has(i); + // Paragraph-end lines and the global last line both use tail-like properties: + // no backslash, tailProto trailing whitespace. + const isTailLike = isGlobalLast || isParaEnd; + + const origSeg = i < contentSegments.length ? contentSegments[i] : null; + // In `"""` mode the first segment's indent is part of the decoded value, so + // we use the new value's own leading whitespace (newFirstIndent) for i === 0, + // letting the value's leading whitespace differ from the original freely. + const indent = isLiteralLineBreakPath && logicalLineStartIndices.has(i) && i > 0 + ? (logicalLineStartIndentByIndex.get(i) ?? '') + : (origSeg + ? (isLeadingNewlineOpening && i === 0 ? newFirstIndent : origSeg.indent) + : (isTailLike ? tailProto.indent : contProto.indent)); + const newContent = newContentLines[i]; + + let trailing: string; + let backslash: string; + if (isTailLike && isGlobalLast) { + trailing = tailProto.trailingWs; + backslash = tailProto.hasBackslash && tailProto.content !== '' ? '\\' : ''; + } else if (isTailLike) { + // Paragraph-end line: no backslash so the following newline is literal, + // creating a blank-line paragraph separator in the TOML source. + trailing = tailProto.trailingWs; + backslash = ''; + } else { + // Continuation line within a paragraph. + trailing = newContent.length > 0 && /\s$/.test(newContent) ? '' : ' '; + backslash = '\\'; + } + rebuiltLines.push(`${indent}${newContent}${trailing}${backslash}`); + + // For literal line-break path, isParaEnd itself encodes the structural newline boundary + // (no trailing backslash). No extra blank lines are inserted here. + if (!isGlobalLast && !isParaEnd && !isLiteralLineBreakPath) { + // Single-group path: insert original blank groups, but only when the packed content + // contains no encoded newline escape sequences (those already represent paragraph + // breaks inline, so blank-line placement from original positions would be misleading). + const singleInput = newContentLines.join(''); + const hasEncodedNewlines = /(? 0) { + for (const blankLine of blankGroups[i]) rebuiltLines.push(blankLine); + } + } + } + + // When the tail prototype had no content, its backslash was purely structural (it only + // skipped whitespace before the closing delimiter). In that case close inline instead + // of preserving the closing-indent format. + const useClosingIndent = hasClosingIndent && !(tailProto.hasBackslash && tailProto.content === ''); + + if (useClosingIndent) { + return `${openingPrefix}${rebuiltLines.join(newlineChar)}${newlineChar}${closingIndent}${delimiter}`; + } + return `${openingPrefix}${rebuiltLines.join(newlineChar)}${delimiter}`; +} diff --git a/src/patch.ts b/src/patch.ts index 1534554..fed539f 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -146,9 +146,8 @@ function reorder(changes: Change[]): Change[] { */ function preserveFormatting(existing: Value, replacement: Value): void { - // Preserve multiline string format - if (isString(existing) && isString(replacement) && isMultilineString(existing.raw)) { - // Generate new string node with preserved multiline format + // Preserve string format (handles basic, literal, multiline in all variants) + if (isString(existing) && isString(replacement)) { const newString = generateString(replacement.value, existing.raw); replacement.raw = newString.raw; replacement.loc = newString.loc; diff --git a/src/utils.ts b/src/utils.ts index d949eed..ec11e58 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,6 +12,23 @@ export function isString(value: any): value is string { return typeof value === 'string'; } +export function isBasicString(raw: string): boolean { + return raw.startsWith('"') && !raw.startsWith('"""'); +} + +/** + * Checks if a string is a multiline string (starts with """ or ''') + * @param raw The raw string value including quotes + * @returns true if the string is multiline, false otherwise + */ +export function isMultilineString(raw: string): boolean { + return raw.startsWith('"""') || raw.startsWith("'''"); +} + +export function isLiteralString(raw: string): boolean { + return raw.startsWith("'") && !raw.startsWith("'''"); +} + export function isInteger(value: any): value is number { return typeof value === 'number' && value % 1 === 0 && isFinite(value) && !Object.is(value, -0); } @@ -83,11 +100,4 @@ export function merge(target: TValue[], values: TValue[]) { } } -/** - * Checks if a string is a multiline string (starts with """ or ''') - * @param raw The raw string value including quotes - * @returns true if the string is multiline, false otherwise - */ -export function isMultilineString(raw: string): boolean { - return raw.startsWith('"""') || raw.startsWith("'''"); -} + diff --git a/src/writer.ts b/src/writer.ts index 37b02af..1830430 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -830,7 +830,12 @@ export function shiftNode( type === NodeType.Comment) { if (!first_line_only || node.loc.start.line === start_line) { node.loc.start.column += columns; - node.loc.end.column += columns; + // Only shift end.column when start and end are on the same line. + // For multiline strings the end is on a completely different line, so its + // column is an absolute position independent of where the node starts. + if (node.loc.end.line === node.loc.start.line) { + node.loc.end.column += columns; + } } node.loc.start.line += lines; node.loc.end.line += lines;