ABAPTestDiscovery.java

package org.egothor.methodatlas.discovery.abap;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;

import org.antlr.v4.runtime.BaseErrorListener;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.RecognitionException;
import org.antlr.v4.runtime.Recognizer;
import org.egothor.methodatlas.api.DiscoveredMethod;
import org.egothor.methodatlas.api.SourceContent;
import org.egothor.methodatlas.api.TestDiscovery;
import org.egothor.methodatlas.api.TestDiscoveryConfig;
import org.egothor.methodatlas.discovery.abap.internal.ABAPTestVisitor;
import org.egothor.methodatlas.discovery.abap.internal.ECATTScriptVisitor;
import org.egothor.methodatlas.discovery.abap.internal.MethodInfo;
import org.egothor.methodatlas.discovery.abap.parser.ABAPTestLexer;
import org.egothor.methodatlas.discovery.abap.parser.ECATTScriptLexer;
import org.egothor.methodatlas.discovery.abap.parser.ECATTScriptParser;

/**
 * {@link TestDiscovery} implementation for SAP ABAP source trees.
 *
 * <p>Scans for two file types and test conventions:</p>
 *
 * <ul>
 *   <li><b>ABAP Unit</b> ({@code .abap} files) — classes annotated with
 *       {@code FOR TESTING} whose methods are also marked {@code FOR TESTING}
 *       via the ABAP Unit framework.  The ANTLR4 {@code ABAPTest} grammar
 *       is used for structural parsing.</li>
 *   <li><b>ecATT</b> ({@code .ecl} files) — exported ecATT script files
 *       produced by SAP transaction {@code SECATT}.  Each {@code FUNCTION}
 *       block is treated as one test case.  The ANTLR4 {@code ECATTScript}
 *       grammar is used.</li>
 * </ul>
 *
 * <h2>FQCN computation</h2>
 * <p>For ABAP Unit: the fully-qualified class name (e.g. {@code ZCL_AUTH_TEST})
 * is used as the FQCN.  For ecATT: the script function name is used.</p>
 *
 * <h2>ServiceLoader registration</h2>
 * <p>Registered via
 * {@code META-INF/services/org.egothor.methodatlas.api.TestDiscovery}.</p>
 *
 * @see TestDiscovery
 * @see DiscoveredMethod
 * @see ABAPTestVisitor
 * @see ECATTScriptVisitor
 */
public final class ABAPTestDiscovery implements TestDiscovery {

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

    private static final String DEFAULT_ABAP_SUFFIX = ".abap";
    private static final String DEFAULT_ECATT_SUFFIX = ".ecl";

    private List<String> abapSuffixes  = List.of(DEFAULT_ABAP_SUFFIX);
    private List<String> ecattSuffixes = List.of(DEFAULT_ECATT_SUFFIX);
    private final AtomicBoolean errors = new AtomicBoolean();

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

    @Override
    public String pluginId() {
        return "abap";
    }

    /**
     * Configures file suffixes from the supplied {@link TestDiscoveryConfig}.
     *
     * <p>Reads {@code fileSuffixesFor("abap")} for ABAP Unit files and
     * {@code fileSuffixesFor("ecatt")} for ecATT files.  Falls back to
     * {@code .abap} and {@code .ecl} respectively when not configured.</p>
     *
     * <p><b>Note:</b> {@code "ecatt"} is a configuration-only namespace — no
     * {@link TestDiscovery#pluginId()} returns {@code "ecatt"}. To override
     * ecATT suffixes separately from ABAP Unit suffixes, use the literal
     * {@code ecatt:} prefix on a {@code -file-suffix} entry (for example
     * {@code -file-suffix ecatt:.txt}).</p>
     *
     * @param config runtime configuration; never {@code null}
     */
    @Override
    public void configure(TestDiscoveryConfig config) {
        List<String> abap  = config.fileSuffixesFor(pluginId());
        List<String> ecatt = config.fileSuffixesFor("ecatt");
        this.abapSuffixes  = abap.isEmpty()  ? List.of(DEFAULT_ABAP_SUFFIX)  : abap;
        this.ecattSuffixes = ecatt.isEmpty() ? List.of(DEFAULT_ECATT_SUFFIX) : ecatt;
    }

