From a28011ac0e9681d46d17caac35032009b6144cd0 Mon Sep 17 00:00:00 2001 From: Joseph Date: Wed, 10 Jun 2026 13:39:10 +0100 Subject: [PATCH] Add UnnecessarySemicolonOperation (java:S2959) Implements SonarQube rule java:S2959 "Unnecessary semicolons should be omitted". Four forms are detected and removed: - Try-with-resources trailing semicolons (SonarJava's core S2959 detection: separators.size() == resources.size() indicates the last separator is unnecessary) - Empty statements (lone ;) inside braced blocks and initialisers - Empty statements inside switch/switch-expression case lists - Class-level semicolons after method/constructor bodies, invisible to the JDT AST, found by scanning source-text gaps between bodyDeclarations() entries at every nesting level (classes, inner classes, anonymous classes, enums, interfaces, records, annotation types) Control-flow empty bodies (while(x);, for(...);, if(x);) are deliberately preserved as they are intentional and removing them would change program semantics. For enum declarations, the required semicolon separating enum constants from body declarations is correctly skipped to avoid false positives. Also ensures CompilationUnitProperty.SOURCE is set on pre-parsed CompilationUnits in the batch-parse path (AstraCore), mirroring readAsCompilationUnit, so that source-text-scanning operations work correctly in production batch runs. 9 tests covering all detected forms plus no-op control-flow preservation. Co-Authored-By: Claude Sonnet 4.6 --- .../s2959/UnnecessarySemicolonOperation.java | 585 ++++++++++++++++++ .../astra/core/utils/AstraCore.java | 4 + .../sonar/s2959/AnonymousClassSemicolon.java | 12 + .../s2959/AnonymousClassSemicolonAfter.java | 10 + .../sonar/s2959/BlockLevelEmptyStatement.java | 41 ++ .../s2959/BlockLevelEmptyStatementAfter.java | 33 + .../sonar/s2959/ClassBraceSemicolon.java | 9 + .../sonar/s2959/ClassBraceSemicolonAfter.java | 9 + .../sonar/s2959/ClassLevelSemicolon.java | 13 + .../sonar/s2959/ClassLevelSemicolonAfter.java | 13 + .../sonar/s2959/CommentedSemicolon.java | 13 + .../sonar/s2959/CommentedSemicolonAfter.java | 13 + .../sonar/s2959/EmptyControlFlowBody.java | 24 + .../s2959/EmptyControlFlowBodyAfter.java | 24 + .../sonar/s2959/EnumBodySemicolon.java | 17 + .../sonar/s2959/EnumBodySemicolonAfter.java | 16 + .../sonar/s2959/MixedSemicolons.java | 16 + .../sonar/s2959/MixedSemicolonsAfter.java | 13 + .../sonar/s2959/NestedTypeSemicolon.java | 20 + .../sonar/s2959/NestedTypeSemicolonAfter.java | 17 + .../sonar/s2959/SwitchCaseSemicolon.java | 20 + .../sonar/s2959/SwitchCaseSemicolonAfter.java | 16 + .../TestUnnecessarySemicolonOperation.java | 124 ++++ .../s2959/TopLevelEnumAnnotatedConstants.java | 14 + .../TopLevelEnumAnnotatedConstantsAfter.java | 14 + .../TryWithResourcesTrailingSemicolon.java | 30 + ...ryWithResourcesTrailingSemicolonAfter.java | 30 + 27 files changed, 1150 insertions(+) create mode 100644 astra-core/src/main/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/UnnecessarySemicolonOperation.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/AnonymousClassSemicolon.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/AnonymousClassSemicolonAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/BlockLevelEmptyStatement.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/BlockLevelEmptyStatementAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassBraceSemicolon.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassBraceSemicolonAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassLevelSemicolon.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassLevelSemicolonAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/CommentedSemicolon.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/CommentedSemicolonAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EmptyControlFlowBody.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EmptyControlFlowBodyAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EnumBodySemicolon.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EnumBodySemicolonAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/MixedSemicolons.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/MixedSemicolonsAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/NestedTypeSemicolon.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/NestedTypeSemicolonAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/SwitchCaseSemicolon.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/SwitchCaseSemicolonAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TestUnnecessarySemicolonOperation.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TopLevelEnumAnnotatedConstants.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TopLevelEnumAnnotatedConstantsAfter.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TryWithResourcesTrailingSemicolon.java create mode 100644 astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TryWithResourcesTrailingSemicolonAfter.java diff --git a/astra-core/src/main/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/UnnecessarySemicolonOperation.java b/astra-core/src/main/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/UnnecessarySemicolonOperation.java new file mode 100644 index 0000000..a112a03 --- /dev/null +++ b/astra-core/src/main/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/UnnecessarySemicolonOperation.java @@ -0,0 +1,585 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.alfasoftware.astra.core.utils.ASTOperation; +import org.alfasoftware.astra.core.utils.CompilationUnitProperty; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.AbstractTypeDeclaration; +import org.eclipse.jdt.core.dom.AnnotationTypeDeclaration; +import org.eclipse.jdt.core.dom.AnonymousClassDeclaration; +import org.eclipse.jdt.core.dom.Block; +import org.eclipse.jdt.core.dom.Comment; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.EmptyStatement; +import org.eclipse.jdt.core.dom.EnumDeclaration; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.RecordDeclaration; +import org.eclipse.jdt.core.dom.SwitchExpression; +import org.eclipse.jdt.core.dom.SwitchStatement; +import org.eclipse.jdt.core.dom.TryStatement; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.rewrite.ASTRewrite; +import org.eclipse.jdt.core.dom.rewrite.TargetSourceRangeComputer; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.text.edits.MalformedTreeException; + +/** + * Removes unnecessary semicolons, satisfying SonarQube rule java:S2959 + * "Unnecessary semicolons should be omitted". + * + *

Five forms are handled: + *

    + *
  1. Try-with-resources trailing semicolons — the core SonarJava S2959 detection: + * when the number of separators in the resource list equals the number of resources, + * the last separator is unnecessary. In JDT terms: any {@code ;} found between the + * end of the last resource expression and the closing {@code )} of the resource list.
  2. + *
  3. Block-level empty statements — a lone {@code ;} inside a {@link Block}. The + * parent of such an {@link EmptyStatement} is a {@link Block}.
  4. + *
  5. Switch-case empty statements — a lone {@code ;} inside a {@link SwitchStatement} + * or {@link SwitchExpression} case list.
  6. + *
  7. Class-level empty declarations — a {@code ;} written directly in any type body + * (class, inner class, enum body section, interface, record, or anonymous class). These + * are invisible to the JDT AST and are found by scanning source-text gaps between + * consecutive {@code bodyDeclarations()} entries at every nesting level. For enum + * declarations the scan skips past the required separator between enum constants and + * body declarations.
  8. + *
  9. Post-type-brace semicolons — a {@code ;} that appears immediately after the + * closing {@code \}} of a top-level type declaration (e.g. {@code class Foo \{\};}), + * found by scanning the source text just beyond the type node's end position.
  10. + *
