From f57a102f6e4bd02912b7c8bdd85b9f4ec211b56e Mon Sep 17 00:00:00 2001 From: Michael Kutz Date: Mon, 4 May 2026 10:08:39 +0200 Subject: [PATCH 1/5] Add TOML version catalog formatter step (#2916) Add a dedicated FormatterStep for formatting and sorting Gradle version catalog files (libs.versions.toml). The step normalizes spacing around operators and inside inline tables/arrays, sorts entries alphabetically, and orders tables canonically ([versions], [libraries], [bundles], [plugins]). --- .../spotless/toml/VersionCatalogStep.java | 235 ++++++++++++++++++ .../gradle/spotless/SpotlessExtension.java | 5 + .../gradle/spotless/TomlExtension.java | 52 ++++ .../spotless/maven/AbstractSpotlessMojo.java | 6 +- .../diffplug/spotless/maven/toml/Toml.java | 39 +++ .../spotless/maven/toml/VersionCatalog.java | 28 +++ .../resources/toml/versionCatalogClean.toml | 22 ++ .../resources/toml/versionCatalogDirty.toml | 22 ++ .../spotless/toml/VersionCatalogStepTest.java | 91 +++++++ 9 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java create mode 100644 plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java create mode 100644 plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/Toml.java create mode 100644 plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java create mode 100644 testlib/src/main/resources/toml/versionCatalogClean.toml create mode 100644 testlib/src/main/resources/toml/versionCatalogDirty.toml create mode 100644 testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java diff --git a/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java b/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java new file mode 100644 index 0000000000..aad2a1531e --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java @@ -0,0 +1,235 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.toml; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.diffplug.spotless.FormatterStep; + +public final class VersionCatalogStep { + private VersionCatalogStep() {} + + private static final String NAME = "versionCatalog"; + + private static final List TABLE_ORDER = Arrays.asList( + "[versions]", + "[libraries]", + "[bundles]", + "[plugins]"); + + private static final Pattern TABLE_HEADER = Pattern.compile("^\\[([a-zA-Z0-9_-]+)]\\s*$"); + private static final Pattern ENTRY_LINE = Pattern.compile("^([^=]+)=(.+)$"); + + public static FormatterStep create() { + return FormatterStep.create(NAME, + VersionCatalogStep.class, + unused -> VersionCatalogStep::format); + } + + static String format(String raw) { + if (raw.trim().isEmpty()) { + return raw; + } + + Map> sections = parseSections(raw); + List preambleLines = extractPreamble(raw); + + StringBuilder result = new StringBuilder(); + + for (String line : preambleLines) { + result.append(line).append('\n'); + } + + boolean first = preambleLines.isEmpty(); + + List orderedKeys = new ArrayList<>(); + for (String key : TABLE_ORDER) { + if (sections.containsKey(key)) { + orderedKeys.add(key); + } + } + for (String key : sections.keySet()) { + if (!TABLE_ORDER.contains(key)) { + orderedKeys.add(key); + } + } + + for (String header : orderedKeys) { + List entries = sections.get(header); + if (!first) { + result.append('\n'); + } + first = false; + result.append(header).append('\n'); + List formatted = new ArrayList<>(); + for (String entry : entries) { + formatted.add(formatEntry(entry)); + } + Collections.sort(formatted); + for (String entry : formatted) { + result.append(entry).append('\n'); + } + } + + return result.toString(); + } + + private static List extractPreamble(String raw) { + List preamble = new ArrayList<>(); + for (String line : raw.split("\n", -1)) { + if (TABLE_HEADER.matcher(line.trim()).matches()) { + break; + } + String trimmed = line.trim(); + if (!trimmed.isEmpty()) { + preamble.add(trimmed); + } + } + return preamble; + } + + private static Map> parseSections(String raw) { + Map> sections = new LinkedHashMap<>(); + String currentHeader = null; + List currentEntries = null; + + for (String line : raw.split("\n", -1)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || (currentHeader == null && !TABLE_HEADER.matcher(trimmed).matches())) { + continue; + } + Matcher headerMatcher = TABLE_HEADER.matcher(trimmed); + if (headerMatcher.matches()) { + currentHeader = "[" + headerMatcher.group(1) + "]"; + currentEntries = new ArrayList<>(); + sections.put(currentHeader, currentEntries); + } else if (currentEntries != null && !trimmed.startsWith("#")) { + currentEntries.add(trimmed); + } + } + + return sections; + } + + static String formatEntry(String entry) { + Matcher matcher = ENTRY_LINE.matcher(entry); + if (!matcher.matches()) { + return entry; + } + + String key = matcher.group(1).trim(); + String value = matcher.group(2).trim(); + + value = formatValue(value); + + return key + " = " + value; + } + + private static String formatValue(String value) { + if (value.startsWith("{")) { + return formatInlineTable(value); + } + if (value.startsWith("[")) { + return formatInlineArray(value); + } + return value; + } + + private static String formatInlineTable(String value) { + if (!value.startsWith("{") || !value.endsWith("}")) { + return value; + } + + String inner = value.substring(1, value.length() - 1).trim(); + if (inner.isEmpty()) { + return "{}"; + } + + String[] pairs = splitTopLevel(inner, ','); + StringBuilder result = new StringBuilder("{ "); + for (int i = 0; i < pairs.length; i++) { + if (i > 0) { + result.append(", "); + } + String pair = pairs[i].trim(); + Matcher pairMatcher = ENTRY_LINE.matcher(pair); + if (pairMatcher.matches()) { + String pairKey = pairMatcher.group(1).trim(); + String pairValue = pairMatcher.group(2).trim(); + pairValue = formatValue(pairValue); + result.append(pairKey).append(" = ").append(pairValue); + } else { + result.append(pair); + } + } + result.append(" }"); + return result.toString(); + } + + private static String formatInlineArray(String value) { + if (!value.startsWith("[") || !value.endsWith("]")) { + return value; + } + + String inner = value.substring(1, value.length() - 1).trim(); + if (inner.isEmpty()) { + return "[]"; + } + + String[] elements = splitTopLevel(inner, ','); + StringBuilder result = new StringBuilder("[ "); + for (int i = 0; i < elements.length; i++) { + if (i > 0) { + result.append(", "); + } + result.append(elements[i].trim()); + } + result.append(" ]"); + return result.toString(); + } + + private static String[] splitTopLevel(String input, char delimiter) { + List parts = new ArrayList<>(); + int depth = 0; + boolean inQuote = false; + int start = 0; + + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (c == '"' && (i == 0 || input.charAt(i - 1) != '\\')) { + inQuote = !inQuote; + } else if (!inQuote) { + if (c == '{' || c == '[') { + depth++; + } else if (c == '}' || c == ']') { + depth--; + } else if (c == delimiter && depth == 0) { + parts.add(input.substring(start, i)); + start = i + 1; + } + } + } + parts.add(input.substring(start)); + return parts.toArray(new String[0]); + } +} diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java index 26e91eba8c..019b3588ff 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessExtension.java @@ -234,6 +234,11 @@ public void gherkin(Action closure) { format(GherkinExtension.NAME, GherkinExtension.class, closure); } + public void toml(Action closure) { + requireNonNull(closure); + format(TomlExtension.NAME, TomlExtension.class, closure); + } + public void go(Action closure) { requireNonNull(closure); format(GoExtension.NAME, GoExtension.class, closure); diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java new file mode 100644 index 0000000000..7de1b3ad9f --- /dev/null +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.gradle.spotless; + +import javax.inject.Inject; + +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.toml.VersionCatalogStep; + +public class TomlExtension extends FormatExtension { + static final String NAME = "toml"; + + @Inject + public TomlExtension(SpotlessExtension spotless) { + super(spotless); + } + + @Override + protected void setupTask(SpotlessTask task) { + if (target == null) { + throw noDefaultTargetException(); + } + super.setupTask(task); + } + + public VersionCatalogConfig versionCatalog() { + return new VersionCatalogConfig(); + } + + public class VersionCatalogConfig { + public VersionCatalogConfig() { + addStep(createStep()); + } + + private FormatterStep createStep() { + return VersionCatalogStep.create(); + } + } +} diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java index 26c3e602c4..5fdef8a802 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/AbstractSpotlessMojo.java @@ -83,6 +83,7 @@ import com.diffplug.spotless.maven.shell.Shell; import com.diffplug.spotless.maven.sql.Sql; import com.diffplug.spotless.maven.tabletest.TableTest; +import com.diffplug.spotless.maven.toml.Toml; import com.diffplug.spotless.maven.typescript.Typescript; import com.diffplug.spotless.maven.yaml.Yaml; @@ -212,6 +213,9 @@ public abstract class AbstractSpotlessMojo extends AbstractMojo { @Parameter private TableTest tableTest; + @Parameter + private Toml toml; + @Parameter(property = "spotlessFiles") private String filePatterns; @@ -414,7 +418,7 @@ private FileLocator getFileLocator() { } private List getFormatterFactories() { - return Stream.concat(formats.stream(), Stream.of(groovy, java, scala, kotlin, cpp, css, typescript, javascript, antlr4, pom, sql, python, markdown, json, shell, yaml, gherkin, go, rdf, protobuf, tableTest)) + return Stream.concat(formats.stream(), Stream.of(groovy, java, scala, kotlin, cpp, css, typescript, javascript, antlr4, pom, sql, python, markdown, json, shell, yaml, gherkin, go, rdf, protobuf, tableTest, toml)) .filter(Objects::nonNull) .map(factory -> factory.init(repositorySystemSession)) .collect(toList()); diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/Toml.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/Toml.java new file mode 100644 index 0000000000..5b5baccc39 --- /dev/null +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/Toml.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.maven.toml; + +import java.util.Collections; +import java.util.Set; + +import org.apache.maven.project.MavenProject; + +import com.diffplug.spotless.maven.FormatterFactory; + +public class Toml extends FormatterFactory { + @Override + public Set defaultIncludes(MavenProject project) { + return Collections.emptySet(); + } + + @Override + public String licenseHeaderDelimiter() { + return null; + } + + public void addVersionCatalog(VersionCatalog versionCatalog) { + addStepFactory(versionCatalog); + } +} diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java new file mode 100644 index 0000000000..d68ed2f53d --- /dev/null +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.maven.toml; + +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.maven.FormatterStepConfig; +import com.diffplug.spotless.maven.FormatterStepFactory; +import com.diffplug.spotless.toml.VersionCatalogStep; + +public class VersionCatalog implements FormatterStepFactory { + @Override + public FormatterStep newFormatterStep(FormatterStepConfig config) { + return VersionCatalogStep.create(); + } +} diff --git a/testlib/src/main/resources/toml/versionCatalogClean.toml b/testlib/src/main/resources/toml/versionCatalogClean.toml new file mode 100644 index 0000000000..c24fb4765e --- /dev/null +++ b/testlib/src/main/resources/toml/versionCatalogClean.toml @@ -0,0 +1,22 @@ +[versions] +assertj = "3.27.7" +junit = "6.0.3" +testcontainers = "2.0.5" + +[libraries] +assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } +junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } +junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +testcontainers-junit-jupiter = { module = "org.testcontainers:testcontainers-junit-jupiter", version.ref = "testcontainers" } +testcontainers-postgresql = { module = "org.testcontainers:testcontainers-postgresql", version.ref = "testcontainers" } + +[bundles] +junit = [ "junit-jupiter-api", "junit-jupiter-params" ] + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.3.21" } +sonar = { id = "org.sonarqube", version = "7.3.0.8198" } +spotless = { id = "com.diffplug.spotless", version = "7.2.1" } diff --git a/testlib/src/main/resources/toml/versionCatalogDirty.toml b/testlib/src/main/resources/toml/versionCatalogDirty.toml new file mode 100644 index 0000000000..707a9fc644 --- /dev/null +++ b/testlib/src/main/resources/toml/versionCatalogDirty.toml @@ -0,0 +1,22 @@ +[versions] +assertj="3.27.7" +testcontainers= "2.0.5" +junit ="6.0.3" + +[libraries] +junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } +junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api" } +junit-jupiter-engine = {module = "org.junit.jupiter:junit-jupiter-engine"} +junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params" } +junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +assertj-core = { module = "org.assertj:assertj-core", version.ref="assertj" } +testcontainers-junit-jupiter= { module = "org.testcontainers:testcontainers-junit-jupiter", version.ref = "testcontainers" } +testcontainers-postgresql ={ module = "org.testcontainers:testcontainers-postgresql", version.ref = "testcontainers" } + +[plugins] +sonar = { id = "org.sonarqube", version= "7.3.0.8198" } +spotless = { id = "com.diffplug.spotless", version= "7.2.1" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.3.21" } + +[bundles] +junit = ["junit-jupiter-api","junit-jupiter-params"] diff --git a/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java b/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java new file mode 100644 index 0000000000..4cd1c6921b --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless.toml; + +import org.junit.jupiter.api.Test; + +import com.diffplug.spotless.FormatterStep; +import com.diffplug.spotless.SerializableEqualityTester; +import com.diffplug.spotless.StepHarness; + +class VersionCatalogStepTest { + @Test + void behavior() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.testResource("toml/versionCatalogDirty.toml", "toml/versionCatalogClean.toml"); + } + + @Test + void idempotent() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.testResourceUnaffected("toml/versionCatalogClean.toml"); + } + + @Test + void spacingAroundEquals() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[versions]\nfoo=\"1.0\"\n", + "[versions]\nfoo = \"1.0\"\n"); + } + + @Test + void inlineTableSpacing() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[libraries]\nfoo = {module=\"org:foo\",version.ref=\"bar\"}\n", + "[libraries]\nfoo = { module = \"org:foo\", version.ref = \"bar\" }\n"); + } + + @Test + void inlineArraySpacing() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[bundles]\nfoo = [\"a\",\"b\"]\n", + "[bundles]\nfoo = [ \"a\", \"b\" ]\n"); + } + + @Test + void sortEntries() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[versions]\nzoo = \"1.0\"\nalpha = \"2.0\"\n", + "[versions]\nalpha = \"2.0\"\nzoo = \"1.0\"\n"); + } + + @Test + void sortTables() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[plugins]\na = \"1\"\n\n[versions]\nb = \"2\"\n", + "[versions]\nb = \"2\"\n\n[plugins]\na = \"1\"\n"); + } + + @Test + void equality() throws Exception { + new SerializableEqualityTester() { + @Override + protected void setupTest(API api) { + api.areDifferentThan(); + } + + @Override + protected FormatterStep create() { + return VersionCatalogStep.create(); + } + }.testEquals(); + } +} From a405e7af1ee2493833cc427286306bf804f39605 Mon Sep 17 00:00:00 2001 From: Michael Kutz Date: Mon, 4 May 2026 10:19:39 +0200 Subject: [PATCH 2/5] Fix comment handling, inline comments, preamble, and sort order - Preserve comments inside sections by attaching them to the entry below, so they travel with the entry during sorting - Strip and re-attach inline comments (# ...) before formatting inline tables/arrays - Preserve blank lines in the file preamble - Sort entries by logical key name, stripping surrounding quotes - Add TODO.md documenting remaining TOML spec edge cases --- .../java/com/diffplug/spotless/toml/TODO.md | 35 ++++++ .../spotless/toml/VersionCatalogStep.java | 107 ++++++++++++++---- .../spotless/toml/VersionCatalogStepTest.java | 40 +++++++ 3 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 lib/src/main/java/com/diffplug/spotless/toml/TODO.md diff --git a/lib/src/main/java/com/diffplug/spotless/toml/TODO.md b/lib/src/main/java/com/diffplug/spotless/toml/TODO.md new file mode 100644 index 0000000000..576a3660e6 --- /dev/null +++ b/lib/src/main/java/com/diffplug/spotless/toml/TODO.md @@ -0,0 +1,35 @@ +# VersionCatalogStep — Known Limitations & Future Work + +Tracked issues found during review against the TOML v1.1.0 spec (https://toml.io/en/v1.1.0). + +## Fixed + +- [x] Comments inside sections are silently dropped (data loss for annotated catalogs) +- [x] Inline comments after values break inline-table/array formatting +- [x] Blank lines in preamble are dropped +- [x] Sort uses raw string including quote characters instead of logical key name + +## TODO — TOML spec edge cases + +These are valid TOML constructs that the current implementation does not handle correctly. +They are uncommon (or impossible) in typical Gradle version catalogs, but should be +addressed for full TOML spec compliance. + +### Parsing + +- [ ] `ENTRY_LINE` regex `^([^=]+)=(.+)$` is not quote-aware — breaks on quoted keys + containing `=`, e.g. `"key=val" = "foo"`. Needs a state-machine parser to find + the separator `=` outside quoting context. +- [ ] `TABLE_HEADER` regex only matches bare keys — rejects dotted table headers + (`[section.subsection]`) and quoted table headers (`["quoted.key"]`). + Their entries are silently dropped. +- [ ] Multi-line values are not handled — TOML v1.1.0 allows newlines inside inline + tables and arrays. A multi-line inline table is split across lines and corrupted. + +### String handling in `splitTopLevel` + +- [ ] Single-quoted (literal) strings `'...'` are not recognized — commas or `=` inside + them will incorrectly split or match. +- [ ] Multiline string delimiters (`"""`, `'''`) confuse the single-char quote toggle. +- [ ] Double-backslash before closing quote (`"value\\"`) is misidentified as an escaped + quote. Needs odd/even backslash counting instead of single-char lookbehind. diff --git a/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java b/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java index aad2a1531e..fb8f3662d8 100644 --- a/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java +++ b/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -51,7 +52,7 @@ static String format(String raw) { return raw; } - Map> sections = parseSections(raw); + Map> sections = parseSections(raw); List preambleLines = extractPreamble(raw); StringBuilder result = new StringBuilder(); @@ -75,19 +76,23 @@ static String format(String raw) { } for (String header : orderedKeys) { - List entries = sections.get(header); + List entries = sections.get(header); if (!first) { result.append('\n'); } first = false; result.append(header).append('\n'); - List formatted = new ArrayList<>(); - for (String entry : entries) { - formatted.add(formatEntry(entry)); + + for (Entry entry : entries) { + entry.formatted = formatEntry(entry.content); } - Collections.sort(formatted); - for (String entry : formatted) { - result.append(entry).append('\n'); + Collections.sort(entries, Comparator.comparing(Entry::sortKey)); + + for (Entry entry : entries) { + for (String commentLine : entry.leadingComments) { + result.append(commentLine).append('\n'); + } + result.append(entry.formatted).append('\n'); } } @@ -100,22 +105,23 @@ private static List extractPreamble(String raw) { if (TABLE_HEADER.matcher(line.trim()).matches()) { break; } - String trimmed = line.trim(); - if (!trimmed.isEmpty()) { - preamble.add(trimmed); - } + preamble.add(line); + } + while (!preamble.isEmpty() && preamble.get(preamble.size() - 1).trim().isEmpty()) { + preamble.remove(preamble.size() - 1); } return preamble; } - private static Map> parseSections(String raw) { - Map> sections = new LinkedHashMap<>(); + private static Map> parseSections(String raw) { + Map> sections = new LinkedHashMap<>(); String currentHeader = null; - List currentEntries = null; + List currentEntries = null; + List pendingComments = new ArrayList<>(); for (String line : raw.split("\n", -1)) { String trimmed = line.trim(); - if (trimmed.isEmpty() || (currentHeader == null && !TABLE_HEADER.matcher(trimmed).matches())) { + if (currentHeader == null && !TABLE_HEADER.matcher(trimmed).matches()) { continue; } Matcher headerMatcher = TABLE_HEADER.matcher(trimmed); @@ -123,14 +129,33 @@ private static Map> parseSections(String raw) { currentHeader = "[" + headerMatcher.group(1) + "]"; currentEntries = new ArrayList<>(); sections.put(currentHeader, currentEntries); - } else if (currentEntries != null && !trimmed.startsWith("#")) { - currentEntries.add(trimmed); + pendingComments.clear(); + } else if (currentEntries != null) { + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + pendingComments.add(trimmed); + } else { + Entry entry = new Entry(trimmed, new ArrayList<>(pendingComments)); + currentEntries.add(entry); + pendingComments.clear(); + } } } return sections; } + private static String extractKey(String formattedEntry) { + Matcher matcher = ENTRY_LINE.matcher(formattedEntry); + if (!matcher.matches()) { + return formattedEntry; + } + String key = matcher.group(1).trim(); + if (key.startsWith("\"") && key.endsWith("\"")) { + return key.substring(1, key.length() - 1); + } + return key; + } + static String formatEntry(String entry) { Matcher matcher = ENTRY_LINE.matcher(entry); if (!matcher.matches()) { @@ -138,13 +163,42 @@ static String formatEntry(String entry) { } String key = matcher.group(1).trim(); - String value = matcher.group(2).trim(); + String valueAndComment = matcher.group(2).trim(); + + String inlineComment = extractInlineComment(valueAndComment); + String value = inlineComment != null + ? valueAndComment.substring(0, valueAndComment.length() - inlineComment.length()).trim() + : valueAndComment; value = formatValue(value); + if (inlineComment != null) { + return key + " = " + value + " " + inlineComment; + } return key + " = " + value; } + private static String extractInlineComment(String valueAndComment) { + boolean inQuote = false; + int depth = 0; + + for (int i = 0; i < valueAndComment.length(); i++) { + char c = valueAndComment.charAt(i); + if (c == '"' && (i == 0 || valueAndComment.charAt(i - 1) != '\\')) { + inQuote = !inQuote; + } else if (!inQuote) { + if (c == '{' || c == '[') { + depth++; + } else if (c == '}' || c == ']') { + depth--; + } else if (c == '#' && depth == 0) { + return valueAndComment.substring(i); + } + } + } + return null; + } + private static String formatValue(String value) { if (value.startsWith("{")) { return formatInlineTable(value); @@ -232,4 +286,19 @@ private static String[] splitTopLevel(String input, char delimiter) { parts.add(input.substring(start)); return parts.toArray(new String[0]); } + + private static final class Entry { + final String content; + final List leadingComments; + String formatted; + + Entry(String content, List leadingComments) { + this.content = content; + this.leadingComments = leadingComments; + } + + String sortKey() { + return extractKey(formatted != null ? formatted : content); + } + } } diff --git a/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java b/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java index 4cd1c6921b..8399788073 100644 --- a/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java @@ -74,6 +74,46 @@ void sortTables() throws Exception { "[versions]\nb = \"2\"\n\n[plugins]\na = \"1\"\n"); } + @Test + void commentsPreserved() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[versions]\n# Z library\nzoo = \"1.0\"\n# A library\nalpha = \"2.0\"\n", + "[versions]\n# A library\nalpha = \"2.0\"\n# Z library\nzoo = \"1.0\"\n"); + } + + @Test + void inlineCommentsPreserved() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[versions]\nfoo =\"1.0\" # latest stable\n", + "[versions]\nfoo = \"1.0\" # latest stable\n"); + } + + @Test + void inlineCommentOnInlineTable() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[libraries]\nfoo = {module=\"org:foo\",version.ref=\"bar\"} # important\n", + "[libraries]\nfoo = { module = \"org:foo\", version.ref = \"bar\" } # important\n"); + } + + @Test + void preamblePreserved() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "# Catalog header\n\n# Generated\n\n[versions]\nfoo = \"1.0\"\n", + "# Catalog header\n\n# Generated\n\n[versions]\nfoo = \"1.0\"\n"); + } + + @Test + void quotedKeySortsByLogicalName() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[versions]\n\"zoo\" = \"1.0\"\nalpha = \"2.0\"\n", + "[versions]\nalpha = \"2.0\"\n\"zoo\" = \"1.0\"\n"); + } + @Test void equality() throws Exception { new SerializableEqualityTester() { From 2992ff5f5270d7760e620f792b66e6b63d88be6f Mon Sep 17 00:00:00 2001 From: Michael Kutz Date: Mon, 4 May 2026 10:25:32 +0200 Subject: [PATCH 3/5] Add opt-in stripQuotedKeys option for version catalog step Adds a stripQuotedKeys parameter (default false) that removes unnecessary quotes from keys when the unquoted form is a valid bare TOML key. Keys like "foo.bar" that would change semantics are preserved. --- .../spotless/toml/VersionCatalogStep.java | 44 ++++++++++++++++--- .../gradle/spotless/TomlExtension.java | 10 ++++- .../spotless/maven/toml/VersionCatalog.java | 8 +++- .../spotless/toml/VersionCatalogStepTest.java | 30 ++++++++++++- 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java b/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java index fb8f3662d8..3e022a0606 100644 --- a/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java +++ b/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java @@ -15,6 +15,7 @@ */ package com.diffplug.spotless.toml; +import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -25,6 +26,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import com.diffplug.spotless.FormatterFunc; import com.diffplug.spotless.FormatterStep; public final class VersionCatalogStep { @@ -42,12 +44,16 @@ private VersionCatalogStep() {} private static final Pattern ENTRY_LINE = Pattern.compile("^([^=]+)=(.+)$"); public static FormatterStep create() { - return FormatterStep.create(NAME, - VersionCatalogStep.class, - unused -> VersionCatalogStep::format); + return create(false); } - static String format(String raw) { + public static FormatterStep create(boolean stripQuotedKeys) { + return FormatterStep.createLazy(NAME, + () -> new State(stripQuotedKeys), + State::toFormatter); + } + + static String format(String raw, boolean stripQuotedKeys) { if (raw.trim().isEmpty()) { return raw; } @@ -84,7 +90,7 @@ static String format(String raw) { result.append(header).append('\n'); for (Entry entry : entries) { - entry.formatted = formatEntry(entry.content); + entry.formatted = formatEntry(entry.content, stripQuotedKeys); } Collections.sort(entries, Comparator.comparing(Entry::sortKey)); @@ -156,13 +162,19 @@ private static String extractKey(String formattedEntry) { return key; } - static String formatEntry(String entry) { + static String formatEntry(String entry, boolean stripQuotedKeys) { Matcher matcher = ENTRY_LINE.matcher(entry); if (!matcher.matches()) { return entry; } String key = matcher.group(1).trim(); + if (stripQuotedKeys && key.startsWith("\"") && key.endsWith("\"")) { + String bare = key.substring(1, key.length() - 1); + if (isBareKey(bare)) { + key = bare; + } + } String valueAndComment = matcher.group(2).trim(); String inlineComment = extractInlineComment(valueAndComment); @@ -287,6 +299,26 @@ private static String[] splitTopLevel(String input, char delimiter) { return parts.toArray(new String[0]); } + private static final Pattern BARE_KEY = Pattern.compile("^[a-zA-Z0-9_-]+$"); + + private static boolean isBareKey(String key) { + return BARE_KEY.matcher(key).matches(); + } + + private static final class State implements Serializable { + private static final long serialVersionUID = 1L; + + private final boolean stripQuotedKeys; + + State(boolean stripQuotedKeys) { + this.stripQuotedKeys = stripQuotedKeys; + } + + FormatterFunc toFormatter() { + return raw -> format(raw, stripQuotedKeys); + } + } + private static final class Entry { final String content; final List leadingComments; diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java index 7de1b3ad9f..1bb6343dd2 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java @@ -41,12 +41,20 @@ public VersionCatalogConfig versionCatalog() { } public class VersionCatalogConfig { + private boolean stripQuotedKeys; + public VersionCatalogConfig() { + this.stripQuotedKeys = false; addStep(createStep()); } + public void stripQuotedKeys(boolean stripQuotedKeys) { + this.stripQuotedKeys = stripQuotedKeys; + replaceStep(createStep()); + } + private FormatterStep createStep() { - return VersionCatalogStep.create(); + return VersionCatalogStep.create(stripQuotedKeys); } } } diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java index d68ed2f53d..42dc2ab324 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java @@ -15,14 +15,20 @@ */ package com.diffplug.spotless.maven.toml; +import org.apache.maven.plugins.annotations.Parameter; + import com.diffplug.spotless.FormatterStep; import com.diffplug.spotless.maven.FormatterStepConfig; import com.diffplug.spotless.maven.FormatterStepFactory; import com.diffplug.spotless.toml.VersionCatalogStep; public class VersionCatalog implements FormatterStepFactory { + + @Parameter + private boolean stripQuotedKeys; + @Override public FormatterStep newFormatterStep(FormatterStepConfig config) { - return VersionCatalogStep.create(); + return VersionCatalogStep.create(stripQuotedKeys); } } diff --git a/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java b/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java index 8399788073..c79bd33c07 100644 --- a/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java @@ -114,17 +114,45 @@ void quotedKeySortsByLogicalName() throws Exception { "[versions]\nalpha = \"2.0\"\n\"zoo\" = \"1.0\"\n"); } + @Test + void stripQuotedKeysDisabledByDefault() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[versions]\n\"foo\" = \"1.0\"\n", + "[versions]\n\"foo\" = \"1.0\"\n"); + } + + @Test + void stripQuotedKeysEnabled() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create(true)); + harness.test( + "[versions]\n\"foo\" = \"1.0\"\n", + "[versions]\nfoo = \"1.0\"\n"); + } + + @Test + void stripQuotedKeysPreservesNonBareKeys() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create(true)); + harness.test( + "[versions]\n\"foo.bar\" = \"1.0\"\n", + "[versions]\n\"foo.bar\" = \"1.0\"\n"); + } + @Test void equality() throws Exception { new SerializableEqualityTester() { + boolean stripQuotedKeys; + @Override protected void setupTest(API api) { api.areDifferentThan(); + stripQuotedKeys = true; + api.areDifferentThan(); } @Override protected FormatterStep create() { - return VersionCatalogStep.create(); + return VersionCatalogStep.create(stripQuotedKeys); } }.testEquals(); } From c9fb959823d12e57be5025d305860e4ab0f4158d Mon Sep 17 00:00:00 2001 From: Michael Kutz Date: Mon, 4 May 2026 10:48:51 +0200 Subject: [PATCH 4/5] Handle multi-line inline tables and add maxLineLength option - Parse multi-line inline tables by tracking brace/bracket depth and joining continuation lines into a single entry - Add configurable maxLineLength (default 120): lines exceeding the limit split their inline table across multiple lines (one pair per line, 2-space indent, trailing commas); multi-line tables that fit are joined into a single line - Update TODO.md with fixed items --- .../java/com/diffplug/spotless/toml/TODO.md | 6 +- .../spotless/toml/VersionCatalogStep.java | 116 ++++++++++++++++-- .../gradle/spotless/TomlExtension.java | 8 +- .../spotless/maven/toml/VersionCatalog.java | 5 +- .../resources/toml/versionCatalogClean.toml | 5 +- .../spotless/toml/VersionCatalogStepTest.java | 44 ++++++- 6 files changed, 166 insertions(+), 18 deletions(-) diff --git a/lib/src/main/java/com/diffplug/spotless/toml/TODO.md b/lib/src/main/java/com/diffplug/spotless/toml/TODO.md index 576a3660e6..19ffdaa8f6 100644 --- a/lib/src/main/java/com/diffplug/spotless/toml/TODO.md +++ b/lib/src/main/java/com/diffplug/spotless/toml/TODO.md @@ -8,6 +8,9 @@ Tracked issues found during review against the TOML v1.1.0 spec (https://toml.io - [x] Inline comments after values break inline-table/array formatting - [x] Blank lines in preamble are dropped - [x] Sort uses raw string including quote characters instead of logical key name +- [x] Multi-line inline tables are not parsed correctly (split across lines and corrupted) +- [x] Long lines can be split into multi-line inline tables (configurable `maxLineLength`) +- [x] Short multi-line inline tables are joined into single lines when they fit ## TODO — TOML spec edge cases @@ -23,9 +26,6 @@ addressed for full TOML spec compliance. - [ ] `TABLE_HEADER` regex only matches bare keys — rejects dotted table headers (`[section.subsection]`) and quoted table headers (`["quoted.key"]`). Their entries are silently dropped. -- [ ] Multi-line values are not handled — TOML v1.1.0 allows newlines inside inline - tables and arrays. A multi-line inline table is split across lines and corrupted. - ### String handling in `splitTopLevel` - [ ] Single-quoted (literal) strings `'...'` are not recognized — commas or `=` inside diff --git a/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java b/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java index 3e022a0606..a6880b63b5 100644 --- a/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java +++ b/lib/src/main/java/com/diffplug/spotless/toml/VersionCatalogStep.java @@ -33,6 +33,7 @@ public final class VersionCatalogStep { private VersionCatalogStep() {} private static final String NAME = "versionCatalog"; + private static final int DEFAULT_MAX_LINE_LENGTH = 120; private static final List TABLE_ORDER = Arrays.asList( "[versions]", @@ -44,16 +45,20 @@ private VersionCatalogStep() {} private static final Pattern ENTRY_LINE = Pattern.compile("^([^=]+)=(.+)$"); public static FormatterStep create() { - return create(false); + return create(false, DEFAULT_MAX_LINE_LENGTH); } public static FormatterStep create(boolean stripQuotedKeys) { + return create(stripQuotedKeys, DEFAULT_MAX_LINE_LENGTH); + } + + public static FormatterStep create(boolean stripQuotedKeys, int maxLineLength) { return FormatterStep.createLazy(NAME, - () -> new State(stripQuotedKeys), + () -> new State(stripQuotedKeys, maxLineLength), State::toFormatter); } - static String format(String raw, boolean stripQuotedKeys) { + static String format(String raw, boolean stripQuotedKeys, int maxLineLength) { if (raw.trim().isEmpty()) { return raw; } @@ -90,7 +95,8 @@ static String format(String raw, boolean stripQuotedKeys) { result.append(header).append('\n'); for (Entry entry : entries) { - entry.formatted = formatEntry(entry.content, stripQuotedKeys); + String formatted = formatEntry(entry.content, stripQuotedKeys); + entry.formatted = applyLineLength(formatted, maxLineLength); } Collections.sort(entries, Comparator.comparing(Entry::sortKey)); @@ -124,9 +130,22 @@ private static Map> parseSections(String raw) { String currentHeader = null; List currentEntries = null; List pendingComments = new ArrayList<>(); + StringBuilder multiLineAccumulator = null; for (String line : raw.split("\n", -1)) { String trimmed = line.trim(); + + if (multiLineAccumulator != null) { + multiLineAccumulator.append(' ').append(trimmed); + if (isBalanced(multiLineAccumulator.toString())) { + Entry entry = new Entry(multiLineAccumulator.toString(), new ArrayList<>(pendingComments)); + currentEntries.add(entry); + pendingComments.clear(); + multiLineAccumulator = null; + } + continue; + } + if (currentHeader == null && !TABLE_HEADER.matcher(trimmed).matches()) { continue; } @@ -139,6 +158,8 @@ private static Map> parseSections(String raw) { } else if (currentEntries != null) { if (trimmed.isEmpty() || trimmed.startsWith("#")) { pendingComments.add(trimmed); + } else if (!isBalanced(trimmed)) { + multiLineAccumulator = new StringBuilder(trimmed); } else { Entry entry = new Entry(trimmed, new ArrayList<>(pendingComments)); currentEntries.add(entry); @@ -150,8 +171,28 @@ private static Map> parseSections(String raw) { return sections; } + private static boolean isBalanced(String text) { + int depth = 0; + boolean inQuote = false; + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' && (i == 0 || text.charAt(i - 1) != '\\')) { + inQuote = !inQuote; + } else if (!inQuote) { + if (c == '{' || c == '[') { + depth++; + } else if (c == '}' || c == ']') { + depth--; + } + } + } + return depth == 0; + } + private static String extractKey(String formattedEntry) { - Matcher matcher = ENTRY_LINE.matcher(formattedEntry); + String firstLine = formattedEntry.contains("\n") ? formattedEntry.substring(0, formattedEntry.indexOf('\n')) : formattedEntry; + Matcher matcher = ENTRY_LINE.matcher(firstLine); if (!matcher.matches()) { return formattedEntry; } @@ -190,6 +231,52 @@ static String formatEntry(String entry, boolean stripQuotedKeys) { return key + " = " + value; } + private static String applyLineLength(String formattedEntry, int maxLineLength) { + if (maxLineLength <= 0) { + return formattedEntry; + } + + String inlineComment = extractInlineComment(formattedEntry); + String entryWithoutComment = inlineComment != null + ? formattedEntry.substring(0, formattedEntry.length() - inlineComment.length()).trim() + : formattedEntry; + + Matcher matcher = ENTRY_LINE.matcher(entryWithoutComment); + if (!matcher.matches()) { + return formattedEntry; + } + + String key = matcher.group(1).trim(); + String value = matcher.group(2).trim(); + String commentSuffix = inlineComment != null ? " " + inlineComment : ""; + + if (value.startsWith("{") && value.endsWith("}")) { + if (formattedEntry.length() > maxLineLength) { + return splitInlineTable(key, value, commentSuffix); + } + return formattedEntry; + } + + return formattedEntry; + } + + private static String splitInlineTable(String key, String value, String commentSuffix) { + String inner = value.substring(1, value.length() - 1).trim(); + String[] pairs = splitTopLevel(inner, ','); + + StringBuilder result = new StringBuilder(); + result.append(key).append(" = {").append(commentSuffix).append('\n'); + for (int i = 0; i < pairs.length; i++) { + String pair = pairs[i].trim(); + if (pair.isEmpty()) { + continue; + } + result.append(" ").append(pair).append(',').append('\n'); + } + result.append('}'); + return result.toString(); + } + private static String extractInlineComment(String valueAndComment) { boolean inQuote = false; int depth = 0; @@ -233,11 +320,16 @@ private static String formatInlineTable(String value) { String[] pairs = splitTopLevel(inner, ','); StringBuilder result = new StringBuilder("{ "); - for (int i = 0; i < pairs.length; i++) { - if (i > 0) { + boolean first = true; + for (String rawPair : pairs) { + String pair = rawPair.trim(); + if (pair.isEmpty()) { + continue; + } + if (!first) { result.append(", "); } - String pair = pairs[i].trim(); + first = false; Matcher pairMatcher = ENTRY_LINE.matcher(pair); if (pairMatcher.matches()) { String pairKey = pairMatcher.group(1).trim(); @@ -306,16 +398,18 @@ private static boolean isBareKey(String key) { } private static final class State implements Serializable { - private static final long serialVersionUID = 1L; + private static final long serialVersionUID = 2L; private final boolean stripQuotedKeys; + private final int maxLineLength; - State(boolean stripQuotedKeys) { + State(boolean stripQuotedKeys, int maxLineLength) { this.stripQuotedKeys = stripQuotedKeys; + this.maxLineLength = maxLineLength; } FormatterFunc toFormatter() { - return raw -> format(raw, stripQuotedKeys); + return raw -> format(raw, stripQuotedKeys, maxLineLength); } } diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java index 1bb6343dd2..3b2eef54b2 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/TomlExtension.java @@ -42,6 +42,7 @@ public VersionCatalogConfig versionCatalog() { public class VersionCatalogConfig { private boolean stripQuotedKeys; + private int maxLineLength = 120; public VersionCatalogConfig() { this.stripQuotedKeys = false; @@ -53,8 +54,13 @@ public void stripQuotedKeys(boolean stripQuotedKeys) { replaceStep(createStep()); } + public void maxLineLength(int maxLineLength) { + this.maxLineLength = maxLineLength; + replaceStep(createStep()); + } + private FormatterStep createStep() { - return VersionCatalogStep.create(stripQuotedKeys); + return VersionCatalogStep.create(stripQuotedKeys, maxLineLength); } } } diff --git a/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java b/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java index 42dc2ab324..2c8551a680 100644 --- a/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java +++ b/plugin-maven/src/main/java/com/diffplug/spotless/maven/toml/VersionCatalog.java @@ -27,8 +27,11 @@ public class VersionCatalog implements FormatterStepFactory { @Parameter private boolean stripQuotedKeys; + @Parameter + private int maxLineLength = 120; + @Override public FormatterStep newFormatterStep(FormatterStepConfig config) { - return VersionCatalogStep.create(stripQuotedKeys); + return VersionCatalogStep.create(stripQuotedKeys, maxLineLength); } } diff --git a/testlib/src/main/resources/toml/versionCatalogClean.toml b/testlib/src/main/resources/toml/versionCatalogClean.toml index c24fb4765e..d6ab0a26c2 100644 --- a/testlib/src/main/resources/toml/versionCatalogClean.toml +++ b/testlib/src/main/resources/toml/versionCatalogClean.toml @@ -10,7 +10,10 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } -testcontainers-junit-jupiter = { module = "org.testcontainers:testcontainers-junit-jupiter", version.ref = "testcontainers" } +testcontainers-junit-jupiter = { + module = "org.testcontainers:testcontainers-junit-jupiter", + version.ref = "testcontainers", +} testcontainers-postgresql = { module = "org.testcontainers:testcontainers-postgresql", version.ref = "testcontainers" } [bundles] diff --git a/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java b/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java index c79bd33c07..a1439cffc2 100644 --- a/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java +++ b/testlib/src/test/java/com/diffplug/spotless/toml/VersionCatalogStepTest.java @@ -138,21 +138,63 @@ void stripQuotedKeysPreservesNonBareKeys() throws Exception { "[versions]\n\"foo.bar\" = \"1.0\"\n"); } + @Test + void multiLineInlineTableJoined() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[libraries]\nfoo = {\n module = \"org:foo\",\n version.ref = \"bar\"\n}\n", + "[libraries]\nfoo = { module = \"org:foo\", version.ref = \"bar\" }\n"); + } + + @Test + void multiLineInlineTableWithTrailingComma() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create()); + harness.test( + "[libraries]\nfoo = {\n module = \"org:foo\",\n version.ref = \"bar\",\n}\n", + "[libraries]\nfoo = { module = \"org:foo\", version.ref = \"bar\" }\n"); + } + + @Test + void longLineSplitsInlineTable() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create(false, 40)); + harness.test( + "[libraries]\nfoo = { module = \"org.example:foo-bar\", version.ref = \"fooBar\" }\n", + "[libraries]\nfoo = {\n module = \"org.example:foo-bar\",\n version.ref = \"fooBar\",\n}\n"); + } + + @Test + void shortLineStaysSingleLine() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create(false, 200)); + harness.test( + "[libraries]\nfoo = {module=\"org:foo\",version.ref=\"bar\"}\n", + "[libraries]\nfoo = { module = \"org:foo\", version.ref = \"bar\" }\n"); + } + + @Test + void splitLineIdempotent() throws Exception { + StepHarness harness = StepHarness.forStep(VersionCatalogStep.create(false, 40)); + harness.testUnaffected( + "[libraries]\nfoo = {\n module = \"org.example:foo-bar\",\n version.ref = \"fooBar\",\n}\n"); + } + @Test void equality() throws Exception { new SerializableEqualityTester() { boolean stripQuotedKeys; + int maxLineLength = 120; @Override protected void setupTest(API api) { api.areDifferentThan(); stripQuotedKeys = true; api.areDifferentThan(); + maxLineLength = 80; + api.areDifferentThan(); } @Override protected FormatterStep create() { - return VersionCatalogStep.create(stripQuotedKeys); + return VersionCatalogStep.create(stripQuotedKeys, maxLineLength); } }.testEquals(); } From 35356dbd7aa1d365962360f2a9d0422ddb071232 Mon Sep 17 00:00:00 2001 From: Michael Kutz Date: Mon, 4 May 2026 13:25:24 +0200 Subject: [PATCH 5/5] Add changelog entries for TOML version catalog step --- CHANGES.md | 1 + lib/src/main/java/com/diffplug/spotless/toml/TODO.md | 1 + plugin-gradle/CHANGES.md | 1 + plugin-maven/CHANGES.md | 1 + 4 files changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 3bb2ae6f56..215a62a49e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [Unreleased] ### Added +- Add `versionCatalog` step for formatting and sorting Gradle version catalog (`.toml`) files. ([#2916](https://github.com/diffplug/spotless/issues/2916)) - Add `javaparserVersion` option to the Cleanthat step, allowing callers to override the JavaParser version pulled in transitively by Cleanthat. ([#2903](https://github.com/diffplug/spotless/pull/2903)) ### Changes - Bump default `cleanthat` version `2.24` -> `2.25`. ([#2903](https://github.com/diffplug/spotless/pull/2903)) diff --git a/lib/src/main/java/com/diffplug/spotless/toml/TODO.md b/lib/src/main/java/com/diffplug/spotless/toml/TODO.md index 19ffdaa8f6..f43d70bfe1 100644 --- a/lib/src/main/java/com/diffplug/spotless/toml/TODO.md +++ b/lib/src/main/java/com/diffplug/spotless/toml/TODO.md @@ -26,6 +26,7 @@ addressed for full TOML spec compliance. - [ ] `TABLE_HEADER` regex only matches bare keys — rejects dotted table headers (`[section.subsection]`) and quoted table headers (`["quoted.key"]`). Their entries are silently dropped. + ### String handling in `splitTopLevel` - [ ] Single-quoted (literal) strings `'...'` are not recognized — commas or `=` inside diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index e939b70b70..e1051db335 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -4,6 +4,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [Unreleased] ### Added +- Add `toml` format type with `versionCatalog()` step for formatting and sorting Gradle version catalog files. ([#2916](https://github.com/diffplug/spotless/issues/2916)) - Add `withIndentStyle` and `withIndentSize` configuration to `tableTestFormatter` for setting the fallback indent when no `.editorconfig` is found. ([#2893](https://github.com/diffplug/spotless/pull/2893)) - Add `javaparserVersion(...)` to `cleanthat`, allowing users to override the JavaParser version pulled in transitively by Cleanthat. ([#2903](https://github.com/diffplug/spotless/pull/2903)) ### Fixed diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index 70f0ec99c7..574bbc3ed1 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -4,6 +4,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format ( ## [Unreleased] ### Added +- Add `` format type with `` step for formatting and sorting Gradle version catalog files. ([#2916](https://github.com/diffplug/spotless/issues/2916)) - Add `` option to ``, allowing users to override the JavaParser version pulled in transitively by Cleanthat. ([#2903](https://github.com/diffplug/spotless/pull/2903)) ### Changes - Bump default `cleanthat` version `2.24` -> `2.25`. ([#2903](https://github.com/diffplug/spotless/pull/2903))