feat: add @Tag reporting and CSV/plain output

- Default CSV output with header; -plain enables labeled text format
- Extract repeated @Tag and @Tags container values
- Configure JavaParser language level for modern syntax (records)
- Add fixture-based JUnit tests and README usage/examples
This commit is contained in:
2026-02-11 02:10:48 +01:00
parent 968558aed1
commit 63f6b8c803
19 changed files with 1382 additions and 73 deletions

View File

@@ -0,0 +1,443 @@
package org.egothor.methodatlas;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.ParserConfiguration.LanguageLevel;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
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;
/**
* Command-line utility that scans Java source trees for JUnit test methods and
* reports per-method statistics.
*
* <p>
* The tool walks one or more root directories, parses {@code *Test.java} files
* using JavaParser, and emits a record for each method annotated with one of
* the supported JUnit Jupiter test annotations.
* </p>
*
* <h2>Detection</h2>
* <ul>
* <li>Test methods are detected by annotations {@code @Test},
* {@code @ParameterizedTest}, and {@code @RepeatedTest} (simple name
* match).</li>
* <li>{@code @Tag} values are collected from repeated {@code @Tag("...")}
* annotations and from {@code @Tags({ @Tag("..."), ... })} containers.</li>
* <li>Lines of code (LOC) is computed from the AST source range:
* {@code endLine - beginLine + 1}. If the range is unavailable, LOC is
* {@code 0}.</li>
* </ul>
*
* <h2>Output</h2>
* <p>
* By default, the tool prints CSV with a header line:
* {@code fqcn,method,loc,tags}. The {@code tags} field is a semicolon-separated
* list.
* </p>
*
* <p>
* If {@code -plain} is provided as the first argument, the tool prints a plain
* text format:
* </p>
* <pre>
* fqcn, method, LOC=&lt;n&gt;, TAGS=&lt;tag1;tag2&gt;
* </pre>
* <p>
* If no tags are present, {@code TAGS=-} is printed.
* </p>
*
* <h2>Examples</h2> <pre>
* java -jar methodatlas.jar /path/to/repo
* java -jar methodatlas.jar -plain /path/to/repo /another/repo
* </pre>
*/
public class MethodAtlasApp {
/**
* Logging facility for scan progress and parse failures.
*/
private static final Logger LOG = Logger.getLogger(MethodAtlasApp.class.getName());
/**
* Output formats supported by the application.
*/
private enum OutputMode {
/**
* Comma-separated values with a header line and raw numeric LOC.
*/
CSV,
/**
* Plain text lines including {@code LOC=} and {@code TAGS=} labels.
*/
PLAIN
}
/**
* Program entry point.
*
* <p>
* Usage:
* </p>
* <pre>
* java -jar methodatlas.jar [ -plain ] &lt;path1&gt; [ &lt;path2&gt; ... ]
* </pre>
*
* <ul>
* <li>If {@code -plain} is provided as the first argument, the tool uses the
* plain-text output mode; otherwise CSV output is used.</li>
* <li>If no paths are provided, the current directory {@code "."} is
* scanned.</li>
* </ul>
*
* <p>
* The JavaParser language level is configured before parsing to support modern
* Java syntax (for example, {@code record} declarations).
* </p>
*
* @param args command-line arguments; see usage above
* @throws IOException if directory traversal fails while scanning input paths
*/
public static void main(String[] args) throws IOException {
ParserConfiguration pc = new ParserConfiguration();
pc.setLanguageLevel(LanguageLevel.JAVA_21); // or JAVA_17, etc.
StaticJavaParser.setConfiguration(pc);
OutputMode mode = OutputMode.CSV;
int firstPathIndex = 0;
if (args.length > 0 && "-plain".equals(args[0])) {
mode = OutputMode.PLAIN;
firstPathIndex = 1;
}
if (mode == OutputMode.CSV) {
System.out.println("fqcn,method,loc,tags");
}
if (args.length <= firstPathIndex) {
scanRoot(Paths.get("."), mode);
return;
}
for (int i = firstPathIndex; i < args.length; i++) {
scanRoot(Paths.get(args[i]), mode);
}
}
/**
* Recursively scans the supplied root directory for Java test files and
* processes them.
*
* <p>
* The current implementation matches files by suffix {@code "Test.java"}.
* </p>
*
* @param root root directory to scan
* @param mode output mode to use for emitted records
* @throws IOException if the file tree walk fails
*/
private static void scanRoot(Path root, OutputMode mode) throws IOException {
LOG.log(Level.INFO, "Scanning {0} for JUnit files", root);
Files.walk(root).filter(p -> p.toString().endsWith("Test.java")).forEach(p -> processFile(p, mode));
}
/**
* Parses a single Java source file and emits records for all detected JUnit
* test methods.
*
* <p>
* Parse errors are logged. Files that cannot be parsed are skipped.
* </p>
*
* @param path Java source file to parse
* @param mode output mode to use for emitted records
*/
private static void processFile(Path path, OutputMode mode) {
try {
CompilationUnit cu = StaticJavaParser.parse(path);
String pkg = cu.getPackageDeclaration().map(p -> p.getNameAsString()).orElse("");
cu.findAll(ClassOrInterfaceDeclaration.class).forEach(clazz -> {
String className = clazz.getNameAsString();
String fqcn = pkg.isEmpty() ? className : pkg + "." + className;
clazz.findAll(MethodDeclaration.class).forEach(method -> {
if (isJUnitTest(method)) {
int loc = countLOC(method);
List<String> tags = getTagValues(method);
emit(mode, fqcn, method.getNameAsString(), loc, tags);
}
});
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to parse: {0}", path);
e.printStackTrace();
}
}
/**
* Emits a single output record representing one test method.
*
* <p>
* For CSV output, values are printed as {@code fqcn,method,loc,tags} with CSV
* escaping applied to text fields. The {@code tags} field is a
* semicolon-separated list, or an empty field if no tags exist.
* </p>
*
* <p>
* For plain output, labels {@code LOC=} and {@code TAGS=} are included. If no
* tags exist, {@code TAGS=-} is printed.
* </p>
*
* @param mode output mode to use
* @param fqcn fully-qualified class name
* @param method method name
* @param loc lines of code for the method declaration (inclusive range)
* @param tags list of tag values; may be empty
*/
private static void emit(OutputMode mode, String fqcn, String method, int loc, List<String> tags) {
if (mode == OutputMode.PLAIN) {
String tagText = tags.isEmpty() ? "-" : String.join(";", tags);
System.out.println(fqcn + ", " + method + ", LOC=" + loc + ", TAGS=" + tagText);
return;
}
String tagText = tags.isEmpty() ? "" : String.join(";", tags);
System.out.println(csvEscape(fqcn) + "," + csvEscape(method) + "," + loc + "," + csvEscape(tagText));
}
/**
* Escapes a value for safe inclusion in a CSV field.
*
* <p>
* If the value contains a comma, quote, or line break, the value is quoted and
* internal quotes are doubled, per common CSV conventions.
* </p>
*
* @param value input value; may be {@code null}
* @return escaped CSV field value (never {@code null})
*/
private static String csvEscape(String value) {
if (value == null) {
return "";
}
boolean mustQuote = value.indexOf(',') >= 0 || value.indexOf('"') >= 0 || value.indexOf('\n') >= 0
|| value.indexOf('\r') >= 0;
if (!mustQuote) {
return value;
}
return "\"" + value.replace("\"", "\"\"") + "\"";
}
/**
* Determines whether the given method declaration represents a JUnit Jupiter
* test method.
*
* <p>
* The method is considered a test if it is annotated with one of the supported
* test annotations by simple name: {@code Test}, {@code ParameterizedTest}, or
* {@code RepeatedTest}.
* </p>
*
* @param method method declaration to inspect
* @return {@code true} if the method is considered a test method; {@code false}
* otherwise
*/
private static boolean isJUnitTest(MethodDeclaration method) {
for (AnnotationExpr ann : method.getAnnotations()) {
String name = ann.getNameAsString();
if ("Test".equals(name) || "ParameterizedTest".equals(name) || "RepeatedTest".equals(name)) {
return true;
}
}
return false;
}
/**
* Collects all {@code @Tag} values declared directly on a method.
*
* <p>
* Supported forms:
* </p>
* <ul>
* <li>Repeated {@code @Tag("...")} annotations</li>
* <li>{@code @Tags({ @Tag("..."), @Tag("...") })} container annotation</li>
* </ul>
*
* @param method method declaration to inspect
* @return list of tag values in encounter order; never {@code null}
*/
private static List<String> getTagValues(MethodDeclaration method) {
List<String> tags = new ArrayList<>();
for (AnnotationExpr ann : method.getAnnotations()) {
String name = ann.getNameAsString();
if (isTagAnnotationName(name)) {
extractTagValue(ann).ifPresent(tags::add);
} else if (isTagsContainerAnnotationName(name)) {
tags.addAll(extractTagValuesFromContainer(ann));
}
}
return tags;
}
/**
* Checks whether an annotation name represents {@code @Tag}.
*
* @param name annotation name (simple or qualified)
* @return {@code true} if it matches {@code Tag}; {@code false} otherwise
*/
private static boolean isTagAnnotationName(String name) {
return "Tag".equals(name) || name.endsWith(".Tag");
}
/**
* Checks whether an annotation name represents {@code @Tags} (the container for
* {@code @Tag}).
*
* @param name annotation name (simple or qualified)
* @return {@code true} if it matches {@code Tags}; {@code false} otherwise
*/
private static boolean isTagsContainerAnnotationName(String name) {
return "Tags".equals(name) || name.endsWith(".Tags");
}
/**
* Extracts the tag value from a {@code @Tag} annotation expression.
*
* <p>
* Both single-member and normal annotation syntaxes are supported:
* </p>
* <ul>
* <li>{@code @Tag("fast")}</li>
* <li>{@code @Tag(value = "fast")}</li>
* </ul>
*
* @param ann annotation expression representing {@code @Tag}
* @return extracted tag value, or empty if it cannot be determined
*/
private static Optional<String> extractTagValue(AnnotationExpr ann) {
if (ann.isSingleMemberAnnotationExpr()) {
return Optional.of(expressionToTagText(ann.asSingleMemberAnnotationExpr().getMemberValue()));
}
if (ann.isNormalAnnotationExpr()) {
for (MemberValuePair pair : ann.asNormalAnnotationExpr().getPairs()) {
if ("value".equals(pair.getNameAsString())) {
return Optional.of(expressionToTagText(pair.getValue()));
}
}
}
return Optional.empty();
}
/**
* Extracts all contained {@code @Tag} values from a {@code @Tags} container
* annotation.
*
* <p>
* Handles array initializers such as:
* </p>
* <pre>
* &#64;Tags({ @Tag("a"), @Tag("b") })
* </pre>
*
* @param ann annotation expression representing {@code @Tags}
* @return list of extracted tag values; never {@code null}
*/
private static List<String> extractTagValuesFromContainer(AnnotationExpr ann) {
List<String> tags = new ArrayList<>();
Optional<Expression> maybeValue = Optional.empty();
if (ann.isSingleMemberAnnotationExpr()) {
maybeValue = Optional.of(ann.asSingleMemberAnnotationExpr().getMemberValue());
} else if (ann.isNormalAnnotationExpr()) {
for (MemberValuePair pair : ann.asNormalAnnotationExpr().getPairs()) {
if ("value".equals(pair.getNameAsString())) {
maybeValue = Optional.of(pair.getValue());
break;
}
}
}
if (maybeValue.isEmpty()) {
return tags;
}
Expression value = maybeValue.get();
if (value.isArrayInitializerExpr()) {
ArrayInitializerExpr array = value.asArrayInitializerExpr();
for (Expression element : array.getValues()) {
if (element.isAnnotationExpr()) {
AnnotationExpr inner = element.asAnnotationExpr();
if (isTagAnnotationName(inner.getNameAsString())) {
extractTagValue(inner).ifPresent(tags::add);
}
}
}
} else if (value.isAnnotationExpr()) {
AnnotationExpr inner = value.asAnnotationExpr();
if (isTagAnnotationName(inner.getNameAsString())) {
extractTagValue(inner).ifPresent(tags::add);
}
}
return tags;
}
/**
* Converts an annotation value expression to tag text.
*
* <p>
* String literals are returned as their unescaped string value. Other
* expressions are returned using {@link Expression#toString()}.
* </p>
*
* @param expr expression to convert; may be {@code null}
* @return converted tag text (never {@code null})
*/
private static String expressionToTagText(Expression expr) {
if (expr == null) {
return "";
}
if (expr.isStringLiteralExpr()) {
return expr.asStringLiteralExpr().asString();
}
return expr.toString();
}
/**
* Computes the lines of code (LOC) for a method declaration using its source
* range.
*
* @param method method declaration
* @return inclusive LOC computed from the source range; {@code 0} if range is
* not available
*/
private static int countLOC(MethodDeclaration method) {
if (method.getRange().isPresent()) {
return method.getRange().get().end.line - method.getRange().get().begin.line + 1;
}
return 0;
}
}

View File

@@ -0,0 +1,18 @@
/**
* Provides the {@code MethodAtlasApp} command-line utility for scanning Java
* source trees for JUnit test methods and emitting per-method statistics.
*
* <p>
* The primary entry point is {@link org.egothor.methodatlas.MethodAtlasApp}.
* </p>
*
* <p>
* Output modes:
* </p>
* <ul>
* <li>CSV (default): {@code fqcn,method,loc,tags}</li>
* <li>Plain text: enabled by {@code -plain} as the first command-line
* argument</li>
* </ul>
*/
package org.egothor.methodatlas;

View File

@@ -0,0 +1,242 @@
package org.egothor.methodatlas;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/**
* End-to-end tests for {@link MethodAtlasApp} output formats (CSV default,
* -plain).
*
* These tests copy predefined Java fixture files from
* src/test/resources/fixtures into a temporary directory and run
* MethodAtlasApp.main(...) against that directory, asserting the detected
* methods, LOC, and extracted @Tag values.
*/
public class MethodAtlasAppTest {
@Test
public void csvMode_detectsMethodsLocAndTags(@TempDir Path tempDir) throws Exception {
copyFixture(tempDir, "SampleOneTest.java");
copyFixture(tempDir, "AnotherTest.java");
String output = runAppCapturingStdout(new String[] { tempDir.toString() });
List<String> lines = nonEmptyLines(output);
assertTrue(lines.size() >= 3, "Expected header + at least 2 records, got: " + lines.size());
assertEquals("fqcn,method,loc,tags", lines.get(0));
Map<String, CsvRow> rows = new HashMap<>();
for (int i = 1; i < lines.size(); i++) {
CsvRow row = parseCsvRow(lines.get(i));
rows.put(row.fqcn + "#" + row.method, row);
}
assertCsvRow(rows, "com.acme.tests.SampleOneTest", "alpha", 8, List.of("fast", "crypto"));
assertCsvRow(rows, "com.acme.tests.SampleOneTest", "beta", 6, List.of("param"));
assertCsvRow(rows, "com.acme.tests.SampleOneTest", "gamma", 4, List.of("nested1", "nested2"));
assertCsvRow(rows, "com.acme.other.AnotherTest", "delta", 3, List.of());
}
@Test
public void plainMode_detectsMethodsLocAndTags(@TempDir Path tempDir) throws Exception {
copyFixture(tempDir, "SampleOneTest.java");
copyFixture(tempDir, "AnotherTest.java");
String output = runAppCapturingStdout(new String[] { "-plain", tempDir.toString() });
List<String> lines = nonEmptyLines(output);
assertTrue(lines.size() >= 4, "Expected at least 4 method lines, got: " + lines.size());
Map<String, PlainRow> rows = new HashMap<>();
for (String line : lines) {
PlainRow row = parsePlainRow(line);
rows.put(row.fqcn + "#" + row.method, row);
}
assertPlainRow(rows, "com.acme.tests.SampleOneTest", "alpha", 8, "fast;crypto");
assertPlainRow(rows, "com.acme.tests.SampleOneTest", "beta", 6, "param");
assertPlainRow(rows, "com.acme.tests.SampleOneTest", "gamma", 4, "nested1;nested2");
assertPlainRow(rows, "com.acme.other.AnotherTest", "delta", 3, "-");
}
private static void assertCsvRow(Map<String, CsvRow> rows, String fqcn, String method, int expectedLoc,
List<String> expectedTags) {
CsvRow row = rows.get(fqcn + "#" + method);
assertNotNull(row, "Missing row for " + fqcn + "#" + method);
assertEquals(expectedLoc, row.loc, "LOC mismatch for " + fqcn + "#" + method);
assertEquals(expectedTags, row.tags, "Tags mismatch for " + fqcn + "#" + method);
}
private static void assertPlainRow(Map<String, PlainRow> rows, String fqcn, String method, int expectedLoc,
String expectedTagsText) {
PlainRow row = rows.get(fqcn + "#" + method);
assertNotNull(row, "Missing row for " + fqcn + "#" + method);
assertEquals(expectedLoc, row.loc, "LOC mismatch for " + fqcn + "#" + method);
assertEquals(expectedTagsText, row.tagsText, "Tags mismatch for " + fqcn + "#" + method);
}
private static void copyFixture(Path destDir, String fixtureFileName) throws IOException {
String resourcePath = "/fixtures/" + fixtureFileName + ".txt";
try (InputStream in = MethodAtlasAppTest.class.getResourceAsStream(resourcePath)) {
assertNotNull(in, "Missing test resource: " + resourcePath);
Path out = destDir.resolve(fixtureFileName);
Files.copy(in, out);
}
}
private static String runAppCapturingStdout(String[] args) throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PrintStream previous = System.out;
try (PrintStream ps = new PrintStream(baos, true, StandardCharsets.UTF_8)) {
System.setOut(ps);
MethodAtlasApp.main(args);
} finally {
System.setOut(previous);
}
return baos.toString(StandardCharsets.UTF_8);
}
private static List<String> nonEmptyLines(String text) {
String[] parts = text.split("\\R");
List<String> lines = new ArrayList<>();
for (String p : parts) {
String trimmed = p.trim();
if (!trimmed.isEmpty()) {
lines.add(trimmed);
}
}
return lines;
}
private static CsvRow parseCsvRow(String line) {
List<String> fields = parseCsvFields(line);
assertEquals(4, fields.size(), "Expected 4 CSV fields, got " + fields.size() + " from: " + line);
CsvRow row = new CsvRow();
row.fqcn = fields.get(0);
row.method = fields.get(1);
row.loc = Integer.parseInt(fields.get(2));
String tagsText = fields.get(3);
row.tags = splitTags(tagsText);
return row;
}
private static List<String> splitTags(String tagsText) {
List<String> tags = new ArrayList<>();
if (tagsText == null || tagsText.isEmpty()) {
return tags;
}
String[] parts = tagsText.split(";");
for (String p : parts) {
String t = p.trim();
if (!t.isEmpty()) {
tags.add(t);
}
}
return tags;
}
/**
* Minimal CSV parser that supports commas and quotes.
*/
private static List<String> parseCsvFields(String line) {
List<String> out = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
int i = 0;
while (i < line.length()) {
char ch = line.charAt(i);
if (inQuotes) {
if (ch == '\"') {
if (i + 1 < line.length() && line.charAt(i + 1) == '\"') {
current.append('\"');
i += 2;
continue;
}
inQuotes = false;
i++;
continue;
}
current.append(ch);
i++;
continue;
}
if (ch == '\"') {
inQuotes = true;
i++;
continue;
}
if (ch == ',') {
out.add(current.toString());
current.setLength(0);
i++;
continue;
}
current.append(ch);
i++;
}
out.add(current.toString());
return out;
}
private static PlainRow parsePlainRow(String line) {
Pattern p = Pattern.compile("^(.*),\\s+(.*),\\s+LOC=(\\d+),\\s+TAGS=(.*)$");
Matcher m = p.matcher(line);
assertTrue(m.matches(), "Unexpected plain output line: " + line);
PlainRow row = new PlainRow();
row.fqcn = m.group(1).trim();
row.method = m.group(2).trim();
row.loc = Integer.parseInt(m.group(3));
row.tagsText = m.group(4).trim();
return row;
}
private static final class CsvRow {
private String fqcn;
private String method;
private int loc;
private List<String> tags;
}
private static final class PlainRow {
private String fqcn;
private String method;
private int loc;
private String tagsText;
}
}

View File

@@ -0,0 +1,10 @@
package com.acme.other;
import org.junit.jupiter.api.RepeatedTest;
public class AnotherTest {
@RepeatedTest(2)
void delta() {
}
}

View File

@@ -0,0 +1,31 @@
package com.acme.tests;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Tags;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
public class SampleOneTest {
@Test
@Tag("fast")
@Tag("crypto")
void alpha() {
int a = 1;
int b = 2;
int c = a + b;
}
@ParameterizedTest
@ValueSource(ints = { 1, 2 })
@Tag("param")
void beta(int x) {
// single line
}
@Test
@Tags({ @Tag("nested1"), @Tag("nested2") })
void gamma() {
}
}