+ * + *

Semicolons that fall inside any comment (line or block) are never removed. + * {@link CompilationUnit#getCommentList()} is used to build the exclusion set. + * + *

Empty statements whose immediate parent is a control-flow construct (e.g. + * {@code while (cond);}, {@code for (...);}, {@code if (cond);}) are intentional and are + * left untouched, because removing them would alter the program's semantics. + * + *

Processing is rooted at top-level type declarations (those whose parent is the + * {@link CompilationUnit}). Nested and anonymous types are handled automatically via + * recursive source-text scanning within the outer type's replacement region. + */ +public class UnnecessarySemicolonOperation implements ASTOperation { + + private static final String EXTENDED_RANGES_KEY = + UnnecessarySemicolonOperation.class.getName() + ".extendedRanges"; + + @Override + public void run(CompilationUnit compilationUnit, ASTNode node, ASTRewrite rewriter) + throws IOException, MalformedTreeException, BadLocationException { + + if (!(node instanceof AbstractTypeDeclaration)) { + return; + } + if (!(node.getParent() instanceof CompilationUnit)) { + return; + } + AbstractTypeDeclaration typeDecl = (AbstractTypeDeclaration) node; + + String source = (String) compilationUnit.getProperty(CompilationUnitProperty.SOURCE); + if (source == null) { + return; + } + + // Install the TargetSourceRangeComputer on the first visit to any top-level type in this + // file. It is consulted at rewriteAST() time to obtain the replacement region for each + // node; for types with a trailing ';' we extend the region to cover that character too. + ensureRangeComputerInstalled(compilationUnit, rewriter); + + List commentRanges = buildCommentRanges(compilationUnit); + List deletions = new ArrayList<>(); + collectBlockLevelDeletions(source, typeDecl, deletions, commentRanges); + collectSwitchCaseDeletions(source, typeDecl, deletions, commentRanges); + collectTryWithResourcesTrailingSemicolons(source, typeDecl, deletions, commentRanges); + collectClassLevelSemicolons(source, typeDecl, deletions, commentRanges); + + int typeStart = typeDecl.getStartPosition(); + int typeLength = typeDecl.getLength(); + int postTypeSemicolon = findPostTypeSemicolon(source, typeStart + typeLength, commentRanges); + + int extEndMutable = typeStart + typeLength; + if (postTypeSemicolon >= 0) { + extEndMutable = postTypeSemicolon + 1; + deletions.add(semiColonDeletionRange(source, typeStart, postTypeSemicolon)); + @SuppressWarnings("unchecked") + Map extendedRanges = + (Map) compilationUnit.getProperty(EXTENDED_RANGES_KEY); + extendedRanges.put(typeDecl, new int[]{typeStart, extEndMutable - typeStart}); + } + final int extEnd = extEndMutable; + + if (deletions.isEmpty()) { + return; + } + + // Sort from end to start so each deletion's offset stays valid after prior deletions. + deletions.sort((a, b) -> Integer.compare(b[0], a[0])); + + StringBuilder sb = new StringBuilder(source.substring(typeStart, extEnd)); + + for (int[] deletion : deletions) { + int relStart = deletion[0] - typeStart; + int len = deletion[1]; + if (relStart >= 0 && relStart + len <= sb.length()) { + sb.delete(relStart, relStart + len); + } + } + + ASTNode placeholder = rewriter.createStringPlaceholder(sb.toString(), typeDecl.getNodeType()); + rewriter.replace(typeDecl, placeholder, null); + } + + /** + * Installs a {@link TargetSourceRangeComputer} on {@code rewriter} on the first call for a + * given file. The computer widens the replacement region for any type declaration that has a + * registered post-brace extension; for all other nodes it falls back to the default behaviour. + * + *

The per-file extension map is stored as a property on the {@link CompilationUnit} so + * that later {@link #run} calls (for additional top-level types in the same file) can add + * entries without overwriting the computer that was already set. + */ + @SuppressWarnings("unchecked") + private void ensureRangeComputerInstalled(CompilationUnit compilationUnit, ASTRewrite rewriter) { + if (compilationUnit.getProperty(EXTENDED_RANGES_KEY) != null) { + return; + } + Map extendedRanges = new HashMap<>(); + compilationUnit.setProperty(EXTENDED_RANGES_KEY, extendedRanges); + rewriter.setTargetSourceRangeComputer(new TargetSourceRangeComputer() { + @Override + public SourceRange computeSourceRange(ASTNode n) { + int[] range = extendedRanges.get(n); + if (range != null) { + return new SourceRange(range[0], range[1]); + } + return super.computeSourceRange(n); + } + }); + } + + // ------------------------------------------------------------------------- + // Block-level empty statements + // ------------------------------------------------------------------------- + + /** + * Collects deletion ranges for {@link EmptyStatement} nodes directly inside a {@link Block}. + * Statements whose parent is a control-flow construct (e.g. {@code if (x);}) are excluded + * because removing them would change the programme's behaviour. + */ + private void collectBlockLevelDeletions(String source, AbstractTypeDeclaration typeDecl, + List deletions, List commentRanges) { + int outerStart = typeDecl.getStartPosition(); + typeDecl.accept(new ASTVisitor() { + @Override + public boolean visit(EmptyStatement emptyStatement) { + if (emptyStatement.getParent() instanceof Block) { + int pos = emptyStatement.getStartPosition(); + if (!isInComment(pos, commentRanges)) { + deletions.add(semiColonDeletionRange(source, outerStart, pos)); + } + } + return false; + } + }); + } + + // ------------------------------------------------------------------------- + // Switch-case empty statements + // ------------------------------------------------------------------------- + + /** + * Collects deletion ranges for {@link EmptyStatement} nodes inside {@link SwitchStatement} + * or {@link SwitchExpression} case lists. + */ + private void collectSwitchCaseDeletions(String source, AbstractTypeDeclaration typeDecl, + List deletions, List commentRanges) { + int outerStart = typeDecl.getStartPosition(); + typeDecl.accept(new ASTVisitor() { + @Override + public boolean visit(EmptyStatement emptyStatement) { + ASTNode parent = emptyStatement.getParent(); + if (parent instanceof SwitchStatement || parent instanceof SwitchExpression) { + int pos = emptyStatement.getStartPosition(); + if (!isInComment(pos, commentRanges)) { + deletions.add(semiColonDeletionRange(source, outerStart, pos)); + } + } + return false; + } + }); + } + + // ------------------------------------------------------------------------- + // Try-with-resources trailing semicolons (SonarJava S2959 core detection) + // ------------------------------------------------------------------------- + + /** + * Mirrors SonarJava's S2959 check: when the number of separators in a try-with-resources + * resource list equals the number of resources, the last separator is unnecessary. + * + *

