JavaSourcePatcher.java

package org.egothor.methodatlas.discovery.jvm;

import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.egothor.methodatlas.api.SourcePatcher;
import org.egothor.methodatlas.api.TestDiscoveryConfig;

import com.github.javaparser.JavaParser;
import com.github.javaparser.ParseResult;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.ParserConfiguration.LanguageLevel;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.expr.StringLiteralExpr;
import com.github.javaparser.ast.nodeTypes.NodeWithName;
import com.github.javaparser.printer.lexicalpreservation.LexicalPreservingPrinter;

/**
 * {@link SourcePatcher} implementation for Java source files.
 *
 * <p>
 * Applies annotation changes (tags and display names) to Java test source
 * files driven by a reviewed MethodAtlas CSV export. This class contains the
 * logic for writing {@code @Tag} and {@code @DisplayName} annotations back
 * into {@code .java} source files using JavaParser's lexical-preserving
 * printer so that unrelated formatting is left intact.
 * </p>
 *
 * <p>
 * The implementation handles a desired-state specification: for each test
 * method it receives the exact set of {@code @Tag} annotations and the
 * {@code @DisplayName} text that the method should have after patching.
 * Existing tags not in the desired set are removed; desired tags not already
 * present are added.
 * </p>
 *
 * <h2>ServiceLoader registration</h2>
 * <p>
 * This class is registered as a {@link SourcePatcher} provider via
 * {@code META-INF/services/org.egothor.methodatlas.api.SourcePatcher}.
 * The orchestration layer loads it automatically via
 * {@link java.util.ServiceLoader}.
 * </p>
 *
 * @see SourcePatcher
 * @see AnnotationInspector
 */
public final class JavaSourcePatcher implements SourcePatcher {

    private static final Logger LOG = Logger.getLogger(JavaSourcePatcher.class.getName());

    /** Fully qualified name of {@code @DisplayName} for import management. */
    /* default */ static final String IMPORT_DISPLAY_NAME = "org.junit.jupiter.api.DisplayName";

    /** Fully qualified name of {@code @Tag} for import management. */
    /* default */ static final String IMPORT_TAG = "org.junit.jupiter.api.Tag";

    private static final String ANNOTATION_DISPLAY_NAME = "DisplayName";
    private static final String ANNOTATION_TAG = "Tag";

    private List<String> fileSuffixes = List.of("Test.java");

    /**
     * No-arg constructor required by {@link java.util.ServiceLoader}.
     */
    public JavaSourcePatcher() {
        // Required by ServiceLoader
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String pluginId() {
        return "java";
    }

    /**
     * {@inheritDoc}
     *
     * <p>
     * Stores the configured file suffixes used by {@link #supports(Path)}.
     * Suffixes that target other plugins (e.g. {@code "dotnet:Test.cs"}) are
     * automatically excluded via
     * {@link TestDiscoveryConfig#fileSuffixesFor(String)}.
     * When no global or {@code "java:"}-prefixed entries remain, the default
     * suffix {@code "Test.java"} is used.
     * </p>
     */
    @Override
    public void configure(TestDiscoveryConfig config) {
        List<String> suffixes = config.fileSuffixesFor(pluginId());
        this.fileSuffixes = suffixes.isEmpty() ? List.of("Test.java") : suffixes;
    }

    /**
     * {@inheritDoc}
     *
     * <p>
     * Returns {@code true} if the source file's name ends with any of the
     * configured file suffixes (default: {@code "Test.java"}).
     * </p>
     */
    @Override
    public boolean supports(Path sourceFile) {
        Path fileNamePath = sourceFile.getFileName();
        if (fileNamePath == null) {
            return false;
        }
        String name = fileNamePath.toString();
        return fileSuffixes.stream().anyMatch(name::endsWith);
    }

    /**
     * {@inheritDoc}
     *
     * <p>
     * Parses the source file with JavaParser (Java 21 language level) and
     * returns a map from FQCN to the list of simple test-method names declared
     * in each class. Methods are identified using
     * {@link AnnotationInspector#isJUnitTest} with the default test annotations.
     * </p>
     */
    @Override
    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
    public Map<String, List<String>> discoverMethodsByClass(Path sourceFile) throws IOException {
        ParserConfiguration cfg = new ParserConfiguration();
        cfg.setLanguageLevel(LanguageLevel.JAVA_21);
        JavaParser parser = new JavaParser(cfg);

        ParseResult<CompilationUnit> parseResult = parser.parse(sourceFile);
        if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
            if (LOG.isLoggable(Level.WARNING)) {
                LOG.warning("discoverMethodsByClass: failed to parse: " + sourceFile
                        + " — " + parseResult.getProblems());
            }
            return Map.of();
        }

        CompilationUnit cu = parseResult.getResult().orElseThrow();
        String packageName = cu.getPackageDeclaration()
                .map(NodeWithName::getNameAsString).orElse("");
        Set<String> effective = AnnotationInspector.effectiveAnnotations(
                cu, AnnotationInspector.DEFAULT_TEST_ANNOTATIONS);

        Map<String, List<String>> result = new LinkedHashMap<>();
        for (ClassOrInterfaceDeclaration clazz : cu.findAll(ClassOrInterfaceDeclaration.class)) {
            String fqcn = buildFqcn(packageName, clazz.getNameAsString());
            List<String> names = new ArrayList<>();
            for (MethodDeclaration method : clazz.getMethods()) {
                if (AnnotationInspector.isJUnitTest(method, effective)) {
                    names.add(method.getNameAsString());
                }
            }
            if (!names.isEmpty()) {
                result.put(fqcn, names);
            }
        }
        return result;
    }

