added finalguard plugin in aplha state
All checks were successful
CI / build (push) Successful in 3m17s

This commit is contained in:
2025-11-18 09:47:24 +08:00
parent 3f6627465c
commit c7d96f3d83
18 changed files with 599 additions and 6 deletions

12
finalguard/build.gradle Normal file
View File

@@ -0,0 +1,12 @@
plugins {
id 'java-gradle-plugin'
}
gradlePlugin {
plugins {
create("FinalGuardPlugin") {
id = 'net.woggioni.gradle.finalguard'
implementationClass = "net.woggioni.gradle.finalguard.FinalGuardPlugin"
}
}
}

View File

@@ -0,0 +1,38 @@
plugins {
id 'java-library'
}
group = "net.woggioni.finalguard"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
java {
sourceCompatibility(JavaVersion.VERSION_1_8.toString())
targetCompatibility(JavaVersion.VERSION_1_8.toString())
modularity.inferModulePath = false
}
tasks.named(org.gradle.api.plugins.JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile.class) {
options.compilerArgs << '-parameters'
}
test {
def testCompilationClassPath = sourceSets["main"].output.classesDirs.files +
sourceSets["main"].runtimeClasspath.files +
sourceSets["test"].resources.srcDirs
systemProperty("test.compilation.classpath",
String.join(File.pathSeparator, testCompilationClassPath.collect {it.toString() }))
}
publishing {
publications {
maven(MavenPublication) {
from(components["java"])
}
}
}

View File

@@ -0,0 +1,178 @@
package net.woggioni.finalguard;
import com.sun.source.tree.AssignmentTree;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.CompoundAssignmentTree;
import com.sun.source.tree.IdentifierTree;
import com.sun.source.tree.MethodTree;
import com.sun.source.tree.Tree;
import com.sun.source.tree.UnaryTree;
import com.sun.source.tree.VariableTree;
import com.sun.source.util.JavacTask;
import com.sun.source.util.Plugin;
import com.sun.source.util.TaskEvent;
import com.sun.source.util.TaskListener;
import com.sun.source.util.TreePath;
import com.sun.source.util.TreePathScanner;
import com.sun.source.util.Trees;
import javax.lang.model.element.Element;
import javax.lang.model.element.Modifier;
import javax.tools.Diagnostic;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public class FinalGuardPlugin implements Plugin {
public static final String DIAGNOSTIC_LEVEL_KEY = "net.woggioni.finalguard.diagnostic.level";
private static final Diagnostic.Kind diagnosticLevel =
Optional.ofNullable(System.getProperty(DIAGNOSTIC_LEVEL_KEY))
.map(Diagnostic.Kind::valueOf)
.orElse(Diagnostic.Kind.WARNING);
@Override
public String getName() {
return getClass().getName();
}
@Override
public void init(JavacTask task, String... args) {
task.addTaskListener(new TaskListener() {
@Override
public void started(TaskEvent e) {}
@Override
public void finished(TaskEvent e) {
if (e.getKind() == TaskEvent.Kind.ANALYZE) {
analyzeFinalVariables(e.getCompilationUnit(), task, e.getTypeElement());
}
}
});
}
private void analyzeFinalVariables(CompilationUnitTree compilationUnit, JavacTask task, Element typeElement) {
FinalVariableAnalyzer analyzer = new FinalVariableAnalyzer(compilationUnit, task);
TreePath path = Trees.instance(task).getPath(typeElement);
if(path != null) {
analyzer.scan(path, null);
}
}
private static class FinalVariableAnalyzer extends TreePathScanner<Void, Void> {
private final CompilationUnitTree compilationUnit;
private final Trees trees;
private final Map<String, VariableInfo> variableInfoMap = new HashMap<>();
private final Set<String> reassignedVariables = new HashSet<>();
private String currentMethod;
public FinalVariableAnalyzer(CompilationUnitTree compilationUnit, JavacTask task) {
this.compilationUnit = compilationUnit;
this.trees = Trees.instance(task);
}
@Override
public Void visitMethod(MethodTree node, Void p) {
String previousMethod = currentMethod;
currentMethod = node.getName().toString();
variableInfoMap.clear();
reassignedVariables.clear();
// Analyze parameters first
for (VariableTree param : node.getParameters()) {
String varName = param.getName().toString();
variableInfoMap.put(varName, new VariableInfo(param, false));
}
// Then analyze method body
super.visitMethod(node, p);
// Check for variables that could be final
checkForFinalCandidates();
currentMethod = previousMethod;
return null;
}
@Override
public Void visitVariable(VariableTree node, Void p) {
if (currentMethod != null) {
String varName = node.getName().toString();
boolean isParameter = node.getKind() == Tree.Kind.METHOD;
variableInfoMap.put(varName, new VariableInfo(node, isParameter));
}
return super.visitVariable(node, p);
}
@Override
public Void visitAssignment(AssignmentTree node, Void p) {
if (node.getVariable() instanceof IdentifierTree) {
IdentifierTree ident = (IdentifierTree) node.getVariable();
reassignedVariables.add(ident.getName().toString());
}
return super.visitAssignment(node, p);
}
@Override
public Void visitUnary(UnaryTree node, Void p) {
if ((node.getKind() == Tree.Kind.PREFIX_INCREMENT ||
node.getKind() == Tree.Kind.PREFIX_DECREMENT ||
node.getKind() == Tree.Kind.POSTFIX_INCREMENT ||
node.getKind() == Tree.Kind.POSTFIX_DECREMENT) &&
node.getExpression() instanceof IdentifierTree) {
IdentifierTree ident = (IdentifierTree) node.getExpression();
reassignedVariables.add(ident.getName().toString());
}
return super.visitUnary(node, p);
}
@Override
public Void visitCompoundAssignment(CompoundAssignmentTree node, Void p) {
if (node.getVariable() instanceof IdentifierTree) {
IdentifierTree ident = (IdentifierTree) node.getVariable();
reassignedVariables.add(ident.getName().toString());
}
return super.visitCompoundAssignment(node, p);
}
private void checkForFinalCandidates() {
for (Map.Entry<String, VariableInfo> entry : variableInfoMap.entrySet()) {
String varName = entry.getKey();
VariableInfo info = entry.getValue();
// Skip if already final
if (isFinal(info.variableTree)) {
continue;
}
// Skip if reassigned
if (reassignedVariables.contains(varName)) {
continue;
}
String message = "Local variable '" + varName + "' is never reassigned, so it should be declared final";
trees.printMessage(FinalGuardPlugin.diagnosticLevel,
message,
info.variableTree,
compilationUnit);
}
}
private static boolean isFinal(VariableTree variableTree) {
Set<Modifier> modifiers = variableTree.getModifiers().getFlags();
return modifiers.contains(Modifier.FINAL);
}
}
private static class VariableInfo {
final VariableTree variableTree;
VariableInfo(VariableTree variableTree, boolean isParameter) {
this.variableTree = variableTree;
}
}
}