    /**
     * Scans {@code root} for ABAP Unit and ecATT test files.
     *
     * @param root directory to scan; must be an existing directory
     * @return stream of discovered test methods; never {@code null}
     * @throws IOException if traversing the file tree fails
     */
    @Override
    public Stream<DiscoveredMethod> discover(Path root) throws IOException {
        if (!Files.isDirectory(root)) {
            return Stream.empty();
        }
        List<DiscoveredMethod> results = new ArrayList<>();
        try (Stream<Path> walk = Files.walk(root)) {
            walk.filter(Files::isRegularFile)
                .forEach(file -> {
                    try {
                        if (isAbapFile(file)) {
                            discoverAbapUnit(file, root, results);
                        } else if (isEcattFile(file)) {
                            discoverEcatt(file, root, results);
                        }
                    } catch (Exception e) {
                        errors.set(true);
                        if (LOG.isLoggable(Level.WARNING)) {
                            LOG.log(Level.WARNING, "Failed to process: " + file, e);
                        }
                    }
                });
        }
        return results.stream();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public boolean hadErrors() {
        return errors.get();
    }

    // ── Private: ABAP Unit ────────────────────────────────────────────

    private void discoverAbapUnit(Path file, Path root,
                                  List<DiscoveredMethod> results) throws IOException {
        // ABAP discovery uses the lexer only — the visitor walks the raw
        // token stream so the discovery is robust against the wide variety
        // of statement-level constructs that appear inside method bodies
        // and class headers.
        ABAPTestLexer lexer = new ABAPTestLexer(CharStreams.fromPath(file));
        lexer.removeErrorListeners();
        CommonTokenStream tokens = new CommonTokenStream(lexer);

        ABAPTestVisitor visitor = new ABAPTestVisitor();
        visitor.scan(tokens);

        List<MethodInfo> methods = visitor.getDiscoveredMethods();
        if (methods.isEmpty()) {
            return;
        }
        SourceContent content = lazyContent(file);
        String stem = buildFileStem(file, root);
        for (MethodInfo m : methods) {
            int loc = m.endLine() - m.beginLine() + 1;
            results.add(new DiscoveredMethod(
                    m.className(), m.name(),
                    m.beginLine(), m.endLine(), loc,
                    List.of(), null, file, stem, content));
        }
    }

    // ── Private: ecATT ────────────────────────────────────────────────

    private void discoverEcatt(Path file, Path root,
                               List<DiscoveredMethod> results) throws IOException {
        ECATTScriptLexer lexer = new ECATTScriptLexer(CharStreams.fromPath(file));
        lexer.removeErrorListeners();
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        ECATTScriptParser parser = new ECATTScriptParser(tokens);
        parser.removeErrorListeners();
        addEcattSyntaxErrorListener(parser, file);

        ECATTScriptParser.SourceFileContext tree = parser.sourceFile();
        ECATTScriptVisitor visitor = new ECATTScriptVisitor();
        visitor.visit(tree);

        List<MethodInfo> methods = visitor.getDiscoveredMethods();
        if (methods.isEmpty()) {
            return;
        }
        SourceContent content = lazyContent(file);
        String stem = buildFileStem(file, root);
        for (MethodInfo m : methods) {
            int loc = m.endLine() - m.beginLine() + 1;
            results.add(new DiscoveredMethod(
                    m.name(), m.name(),
                    m.beginLine(), m.endLine(), loc,
                    List.of(), null, file, stem, content));
        }
    }

    // ── Private: helpers ──────────────────────────────────────────────

    private boolean isAbapFile(Path path) {
        Path fn = path.getFileName();
        if (fn == null) {
            return false;
        }
        String name = fn.toString();
        return abapSuffixes.stream().anyMatch(name::endsWith);
    }

    private boolean isEcattFile(Path path) {
        Path fn = path.getFileName();
        if (fn == null) {
            return false;
        }
        String name = fn.toString();
        return ecattSuffixes.stream().anyMatch(name::endsWith);
    }

    private void addEcattSyntaxErrorListener(ECATTScriptParser parser, Path file) {
        parser.addErrorListener(new BaseErrorListener() {
            @Override
            public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol,
                                    int line, int charPositionInLine,
                                    String msg, RecognitionException e) {
                errors.set(true);
                if (LOG.isLoggable(Level.WARNING)) {
                    LOG.warning("ecATT parse error: " + file + ":" + line + ":"
                            + charPositionInLine + ": " + msg);
                }
            }
        });
    }

    private static SourceContent lazyContent(Path file) {
        return SourceContent.ofFile(file);
    }

    // ── Package-private static helpers (testable) ─────────────────────

    /**
     * Derives a dot-separated file stem from the file's path relative to
     * the scan root, stripping the file extension from the last segment.
     *
     * @param file path to the source file
     * @param root scan root directory
     * @return dot-separated stem; never {@code null} or empty
     */
    /* default */ static String buildFileStem(Path file, Path root) {
        Path rel = root.relativize(file);
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < rel.getNameCount(); i++) {
            if (!sb.isEmpty()) {
                sb.append('.');
            }
            String part = rel.getName(i).toString();
            if (i == rel.getNameCount() - 1) {
                int dot = part.lastIndexOf('.');
                if (dot > 0) {
                    part = part.substring(0, dot);
                }
            }
            sb.append(part);
        }
        return sb.toString();
    }
}