From 66b9a08e33bbaa280a7017dff1fec1358332e959 Mon Sep 17 00:00:00 2001 From: Martin Wittlinger Date: Fri, 9 Jun 2023 19:34:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Add=20pattern=20based=20spo?= =?UTF-8?q?on=20analyzer=20(#719)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spoon/AbstractSpoonRuleAnalyzer.java | 45 +++++++ .../analyzer/spoon/SpoonAnalyzer.java | 35 +++++ .../analyzer/spoon/SpoonAnalyzerResult.java | 84 ++++++++++++ .../analyzer/spoon/SpoonAnalyzerRules.java | 47 +++++++ .../analyzer/spoon/TemplateHelper.java | 31 +++++ .../spoon/rules/UnnecessaryToStringCall.java | 92 +++++++++++++ .../patternDB/UnnecessaryToStringCall | 26 ++++ .../UnnecessaryToStringCallAnalyzerTest.java | 127 ++++++++++++++++++ .../data/SpoonPatternAnalyzerResult.java | 11 ++ .../laughing_train/mining/PeriodicMiner.java | 39 +++++- .../services/SpoonPatternAnalyzer.java | 85 ++++++++++++ .../data/SpoonPatternAnalyzerResultTest.java | 30 +++++ 12 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/AbstractSpoonRuleAnalyzer.java create mode 100644 code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzer.java create mode 100644 code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzerResult.java create mode 100644 code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzerRules.java create mode 100644 code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/TemplateHelper.java create mode 100644 code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/rules/UnnecessaryToStringCall.java create mode 100644 code-transformation/src/main/resources/patternDB/UnnecessaryToStringCall create mode 100644 code-transformation/src/test/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/rules/UnnecessaryToStringCallAnalyzerTest.java create mode 100644 github-bot/src/main/java/io/github/martinwitt/laughing_train/data/SpoonPatternAnalyzerResult.java create mode 100644 github-bot/src/main/java/io/github/martinwitt/laughing_train/services/SpoonPatternAnalyzer.java create mode 100644 github-bot/src/test/java/io/github/martinwitt/laughing_train/data/SpoonPatternAnalyzerResultTest.java diff --git a/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/AbstractSpoonRuleAnalyzer.java b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/AbstractSpoonRuleAnalyzer.java new file mode 100644 index 000000000..cf1db0686 --- /dev/null +++ b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/AbstractSpoonRuleAnalyzer.java @@ -0,0 +1,45 @@ +package xyz.keksdose.spoon.code_solver.analyzer.spoon; + +import io.github.martinwitt.laughing_train.domain.entity.AnalyzerResult; +import java.nio.file.Path; +import java.util.List; +import spoon.reflect.declaration.CtType; +import spoon.reflect.declaration.CtTypeParameter; +import xyz.keksdose.spoon.code_solver.history.ChangeListener; +import xyz.keksdose.spoon.code_solver.transformations.BadSmell; + +public interface AbstractSpoonRuleAnalyzer { + + abstract List analyze(CtType type); + + /** + * Applies the refactoring to the given {@link CtType}. + * @param listener The listener which is used to report the changes. + * @param compilationUnit The type which contains the reported bad smell. + * @param result the result of an analysis run. + */ + abstract void refactor(ChangeListener listener, CtType type, AnalyzerResult result); + + /** + * Returns a list of all {@link BadSmell}s which are refactored by this refactoring. + * @return A list of all {@link BadSmell}s which are refactored by this refactoring. Never null. + */ + public abstract List getHandledBadSmells(); + + /** + * Checks if the given {@link CtType} is the type which contains the reported bad smell. + * @param type The type which should be checked. + * @param resultPath The path of the file which contains the reported bad smell. + * @return True if the given {@link CtType} is the type which contains the reported bad smell. + */ + default boolean isSameType(CtType type, Path resultPath) { + return type.getPosition().isValidPosition() + && !(type instanceof CtTypeParameter) + && type.getPosition() + .getCompilationUnit() + .getFile() + .toPath() + .toString() + .endsWith(resultPath.normalize().toString()); + } +} diff --git a/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzer.java b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzer.java new file mode 100644 index 000000000..5258688d0 --- /dev/null +++ b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzer.java @@ -0,0 +1,35 @@ +package xyz.keksdose.spoon.code_solver.analyzer.spoon; + +import io.github.martinwitt.laughing_train.domain.entity.AnalyzerResult; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import spoon.Launcher; +import spoon.reflect.CtModel; +import spoon.reflect.declaration.CtType; + +public class SpoonAnalyzer { + + public List analyze(Path path) { + CtModel model = buildModel(path); + return model.getAllTypes().stream() + .flatMap(v -> analyzeType(v).stream()) + .collect(Collectors.toList()); + } + + private CtModel buildModel(Path path) { + Launcher launcher = new Launcher(); + launcher.addInputResource(path.toString()); + launcher.getEnvironment().setAutoImports(true); + launcher.getEnvironment().setNoClasspath(true); + launcher.getEnvironment().setIgnoreDuplicateDeclarations(true); + launcher.getEnvironment().setShouldCompile(false); + return launcher.buildModel(); + } + + private List analyzeType(CtType type) { + List results = Arrays.asList(SpoonAnalyzerRules.values()); + return results.stream().flatMap(v -> v.analyze(type).stream()).collect(Collectors.toList()); + } +} diff --git a/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzerResult.java b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzerResult.java new file mode 100644 index 000000000..bf4a96b79 --- /dev/null +++ b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzerResult.java @@ -0,0 +1,84 @@ +package xyz.keksdose.spoon.code_solver.analyzer.spoon; + +import io.github.martinwitt.laughing_train.domain.entity.AnalyzerResult; +import io.github.martinwitt.laughing_train.domain.value.Position; +import io.github.martinwitt.laughing_train.domain.value.RuleId; +import java.util.Objects; + +public class SpoonAnalyzerResult implements AnalyzerResult { + + private static final String ANALYZER = "SpoonAnalyzer"; + private final RuleId ruleId; + private final String filePath; + private final Position position; + private final String message; + private final String messageMarkdown; + private final String snippet; + + public SpoonAnalyzerResult( + RuleId ruleId, String filePath, Position position, String message, String messageMarkdown, String snippet) { + this.ruleId = ruleId; + this.filePath = filePath; + this.position = position; + this.message = message; + this.messageMarkdown = messageMarkdown; + this.snippet = snippet; + } + + @Override + public String getAnalyzer() { + return ANALYZER; + } + + @Override + public RuleId ruleID() { + return ruleId; + } + + @Override + public String filePath() { + return filePath; + } + + @Override + public Position position() { + return position; + } + + @Override + public String message() { + return message; + } + + @Override + public String messageMarkdown() { + return messageMarkdown; + } + + @Override + public String snippet() { + return snippet; + } + + @Override + public int hashCode() { + return Objects.hash(ruleId, filePath, position, message, messageMarkdown, snippet); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof SpoonAnalyzerResult)) { + return false; + } + SpoonAnalyzerResult other = (SpoonAnalyzerResult) obj; + return Objects.equals(ruleId, other.ruleId) + && Objects.equals(filePath, other.filePath) + && Objects.equals(position, other.position) + && Objects.equals(message, other.message) + && Objects.equals(messageMarkdown, other.messageMarkdown) + && Objects.equals(snippet, other.snippet); + } +} diff --git a/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzerRules.java b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzerRules.java new file mode 100644 index 000000000..ab9f911f0 --- /dev/null +++ b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/SpoonAnalyzerRules.java @@ -0,0 +1,47 @@ +package xyz.keksdose.spoon.code_solver.analyzer.spoon; + +import io.github.martinwitt.laughing_train.domain.entity.AnalyzerResult; +import io.github.martinwitt.laughing_train.domain.value.RuleId; +import java.util.List; +import spoon.reflect.declaration.CtType; +import xyz.keksdose.spoon.code_solver.analyzer.AnalyzerRule; +import xyz.keksdose.spoon.code_solver.analyzer.spoon.rules.UnnecessaryToStringCall; +import xyz.keksdose.spoon.code_solver.history.ChangeListener; +import xyz.keksdose.spoon.code_solver.transformations.BadSmell; + +public enum SpoonAnalyzerRules implements AbstractSpoonRuleAnalyzer, AnalyzerRule { + UNNECESSARY_TOSTRING_CALL("UnnecessaryTostringCall", new UnnecessaryToStringCall()); + + private final RuleId ruleId; + private final AbstractSpoonRuleAnalyzer analyzer; + + SpoonAnalyzerRules(String ruleId, AbstractSpoonRuleAnalyzer analyzer) { + this.ruleId = new RuleId(ruleId); + this.analyzer = analyzer; + } + + @Override + public RuleId getRuleId() { + return ruleId; + } + + List getDescription() { + return analyzer.getHandledBadSmells(); + } + + @Override + public List analyze(CtType type) { + + return analyzer.analyze(type); + } + + @Override + public void refactor(ChangeListener listener, CtType type, AnalyzerResult result) { + analyzer.refactor(listener, type, result); + } + + @Override + public List getHandledBadSmells() { + return analyzer.getHandledBadSmells(); + } +} diff --git a/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/TemplateHelper.java b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/TemplateHelper.java new file mode 100644 index 000000000..0c8d01ff8 --- /dev/null +++ b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/TemplateHelper.java @@ -0,0 +1,31 @@ +package xyz.keksdose.spoon.code_solver.analyzer.spoon; + +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import spoon.Launcher; +import spoon.reflect.declaration.CtType; +import spoon.support.compiler.VirtualFile; + +public class TemplateHelper { + + private TemplateHelper() {} + + public static CtType fromResource(String resource) { + ClassLoader classLoader = TemplateHelper.class.getClassLoader(); + URL resourceUrl = classLoader.getResource(resource); + if (resourceUrl == null) { + throw new IllegalArgumentException("Resource not found: " + resource); + } + try { + String content = Files.readString(Path.of(resourceUrl.toURI())); + Launcher launcher = new Launcher(); + VirtualFile file = new VirtualFile(content, "Test"); + launcher.addInputResource(file); + var model = launcher.buildModel(); + return model.getAllTypes().iterator().next(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/rules/UnnecessaryToStringCall.java b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/rules/UnnecessaryToStringCall.java new file mode 100644 index 000000000..c9e6c4f54 --- /dev/null +++ b/code-transformation/src/main/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/rules/UnnecessaryToStringCall.java @@ -0,0 +1,92 @@ +package xyz.keksdose.spoon.code_solver.analyzer.spoon.rules; + +import static xyz.keksdose.spoon.code_solver.history.MarkdownString.fromMarkdown; + +import io.github.martinwitt.laughing_train.domain.entity.AnalyzerResult; +import io.github.martinwitt.laughing_train.domain.value.Position; +import io.github.martinwitt.laughing_train.domain.value.RuleId; +import java.util.ArrayList; +import java.util.List; +import spoon.reflect.code.CtReturn; +import spoon.reflect.declaration.CtElement; +import spoon.reflect.declaration.CtMethod; +import spoon.reflect.declaration.CtType; +import spoon.reflect.visitor.filter.TypeFilter; +import spoon.template.TemplateMatcher; +import xyz.keksdose.spoon.code_solver.analyzer.spoon.AbstractSpoonRuleAnalyzer; +import xyz.keksdose.spoon.code_solver.analyzer.spoon.SpoonAnalyzerResult; +import xyz.keksdose.spoon.code_solver.analyzer.spoon.TemplateHelper; +import xyz.keksdose.spoon.code_solver.history.ChangeListener; +import xyz.keksdose.spoon.code_solver.history.MarkdownString; +import xyz.keksdose.spoon.code_solver.transformations.BadSmell; + +public class UnnecessaryToStringCall implements AbstractSpoonRuleAnalyzer { + + private static final List templates = createPattern(); + private static final BadSmell UNNECESSARY_TO_STRING_CALL = new BadSmell() { + @Override + public MarkdownString getName() { + return MarkdownString.fromRaw("UnnecessaryToStringCall"); + } + + @Override + public MarkdownString getDescription() { + return fromMarkdown( + "The `toString()` method is not needed in cases the underlying method handles the conversion. Also calling toString() on a String is redundant. Removing them simplifies the code."); + } + }; + + @Override + public List getHandledBadSmells() { + throw new UnsupportedOperationException("Unimplemented method 'getHandledBadSmells'"); + } + + @Override + public List analyze(CtType type) { + List results = new ArrayList<>(); + List matches = new ArrayList<>(); + for (TemplateMatcher templateMatcher : templates) { + templateMatcher.find(type).forEach(matches::add); + } + for (CtElement ctElement : matches) { + String filePath = ctElement.getPosition().getFile().getAbsolutePath(); + Position position = createPosition(ctElement); + String snippet = ctElement.toString(); + RuleId ruleId = new RuleId(UNNECESSARY_TO_STRING_CALL.getName().asText()); + String message = UNNECESSARY_TO_STRING_CALL.getDescription().asText(); + String messageMarkdown = UNNECESSARY_TO_STRING_CALL.getDescription().asMarkdown(); + results.add(new SpoonAnalyzerResult(ruleId, filePath, position, message, messageMarkdown, snippet)); + } + return results; + } + + private Position createPosition(CtElement ctElement) { + int startLine = ctElement.getPosition().getLine(); + int endLine = ctElement.getPosition().getEndLine(); + int startColumn = ctElement.getPosition().getColumn(); + int endColumn = ctElement.getPosition().getEndColumn(); + int startOffset = ctElement.getPosition().getSourceStart(); + int length = ctElement.getPosition().getSourceEnd() - startOffset; + Position position = new Position(startLine, endLine, startColumn, endColumn, startOffset, length); + return position; + } + + private static List createPattern() { + List templates = new ArrayList<>(); + var templateType = TemplateHelper.fromResource("patternDB/UnnecessaryToStringCall"); + for (CtMethod method : templateType.getMethods()) { + if (method.getSimpleName().startsWith("matcher")) { + var root = method.getElements(new TypeFilter<>(CtReturn.class)) + .get(0) + .getReturnedExpression(); + templates.add(new TemplateMatcher(root)); + } + } + return templates; + } + + @Override + public void refactor(ChangeListener listener, CtType type, AnalyzerResult result) { + throw new UnsupportedOperationException("Unimplemented method 'refactor'"); + } +} diff --git a/code-transformation/src/main/resources/patternDB/UnnecessaryToStringCall b/code-transformation/src/main/resources/patternDB/UnnecessaryToStringCall new file mode 100644 index 000000000..7dfb706b6 --- /dev/null +++ b/code-transformation/src/main/resources/patternDB/UnnecessaryToStringCall @@ -0,0 +1,26 @@ +package patternDB; + +import java.util.Collection; + +import spoon.template.Parameter; +import spoon.template.TemplateParameter; + +public class UnnecessaryToStringCall { + // Step 1: + public TemplateParameter> _col_; + + public TemplateParameter _str_; + + @Parameter + String foo; + + + public String matcher1() { + return _str_.S().toString(); + } + + public String matcher2() { + return _str_.S() + toString(); + } +} + diff --git a/code-transformation/src/test/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/rules/UnnecessaryToStringCallAnalyzerTest.java b/code-transformation/src/test/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/rules/UnnecessaryToStringCallAnalyzerTest.java new file mode 100644 index 000000000..3e74c4491 --- /dev/null +++ b/code-transformation/src/test/java/xyz/keksdose/spoon/code_solver/analyzer/spoon/rules/UnnecessaryToStringCallAnalyzerTest.java @@ -0,0 +1,127 @@ +package xyz.keksdose.spoon.code_solver.analyzer.spoon.rules; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.Test; +import spoon.Launcher; +import spoon.reflect.CtModel; +import spoon.reflect.declaration.CtType; +import spoon.support.compiler.VirtualFile; +import xyz.keksdose.spoon.code_solver.analyzer.spoon.SpoonAnalyzerResult; + +public class UnnecessaryToStringCallAnalyzerTest { + + @Test + public void testUnnecessaryToStringCallAnalyzer() throws IOException { + String sourceCode = + """ + public class Test { + public void test() { + String s = "test"; + System.out.println(s.toString()); + } + } + """; + VirtualFile file = new VirtualFile(sourceCode, "Test.java"); + Launcher launcher = new Launcher(); + launcher.addInputResource(file); + CtModel model = launcher.buildModel(); + CtType type = model.getAllTypes().stream() + .filter(t -> t.getSimpleName().equals("Test")) + .findFirst() + .orElseThrow(() -> new RuntimeException("Test class not found")); + UnnecessaryToStringCall analyzer = new UnnecessaryToStringCall(); + List results = analyzer.analyze(type); + assertEquals(1, results.size()); + SpoonAnalyzerResult result = results.get(0); + assertEquals("UnnecessaryToStringCall", result.ruleID().id()); + assertEquals("s.toString()", result.snippet()); + } + + @Test + public void testUnnecessaryToStringCallAnalyzer_noIssues() throws IOException { + String sourceCode = + """ + public class Test { + public void test() { + String s = "test"; + System.out.println(s); + } + } + """; + VirtualFile file = new VirtualFile(sourceCode, "Test.java"); + Launcher launcher = new Launcher(); + launcher.addInputResource(file); + CtModel model = launcher.buildModel(); + CtType type = model.getAllTypes().stream() + .filter(t -> t.getSimpleName().equals("Test")) + .findFirst() + .orElseThrow(() -> new RuntimeException("Test class not found")); + UnnecessaryToStringCall analyzer = new UnnecessaryToStringCall(); + List results = analyzer.analyze(type); + assertEquals(0, results.size()); + } + + @Test + public void testUnnecessaryToStringCallAnalyzer_multipleIssues() throws IOException { + String sourceCode = + """ + public class Test { + public void test() { + String s1 = "test1"; + String s2 = "test2"; + System.out.println(s1.toString() + s2.toString()); + } + } + """; + VirtualFile file = new VirtualFile(sourceCode, "Test.java"); + Launcher launcher = new Launcher(); + launcher.addInputResource(file); + CtModel model = launcher.buildModel(); + CtType type = model.getAllTypes().stream() + .filter(t -> t.getSimpleName().equals("Test")) + .findFirst() + .orElseThrow(() -> new RuntimeException("Test class not found")); + UnnecessaryToStringCall analyzer = new UnnecessaryToStringCall(); + List results = analyzer.analyze(type); + assertEquals(2, results.size()); + SpoonAnalyzerResult result1 = results.get(0); + assertEquals("UnnecessaryToStringCall", result1.ruleID().id()); + assertEquals("s1.toString()", result1.snippet()); + SpoonAnalyzerResult result2 = results.get(1); + assertEquals("UnnecessaryToStringCall", result2.ruleID().id()); + assertEquals("s2.toString()", result2.snippet()); + } + + @Test + public void testUnnecessaryToStringCallAnalyzer_nestedMethodCall() throws IOException { + String sourceCode = + """ + public class Test { + public void test() { + String s = "test"; + System.out.println(getValue(s).toString()); + } + private String getValue(String s) { + return s; + } + } + """; + VirtualFile file = new VirtualFile(sourceCode, "Test.java"); + Launcher launcher = new Launcher(); + launcher.addInputResource(file); + CtModel model = launcher.buildModel(); + CtType type = model.getAllTypes().stream() + .filter(t -> t.getSimpleName().equals("Test")) + .findFirst() + .orElseThrow(() -> new RuntimeException("Test class not found")); + UnnecessaryToStringCall analyzer = new UnnecessaryToStringCall(); + List results = analyzer.analyze(type); + assertEquals(1, results.size()); + SpoonAnalyzerResult result = results.get(0); + assertEquals("UnnecessaryToStringCall", result.ruleID().id()); + assertEquals("getValue(s).toString()", result.snippet()); + } +} diff --git a/github-bot/src/main/java/io/github/martinwitt/laughing_train/data/SpoonPatternAnalyzerResult.java b/github-bot/src/main/java/io/github/martinwitt/laughing_train/data/SpoonPatternAnalyzerResult.java new file mode 100644 index 000000000..e4f224f8f --- /dev/null +++ b/github-bot/src/main/java/io/github/martinwitt/laughing_train/data/SpoonPatternAnalyzerResult.java @@ -0,0 +1,11 @@ +package io.github.martinwitt.laughing_train.data; + +import io.github.martinwitt.laughing_train.domain.entity.AnalyzerResult; +import java.io.Serializable; +import java.util.List; + +public sealed interface SpoonPatternAnalyzerResult extends Serializable { + record Success(List result, Project project) implements SpoonPatternAnalyzerResult {} + + record Failure(String message) implements SpoonPatternAnalyzerResult {} +} diff --git a/github-bot/src/main/java/io/github/martinwitt/laughing_train/mining/PeriodicMiner.java b/github-bot/src/main/java/io/github/martinwitt/laughing_train/mining/PeriodicMiner.java index 3ec01037e..cbba990b8 100644 --- a/github-bot/src/main/java/io/github/martinwitt/laughing_train/mining/PeriodicMiner.java +++ b/github-bot/src/main/java/io/github/martinwitt/laughing_train/mining/PeriodicMiner.java @@ -5,11 +5,14 @@ import io.github.martinwitt.laughing_train.data.ProjectRequest; import io.github.martinwitt.laughing_train.data.ProjectResult; import io.github.martinwitt.laughing_train.data.QodanaResult; +import io.github.martinwitt.laughing_train.data.SpoonPatternAnalyzerResult; +import io.github.martinwitt.laughing_train.data.SpoonPatternAnalyzerResult.Success; import io.github.martinwitt.laughing_train.domain.entity.AnalyzerResult; import io.github.martinwitt.laughing_train.domain.entity.Project; import io.github.martinwitt.laughing_train.persistence.repository.ProjectRepository; import io.github.martinwitt.laughing_train.services.ProjectService; import io.github.martinwitt.laughing_train.services.QodanaService; +import io.github.martinwitt.laughing_train.services.SpoonPatternAnalyzer; import io.micrometer.core.instrument.MeterRegistry; import io.quarkus.runtime.StartupEvent; import io.vertx.core.Vertx; @@ -37,6 +40,7 @@ public class PeriodicMiner { final ProjectRepository projectRepository; final QodanaService qodanaService; final ProjectService projectService; + final SpoonPatternAnalyzer spoonPatternAnalyzer; MeterRegistry registry; @@ -50,7 +54,8 @@ public PeriodicMiner( ProjectRepository projectRepository, QodanaService qodanaService, ProjectService projectService, - MiningPrinter miningPrinter) { + MiningPrinter miningPrinter, + SpoonPatternAnalyzer spoonPatternAnalyzer) { this.registry = registry; this.vertx = vertx; this.searchProjectService = searchProjectService; @@ -58,6 +63,7 @@ public PeriodicMiner( this.qodanaService = qodanaService; this.projectService = projectService; this.miningPrinter = miningPrinter; + this.spoonPatternAnalyzer = spoonPatternAnalyzer; } private Project getRandomProject() throws IOException { @@ -90,6 +96,14 @@ private void mineRandomRepo() { if (checkoutResult instanceof ProjectResult.Success success) { logger.atInfo().log("Successfully checked out project %s", success.project()); var qodanaResult = analyzeProject(success); + var spoonPatternAnalyzerResult = + spoonPatternAnalyzer.analyze(new AnalyzerRequest.WithProject(success.project())); + + if (spoonPatternAnalyzerResult instanceof SpoonPatternAnalyzerResult.Success spoonSuccess) { + logger.atInfo().log("Successfully analyzed project %s", success.project()); + saveSpoonResults(spoonSuccess); + addOrUpdateCommitHash(success); + } if (qodanaResult instanceof QodanaResult.Failure) { logger.atWarning().log("Failed to analyze project %s", success.project()); registry.counter("mining.qodana.error").increment(); @@ -111,6 +125,24 @@ private void mineRandomRepo() { } } + private void saveSpoonResults(Success spoonSuccess) { + spoonSuccess.project().runInContext(() -> { + try { + List results = spoonSuccess.result(); + if (results.isEmpty()) { + logger.atInfo().log("No results for %s", spoonSuccess); + return Optional.empty(); + } + String content = printFormattedResults(spoonSuccess, results); + var laughingRepo = getLaughingRepo(); + updateOrCreateContent(laughingRepo, spoonSuccess.project().name(), content); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Error while updating content"); + } + return Optional.empty(); + }); + } + private ProjectResult checkoutProject(Project project) throws IOException { return projectService.handleProjectRequest(new ProjectRequest.WithUrl(project.getProjectUrl())); } @@ -168,6 +200,11 @@ private String printFormattedResults(QodanaResult.Success success, List results) { + return "# %s %n %s" + .formatted(success.project().name(), miningPrinter.printAllResults(results, success.project())); + } + private GHRepository getLaughingRepo() throws IOException { return GitHub.connectUsingOAuth(System.getenv("GITHUB_TOKEN")).getRepository("MartinWitt/laughing-train"); } diff --git a/github-bot/src/main/java/io/github/martinwitt/laughing_train/services/SpoonPatternAnalyzer.java b/github-bot/src/main/java/io/github/martinwitt/laughing_train/services/SpoonPatternAnalyzer.java new file mode 100644 index 000000000..a80bd5cf5 --- /dev/null +++ b/github-bot/src/main/java/io/github/martinwitt/laughing_train/services/SpoonPatternAnalyzer.java @@ -0,0 +1,85 @@ +package io.github.martinwitt.laughing_train.services; + +import com.google.common.base.Strings; +import com.google.common.flogger.FluentLogger; +import io.github.martinwitt.laughing_train.Config; +import io.github.martinwitt.laughing_train.data.AnalyzerRequest; +import io.github.martinwitt.laughing_train.data.SpoonPatternAnalyzerResult; +import io.smallrye.health.api.AsyncHealthCheck; +import io.smallrye.mutiny.Uni; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Readiness; +import xyz.keksdose.spoon.code_solver.analyzer.spoon.SpoonAnalyzer; + +@ApplicationScoped +public class SpoonPatternAnalyzer { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + final Config config; + final ThreadPoolManager threadPoolManager; + final ProjectConfigService projectConfigService; + final AnalyzerResultPersistenceService analyzerResultPersistenceService; + + SpoonPatternAnalyzer( + Config config, + ThreadPoolManager threadPoolManager, + ProjectConfigService projectConfigService, + AnalyzerResultPersistenceService analyzerResultPersistenceService) { + this.config = config; + this.threadPoolManager = threadPoolManager; + this.projectConfigService = projectConfigService; + this.analyzerResultPersistenceService = analyzerResultPersistenceService; + } + + public SpoonPatternAnalyzerResult analyze(AnalyzerRequest request) { + logger.atInfo().log("Received request %s", request); + try { + if (request instanceof AnalyzerRequest.WithProject project) { + SpoonAnalyzer analyzer = new SpoonAnalyzer(); + + return new SpoonPatternAnalyzerResult.Success( + analyzer.analyze(project.project().folder().toPath()), project.project()); + } else { + return new SpoonPatternAnalyzerResult.Failure("Unknown request type"); + } + } catch (Exception e) { + return new SpoonPatternAnalyzerResult.Failure(Strings.nullToEmpty(e.getMessage())); + } + } + + @ApplicationScoped + static class ThreadPoolManager { + @SuppressWarnings("NullAway") + ExecutorService service; + + @PostConstruct + void setup() { + service = Executors.newFixedThreadPool(1); + } + + @PreDestroy + void close() { + service.shutdown(); + } + + public ExecutorService getService() { + return service; + } + } + + @Readiness + @ApplicationScoped + private static class HealthCheck implements AsyncHealthCheck { + + @Override + public Uni call() { + return Uni.createFrom() + .item(HealthCheckResponse.named("Qodana Analyzer").up().build()); + } + } +} diff --git a/github-bot/src/test/java/io/github/martinwitt/laughing_train/data/SpoonPatternAnalyzerResultTest.java b/github-bot/src/test/java/io/github/martinwitt/laughing_train/data/SpoonPatternAnalyzerResultTest.java new file mode 100644 index 000000000..13b668a5b --- /dev/null +++ b/github-bot/src/test/java/io/github/martinwitt/laughing_train/data/SpoonPatternAnalyzerResultTest.java @@ -0,0 +1,30 @@ +package io.github.martinwitt.laughing_train.data; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.github.martinwitt.laughing_train.domain.entity.AnalyzerResult; +import io.github.martinwitt.laughing_train.domain.value.Position; +import io.github.martinwitt.laughing_train.utils.TestAnalyzerResult; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class SpoonPatternAnalyzerResultTest { + + @Test + public void testSuccessRecord() { + Project project = new Project("test-owner", "test-name", null, "test-url", "#9999"); + AnalyzerResult result1 = new TestAnalyzerResult("test-rule-1", new Position(0, 0, 0, 0, 0, 0)); + AnalyzerResult result2 = new TestAnalyzerResult("test-rule-2", new Position(0, 0, 0, 0, 0, 0)); + List results = List.of(result1, result2); + SpoonPatternAnalyzerResult.Success success = new SpoonPatternAnalyzerResult.Success(results, project); + assertEquals(results, success.result()); + assertEquals(project, success.project()); + } + + @Test + public void testFailureRecord() { + String message = "test-message"; + SpoonPatternAnalyzerResult.Failure failure = new SpoonPatternAnalyzerResult.Failure(message); + assertEquals(message, failure.message()); + } +}