GoTestDiscovery.java
package org.egothor.methodatlas.discovery.go;
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.go.internal.GoTestVisitor;
import org.egothor.methodatlas.discovery.go.internal.MethodInfo;
import org.egothor.methodatlas.discovery.go.parser.GoTestLexer;
import org.egothor.methodatlas.discovery.go.parser.GoTestParser;
/**
* {@link TestDiscovery} implementation for Go source trees.
*
* <p>Scans a directory root for {@code *_test.go} files (configurable via
* {@link TestDiscoveryConfig#fileSuffixesFor(String)}), parses each with the
* ANTLR4-generated {@code GoTest} grammar, and emits one
* {@link DiscoveredMethod} per Go test function found.</p>
*
* <h2>Test-function detection</h2>
* <p>A function is identified as a test if its signature matches the
* {@code go test} specification:</p>
* <pre>
* func TestXxx(t *testing.T)
* </pre>
* <p>where {@code Xxx} starts with an upper-case letter or underscore.
* Benchmark ({@code BenchmarkXxx}), Example ({@code ExampleXxx}), and Fuzz
* ({@code FuzzXxx}) functions are not recognised by the visitor and are
* therefore excluded.</p>
*
* <h2>Tags and display names</h2>
* <p>Go has no annotation-based tag or display-name system; both fields are
* always empty / {@code null} in the emitted {@link DiscoveredMethod}.</p>
*
* <h2>Parser scope</h2>
* <p>The {@code GoTest} grammar is structural: it covers package declarations,
* import declarations, and function/method declarations, treating function
* bodies as opaque balanced-brace content. It is not a full implementation
* of the Go specification. When a parse error occurs, a {@code WARNING} is
* logged; ANTLR4 error recovery then continues so remaining test functions
* are still discovered.</p>
*
* <h2>ServiceLoader registration</h2>
* <p>Registered via
* {@code META-INF/services/org.egothor.methodatlas.api.TestDiscovery}.</p>
*
* @see TestDiscovery
* @see DiscoveredMethod
* @see GoTestVisitor
*/
public final class GoTestDiscovery implements TestDiscovery {
private static final Logger LOG = Logger.getLogger(GoTestDiscovery.class.getName());
private static final String DEFAULT_SUFFIX = "_test.go";
private List<String> fileSuffixes = List.of(DEFAULT_SUFFIX);
private final AtomicBoolean errors = new AtomicBoolean();
/**
* No-arg constructor required by {@link java.util.ServiceLoader}.
*/
public GoTestDiscovery() {
// Required by ServiceLoader
}
@Override
public String pluginId() {
return "go";
}
/**
* Configures file suffixes from the supplied {@link TestDiscoveryConfig}.
*
* <p>Calls {@link TestDiscoveryConfig#fileSuffixesFor(String)} with
* {@code "go"}; if the result is empty the default {@code "_test.go"}
* suffix is used.</p>
*
* @param config runtime configuration supplied by the calling application;
* never {@code null}
*/
@Override
public void configure(TestDiscoveryConfig config) {
List<String> suffixes = config.fileSuffixesFor(pluginId());
this.fileSuffixes = suffixes.isEmpty() ? List.of(DEFAULT_SUFFIX) : suffixes;
}
/**
* Scans {@code root} for Go test files and returns discovered test functions.
*
* <p>The returned stream is fully materialised before being returned; it is
* safe to call this method multiple times (e.g. once per scan root).</p>
*
* @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)
.filter(this::isGoTestFile)
.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();
}
/**
* Returns {@code true} if any file could not be processed during discovery.
*
* @return {@code true} when at least one per-file error was encountered
*/
@Override
public boolean hadErrors() {
return errors.get();
}
// ── Private helpers ────────────────────────────────────────────────────
private boolean isGoTestFile(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 {
GoTestParser.SourceFileContext tree = parse(file);
if (tree == null) {
return;
}
GoTestVisitor visitor = new GoTestVisitor();
visitor.visit(tree);
List<MethodInfo> methods = visitor.getDiscoveredMethods();
if (methods.isEmpty()) {
return;
}
String packageName = visitor.getPackageName();
String fqcn = buildFqcn(file, root, packageName);
String stem = buildFileStem(file, root);
SourceContent content = buildSourceContent(file);
for (MethodInfo m : methods) {
int loc = m.endLine() - m.beginLine() + 1;
results.add(new DiscoveredMethod(
fqcn,
m.name(),
m.beginLine(),
m.endLine(),
loc,
List.of(),
null,
file,
stem,
content));
}
}
private GoTestParser.SourceFileContext parse(Path file) throws IOException {
GoTestLexer lexer = new GoTestLexer(CharStreams.fromPath(file));
lexer.removeErrorListeners();
CommonTokenStream tokens = new CommonTokenStream(lexer);
GoTestParser parser = new GoTestParser(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);
}
});
GoTestParser.SourceFileContext tree = parser.sourceFile();
if (!syntaxErrors.isEmpty()) {
errors.set(true);
if (LOG.isLoggable(Level.WARNING)) {
syntaxErrors.forEach(err -> LOG.warning("Go parse error: " + err));
}
}
return tree;
}
// ── Package-private static helpers (testable) ──────────────────────────
/**
* Derives the fully-qualified class name from the file's parent directory
* relative to the scan root.
*
* <p>The path segments of the parent directory relative to {@code root}
* are joined with {@code "."}. If the file resides directly under
* {@code root} (no parent segments) or relativization fails,
* {@code packageName} is returned as the fallback.</p>
*
* @param file path to the Go source file
* @param root scan root directory
* @param packageName package name extracted from the file (used as fallback)
* @return dot-separated identifier representing the file's directory path
*/
/* default */ static String buildFqcn(Path file, Path root, String packageName) {
try {
Path relParent = root.relativize(file.getParent());
if (relParent.getNameCount() == 0 || relParent.toString().isEmpty()) {
return packageName;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < relParent.getNameCount(); i++) {
if (!sb.isEmpty()) {
sb.append('.');
}
sb.append(relParent.getName(i).toString());
}
return sb.toString();
} catch (IllegalArgumentException e) {
return packageName;
}
}
/**
* Derives the dot-separated file stem by relativizing {@code file} from
* {@code root}, joining segments with {@code "."}, and stripping the
* {@code _test.go} suffix from the last segment.
*
* @param file path to the Go test source file
* @param root scan root directory
* @return dot-separated file stem without the {@code _test.go} extension
*/
/* 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 && part.endsWith(DEFAULT_SUFFIX)) {
part = part.substring(0, part.length() - DEFAULT_SUFFIX.length());
}
sb.append(part);
}
return sb.toString();
}
private static SourceContent buildSourceContent(Path file) {
return SourceContent.ofFile(file);
}
}