diff --git a/src/main/java/org/openrewrite/staticanalysis/AvoidRepeatedPatternCompile.java b/src/main/java/org/openrewrite/staticanalysis/AvoidRepeatedPatternCompile.java new file mode 100644 index 000000000..2286b192b --- /dev/null +++ b/src/main/java/org/openrewrite/staticanalysis/AvoidRepeatedPatternCompile.java @@ -0,0 +1,830 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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 org.openrewrite.staticanalysis; + +import org.openrewrite.Cursor; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.JavaTemplate; +import org.openrewrite.java.MethodMatcher; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.Flag; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.Space; +import org.openrewrite.java.tree.Statement; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +public class AvoidRepeatedPatternCompile extends Recipe { + + private static final MethodMatcher PATTERN_COMPILE = + new MethodMatcher( + "java.util.regex.Pattern compile(java.lang.String)" + ); + + private static final MethodMatcher PATTERN_COMPILE_WITH_FLAGS = + new MethodMatcher( + "java.util.regex.Pattern compile(java.lang.String, int)" + ); + + private static final String METHOD_CANDIDATES = + "avoidRepeatedPatternCompile.methodCandidates"; + + private static final String CLASS_CANDIDATES = + "avoidRepeatedPatternCompile.classCandidates"; + + private static final String USED_FIELD_NAMES = + "avoidRepeatedPatternCompile.usedFieldNames"; + + @Override + public String getDisplayName() { + return "Avoid repeated `Pattern.compile()` calls"; + } + + @Override + public String getDescription() { + return "Moves `Pattern.compile()` calls with constant regular expressions " + + "from method-local variables into private static final fields."; + } + + @Override + public TreeVisitor getVisitor() { + return new JavaIsoVisitor() { + + @Override + public J.CompilationUnit visitCompilationUnit( + J.CompilationUnit compilationUnit, + ExecutionContext ctx) { + + J.CompilationUnit cu = + super.visitCompilationUnit(compilationUnit, ctx); + + if (cu != compilationUnit) { + maybeAddImport("java.util.regex.Pattern"); + } + + return cu; + } + + @Override + public J.ClassDeclaration visitClassDeclaration( + J.ClassDeclaration classDeclaration, + ExecutionContext ctx) { + + J.ClassDeclaration cd = + super.visitClassDeclaration(classDeclaration, ctx); + + List candidates = + getCursor().getMessage(CLASS_CANDIDATES); + + if (candidates == null || candidates.isEmpty()) { + return cd; + } + + /* + * Add in reverse order because firstStatement() + * always inserts at the beginning. + */ + for (int i = candidates.size() - 1; i >= 0; i--) { + + Candidate candidate = candidates.get(i); + + JavaTemplate template = + JavaTemplate.builder( + "private static final Pattern " + + candidate.constantName + + " = #{any(java.util.regex.Pattern)};" + ) + .imports("java.util.regex.Pattern") + .build(); + + cd = template.apply( + updateCursor(cd), + cd.getBody() + .getCoordinates() + .firstStatement(), + candidate.initializer + ); + } + + /* + * FIX: + * + * JavaTemplate may leave: + * + * private static final Pattern EMAIL_PATTERN = ...; + * + * private static final Pattern PHONE_PATTERN = ...; + * + * Remove the extra blank line between consecutive + * generated Pattern constants. + */ + if (candidates.size() > 1) { + + List statements = + new ArrayList<>( + cd.getBody() + .getStatements() + ); + + String firstWhitespace = + statements.get(0) + .getPrefix() + .getWhitespace(); + + int lastNewline = + firstWhitespace.lastIndexOf('\n'); + + String indentation = + lastNewline >= 0 + ? firstWhitespace.substring( + lastNewline + 1 + ) + : ""; + + /* + * The first N statements are the generated + * Pattern constants. + * + * Keep only one newline between them. + */ + for (int i = 1; + i < candidates.size(); + i++) { + + statements.set( + i, + statements.get(i) + .withPrefix( + Space.format( + "\n" + + indentation + ) + ) + ); + } + + cd = cd.withBody( + cd.getBody() + .withStatements(statements) + ); + } + + return cd; + } + + @Override + public J.MethodDeclaration visitMethodDeclaration( + J.MethodDeclaration method, + ExecutionContext ctx) { + + if (method.getBody() == null) { + return super.visitMethodDeclaration(method, ctx); + } + + J.ClassDeclaration enclosingClass = + getCursor() + .firstEnclosing( + J.ClassDeclaration.class + ); + + if (enclosingClass == null || + enclosingClass.getType() == null) { + + return super.visitMethodDeclaration(method, ctx); + } + + /* + * Keep the first version conservative. + * Don't process inner/nested classes. + */ + if (enclosingClass + .getType() + .getOwningClass() != null) { + + return super.visitMethodDeclaration(method, ctx); + } + + /* + * Only normal classes for now. + */ + if (enclosingClass.getKind() != + J.ClassDeclaration.Kind.Type.Class) { + + return super.visitMethodDeclaration(method, ctx); + } + + Cursor classCursor = + getCursor().dropParentUntil( + J.ClassDeclaration.class::isInstance + ); + + Set usedFieldNames = + classCursor.computeMessageIfAbsent( + USED_FIELD_NAMES, + key -> + collectExistingFieldNames( + enclosingClass + ) + ); + + List candidates = + new ArrayList<>(); + + /* + * Version 1 only handles direct method statements. + * + * Example: + * + * void validate() { + * Pattern p = Pattern.compile("..."); + * } + */ + for (Statement statement : + method.getBody() + .getStatements()) { + + if (!(statement instanceof + J.VariableDeclarations)) { + continue; + } + + J.VariableDeclarations declarations = + (J.VariableDeclarations) statement; + + /* + * Don't handle: + * + * Pattern a = ..., b = ...; + */ + if (declarations + .getVariables() + .size() != 1) { + + continue; + } + + J.VariableDeclarations.NamedVariable variable = + declarations + .getVariables() + .get(0); + + Expression initializer = + variable.getInitializer(); + + if (!(initializer instanceof + J.MethodInvocation)) { + + continue; + } + + J.MethodInvocation compile = + (J.MethodInvocation) initializer; + + boolean oneArgument = + PATTERN_COMPILE.matches(compile); + + boolean twoArguments = + PATTERN_COMPILE_WITH_FLAGS.matches( + compile + ); + + if (!oneArgument && !twoArguments) { + continue; + } + + List arguments = + compile.getArguments(); + + if (arguments.isEmpty()) { + continue; + } + + /* + * Version 1 only accepts literal regexes. + * + * YES: + * + * Pattern.compile("[0-9]+") + * + * NO: + * + * Pattern.compile(regex) + * + * NO: + * + * Pattern.compile(createRegex()) + */ + Expression regex = + arguments.get(0); + + if (!(regex instanceof J.Literal)) { + continue; + } + + Object literalValue = + ((J.Literal) regex).getValue(); + + if (!(literalValue instanceof String)) { + continue; + } + + /* + * For: + * + * Pattern.compile(regex, flags) + * + * make sure flags are safe to move + * to class scope. + */ + if (twoArguments) { + + if (arguments.size() != 2 || + !isConstantExpression( + arguments.get(1) + )) { + + continue; + } + } + + JavaType.Variable variableType = + variable.getVariableType(); + + /* + * Need type attribution so identifier + * replacement is safe. + */ + if (variableType == null) { + continue; + } + + String constantName = + createConstantName( + variable.getSimpleName(), + method.getSimpleName() + ); + + constantName = + makeUnique( + constantName, + usedFieldNames + ); + + usedFieldNames.add(constantName); + + JavaType.Variable newFieldType = + variableType + .withName(constantName) + .withOwner( + enclosingClass.getType() + ); + + Set flags = + new HashSet<>( + newFieldType.getFlags() + ); + + flags.add(Flag.Private); + flags.add(Flag.Static); + flags.add(Flag.Final); + + newFieldType = + newFieldType.withFlags(flags); + + Candidate candidate = + new Candidate( + declarations.getId(), + variableType, + newFieldType, + constantName, + compile.withPrefix( + Space.EMPTY + ) + ); + + candidates.add(candidate); + } + + if (candidates.isEmpty()) { + return super.visitMethodDeclaration( + method, + ctx + ); + } + + /* + * Child visitors use this to: + * + * 1. replace identifiers + * 2. remove local declarations + */ + getCursor().putMessage( + METHOD_CANDIDATES, + candidates + ); + + /* + * Tell enclosing class which static fields + * need to be generated. + */ + List classCandidates = + classCursor.computeMessageIfAbsent( + CLASS_CANDIDATES, + key -> new ArrayList<>() + ); + + classCandidates.addAll(candidates); + + return super.visitMethodDeclaration( + method, + ctx + ); + } + + /* + * Replace: + * + * emailPattern.matcher(...) + * + * with: + * + * EMAIL_PATTERN.matcher(...) + */ + @Override + public J.Identifier visitIdentifier( + J.Identifier identifier, + ExecutionContext ctx) { + + J.Identifier id = + super.visitIdentifier( + identifier, + ctx + ); + + List candidates = + getCursor().getNearestMessage( + METHOD_CANDIDATES + ); + + if (candidates == null) { + return id; + } + + for (Candidate candidate : candidates) { + + /* + * Match the actual variable metadata, + * not just its String name. + */ + if (Objects.equals( + id.getFieldType(), + candidate.originalVariableType)) { + + return id + .withSimpleName( + candidate.constantName + ) + .withFieldType( + candidate.newFieldType + ); + } + } + + return id; + } + + /* + * Remove: + * + * Pattern emailPattern = + * Pattern.compile(...); + */ + @Override + public J.VariableDeclarations + visitVariableDeclarations( + J.VariableDeclarations declarations, + ExecutionContext ctx) { + + List candidates = + getCursor().getNearestMessage( + METHOD_CANDIDATES + ); + + if (candidates != null) { + + for (Candidate candidate : + candidates) { + + if (declarations + .getId() + .equals( + candidate.declarationId + )) { + + return null; + } + } + } + + return super.visitVariableDeclarations( + declarations, + ctx + ); + } + }; + } + + /* + * Checks whether Pattern.compile(..., flags) + * uses flags that are safe to move to a + * static field. + */ + private static boolean isConstantExpression( + Expression expression) { + + if (expression instanceof J.Literal) { + return true; + } + + if (expression instanceof J.Identifier) { + + JavaType.Variable variable = + ((J.Identifier) expression) + .getFieldType(); + + return variable != null && + variable.hasFlags( + Flag.Static, + Flag.Final + ); + } + + if (expression instanceof J.FieldAccess) { + + JavaType.Variable variable = + ((J.FieldAccess) expression) + .getName() + .getFieldType(); + + return variable != null && + variable.hasFlags( + Flag.Static, + Flag.Final + ); + } + + /* + * Supports: + * + * Pattern.CASE_INSENSITIVE | + * Pattern.UNICODE_CASE + */ + if (expression instanceof J.Binary) { + + J.Binary binary = + (J.Binary) expression; + + return isConstantExpression( + binary.getLeft() + ) && + isConstantExpression( + binary.getRight() + ); + } + + return false; + } + + /* + * emailPattern -> EMAIL_PATTERN + * phonePattern -> PHONE_PATTERN + * + * If the variable is simply: + * + * Pattern pattern + * + * use the method name where possible. + * + * isValidEmail -> EMAIL_PATTERN + */ + private static String createConstantName( + String variableName, + String methodName) { + + if (!"pattern".equals(variableName)) { + return toUpperSnakeCase(variableName); + } + + String subject = methodName; + + String[] prefixes = { + "isValid", + "validate", + "valid", + "matches", + "match" + }; + + for (String prefix : prefixes) { + + if (subject.startsWith(prefix) && + subject.length() > prefix.length()) { + + subject = + subject.substring( + prefix.length() + ); + + break; + } + } + + if (subject.isEmpty()) { + return "PATTERN"; + } + + String result = + toUpperSnakeCase(subject); + + if (!result.endsWith("_PATTERN")) { + result += "_PATTERN"; + } + + return result; + } + + /* + * emailPattern -> EMAIL_PATTERN + * phonePattern -> PHONE_PATTERN + */ + private static String toUpperSnakeCase( + String name) { + + StringBuilder result = + new StringBuilder(); + + for (int i = 0; + i < name.length(); + i++) { + + char current = + name.charAt(i); + + if (!Character.isLetterOrDigit(current)) { + + if (result.length() > 0 && + result.charAt( + result.length() - 1 + ) != '_') { + + result.append('_'); + } + + continue; + } + + if (Character.isUpperCase(current) && + i > 0) { + + char previous = + name.charAt(i - 1); + + if (Character.isLowerCase(previous) || + Character.isDigit(previous)) { + + result.append('_'); + } + } + + result.append( + Character.toUpperCase(current) + ); + } + + return result.toString(); + } + + /* + * Avoid duplicate generated field names. + * + * EMAIL_PATTERN + * EMAIL_PATTERN_2 + * EMAIL_PATTERN_3 + */ + private static String makeUnique( + String desiredName, + Set usedNames) { + + if (!usedNames.contains(desiredName)) { + return desiredName; + } + + int number = 2; + + while (usedNames.contains( + desiredName + "_" + number)) { + + number++; + } + + return desiredName + "_" + number; + } + + /* + * Collect fields that already exist in the class + * so generated constants don't collide with them. + */ + private static Set + collectExistingFieldNames( + J.ClassDeclaration classDeclaration) { + + Set names = + new HashSet<>(); + + for (Statement statement : + classDeclaration + .getBody() + .getStatements()) { + + if (!(statement instanceof + J.VariableDeclarations)) { + + continue; + } + + J.VariableDeclarations declarations = + (J.VariableDeclarations) statement; + + for (J.VariableDeclarations.NamedVariable variable : + declarations.getVariables()) { + + names.add( + variable.getSimpleName() + ); + } + } + + return names; + } + + private static class Candidate { + + private final UUID declarationId; + + private final JavaType.Variable + originalVariableType; + + private final JavaType.Variable + newFieldType; + + private final String constantName; + + private final J.MethodInvocation + initializer; + + private Candidate( + UUID declarationId, + JavaType.Variable originalVariableType, + JavaType.Variable newFieldType, + String constantName, + J.MethodInvocation initializer) { + + this.declarationId = + declarationId; + + this.originalVariableType = + originalVariableType; + + this.newFieldType = + newFieldType; + + this.constantName = + constantName; + + this.initializer = + initializer; + } + } +} diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv index 417d4db7a..1bbde587c 100644 --- a/src/main/resources/META-INF/rewrite/recipes.csv +++ b/src/main/resources/META-INF/rewrite/recipes.csv @@ -9,6 +9,7 @@ maven,org.openrewrite.recipe:rewrite-static-analysis,org.openrewrite.staticanaly maven,org.openrewrite.recipe:rewrite-static-analysis,org.openrewrite.staticanalysis.AnnotateRequiredParameters,Annotate required method parameters with `@NonNull`,"Add `@NonNull` to parameters of public methods that are explicitly checked for `null` and throw an exception if null. By default `org.jspecify.annotations.NonNull` is used, but through the `nonNullAnnotationClass` option a custom annotation can be provided. When providing a custom `nonNullAnnotationClass` that annotation should be meta annotated with `@Target(TYPE_USE)`. This recipe scans for methods that do not already have parameters annotated with `@NonNull` annotation and checks for null validation patterns that throw exceptions, such as `if (param == null) throw new IllegalArgumentException()`.",1,,Static analysis and remediation,,Remediations for issues identified by SAST tools.,"[{""name"":""nonNullAnnotationClass"",""type"":""String"",""displayName"":""`@NonNull` annotation class"",""description"":""The fully qualified name of the @NonNull annotation. The annotation should be meta annotated with `@Target(TYPE_USE)`. Defaults to `org.jspecify.annotations.NonNull`"",""example"":""org.jspecify.annotations.NonNull""}]", maven,org.openrewrite.recipe:rewrite-static-analysis,org.openrewrite.staticanalysis.AtomicPrimitiveEqualsUsesGet,"Atomic Boolean, Integer, and Long equality checks compare their values","`AtomicBoolean#equals(Object)`, `AtomicInteger#equals(Object)` and `AtomicLong#equals(Object)` are only equal to their instance. This recipe converts `a.equals(b)` to `a.get() == b.get()`. These atomic classes do not override `equals` from `Object`, so calling it compares object identity rather than the wrapped value, which is almost never the intended behavior.",1,,Static analysis and remediation,,Remediations for issues identified by SAST tools.,, maven,org.openrewrite.recipe:rewrite-static-analysis,org.openrewrite.staticanalysis.AvoidBoxedBooleanExpressions,Avoid boxed boolean expressions,"Under certain conditions the `java.lang.Boolean` type is used as an expression, and it may throw a `NullPointerException` if the value is null. Using `Boolean.TRUE.equals(...)` guards against unboxing a `null` reference in control flow positions like `if` conditions and ternary operators.",1,,Static analysis and remediation,,Remediations for issues identified by SAST tools.,, +maven,org.openrewrite.recipe:rewrite-static-analysis,org.openrewrite.staticanalysis.AvoidRepeatedPatternCompile,Avoid repeated `Pattern.compile()` calls,Moves `Pattern.compile()` calls with constant regular expressions from method-local variables into private static final fields.,1,,Static analysis and remediation,,Remediations for issues identified by SAST tools.,, maven,org.openrewrite.recipe:rewrite-static-analysis,org.openrewrite.staticanalysis.BigDecimalDoubleConstructorRecipe,`new BigDecimal(double)` should not be used,"Use of `new BigDecimal(double)` constructor can lead to loss of precision. Use `BigDecimal.valueOf(double)` instead. For example writing `new BigDecimal(0.1)` does not create a `BigDecimal` which is exactly equal to `0.1`, but it is equal to `0.1000000000000000055511151231257827021181583404541015625`. This is because `0.1` cannot be represented exactly as a double (or, for that matter, as a binary fraction of any finite length). `BigDecimal.valueOf` avoids this by converting through a string representation, preserving the value you actually intended.",1,,Static analysis and remediation,,Remediations for issues identified by SAST tools.,, maven,org.openrewrite.recipe:rewrite-static-analysis,org.openrewrite.staticanalysis.BigDecimalRoundingConstantsToEnums,`BigDecimal` rounding constants to `RoundingMode` enums,Convert `BigDecimal` rounding constants to the equivalent `RoundingMode` enum. The integer-based rounding constants on `BigDecimal` are deprecated and lack type safety; the `RoundingMode` enum makes the rounding behavior self-documenting and prevents invalid values.,1,,Static analysis and remediation,,Remediations for issues identified by SAST tools.,, diff --git a/src/test/java/org/openrewrite/staticanalysis/AvoidRepeatedPatternCompileTest.java b/src/test/java/org/openrewrite/staticanalysis/AvoidRepeatedPatternCompileTest.java new file mode 100644 index 000000000..7ab2c0b14 --- /dev/null +++ b/src/test/java/org/openrewrite/staticanalysis/AvoidRepeatedPatternCompileTest.java @@ -0,0 +1,263 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Moderne Source Available License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://docs.moderne.io/licensing/moderne-source-available-license + *

