AnnotationInspector.java

package org.egothor.methodatlas.discovery.jvm;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;

import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.ImportDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.ArrayInitializerExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MemberValuePair;

/**
 * Static utilities for inspecting test-framework annotations on parsed method
 * declarations.
 *
 * <p>
 * This class centralizes the annotation-analysis logic used during test method
 * discovery. All methods operate on parsed AST nodes produced by JavaParser and
 * do not perform symbol resolution.
 * </p>
 *
 * <p>
 * Annotation matching is performed by simple name because fully qualified
 * names require symbol resolution, which is not available in the current
 * source-only parsing configuration. False positives are therefore possible if
 * a project defines a custom annotation with the same simple name as a supported
 * test annotation.
 * </p>
 *
 * <p>
 * {@link #effectiveAnnotations(CompilationUnit, Set)} performs per-file
 * framework detection from import declarations and selects the appropriate
 * annotation set automatically when no custom set has been configured.
 * </p>
 *
 * <p>
 * This class is a non-instantiable utility holder.
 * </p>
 *
 * @see JavaTestDiscovery
 */
public final class AnnotationInspector {

    /**
     * Default annotation simple names recognised as JUnit 5 (Jupiter) test
     * methods when no custom set is configured and no other framework is
     * detected from import declarations.
     *
     * <p>
     * The set contains: {@code Test}, {@code ParameterizedTest},
     * {@code RepeatedTest}, {@code TestFactory}, and {@code TestTemplate}.
     * </p>
     */
    public static final Set<String> DEFAULT_TEST_ANNOTATIONS = Set.of(
            "Test", "ParameterizedTest", "RepeatedTest", "TestFactory", "TestTemplate");

    /**
     * Annotation simple names recognised as JUnit 4 test methods.
     *
     * <p>
     * Includes {@code Test} (shared with JUnit 5 and TestNG) and
     * {@code Theory} from {@code org.junit.experimental.theories}.
     * </p>
     */
    public static final Set<String> JUNIT4_TEST_ANNOTATIONS = Set.of("Test", "Theory");

    /**
     * Annotation simple names recognised as TestNG test methods.
     *
     * <p>
     * TestNG uses a single {@code @Test} annotation for all test methods,
     * including data-driven variants (those specify {@code dataProvider} as
     * an attribute rather than using a separate annotation).
     * </p>
     */
    public static final Set<String> TESTNG_TEST_ANNOTATIONS = Set.of("Test");

    /**
     * Prevents instantiation of this utility class.
     */
    private AnnotationInspector() {
    }

