diff --git a/src/main/java/org/openrewrite/java/migrate/search/ThreadLocalTable.java b/src/main/java/org/openrewrite/java/migrate/search/ThreadLocalTable.java new file mode 100644 index 0000000000..13051ccaf5 --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/search/ThreadLocalTable.java @@ -0,0 +1,61 @@ +/* + * 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.java.migrate.search; + +import lombok.Value; +import org.openrewrite.Column; +import org.openrewrite.DataTable; +import org.openrewrite.Recipe; + +public class ThreadLocalTable extends DataTable { + + public ThreadLocalTable(Recipe recipe) { + super(recipe, + "ThreadLocal usage", + "ThreadLocal variables and their mutation patterns."); + } + + @Value + public static class Row { + @Column(displayName = "Source file", + description = "The source file containing the ThreadLocal declaration.") + String sourceFile; + + @Column(displayName = "Class name", + description = "The fully qualified class name where the ThreadLocal is declared.") + String className; + + @Column(displayName = "Field name", + description = "The name of the ThreadLocal field.") + String fieldName; + + @Column(displayName = "Access modifier", + description = "The access modifier of the ThreadLocal field (private, protected, public, package-private).") + String accessModifier; + + @Column(displayName = "Field modifiers", + description = "Additional modifiers like static, final.") + String modifiers; + + @Column(displayName = "Mutation type", + description = "Type of mutation detected (Never mutated, Mutated only in initialization, Mutated in defining class, Mutated externally, Potentially mutable).") + String mutationType; + + @Column(displayName = "Message", + description = "Detailed message about the ThreadLocal's usage pattern.") + String message; + } +} diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/AbstractFindThreadLocals.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/AbstractFindThreadLocals.java new file mode 100644 index 0000000000..1b32173630 --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/AbstractFindThreadLocals.java @@ -0,0 +1,408 @@ +/* + * 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.java.migrate.search.threadlocal; + +import lombok.Getter; +import lombok.Value; +import org.jspecify.annotations.Nullable; +import org.openrewrite.ExecutionContext; +import org.openrewrite.ScanningRecipe; +import org.openrewrite.SourceFile; +import org.openrewrite.TreeVisitor; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.MethodMatcher; +import org.openrewrite.java.migrate.search.ThreadLocalTable; +import org.openrewrite.java.search.UsesType; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.TypeUtils; +import org.openrewrite.marker.SearchResult; + +import java.nio.file.Path; +import java.util.*; + +import static org.openrewrite.Preconditions.check; +import static org.openrewrite.Preconditions.or; + + +public abstract class AbstractFindThreadLocals extends ScanningRecipe { + + protected static final String THREAD_LOCAL_FQN = "java.lang.ThreadLocal"; + protected static final String INHERITED_THREAD_LOCAL_FQN = "java.lang.InheritableThreadLocal"; + + private static final MethodMatcher THREAD_LOCAL_SET = new MethodMatcher(THREAD_LOCAL_FQN + " set(..)"); + private static final MethodMatcher THREAD_LOCAL_REMOVE = new MethodMatcher(THREAD_LOCAL_FQN + " remove()"); + private static final MethodMatcher INHERITABLE_THREAD_LOCAL_SET = new MethodMatcher(INHERITED_THREAD_LOCAL_FQN + " set(..)"); + private static final MethodMatcher INHERITABLE_THREAD_LOCAL_REMOVE = new MethodMatcher(INHERITED_THREAD_LOCAL_FQN + " remove()"); + + transient ThreadLocalTable dataTable = new ThreadLocalTable(this); + + @Value + public static class ThreadLocalAccumulator { + Map threadLocals = new HashMap<>(); + + public void recordDeclaration(String fqn, Path sourcePath, boolean isPrivate, boolean isStatic, boolean isFinal) { + threadLocals.computeIfAbsent(fqn, k -> new ThreadLocalInfo()) + .setDeclaration(sourcePath, isPrivate, isStatic, isFinal); + } + + public void recordMutation(String fqn, Path sourcePath, boolean isInitContext) { + ThreadLocalInfo info = threadLocals.computeIfAbsent(fqn, k -> new ThreadLocalInfo()); + if (isInitContext) { + info.addInitMutation(sourcePath); + } else { + info.addRegularMutation(sourcePath); + } + } + + public @Nullable ThreadLocalInfo getInfo(String fqn) { + return threadLocals.get(fqn); + } + + public boolean hasDeclarations() { + return threadLocals.values().stream().anyMatch(ThreadLocalInfo::isDeclared); + } + } + + public static class ThreadLocalInfo { + private @Nullable Path declarationPath; + @Getter + private boolean isPrivate; + @Getter + private boolean isStatic; + @Getter + private boolean isFinal; + @Getter + private boolean declared; + private final Set initMutationPaths = new HashSet<>(); + private final Set regularMutationPaths = new HashSet<>(); + + void setDeclaration(Path path, boolean priv, boolean stat, boolean fin) { + this.declarationPath = path; + this.isPrivate = priv; + this.isStatic = stat; + this.isFinal = fin; + this.declared = true; + } + + /** + * Records a mutation from an initialization context (constructor/static initializer). + */ + void addInitMutation(Path path) { + initMutationPaths.add(path); + } + + /** + * Records a regular (non-initialization) mutation. + */ + void addRegularMutation(Path path) { + regularMutationPaths.add(path); + } + + /** + * Checks if there are no mutations (both init and regular). + */ + public boolean hasNoMutation() { + return initMutationPaths.isEmpty() && regularMutationPaths.isEmpty(); + } + + /** + * Checks if there are only mutations from initialization contexts (constructors/static initializers). + */ + public boolean hasOnlyInitMutations() { + return !initMutationPaths.isEmpty() && regularMutationPaths.isEmpty(); + } + + /** + * Checks if there are any mutations (both init and regular) from files other than the declaration file. + */ + public boolean hasExternalMutations() { + if (!declared || declarationPath == null) { + return true; // Conservative + } + + // Check if any mutation is from a different file + return initMutationPaths.stream().anyMatch(p -> !p.equals(declarationPath)) || + regularMutationPaths.stream().anyMatch(p -> !p.equals(declarationPath)); + } + + /** + * Checks if all mutations (both init and regular) are from the same file as the declaration. + */ + public boolean isOnlyLocallyMutated() { + if (!declared || declarationPath == null) { + return false; + } + + // All mutations must be from the same file as declaration + return initMutationPaths.stream().allMatch(p -> p.equals(declarationPath)) && + regularMutationPaths.stream().allMatch(p -> p.equals(declarationPath)); + } + } + + @Override + public ThreadLocalAccumulator getInitialValue(ExecutionContext ctx) { + return new ThreadLocalAccumulator(); + } + + @Override + public TreeVisitor getScanner(ThreadLocalAccumulator acc) { + return check( + or(new UsesType<>(THREAD_LOCAL_FQN, true), + new UsesType<>(INHERITED_THREAD_LOCAL_FQN, true)), + new JavaIsoVisitor() { + @Override + public J.VariableDeclarations.NamedVariable visitVariable( + J.VariableDeclarations.NamedVariable variable, ExecutionContext ctx) { + variable = super.visitVariable(variable, ctx); + + // Early return for non-ThreadLocal types + if (!isThreadLocalType(variable.getType())) { + return variable; + } + + // Early return for local variables (not fields) + J.MethodDeclaration enclosingMethod = getCursor().firstEnclosing(J.MethodDeclaration.class); + if (enclosingMethod != null) { + return variable; + } + + // Early return if not in a class + J.ClassDeclaration classDecl = getCursor().firstEnclosing(J.ClassDeclaration.class); + if (classDecl == null) { + return variable; + } + + // Early return if we can't find the variable declarations + J.VariableDeclarations variableDecls = getCursor().firstEnclosing(J.VariableDeclarations.class); + if (variableDecls == null) { + return variable; + } + + // Process ThreadLocal field declaration + JavaType.@Nullable FullyQualified classType = classDecl.getType(); + String className = classType != null ? classType.getFullyQualifiedName() : "UnknownClass"; + String fqn = className + "." + variable.getName().getSimpleName(); + + boolean isPrivate = variableDecls.hasModifier(J.Modifier.Type.Private); + boolean isStatic = variableDecls.hasModifier(J.Modifier.Type.Static); + boolean isFinal = variableDecls.hasModifier(J.Modifier.Type.Final); + Path sourcePath = getCursor().firstEnclosingOrThrow(SourceFile.class).getSourcePath(); + + acc.recordDeclaration(fqn, sourcePath, isPrivate, isStatic, isFinal); + return variable; + } + + @Override + public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) { + method = super.visitMethodInvocation(method, ctx); + + // Early return if not a ThreadLocal mutation method + if (!THREAD_LOCAL_SET.matches(method) && !THREAD_LOCAL_REMOVE.matches(method) && + !INHERITABLE_THREAD_LOCAL_SET.matches(method) && !INHERITABLE_THREAD_LOCAL_REMOVE.matches(method)) { + return method; + } + + String fqn = getFieldFullyQualifiedName(method.getSelect()); + if (fqn == null) { + return method; + } + + Path sourcePath = getCursor().firstEnclosingOrThrow(SourceFile.class).getSourcePath(); + boolean isInitContext = isInInitializationContext(); + acc.recordMutation(fqn, sourcePath, isInitContext); + return method; + } + + @Override + public J.Assignment visitAssignment(J.Assignment assignment, ExecutionContext ctx) { + assignment = super.visitAssignment(assignment, ctx); + + // Early return if not a ThreadLocal field access + if (!isThreadLocalFieldAccess(assignment.getVariable())) { + return assignment; + } + + String fqn = getFieldFullyQualifiedName(assignment.getVariable()); + if (fqn == null) { + return assignment; + } + + Path sourcePath = getCursor().firstEnclosingOrThrow(SourceFile.class).getSourcePath(); + boolean isInitContext = isInInitializationContext(); + acc.recordMutation(fqn, sourcePath, isInitContext); + return assignment; + } + + private boolean isInInitializationContext() { + J.MethodDeclaration methodDecl = getCursor().firstEnclosing(J.MethodDeclaration.class); + + if (methodDecl == null) { + // Check if we're in a static initializer block + return getCursor().getPathAsStream() + .filter(J.Block.class::isInstance) + .map(J.Block.class::cast) + .anyMatch(J.Block::isStatic); + } + + // Check if it's a constructor + return methodDecl.isConstructor(); + } + + private boolean isThreadLocalFieldAccess(Expression expression) { + if (expression instanceof J.Identifier) { + return isThreadLocalType(expression.getType()); + } + if (expression instanceof J.FieldAccess) { + return isThreadLocalType(expression.getType()); + } + return false; + } + + private @Nullable String getFieldFullyQualifiedName(@Nullable Expression expression) { + if (expression == null) { + return null; + } + + JavaType.@Nullable Variable varType = null; + if (expression instanceof J.Identifier) { + varType = ((J.Identifier) expression).getFieldType(); + } else if (expression instanceof J.FieldAccess) { + varType = ((J.FieldAccess) expression).getName().getFieldType(); + } + + if (varType == null) { + return null; + } + + JavaType owner = varType.getOwner(); + if (!(owner instanceof JavaType.FullyQualified)) { + return null; + } + + return ((JavaType.FullyQualified) owner).getFullyQualifiedName() + "." + varType.getName(); + } + + }); + } + + @Override + public TreeVisitor getVisitor(ThreadLocalAccumulator acc) { + return check(acc.hasDeclarations(), + new JavaIsoVisitor() { + + + @Override + public J.VariableDeclarations visitVariableDeclarations(J.VariableDeclarations multiVariable, ExecutionContext ctx) { + multiVariable = super.visitVariableDeclarations(multiVariable, ctx); + + J.ClassDeclaration classDecl = getCursor().firstEnclosing(J.ClassDeclaration.class); + if(classDecl == null) { + return multiVariable; + } + + for (J.VariableDeclarations.NamedVariable variable : multiVariable.getVariables()) { + if (isThreadLocalType(variable.getType())) { + String className = classDecl.getType() != null ? + classDecl.getType().getFullyQualifiedName() : "UnknownClass"; + String fieldName = variable.getName().getSimpleName(); + String fqn = className + "." + fieldName; + + ThreadLocalInfo info = acc.getInfo(fqn); + if (info != null && shouldMarkThreadLocal(info)) { + String message = getMessage(info); + String mutationType = getMutationType(info); + + dataTable.insertRow(ctx, new ThreadLocalTable.Row( + getCursor().firstEnclosingOrThrow(SourceFile.class).getSourcePath().toString(), + className, + fieldName, + getAccessModifier(multiVariable), + getFieldModifiers(multiVariable), + mutationType, + message + )); + + return SearchResult.found(multiVariable, message); + } + } + } + + return multiVariable; + } + + private String getAccessModifier(J.VariableDeclarations variableDecls) { + if (variableDecls.hasModifier(J.Modifier.Type.Private)) { + return "private"; + } + if (variableDecls.hasModifier(J.Modifier.Type.Protected)) { + return "protected"; + } + if (variableDecls.hasModifier(J.Modifier.Type.Public)) { + return "public"; + } + return "package-private"; + } + + private String getFieldModifiers(J.VariableDeclarations variableDecls) { + List mods = new ArrayList<>(); + if (variableDecls.hasModifier(J.Modifier.Type.Static)) { + mods.add("static"); + } + if (variableDecls.hasModifier(J.Modifier.Type.Final)) { + mods.add("final"); + } + return String.join(" ", mods); + } + + }); + } + + /** + * Determines whether a ThreadLocal should be marked based on its usage info. + * Implementations should define the criteria for marking. + * It is used to decide if a ThreadLocal variable should be highlighted in the results. + * If an expected ThreadLocal instance is missing from the results, consider adjusting this method. + * + * @param info The ThreadLocalInfo containing usage details. + * @return true if the ThreadLocal should be marked, false otherwise. + */ + protected abstract boolean shouldMarkThreadLocal(ThreadLocalInfo info); + /** + * Generates a descriptive message about the ThreadLocal's usage pattern. + * Implementations should provide context-specific messages. + * It is used to receive the Markers message and the Data Tables detailed message. + * + * @param info The ThreadLocalInfo containing usage details. + * @return A string message describing the ThreadLocal's usage. + */ + protected abstract String getMessage(ThreadLocalInfo info); + /** + * Determines the mutation type of the ThreadLocal based on its usage info. + * Implementations should define the mutation categories. + * It is used to populate the Data Tables human-readable mutation type column. + * + * @param info The ThreadLocalInfo containing usage details. + * @return A string representing the mutation type. + */ + protected abstract String getMutationType(ThreadLocalInfo info); + + protected static boolean isThreadLocalType(@Nullable JavaType type) { + return TypeUtils.isOfClassType(type, THREAD_LOCAL_FQN) || + TypeUtils.isOfClassType(type, INHERITED_THREAD_LOCAL_FQN); + } +} diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocals.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocals.java new file mode 100644 index 0000000000..728040176b --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocals.java @@ -0,0 +1,61 @@ +/* + * 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.java.migrate.search.threadlocal; + +import lombok.EqualsAndHashCode; +import lombok.Value; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +@Value +@EqualsAndHashCode(callSuper = false) +public class FindNeverMutatedThreadLocals extends AbstractFindThreadLocals { + + @Override + public String getDisplayName() { + return "Find ThreadLocal variables that are never mutated"; + } + + @Override + public String getDescription() { + return "Find `ThreadLocal` variables that are never mutated after initialization. " + + "These are prime candidates for migration to `ScopedValue` in Java 25+ as they are effectively immutable. " + + "The recipe identifies `ThreadLocal` variables that are only initialized but never reassigned or modified through `set()` or `remove()` methods."; + } + + @Override + public Set getTags() { + return new HashSet<>(Arrays.asList("java25", "threadlocal", "scopedvalue", "migration")); + } + + @Override + protected boolean shouldMarkThreadLocal(ThreadLocalInfo info) { + // Mark ThreadLocals that have no mutations at all + return info.hasNoMutation(); + } + + @Override + protected String getMessage(ThreadLocalInfo info) { + return "ThreadLocal is never mutated and could be replaced with ScopedValue"; + } + + @Override + protected String getMutationType(ThreadLocalInfo info) { + return "Never mutated"; + } +} diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutside.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutside.java new file mode 100644 index 0000000000..b6c0909640 --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutside.java @@ -0,0 +1,74 @@ +/* + * 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.java.migrate.search.threadlocal; + +import lombok.EqualsAndHashCode; +import lombok.Value; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +@Value +@EqualsAndHashCode(callSuper = false) +public class FindThreadLocalsMutableFromOutside extends AbstractFindThreadLocals { + + @Override + public String getDisplayName() { + return "Find ThreadLocal variables mutable from outside their defining class"; + } + + @Override + public String getDescription() { + //language=markdown + return "Find `ThreadLocal` variables that can be mutated from outside their defining class. " + + "These ThreadLocals have the highest risk as they can be modified by any code with access to them. " + + "This includes non-private ThreadLocals or those mutated from other classes in the codebase."; + } + + @Override + public Set getTags() { + return new HashSet<>(Arrays.asList("java25", "threadlocal", "scopedvalue", "migration", "security")); + } + + @Override + protected boolean shouldMarkThreadLocal(ThreadLocalInfo info) { + // Mark ThreadLocals that are either: + // 1. Actually mutated from outside their defining class + // 2. Non-private (and thus potentially mutable from outside) + return info.hasExternalMutations() || !info.isPrivate(); + } + + @Override + protected String getMessage(ThreadLocalInfo info) { + if (info.hasExternalMutations()) { + return "ThreadLocal is mutated from outside its defining class"; + } + + // Non-private but not currently mutated externally + String access = info.isStatic() ? "static " : ""; + return "ThreadLocal is " + access + "non-private and can potentially be mutated from outside"; + } + + @Override + protected String getMutationType(ThreadLocalInfo info) { + if (info.hasExternalMutations()) { + return "Mutated externally"; + } + + return "Potentially mutable"; + } +} diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScope.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScope.java new file mode 100644 index 0000000000..844b5c88da --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScope.java @@ -0,0 +1,75 @@ +/* + * 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.java.migrate.search.threadlocal; + +import lombok.EqualsAndHashCode; +import lombok.Value; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +@Value +@EqualsAndHashCode(callSuper = false) +public class FindThreadLocalsMutatedOnlyInDefiningScope extends AbstractFindThreadLocals { + + @Override + public String getDisplayName() { + return "Find ThreadLocal variables mutated only in their defining scope"; + } + + @Override + public String getDescription() { + //language=markdown + return "Find `ThreadLocal` variables that are only mutated within their defining class or initialization context (constructor/static initializer). " + + "These may be candidates for refactoring as they have limited mutation scope. " + + "The recipe identifies `ThreadLocal` variables that are only modified during initialization or within their declaring class."; + } + + @Override + public Set getTags() { + return new HashSet<>(Arrays.asList("java25", "threadlocal", "scopedvalue", "migration")); + } + + @Override + protected boolean shouldMarkThreadLocal(ThreadLocalInfo info) { + if (info.hasNoMutation()) { + return false; + } + if (!info.isPrivate()) { + return false; + } + return info.isOnlyLocallyMutated(); + } + + @Override + protected String getMessage(ThreadLocalInfo info) { + if (info.hasOnlyInitMutations()) { + return "ThreadLocal is only mutated during initialization (constructor/static initializer)"; + } + + return "ThreadLocal is only mutated within its defining class"; + } + + @Override + protected String getMutationType(ThreadLocalInfo info) { + if (info.hasOnlyInitMutations()) { + return "Mutated only in initialization"; + } + + return "Mutated in defining class"; + } +} diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/package-info.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/package-info.java new file mode 100644 index 0000000000..34bcf91372 --- /dev/null +++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/package-info.java @@ -0,0 +1,21 @@ +/* + * 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. + */ +@NullMarked +@NonNullFields +package org.openrewrite.java.migrate.search.threadlocal; + +import org.jspecify.annotations.NullMarked; +import org.openrewrite.internal.lang.NonNullFields; diff --git a/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsCrossFileTest.java b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsCrossFileTest.java new file mode 100644 index 0000000000..d19906a99d --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsCrossFileTest.java @@ -0,0 +1,419 @@ +/* + * 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.java.migrate.search.threadlocal; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class FindNeverMutatedThreadLocalsCrossFileTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new FindNeverMutatedThreadLocals()); + } + + @DocumentExample + @Test + void detectMutationFromAnotherClassInSamePackage() { + rewriteRun( + // First class with package-private ThreadLocal + java( + """ + package com.example; + + class ThreadLocalHolder { + static final ThreadLocal SHARED_TL = new ThreadLocal<>(); + + public String getValue() { + return SHARED_TL.get(); + } + } + """ + ), + // Second class that mutates the ThreadLocal + java( + """ + package com.example; + + class ThreadLocalMutator { + public void mutate() { + ThreadLocalHolder.SHARED_TL.set("mutated"); + } + + public void cleanup() { + ThreadLocalHolder.SHARED_TL.remove(); + } + } + """ + ) + ); + // The ThreadLocal should NOT be marked as immutable because it's mutated in ThreadLocalMutator + } + + @Test + void detectNoMutationAcrossMultipleClasses() { + rewriteRun( + java( + """ + package com.example; + + public class ReadOnlyHolder { + public static final ThreadLocal COUNTER = ThreadLocal.withInitial(() -> 0); + + public int getCount() { + return COUNTER.get(); + } + } + """, + """ + package com.example; + + public class ReadOnlyHolder { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/public static final ThreadLocal COUNTER = ThreadLocal.withInitial(() -> 0); + + public int getCount() { + return COUNTER.get(); + } + } + """ + ), + java( + """ + package com.example; + + class Reader1 { + public void readValue() { + Integer value = ReadOnlyHolder.COUNTER.get(); + System.out.println(value); + } + } + """ + ), + java( + """ + package com.example; + + class Reader2 { + public int calculate() { + return ReadOnlyHolder.COUNTER.get() + 10; + } + } + """ + ) + ); + // The ThreadLocal should be marked with a warning since it's public but never mutated + } + + @Test + void detectMutationThroughInheritance() { + rewriteRun( + java( + """ + package com.example; + + public class BaseClass { + protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>(); + + public String getValue() { + return PROTECTED_TL.get(); + } + } + """ + ), + java( + """ + package com.example.sub; + + import com.example.BaseClass; + + public class SubClass extends BaseClass { + public void modifyThreadLocal() { + PROTECTED_TL.set("modified by subclass"); + } + } + """ + ) + ); + // The ThreadLocal should NOT be marked as immutable because it's mutated in SubClass + } + + @Test + void privateThreadLocalNotAccessibleFromOtherClass() { + rewriteRun( + java( + """ + package com.example; + + public class PrivateHolder { + private static final ThreadLocal PRIVATE_TL = new ThreadLocal<>(); + + public String getValue() { + return PRIVATE_TL.get(); + } + + public static class NestedClass { + public void tryToAccess() { + // Can access private field from nested class + String value = PRIVATE_TL.get(); + } + } + } + """, + """ + package com.example; + + public class PrivateHolder { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal PRIVATE_TL = new ThreadLocal<>(); + + public String getValue() { + return PRIVATE_TL.get(); + } + + public static class NestedClass { + public void tryToAccess() { + // Can access private field from nested class + String value = PRIVATE_TL.get(); + } + } + } + """ + ), + java( + """ + package com.example; + + class ExternalClass { + public void cannotAccess() { + // Cannot access private ThreadLocal from PrivateHolder + // This class cannot mutate PRIVATE_TL + } + } + """ + ) + ); + // Private ThreadLocal should be marked as immutable since it can't be mutated externally + } + + @Test + void detectMutationInNestedClass() { + rewriteRun( + java( + """ + package com.example; + + public class OuterClass { + private static final ThreadLocal TL = new ThreadLocal<>(); + + public String getValue() { + return TL.get(); + } + + public static class InnerClass { + public void mutate() { + TL.set("mutated by inner class"); + } + } + } + """ + ) + ); + // ThreadLocal should NOT be marked as immutable because it's mutated in InnerClass + } + + @Test + void detectMutationThroughStaticImport() { + rewriteRun( + java( + """ + package com.example; + + public class ThreadLocalProvider { + public static final ThreadLocal STATIC_TL = new ThreadLocal<>(); + } + """ + ), + java( + """ + package com.example.user; + + import static com.example.ThreadLocalProvider.STATIC_TL; + + public class StaticImportUser { + public void mutate() { + STATIC_TL.set("mutated through static import"); + } + } + """ + ) + ); + // ThreadLocal should NOT be marked as immutable due to mutation through static import + } + + @Test + void multipleThreadLocalsWithMixedAccessPatterns() { + rewriteRun( + java( + """ + package com.example; + + public class MultipleThreadLocals { + private static final ThreadLocal PRIVATE_IMMUTABLE = new ThreadLocal<>(); + static final ThreadLocal PACKAGE_MUTATED = new ThreadLocal<>(); + public static final ThreadLocal PUBLIC_READ_ONLY = new ThreadLocal<>(); + protected static final ThreadLocal PROTECTED_MUTATED = new ThreadLocal<>(); + + public void readAll() { + String p1 = PRIVATE_IMMUTABLE.get(); + String p2 = PACKAGE_MUTATED.get(); + String p3 = PUBLIC_READ_ONLY.get(); + String p4 = PROTECTED_MUTATED.get(); + } + } + """, + """ + package com.example; + + public class MultipleThreadLocals { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal PRIVATE_IMMUTABLE = new ThreadLocal<>(); + static final ThreadLocal PACKAGE_MUTATED = new ThreadLocal<>(); + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/public static final ThreadLocal PUBLIC_READ_ONLY = new ThreadLocal<>(); + protected static final ThreadLocal PROTECTED_MUTATED = new ThreadLocal<>(); + + public void readAll() { + String p1 = PRIVATE_IMMUTABLE.get(); + String p2 = PACKAGE_MUTATED.get(); + String p3 = PUBLIC_READ_ONLY.get(); + String p4 = PROTECTED_MUTATED.get(); + } + } + """ + ), + java( + """ + package com.example; + + class Mutator { + public void mutate() { + MultipleThreadLocals.PACKAGE_MUTATED.set("mutated"); + MultipleThreadLocals.PROTECTED_MUTATED.remove(); + } + + public void readOnly() { + String value = MultipleThreadLocals.PUBLIC_READ_ONLY.get(); + } + } + """ + ) + ); + } + + @Test + void detectMutationThroughMethodReference() { + rewriteRun( + java( + """ + package com.example; + + import java.util.function.Consumer; + + public class MethodReferenceExample { + public static final ThreadLocal TL = new ThreadLocal<>(); + + public static void setValue(String value) { + TL.set(value); + } + + public void useMethodReference() { + Consumer setter = MethodReferenceExample::setValue; + setter.accept("value"); + } + } + """ + ) + ); + // ThreadLocal is mutated through setValue method, should NOT be marked as immutable + } + + @Test + void detectIndirectMutationThroughPublicSetter() { + rewriteRun( + java( + """ + package com.example; + + public class IndirectMutation { + private static final ThreadLocal PRIVATE_TL = new ThreadLocal<>(); + + public static void setThreadLocalValue(String value) { + PRIVATE_TL.set(value); + } + + public static String getThreadLocalValue() { + return PRIVATE_TL.get(); + } + } + """ + ), + java( + """ + package com.example; + + class ExternalSetter { + public void mutateIndirectly() { + IndirectMutation.setThreadLocalValue("mutated"); + } + } + """ + ) + ); + // ThreadLocal should NOT be marked as immutable because setThreadLocalValue mutates it + } + + @Test + void instanceThreadLocalWithCrossFileAccess() { + rewriteRun( + java( + """ + package com.example; + + public class InstanceThreadLocalHolder { + public final ThreadLocal instanceTL = new ThreadLocal<>(); + + public String getValue() { + return instanceTL.get(); + } + } + """ + ), + java( + """ + package com.example; + + class InstanceMutator { + public void mutate(InstanceThreadLocalHolder holder) { + holder.instanceTL.set("mutated"); + } + } + """ + ) + ); + // Instance ThreadLocal should NOT be marked as immutable due to external mutation + } +} diff --git a/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsTest.java b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsTest.java new file mode 100644 index 0000000000..03f2add0a1 --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsTest.java @@ -0,0 +1,350 @@ +/* + * 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.java.migrate.search.threadlocal; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class FindNeverMutatedThreadLocalsTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new FindNeverMutatedThreadLocals()); + } + + @DocumentExample + @Test + void identifySimpleImmutableThreadLocal() { + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal TL = new ThreadLocal<>(); + + public String getValue() { + return TL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal TL = new ThreadLocal<>(); + + public String getValue() { + return TL.get(); + } + } + """ + ) + ); + } + + @Test + void identifyThreadLocalWithInitialValue() { + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal COUNTER = ThreadLocal.withInitial(() -> 0); + + public int getCount() { + return COUNTER.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal COUNTER = ThreadLocal.withInitial(() -> 0); + + public int getCount() { + return COUNTER.get(); + } + } + """ + ) + ); + } + + @Test + void doNotMarkThreadLocalWithSetCall() { + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal TL = new ThreadLocal<>(); + + public void setValue(String value) { + TL.set(value); + } + + public String getValue() { + return TL.get(); + } + } + """ + ) + ); + } + + @Test + void doNotMarkThreadLocalWithRemoveCall() { + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal TL = new ThreadLocal<>(); + + public void cleanup() { + TL.remove(); + } + + public String getValue() { + return TL.get(); + } + } + """ + ) + ); + } + + @Test + void doNotMarkReassignedThreadLocal() { + rewriteRun( + java( + """ + class Example { + private static ThreadLocal tl = new ThreadLocal<>(); + + public void reset() { + tl = new ThreadLocal<>(); + } + + public String getValue() { + return tl.get(); + } + } + """ + ) + ); + } + + @Test + void handleMultipleThreadLocalsWithMixedPatterns() { + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal IMMUTABLE_TL = new ThreadLocal<>(); + private static final ThreadLocal MUTABLE_TL = new ThreadLocal<>(); + private static final ThreadLocal ANOTHER_IMMUTABLE = ThreadLocal.withInitial(() -> false); + + public void updateMutable(int value) { + MUTABLE_TL.set(value); + } + + public String getImmutable() { + return IMMUTABLE_TL.get(); + } + + public Boolean getAnother() { + return ANOTHER_IMMUTABLE.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal IMMUTABLE_TL = new ThreadLocal<>(); + private static final ThreadLocal MUTABLE_TL = new ThreadLocal<>(); + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal ANOTHER_IMMUTABLE = ThreadLocal.withInitial(() -> false); + + public void updateMutable(int value) { + MUTABLE_TL.set(value); + } + + public String getImmutable() { + return IMMUTABLE_TL.get(); + } + + public Boolean getAnother() { + return ANOTHER_IMMUTABLE.get(); + } + } + """ + ) + ); + } + + @Test + void identifyInstanceThreadLocal() { + rewriteRun( + java( + """ + class Example { + private final ThreadLocal instanceTL = new ThreadLocal<>(); + + public String getValue() { + return instanceTL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private final ThreadLocal instanceTL = new ThreadLocal<>(); + + public String getValue() { + return instanceTL.get(); + } + } + """ + ) + ); + } + + @Test + void handleThreadLocalWithComplexInitialization() { + rewriteRun( + java( + """ + import java.text.SimpleDateFormat; + + class Example { + private static final ThreadLocal DATE_FORMAT = + ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd")); + + public String formatDate(java.util.Date date) { + return DATE_FORMAT.get().format(date); + } + } + """, + """ + import java.text.SimpleDateFormat; + + class Example { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal DATE_FORMAT = + ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd")); + + public String formatDate(java.util.Date date) { + return DATE_FORMAT.get().format(date); + } + } + """ + ) + ); + } + + @Test + void doNotMarkLocalVariableThreadLocal() { + // Local ThreadLocals are unusual but should not be marked as they have different lifecycle + rewriteRun( + java( + """ + class Example { + public void method() { + ThreadLocal localTL = new ThreadLocal<>(); + localTL.set("value"); + System.out.println(localTL.get()); + } + } + """ + ) + ); + } + + @Test + void warnAboutPackagePrivateThreadLocal() { + // Package-private ThreadLocals without mutations are still flagged by FindNeverMutatedThreadLocals + // but should NOT be flagged by FindThreadLocalsMutatableFromOutside if they have no mutations + // For this test, we'll test that it's flagged as never mutated + rewriteRun( + java( + """ + class Example { + static final ThreadLocal PACKAGE_TL = new ThreadLocal<>(); + + public String getValue() { + return PACKAGE_TL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/static final ThreadLocal PACKAGE_TL = new ThreadLocal<>(); + + public String getValue() { + return PACKAGE_TL.get(); + } + } + """ + ) + ); + } + + @Test + void warnAboutProtectedThreadLocal() { + rewriteRun( + java( + """ + class Example { + protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>(); + + public String getValue() { + return PROTECTED_TL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>(); + + public String getValue() { + return PROTECTED_TL.get(); + } + } + """ + ) + ); + } + + @Test + void handleInheritableThreadLocal() { + rewriteRun( + java( + """ + class Example { + private static final InheritableThreadLocal ITL = new InheritableThreadLocal<>(); + + public String getValue() { + return ITL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final InheritableThreadLocal ITL = new InheritableThreadLocal<>(); + + public String getValue() { + return ITL.get(); + } + } + """ + ) + ); + } +} diff --git a/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutsideTest.java b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutsideTest.java new file mode 100644 index 0000000000..56308de926 --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutsideTest.java @@ -0,0 +1,254 @@ +/* + * 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.java.migrate.search.threadlocal; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class FindThreadLocalsMutableFromOutsideTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new FindThreadLocalsMutableFromOutside()); + } + + @DocumentExample + @Test + void identifyNonPrivateThreadLocal() { + rewriteRun( + java( + """ + class Example { + public static final ThreadLocal PUBLIC_TL = new ThreadLocal<>(); + + public String getValue() { + return PUBLIC_TL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is static non-private and can potentially be mutated from outside)~~>*/public static final ThreadLocal PUBLIC_TL = new ThreadLocal<>(); + + public String getValue() { + return PUBLIC_TL.get(); + } + } + """ + ) + ); + } + + @Test + void identifyPackagePrivateThreadLocal() { + rewriteRun( + java( + """ + class Example { + static final ThreadLocal PACKAGE_TL = new ThreadLocal<>(); + + public String getValue() { + return PACKAGE_TL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is static non-private and can potentially be mutated from outside)~~>*/static final ThreadLocal PACKAGE_TL = new ThreadLocal<>(); + + public String getValue() { + return PACKAGE_TL.get(); + } + } + """ + ) + ); + } + + @Test + void identifyProtectedThreadLocal() { + rewriteRun( + java( + """ + class Example { + protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>(); + + public String getValue() { + return PROTECTED_TL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is static non-private and can potentially be mutated from outside)~~>*/protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>(); + + public String getValue() { + return PROTECTED_TL.get(); + } + } + """ + ) + ); + } + + @Test + void doNotMarkPrivateThreadLocal() { + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal PRIVATE_TL = new ThreadLocal<>(); + + public String getValue() { + return PRIVATE_TL.get(); + } + } + """ + ) + ); + } + + @Test + void identifyThreadLocalMutatedFromOutside() { + rewriteRun( + java( + """ + package com.example; + + public class ThreadLocalHolder { + public static final ThreadLocal SHARED_TL = new ThreadLocal<>(); + + public String getValue() { + return SHARED_TL.get(); + } + } + """, + """ + package com.example; + + public class ThreadLocalHolder { + /*~~(ThreadLocal is mutated from outside its defining class)~~>*/public static final ThreadLocal SHARED_TL = new ThreadLocal<>(); + + public String getValue() { + return SHARED_TL.get(); + } + } + """ + ), + java( + """ + package com.example; + + class ThreadLocalMutator { + public void mutate() { + ThreadLocalHolder.SHARED_TL.set("mutated"); + } + } + """ + ) + ); + } + + @Test + void identifyInstanceThreadLocalNonPrivate() { + rewriteRun( + java( + """ + class Example { + public final ThreadLocal instanceTL = new ThreadLocal<>(); + + public String getValue() { + return instanceTL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is non-private and can potentially be mutated from outside)~~>*/public final ThreadLocal instanceTL = new ThreadLocal<>(); + + public String getValue() { + return instanceTL.get(); + } + } + """ + ) + ); + } + + @Test + void doNotMarkPrivateInstanceThreadLocal() { + rewriteRun( + java( + """ + class Example { + private final ThreadLocal instanceTL = new ThreadLocal<>(); + + public String getValue() { + return instanceTL.get(); + } + } + """ + ) + ); + } + + @Test + void identifyProtectedMutatedFromSubclass() { + rewriteRun( + java( + """ + package com.example; + + public class BaseClass { + protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>(); + + public String getValue() { + return PROTECTED_TL.get(); + } + } + """, + """ + package com.example; + + public class BaseClass { + /*~~(ThreadLocal is mutated from outside its defining class)~~>*/protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>(); + + public String getValue() { + return PROTECTED_TL.get(); + } + } + """ + ), + java( + """ + package com.example.sub; + + import com.example.BaseClass; + + public class SubClass extends BaseClass { + public void modifyThreadLocal() { + PROTECTED_TL.set("modified by subclass"); + } + } + """ + ) + ); + } +} diff --git a/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScopeTest.java b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScopeTest.java new file mode 100644 index 0000000000..d496183db8 --- /dev/null +++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScopeTest.java @@ -0,0 +1,254 @@ +/* + * 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.java.migrate.search.threadlocal; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.java.Assertions.java; + +class FindThreadLocalsMutatedOnlyInDefiningScopeTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new FindThreadLocalsMutatedOnlyInDefiningScope()); + } + + @DocumentExample + @Test + void identifyThreadLocalMutatedOnlyInConstructor() { + rewriteRun( + java( + """ + class Example { + private final ThreadLocal TL = new ThreadLocal<>(); + + public Example() { + TL.set("initial value"); + } + + public String getValue() { + return TL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is only mutated during initialization (constructor/static initializer))~~>*/private final ThreadLocal TL = new ThreadLocal<>(); + + public Example() { + TL.set("initial value"); + } + + public String getValue() { + return TL.get(); + } + } + """ + ) + ); + } + + @Test + void identifyThreadLocalMutatedOnlyInStaticInitializer() { + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal TL = new ThreadLocal<>(); + + static { + TL.set("static initial value"); + } + + public String getValue() { + return TL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is only mutated during initialization (constructor/static initializer))~~>*/private static final ThreadLocal TL = new ThreadLocal<>(); + + static { + TL.set("static initial value"); + } + + public String getValue() { + return TL.get(); + } + } + """ + ) + ); + } + + @Test + void identifyThreadLocalMutatedInDefiningClass() { + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal TL = new ThreadLocal<>(); + + public void setValue(String value) { + TL.set(value); + } + + public String getValue() { + return TL.get(); + } + + public void cleanup() { + TL.remove(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is only mutated within its defining class)~~>*/private static final ThreadLocal TL = new ThreadLocal<>(); + + public void setValue(String value) { + TL.set(value); + } + + public String getValue() { + return TL.get(); + } + + public void cleanup() { + TL.remove(); + } + } + """ + ) + ); + } + + @Test + void doNotMarkThreadLocalNeverMutated() { + // This should not be marked by this recipe - it's for FindNeverMutatedThreadLocals + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal TL = new ThreadLocal<>(); + + public String getValue() { + return TL.get(); + } + } + """ + ) + ); + } + + @Test + void doNotMarkNonPrivateThreadLocal() { + // Non-private ThreadLocals shouldn't be marked by this recipe + rewriteRun( + java( + """ + class Example { + static final ThreadLocal TL = new ThreadLocal<>(); + + public void setValue(String value) { + TL.set(value); + } + } + """ + ) + ); + } + + @Test + void identifyInstanceThreadLocalMutatedInConstructor() { + rewriteRun( + java( + """ + class Example { + private final ThreadLocal counter = new ThreadLocal<>(); + + public Example(int initial) { + counter.set(initial); + } + + public Integer getCount() { + return counter.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is only mutated during initialization (constructor/static initializer))~~>*/private final ThreadLocal counter = new ThreadLocal<>(); + + public Example(int initial) { + counter.set(initial); + } + + public Integer getCount() { + return counter.get(); + } + } + """ + ) + ); + } + + @Test + void identifyThreadLocalWithMixedInitAndClassMutations() { + rewriteRun( + java( + """ + class Example { + private static final ThreadLocal TL = new ThreadLocal<>(); + + static { + TL.set("initial"); + } + + public void update(String value) { + TL.set(value); + } + + public String getValue() { + return TL.get(); + } + } + """, + """ + class Example { + /*~~(ThreadLocal is only mutated within its defining class)~~>*/private static final ThreadLocal TL = new ThreadLocal<>(); + + static { + TL.set("initial"); + } + + public void update(String value) { + TL.set(value); + } + + public String getValue() { + return TL.get(); + } + } + """ + ) + ); + } +}