DotNetTestDiscovery.java

package org.egothor.methodatlas.discovery.dotnet;

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.Set;
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.dotnet.internal.AttributeInfo;
import org.egothor.methodatlas.discovery.dotnet.internal.CSharpTestVisitor;
import org.egothor.methodatlas.discovery.dotnet.internal.FrameworkKind;
import org.egothor.methodatlas.discovery.dotnet.internal.MethodInfo;
import org.egothor.methodatlas.discovery.dotnet.parser.CSharpTestLexer;
import org.egothor.methodatlas.discovery.dotnet.parser.CSharpTestParser;

/**
 * {@link TestDiscovery} implementation for C# source trees.
 *
 * <p>Scans a directory root for {@code .cs} files (configurable via
 * {@link TestDiscoveryConfig#fileSuffixes()}), parses each with the
 * ANTLR4-generated {@code CSharpTest} grammar, and emits one
 * {@link DiscoveredMethod} per test method found.</p>
 *
 * <p>Test-framework detection is automatic from {@code using} directives:
 * xUnit ({@code Xunit.*}), NUnit ({@code NUnit.*}), and MSTest
 * ({@code Microsoft.VisualStudio.TestTools.*}) are all supported.
 * Test markers can be overridden via
 * {@link TestDiscoveryConfig#testMarkers()}.</p>
 *
 * <h2>Tag extraction</h2>
 * <ul>
 *   <li>NUnit — {@code [Category("value")]}</li>
 *   <li>xUnit — {@code [Trait("Tag", "value")]} or
 *       {@code [Trait("Category", "value")]}</li>
 *   <li>MSTest — {@code [TestCategory("value")]}</li>
 * </ul>
 *
 * <h2>Display-name extraction</h2>
 * <p>xUnit only: {@code [Fact(DisplayName = "text")]} /
 * {@code [Theory(DisplayName = "text")]}. NUnit and MSTest do not have a
 * standard display-name attribute and return {@code null}.</p>
 *
 * <h2>Parser scope</h2>
 * <p>The {@code CSharpTest} grammar is structural: it covers namespaces, type
 * declarations, method declarations, and attribute sections, treating method
 * bodies as opaque balanced-brace content. It is not a full implementation of
 * the C# language specification and may not handle every exotic syntax
 * construct. When a parse error occurs, a {@code WARNING} is logged with the
 * file path, line number, character position, and problem description; ANTLR4
 * error recovery then continues so remaining test methods are still discovered.
 * If you encounter a parse warning on valid source, please report it with the
 * relevant code fragment — grammar fixes are localised and typically quick.</p>
 *
 * <h2>ServiceLoader registration</h2>
 * <p>Registered via
 * {@code META-INF/services/org.egothor.methodatlas.api.TestDiscovery}.</p>
 *
 * @see DotNetSourcePatcher
 */
public final class DotNetTestDiscovery implements TestDiscovery {

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

    private List<String> fileSuffixes = List.of(".cs");
    private Set<String>  testMarkers  = Set.of();
    private final AtomicBoolean errors = new AtomicBoolean();

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

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

    /**
     * {@inheritDoc}
     */
    @Override
    public void configure(TestDiscoveryConfig config) {
        List<String> suffixes = config.fileSuffixesFor(pluginId());
        this.fileSuffixes = suffixes.isEmpty() ? List.of(".cs") : suffixes;
        this.testMarkers = Set.copyOf(config.testMarkers());
    }