View File

@@ -0,0 +1 @@
net.woggioni.finalguard.FinalGuardPlugin

View File

@@ -0,0 +1,183 @@
package net.woggioni.finalguard;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.provider.ArgumentsSource;
import javax.tools.Diagnostic;
import javax.tools.FileObject;
import javax.tools.ForwardingJavaFileManager;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.Reader;
import java.io.StringWriter;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
public class PluginTest {
private static class ClassFile extends SimpleJavaFileObject {
private ByteArrayOutputStream out;
public ClassFile(URI uri) {
super(uri, Kind.CLASS);
}
@Override
public OutputStream openOutputStream() {
return out = new ByteArrayOutputStream();
}
public byte[] getCompiledBinaries() {
return out.toByteArray();
}
}
private static class SourceFile extends SimpleJavaFileObject {
public SourceFile(URI uri) {
super(uri, Kind.SOURCE);
}
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
Reader r = new InputStreamReader(uri.toURL().openStream());
StringBuilder sb = new StringBuilder();
char[] buffer = new char[0x1000];
while (true) {
int read = r.read(buffer);
if(read < 0) break;
sb.append(buffer, 0, read);
}
return sb.toString();
}
}
private static class FileManager extends ForwardingJavaFileManager<StandardJavaFileManager> {
private final List<ClassFile> compiled = new ArrayList<>();
protected FileManager(StandardJavaFileManager fileManager) {
super(fileManager);
}
@Override
public JavaFileObject getJavaFileForOutput(Location location,
String className,
JavaFileObject.Kind kind,
FileObject sibling) {
ClassFile result = new ClassFile(URI.create("string://" + className));
compiled.add(result);
return result;
}
public List<ClassFile> getCompiled() {
return compiled;
}
}
private Optional<Iterable<Diagnostic<? extends JavaFileObject>>> compile(Iterable<URI> sources) {
StringWriter output = new StringWriter();
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
FileManager fileManager =
new FileManager(compiler.getStandardFileManager(null, null, null));
List<JavaFileObject> compilationUnits = StreamSupport.stream(sources.spliterator(), false)
.map(SourceFile::new).collect(Collectors.toList());
List<String> arguments = Arrays.asList(
"-classpath", System.getProperty("test.compilation.classpath"),
"-Xplugin:" + FinalGuardPlugin.class.getSimpleName()
);
final ArrayList<Diagnostic<? extends JavaFileObject>> compilerMessages = new ArrayList<>();
System.setProperty(FinalGuardPlugin.DIAGNOSTIC_LEVEL_KEY, "ERROR");
JavaCompiler.CompilationTask task = compiler.getTask(
output,
fileManager,
compilerMessages::add,
arguments,
null,
compilationUnits
);
if(task.call()) return Optional.empty();
else return Optional.of(compilerMessages);
}
private enum CompilationResult {
SUCCESS, FAILURE
}
private static class TestCaseProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
String prefix = "net/woggioni/finalguard/test/";
return Stream.of(
Arguments.of(prefix + "TestCase1.java", CompilationResult.FAILURE),
Arguments.of(prefix + "TestCase2.java", CompilationResult.SUCCESS),
Arguments.of(prefix + "TestCase3.java", CompilationResult.FAILURE),
Arguments.of(prefix + "TestCase4.java", CompilationResult.FAILURE),
Arguments.of(prefix + "TestCase5.java", CompilationResult.FAILURE),
Arguments.of(prefix + "TestCase6.java", CompilationResult.FAILURE),
Arguments.of(prefix + "TestCase7.java", CompilationResult.FAILURE),
Arguments.of(prefix + "TestCase8.java", CompilationResult.FAILURE)
);
}
}
@Disabled
@ParameterizedTest(name="{0}")
@ArgumentsSource(TestCaseProvider.class)
public void test(String sourceFilePath, CompilationResult expectedCompilationResult) {
Optional<Iterable<Diagnostic<? extends JavaFileObject>>> result;
try {
ClassLoader cl = getClass().getClassLoader();
result = compile(Collections.singletonList(cl.getResource(sourceFilePath).toURI()));
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
result.ifPresent(diagnostics -> {
for(Diagnostic<? extends JavaFileObject> diagnostic : diagnostics) {
System.err.printf("%s:%s %s\n",
diagnostic.getSource().getName(),
diagnostic.getLineNumber(),
diagnostic.getMessage(Locale.getDefault()));
}
});
final Pattern errorMessage = Pattern.compile(
"Local variable '[a-zA-Z0-9_]+' is never reassigned, so it should be declared final");
switch (expectedCompilationResult) {
case SUCCESS:
Assertions.assertFalse(result.isPresent(), "Compilation was expected to succeed but it has failed");
break;
case FAILURE:
Assertions.assertTrue(result.isPresent(), "Compilation was expected to fail but it succeeded");
Optional<Diagnostic<? extends JavaFileObject>> illegalSuspendableInvocationMessage = StreamSupport.stream(
result.get().spliterator(), false)
.filter( diagnostic ->
errorMessage.matcher(diagnostic.getMessage(Locale.ENGLISH)).matches()
).findFirst();
Assertions.assertTrue(illegalSuspendableInvocationMessage.isPresent());
break;
}
}
}