    /**
     * Returns the effective annotation set to use for test method discovery in
     * a given compilation unit.
     *
     * <p>
     * When {@code configured} is the {@link #DEFAULT_TEST_ANNOTATIONS default set}
     * (i.e. the user did not supply a custom {@code -test-annotation} flag or
     * {@code testAnnotations} YAML entry), the method inspects the compilation
     * unit's import declarations to detect the test framework and returns the
     * framework-appropriate annotation set:
     * </p>
     *
     * <ul>
     *   <li>{@code org.junit.jupiter.*} imports → JUnit 5 ({@link #DEFAULT_TEST_ANNOTATIONS})</li>
     *   <li>{@code org.junit.*} or {@code junit.framework.*} imports → JUnit 4
     *       ({@link #JUNIT4_TEST_ANNOTATIONS}, adds {@code Theory})</li>
     *   <li>{@code org.testng.*} imports → TestNG ({@link #TESTNG_TEST_ANNOTATIONS})</li>
     * </ul>
     *
     * <p>
     * If multiple frameworks are imported (e.g. during a JUnit 4 → 5 migration),
     * the union of the matching annotation sets is returned. When no framework
     * imports are found, {@link #DEFAULT_TEST_ANNOTATIONS} is used as fallback.
     * </p>
     *
     * <p>
     * When {@code configured} differs from the default set, the caller has
     * explicitly customised the annotation list; auto-detection is skipped and
     * {@code configured} is returned unchanged.
     * </p>
     *
     * @param cu         parsed compilation unit whose imports are inspected
     * @param configured the annotation set from configuration; if equal to
     *                   {@link #DEFAULT_TEST_ANNOTATIONS}, auto-detection runs
     * @return effective annotation set for the given file; never {@code null}
     */
    public static Set<String> effectiveAnnotations(CompilationUnit cu, Set<String> configured) {
        if (!DEFAULT_TEST_ANNOTATIONS.equals(configured)) {
            return configured;
        }

        boolean hasJUnit4 = false;
        boolean hasTestNG = false;
        for (ImportDeclaration imp : cu.getImports()) {
            String name = imp.getNameAsString();
            if (name.startsWith("org.junit.jupiter")) { // NOPMD EmptyControlStatement — JUnit 5 is the default; no action needed
            } else if (name.startsWith("org.junit") || name.startsWith("junit.framework")) {
                hasJUnit4 = true;
            } else if (name.startsWith("org.testng")) {
                hasTestNG = true;
            }
        }

        if (!hasJUnit4 && !hasTestNG) {
            return DEFAULT_TEST_ANNOTATIONS;
        }

        Set<String> effective = new LinkedHashSet<>(DEFAULT_TEST_ANNOTATIONS);
        if (hasJUnit4) {
            effective.addAll(JUNIT4_TEST_ANNOTATIONS);
        }
        if (hasTestNG) {
            effective.addAll(TESTNG_TEST_ANNOTATIONS);
        }
        return Collections.unmodifiableSet(effective);
    }

    /**
     * Determines whether a method declaration represents a JUnit test method
     * using the {@link #DEFAULT_TEST_ANNOTATIONS default annotation set}.
     *
     * @param method method declaration to inspect
     * @return {@code true} if the method carries a recognised test annotation
     */
    public static boolean isJUnitTest(MethodDeclaration method) {
        return isJUnitTest(method, DEFAULT_TEST_ANNOTATIONS);
    }

