diff --git a/Build.java b/Build.java index e4bb166..ab6a191 100644 --- a/Build.java +++ b/Build.java @@ -10,6 +10,20 @@ import java.util.stream.Collectors; public class Build { static final String program = "jtags"; + static void printUsage() { + System.err.println( + """ + Usage: java Build [args...] + Subcommands: + build Build %1$s + run [args...] Run %1$s with arguments + package Package %1$s to a JAR file + [file] Output JAR filename [Default: Jtags.jar] + test Run %1$s tests + """ + .formatted(program)); + } + public static void main(String[] args) { rebuildSelf(Build.class, args); @@ -47,6 +61,11 @@ public class Build { packageJar(Optional.ofNullable(arguments.poll())); break; + case "test": + buildJtags(mainClass, sourcePaths, classPaths); + runTests(); + break; + default: logger.severe("Unknown subcommand: " + subcommand); printUsage(); @@ -77,16 +96,17 @@ public class Build { "."); } - static void printUsage() { - System.err.println( - """ - Usage: java Build [args...] - Subcommands: - build Build %1$s - run [args...] Run %1$s with arguments - package Package %1$s to a JAR file - [file] Output JAR filename [Default: Jtags.jar] - """ - .formatted(program)); + static void runTests() { + String mainClass = "TestJtags"; + String[] sourcePaths = glob("src/test/java/**.java"); + + if (classNeedsRebuild(mainClass, sourcePaths)) { + logger.info("Compiling %s...".formatted(mainClass)); + if (!compileJava(sourcePaths)) { + System.exit(1); + } + } + + System.exit(runJava(new String[0], new String[] {"-enableassertions"}, mainClass)); } } diff --git a/src/main/java/xyz/naofal/jtags/TagCollector.java b/src/main/java/xyz/naofal/jtags/TagCollector.java index 5825850..a067429 100644 --- a/src/main/java/xyz/naofal/jtags/TagCollector.java +++ b/src/main/java/xyz/naofal/jtags/TagCollector.java @@ -6,7 +6,7 @@ import com.sun.source.tree.CompilationUnitTree; import com.sun.source.util.JavacTask; import com.sun.source.util.Trees; import java.io.IOException; -import java.util.PriorityQueue; +import java.util.AbstractQueue; import javax.tools.JavaCompiler; import javax.tools.JavaFileObject; import javax.tools.StandardJavaFileManager; @@ -17,7 +17,7 @@ public class TagCollector { static JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); - public static PriorityQueue collectTags(Options options) { + public static AbstractQueue collectTags(Options options) { try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null)) { Iterable compilationUnits = @@ -26,11 +26,14 @@ public class TagCollector { JavacTask task = (JavacTask) compiler.getTask(null, fileManager, null, null, null, compilationUnits); Iterable trees = task.parse(); - TreeVisitorContext context = new TreeVisitorContext(Trees.instance(task)); + TreeVisitor treeVisitor = new TreeVisitor(options); + TreeVisitorContext context = new TreeVisitorContext(Trees.instance(task)); + for (CompilationUnitTree compilationUnitTree : trees) { treeVisitor.scan(compilationUnitTree, context); } + return treeVisitor.tags; } catch (IOException ex) { diff --git a/src/main/java/xyz/naofal/jtags/TagsWriter.java b/src/main/java/xyz/naofal/jtags/TagsWriter.java index 6af902d..99359ea 100644 --- a/src/main/java/xyz/naofal/jtags/TagsWriter.java +++ b/src/main/java/xyz/naofal/jtags/TagsWriter.java @@ -9,20 +9,20 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.Writer; -import java.util.PriorityQueue; +import java.util.AbstractQueue; import xyz.naofal.jtags.Jtags.Options; public record TagsWriter(Options options) { private static final int MAX_PATTERN_LENGTH = 96; - public boolean writeTagsFile(PriorityQueue tags) { + public boolean writeTagsFile(AbstractQueue tags) { try (var outputStream = new FileOutputStream(options().output.toFile()); var writer = new OutputStreamWriter(outputStream); ) { writer.write( """ - !_TAG_FILE_ENCODING\tutf-8\t + !_TAG_FILE_ENCODING\tutf-8 !_TAG_FILE_SORTED\t2\t/0=unsorted, 1=sorted, 2=foldcase/ """); @@ -48,7 +48,7 @@ public record TagsWriter(Options options) { ? tag.location().toString() : options.output.getParent().toAbsolutePath().relativize(tag.location()).toString()); writer.write("\t/^"); - writer.write(tag.line().substring(0, Math.min(tag.line().length(), MAX_PATTERN_LENGTH))); + writer.write(tag.line()); writer.write("$/;\"\t"); writer.write( switch (tag.kind()) { diff --git a/src/main/java/xyz/naofal/jtags/TreeVisitor.java b/src/main/java/xyz/naofal/jtags/TreeVisitor.java index a70c922..8dc1ba4 100644 --- a/src/main/java/xyz/naofal/jtags/TreeVisitor.java +++ b/src/main/java/xyz/naofal/jtags/TreeVisitor.java @@ -9,16 +9,17 @@ import com.sun.source.tree.PackageTree; import com.sun.source.tree.Tree; import com.sun.source.tree.VariableTree; import com.sun.source.util.TreePathScanner; +import java.util.AbstractQueue; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.PriorityQueue; +import java.util.concurrent.PriorityBlockingQueue; import javax.lang.model.element.Modifier; import xyz.naofal.jtags.Jtags.Options; public class TreeVisitor extends TreePathScanner { public final Options options; - public final PriorityQueue tags = new PriorityQueue<>(); + public final AbstractQueue tags = new PriorityBlockingQueue<>(); public TreeVisitor(Options options) { this.options = options; @@ -104,7 +105,7 @@ public class TreeVisitor extends TreePathScanner { @Override public Void visitMethod(MethodTree node, TreeVisitorContext p) { - ClassTree enclosingType = (ClassTree)getCurrentPath().getParentPath().getLeaf(); + ClassTree enclosingType = (ClassTree) getCurrentPath().getParentPath().getLeaf(); if (options.excludeNonPublic && !node.getModifiers().getFlags().contains(Modifier.PUBLIC) diff --git a/src/main/java/xyz/naofal/jtags/example/Example.java b/src/main/java/xyz/naofal/jtags/example/Example.java deleted file mode 100644 index 3aefb39..0000000 --- a/src/main/java/xyz/naofal/jtags/example/Example.java +++ /dev/null @@ -1,29 +0,0 @@ -package xyz.naofal.jtags.example; - -@interface T { - static int t = 1; -} - -public class Example { - - public static void lorem( - String string1, - String string2, - String string3, - String string4, - String string5, - String string6) { - - Object o = - new Object() { - void f() {} - ; - }; - - class T { - void f() {} - - private void fp() {} - } - } -} diff --git a/src/test/java/TestJtags.java b/src/test/java/TestJtags.java new file mode 100644 index 0000000..eab0807 --- /dev/null +++ b/src/test/java/TestJtags.java @@ -0,0 +1,34 @@ +import static notest.Test.Util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import notest.Test; + +class TestJtags { + public static final String Jtags = "xyz.naofal.jtags.Jtags"; + + public static void main(String[] args) { + System.exit(Util.runTests(TestJtags.class) ? 0 : 1); + } + + @Test + static void basicExample() throws IOException { + Path file = Files.createTempFile("jtags", null); + Util.runJava( + new String[0], + new String[] {"-Dlogger.level=CONFIG"}, + Jtags, + "-o", + file.toString(), + "src/test/java/examples/BasicExample.java"); + assert Files.readString(file) + .trim() + .equals( + """ + ABC + """ + .trim()) + : "Unexpected Result:\n" + Files.readString(file); + } +} diff --git a/src/test/java/examples/BasicExample.java b/src/test/java/examples/BasicExample.java new file mode 100644 index 0000000..9ad45c8 --- /dev/null +++ b/src/test/java/examples/BasicExample.java @@ -0,0 +1,28 @@ +package examples; + +public class BasicExample { + + private boolean method1() { + return true; + } + + public static void method2( + String string1, + String string2, + String string3, + String string4, + String string5, + String string6) { + + Object innerObject = + new Object() { + void method2() {} + }; + + class InnerClass { + void method3() {} + + private void method4() {} + } + } +} diff --git a/src/test/java/notest/Test.java b/src/test/java/notest/Test.java new file mode 100644 index 0000000..7762dfd --- /dev/null +++ b/src/test/java/notest/Test.java @@ -0,0 +1,214 @@ +/* + * NoTest - Simple testing utilities + * + * MIT License + * + * Copyright (c) 2025 Naofal Helal + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package notest; + +import java.io.File; +import java.io.IOException; +import java.lang.ProcessBuilder.Redirect; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.InvocationTargetException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.IntSummaryStatistics; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** Marks a method as a test method */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Test { + + /** Utility methods for performing tests */ + public static class Util { + public static Path javaHome = Paths.get(System.getProperty("java.home")); + public static String javaClassPath = System.getProperty("java.class.path"); + public static String javaBin = javaHome.resolve("bin", "java").toString(); + + /** Runs all tests in {@code testClass} */ + public static boolean runTests(Class testClass) { + IntSummaryStatistics stats = + Arrays.stream(testClass.getDeclaredMethods()) + .filter(it -> it.isAnnotationPresent(Test.class)) + .map( + it -> { + try { + System.err.println( + "╭" + center(" Test " + it.getName() + " ", 48, '─') + "╮"); + it.setAccessible(true); + it.invoke(null); + System.err.println( + "╰" + center(" " + it.getName() + ": SUCCESS ", 48, '─') + "╯"); + System.err.println(); + return 1; + } catch (IllegalAccessException ex) { + System.err.println( + "╰" + + center(" ERROR: could not run " + it.getName() + " ", 48, '─') + + "╯"); + System.err.println(ex.toString()); + System.err.println(); + return 0; + } catch (InvocationTargetException ex) { + System.err.println( + "├" + + center( + " " + ex.getTargetException().getClass().getSimpleName() + " ", + 48, + '─') + + "┤"); + Optional.ofNullable(ex.getTargetException().getMessage()) + .orElse("") + .lines() + .forEach(line -> System.err.println("│ " + line)); + System.err.println( + "╰" + center(" " + it.getName() + ": FAIL ", 48, '─') + "╯"); + System.err.println(); + return 0; + } + }) + .collect(Collectors.summarizingInt(it -> it)); + + System.err.printf( + "Ran %d tests; %d Succeeded, %d Failed%n" + .formatted(stats.getCount(), stats.getSum(), stats.getCount() - stats.getSum())); + + return stats.getCount() == stats.getSum(); + } + + /** + * Runs a Java class + * + * @param mainClass The fully qualified class name, e.g. {@code com.example.MyClass$MySubClass} + * @param args Arguments to pass to the main method + * @return Exit status code + */ + public static int runJava(String mainClass, String... args) { + return runJava(new String[0], new String[0], mainClass, args); + } + + /** + * Runs a Java class + * + * @param additionalClassPaths Additional class paths to pass to the compiler + * @param javaArguments Arguments to pass to the java binary + * @param mainClass The fully qualified class name, e.g. {@code com.example.MyClass$MySubClass} + * @param args Arguments to pass to the main method + * @return Exit status code + */ + public static int runJava( + String[] additionalClassPaths, String[] javaArguments, String mainClass, String... args) { + String pathSeparator = System.getProperty("path.separator"); + + String classPaths = + String.join( + pathSeparator, + Stream.concat(Stream.of(javaClassPath), Arrays.stream(additionalClassPaths)) + .toArray(String[]::new)); + + Stream commandLineStream = + Stream.of( + Stream.of(javaBin.toString(), "-cp", classPaths), + Arrays.stream(javaArguments), + Stream.of(mainClass), + Arrays.stream(args)) + .flatMap(it -> it); + + return command(commandLineStream.toArray(String[]::new)); + } + + /** + * Runs a shell command. May throw an {@code IOException} + * + * @return Exit status code + * @throws Exception + */ + public static int command(String... command) { + ProcessBuilder pb = + new ProcessBuilder(command) + .redirectInput(Redirect.INHERIT) + .redirectOutput(Redirect.INHERIT) + .redirectError(Redirect.INHERIT); + Process process; + try { + process = pb.start(); + process.waitFor(); + return process.exitValue(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + /** + * Returns file paths that match a {@code globPattern} + * + * @see java.nio.file.FileSystem#getPathMatcher(String) getPathMatcher + */ + public static String[] glob(String globPattern) { + Path cwd = Paths.get("."); + PathMatcher pathMatcher = + FileSystems.getDefault() + .getPathMatcher( + String.join("", "glob:", cwd.toString(), File.separator, globPattern)); + try (@SuppressWarnings("unused") + Stream paths = + Files.find( + cwd, + Integer.MAX_VALUE, + (path, basicFileAttributes) -> pathMatcher.matches(path))) { + return paths.map(Path::toString).toArray(String[]::new); + } catch (IOException ex) { + throw new RuntimeException("Error during glob pattern matching", ex); + } + } + + /** + * Pads string to center. + * + *

From stackoverflow.com/a/8155547 + */ + private static String center(String s, int size, char pad) { + if (s == null || size <= s.length()) return s; + + StringBuilder sb = new StringBuilder(size); + for (int i = 0; i < (size - s.length()) / 2; i++) { + sb.append(pad); + } + sb.append(s); + while (sb.length() < size) { + sb.append(pad); + } + return sb.toString(); + } + } +}