diff --git a/lib/src/main/java/com/diffplug/spotless/groovy/RemoveSemicolonsStep.java b/lib/src/main/java/com/diffplug/spotless/groovy/RemoveSemicolonsStep.java
index c701be63ee..48cdc6312d 100644
--- a/lib/src/main/java/com/diffplug/spotless/groovy/RemoveSemicolonsStep.java
+++ b/lib/src/main/java/com/diffplug/spotless/groovy/RemoveSemicolonsStep.java
@@ -15,16 +15,14 @@
*/
package com.diffplug.spotless.groovy;
-import java.io.BufferedReader;
import java.io.Serial;
import java.io.Serializable;
-import java.io.StringReader;
import com.diffplug.spotless.FormatterFunc;
import com.diffplug.spotless.FormatterStep;
/**
- * Removes all semicolons from the end of lines.
+ * Removes unnecessary semicolons from Groovy code. Preserves semicolons inside strings and comments.
*
* @author Jose Luis Badano
*/
@@ -49,32 +47,112 @@ private static final class State implements Serializable {
FormatterFunc toFormatter() {
return raw -> {
- try (BufferedReader reader = new BufferedReader(new StringReader(raw))) {
- StringBuilder result = new StringBuilder();
- String line;
- while ((line = reader.readLine()) != null) {
- result.append(removeSemicolon(line));
- result.append(System.lineSeparator());
+ StringBuilder result = new StringBuilder(raw.length());
+
+ // State tracking
+ boolean inSingleQuoteString = false;
+ boolean inDoubleQuoteString = false;
+ boolean inTripleSingleQuoteString = false;
+ boolean inTripleDoubleQuoteString = false;
+ boolean inSingleLineComment = false;
+ boolean inMultiLineComment = false;
+ boolean escaped = false;
+
+ for (int i = 0; i < raw.length(); i++) {
+ char c = raw.charAt(i);
+
+ // Check for triple quotes first (needs lookahead)
+ if (!inSingleLineComment && !inMultiLineComment && i + 2 < raw.length()) {
+ String triple = raw.substring(i, i + 3);
+ if ("'''".equals(triple) && !inDoubleQuoteString && !inTripleDoubleQuoteString) {
+ inTripleSingleQuoteString = !inTripleSingleQuoteString;
+ result.append(triple);
+ i += 2;
+ continue;
+ } else if ("\"\"\"".equals(triple) && !inSingleQuoteString && !inTripleSingleQuoteString) {
+ inTripleDoubleQuoteString = !inTripleDoubleQuoteString;
+ result.append(triple);
+ i += 2;
+ continue;
+ }
+ }
+
+ // Handle escaping
+ if (c == '\\' && (inSingleQuoteString || inDoubleQuoteString ||
+ inTripleSingleQuoteString || inTripleDoubleQuoteString)) {
+ escaped = !escaped;
+ result.append(c);
+ continue;
+ }
+
+ // Check for comments (only if not in string)
+ if (!inSingleQuoteString && !inDoubleQuoteString &&
+ !inTripleSingleQuoteString && !inTripleDoubleQuoteString && !escaped) {
+
+ // Single line comment
+ if (c == '/' && i + 1 < raw.length() && raw.charAt(i + 1) == '/' && !inMultiLineComment) {
+ inSingleLineComment = true;
+ }
+ // Multi-line comment start
+ else if (c == '/' && i + 1 < raw.length() && raw.charAt(i + 1) == '*' && !inSingleLineComment) {
+ inMultiLineComment = true;
+ }
+ // Multi-line comment end
+ else if (c == '*' && i + 1 < raw.length() && raw.charAt(i + 1) == '/' && inMultiLineComment) {
+ inMultiLineComment = false;
+ result.append(c);
+ if (i + 1 < raw.length()) {
+ result.append(raw.charAt(i + 1));
+ i++;
+ }
+ continue;
+ }
+ }
+
+ // Check for string quotes (only if not in comment and not already in triple quotes)
+ if (!inSingleLineComment && !inMultiLineComment && !escaped) {
+ if (c == '\'' && !inDoubleQuoteString && !inTripleSingleQuoteString && !inTripleDoubleQuoteString) {
+ inSingleQuoteString = !inSingleQuoteString;
+ } else if (c == '"' && !inSingleQuoteString && !inTripleSingleQuoteString && !inTripleDoubleQuoteString) {
+ inDoubleQuoteString = !inDoubleQuoteString;
+ }
}
- return result.toString();
+
+ // End single line comment on newline
+ if ((c == '\n' || c == '\r') && inSingleLineComment) {
+ inSingleLineComment = false;
+ }
+
+ // Check if we should remove this semicolon
+ if (c == ';' && !inSingleQuoteString && !inDoubleQuoteString &&
+ !inTripleSingleQuoteString && !inTripleDoubleQuoteString &&
+ !inSingleLineComment && !inMultiLineComment) {
+
+ // Look ahead to see if this semicolon is at the end of the line
+ boolean isEndOfLine = true;
+ for (int j = i + 1; j < raw.length(); j++) {
+ char next = raw.charAt(j);
+ if (next == '\n' || next == '\r') {
+ break; // End of line
+ } else if (!Character.isWhitespace(next)) {
+ isEndOfLine = false;
+ break;
+ }
+ // If it's whitespace but not newline, continue checking
+ }
+
+ // Only remove if it's at the end of the line
+ if (isEndOfLine) {
+ continue; // Skip this semicolon
+ }
+ }
+
+ result.append(c);
+ escaped = false;
}
- };
- }
- /**
- * Removes the last semicolon in a line if it exists.
- *
- * @param line the line to remove the semicolon from
- * @return the line without the last semicolon
- */
- private String removeSemicolon(String line) {
- // Find the last semicolon in a string and remove it.
- int lastSemicolon = line.lastIndexOf(";");
- if (lastSemicolon != -1 && lastSemicolon == line.length() - 1) {
- return line.substring(0, lastSemicolon);
- } else {
- return line;
- }
+ return result.toString();
+ };
}
}
}
diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md
index d996ecc6d8..25a4b95ce8 100644
--- a/plugin-maven/CHANGES.md
+++ b/plugin-maven/CHANGES.md
@@ -7,6 +7,7 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (
- Add the ability to specify a wildcard version (`*`) for external formatter executables. ([#2757](https://github.com/diffplug/spotless/issues/2757))
### Fixed
- [fix] `NPE` due to workingTreeIterator being null for git ignored files. #911 ([#2771](https://github.com/diffplug/spotless/issues/2771))
+- [fix] `removeSemicolons()` should not be applied to multiline strings in groovy #2780 ([#2792](https://github.com/diffplug/spotless/issues/2792))
### Changes
* Bump default `ktlint` version to latest `1.7.1` -> `1.8.0`. ([2763](https://github.com/diffplug/spotless/pull/2763))
* Bump default `gherkin-utils` version to latest `9.2.0` -> `10.0.0`. ([#2619](https://github.com/diffplug/spotless/pull/2619))
diff --git a/plugin-maven/src/test/java/com/diffplug/spotless/maven/groovy/RemoveSemicolonsTest.java b/plugin-maven/src/test/java/com/diffplug/spotless/maven/groovy/RemoveSemicolonsTest.java
index fc78c87a27..ea0fdb6ee4 100644
--- a/plugin-maven/src/test/java/com/diffplug/spotless/maven/groovy/RemoveSemicolonsTest.java
+++ b/plugin-maven/src/test/java/com/diffplug/spotless/maven/groovy/RemoveSemicolonsTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 DiffPlug
+ * Copyright 2023-2025 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,6 +15,7 @@
*/
package com.diffplug.spotless.maven.groovy;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import com.diffplug.spotless.maven.MavenIntegrationHarness;
@@ -43,6 +44,32 @@ void testRemoveSemicolons() throws Exception {
assertFile(path).sameAsResource("groovy/removeSemicolons/GroovyCodeWithSemicolonsFormatted.test");
}
+ /**
+ * `removeSemicolons()` should not be applied to multiline strings in groovy
+ */
+ @Nested
+ class Issue2780 {
+ @Test
+ void testMultilineStrings() throws Exception {
+ writePomWithGroovySteps("");
+
+ String path = "src/main/groovy/test.groovy";
+ setFile(path).toResource("groovy/removeSemicolons/Issue2780/MultilineString.test");
+ mavenRunner().withArguments("spotless:apply").runNoError();
+ assertFile(path).sameAsResource("groovy/removeSemicolons/Issue2780/MultilineStringFormatted.test");
+ }
+
+ @Test
+ void testComments() throws Exception {
+ writePomWithGroovySteps("");
+
+ String path = "src/main/groovy/test.groovy";
+ setFile(path).toResource("groovy/removeSemicolons/Issue2780/Comments.test");
+ mavenRunner().withArguments("spotless:apply").runNoError();
+ assertFile(path).sameAsResource("groovy/removeSemicolons/Issue2780/CommentsFormatted.test");
+ }
+ }
+
private void runTest(String sourceContent, String targetContent) throws Exception {
String path = "src/main/groovy/test.groovy";
setFile(path).toContent(sourceContent);
diff --git a/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/Comments.test b/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/Comments.test
new file mode 100644
index 0000000000..d2602ef561
--- /dev/null
+++ b/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/Comments.test
@@ -0,0 +1,6 @@
+println("Hello");
+def x = 5;
+return x;
+// This comment has a semicolon;
+/* Multi-line comment;
+ with semicolons; */
\ No newline at end of file
diff --git a/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/CommentsFormatted.test b/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/CommentsFormatted.test
new file mode 100644
index 0000000000..6fbdf3a251
--- /dev/null
+++ b/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/CommentsFormatted.test
@@ -0,0 +1,6 @@
+println("Hello")
+def x = 5
+return x
+// This comment has a semicolon;
+/* Multi-line comment;
+ with semicolons; */
\ No newline at end of file
diff --git a/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/MultilineString.test b/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/MultilineString.test
new file mode 100644
index 0000000000..338ec0683a
--- /dev/null
+++ b/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/MultilineString.test
@@ -0,0 +1,14 @@
+println("Hello");
+def x = 5;
+return x;
+def multilineString = '''
+ function (doc, meta) {
+ if (doc._class == "springdata.Doc") {
+ emit(meta.id, null);
+ }
+ }
+'''.stripIndent()
+def another = """
+ SELECT * FROM users;
+ WHERE active = true;
+"""
\ No newline at end of file
diff --git a/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/MultilineStringFormatted.test b/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/MultilineStringFormatted.test
new file mode 100644
index 0000000000..234dc41ab7
--- /dev/null
+++ b/testlib/src/main/resources/groovy/removeSemicolons/Issue2780/MultilineStringFormatted.test
@@ -0,0 +1,14 @@
+println("Hello")
+def x = 5
+return x
+def multilineString = '''
+ function (doc, meta) {
+ if (doc._class == "springdata.Doc") {
+ emit(meta.id, null);
+ }
+ }
+'''.stripIndent()
+def another = """
+ SELECT * FROM users;
+ WHERE active = true;
+"""
\ No newline at end of file