[jnigen] JAR handling improvements (https://github.com/dart-lang/jnigen/issues/220)

* Move summarizer invocation into summary.dart for better testability.
* Support reading source JARs (https://github.com/dart-lang/jnigen/issues/208)
* Fix an issue where the summarizer failed when providing only classes and no source (https://github.com/dart-lang/jnigen/issues/147)
diff --git a/pkgs/jnigen/bin/jnigen.dart b/pkgs/jnigen/bin/jnigen.dart
index 518ba11..be04b58 100644
--- a/pkgs/jnigen/bin/jnigen.dart
+++ b/pkgs/jnigen/bin/jnigen.dart
@@ -11,10 +11,8 @@
     config = Config.parseArgs(args);
   } on ConfigException catch (e) {
     log.fatal(e);
-    return;
   } on FormatException catch (e) {
     log.fatal(e);
-    return;
   }
   await generateJniBindings(config);
 }
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/Main.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/Main.java
index d636f65..07f4430 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/Main.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/Main.java
@@ -4,134 +4,44 @@
 
 package com.github.dart_lang.jnigen.apisummarizer;
 
-import static com.github.dart_lang.jnigen.apisummarizer.util.ExceptionUtil.wrapCheckedException;
-
 import com.github.dart_lang.jnigen.apisummarizer.disasm.AsmSummarizer;
 import com.github.dart_lang.jnigen.apisummarizer.doclet.SummarizerDoclet;
 import com.github.dart_lang.jnigen.apisummarizer.elements.ClassDecl;
+import com.github.dart_lang.jnigen.apisummarizer.util.InputStreamProvider;
 import com.github.dart_lang.jnigen.apisummarizer.util.JsonUtil;
 import com.github.dart_lang.jnigen.apisummarizer.util.Log;
-import com.github.dart_lang.jnigen.apisummarizer.util.StreamUtil;
-import java.io.*;
-import java.util.*;
-import java.util.jar.JarFile;
-import java.util.stream.Collectors;
-import java.util.zip.ZipEntry;
+import com.github.dart_lang.jnigen.apisummarizer.util.SearchUtil;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
 import javax.tools.DocumentationTool;
+import javax.tools.JavaFileObject;
 import javax.tools.ToolProvider;
 import jdk.javadoc.doclet.Doclet;
-import org.apache.commons.cli.*;
 
 public class Main {
   public enum Backend {
-    // Produce API descriptions from source files using Doclet API.
+    /** Produce API descriptions from source files using Doclet API. */
     DOCLET,
-    // Produce API descriptions from class files under classpath.
+    /** Produce API descriptions from class files under classpath. */
     ASM,
-    // Prefer source but fall back to JARs in classpath if sources not found.
+    /** Prefer source but fall back to JARs in classpath if sources not found. */
     AUTO,
   }
 
-  public static class SummarizerOptions {
-    String sourcePath;
-    String classPath;
-    boolean useModules;
-    Backend backend;
-    String modulesList;
-    boolean addDependencies;
-    String toolArgs;
-    boolean verbose;
-    String outputFile;
-    String[] args;
-
-    SummarizerOptions() {}
-
-    public static SummarizerOptions fromCommandLine(CommandLine cmd) {
-      var opts = new SummarizerOptions();
-      opts.sourcePath = cmd.getOptionValue("sources", ".");
-      var backendString = cmd.getOptionValue("backend", "auto");
-      opts.backend = Backend.valueOf(backendString.toUpperCase());
-      opts.classPath = cmd.getOptionValue("classes", null);
-      opts.useModules = cmd.hasOption("use-modules");
-      opts.modulesList = cmd.getOptionValue("module-names", null);
-      opts.addDependencies = cmd.hasOption("recursive");
-      opts.toolArgs = cmd.getOptionValue("doctool-args", null);
-      opts.verbose = cmd.hasOption("verbose");
-      opts.outputFile = cmd.getOptionValue("output-file", null);
-      opts.args = cmd.getArgs();
-      return opts;
-    }
-  }
-
-  private static final CommandLineParser parser = new DefaultParser();
   static SummarizerOptions options;
 
-  public static SummarizerOptions parseArgs(String[] args) {
-    var options = new Options();
-    Option sources = new Option("s", "sources", true, "paths to search for source files");
-    Option classes = new Option("c", "classes", true, "paths to search for compiled classes");
-    Option backend =
-        new Option(
-            "b",
-            "backend",
-            true,
-            "backend to use for summary generation ('doclet', 'asm' or 'auto' (default)).");
-    Option useModules = new Option("M", "use-modules", false, "use Java modules");
-    Option recursive = new Option("r", "recursive", false, "Include dependencies of classes");
-    Option moduleNames =
-        new Option("m", "module-names", true, "comma separated list of module names");
-    Option doctoolArgs =
-        new Option("D", "doctool-args", true, "Arguments to pass to the documentation tool");
-    Option verbose = new Option("v", "verbose", false, "Enable verbose output");
-    Option outputFile =
-        new Option("o", "output-file", true, "Write JSON to file instead of stdout");
-    for (Option opt :
-        new Option[] {
-          sources,
-          classes,
-          backend,
-          useModules,
-          recursive,
-          moduleNames,
-          doctoolArgs,
-          verbose,
-          outputFile,
-        }) {
-      options.addOption(opt);
-    }
-
-    HelpFormatter help = new HelpFormatter();
-
-    CommandLine cmd;
-
-    try {
-      cmd = parser.parse(options, args);
-      if (cmd.getArgs().length < 1) {
-        throw new ParseException("Need to specify paths to source files");
-      }
-    } catch (ParseException e) {
-      System.out.println(e.getMessage());
-      help.printHelp(
-          "java -jar <JAR> [-s <SOURCE_DIR=.>] "
-              + "[-c <CLASSES_JAR>] <CLASS_OR_PACKAGE_NAMES>\n"
-              + "Class or package names should be fully qualified.\n\n",
-          options);
-      System.exit(1);
-      throw new RuntimeException("Unreachable code");
-    }
-    return SummarizerOptions.fromCommandLine(cmd);
-  }
-
   public static List<ClassDecl> runDocletWithClass(
-      Class<? extends Doclet> docletClass, List<File> javaFilePaths, SummarizerOptions options) {
+      DocumentationTool javaDoc,
+      Class<? extends Doclet> docletClass,
+      List<JavaFileObject> fileObjects,
+      SummarizerOptions options) {
     Log.setVerbose(options.verbose);
-
-    var files = javaFilePaths.stream().map(File::getPath).toArray(String[]::new);
-
-    DocumentationTool javadoc = ToolProvider.getSystemDocumentationTool();
-    var fileManager = javadoc.getStandardFileManager(null, null, null);
-    var fileObjects = fileManager.getJavaFileObjects(files);
-
+    var fileManager = javaDoc.getStandardFileManager(null, null, null);
     var cli = new ArrayList<String>();
     cli.add((options.useModules ? "--module-" : "--") + "source-path=" + options.sourcePath);
     if (options.classPath != null) {
@@ -146,161 +56,56 @@
       cli.addAll(List.of(options.toolArgs.split(" ")));
     }
 
-    javadoc.getTask(null, fileManager, System.err::println, docletClass, cli, fileObjects).call();
+    javaDoc.getTask(null, fileManager, System.err::println, docletClass, cli, fileObjects).call();
 
     return SummarizerDoclet.getClasses();
   }
 
-  public static List<ClassDecl> runDoclet(List<File> javaFilePaths, SummarizerOptions options) {
-    return runDocletWithClass(SummarizerDoclet.class, javaFilePaths, options);
-  }
-
-  /**
-   * Lists all files under given directory, which satisfy the condition of filter. The order of
-   * listing shall be deterministic.
-   */
-  public static List<File> recursiveListFiles(File file, FileFilter filter) {
-    if (!file.exists()) {
-      throw new RuntimeException("File not found: " + file.getPath());
-    }
-
-    if (!file.isDirectory()) {
-      return List.of(file);
-    }
-
-    // List files using a breadth-first traversal.
-    var files = new ArrayList<File>();
-    var queue = new ArrayDeque<File>();
-    queue.add(file);
-    while (!queue.isEmpty()) {
-      var dir = queue.poll();
-      var list = dir.listFiles(entry -> entry.isDirectory() || filter.accept(entry));
-      if (list == null) throw new IllegalArgumentException("File.listFiles returned null!");
-      Arrays.sort(list);
-      for (var path : list) {
-        if (path.isDirectory()) {
-          queue.add(path);
-        } else {
-          files.add(path);
-        }
-      }
-    }
-    return files;
-  }
-
-  /**
-   * Finds and returns source file (s) corresponding to qualified name. It's assumed that package
-   * hierarchy in Java is same as the filesystem hierarchy, i.e. each package corresponds to a
-   * directory and each class corresponds to a file in its respective package. If the respective
-   * file or directory does not exist, the returned optional is empty.
-   */
-  public static Optional<List<File>> findSourceFiles(String qualifiedName, String[] sourcePaths) {
-    return findFiles(qualifiedName, sourcePaths, ".java");
-  }
-
-  public static Optional<List<File>> findFiles(
-      String qualifiedName, String[] searchPaths, String suffix) {
-    var s = qualifiedName.replace(".", "/");
-    for (var folder : searchPaths) {
-      var f = new File(folder, s + suffix);
-      if (f.exists() && f.isFile()) {
-        return Optional.of(List.of(f));
-      }
-      var d = new File(folder, s);
-      if (d.exists() && d.isDirectory()) {
-        return Optional.of(recursiveListFiles(d, file -> file.getName().endsWith(".java")));
-      }
-    }
-    return Optional.empty();
-  }
-
-  public static Optional<List<InputStream>> findClassInputStreamsInJar(
-      JarFile jar, String relativePath) {
-    var suffix = ".class";
-    var classEntry = jar.getEntry(relativePath + suffix);
-    if (classEntry != null) {
-      return Optional.of(List.of(wrapCheckedException(jar::getInputStream, classEntry)));
-    }
-    var dirPath = relativePath.endsWith("/") ? relativePath : relativePath + "/";
-    var dirEntry = jar.getEntry(dirPath);
-    if (dirEntry != null && dirEntry.isDirectory()) {
-      var result =
-          jar.stream()
-              .map(je -> (ZipEntry) je)
-              .filter(
-                  entry -> {
-                    var name = entry.getName();
-                    return name.endsWith(suffix) && name.startsWith(dirPath);
-                  })
-              .map(entry -> wrapCheckedException(jar::getInputStream, entry))
-              .collect(Collectors.toList());
-      return Optional.of(result);
-    }
-    return Optional.empty();
-  }
-
-  /**
-   * Finds and returns class file(s) corresponding to qualified name from JAR files in classpath.
-   */
-  public static Optional<List<InputStream>> findClassInputStreams(
-      String binaryName, String[] classPaths) {
-    String relativePath = binaryName.replace(".", "/");
-    for (var path : classPaths) {
-      var file = new File(path);
-      // A path in classpath can be a directory or JAR. These cases require different logic.
-      if (file.isDirectory()) {
-        var directorySearchResult = findFiles(binaryName, classPaths, ".class");
-        if (directorySearchResult.isPresent()) {
-          var list = directorySearchResult.get();
-          return Optional.of(
-              StreamUtil.map(
-                  list, fileElement -> wrapCheckedException(FileInputStream::new, fileElement)));
-        }
-        continue;
-      }
-      try {
-        JarFile jar = new JarFile(file);
-        var inJar = findClassInputStreamsInJar(jar, relativePath);
-        if (inJar.isPresent()) {
-          return inJar;
-        } else {
-          jar.close();
-        }
-      } catch (IOException e) {
-        throw new RuntimeException(e);
-      }
-    }
-    return Optional.empty();
+  public static List<ClassDecl> runDoclet(
+      DocumentationTool javaDoc, List<JavaFileObject> javaFileObjects, SummarizerOptions options) {
+    return runDocletWithClass(javaDoc, SummarizerDoclet.class, javaFileObjects, options);
   }
 
   public static void main(String[] args) throws FileNotFoundException {
-    options = parseArgs(args);
+    options = SummarizerOptions.parseArgs(args);
     OutputStream output;
+
     if (options.outputFile == null || options.outputFile.equals("-")) {
       output = System.out;
     } else {
       output = new FileOutputStream(options.outputFile);
     }
-    var sourcePaths =
-        options.sourcePath != null ? options.sourcePath.split(File.pathSeparator) : new String[] {};
-    var classPaths =
-        options.classPath != null ? options.classPath.split(File.pathSeparator) : new String[] {};
-    var classStreams = new ArrayList<InputStream>();
-    var sourceFiles = new ArrayList<File>();
+
+    List<String> sourcePaths =
+        options.sourcePath != null
+            ? Arrays.asList(options.sourcePath.split(File.pathSeparator))
+            : List.of();
+    List<String> classPaths =
+        options.classPath != null
+            ? Arrays.asList(options.classPath.split(File.pathSeparator))
+            : List.of();
+
+    var classStreamProviders = new ArrayList<InputStreamProvider>();
+    var sourceFiles = new ArrayList<JavaFileObject>();
     var notFound = new ArrayList<String>();
+
+    var javaDoc = ToolProvider.getSystemDocumentationTool();
+
     for (var qualifiedName : options.args) {
       var found = false;
       if (options.backend != Backend.ASM) {
-        var sources = findSourceFiles(qualifiedName, sourcePaths);
+        var sources =
+            SearchUtil.findJavaSources(
+                qualifiedName, sourcePaths, javaDoc.getStandardFileManager(null, null, null));
         if (sources.isPresent()) {
           sourceFiles.addAll(sources.get());
           found = true;
         }
       }
       if (options.backend != Backend.DOCLET && !found) {
-        var classes = findClassInputStreams(qualifiedName, classPaths);
+        var classes = SearchUtil.findJavaClasses(qualifiedName, classPaths);
         if (classes.isPresent()) {
-          classStreams.addAll(classes.get());
+          classStreamProviders.addAll(classes.get());
           found = true;
         }
       }
@@ -316,14 +121,19 @@
 
     switch (options.backend) {
       case DOCLET:
-        JsonUtil.writeJSON(runDoclet(sourceFiles, options), output);
+        JsonUtil.writeJSON(runDoclet(javaDoc, sourceFiles, options), output);
         break;
       case ASM:
-        JsonUtil.writeJSON(AsmSummarizer.run(classStreams), output);
+        JsonUtil.writeJSON(AsmSummarizer.run(classStreamProviders), output);
         break;
       case AUTO:
-        var decls = runDoclet(sourceFiles, options);
-        decls.addAll(AsmSummarizer.run(classStreams));
+        List<ClassDecl> decls = new ArrayList<>();
+        if (!sourceFiles.isEmpty()) {
+          decls.addAll(runDoclet(javaDoc, sourceFiles, options));
+        }
+        if (!classStreamProviders.isEmpty()) {
+          decls.addAll(AsmSummarizer.run(classStreamProviders));
+        }
         JsonUtil.writeJSON(decls, output);
         break;
     }
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/SummarizerOptions.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/SummarizerOptions.java
new file mode 100644
index 0000000..ca63a45
--- /dev/null
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/SummarizerOptions.java
@@ -0,0 +1,94 @@
+package com.github.dart_lang.jnigen.apisummarizer;
+
+import org.apache.commons.cli.*;
+
+public class SummarizerOptions {
+  private static final CommandLineParser parser = new DefaultParser();
+  String sourcePath;
+  String classPath;
+  boolean useModules;
+  Main.Backend backend;
+  String modulesList;
+  boolean addDependencies;
+  String toolArgs;
+  boolean verbose;
+  String outputFile;
+  String[] args;
+
+  SummarizerOptions() {}
+
+  public static SummarizerOptions fromCommandLine(CommandLine cmd) {
+    var opts = new SummarizerOptions();
+    opts.sourcePath = cmd.getOptionValue("sources", ".");
+    var backendString = cmd.getOptionValue("backend", "auto");
+    opts.backend = Main.Backend.valueOf(backendString.toUpperCase());
+    opts.classPath = cmd.getOptionValue("classes", null);
+    opts.useModules = cmd.hasOption("use-modules");
+    opts.modulesList = cmd.getOptionValue("module-names", null);
+    opts.addDependencies = cmd.hasOption("recursive");
+    opts.toolArgs = cmd.getOptionValue("doctool-args", null);
+    opts.verbose = cmd.hasOption("verbose");
+    opts.outputFile = cmd.getOptionValue("output-file", null);
+    opts.args = cmd.getArgs();
+    if (opts.args.length == 0) {
+      throw new IllegalArgumentException("Need one or more class or package names as arguments");
+    }
+    return opts;
+  }
+
+  public static SummarizerOptions parseArgs(String[] args) {
+    var options = new Options();
+    Option sources = new Option("s", "sources", true, "paths to search for source files");
+    Option classes = new Option("c", "classes", true, "paths to search for compiled classes");
+    Option backend =
+        new Option(
+            "b",
+            "backend",
+            true,
+            "backend to use for summary generation ('doclet', 'asm' or 'auto' (default)).");
+    Option useModules = new Option("M", "use-modules", false, "use Java modules");
+    Option recursive = new Option("r", "recursive", false, "include dependencies of classes");
+    Option moduleNames =
+        new Option("m", "module-names", true, "comma separated list of module names");
+    Option doctoolArgs =
+        new Option("D", "doctool-args", true, "arguments to pass to the documentation tool");
+    Option verbose = new Option("v", "verbose", false, "enable verbose output");
+    Option outputFile =
+        new Option("o", "output-file", true, "write JSON to file instead of stdout");
+    for (Option opt :
+        new Option[] {
+          sources,
+          classes,
+          backend,
+          useModules,
+          recursive,
+          moduleNames,
+          doctoolArgs,
+          verbose,
+          outputFile,
+        }) {
+      options.addOption(opt);
+    }
+
+    HelpFormatter help = new HelpFormatter();
+
+    CommandLine cmd;
+
+    try {
+      cmd = parser.parse(options, args);
+      if (cmd.getArgs().length < 1) {
+        throw new ParseException("Need to specify paths to source files");
+      }
+    } catch (ParseException e) {
+      System.out.println(e.getMessage());
+      help.printHelp(
+          "java -jar <JAR> [-s <SOURCE_DIR=.>] "
+              + "[-c <CLASSES_JAR>] <CLASS_OR_PACKAGE_NAMES>\n"
+              + "Class or package names should be fully qualified.\n\n",
+          options);
+      System.exit(1);
+      throw new RuntimeException("Unreachable code");
+    }
+    return fromCommandLine(cmd);
+  }
+}
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/disasm/AsmSummarizer.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/disasm/AsmSummarizer.java
index 9cf03d9..391b831 100644
--- a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/disasm/AsmSummarizer.java
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/disasm/AsmSummarizer.java
@@ -7,23 +7,23 @@
 import static com.github.dart_lang.jnigen.apisummarizer.util.ExceptionUtil.wrapCheckedException;
 
 import com.github.dart_lang.jnigen.apisummarizer.elements.ClassDecl;
-import java.io.InputStream;
+import com.github.dart_lang.jnigen.apisummarizer.util.InputStreamProvider;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.stream.Collectors;
 import org.objectweb.asm.ClassReader;
 
 public class AsmSummarizer {
 
-  public static List<ClassDecl> run(ArrayList<InputStream> inputs) {
-    return inputs.stream()
-        .map(input -> wrapCheckedException(ClassReader::new, input))
-        .flatMap(
-            reader -> {
-              var visitor = new AsmClassVisitor();
-              reader.accept(visitor, 0);
-              return visitor.getVisited().stream();
-            })
-        .collect(Collectors.toList());
+  public static List<ClassDecl> run(List<InputStreamProvider> inputProviders) {
+    List<ClassDecl> parsed = new ArrayList<>();
+    for (var provider : inputProviders) {
+      var inputStream = provider.getInputStream();
+      var classReader = wrapCheckedException(ClassReader::new, inputStream);
+      var visitor = new AsmClassVisitor();
+      classReader.accept(visitor, 0);
+      parsed.addAll(visitor.getVisited());
+      provider.close();
+    }
+    return parsed;
   }
 }
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/FileInputStreamProvider.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/FileInputStreamProvider.java
new file mode 100644
index 0000000..da5cadf
--- /dev/null
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/FileInputStreamProvider.java
@@ -0,0 +1,38 @@
+package com.github.dart_lang.jnigen.apisummarizer.util;
+
+import java.io.*;
+
+/** Implementation of InputStreamProvider backed by a File. */
+public class FileInputStreamProvider implements InputStreamProvider {
+  File file;
+  InputStream stream;
+
+  public FileInputStreamProvider(File file) {
+    this.file = file;
+  }
+
+  @Override
+  public InputStream getInputStream() {
+    if (stream == null) {
+      try {
+        stream = new FileInputStream(file);
+      } catch (FileNotFoundException e) {
+        throw new RuntimeException(e);
+      }
+    }
+    return stream;
+  }
+
+  @Override
+  public void close() {
+    if (stream == null) {
+      return;
+    }
+    try {
+      stream.close();
+      stream = null;
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/InputStreamProvider.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/InputStreamProvider.java
new file mode 100644
index 0000000..85ba15b
--- /dev/null
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/InputStreamProvider.java
@@ -0,0 +1,17 @@
+package com.github.dart_lang.jnigen.apisummarizer.util;
+
+import java.io.InputStream;
+
+/**
+ * Implementers of this interface provide an InputStream on-demand for writing, and provide a way to
+ * close the same. <br>
+ * The implementation doesn't need to be thread-safe, since this is only used in AsmSummarizer,
+ * which reads the classes serially.
+ */
+public interface InputStreamProvider {
+  /** Return the input stream, initializing it if needed. */
+  InputStream getInputStream();
+
+  /** close the underlying InputStream. */
+  void close();
+}
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JarEntryFileObject.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JarEntryFileObject.java
new file mode 100644
index 0000000..49f187f
--- /dev/null
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JarEntryFileObject.java
@@ -0,0 +1,41 @@
+package com.github.dart_lang.jnigen.apisummarizer.util;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.jar.JarFile;
+import java.util.zip.ZipEntry;
+import javax.tools.SimpleJavaFileObject;
+
+/** Implements JavaFileObject for use by Doclet summarizer. */
+class JarEntryFileObject extends SimpleJavaFileObject {
+  JarFile jarFile;
+  String relativePath;
+
+  protected JarEntryFileObject(JarFile jarFile, ZipEntry entry) {
+    super(URI.create(new File(jarFile.getName()).toURI() + "/" + entry.getName()), Kind.SOURCE);
+    this.jarFile = jarFile;
+    this.relativePath = entry.getName();
+  }
+
+  private int getEntrySize(ZipEntry entry) {
+    long limit = 1024L * 1024L * 16L; // Arbitrary limit, how long can be a source file?
+    long size = entry.getSize();
+    return (int) Long.min(size, limit);
+  }
+
+  @Override
+  public CharSequence getCharContent(boolean ignoreEncodingErrors) {
+    var entry = jarFile.getEntry(relativePath);
+    var out = new ByteArrayOutputStream(getEntrySize(entry));
+
+    try (var stream = jarFile.getInputStream(entry)) {
+      stream.transferTo(out);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+    return out.toString(StandardCharsets.UTF_8);
+  }
+}
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JarEntryInputStreamProvider.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JarEntryInputStreamProvider.java
new file mode 100644
index 0000000..cba3f76
--- /dev/null
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/JarEntryInputStreamProvider.java
@@ -0,0 +1,29 @@
+package com.github.dart_lang.jnigen.apisummarizer.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.jar.JarFile;
+import java.util.zip.ZipEntry;
+
+public class JarEntryInputStreamProvider implements InputStreamProvider {
+
+  private final JarFile jarFile;
+  private final ZipEntry zipEntry;
+
+  public JarEntryInputStreamProvider(JarFile jarFile, ZipEntry zipEntry) {
+    this.jarFile = jarFile;
+    this.zipEntry = zipEntry;
+  }
+
+  @Override
+  public InputStream getInputStream() {
+    try {
+      return jarFile.getInputStream(zipEntry);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Override
+  public void close() {}
+}
diff --git a/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/SearchUtil.java b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/SearchUtil.java
new file mode 100644
index 0000000..9f1611d
--- /dev/null
+++ b/pkgs/jnigen/java/src/main/java/com/github/dart_lang/jnigen/apisummarizer/util/SearchUtil.java
@@ -0,0 +1,155 @@
+package com.github.dart_lang.jnigen.apisummarizer.util;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.util.*;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.jar.JarFile;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+
+public class SearchUtil {
+  public static Optional<List<File>> findFilesInPath(
+      String qualifiedName, String searchPath, String suffix) {
+    var s = qualifiedName.replace(".", File.separator);
+    var f = new File(searchPath, s + suffix);
+    if (f.exists() && f.isFile()) {
+      return Optional.of(List.of(f));
+    }
+
+    var d = new File(searchPath, s);
+    if (d.exists() && d.isDirectory()) {
+      return Optional.of(recursiveListFiles(d, file -> file.getName().endsWith(".java")));
+    }
+
+    return Optional.empty();
+  }
+
+  public static Optional<List<ZipEntry>> findFilesInJar(
+      String qualifiedName, JarFile jar, String suffix) {
+    String relativePath = qualifiedName.replace(".", "/");
+    var classEntry = jar.getEntry(relativePath + suffix);
+    if (classEntry != null) {
+      return Optional.of(List.of(classEntry));
+    }
+    var dirPath = relativePath.endsWith("/") ? relativePath : relativePath + "/";
+    var dirEntry = jar.getEntry(dirPath);
+    if (dirEntry != null && dirEntry.isDirectory()) {
+      var result =
+          jar.stream()
+              .map(je -> (ZipEntry) je)
+              .filter(
+                  entry -> {
+                    var name = entry.getName();
+                    return name.endsWith(suffix) && name.startsWith(dirPath);
+                  })
+              .collect(Collectors.toList());
+      return Optional.of(result);
+    }
+    return Optional.empty();
+  }
+
+  public static <T> Optional<List<T>> find(
+      String qualifiedName,
+      List<String> searchPaths,
+      String suffix,
+      Function<List<File>, List<T>> fileMapper,
+      BiFunction<JarFile, List<ZipEntry>, List<T>> entryMapper) {
+    for (var searchPath : searchPaths) {
+      File searchFile = new File(searchPath);
+      if (searchFile.isDirectory()) {
+        var result = findFilesInPath(qualifiedName, searchPath, suffix);
+        if (result.isPresent()) {
+          var mappedResult = fileMapper.apply(result.get());
+          return Optional.of(mappedResult);
+        }
+      }
+      if (searchFile.isFile() && searchPath.endsWith(".jar")) {
+        var jarFile = ExceptionUtil.wrapCheckedException(JarFile::new, searchPath);
+        var result = findFilesInJar(qualifiedName, jarFile, suffix);
+        if (result.isPresent()) {
+          var mappedResult = entryMapper.apply(jarFile, result.get());
+          return Optional.of(mappedResult);
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
+  private static List<JavaFileObject> getJavaFileObjectsFromFiles(
+      List<File> files, StandardJavaFileManager fm) {
+    var result = new ArrayList<JavaFileObject>();
+    fm.getJavaFileObjectsFromFiles(files).forEach(result::add);
+    return result;
+  }
+
+  private static List<JavaFileObject> getJavaFileObjectsFromJar(
+      JarFile jarFile, List<ZipEntry> entries) {
+    return StreamUtil.map(entries, (entry) -> new JarEntryFileObject(jarFile, entry));
+  }
+
+  private static List<InputStreamProvider> getInputStreamProvidersFromFiles(List<File> files) {
+    return StreamUtil.map(files, FileInputStreamProvider::new);
+  }
+
+  private static List<InputStreamProvider> getInputStreamProvidersFromJar(
+      JarFile jarFile, List<ZipEntry> entries) {
+    return StreamUtil.map(entries, entry -> new JarEntryInputStreamProvider(jarFile, entry));
+  }
+
+  public static Optional<List<JavaFileObject>> findJavaSources(
+      String qualifiedName, List<String> searchPaths, StandardJavaFileManager fm) {
+    return find(
+        qualifiedName,
+        searchPaths,
+        ".java",
+        files -> getJavaFileObjectsFromFiles(files, fm),
+        SearchUtil::getJavaFileObjectsFromJar);
+  }
+
+  public static Optional<List<InputStreamProvider>> findJavaClasses(
+      String qualifiedName, List<String> searchPaths) {
+    return find(
+        qualifiedName,
+        searchPaths,
+        ".class",
+        SearchUtil::getInputStreamProvidersFromFiles,
+        SearchUtil::getInputStreamProvidersFromJar);
+  }
+
+  /**
+   * Lists all files under given directory, which satisfy the condition of filter. <br>
+   * The order of listing will be deterministic.
+   */
+  public static List<File> recursiveListFiles(File file, FileFilter filter) {
+    if (!file.exists()) {
+      throw new RuntimeException("File not found: " + file.getPath());
+    }
+
+    if (!file.isDirectory()) {
+      return List.of(file);
+    }
+
+    // List files using a breadth-first traversal.
+    var files = new ArrayList<File>();
+    var queue = new ArrayDeque<File>();
+    queue.add(file);
+    while (!queue.isEmpty()) {
+      var dir = queue.poll();
+      var list = dir.listFiles(entry -> entry.isDirectory() || filter.accept(entry));
+      if (list == null) throw new IllegalArgumentException("File.listFiles returned null!");
+      Arrays.sort(list);
+      for (var path : list) {
+        if (path.isDirectory()) {
+          queue.add(path);
+        } else {
+          files.add(path);
+        }
+      }
+    }
+    return files;
+  }
+}
diff --git a/pkgs/jnigen/lib/src/generate_bindings.dart b/pkgs/jnigen/lib/src/generate_bindings.dart
index 9c1202d..bc4deb0 100644
--- a/pkgs/jnigen/lib/src/generate_bindings.dart
+++ b/pkgs/jnigen/lib/src/generate_bindings.dart
@@ -9,7 +9,6 @@
 import 'bindings/excluder.dart';
 import 'bindings/linker.dart';
 import 'bindings/renamer.dart';
-import 'elements/elements.dart';
 import 'summary/summary.dart';
 import 'config/config.dart';
 import 'tools/tools.dart';
@@ -23,83 +22,8 @@
 
   await buildSummarizerIfNotExists();
 
-  final summarizer = SummarizerCommand(
-    sourcePath: config.sourcePath,
-    classPath: config.classPath,
-    classes: config.classes,
-    workingDirectory: config.summarizerOptions?.workingDirectory,
-    extraArgs: config.summarizerOptions?.extraArgs ?? const [],
-    backend: config.summarizerOptions?.backend,
-  );
+  final classes = await getSummary(config);
 
-  // Additional sources added using maven downloads and gradle trickery.
-  final extraSources = <Uri>[];
-  final extraJars = <Uri>[];
-  final mavenDl = config.mavenDownloads;
-  if (mavenDl != null) {
-    final sourcePath = mavenDl.sourceDir;
-    await Directory(sourcePath).create(recursive: true);
-    await MavenTools.downloadMavenSources(
-        MavenTools.deps(mavenDl.sourceDeps), sourcePath);
-    extraSources.add(Uri.directory(sourcePath));
-    final jarPath = mavenDl.jarDir;
-    await Directory(jarPath).create(recursive: true);
-    await MavenTools.downloadMavenJars(
-        MavenTools.deps(mavenDl.sourceDeps + mavenDl.jarOnlyDeps), jarPath);
-    extraJars.addAll(await Directory(jarPath)
-        .list()
-        .where((entry) => entry.path.endsWith('.jar'))
-        .map((entry) => entry.uri)
-        .toList());
-  }
-  final androidConfig = config.androidSdkConfig;
-  if (androidConfig != null && androidConfig.addGradleDeps) {
-    final deps = AndroidSdkTools.getGradleClasspaths(
-      configRoot: config.configRoot,
-      androidProject: androidConfig.androidExample ?? '.',
-    );
-    extraJars.addAll(deps.map(Uri.file));
-  }
-  if (androidConfig != null && androidConfig.versions != null) {
-    final versions = androidConfig.versions!;
-    final androidSdkRoot =
-        androidConfig.sdkRoot ?? AndroidSdkTools.getAndroidSdkRoot();
-    final androidJar = await AndroidSdkTools.getAndroidJarPath(
-        sdkRoot: androidSdkRoot, versionOrder: versions);
-    if (androidJar != null) {
-      extraJars.add(Uri.directory(androidJar));
-    }
-  }
-
-  summarizer.addSourcePaths(extraSources);
-  summarizer.addClassPaths(extraJars);
-
-  Process process;
-  Stream<List<int>> input;
-  try {
-    process = await summarizer.runProcess();
-    input = process.stdout;
-  } on Exception catch (e) {
-    log.fatal('Cannot obtain API summary: $e');
-    return;
-  }
-  final errorLog = StringBuffer();
-  collectOutputStream(process.stderr, errorLog);
-  final stream = const JsonDecoder().bind(const Utf8Decoder().bind(input));
-  dynamic json;
-  try {
-    json = await stream.single;
-  } on Exception catch (e) {
-    printError(errorLog);
-    log.fatal('Cannot parse summary: $e');
-    return;
-  }
-  if (json == null) {
-    log.fatal('Expected JSON element from summarizer.');
-    return;
-  }
-  final list = json as List;
-  final classes = Classes.fromJson(list);
   final cBased = config.outputConfig.bindingsType == BindingsType.cBased;
   classes
     ..accept(Excluder(config))
diff --git a/pkgs/jnigen/lib/src/logging/logging.dart b/pkgs/jnigen/lib/src/logging/logging.dart
index 561b06b..67c605e 100644
--- a/pkgs/jnigen/lib/src/logging/logging.dart
+++ b/pkgs/jnigen/lib/src/logging/logging.dart
@@ -49,9 +49,9 @@
 }
 
 extension FatalErrors on Logger {
-  void fatal(Object? message, {int exitCode = 1}) {
+  Never fatal(Object? message, {int exitCode = 1}) {
     message = _colorize('Fatal: $message', _ansiRed);
     stderr.writeln(message);
-    exit(exitCode);
+    return exit(exitCode);
   }
 }
diff --git a/pkgs/jnigen/lib/src/summary/summary.dart b/pkgs/jnigen/lib/src/summary/summary.dart
index 4580dbe..29e50b7 100644
--- a/pkgs/jnigen/lib/src/summary/summary.dart
+++ b/pkgs/jnigen/lib/src/summary/summary.dart
@@ -3,8 +3,13 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:convert';
 import 'dart:io';
 
+import '../../tools.dart';
+import '../config/config.dart';
+import '../elements/elements.dart';
+import '../generate_bindings.dart';
 import '../logging/logging.dart';
 
 /// A command based summary source which calls the ApiSummarizer command.
@@ -86,3 +91,82 @@
     return proc;
   }
 }
+
+Future<Classes> getSummary(Config config) async {
+  setLoggingLevel(config.logLevel);
+  final summarizer = SummarizerCommand(
+    sourcePath: config.sourcePath,
+    classPath: config.classPath,
+    classes: config.classes,
+    workingDirectory: config.summarizerOptions?.workingDirectory,
+    extraArgs: config.summarizerOptions?.extraArgs ?? const [],
+    backend: config.summarizerOptions?.backend,
+  );
+
+  // Additional sources added using maven downloads and gradle trickery.
+  final extraSources = <Uri>[];
+  final extraJars = <Uri>[];
+  final mavenDl = config.mavenDownloads;
+  if (mavenDl != null) {
+    final sourcePath = mavenDl.sourceDir;
+    await Directory(sourcePath).create(recursive: true);
+    await MavenTools.downloadMavenSources(
+        MavenTools.deps(mavenDl.sourceDeps), sourcePath);
+    extraSources.add(Uri.directory(sourcePath));
+    final jarPath = mavenDl.jarDir;
+    await Directory(jarPath).create(recursive: true);
+    await MavenTools.downloadMavenJars(
+        MavenTools.deps(mavenDl.sourceDeps + mavenDl.jarOnlyDeps), jarPath);
+    extraJars.addAll(await Directory(jarPath)
+        .list()
+        .where((entry) => entry.path.endsWith('.jar'))
+        .map((entry) => entry.uri)
+        .toList());
+  }
+  final androidConfig = config.androidSdkConfig;
+  if (androidConfig != null && androidConfig.addGradleDeps) {
+    final deps = AndroidSdkTools.getGradleClasspaths(
+      configRoot: config.configRoot,
+      androidProject: androidConfig.androidExample ?? '.',
+    );
+    extraJars.addAll(deps.map(Uri.file));
+  }
+  if (androidConfig != null && androidConfig.versions != null) {
+    final versions = androidConfig.versions!;
+    final androidSdkRoot =
+        androidConfig.sdkRoot ?? AndroidSdkTools.getAndroidSdkRoot();
+    final androidJar = await AndroidSdkTools.getAndroidJarPath(
+        sdkRoot: androidSdkRoot, versionOrder: versions);
+    if (androidJar != null) {
+      extraJars.add(Uri.directory(androidJar));
+    }
+  }
+
+  summarizer.addSourcePaths(extraSources);
+  summarizer.addClassPaths(extraJars);
+
+  Process process;
+  Stream<List<int>> input;
+  try {
+    process = await summarizer.runProcess();
+    input = process.stdout;
+  } on Exception catch (e) {
+    log.fatal('Cannot obtain API summary: $e');
+  }
+  final errorLog = StringBuffer();
+  collectOutputStream(process.stderr, errorLog);
+  final stream = const JsonDecoder().bind(const Utf8Decoder().bind(input));
+  dynamic json;
+  try {
+    json = await stream.single;
+  } on Exception catch (e) {
+    printError(errorLog);
+    log.fatal('Cannot parse summary: $e');
+  }
+  if (json == null) {
+    log.fatal('Expected JSON element from summarizer.');
+  }
+  final list = json as List;
+  final classes = Classes.fromJson(list);
+  return classes;
+}
diff --git a/pkgs/jnigen/lib/src/tools/build_summarizer.dart b/pkgs/jnigen/lib/src/tools/build_summarizer.dart
index 2647bc8..30ce426 100644
--- a/pkgs/jnigen/lib/src/tools/build_summarizer.dart
+++ b/pkgs/jnigen/lib/src/tools/build_summarizer.dart
@@ -25,7 +25,6 @@
   final pkg = await findPackageRoot('jnigen');
   if (pkg == null) {
     log.fatal('package jnigen not found!');
-    return;
   }
   final pom = pkg.resolve('java/pom.xml');
   await Directory(toolPath).create(recursive: true);
diff --git a/pkgs/jnigen/test/summary_generation_test.dart b/pkgs/jnigen/test/summary_generation_test.dart
new file mode 100644
index 0000000..6bf7457
--- /dev/null
+++ b/pkgs/jnigen/test/summary_generation_test.dart
@@ -0,0 +1,168 @@
+// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// These tests validate summary generation in various scenarios.
+// Currently, no validation of the summary content itself is done.
+
+import 'dart:io';
+
+import 'package:jnigen/src/config/config.dart';
+import 'package:jnigen/src/elements/elements.dart';
+import 'package:jnigen/src/logging/logging.dart';
+import 'package:jnigen/src/summary/summary.dart';
+import 'package:logging/logging.dart';
+
+import 'package:path/path.dart' hide equals;
+import 'package:test/test.dart';
+
+import 'test_util/test_util.dart';
+
+void expectNonEmptySummary(Classes? classes) {
+  expect(classes, isNotNull);
+  final decls = classes!.decls;
+  expect(decls.entries.length, greaterThanOrEqualTo(javaFiles.length));
+  final declNames = decls.keys.toSet();
+  final expectedClasses =
+      javaClasses.where((name) => !name.contains("annotations.")).toList();
+  expect(declNames, containsAll(expectedClasses));
+}
+
+void deleteTempDir(Directory directory) {
+  try {
+    if (Platform.isWindows) {
+      // This appears to avoid "file used by another process" errors.
+      sleep(const Duration(seconds: 1));
+    }
+    directory.deleteSync(recursive: true);
+  } on FileSystemException catch (e) {
+    log.warning("Cannot delete directory: $e");
+  }
+}
+
+List<String> findFiles(Directory dir, String suffix) {
+  return dir
+      .listSync(recursive: true)
+      .map((entry) => relative(entry.path, from: dir.path))
+      .where((path) => path.endsWith(suffix))
+      .toList();
+}
+
+Future<void> createJar(List<String> paths, String target) async {
+  final relativeTarget = relative(target, from: simplePackagePath);
+  final status = await runCommand(
+    'jar',
+    ['cf', relativeTarget, ...paths],
+    workingDirectory: simplePackagePath,
+  );
+  if (status != 0) {
+    throw ArgumentError('Cannot create JAR from provided arguments');
+  }
+}
+
+Future<void> compileJavaFiles(List<String> paths, Directory target) async {
+  final status = await runCommand(
+    'javac',
+    ['-d', target.absolute.path, ...paths],
+    workingDirectory: simplePackagePath,
+  );
+  if (status != 0) {
+    throw ArgumentError('Cannot compile Java sources');
+  }
+}
+
+String getClassNameFromPath(String path) {
+  if (!path.endsWith('.java')) {
+    throw ArgumentError('Filename must end with java');
+  }
+  return path
+      .replaceAll('/', '.')
+      .replaceAll('\\', '.')
+      .substring(0, path.length - 5);
+}
+
+final simplePackagePath = join('test', 'simple_package_test', 'java');
+final simplePackageDir = Directory(simplePackagePath);
+final javaFiles = findFiles(simplePackageDir, '.java');
+final javaClasses = javaFiles.map(getClassNameFromPath).toList();
+
+Config getConfig({List<String>? sourcePath, List<String>? classPath}) {
+  return Config(
+    outputConfig: OutputConfig(
+      bindingsType: BindingsType.dartOnly,
+      dartConfig: DartCodeOutputConfig(
+        path: Uri.file('unused.dart'),
+        structure: OutputStructure.singleFile,
+      ),
+    ),
+    classes: javaClasses,
+    sourcePath: sourcePath?.map((e) => Uri.file(e)).toList(),
+    classPath: classPath?.map((e) => Uri.file(e)).toList(),
+    logLevel: Level.WARNING,
+  );
+}
+
+void main() {
+  late Directory tempDir;
+  setUpAll(() {
+    tempDir = getTempDir("jnigen_summary_tests_");
+  });
+
+  test('Test summary generation from compiled JAR', () async {
+    final targetDir = tempDir.createTempSync("compiled_jar_test_");
+    await compileJavaFiles(javaFiles, targetDir);
+    final classFiles = findFiles(targetDir, '.class');
+    final jarFilePath = join(targetDir.absolute.path, 'classes.jar');
+    await createJar(classFiles, jarFilePath);
+    final config = getConfig(classPath: [jarFilePath]);
+    final summaryClasses = await getSummary(config);
+    expectNonEmptySummary(summaryClasses);
+  });
+
+  test('Test summary generation from source JAR', () async {
+    final targetDir = tempDir.createTempSync("source_jar_test_");
+    final jarFilePath = join(targetDir.path, 'sources.jar');
+    await createJar(javaFiles, jarFilePath);
+    final config = getConfig(sourcePath: [jarFilePath]);
+    final summaryClasses = await getSummary(config);
+    expectNonEmptySummary(summaryClasses);
+  });
+
+  test('Test summary generation from source folder', () async {
+    final config = getConfig(sourcePath: [simplePackagePath]);
+    final summaryClasses = await getSummary(config);
+    expectNonEmptySummary(summaryClasses);
+  });
+
+  test('Test summary generation from compiled classes in directory', () async {
+    final targetDir = tempDir.createTempSync("compiled_classes_test_");
+    await compileJavaFiles(javaFiles, targetDir);
+    final config = getConfig(classPath: [targetDir.path]);
+    final summaryClasses = await getSummary(config);
+    expectNonEmptySummary(summaryClasses);
+  });
+
+  // Test summary generation from combination of a source and class path
+  test('Test summary generation from combination', () async {
+    final targetDir = tempDir.createTempSync("combination_test_");
+
+    // remove a class from source files and create a source JAR
+    final sourceFiles = javaFiles.toList();
+    sourceFiles.removeLast();
+    final sourceJarPath = join(targetDir.path, 'sources.jar');
+    await createJar(sourceFiles, sourceJarPath);
+
+    await compileJavaFiles(javaFiles, targetDir);
+    final classFiles = findFiles(targetDir, '.class');
+    final classesJarPath = join(targetDir.path, 'classes.jar');
+    await createJar(classFiles, classesJarPath);
+    final config = getConfig(
+      classPath: [classesJarPath],
+      sourcePath: [sourceJarPath],
+    );
+    final summaryClasses = await getSummary(config);
+    expectNonEmptySummary(summaryClasses);
+  });
+
+  tearDownAll(() => deleteTempDir(tempDir));
+}
diff --git a/pkgs/jnigen/test/test_util/test_util.dart b/pkgs/jnigen/test/test_util/test_util.dart
index 5169684..e48fd0a 100644
--- a/pkgs/jnigen/test/test_util/test_util.dart
+++ b/pkgs/jnigen/test/test_util/test_util.dart
@@ -11,6 +11,12 @@
 
 import 'package:jnigen/src/logging/logging.dart' show printError;
 
+final _currentDirectory = Directory(".");
+
+Directory getTempDir(String prefix) {
+  return _currentDirectory.createTempSync(prefix);
+}
+
 Future<bool> isEmptyOrNotExistDir(String path) async {
   final dir = Directory(path);
   return (!await dir.exists()) || (await dir.list().length == 0);