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