    @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)
                .filter(this::isCSharpFile)
                .forEach(file -> {
                    try {
                        discoverInFile(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 helpers ───────────────────────────────────────────────

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

    private void discoverInFile(Path file, Path root,
                                 List<DiscoveredMethod> results) throws IOException {
        CSharpTestParser.CompilationUnitContext tree = parse(file);
        if (tree == null) { return; }

        CSharpTestVisitor visitor = new CSharpTestVisitor(testMarkers);
        visitor.visit(tree);

        List<MethodInfo> methods = visitor.getDiscoveredMethods();
        if (methods.isEmpty()) { return; }

        FrameworkKind framework = visitor.getFramework();

        String stem = buildFileStem(file, root);
        // Shared SourceContent for all methods in this file
        SourceContent content = buildSourceContent(file);

        for (MethodInfo m : methods) {
            results.add(buildDiscoveredMethod(m, file, stem, content, framework));
        }
    }

    private DiscoveredMethod buildDiscoveredMethod(MethodInfo m, Path file,
                                                    String stem,
                                                    SourceContent content,
                                                    FrameworkKind fw) {
        List<String> tags    = extractTags(m, fw);
        String       dispName = extractDisplayName(m, fw);
        int          loc      = m.endLine() - m.beginLine() + 1;

        return new DiscoveredMethod(
                m.fqcn(),
                m.methodName(),
                m.beginLine(),
                m.endLine(),
                loc,
                tags,
                dispName,
                file,
                stem,
                content);
    }

    private List<String> extractTags(MethodInfo m, FrameworkKind fw) {
        Set<String> tagAttrNames = fw.tagAttributeNames();
        List<String> tags = new ArrayList<>();
        for (AttributeInfo attr : m.attributes()) {
            if (!tagAttrNames.contains(attr.simpleName())) { continue; }
            switch (fw) {
                case XUNIT -> {
                    // [Trait("Tag", "value")] or [Trait("Category", "value")]
                    List<String> pos = attr.positionalArgs();
                    if (pos.size() >= 2 && pos.get(1) != null) {
                        String key = pos.get(0);
                        if ("Tag".equalsIgnoreCase(key) || "Category".equalsIgnoreCase(key)) {
                            tags.add(pos.get(1));
                        }
                    }
                }
                case NUNIT, MSTEST, UNKNOWN -> {
                    // [Category("value")] or [TestCategory("value")]
                    List<String> pos = attr.positionalArgs();
                    if (!pos.isEmpty() && pos.get(0) != null) {
                        tags.add(pos.get(0));
                    }
                }
            }
        }
        return List.copyOf(tags);
    }

    private String extractDisplayName(MethodInfo m, FrameworkKind fw) {
        if (!fw.supportsDisplayName()) { return null; }
        // xUnit: [Fact(DisplayName = "text")] or [Theory(DisplayName = "text")]
        for (AttributeInfo attr : m.attributes()) {
            if ("Fact".equals(attr.simpleName()) || "Theory".equals(attr.simpleName())) {
                return attr.namedArgs().get("DisplayName");  // null if absent = no annotation
            }
        }
        return null;
    }

    private CSharpTestParser.CompilationUnitContext parse(Path file) throws IOException {
        CSharpTestLexer lexer = new CSharpTestLexer(CharStreams.fromPath(file));
        lexer.removeErrorListeners();
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        CSharpTestParser parser = new CSharpTestParser(tokens);
        parser.removeErrorListeners();
        List<String> syntaxErrors = new ArrayList<>();
        parser.addErrorListener(new BaseErrorListener() {
            @Override
            public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol,
                                    int line, int charPositionInLine,
                                    String msg, RecognitionException e) {
                syntaxErrors.add(file + ":" + line + ":" + charPositionInLine + ": " + msg);
            }
        });
        CSharpTestParser.CompilationUnitContext tree = parser.compilationUnit();
        if (!syntaxErrors.isEmpty()) {
            errors.set(true);
            if (LOG.isLoggable(Level.WARNING)) {
                syntaxErrors.forEach(err -> LOG.warning("C# parse error: " + err));
            }
        }
        return tree;
    }

    private 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();
            // drop .cs extension from last segment
            if (i == rel.getNameCount() - 1 && part.endsWith(".cs")) {
                part = part.substring(0, part.length() - 3);
            }
            sb.append(part);
        }
        return sb.toString();
    }

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