Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 109 additions & 12 deletions src/__tests__/patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `\<LF>` sequences (its raw-string cleanup regex), so these
// strings are written with explicit concatenation to control every character exactly.
//
// The TOML ` Hello \<LF> ` encodes value ` Hello `
// (8 spaces + "Hello " — the `\<LF><spaces>` 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 `\<LF><indent>` 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', () => {
Expand Down
164 changes: 161 additions & 3 deletions src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
* - `"""\<NL>content\<NL>indent"""` (leading line-continuation)
* - `"""<NL>content\<NL>..."""` (leading newline)
* - `"""content\<NL>..."""` (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}`)) {
// """\<NL> format — delimiter followed by line-continuation
bodyStart = delimiter.length + 1 + newlineChar.length;
openingPrefix = `${delimiter}\\${newlineChar}`;
} else if (existingRaw.startsWith(`${delimiter}${newlineChar}`)) {
// """<NL> 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 };
Comment on lines +210 to +211
}

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(' '));
}
Comment on lines +231 to +255

// Trim trailing empty content entries (when new value has fewer words)
Comment on lines +233 to +257
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.
*
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Comment on lines +334 to +347
raw = `${delimiter}${newlineChar}${escaped}${delimiter}`;
} else {
raw = `${delimiter}${escaped}${delimiter}`;
Expand Down