Skip to content

Commit cd8b6e2

Browse files
authored
Fix version catalog data loss and string corruption from comments and quotes (#3042)
2 parents 4d141bc + 9a10a9e commit cd8b6e2

6 files changed

Lines changed: 275 additions & 42 deletions

File tree

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
1111

1212
## [Unreleased]
1313

14+
### Fixed
15+
- `VersionCatalogStep` preserves entries when comments contain unmatched brackets, preserves commas inside quoted strings, and keeps significant line boundaries in multiline entries. ([#3042](https://github.com/diffplug/spotless/pull/3042))
16+
- `VersionCatalogStep` now reports unfinished entries as lints at their starting line. These fail formatting by default, so upgrading may expose catalog errors that previously caused silent data loss. ([#3042](https://github.com/diffplug/spotless/pull/3042))
17+
1418
## [4.10.2] - 2026-09-04
1519
### Fixed
1620
- `ShortenFullyQualifiedTypesStep` now shortens fully-qualified types used in expression contexts (such as static method calls, static fields, and enum constants) while avoiding imports that would change how existing unqualified type references resolve. ([#3039](https://github.com/diffplug/spotless/pull/3039))

lib/src/main/java/com/diffplug/spotless/toml/TODO.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ Tracked issues found during review against the TOML v1.1.0 spec (https://toml.io
1111
- [x] Multi-line inline tables are not parsed correctly (split across lines and corrupted)
1212
- [x] Long lines can be split into multi-line inline tables (configurable `maxLineLength`)
1313
- [x] Short multi-line inline tables are joined into single lines when they fit
14+
- [x] Commas inside literal strings and triple-quoted strings are preserved by `splitTopLevel`
15+
- [x] Escaped backslashes before closing quotes no longer confuse `splitTopLevel`
16+
- [x] Single-line strings cannot consume following entries across a newline; unfinished
17+
entries report a lint at their starting line instead of returning a partial catalog
1418

1519
## TODO — TOML spec edge cases
1620

@@ -27,10 +31,9 @@ addressed for full TOML spec compliance.
2731
(`[section.subsection]`) and quoted table headers (`["quoted.key"]`).
2832
Their entries are silently dropped.
2933

30-
### String handling in `splitTopLevel`
34+
### Scanner and formatting improvements
3135

32-
- [ ] Single-quoted (literal) strings `'...'` are not recognized — commas or `=` inside
33-
them will incorrectly split or match.
34-
- [ ] Multiline string delimiters (`"""`, `'''`) confuse the single-char quote toggle.
35-
- [ ] Double-backslash before closing quote (`"value\\"`) is misidentified as an escaped
36-
quote. Needs odd/even backslash counting instead of single-char lookbehind.
36+
- [ ] Carry scanner state across lines instead of rescanning the accumulated multiline
37+
entry after each line; the current approach is quadratic for long entries.
38+
- [ ] Normalize trailing whitespace outside strings in preserved multiline entries;
39+
whitespace inside multiline strings must remain unchanged.

lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java

Lines changed: 105 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import com.diffplug.spotless.FormatterFunc;
3030
import com.diffplug.spotless.FormatterStep;
31+
import com.diffplug.spotless.Lint;
3132

3233
public final class VersionCatalogStep {
3334
private VersionCatalogStep() {}
@@ -125,12 +126,15 @@ private static Map<String, List<Entry>> parseSections(String raw) {
125126
List<Entry> currentEntries = null;
126127
List<String> pendingComments = new ArrayList<>();
127128
StringBuilder multiLineAccumulator = null;
129+
int lineNumber = 0;
130+
int entryStartLine = 0;
128131

129132
for (String line : raw.split("\n", -1)) {
133+
lineNumber++;
130134
String trimmed = line.trim();
131135

132136
if (multiLineAccumulator != null) {
133-
multiLineAccumulator.append(' ').append(trimmed);
137+
multiLineAccumulator.append('\n').append(line);
134138
if (isBalanced(multiLineAccumulator.toString())) {
135139
Entry entry = new Entry(multiLineAccumulator.toString(), new ArrayList<>(pendingComments));
136140
currentEntries.add(entry);
@@ -153,7 +157,8 @@ private static Map<String, List<Entry>> parseSections(String raw) {
153157
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
154158
pendingComments.add(trimmed);
155159
} else if (!isBalanced(trimmed)) {
156-
multiLineAccumulator = new StringBuilder(trimmed);
160+
multiLineAccumulator = new StringBuilder(line.stripLeading());
161+
entryStartLine = lineNumber;
157162
} else {
158163
Entry entry = new Entry(trimmed, new ArrayList<>(pendingComments));
159164
currentEntries.add(entry);
@@ -162,30 +167,90 @@ private static Map<String, List<Entry>> parseSections(String raw) {
162167
}
163168
}
164169

170+
if (multiLineAccumulator != null) {
171+
// Report the incomplete entry instead of silently returning a partially parsed catalog.
172+
throw Lint.atLine(entryStartLine, "unterminatedEntry", "Unterminated version catalog entry in " + currentHeader).shortcut();
173+
}
165174
return sections;
166175
}
167176

168177
private static boolean isBalanced(String text) {
169178
int depth = 0;
170-
boolean inQuote = false;
171179

172180
for (int i = 0; i < text.length(); i++) {
173181
char c = text.charAt(i);
174-
if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) {
175-
inQuote = !inQuote;
176-
} else if (!inQuote) {
177-
if (c == '{' || c == '[') {
178-
depth++;
179-
} else if (c == '}' || c == ']') {
180-
depth--;
182+
if (c == '"' || c == '\'') {
183+
i = skipQuotedString(text, i);
184+
if (i == text.length()) {
185+
return false;
186+
}
187+
} else if (c == '#') {
188+
i = text.indexOf('\n', i);
189+
if (i == -1) {
190+
break;
181191
}
192+
} else if (c == '{' || c == '[') {
193+
depth++;
194+
} else if (c == '}' || c == ']') {
195+
depth--;
182196
}
183197
}
184198
return depth == 0;
185199
}
186200

201+
/** Returns the closing quote's index, or the text length if the string is unfinished. */
202+
private static int skipQuotedString(String text, int start) {
203+
char quote = text.charAt(start);
204+
boolean multiline = isMultilineString(text, start);
205+
for (int i = start + (multiline ? 3 : 1); i < text.length(); i++) {
206+
char c = text.charAt(i);
207+
if (!multiline && c == '\n') {
208+
return text.length();
209+
}
210+
if (quote == '"' && c == '\\' && i + 1 < text.length() && text.charAt(i + 1) != '\n') {
211+
i++;
212+
} else if (c == quote) {
213+
if (!multiline) {
214+
return i;
215+
}
216+
if (i + 2 < text.length() && text.charAt(i + 1) == quote && text.charAt(i + 2) == quote) {
217+
i += 2;
218+
// A multiline string may end with one or two additional literal quotes.
219+
while (i + 1 < text.length() && text.charAt(i + 1) == quote) {
220+
i++;
221+
}
222+
return i;
223+
}
224+
}
225+
}
226+
return text.length();
227+
}
228+
229+
/** Called only at an opening quote. */
230+
private static boolean isMultilineString(String text, int start) {
231+
char quote = text.charAt(start);
232+
return start + 2 < text.length() && text.charAt(start + 1) == quote && text.charAt(start + 2) == quote;
233+
}
234+
235+
private static boolean hasCommentsOrMultilineStrings(String text) {
236+
for (int i = 0; i < text.length(); i++) {
237+
char c = text.charAt(i);
238+
if (c == '"' || c == '\'') {
239+
if (isMultilineString(text, i)) {
240+
return true;
241+
}
242+
i = skipQuotedString(text, i);
243+
} else if (c == '#') {
244+
return true;
245+
}
246+
}
247+
return false;
248+
}
249+
187250
private static String extractKey(String formattedEntry) {
188-
Matcher matcher = ENTRY_LINE.matcher(formattedEntry);
251+
int lineEnd = formattedEntry.indexOf('\n');
252+
String firstLine = lineEnd == -1 ? formattedEntry : formattedEntry.substring(0, lineEnd);
253+
Matcher matcher = ENTRY_LINE.matcher(firstLine);
189254
if (!matcher.matches()) {
190255
return formattedEntry;
191256
}
@@ -197,7 +262,13 @@ private static String extractKey(String formattedEntry) {
197262
}
198263

199264
static String formatEntry(String entry, boolean stripQuotedKeys) {
200-
Matcher matcher = ENTRY_LINE.matcher(entry);
265+
int lineEnd = entry.indexOf('\n');
266+
// Preserve line boundaries that can be significant to comments or multiline strings.
267+
boolean preserveLines = lineEnd != -1 && hasCommentsOrMultilineStrings(entry);
268+
if (lineEnd != -1 && !preserveLines) {
269+
entry = String.join(" ", entry.lines().map(String::trim).toList());
270+
}
271+
Matcher matcher = ENTRY_LINE.matcher(preserveLines ? entry.substring(0, lineEnd) : entry);
201272
if (!matcher.matches()) {
202273
return entry;
203274
}
@@ -209,6 +280,10 @@ static String formatEntry(String entry, boolean stripQuotedKeys) {
209280
key = bare;
210281
}
211282
}
283+
if (preserveLines) {
284+
// The first line starts at offset zero, so the match's value offset also applies to the full entry.
285+
return key + " = " + entry.substring(matcher.start(2)).stripLeading();
286+
}
212287
String valueAndComment = matcher.group(2).trim();
213288

214289
String inlineComment = extractInlineComment(valueAndComment);
@@ -225,21 +300,18 @@ static String formatEntry(String entry, boolean stripQuotedKeys) {
225300
}
226301

227302
private static String extractInlineComment(String valueAndComment) {
228-
boolean inQuote = false;
229303
int depth = 0;
230304

231305
for (int i = 0; i < valueAndComment.length(); i++) {
232306
char c = valueAndComment.charAt(i);
233-
if (c == '"' && (i == 0 || valueAndComment.charAt(i - 1) != '\\')) {
234-
inQuote = !inQuote;
235-
} else if (!inQuote) {
236-
if (c == '{' || c == '[') {
237-
depth++;
238-
} else if (c == '}' || c == ']') {
239-
depth--;
240-
} else if (c == '#' && depth == 0) {
241-
return valueAndComment.substring(i);
242-
}
307+
if (c == '"' || c == '\'') {
308+
i = skipQuotedString(valueAndComment, i);
309+
} else if (c == '{' || c == '[') {
310+
depth++;
311+
} else if (c == '}' || c == ']') {
312+
depth--;
313+
} else if (c == '#' && depth == 0) {
314+
return valueAndComment.substring(i);
243315
}
244316
}
245317
return null;
@@ -316,22 +388,19 @@ private static String formatInlineArray(String value) {
316388
private static String[] splitTopLevel(String input, char delimiter) {
317389
List<String> parts = new ArrayList<>();
318390
int depth = 0;
319-
boolean inQuote = false;
320391
int start = 0;
321392

322393
for (int i = 0; i < input.length(); i++) {
323394
char c = input.charAt(i);
324-
if (c == '"' && (i == 0 || input.charAt(i - 1) != '\\')) {
325-
inQuote = !inQuote;
326-
} else if (!inQuote) {
327-
if (c == '{' || c == '[') {
328-
depth++;
329-
} else if (c == '}' || c == ']') {
330-
depth--;
331-
} else if (c == delimiter && depth == 0) {
332-
parts.add(input.substring(start, i));
333-
start = i + 1;
334-
}
395+
if (c == '"' || c == '\'') {
396+
i = skipQuotedString(input, i);
397+
} else if (c == '{' || c == '[') {
398+
depth++;
399+
} else if (c == '}' || c == ']') {
400+
depth--;
401+
} else if (c == delimiter && depth == 0) {
402+
parts.add(input.substring(start, i));
403+
start = i + 1;
335404
}
336405
}
337406
parts.add(input.substring(start));
@@ -345,7 +414,7 @@ private static boolean isBareKey(String key) {
345414
}
346415

347416
private static final class State implements Serializable {
348-
private static final long serialVersionUID = 3L;
417+
private static final long serialVersionUID = 5L;
349418

350419
private final boolean stripQuotedKeys;
351420

plugin-gradle/CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
44

55
## [Unreleased]
66

7+
### Fixed
8+
- `versionCatalog()` preserves entries when comments contain unmatched brackets, preserves commas inside quoted strings, and keeps significant line boundaries in multiline entries. ([#3042](https://github.com/diffplug/spotless/pull/3042))
9+
- `versionCatalog()` now reports unfinished entries as lints at their starting line. These fail formatting by default, so upgrading may expose catalog errors that previously caused silent data loss. ([#3042](https://github.com/diffplug/spotless/pull/3042))
10+
711
## [8.10.2] - 2026-09-04
812
### Fixed
913
- `shortenFullyQualifiedTypes()` now shortens fully-qualified types used in expression contexts (such as static method calls, static fields, and enum constants) while avoiding imports that would change how existing unqualified type references resolve. ([#3039](https://github.com/diffplug/spotless/pull/3039))

plugin-maven/CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
44

55
## [Unreleased]
66

7+
### Fixed
8+
- `<versionCatalog>` preserves entries when comments contain unmatched brackets, preserves commas inside quoted strings, and keeps significant line boundaries in multiline entries. ([#3042](https://github.com/diffplug/spotless/pull/3042))
9+
- `<versionCatalog>` now reports unfinished entries as lints at their starting line. These fail formatting by default, so upgrading may expose catalog errors that previously caused silent data loss. ([#3042](https://github.com/diffplug/spotless/pull/3042))
10+
711
## [3.10.2] - 2026-09-04
812
### Fixed
913
- `<shortenFullyQualifiedTypes>` now shortens fully-qualified types used in expression contexts (such as static method calls, static fields, and enum constants) while avoiding imports that would change how existing unqualified type references resolve. ([#3039](https://github.com/diffplug/spotless/pull/3039))

0 commit comments

Comments
 (0)