From 30587715ee5b1c7e8c04b038d22c971389fc1c42 Mon Sep 17 00:00:00 2001 From: Danny Thomas Date: Wed, 16 Sep 2026 19:03:29 +1000 Subject: [PATCH] Support provider-managed documentation browsing --- .../tools/jdocserver/test/JdocServerTest.java | 17 ++++- .../tools/jdocserver/CompilationContext.java | 23 +++++-- .../jdocserver/DocumentationHandler.java | 69 ++++++++++++++----- .../netflix/tools/jdocserver/JdocServer.java | 22 ++++-- .../module-info.java | 3 + 5 files changed, 103 insertions(+), 31 deletions(-) diff --git a/src/com.netflix.tools.jdocserver.test/com/netflix/tools/jdocserver/test/JdocServerTest.java b/src/com.netflix.tools.jdocserver.test/com/netflix/tools/jdocserver/test/JdocServerTest.java index aba5281..a377513 100644 --- a/src/com.netflix.tools.jdocserver.test/com/netflix/tools/jdocserver/test/JdocServerTest.java +++ b/src/com.netflix.tools.jdocserver.test/com/netflix/tools/jdocserver/test/JdocServerTest.java @@ -133,8 +133,8 @@ void commandHelpDescribesServerOptions() { .contains("--port "), "help omits the port option"); assertTrue(output.toString() - .contains("--browse"), - "help omits the browse option"); + .contains("--browse[=]"), + "help omits the browse target option"); assertTrue(output.toString() .contains("--version"), "help omits the version option"); @@ -143,6 +143,17 @@ void commandHelpDescribesServerOptions() { "help advertises classpath documentation"); } + @Test + void commandRejectsAnEmptyBrowseTarget() { + var output = new StringWriter(); + var error = new StringWriter(); + + int result = new JdocServer().run(new PrintWriter(output), new PrintWriter(error), "--help", "--browse="); + + assertEquals(2, result); + assertTrue(error.toString().contains("--browse requires a type"), error.toString()); + } + @Test void commandReportsStartupDetails() throws Exception { Path temporary = Files.createTempDirectory("jdocserver-command-test-"); @@ -336,7 +347,7 @@ public final class Api {} .followRedirects(Redirect.NORMAL) .build(); HttpResponse type = get(client, server.uri(handler.typeUri("example.module.Api"))); - assertEquals(200, type.statusCode(), "module type request failed"); + assertEquals(200, type.statusCode(), "module type request failed: " + type.body()); assertTrue(type.body() .contains("Class Api"), "module documentation is missing"); diff --git a/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/CompilationContext.java b/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/CompilationContext.java index 8e15523..354bec8 100644 --- a/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/CompilationContext.java +++ b/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/CompilationContext.java @@ -40,12 +40,15 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.ServiceLoader; +import java.util.ServiceLoader.Provider; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.zip.ZipException; +import javax.tools.JavaCompiler; import javax.tools.JavaFileObject; import javax.tools.JavaFileObject.Kind; import javax.tools.SimpleJavaFileObject; @@ -56,9 +59,9 @@ import com.sun.source.tree.ProvidesTree; import com.sun.source.tree.RequiresTree; import com.sun.source.util.JavacTask; -import com.sun.tools.javac.api.JavacTool; final class CompilationContext { + private final Provider compiler; private final List toolArguments; private final List modulePath; private final List sourcePath; @@ -69,6 +72,7 @@ final class CompilationContext { private final boolean defaultJdk; private CompilationContext( + Provider compiler, List toolArguments, List modulePath, List sourcePath, @@ -77,6 +81,7 @@ private CompilationContext( Path system, Path systemSources, boolean defaultJdk) { + this.compiler = compiler; this.toolArguments = List.copyOf(toolArguments); this.modulePath = List.copyOf(modulePath); this.sourcePath = List.copyOf(sourcePath); @@ -87,7 +92,11 @@ private CompilationContext( this.defaultJdk = defaultJdk; } - static CompilationContext parse(List arguments) { + static CompilationContext parse(List arguments) throws IOException { + Provider compiler = ServiceLoader.load(JavaCompiler.class) + .stream() + .findFirst() + .orElseThrow(() -> new IOException("The running JDK does not provide javac")); var retained = new ArrayList(); var classPath = new ArrayList(); var modulePath = new ArrayList(); @@ -162,8 +171,8 @@ static CompilationContext parse(List arguments) { if (!defaultJdk && moduleSourcePath.isEmpty()) { throw new IllegalArgumentException("no modular documentation sources were provided"); } - return new CompilationContext(retained, modulePath, sourcePath, moduleSourcePath, requestedModules, system, - systemSources, defaultJdk); + return new CompilationContext(compiler, retained, modulePath, sourcePath, moduleSourcePath, requestedModules, + system, systemSources, defaultJdk); } boolean defaultJdk() { @@ -344,7 +353,7 @@ private List discoverSourceModules() throws IOException { return List.copyOf(modules.values()); } - private static void addSourceModule(Path root, Map modules) throws IOException { + private void addSourceModule(Path root, Map modules) throws IOException { byte[] source = read(root, "module-info.java"); if (source == null) { return; @@ -412,7 +421,7 @@ private Optional sourceModule(String name) throws IOException return Optional.empty(); } - private static ModuleDescriptor parseModuleDescriptor(byte[] bytes) throws IOException { + private ModuleDescriptor parseModuleDescriptor(byte[] bytes) throws IOException { String content = new String(bytes, StandardCharsets.UTF_8); JavaFileObject source = new SimpleJavaFileObject(URI.create("memory:///module-info.java"), Kind.SOURCE) { @Override @@ -420,7 +429,7 @@ public CharSequence getCharContent(boolean ignoreEncodingErrors) { return content; } }; - JavacTask task = JavacTool.create().getTask(null, null, null, List.of("-proc:none"), null, List.of(source)); + JavacTask task = (JavacTask) compiler.get().getTask(null, null, null, List.of("-proc:none"), null, List.of(source)); ModuleTree module = null; for (var unit : task.parse()) { if (unit.getModule() != null) { diff --git a/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/DocumentationHandler.java b/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/DocumentationHandler.java index 4d1140c..5770921 100644 --- a/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/DocumentationHandler.java +++ b/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/DocumentationHandler.java @@ -41,6 +41,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.ServiceLoader; +import java.util.ServiceLoader.Provider; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.CompletableFuture; @@ -77,7 +79,12 @@ */ public final class DocumentationHandler implements HttpHandler, AutoCloseable { private static final OptionChecker OPTION_CHECKER = option -> { - DocumentationTool documentation = new JavadocTool(); + DocumentationTool documentation = ServiceLoader.load(DocumentationTool.class) + .findFirst() + .orElse(null); + if (documentation == null) { + return -1; + } int operands = documentation.isSupportedOption(option); if (operands >= 0) { return operands; @@ -90,6 +97,7 @@ public final class DocumentationHandler implements HttpHandler, AutoCloseable { }; private final CompilationContext context; + private final Provider documentationTool; private final List inputArguments; private final Path workspace; private final Path generatedRoot; @@ -101,6 +109,10 @@ public final class DocumentationHandler implements HttpHandler, AutoCloseable { private volatile boolean closed; private DocumentationHandler(List arguments) throws IOException { + documentationTool = ServiceLoader.load(DocumentationTool.class) + .stream() + .findFirst() + .orElseThrow(() -> new IOException("The running JDK does not provide javadoc")); inputArguments = expandArgumentFiles(arguments); context = CompilationContext.parse(inputArguments); workspace = Files.createTempDirectory("jdocserver-"); @@ -198,6 +210,17 @@ CompletableFuture startOverview() { */ @Override public void handle(HttpExchange exchange) throws IOException { + try { + handleRequest(exchange); + } catch (IOException failure) { + sendHtml(exchange, 500, errorPage(failure), exchange.getRequestMethod().equals("HEAD")); + } catch (RuntimeException | Error failure) { + failure.printStackTrace(); + throw failure; + } + } + + private void handleRequest(HttpExchange exchange) throws IOException { if (closed) { sendText(exchange, 503, "Documentation handler is closed"); return; @@ -221,22 +244,18 @@ public void handle(HttpExchange exchange) throws IOException { handleType(exchange, method.equals("HEAD")); return; } - try { - ensureOverview(); - String relative = path.equals("/") ? "index.html" : path.substring(1); - if (serveFile(exchange, overviewRoot, relative, method.equals("HEAD"))) { + ensureOverview(); + String relative = path.equals("/") ? "index.html" : path.substring(1); + if (serveFile(exchange, overviewRoot, relative, method.equals("HEAD"))) { + return; + } + if (relative.endsWith(".html")) { + String typeName = typeNameFromOverviewPath(relative); + if (typeName != null && materializeOverviewClass(typeName, relative) && serveFile(exchange, overviewRoot, relative, method.equals("HEAD"))) { return; } - if (relative.endsWith(".html")) { - String typeName = typeNameFromOverviewPath(relative); - if (typeName != null && materializeOverviewClass(typeName, relative) && serveFile(exchange, overviewRoot, relative, method.equals("HEAD"))) { - return; - } - } - sendText(exchange, 404, "Not found"); - } catch (IOException failure) { - sendHtml(exchange, 500, errorPage(failure), method.equals("HEAD")); } + sendText(exchange, 404, "Not found"); } /** @@ -274,9 +293,18 @@ private void ensureOverview() throws IOException { overviewFuture().join(); } catch (CompletionException failure) { Throwable cause = failure.getCause(); + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } if (cause instanceof IOException io) { throw io; } + if (cause instanceof RuntimeException runtime) { + throw runtime; + } + if (cause instanceof Error error) { + throw error; + } throw new IOException("Overview generation failed", cause); } } @@ -365,7 +393,7 @@ private void generateOverview() throws IOException { arguments.add("-d"); arguments.add(overviewRoot.toString()); - var javadoc = new JavadocTool(); + DocumentationTool javadoc = documentationTool.get(); var diagnostics = new StringWriter(); try (var systemSources = FileSystems.newFileSystem(context.systemSources()); var fileManager = javadoc.getStandardFileManager(null, null, StandardCharsets.UTF_8)) { @@ -606,9 +634,18 @@ private GeneratedDocumentation documentation(String name) throws IOException { .join(); } catch (CompletionException failure) { Throwable cause = failure.getCause(); + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } if (cause instanceof IOException io) { throw io; } + if (cause instanceof RuntimeException runtime) { + throw runtime; + } + if (cause instanceof Error error) { + throw error; + } throw new IOException("Documentation generation failed", cause); } } @@ -663,7 +700,7 @@ private GeneratedDocumentation generate(String requestedName) throws IOException Files.createDirectories(output); Files.write(sourceFile, source.bytes()); - var javadoc = new JavadocTool(); + DocumentationTool javadoc = documentationTool.get(); var arguments = new ArrayList<>(context.javadocArguments(type, sourceRoot)); if (documentationBase != null && Files.isRegularFile(overviewRoot.resolve("element-list"))) { arguments.add("-linkoffline"); diff --git a/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/JdocServer.java b/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/JdocServer.java index 6cf7a19..b10eb1f 100644 --- a/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/JdocServer.java +++ b/src/com.netflix.tools.jdocserver/com/netflix/tools/jdocserver/JdocServer.java @@ -24,6 +24,7 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; @@ -106,7 +107,11 @@ public int run(PrintWriter out, PrintWriter err, String... arguments) { out.println("URL " + uri); out.flush(); if (options.browse()) { - browse(uri); + URI target = options.browseType() + .map(handler::typeUri) + .map(uri::resolve) + .orElse(uri); + browse(target); } var shutdownHook = new Thread(running::close, "jdocserver-shutdown"); @@ -194,7 +199,7 @@ private static void printHelp(PrintWriter out) { Server options: -b, --bind-address
Address to bind (default: 127.0.0.1) --port Port to listen on (default: 8000) - --browse Browse the documentation index + --browse[=] Browse the index or a qualified type -h, --help Print this help message --version Print version information @@ -216,11 +221,12 @@ private static void printHelp(PrintWriter out) { } private record Options(InetSocketAddress address, boolean defaultBinding, boolean browse, - boolean help, List documentationArguments) { + Optional browseType, boolean help, List documentationArguments) { static Options parse(String[] arguments) { String bindAddress = null; String port = null; boolean browse = false; + String browseType = null; boolean help = false; var documentation = new ArrayList(); for (int i = 0; i < arguments.length; i++) { @@ -245,14 +251,20 @@ static Options parse(String[] arguments) { bindAddress = argument.substring("--bind-address=".length()); } else if (argument.startsWith("--port=")) { port = argument.substring("--port=".length()); + } else if (argument.startsWith("--browse=")) { + browseType = argument.substring("--browse=".length()); + if (browseType.isBlank()) { + throw new IllegalArgumentException("--browse requires a type after ="); + } + browse = true; } else { documentation.add(argument); } } } } - return new Options(parseAddress(bindAddress, port), bindAddress == null, browse, help, - List.copyOf(documentation)); + return new Options(parseAddress(bindAddress, port), bindAddress == null, browse, + Optional.ofNullable(browseType), help, List.copyOf(documentation)); } private static InetSocketAddress parseAddress(String address, String value) { diff --git a/src/com.netflix.tools.jdocserver/module-info.java b/src/com.netflix.tools.jdocserver/module-info.java index 0a1320a..23320e6 100644 --- a/src/com.netflix.tools.jdocserver/module-info.java +++ b/src/com.netflix.tools.jdocserver/module-info.java @@ -52,5 +52,8 @@ exports com.netflix.tools.jdocserver; exports com.netflix.tools.jdocserver.internal to jdk.javadoc; + uses javax.tools.DocumentationTool; + uses javax.tools.JavaCompiler; + provides java.util.spi.ToolProvider with com.netflix.tools.jdocserver.JdocServer; }