    /**
     * {@inheritDoc}
     *
     * <p>
     * Parses the source file with JavaParser (Java 21 language level) and
     * applies the desired annotation state to each matching test method.
     * The file is written back using {@link LexicalPreservingPrinter} so that
     * formatting outside the modified annotations is preserved.
     * </p>
     *
     * <p>
     * The desired state for each method is driven by the {@code tagsToApply}
     * and {@code displayNames} maps:
     * </p>
     * <ul>
     *   <li>All existing {@code @Tag} and {@code @Tags} annotations are removed
     *       and replaced with exactly the tags listed in {@code tagsToApply}.</li>
     *   <li>{@code displayNames} value of {@code null} or absent key — leaves
     *       any existing {@code @DisplayName} untouched.</li>
     *   <li>{@code displayNames} value of {@code ""} — removes any existing
     *       {@code @DisplayName}.</li>
     *   <li>{@code displayNames} value of non-empty text — sets the
     *       {@code @DisplayName} to that text.</li>
     * </ul>
     *
     * @return number of annotation changes made; {@code 0} if the file was not
     *         modified
     */
    @Override
    public int patch(Path sourceFile,
                     Map<String, List<String>> tagsToApply,
                     Map<String, String> displayNames,
                     PrintWriter diagnostics) throws IOException {

        ParserConfiguration cfg = new ParserConfiguration();
        cfg.setLanguageLevel(LanguageLevel.JAVA_21);
        JavaParser parser = new JavaParser(cfg);

        ParseResult<CompilationUnit> parseResult = parser.parse(sourceFile);
        if (!parseResult.isSuccessful() || parseResult.getResult().isEmpty()) {
            if (LOG.isLoggable(Level.WARNING)) {
                LOG.warning("Failed to parse: " + sourceFile + " — " + parseResult.getProblems());
            }
            return 0;
        }

        CompilationUnit cu = parseResult.getResult().orElseThrow();
        LexicalPreservingPrinter.setup(cu);

        String packageName = cu.getPackageDeclaration()
                .map(NodeWithName::getNameAsString).orElse("");

        Set<String> effective = AnnotationInspector.effectiveAnnotations(cu, AnnotationInspector.DEFAULT_TEST_ANNOTATIONS);

        boolean needsTagImport = false;
        boolean needsDisplayNameImport = false;
        int totalChanges = 0;

        for (ClassOrInterfaceDeclaration clazz : cu.findAll(ClassOrInterfaceDeclaration.class)) {
            String fqcn = buildFqcn(packageName, clazz.getNameAsString());

            for (MethodDeclaration method : clazz.getMethods()) {
                if (!AnnotationInspector.isJUnitTest(method, effective)) {
                    continue;
                }
                String methodName = method.getNameAsString();
                List<String> desiredTags = tagsToApply.get(methodName);
                String desiredDisplayName = displayNames.get(methodName);

                if (desiredTags == null && !displayNames.containsKey(methodName)) {
                    // Method is not mentioned in either map — leave unchanged.
                    continue;
                }

                MethodApplyResult result = applyDesiredState(method, desiredTags, desiredDisplayName);

                if (result.modified()) {
                    int changes = result.tagsAdded() + result.tagsRemoved()
                            + (result.displayNameChanged() ? 1 : 0);
                    totalChanges += changes;
                    if (result.needsTagImport()) {
                        needsTagImport = true;
                    }
                    if (result.needsDisplayNameImport()) {
                        needsDisplayNameImport = true;
                    }
                    if (LOG.isLoggable(Level.FINE)) {
                        LOG.fine("Patched method " + fqcn + "#" + methodName + ": " + changes + " change(s)");
                    }
                }
            }
        }

        if (totalChanges > 0) {
            if (needsTagImport) {
                cu.addImport(IMPORT_TAG);
            }
            if (needsDisplayNameImport) {
                cu.addImport(IMPORT_DISPLAY_NAME);
            }
            Files.writeString(sourceFile, LexicalPreservingPrinter.print(cu), StandardCharsets.UTF_8);
            diagnostics.println("Patched: " + sourceFile + " (+" + totalChanges + " change(s))");
        }
        return totalChanges;
    }