In SonarJava terms: {@code separators.size() == resources.size()} triggers a report. + * In JDT terms: any {@code ;} found between the end of the last resource expression and + * the closing {@code )} of the resource list is an unnecessary trailing separator. + */ + @SuppressWarnings("unchecked") + private void collectTryWithResourcesTrailingSemicolons(String source, + AbstractTypeDeclaration typeDecl, List deletions, List commentRanges) { + int outerStart = typeDecl.getStartPosition(); + typeDecl.accept(new ASTVisitor() { + @Override + public boolean visit(TryStatement tryStatement) { + List resources = tryStatement.resources(); + if (resources.isEmpty()) { + return true; + } + Expression lastResource = resources.get(resources.size() - 1); + int searchFrom = lastResource.getStartPosition() + lastResource.getLength(); + + // Scan for the closing ')' of the resource list; any ';' found on the way is trailing. + for (int k = searchFrom; k < source.length(); k++) { + char c = source.charAt(k); + if (c == ')') { + break; + } + if (c == ';' && !isInComment(k, commentRanges)) { + deletions.add(semiColonDeletionRange(source, outerStart, k)); + } + } + return true; + } + }); + } + + // ------------------------------------------------------------------------- + // Class-level semicolons (invisible to the AST, found via source scanning) + // ------------------------------------------------------------------------- + + /** + * Collects all class-level {@code ;} positions by scanning source-text gaps between + * consecutive {@code bodyDeclarations()} entries in the given top-level type and all + * nested/anonymous types at any depth. + * + *

For {@link EnumDeclaration} nodes the scan of the opening gap (before the first body + * declaration) starts after the required {@code ;} that separates enum constants + * from body declarations, to avoid incorrectly flagging that separator as unnecessary. + */ + private void collectClassLevelSemicolons(String source, AbstractTypeDeclaration typeDecl, + List deletions, List commentRanges) { + int outerStart = typeDecl.getStartPosition(); + + // For a top-level enum the scan must start after the required ';' that separates the + // enum constants from the body declarations, just as for nested enums (see visitor below). + // Passing 0 tells scanTypeBodyGaps to start immediately after the opening '{', which + // would incorrectly include the enum constants region and their required ';'. + int topLevelScanStart = (typeDecl instanceof EnumDeclaration) + ? enumBodyScanStart(source, (EnumDeclaration) typeDecl) + : 0; + scanTypeBodyGaps(source, outerStart, + typeDecl.getStartPosition(), + typeDecl.getStartPosition() + typeDecl.getLength() - 1, + typeDecl.bodyDeclarations(), + topLevelScanStart, + deletions, + commentRanges); + + // Recurse into every nested named type and every anonymous class at any depth. + typeDecl.accept(new ASTVisitor() { + + @Override + public boolean visit(TypeDeclaration nested) { + if (nested != typeDecl) { + scanTypeBodyGaps(source, outerStart, + nested.getStartPosition(), + nested.getStartPosition() + nested.getLength() - 1, + nested.bodyDeclarations(), + 0, + deletions, + commentRanges); + } + return true; + } + + @Override + public boolean visit(EnumDeclaration nested) { + if (nested == typeDecl) { + return true; + } + int scanStart = enumBodyScanStart(source, nested); + scanTypeBodyGaps(source, outerStart, + nested.getStartPosition(), + nested.getStartPosition() + nested.getLength() - 1, + nested.bodyDeclarations(), + scanStart, + deletions, + commentRanges); + return true; + } + + @Override + public boolean visit(AnnotationTypeDeclaration nested) { + scanTypeBodyGaps(source, outerStart, + nested.getStartPosition(), + nested.getStartPosition() + nested.getLength() - 1, + nested.bodyDeclarations(), + 0, + deletions, + commentRanges); + return true; + } + + @Override + public boolean visit(RecordDeclaration nested) { + scanTypeBodyGaps(source, outerStart, + nested.getStartPosition(), + nested.getStartPosition() + nested.getLength() - 1, + nested.bodyDeclarations(), + 0, + deletions, + commentRanges); + return true; + } + + /** + * {@link AnonymousClassDeclaration#getStartPosition()} is the opening {@code \{}, + * so the pre-declarations scan starts at the very next character. + */ + @Override + public boolean visit(AnonymousClassDeclaration acd) { + scanTypeBodyGaps(source, outerStart, + acd.getStartPosition(), + acd.getStartPosition() + acd.getLength() - 1, + acd.bodyDeclarations(), + acd.getStartPosition() + 1, + deletions, + commentRanges); + return true; + } + }); + } + + /** + * Scans source-text gaps of a single type body for stray {@code ;} characters. + * + *

Three regions are covered: + *

    + *
  1. The gap between the opening {@code \{} and the first body declaration.
  2. + *
  3. Gaps between adjacent body declarations.
  4. + *
  5. The gap between the last body declaration and the closing {@code \}}.
  6. + *
+ * + *

The opening {@code \{} is located by scanning from {@code nodeStart} up to + * {@code firstDeclOrClose}, taking the last occurrence found. This search boundary + * deliberately excludes any {@code \{} that belongs to a method or initialiser body, + * preventing those from being misidentified as the type body's opening brace. + * + * @param nodeStart start position of the type node (or the {@code \{} for + * anonymous class declarations) + * @param lastCharPos position of the closing {@code \}} + * @param rawBodyDecls the type's {@code bodyDeclarations()} list + * @param preDeclarationsScanStart if positive and greater than {@code openBrace + 1}, the + * pre-declarations gap scan begins here instead of immediately + * after the opening brace; used for enums to skip past the + * required separator between constants and body declarations + */ + @SuppressWarnings("unchecked") + private void scanTypeBodyGaps(String source, int outerStart, int nodeStart, int lastCharPos, + List rawBodyDecls, int preDeclarationsScanStart, List deletions, + List commentRanges) { + + List bodyDecls = (List) rawBodyDecls; + + int firstDeclOrClose = bodyDecls.isEmpty() + ? lastCharPos + : bodyDecls.get(0).getStartPosition(); + + // Locate the type body's opening '{' by searching only up to firstDeclOrClose. + // This prevents '{' characters inside method bodies from being picked up. + int openBrace = -1; + for (int k = nodeStart; k < firstDeclOrClose && k < source.length(); k++) { + if (source.charAt(k) == '{') { + openBrace = k; // take last '{' found (handles '{' in annotation arguments) + } + } + if (openBrace < 0) { + return; + } + + // Gap before the first body declaration (or the entire body if empty). + int gapStart = (preDeclarationsScanStart > openBrace + 1) + ? preDeclarationsScanStart + : openBrace + 1; + scanGap(source, outerStart, gapStart, firstDeclOrClose, deletions, commentRanges); + + // Gaps between adjacent body declarations and after the last one. + for (int i = 0; i < bodyDecls.size(); i++) { + ASTNode decl = bodyDecls.get(i); + int gapFrom = decl.getStartPosition() + decl.getLength(); + int gapEnd = (i + 1 < bodyDecls.size()) + ? bodyDecls.get(i + 1).getStartPosition() + : lastCharPos; + scanGap(source, outerStart, gapFrom, gapEnd, deletions, commentRanges); + } + } + + /** Scans a source range for {@code ;} characters outside comments and records deletion ranges. */ + private void scanGap(String source, int outerStart, int gapStart, int gapEnd, + List deletions, List commentRanges) { + for (int j = gapStart; j < gapEnd && j < source.length(); j++) { + if (source.charAt(j) == ';' && !isInComment(j, commentRanges)) { + deletions.add(semiColonDeletionRange(source, outerStart, j)); + } + } + } + + // ------------------------------------------------------------------------- + // Post-type-brace semicolons + // ------------------------------------------------------------------------- + + /** + * Scans the source text starting at {@code searchFrom} for a {@code ;} that appears + * immediately after the closing {@code \}} of a top-level type declaration (with only + * whitespace in between). Returns the absolute position of the {@code ;}, or {@code -1} + * if the first non-whitespace character encountered is not a {@code ;} or if the + * semicolon falls inside a comment. + */ + private int findPostTypeSemicolon(String source, int searchFrom, List commentRanges) { + for (int k = searchFrom; k < source.length(); k++) { + char c = source.charAt(k); + if (c == ';') { + return isInComment(k, commentRanges) ? -1 : k; + } + if (c != ' ' && c != '\t' && c != '\r' && c != '\n') { + return -1; + } + } + return -1; + } + + // ------------------------------------------------------------------------- + // Comment-range helpers + // ------------------------------------------------------------------------- + + /** + * Builds a list of {@code [start, end)} ranges from every comment in the compilation unit. + */ + @SuppressWarnings("unchecked") + private List buildCommentRanges(CompilationUnit compilationUnit) { + List ranges = new ArrayList<>(); + for (Comment comment : (List) compilationUnit.getCommentList()) { + int start = comment.getStartPosition(); + ranges.add(new int[]{start, start + comment.getLength()}); + } + return ranges; + } + + /** Returns {@code true} if {@code pos} falls within any comment range. */ + private boolean isInComment(int pos, List commentRanges) { + for (int[] range : commentRanges) { + if (pos >= range[0] && pos < range[1]) { + return true; + } + } + return false; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Returns the position from which to begin scanning the pre-body-declarations gap of an + * {@link EnumDeclaration}. This is the position immediately after the {@code ;} that + * separates the enum constants from the body declarations (if any constants exist), or + * the position immediately after the opening {@code \{} (if the constants list is empty). + * + *

Skipping over the required separator prevents the scanner from incorrectly flagging + * it as an unnecessary semicolon. + */ + @SuppressWarnings("unchecked") + private int enumBodyScanStart(String source, EnumDeclaration enumDecl) { + List constants = (List) enumDecl.enumConstants(); + int declEnd = enumDecl.getStartPosition() + enumDecl.getLength() - 1; + + int searchFrom; + if (!constants.isEmpty()) { + ASTNode last = constants.get(constants.size() - 1); + searchFrom = last.getStartPosition() + last.getLength(); + } else { + // No constants — find the opening '{' and start from the character after it. + searchFrom = -1; + for (int k = enumDecl.getStartPosition(); k < declEnd && k < source.length(); k++) { + if (source.charAt(k) == '{') { + searchFrom = k + 1; + } + } + if (searchFrom < 0) { + return enumDecl.getStartPosition(); + } + } + + // Advance past the required ';' (if present) — that semicolon is not stray. + for (int k = searchFrom; k < declEnd && k < source.length(); k++) { + char c = source.charAt(k); + if (c == ';') { + return k + 1; + } + if (c == '}') { + return k; + } + } + return searchFrom; + } + + /** + * Computes the source range to delete for a single {@code ;} at position {@code semiPos}. + * + *

+ * + * @param outerStart lower bound for the backward line-start scan (the outer type's start) + * @param semiPos absolute position of the {@code ;} in {@code source} + * @return {@code int[2]} where {@code [0]} is the start of the deletion and {@code [1]} + * is the length to delete + */ + private int[] semiColonDeletionRange(String source, int outerStart, int semiPos) { + boolean soloOnLine = true; + for (int k = semiPos - 1; k > outerStart; k--) { + char c = source.charAt(k); + if (c == '\n') { + break; + } + if (c != ' ' && c != '\t' && c != '\r') { + soloOnLine = false; + break; + } + } + + if (!soloOnLine) { + return new int[]{semiPos, 1}; + } + + // Solo on line: delete from the preceding newline (inclusive) through the ';'. + int delStart = semiPos; + for (int k = semiPos - 1; k >= outerStart; k--) { + if (source.charAt(k) == '\n') { + delStart = k; + if (k > outerStart && source.charAt(k - 1) == '\r') { + delStart = k - 1; + } + break; + } + } + return new int[]{delStart, semiPos - delStart + 1}; + } +} diff --git a/astra-core/src/main/java/org/alfasoftware/astra/core/utils/AstraCore.java b/astra-core/src/main/java/org/alfasoftware/astra/core/utils/AstraCore.java index 524db9f..48e7ab9 100644 --- a/astra-core/src/main/java/org/alfasoftware/astra/core/utils/AstraCore.java +++ b/astra-core/src/main/java/org/alfasoftware/astra/core/utils/AstraCore.java @@ -311,6 +311,10 @@ private void applyOperationsAndSaveWithPreParsedCompilationUnit( String[] sources, String[] classpath) { try { + // Ensure SOURCE is available on pre-parsed units (batch path), mirroring readAsCompilationUnit. + if (preParseUnit.getProperty(CompilationUnitProperty.SOURCE) == null) { + preParseUnit.setProperty(CompilationUnitProperty.SOURCE, fileContentBefore); + } ASTRewrite rewriter = runOperations(operations, preParseUnit); String fileContentAfter = makeChangesFromAST(fileContentBefore, rewriter); diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/AnonymousClassSemicolon.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/AnonymousClassSemicolon.java new file mode 100644 index 0000000..3adf641 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/AnonymousClassSemicolon.java @@ -0,0 +1,12 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class AnonymousClassSemicolon { + + Runnable r = new Runnable() { + ; + public void run() { + }; + ; + }; + +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/AnonymousClassSemicolonAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/AnonymousClassSemicolonAfter.java new file mode 100644 index 0000000..4f1ed75 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/AnonymousClassSemicolonAfter.java @@ -0,0 +1,10 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class AnonymousClassSemicolonAfter { + + Runnable r = new Runnable() { + public void run() { + } + }; + +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/BlockLevelEmptyStatement.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/BlockLevelEmptyStatement.java new file mode 100644 index 0000000..3691b57 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/BlockLevelEmptyStatement.java @@ -0,0 +1,41 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class BlockLevelEmptyStatement { + + static { + ; + System.out.println("static init"); + } + + { + ; + System.out.println("instance init"); + } + + void singleSemicolon() { + ; + doSomething(); + } + + void multipleSemicolons() { + ; + ; + doSomething(); + ; + } + + void semicolonInIfBlock() { + if (true) { + ; + doSomething(); + } + } + + void semicolonInForBlock() { + for (int i = 0; i < 10; i++) { + ; + } + } + + void doSomething() {} +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/BlockLevelEmptyStatementAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/BlockLevelEmptyStatementAfter.java new file mode 100644 index 0000000..8ef7721 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/BlockLevelEmptyStatementAfter.java @@ -0,0 +1,33 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class BlockLevelEmptyStatementAfter { + + static { + System.out.println("static init"); + } + + { + System.out.println("instance init"); + } + + void singleSemicolon() { + doSomething(); + } + + void multipleSemicolons() { + doSomething(); + } + + void semicolonInIfBlock() { + if (true) { + doSomething(); + } + } + + void semicolonInForBlock() { + for (int i = 0; i < 10; i++) { + } + } + + void doSomething() {} +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassBraceSemicolon.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassBraceSemicolon.java new file mode 100644 index 0000000..48b0393 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassBraceSemicolon.java @@ -0,0 +1,9 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class ClassBraceSemicolon { + + public int getValue() { + return 42; + } + +}; diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassBraceSemicolonAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassBraceSemicolonAfter.java new file mode 100644 index 0000000..f1ae970 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassBraceSemicolonAfter.java @@ -0,0 +1,9 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class ClassBraceSemicolonAfter { + + public int getValue() { + return 42; + } + +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassLevelSemicolon.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassLevelSemicolon.java new file mode 100644 index 0000000..6f1e515 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassLevelSemicolon.java @@ -0,0 +1,13 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class ClassLevelSemicolon { + + public int getValue() { + return 42; + }; + + public String getName() { + return "hello"; + }; + +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassLevelSemicolonAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassLevelSemicolonAfter.java new file mode 100644 index 0000000..e87bff3 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/ClassLevelSemicolonAfter.java @@ -0,0 +1,13 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class ClassLevelSemicolonAfter { + + public int getValue() { + return 42; + } + + public String getName() { + return "hello"; + } + +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/CommentedSemicolon.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/CommentedSemicolon.java new file mode 100644 index 0000000..42dff80 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/CommentedSemicolon.java @@ -0,0 +1,13 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class CommentedSemicolon { + + // ; + + public int getValue() { + return 42; + } + + /* ; */ + +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/CommentedSemicolonAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/CommentedSemicolonAfter.java new file mode 100644 index 0000000..2eb0334 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/CommentedSemicolonAfter.java @@ -0,0 +1,13 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class CommentedSemicolonAfter { + + // ; + + public int getValue() { + return 42; + } + + /* ; */ + +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EmptyControlFlowBody.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EmptyControlFlowBody.java new file mode 100644 index 0000000..2fdd667 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EmptyControlFlowBody.java @@ -0,0 +1,24 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +import java.util.List; + +public class EmptyControlFlowBody { + + void emptyWhileBody(List list) { + while (!list.isEmpty()) ; + } + + void emptyForBody() { + for (int i = 0; i < 10; i++) ; + } + + void emptyEnhancedForBody(List list) { + for (Object o : list) ; + } + + void emptyIfBody() { + if (true) ; + } + + void doSomething() {} +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EmptyControlFlowBodyAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EmptyControlFlowBodyAfter.java new file mode 100644 index 0000000..e01dfad --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EmptyControlFlowBodyAfter.java @@ -0,0 +1,24 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +import java.util.List; + +public class EmptyControlFlowBodyAfter { + + void emptyWhileBody(List list) { + while (!list.isEmpty()) ; + } + + void emptyForBody() { + for (int i = 0; i < 10; i++) ; + } + + void emptyEnhancedForBody(List list) { + for (Object o : list) ; + } + + void emptyIfBody() { + if (true) ; + } + + void doSomething() {} +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EnumBodySemicolon.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EnumBodySemicolon.java new file mode 100644 index 0000000..a90c3ed --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EnumBodySemicolon.java @@ -0,0 +1,17 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class EnumBodySemicolon { + + enum WithMethodsAndSemicolon { + RED, GREEN, BLUE; + + public String label() { + return name().toLowerCase(); + }; + ; + } + + enum WithNoBodyDeclarations { + ONE, TWO, THREE + } +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EnumBodySemicolonAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EnumBodySemicolonAfter.java new file mode 100644 index 0000000..3152b38 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/EnumBodySemicolonAfter.java @@ -0,0 +1,16 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class EnumBodySemicolonAfter { + + enum WithMethodsAndSemicolon { + RED, GREEN, BLUE; + + public String label() { + return name().toLowerCase(); + } + } + + enum WithNoBodyDeclarations { + ONE, TWO, THREE + } +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/MixedSemicolons.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/MixedSemicolons.java new file mode 100644 index 0000000..c780db4 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/MixedSemicolons.java @@ -0,0 +1,16 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class MixedSemicolons { + + public int getValue() { + ; + return 42; + }; + + public void doSomething() { + ; + System.out.println("hello"); + ; + }; + +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/MixedSemicolonsAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/MixedSemicolonsAfter.java new file mode 100644 index 0000000..f3ebc43 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/MixedSemicolonsAfter.java @@ -0,0 +1,13 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class MixedSemicolonsAfter { + + public int getValue() { + return 42; + } + + public void doSomething() { + System.out.println("hello"); + } + +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/NestedTypeSemicolon.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/NestedTypeSemicolon.java new file mode 100644 index 0000000..0aef7a0 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/NestedTypeSemicolon.java @@ -0,0 +1,20 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class NestedTypeSemicolon { + + static class StaticNested { + ; + void foo() { + }; + ; + } + + class Inner { + int x = 1;; + } + + interface InnerInterface { + ; + void method(); + } +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/NestedTypeSemicolonAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/NestedTypeSemicolonAfter.java new file mode 100644 index 0000000..b35cac4 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/NestedTypeSemicolonAfter.java @@ -0,0 +1,17 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class NestedTypeSemicolonAfter { + + static class StaticNested { + void foo() { + } + } + + class Inner { + int x = 1; + } + + interface InnerInterface { + void method(); + } +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/SwitchCaseSemicolon.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/SwitchCaseSemicolon.java new file mode 100644 index 0000000..9784170 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/SwitchCaseSemicolon.java @@ -0,0 +1,20 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class SwitchCaseSemicolon { + + void switchStatement(int x) { + switch (x) { + case 1: + ; + System.out.println("one"); + break; + case 2: + ; + ; + break; + default: + ; + break; + } + } +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/SwitchCaseSemicolonAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/SwitchCaseSemicolonAfter.java new file mode 100644 index 0000000..23b33a3 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/SwitchCaseSemicolonAfter.java @@ -0,0 +1,16 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +public class SwitchCaseSemicolonAfter { + + void switchStatement(int x) { + switch (x) { + case 1: + System.out.println("one"); + break; + case 2: + break; + default: + break; + } + } +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TestUnnecessarySemicolonOperation.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TestUnnecessarySemicolonOperation.java new file mode 100644 index 0000000..23944a3 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TestUnnecessarySemicolonOperation.java @@ -0,0 +1,124 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +import java.util.Collections; +import java.util.Set; + +import org.alfasoftware.astra.core.refactoring.AbstractRefactorTest; +import org.alfasoftware.astra.core.utils.ASTOperation; +import org.junit.Test; + +public class TestUnnecessarySemicolonOperation extends AbstractRefactorTest { + + private static final Set OPERATION = + Collections.singleton(new UnnecessarySemicolonOperation()); + + /** + * SonarJava's core S2959 detection: trailing semicolons after the last resource in + * try-with-resources statements must be removed. + */ + @Test + public void testTryWithResourcesTrailingSemicolonIsRemoved() { + assertRefactor(TryWithResourcesTrailingSemicolon.class, OPERATION); + } + + /** + * Standalone empty statements (lone {@code ;}) inside braced blocks are removed. + * Includes static initialisers, instance initialisers, method bodies, and nested blocks. + */ + @Test + public void testBlockLevelEmptyStatementsAreRemoved() { + assertRefactor(BlockLevelEmptyStatement.class, OPERATION); + } + + /** + * Semicolons that appear directly in a class body after method or constructor closing + * braces (invisible to the JDT AST) are found via source scanning and removed. + */ + @Test + public void testClassLevelSemicolonsAreRemoved() { + assertRefactor(ClassLevelSemicolon.class, OPERATION); + } + + /** + * Empty statements used as the body of control-flow constructs ({@code while}, {@code for}, + * enhanced {@code for}, {@code if}) are intentional and must NOT be removed. + */ + @Test + public void testEmptyControlFlowBodiesArePreserved() { + assertRefactor(EmptyControlFlowBody.class, OPERATION); + } + + /** + * Empty statements inside {@code switch} case lists are removed. + */ + @Test + public void testSwitchCaseSemicolonsAreRemoved() { + assertRefactor(SwitchCaseSemicolon.class, OPERATION); + } + + /** + * Semicolons in nested type bodies (static nested class, inner class, nested interface) + * are removed, including inline double-semicolons on field declarations. + */ + @Test + public void testNestedTypeSemicolonsAreRemoved() { + assertRefactor(NestedTypeSemicolon.class, OPERATION); + } + + /** + * Semicolons inside anonymous class bodies are removed. The field declaration's own + * terminating {@code ;} must be preserved. + */ + @Test + public void testAnonymousClassSemicolonsAreRemoved() { + assertRefactor(AnonymousClassSemicolon.class, OPERATION); + } + + /** + * A file containing a mix of class-level and block-level unnecessary semicolons is + * handled correctly in a single pass. + */ + @Test + public void testMixedBlockAndClassLevelSemicolonsAreRemoved() { + assertRefactor(MixedSemicolons.class, OPERATION); + } + + /** + * Enum body declarations section: stray semicolons between and after methods are removed, + * while the required separator between enum constants and body declarations is preserved. + */ + @Test + public void testEnumBodySemicolonsAreHandledCorrectly() { + assertRefactor(EnumBodySemicolon.class, OPERATION); + } + + /** + * Top-level enum with annotated constants: the required {@code ;} terminating the enum + * constant list must NOT be removed, even though it sits in the gap between the opening + * {@code \{} and the first body declaration. A class-level stray {@code ;} after a method + * body inside the same enum must still be removed. + */ + @Test + public void testTopLevelEnumAnnotatedConstantsSemicolonPreserved() { + assertRefactor(TopLevelEnumAnnotatedConstants.class, OPERATION); + } + + /** + * A {@code ;} immediately after the closing {@code \}} of a top-level class declaration + * is removed. + */ + @Test + public void testPostTypeBraceSemicolonIsRemoved() { + assertRefactor(ClassBraceSemicolon.class, OPERATION); + } + + /** + * Semicolons that appear only inside comments (line or block comments) must NOT be + * touched, even when they fall in positions that the source-text scanner would otherwise + * flag (e.g. class-body gaps between body declarations). + */ + @Test + public void testSemicolonsInsideCommentsArePreserved() { + assertRefactor(CommentedSemicolon.class, OPERATION); + } +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TopLevelEnumAnnotatedConstants.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TopLevelEnumAnnotatedConstants.java new file mode 100644 index 0000000..8a598ab --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TopLevelEnumAnnotatedConstants.java @@ -0,0 +1,14 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +enum TopLevelEnumAnnotatedConstants { + + @Deprecated + ALPHA, + + @Deprecated + BETA; + + public String label() { + return name().toLowerCase(); + }; +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TopLevelEnumAnnotatedConstantsAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TopLevelEnumAnnotatedConstantsAfter.java new file mode 100644 index 0000000..5480050 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TopLevelEnumAnnotatedConstantsAfter.java @@ -0,0 +1,14 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +enum TopLevelEnumAnnotatedConstantsAfter { + + @Deprecated + ALPHA, + + @Deprecated + BETA; + + public String label() { + return name().toLowerCase(); + } +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TryWithResourcesTrailingSemicolon.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TryWithResourcesTrailingSemicolon.java new file mode 100644 index 0000000..6f4b8d9 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TryWithResourcesTrailingSemicolon.java @@ -0,0 +1,30 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +import java.io.InputStream; + +public class TryWithResourcesTrailingSemicolon { + + void singleResourceWithTrailingSemicolon(InputStream input) throws Exception { + try (InputStream i = input;) { + i.read(); + } + } + + void multipleResourcesWithTrailingSemicolon(InputStream i1, InputStream i2) throws Exception { + try (InputStream a = i1; InputStream b = i2;) { + a.read(); + } + } + + void singleResourceNoTrailingSemicolon(InputStream input) throws Exception { + try (InputStream i = input) { + i.read(); + } + } + + void multipleResourcesNoTrailingSemicolon(InputStream i1, InputStream i2) throws Exception { + try (InputStream a = i1; InputStream b = i2) { + a.read(); + } + } +} diff --git a/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TryWithResourcesTrailingSemicolonAfter.java b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TryWithResourcesTrailingSemicolonAfter.java new file mode 100644 index 0000000..a460833 --- /dev/null +++ b/astra-core/src/test/java/org/alfasoftware/astra/core/refactoring/operations/sonar/s2959/TryWithResourcesTrailingSemicolonAfter.java @@ -0,0 +1,30 @@ +package org.alfasoftware.astra.core.refactoring.operations.sonar.s2959; + +import java.io.InputStream; + +public class TryWithResourcesTrailingSemicolonAfter { + + void singleResourceWithTrailingSemicolon(InputStream input) throws Exception { + try (InputStream i = input) { + i.read(); + } + } + + void multipleResourcesWithTrailingSemicolon(InputStream i1, InputStream i2) throws Exception { + try (InputStream a = i1; InputStream b = i2) { + a.read(); + } + } + + void singleResourceNoTrailingSemicolon(InputStream input) throws Exception { + try (InputStream i = input) { + i.read(); + } + } + + void multipleResourcesNoTrailingSemicolon(InputStream i1, InputStream i2) throws Exception { + try (InputStream a = i1; InputStream b = i2) { + a.read(); + } + } +}