// Copyright 2015 The Bazel Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.devtools.build.android.ideinfo;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.io.Files;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.devtools.build.android.Converters.PathConverter;
import com.google.devtools.build.android.Converters.PathListConverter;
import com.google.devtools.build.lib.ideinfo.androidstudio.PackageManifestOuterClass.ArtifactLocation;
import com.google.devtools.build.lib.ideinfo.androidstudio.PackageManifestOuterClass.JavaSourcePackage;
import com.google.devtools.build.lib.ideinfo.androidstudio.PackageManifestOuterClass.PackageManifest;
import com.google.devtools.common.options.Option;
import com.google.devtools.common.options.OptionsBase;
import com.google.devtools.common.options.OptionsParser;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Enumeration;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/** Filters a jar, keeping only the classes that are indicated. */
public final class JarFilter {
/** The options for a {@JarFilter} action. */
public static final class JarFilterOptions extends OptionsBase {
@Option(
name = "filter_jars",
defaultValue = "null",
converter = PathListConverter.class,
category = "input",
help = "A list of the paths to target output jars to filter for generated sources."
)
public List<Path> filterJars;
@Option(
name = "filter_source_jars",
defaultValue = "null",
converter = PathListConverter.class,
category = "input",
help = "A list of the paths to target output source jars to filter for generated sources."
)
public List<Path> filterSourceJars;
@Option(
name = "keep_java_files",
defaultValue = "null",
converter = PathListConverter.class,
category = "input",
help = "A list of target input java files to keep."
)
public List<Path> keepJavaFiles;
@Option(
name = "keep_source_jars",
defaultValue = "null",
converter = PathListConverter.class,
category = "input",
help = "A list of target input .srcjar files to keep."
)
public List<Path> keepSourceJars;
@Option(
name = "filtered_jar",
defaultValue = "null",
converter = PathConverter.class,
category = "output",
help = "The path to the jar to output."
)
public Path filteredJar;
@Option(
name = "filtered_source_jar",
defaultValue = "null",
converter = PathConverter.class,
category = "output",
help = "The path to the source jar to output."
)
public Path filteredSourceJar;
// Deprecated options -- only here to maintain command line backwards compatibility
// with the current blaze native IDE aspect
@Deprecated
@Option(
name = "jars",
defaultValue = "null",
converter = PathListConverter.class,
category = "input",
help = "A list of the paths to jars to filter for generated sources."
)
public List<Path> jars;
@Deprecated
@Option(
name = "manifest",
defaultValue = "null",
converter = PathConverter.class,
category = "input",
help = "The path to a package manifest generated only from generated sources."
)
public Path manifest;
@Deprecated
@Option(
name = "output",
defaultValue = "null",
converter = PathConverter.class,
category = "output",
help = "The path to the jar to output."
)
public Path output;
}
private static final Logger logger = Logger.getLogger(JarFilter.class.getName());
private static final Pattern JAVA_PACKAGE_PATTERN =
Pattern.compile("^\\s*package\\s+([\\w\\.]+);");
public static void main(String[] args) throws Exception {
JarFilterOptions options = parseArgs(args);
try {
main(options);
} catch (Throwable e) {
logger.log(Level.SEVERE, "Error parsing package strings", e);
System.exit(1);
}
System.exit(0);
}
@VisibleForTesting
static void main(JarFilterOptions options) throws Exception {
Preconditions.checkNotNull(options.filteredJar);
if (options.filterJars == null) {
options.filterJars = ImmutableList.of();
}
if (options.filterSourceJars == null) {
options.filterSourceJars = ImmutableList.of();
}
final List<String> archiveFileNamePrefixes = Lists.newArrayList();
if (options.manifest != null) {
archiveFileNamePrefixes.addAll(parsePackageManifest(options.manifest));
}
if (options.keepJavaFiles != null) {
archiveFileNamePrefixes.addAll(parseJavaFiles(options.keepJavaFiles));
}
if (options.keepSourceJars != null) {
archiveFileNamePrefixes.addAll(parseSrcJars(options.keepSourceJars));
}
filterJars(
options.filterJars,
options.filteredJar,
new Predicate<String>() {
@Override
public boolean apply(@Nullable String s) {
return shouldKeepClass(archiveFileNamePrefixes, s);
}
});
if (options.filteredSourceJar != null) {
filterJars(
options.filterSourceJars,
options.filteredSourceJar,
new Predicate<String>() {
@Override
public boolean apply(@Nullable String s) {
return shouldKeepJavaFile(archiveFileNamePrefixes, s);
}
});
}
}
@VisibleForTesting
static JarFilterOptions parseArgs(String[] args) {
args = parseParamFileIfUsed(args);
OptionsParser optionsParser = OptionsParser.newOptionsParser(JarFilterOptions.class);
optionsParser.parseAndExitUponError(args);
// Migrate options from v1 jar filter
JarFilterOptions options = optionsParser.getOptions(JarFilterOptions.class);
if (options.filterJars == null && options.jars != null) {
options.filterJars = options.jars;
}
if (options.filteredJar == null && options.output != null) {
options.filteredJar = options.output;
}
return options;
}
private static String[] parseParamFileIfUsed(@Nonnull String[] args) {
if (args.length != 1 || !args[0].startsWith("@")) {
return args;
}
File paramFile = new File(args[0].substring(1));
try {
return Files.readLines(paramFile, StandardCharsets.UTF_8).toArray(new String[0]);
} catch (IOException e) {
throw new RuntimeException("Error parsing param file: " + args[0], e);
}
}
/** Finds the expected jar archive file name prefixes for the java files. */
static List<String> parseJavaFiles(List<Path> javaFiles) throws IOException {
ListeningExecutorService executorService =
MoreExecutors.listeningDecorator(
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()));
List<ListenableFuture<String>> futures = Lists.newArrayList();
for (final Path javaFile : javaFiles) {
futures.add(
executorService.submit(
new Callable<String>() {
@Override
public String call() throws Exception {
String packageString = getDeclaredPackageOfJavaFile(javaFile);
return packageString != null
? getArchiveFileNamePrefix(javaFile.toString(), packageString)
: null;
}
}));
}
try {
List<String> archiveFileNamePrefixes = Futures.allAsList(futures).get();
List<String> result = Lists.newArrayList();
for (String archiveFileNamePrefix : archiveFileNamePrefixes) {
if (archiveFileNamePrefix != null) {
result.add(archiveFileNamePrefix);
}
}
return result;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException(e);
} catch (ExecutionException e) {
throw new IOException(e);
}
}
static List<String> parseSrcJars(List<Path> srcJars) throws IOException {
List<String> result = Lists.newArrayList();
for (Path srcJar : srcJars) {
try (ZipFile sourceZipFile = new ZipFile(srcJar.toFile())) {
Enumeration<? extends ZipEntry> entries = sourceZipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.getName().endsWith(".java")) {
continue;
}
try (BufferedReader reader =
new BufferedReader(
new InputStreamReader(sourceZipFile.getInputStream(entry), UTF_8))) {
String packageString = parseDeclaredPackage(reader);
if (packageString != null) {
String archiveFileNamePrefix =
getArchiveFileNamePrefix(entry.getName(), packageString);
result.add(archiveFileNamePrefix);
}
}
}
}
}
return result;
}
@Nullable
private static String getDeclaredPackageOfJavaFile(Path javaFile) {
try (BufferedReader reader =
java.nio.file.Files.newBufferedReader(javaFile, StandardCharsets.UTF_8)) {
return parseDeclaredPackage(reader);
} catch (IOException e) {
logger.log(Level.WARNING, "Error parsing package string from java source: " + javaFile, e);
return null;
}
}
@Nullable
private static String parseDeclaredPackage(BufferedReader reader) throws IOException {
String line;
while ((line = reader.readLine()) != null) {
Matcher packageMatch = JAVA_PACKAGE_PATTERN.matcher(line);
if (packageMatch.find()) {
return packageMatch.group(1);
}
}
return null;
}
/**
* Computes the expected archive file name prefix of a java class.
*
* <p>Eg.: file java/com/google/foo/Foo.java, package com.google.foo -> com/google/foo/Foo
*/
private static String getArchiveFileNamePrefix(String javaFile, String packageString) {
int lastSlashIndex = javaFile.lastIndexOf('/');
// On Windows, the separator could be '\\'
if (lastSlashIndex == -1) {
lastSlashIndex = javaFile.lastIndexOf('\\');
}
String fileName = lastSlashIndex != -1 ? javaFile.substring(lastSlashIndex + 1) : javaFile;
String className = fileName.substring(0, fileName.length() - ".java".length());
return packageString.replace('.', '/') + '/' + className;
}
/** Reads the package manifest and computes a list of the expected jar archive file names. */
private static List<String> parsePackageManifest(Path manifest) throws IOException {
try (InputStream inputStream = java.nio.file.Files.newInputStream(manifest)) {
PackageManifest packageManifest = PackageManifest.parseFrom(inputStream);
return parsePackageManifest(packageManifest);
}
}
@VisibleForTesting
static List<String> parsePackageManifest(PackageManifest packageManifest) {
List<String> result = Lists.newArrayList();
for (JavaSourcePackage javaSourcePackage : packageManifest.getSourcesList()) {
ArtifactLocation artifactLocation = javaSourcePackage.getArtifactLocation();
String packageString = javaSourcePackage.getPackageString();
String archiveFileNamePrefix =
getArchiveFileNamePrefix(artifactLocation.getRelativePath(), packageString);
result.add(archiveFileNamePrefix);
}
return result;
}
/** Filters a list of jars, keeping anything matching the passed predicate. */
private static void filterJars(List<Path> jars, Path output, Predicate<String> shouldKeep)
throws IOException {
final int bufferSize = 8 * 1024;
byte[] buffer = new byte[bufferSize];
try (ZipOutputStream outputStream =
new ZipOutputStream(new FileOutputStream(output.toFile()))) {
for (Path jar : jars) {
try (ZipFile sourceZipFile = new ZipFile(jar.toFile())) {
Enumeration<? extends ZipEntry> entries = sourceZipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!shouldKeep.apply(entry.getName())) {
continue;
}
ZipEntry newEntry = new ZipEntry(entry.getName());
outputStream.putNextEntry(newEntry);
try (InputStream inputStream = sourceZipFile.getInputStream(entry)) {
int len;
while ((len = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
}
}
}
}
}
}
@VisibleForTesting
static boolean shouldKeepClass(List<String> archiveFileNamePrefixes, String name) {
if (!name.endsWith(".class")) {
return false;
}
for (String archiveFileNamePrefix : archiveFileNamePrefixes) {
if (name.startsWith(archiveFileNamePrefix)
&& name.length() > archiveFileNamePrefix.length()) {
char c = name.charAt(archiveFileNamePrefix.length());
if (c == '.' || c == '$') {
return true;
}
}
}
return false;
}
private static boolean shouldKeepJavaFile(List<String> archiveFileNamePrefixes, String name) {
if (!name.endsWith(".java")) {
return false;
}
String nameWithoutJava = name.substring(0, name.length() - ".java".length());
return archiveFileNamePrefixes.contains(nameWithoutJava);
}
}