    /**
     * Applies a desired annotation state to a single test method declaration.
     *
     * <p>All existing {@code @Tag} and {@code @Tags} annotations are removed and
     * replaced with exactly the tags from {@code desiredTags}. The
     * {@code @DisplayName} annotation is driven by {@code desiredDisplayName}
     * according to a three-way contract:</p>
     * <ul>
     *   <li>{@code null} — column was absent from the source CSV (old format):
     *       the existing {@code @DisplayName} annotation is left untouched</li>
     *   <li>{@code ""} — column was present but empty: any existing
     *       {@code @DisplayName} is removed</li>
     *   <li>non-empty text — the desired display name: any existing
     *       {@code @DisplayName} is replaced with the new value</li>
     * </ul>
     *
     * @param method             method declaration to modify
     * @param desiredTags        exact set of {@code @Tag} values to apply; {@code null}
     *                           is treated as an empty list (all tags removed)
     * @param desiredDisplayName desired {@code @DisplayName} text; {@code null} means
     *                           leave unchanged; {@code ""} means remove; non-empty
     *                           means set to this value
     * @return result describing what changed; never {@code null}
     */
    @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
    /* default */ static MethodApplyResult applyDesiredState(MethodDeclaration method,
            List<String> desiredTags, String desiredDisplayName) {
        // Handle @DisplayName
        // null  → column absent from CSV (old format): leave @DisplayName unchanged
        // ""    → column present but empty: remove @DisplayName
        // text  → set @DisplayName to the given text
        boolean displayNameChanged = false;
        if (desiredDisplayName != null && !desiredDisplayName.isEmpty()) {
            method.getAnnotations().removeIf(a -> ANNOTATION_DISPLAY_NAME.equals(a.getNameAsString()));
            method.addSingleMemberAnnotation(ANNOTATION_DISPLAY_NAME,
                    new StringLiteralExpr(desiredDisplayName));
            displayNameChanged = true;
        } else if (desiredDisplayName != null) {
            // desiredDisplayName is "" — remove any existing @DisplayName
            boolean hadDisplayName = method.getAnnotations().stream()
                    .anyMatch(a -> ANNOTATION_DISPLAY_NAME.equals(a.getNameAsString()));
            method.getAnnotations().removeIf(a -> ANNOTATION_DISPLAY_NAME.equals(a.getNameAsString()));
            if (hadDisplayName) {
                displayNameChanged = true;
            }
        }
        // else desiredDisplayName == null → no change to @DisplayName

        // Handle @Tag annotations — only mutate the AST if the sets differ.
        Set<String> existingTags = new HashSet<>(AnnotationInspector.getTagValues(method));
        Set<String> desiredTagSet = new HashSet<>();
        if (desiredTags != null) {
            for (String tag : desiredTags) {
                if (tag != null && !tag.isBlank()) {
                    desiredTagSet.add(tag);
                }
            }
        }

        int tagsAdded = 0;
        int tagsRemoved = 0;
        if (!existingTags.equals(desiredTagSet)) {
            method.getAnnotations().removeIf(a -> ANNOTATION_TAG.equals(a.getNameAsString())
                    || "Tags".equals(a.getNameAsString()));
            tagsRemoved = existingTags.size();
            for (String tag : desiredTagSet) {
                method.addSingleMemberAnnotation(ANNOTATION_TAG, new StringLiteralExpr(tag));
                tagsAdded++;
            }
        }

        return new MethodApplyResult(tagsAdded, tagsRemoved, displayNameChanged);
    }

    private static String buildFqcn(String packageName, String className) {
        return packageName.isEmpty() ? className : packageName + "." + className;
    }

    /**
     * Result of applying a desired annotation state to a single method declaration.
     *
     * @param tagsAdded          number of {@code @Tag} annotations added
     * @param tagsRemoved        number of {@code @Tag} annotations removed
     * @param displayNameChanged whether the {@code @DisplayName} annotation was
     *                           set or removed
     */
    /* default */ record MethodApplyResult(int tagsAdded, int tagsRemoved, boolean displayNameChanged) {

        /**
         * Returns {@code true} when at least one annotation was added, removed,
         * or changed.
         *
         * @return {@code true} if the method was modified
         */
        /* default */ boolean modified() {
            return tagsAdded > 0 || tagsRemoved > 0 || displayNameChanged;
        }

        /**
         * Returns {@code true} when a {@code @Tag} import may be required.
         *
         * @return {@code true} if tags were added
         */
        /* default */ boolean needsTagImport() { return tagsAdded > 0; }

        /**
         * Returns {@code true} when a {@code @DisplayName} import may be required.
         *
         * @return {@code true} if the display name was changed
         */
        /* default */ boolean needsDisplayNameImport() { return displayNameChanged; }
    }
}