diff --git a/src/__tests__/patch.test.ts b/src/__tests__/patch.test.ts index 0e5cf04..4d10e7a 100644 --- a/src/__tests__/patch.test.ts +++ b/src/__tests__/patch.test.ts @@ -2930,22 +2930,119 @@ 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' + ); + }); + + 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' + ); + }); + + 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' + ); + }); + + 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' + ); }); test('should preserve no-trailing-newline-before-brace format when editing', () => { diff --git a/src/generate.ts b/src/generate.ts index df4581e..559f9eb 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -139,6 +139,152 @@ export function generateKey(value: string[]): Key { value }; } + +/** + * Rebuilds a 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. Split both the original decoded value and the new value into words. + * 4. Redistribute new words across the same segment structure by mapping word positions. + * 5. 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 after escaping (for basic multiline strings). + * @param decodedValue - The new decoded (unescaped) string value. + * @param delimiter - The multiline delimiter ('"""' or "'''"). + * @param newlineChar - The newline character to use ('\n' or '\r\n'). + * @returns The reconstructed raw TOML string. + */ +function rebuildLineContinuation( + existingRaw: string, + escaped: string, + decodedValue: string, + delimiter: string, + newlineChar: string +): string { + // 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; + + interface Segment { + indent: string; + content: string; + trailingWs: string; + hasBackslash: boolean; + isBlank: boolean; + } + + const segments: Segment[] = bodyLines.map(line => { + if (line.trim() === '') { + return { indent: '', content: '', trailingWs: '', hasBackslash: false, isBlank: true }; + } + + let stripped = line; + const hasBackslash = stripped.endsWith('\\'); + if (hasBackslash) { + stripped = stripped.slice(0, -1); + } + + const indentMatch = stripped.match(/^([\t ]*)/); + const indent = indentMatch ? indentMatch[1] : ''; + const trailingMatch = stripped.match(/([\t ]+)$/); + const trailingWs = trailingMatch ? trailingMatch[1] : ''; + const content = stripped.slice(indent.length, stripped.length - trailingWs.length); + + return { indent, content, trailingWs, hasBackslash, isBlank: false }; + }); + + const contentSegments = segments.filter(s => !s.isBlank); + + const splitWords = (s: string): string[] => s.match(/\S+/g) || []; + + const newWords = splitWords(decodedValue); + + // Map content segments to word ranges: segmentWordRanges[i] = [startWordIdx, endWordIdx) + const segmentWordRanges: [number, number][] = []; + let wordIdx = 0; + for (const seg of contentSegments) { + const segWords = splitWords(seg.content); + const start = wordIdx; + wordIdx += segWords.length; + segmentWordRanges.push([start, wordIdx]); + } + + // Redistribute new words across content segments using the same ranges. + const newContents: string[] = []; + for (let i = 0; i < contentSegments.length; i++) { + const [origStart, origEnd] = segmentWordRanges[i]; + const origCount = origEnd - origStart; + const segNewWords = + i === contentSegments.length - 1 + ? newWords.slice(origStart) + : newWords.slice(origStart, origStart + origCount); + newContents.push(segNewWords.join(' ')); + } + + // Trim trailing empty content entries (when new value has fewer words) + while (newContents.length > 1 && newContents[newContents.length - 1] === '') { + newContents.pop(); + } + + // Reassemble: walk segments, emitting up to the remaining content count. + const numContentToEmit = newContents.length; + let contentIdx = 0; + const rebuiltLines: string[] = []; + for (const seg of segments) { + if (contentIdx >= numContentToEmit) break; + if (seg.isBlank) { + rebuiltLines.push(''); + } else { + const newContent = newContents[contentIdx]; + let trailing = seg.trailingWs; + if (newContent.length > 0 && /\s$/.test(newContent)) { + trailing = ''; + } + const backslash = seg.hasBackslash ? '\\' : ''; + rebuiltLines.push(`${seg.indent}${newContent}${trailing}${backslash}`); + contentIdx++; + } + } + + if (hasClosingIndent) { + return `${openingPrefix}${rebuiltLines.join(newlineChar)}${newlineChar}${closingIndent}${delimiter}`; + } + return `${openingPrefix}${rebuiltLines.join(newlineChar)}${delimiter}`; +} + /** * Generates a new String node, preserving multiline format if existingRaw is provided. * @@ -147,7 +293,7 @@ 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 && isMultilineString(existingRaw)) { // Preserve multiline format @@ -185,8 +331,20 @@ export function generateString(value: string, existingRaw?: string): String { .replace(/"""/g, '""\\\"'); } - // Format with or without leading newline based on original - if (hasLeadingNewline) { + // Detect line-continuation backslashes anywhere in the multiline string body. + // A line ending with an odd number of backslashes is a continuation line. + // Line-continuation is only meaningful in basic (""") strings, not literal ('''). + const innerContent = existingRaw.slice(delimiter.length, existingRaw.length - delimiter.length); + const hasLineContinuation = !isLiteral && !escaped.includes(newlineChar) && + innerContent.split(newlineChar).some(line => { + const m = line.match(/(\\+)$/); + return m !== null && m[1].length % 2 === 1; + }); + + // Generate the replacement raw string, preserving the structural format of the existing raw. + if (hasLineContinuation) { + raw = rebuildLineContinuation(existingRaw, escaped, value, delimiter, newlineChar); + } else if (hasLeadingNewline) { raw = `${delimiter}${newlineChar}${escaped}${delimiter}`; } else { raw = `${delimiter}${escaped}${delimiter}`;