AnnotationInspector.java

package org.egothor.methodatlas;

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

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 JUnit 5 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 JUnit
 * Jupiter annotation.
 * </p>
 *
 * <p>
 * This class is a non-instantiable utility holder.
 * </p>
 *
 * @see MethodAtlasApp
 */
final class AnnotationInspector {

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

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

    /**
     * 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
     */
    /* default */ 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}
     */
    /* default */ 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 JUnit 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}
     */
    /* default */ 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;
    }

    /**
     * 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
     */
    /* default */ static int countLOC(MethodDeclaration method) {
        return method.getRange().map(range -> range.end.line - range.begin.line + 1).orElse(0);
    }

    /**
     * Extracts tag values from a JUnit {@code @Tags} container annotation.
     *
     * @param annotation annotation expected to represent {@code @Tags}
     * @param tagValues  destination list to which extracted tag values are
     *                   appended
     */
    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);
                }
            }
        }
    }

    /**
     * Extracts individual {@code @Tag} values from the value expression of a
     * {@code @Tags} container annotation.
     *
     * @param value     expression holding the container contents
     * @param tagValues destination list to which extracted tag values are
     *                  appended
     */
    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);
            }
        }
    }

    /**
     * Extracts the value from a single JUnit {@code @Tag} annotation.
     *
     * <p>
     * Both the single-member form {@code @Tag("x")} and the normal form
     * {@code @Tag(value = "x")} are supported.
     * </p>
     *
     * @param annotation annotation expected to represent {@code @Tag}
     * @return extracted tag value, or {@link Optional#empty()} if the
     *         annotation is not a supported {@code @Tag} form
     */
    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();
    }
}