View File

@@ -0,0 +1,26 @@
import java.util.Arrays;
public class TestCase1 {
public void testMethod(String param1, String param2) { // Warning: param1 could be final
String localVar = "hello"; // Warning: localVar could be final
String reassignedVar = "initial"; // No warning (reassigned below)
reassignedVar = "changed";
param2 = "modified"; // No warning for param2 (reassigned)
for (int i = 0; i < 10; i++) { // Warning: i could be final
String loopVar = "constant"; // Warning: loopVar could be final
}
// Enhanced for loop - no warning for loop variable
for (String item : Arrays.asList("a", "b")) {
// item is effectively final in each iteration
}
}
public void finalMethod(final String param1, String param2) { // Warning only for param2
final String localVar = "hello"; // No warning (already final)
String anotherVar = "world"; // Warning: anotherVar could be final
}
}

View File

@@ -0,0 +1,18 @@
public class TestCase2 {
public void testMethod(int a, int b, int c, int d, int e, int f, int g, int h, int j, int k, int l, int m, int n) {
a++;
b--;
++c;
--d;
e |= 2;
f &= 1;
g <<= 1;
h >>= 1;
j ^= 1;
k += 1;
l -= 1;
m *= 1;
n /= 1;
}
}

View File

@@ -0,0 +1,6 @@
public class TestCase3 {
public void testMethod() {
int n = 5; // Error: n could be final
}
}