    /**
     * Determines whether a method declaration represents a JUnit test method
     * using a caller-supplied set of annotation simple names.
     *
     * <p>
     * Matching is performed against the annotation's simple name only, because
     * fully qualified name resolution requires symbol resolution which is not
     * available in source-only parsing mode.
     * </p>
     *
     * @param method          method declaration to inspect
     * @param testAnnotations set of annotation simple names to recognise as
     *                        test methods; must not be {@code null}
     * @return {@code true} if the method carries at least one annotation whose
     *         simple name is in {@code testAnnotations}
     */
    public static boolean isJUnitTest(MethodDeclaration method, Set<String> testAnnotations) {
        for (AnnotationExpr annotation : method.getAnnotations()) {
            String name = annotation.getNameAsString();
            // Strip qualifier so @org.junit.jupiter.api.Test matches the simple name "Test"
            int dot = name.lastIndexOf('.');
            String simpleName = dot >= 0 ? name.substring(dot + 1) : name;
            if (testAnnotations.contains(simpleName)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Extracts all tag values declared on a method.
     *
     * <p>
     * Both direct {@code @Tag} annotations and the container-style
     * {@code @Tags} annotation are supported. Tags are returned in declaration
     * order.
     * </p>
     *
     * @param method method declaration whose annotations should be inspected
     * @return list of extracted tag values; possibly empty but never
     *         {@code null}
     */
    public static List<String> getTagValues(MethodDeclaration method) {
        List<String> tagValues = new ArrayList<>();

        for (AnnotationExpr annotation : method.getAnnotations()) {
            String name = annotation.getNameAsString();

            if ("Tag".equals(name)) { // NOPMD
                extractTagValue(annotation).ifPresent(tagValues::add);
            } else if ("Tags".equals(name)) { // NOPMD
                extractTagsContainerValues(annotation, tagValues);
            }
        }

        return tagValues;
    }

    /**
     * Returns the string value of the {@code @DisplayName} annotation if present
     * on the method.
     *
     * <p>
     * Both the single-member form {@code @DisplayName("text")} and the normal
     * annotation form {@code @DisplayName(value = "text")} are supported.
     * Matching is performed against the simple annotation name {@code "DisplayName"}.
     * </p>
     *
     * <p>
     * The return value distinguishes three cases:
     * </p>
     * <ul>
     * <li>{@code null} — no {@code @DisplayName} annotation is present on the method</li>
     * <li>{@code ""} (empty string) — a {@code @DisplayName("")} annotation is present
     *     but its value is an empty string; this is a malformed annotation because JUnit
     *     requires a non-blank display name</li>
     * <li>any non-empty string — the annotation is present with a non-blank value</li>
     * </ul>
     *
     * @param method method declaration whose annotations should be inspected
     * @return the display name text value, {@code ""} when the annotation is
     *         present with an empty value, or {@code null} when no display name
     *         annotation is present
     */
    public static String getDisplayName(MethodDeclaration method) {
        for (AnnotationExpr annotation : method.getAnnotations()) {
            if (!"DisplayName".equals(annotation.getNameAsString())) {
                continue;
            }
            if (annotation.isSingleMemberAnnotationExpr()) {
                Expression memberValue = annotation.asSingleMemberAnnotationExpr().getMemberValue();
                if (memberValue.isStringLiteralExpr()) {
                    return memberValue.asStringLiteralExpr().asString();
                }
            } else if (annotation.isNormalAnnotationExpr()) {
                for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) {
                    if ("value".equals(pair.getNameAsString()) && pair.getValue().isStringLiteralExpr()) {
                        return pair.getValue().asStringLiteralExpr().asString();
                    }
                }
            }
        }
        return null;
    }

    /**
     * Computes the inclusive line count of a method declaration from its
     * source range.
     *
     * <p>
     * Returns {@code 0} if no source position information is available.
     * </p>
     *
     * @param method method declaration whose size should be measured
     * @return inclusive line count, or {@code 0} if no range information is
     *         available
     */
    public static int countLOC(MethodDeclaration method) {
        return method.getRange().map(range -> range.end.line - range.begin.line + 1).orElse(0);
    }

    private static void extractTagsContainerValues(AnnotationExpr annotation, List<String> tagValues) {
        if (annotation.isSingleMemberAnnotationExpr()) {
            Expression memberValue = annotation.asSingleMemberAnnotationExpr().getMemberValue();
            extractTagsFromContainerValue(memberValue, tagValues);
            return;
        }

        if (annotation.isNormalAnnotationExpr()) {
            for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) {
                if ("value".equals(pair.getNameAsString())) { // NOPMD
                    extractTagsFromContainerValue(pair.getValue(), tagValues);
                }
            }
        }
    }

    private static void extractTagsFromContainerValue(Expression value, List<String> tagValues) {
        if (!value.isArrayInitializerExpr()) {
            return;
        }

        ArrayInitializerExpr array = value.asArrayInitializerExpr();
        for (Expression expression : array.getValues()) {
            if (expression.isAnnotationExpr()) {
                extractTagValue(expression.asAnnotationExpr()).ifPresent(tagValues::add);
            }
        }
    }

    private static Optional<String> extractTagValue(AnnotationExpr annotation) {
        if (!"Tag".equals(annotation.getNameAsString())) {
            return Optional.empty();
        }

        if (annotation.isSingleMemberAnnotationExpr()) {
            Expression memberValue = annotation.asSingleMemberAnnotationExpr().getMemberValue();
            if (memberValue.isStringLiteralExpr()) {
                return Optional.of(memberValue.asStringLiteralExpr().asString());
            }
            return Optional.empty();
        }

        if (annotation.isNormalAnnotationExpr()) {
            for (MemberValuePair pair : annotation.asNormalAnnotationExpr().getPairs()) {
                if ("value".equals(pair.getNameAsString()) && pair.getValue().isStringLiteralExpr()) {
                    return Optional.of(pair.getValue().asStringLiteralExpr().asString());
                }
            }
        }

        return Optional.empty();
    }
}