+ * 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 org.openrewrite.staticanalysis; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.Issue; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class AvoidRepeatedPatternCompileTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new AvoidRepeatedPatternCompile()); + } + + /** + * Main happy path: + *

+ * Pattern compiled inside method from literal regex + * becomes static final field. + */ + @Test + @DocumentExample + @Issue("https://github.com/openrewrite/rewrite-static-analysis/issues/622") + void extractLiteralPatternToConstant() { + rewriteRun( + java( + """ + import java.util.regex.Pattern; + + class EmailValidator { + + boolean valid(String email) { + Pattern emailPattern = Pattern.compile("[a-z]+"); + return emailPattern.matcher(email).matches(); + } + } + """, + """ + import java.util.regex.Pattern; + + class EmailValidator { + + private static final Pattern EMAIL_PATTERN = Pattern.compile("[a-z]+"); + + boolean valid(String email) { + return EMAIL_PATTERN.matcher(email).matches(); + } + } + """ + ) + ); + } + + /** + * Dynamic regex must NOT be moved. + *

+ * We don't know its value until runtime. + */ + @Test + void doNotChangeDynamicRegex() { + rewriteRun( + java( + """ + import java.util.regex.Pattern; + + class Validator { + + boolean valid(String value, String regex) { + Pattern pattern = Pattern.compile(regex); + return pattern.matcher(value).matches(); + } + } + """ + ) + ); + } + + /** + * Regex returned from another method is runtime-generated, + * so it must NOT be moved. + */ + @Test + void doNotChangeMethodGeneratedRegex() { + rewriteRun( + java( + """ + import java.util.regex.Pattern; + + class Validator { + + boolean valid(String value) { + Pattern pattern = Pattern.compile(createRegex()); + return pattern.matcher(value).matches(); + } + + private String createRegex() { + return "[a-z]+"; + } + } + """ + ) + ); + } + + /** + * Pattern.compile(String, int) should work when the flags + * are compile-time constants. + */ + @Test + void extractPatternWithConstantFlags() { + rewriteRun( + java( + """ + import java.util.regex.Pattern; + + class EmailValidator { + + boolean valid(String email) { + Pattern emailPattern = Pattern.compile("[a-z]+", Pattern.CASE_INSENSITIVE); + return emailPattern.matcher(email).matches(); + } + } + """, + """ + import java.util.regex.Pattern; + + class EmailValidator { + + private static final Pattern EMAIL_PATTERN = Pattern.compile("[a-z]+", Pattern.CASE_INSENSITIVE); + + boolean valid(String email) { + return EMAIL_PATTERN.matcher(email).matches(); + } + } + """ + ) + ); + } + + /** + * Runtime flags must NOT be moved to a static field. + */ + @Test + void doNotChangeDynamicFlags() { + rewriteRun( + java( + """ + import java.util.regex.Pattern; + + class Validator { + + boolean valid(String value, int flags) { + Pattern pattern = Pattern.compile("[a-z]+", flags); + return pattern.matcher(value).matches(); + } + } + """ + ) + ); + } + + /** + * This verifies that we use JavaType.Variable rather + * than blindly replacing everything named "pattern". + */ + @Test + void doesNotReplaceUnrelatedVariableWithSameName() { + rewriteRun( + java( + """ + import java.util.regex.Pattern; + + class Validator { + + boolean first(String value) { + Pattern pattern = Pattern.compile("[a-z]+"); + return pattern.matcher(value).matches(); + } + + boolean second() { + String pattern = "hello"; + return pattern.isEmpty(); + } + } + """, + """ + import java.util.regex.Pattern; + + class Validator { + + private static final Pattern FIRST_PATTERN = Pattern.compile("[a-z]+"); + + boolean first(String value) { + return FIRST_PATTERN.matcher(value).matches(); + } + + boolean second() { + String pattern = "hello"; + return pattern.isEmpty(); + } + } + """ + ) + ); + } + + /** + * More than one local Pattern should result in + * more than one static final field. + */ + @Test + void multiplePatternsInSameClass() { + rewriteRun( + java( + """ + import java.util.regex.Pattern; + + class Validator { + + boolean valid(String email, String phone) { + Pattern emailPattern = Pattern.compile("[a-z]+"); + Pattern phonePattern = Pattern.compile("[0-9]+"); + return emailPattern.matcher(email).matches() && + phonePattern.matcher(phone).matches(); + } + } + """, + """ + import java.util.regex.Pattern; + + class Validator { + + private static final Pattern EMAIL_PATTERN = Pattern.compile("[a-z]+"); + private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]+"); + + boolean valid(String email, String phone) { + return EMAIL_PATTERN.matcher(email).matches() && + PHONE_PATTERN.matcher(phone).matches(); + } + } + """ + ) + ); + } +}