View File

@@ -0,0 +1,7 @@
public class TestCase4 {
public void testMethod() {
for (int i = 0; i < 10;) { // Error: i could be final
}
}
}

View File

@@ -0,0 +1,8 @@
public class TestCase5 {
public void testMethod() {
for (int i = 0; i < 10; i++) {
String loopVar = "constant"; // Error: loopVar should be final
}
}
}

View File

@@ -0,0 +1,9 @@
import java.util.Arrays;
public class TestCase6 {
public void testMethod() {
for (String item : Arrays.asList("a", "b")) { // Error: item should be final
}
}
}

View File

@@ -0,0 +1,10 @@
public class TestCase7 {
public void testMethod() {
try {
} catch (RuntimeException re) { // Error: re should be final
}
}
}

View File

@@ -0,0 +1,13 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
public class TestCase8 {
public void testMethod() throws IOException {
try(InputStream is = Files.newInputStream(Path.of("some-path"))) { // Error: is could be final
}
}
}

View File

@@ -0,0 +1,10 @@
package net.woggioni.gradle.finalguard;
import org.gradle.api.provider.Property;
import javax.tools.Diagnostic;
public interface FinalGuardExtension {
Property<Diagnostic.Kind> getDiagnosticKind();
}

View File

@@ -0,0 +1,61 @@
package net.woggioni.gradle.finalguard;
import lombok.SneakyThrows;
import org.gradle.api.Action;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.DependencySet;
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.plugins.ExtensionContainer;
import org.gradle.api.tasks.TaskContainer;
import org.gradle.api.tasks.compile.CompileOptions;
import org.gradle.api.tasks.compile.JavaCompile;
import javax.tools.Diagnostic;
import java.net.URL;
import java.util.jar.Attributes;
import java.util.jar.Manifest;
public class FinalGuardPlugin implements Plugin<Project> {
private static final String FINALGUARD_PLUGIN_CONFIGURATION = "finalguard_plugin";
@Override
public void apply(final Project project) {
final ExtensionContainer extensionContainer = project.getExtensions();
final TaskContainer tasks = project.getTasks();
final ObjectFactory objects = project.getObjects();
Configuration javacPluginConfiguration = project.getConfigurations().create(FINALGUARD_PLUGIN_CONFIGURATION);
javacPluginConfiguration.withDependencies(new Action<DependencySet>() {
@Override
@SneakyThrows
public void execute(DependencySet dependencies) {
final Class<?> cls = getClass();
final String resourceName = cls.getName().replace('.', '/') + ".class";
final URL classUrl = cls.getClassLoader().getResource(resourceName);
if (classUrl.getProtocol().startsWith("jar")) {
final String path = classUrl.toString();
String manifestPath = path.substring(0, path.lastIndexOf("!") + 1) +
"/META-INF/MANIFEST.MF";
final Manifest manifest = new Manifest(new URL(manifestPath).openStream());
final Attributes attr = manifest.getMainAttributes();
final String version = attr.getValue(Attributes.Name.SPECIFICATION_VERSION);
dependencies.add(project.getDependencies().create("net.woggioni.finalguard:finalguard-javac-plugin:" + version));
}
}
});
final FinalGuardExtension finalGuardExtension = objects.newInstance(FinalGuardExtension.class);
extensionContainer.add("finalGuard", finalGuardExtension);
tasks.withType(JavaCompile.class, javaCompileTask -> {
javaCompileTask.doFirst(t -> {
final Diagnostic.Kind diagnosticKind = finalGuardExtension.getDiagnosticKind().getOrElse(Diagnostic.Kind.WARNING);
final CompileOptions options = javaCompileTask.getOptions();
options.setAnnotationProcessorPath(options.getAnnotationProcessorPath().plus(javacPluginConfiguration));
options.getForkOptions().getJvmArgs().add("-Dnet.woggioni.finalguard.diagnostic.level=" + diagnosticKind);
options.getCompilerArgs().add("-Xplugin:net.woggioni.finalguard.FinalGuardPlugin");
});
});
}
}