| // Copyright (c) 2026, the R8 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. | |
| package com.android.tools.r8.utils; | |
| import com.android.tools.r8.AssertionsConfiguration; | |
| import com.android.tools.r8.BaseCompilerCommand; | |
| import com.android.tools.r8.ClassFileResourceProvider; | |
| import com.android.tools.r8.Diagnostic; | |
| import com.android.tools.r8.DiagnosticsHandler; | |
| import com.android.tools.r8.DiagnosticsLevel; | |
| import com.android.tools.r8.JdkClassFileProvider; | |
| import com.android.tools.r8.ParseFlagInfo; | |
| import com.android.tools.r8.ParseFlagInfoImpl; | |
| import com.android.tools.r8.ParseFlagPrinter; | |
| import com.android.tools.r8.StringResource; | |
| import com.android.tools.r8.origin.Origin; | |
| import com.android.tools.r8.references.MethodReference; | |
| import com.android.tools.r8.references.Reference; | |
| import com.android.tools.r8.utils.internal.CliParser; | |
| import com.android.tools.r8.utils.internal.CliParserBase; | |
| import com.android.tools.r8.utils.internal.CliParserBase.HelpInfo; | |
| import com.android.tools.r8.utils.internal.CliParserBase.OptionInfo; | |
| import com.android.tools.r8.utils.internal.StringUtils; | |
| import com.android.tools.r8.utils.internal.collections.Pair; | |
| import com.android.tools.r8.utils.internal.exceptions.Unreachable; | |
| import com.google.common.collect.ImmutableList; | |
| 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.function.BiConsumer; | |
| import java.util.function.Consumer; | |
| import java.util.function.Function; | |
| import java.util.function.IntConsumer; | |
| import java.util.function.Predicate; | |
| public class CliParserUtils { | |
| /** The column that help info starts printing at. */ | |
| private static final int DESCRIPTION_OFFSET = 25; | |
| /** The max width of the output text column (by best effort). */ | |
| private static final int MAX_WIDTH = 100; | |
| /** The max width of the description (by best effort). */ | |
| private static final int DESCRIPTION_WIDTH = MAX_WIDTH - DESCRIPTION_OFFSET; | |
| public static List<ParseFlagInfo> getFlagInfos(CliParserBase<?> parser) { | |
| List<ParseFlagInfo> flags = new ArrayList<>(); | |
| for (OptionInfo info : parser.getOptionInfo()) { | |
| flags.add(toParseFlagInfo(info)); | |
| } | |
| return flags; | |
| } | |
| private static ParseFlagInfo toParseFlagInfo(OptionInfo info) { | |
| List<String> helpLines = StringUtils.wrapToWidth(info.description, DESCRIPTION_WIDTH); | |
| List<String> alternatives; | |
| if (info.shorthand != null) { | |
| alternatives = | |
| ImmutableList.of(commandString(info.shorthand, info.suffixLabel, info.paramLabels)); | |
| } else { | |
| alternatives = ImmutableList.of(); | |
| } | |
| return new ParseFlagInfoImpl( | |
| null, | |
| commandString(info.name, info.suffixLabel, info.paramLabels), | |
| alternatives, | |
| helpLines); | |
| } | |
| public static List<ParseFlagInfo> getFlagInfos(CliParser<?> parser) { | |
| return getFlagInfos(parser.baseParser()); | |
| } | |
| /** Returns a string like {@code --output <file>} */ | |
| private static String commandString(String name, String suffixLabel, List<String> paramLabels) { | |
| var sb = new StringBuilder(name); | |
| if (suffixLabel != null) { | |
| sb.append(suffixLabel); | |
| } | |
| for (var label : paramLabels) { | |
| sb.append(' ').append(label); | |
| } | |
| return sb.toString(); | |
| } | |
| public static String getUsageMessage(CliParser<?> parser) { | |
| return getUsageMessage(parser.baseParser()); | |
| } | |
| public static String getUsageMessage(CliParserBase<?> parser) { | |
| var builder = new StringBuilder(parser.getUsageHeader()).append(System.lineSeparator()); | |
| List<ParseFlagInfo> currentFlags = new ArrayList<>(); | |
| for (HelpInfo helpInfo : parser.getHelpInfo()) { | |
| if (helpInfo.isOption()) { | |
| currentFlags.add(toParseFlagInfo(helpInfo.asOption())); | |
| } else if (helpInfo.isHelpText()) { | |
| appendAndClear(currentFlags, builder); | |
| builder.append(helpInfo.asHelpText().text).append(System.lineSeparator()); | |
| } | |
| } | |
| appendAndClear(currentFlags, builder); | |
| return builder.toString(); | |
| } | |
| private static void appendAndClear(List<ParseFlagInfo> currentFlags, StringBuilder builder) { | |
| if (!currentFlags.isEmpty()) { | |
| appendFlags(currentFlags, builder); | |
| currentFlags.clear(); | |
| } | |
| } | |
| private static void appendFlags(List<ParseFlagInfo> currentFlags, StringBuilder builder) { | |
| new ParseFlagPrinter() | |
| .setHelpColumn(DESCRIPTION_OFFSET) | |
| .addFlags(currentFlags) | |
| .appendLinesToBuilder(builder); | |
| } | |
| public static void parsePositiveInt( | |
| String arg, IntConsumer handler, Consumer<String> errorConsumer) { | |
| try { | |
| int parsedArg = Integer.parseInt(arg); | |
| if (parsedArg < 1) { | |
| errorConsumer.accept(arg + " is not a positive integer"); | |
| } else { | |
| handler.accept(parsedArg); | |
| } | |
| } catch (NumberFormatException e) { | |
| errorConsumer.accept(arg + " is not an integer"); | |
| } | |
| } | |
| public static void parseUncheckedApiLevel( | |
| String arg, Consumer<UncheckedApiLevel> handler, Consumer<String> errorConsumer) { | |
| try { | |
| handler.accept(UncheckedApiLevel.parse(arg)); | |
| } catch (IllegalArgumentException e) { | |
| // Note that NumberFormatException is a subclass of IllegalArgumentException. | |
| String rawMessage = e.getMessage(); | |
| String message = rawMessage == null ? "" : ", " + rawMessage; | |
| errorConsumer.accept("Invalid API version: " + arg + message); | |
| } | |
| } | |
| public static DiagnosticsLevel parseDiagnosticsLevel( | |
| String level, Consumer<Diagnostic> errorHandler, Origin origin) { | |
| switch (level) { | |
| case "error": | |
| return DiagnosticsLevel.ERROR; | |
| case "warning": | |
| return DiagnosticsLevel.WARNING; | |
| case "info": | |
| return DiagnosticsLevel.INFO; | |
| case "none": | |
| return DiagnosticsLevel.NONE; | |
| default: | |
| errorHandler.accept( | |
| new StringDiagnostic( | |
| "Invalid diagnostics level '" | |
| + level | |
| + "'. Valid levels are 'error', 'warning', 'info' and 'none'.", | |
| origin)); | |
| return null; | |
| } | |
| } | |
| public static class DiagnosticsMapping { | |
| public final String diagnosticType; | |
| public final DiagnosticsLevel from; | |
| public final DiagnosticsLevel to; | |
| public DiagnosticsMapping(String diagnosticType, DiagnosticsLevel from, DiagnosticsLevel to) { | |
| this.diagnosticType = diagnosticType; | |
| this.from = from; | |
| this.to = to; | |
| } | |
| } | |
| public static <B> Consumer<CliParser<B>> addDesugaredLibOption( | |
| BiConsumer<B, StringResource> action) { | |
| return parser -> | |
| parser.option1( | |
| "--desugared-lib", | |
| "<file>", | |
| "Specify a JSON desugared library configuration <file>.", | |
| (state, arg) -> action.accept(state, StringResource.fromFile(Paths.get(arg)))); | |
| } | |
| public static <T, C extends BaseCompilerCommand, B extends BaseCompilerCommand.Builder<C, B>> | |
| Consumer<CliParser<T>> addForceAssertionOptions( | |
| Function<T, B> getBuilder, Function<T, Origin> getOrigin) { | |
| return parser -> | |
| parser | |
| .prefix0( | |
| "--force-enable-assertions", | |
| "[:[<class name>|<package name>...]]", | |
| "Forcefully enable javac generated assertion code.", | |
| (state, suffix) -> { | |
| B builder = getBuilder.apply(state); | |
| String scope = parseAssertionScope(builder, suffix, getOrigin.apply(state)); | |
| addAssertionTransformation( | |
| builder, AssertionTransformationType.ENABLE, null, scope); | |
| }, | |
| "--force-ea") | |
| .prefix0( | |
| "--force-disable-assertions", | |
| "[:[<class name>|<package name>...]]", | |
| "Forcefully disable javac generated assertion code. This is the default handling of" | |
| + " javac assertion code when generating DEX file format.", | |
| (state, suffix) -> { | |
| B builder = getBuilder.apply(state); | |
| String scope = parseAssertionScope(builder, suffix, getOrigin.apply(state)); | |
| addAssertionTransformation( | |
| builder, AssertionTransformationType.DISABLE, null, scope); | |
| }, | |
| "--force-da") | |
| .prefix0( | |
| "--force-passthrough-assertions", | |
| "[:[<class name>|<package name>...]]", | |
| "Don't change javac generated assertion code. This is the default handling of" | |
| + " javac assertion code when generating class file format.", | |
| (state, suffix) -> { | |
| B builder = getBuilder.apply(state); | |
| String scope = parseAssertionScope(builder, suffix, getOrigin.apply(state)); | |
| addAssertionTransformation( | |
| builder, AssertionTransformationType.PASSTHROUGH, null, scope); | |
| }, | |
| "--force-pa") | |
| .prefix0( | |
| "--force-assertions-handler", | |
| ":<handler method>[:[<class name>|<package name>...]]", | |
| "Change javac and kotlinc generated assertion code to invoke the method <handler" | |
| + " method> with each assertion error instead of throwing it. The <handler" | |
| + " method> is specified as a class name followed by a dot and the method name." | |
| + " The handler method must take a single argument of type java.lang.Throwable" | |
| + " and have return type void.", | |
| (state, suffix) -> { | |
| B builder = getBuilder.apply(state); | |
| Pair<MethodReference, String> handlerAndScope = | |
| parseAssertionHandler(builder, suffix, getOrigin.apply(state)); | |
| addAssertionTransformation( | |
| builder, | |
| AssertionTransformationType.HANDLER, | |
| handlerAndScope.getFirst(), | |
| handlerAndScope.getSecond()); | |
| }, | |
| "--force-ah"); | |
| } | |
| public static <B> Consumer<CliParser<B>> addHelpOption(Consumer<B> action) { | |
| return parser -> parser.option0("--help", "Print usage information.", action, "-h"); | |
| } | |
| public static <B> Consumer<CliParser<B>> addLibOption( | |
| BiConsumer<B, ClassFileResourceProvider> providerConsumer, | |
| BiConsumer<B, Path> fileConsumer, | |
| Function<B, Origin> getOrigin, | |
| Function<B, ? extends DiagnosticsHandler> getDiagnosticsHandler) { | |
| return parser -> | |
| parser.option1( | |
| "--lib", | |
| "<path>", | |
| "Add <path> as a library resource. <path> must be a jar/zip file or a JDK home" | |
| + " directory.", | |
| (state, arg) -> { | |
| Path path = Paths.get(arg); | |
| if (isJdkHome(path)) { | |
| try { | |
| providerConsumer.accept(state, JdkClassFileProvider.fromJdkHome(path)); | |
| } catch (IOException e) { | |
| getDiagnosticsHandler | |
| .apply(state) | |
| .error(new ExceptionDiagnostic(e, getOrigin.apply(state))); | |
| } | |
| } else { | |
| fileConsumer.accept(state, path); | |
| } | |
| }); | |
| } | |
| /** | |
| * This method must match the lookup in {@link | |
| * com.android.tools.r8.JdkClassFileProvider#fromJdkHome}. | |
| */ | |
| private static boolean isJdkHome(Path home) { | |
| Path jrtFsJar = home.resolve("lib").resolve("jrt-fs.jar"); | |
| if (Files.exists(jrtFsJar)) { | |
| return true; | |
| } | |
| // JDK has rt.jar in jre/lib/rt.jar. | |
| Path rtJar = home.resolve("jre").resolve("lib").resolve("rt.jar"); | |
| if (Files.exists(rtJar)) { | |
| return true; | |
| } | |
| // JRE has rt.jar in lib/rt.jar. | |
| rtJar = home.resolve("lib").resolve("rt.jar"); | |
| return Files.exists(rtJar); | |
| } | |
| public static <B> Consumer<CliParser<B>> addMapDiagnosticsOption( | |
| BiConsumer<B, DiagnosticsMapping> action, | |
| BiConsumer<B, Diagnostic> errorHandler, | |
| Function<B, Origin> getOrigin) { | |
| return parser -> | |
| parser.prefix2( | |
| "--map-diagnostics", | |
| "[:<type>]", | |
| "<from-level>", | |
| "<to-level>", | |
| "Map diagnostics of <type> (default any) reported as <from-level> to <to-level> where" | |
| + " <from-level> and <to-level> are one of 'none', 'info', 'warning', or 'error'," | |
| + " and the optional <type> is either the simple or fully qualified Java type name" | |
| + " of a diagnostic. If <type> is unspecified, all diagnostics at <from-level> will" | |
| + " be mapped. Note that fatal compiler errors cannot be mapped.", | |
| (state, suffix, fromLevel, toLevel) -> { | |
| Consumer<Diagnostic> errorHandler1 = error -> errorHandler.accept(state, error); | |
| Origin origin = getOrigin.apply(state); | |
| String diagnosticsClassName = ""; | |
| if (!suffix.isEmpty()) { | |
| if (suffix.length() == 1 || suffix.charAt(0) != ':') { | |
| errorHandler1.accept( | |
| new StringDiagnostic( | |
| "Invalid diagnostics type specification --map-diagnostics" + suffix + ".", | |
| origin)); | |
| return; | |
| } | |
| diagnosticsClassName = suffix.substring(1); | |
| } | |
| DiagnosticsLevel fromLevel1 = parseDiagnosticsLevel(fromLevel, errorHandler1, origin); | |
| DiagnosticsLevel toLevel1 = parseDiagnosticsLevel(toLevel, errorHandler1, origin); | |
| if (fromLevel1 != null && toLevel1 != null) { | |
| if (fromLevel1 == DiagnosticsLevel.NONE) { | |
| errorHandler1.accept( | |
| new StringDiagnostic("Cannot map from diagnostics level 'none'.", origin)); | |
| return; | |
| } | |
| action.accept( | |
| state, new DiagnosticsMapping(diagnosticsClassName, fromLevel1, toLevel1)); | |
| } | |
| }); | |
| } | |
| public static <B> Consumer<CliParser<B>> addMapDiagnosticsOption( | |
| Function<B, Reporter> getReporter, Function<B, Origin> getOrigin) { | |
| return addMapDiagnosticsOption( | |
| (state, mapping) -> | |
| getReporter | |
| .apply(state) | |
| .addDiagnosticsLevelMapping(mapping.from, mapping.diagnosticType, mapping.to), | |
| (state, error) -> getReporter.apply(state).error(error), | |
| getOrigin); | |
| } | |
| /** | |
| * Adds the {@code --min-api} option using {@link AndroidApiLevel#getDefault()} as the default API | |
| * level listed in the option description. | |
| */ | |
| public static <B> Consumer<CliParser<B>> addMinApiOption( | |
| Predicate<B> hasDefinedApiLevel, | |
| BiConsumer<B, UncheckedApiLevel> action, | |
| BiConsumer<B, String> errorHandler) { | |
| return addMinApiOption(hasDefinedApiLevel, action, errorHandler, AndroidApiLevel.getDefault()); | |
| } | |
| public static <B> Consumer<CliParser<B>> addMinApiOption( | |
| Predicate<B> hasDefinedApiLevel, | |
| BiConsumer<B, UncheckedApiLevel> action, | |
| BiConsumer<B, String> errorHandler, | |
| AndroidApiLevel defaultApiLevel) { | |
| return parser -> | |
| parser.option1( | |
| "--min-api", | |
| "<number>[.<number>]", | |
| "Minimum Android API level compatibility (default: " | |
| + defaultApiLevel.getNumericString() | |
| + ").", | |
| (state, arg) -> { | |
| if (hasDefinedApiLevel.test(state)) { | |
| errorHandler.accept(state, "Cannot set multiple --min-api options"); | |
| } else { | |
| parseUncheckedApiLevel( | |
| arg, | |
| apiLevel -> action.accept(state, apiLevel), | |
| error -> errorHandler.accept(state, "Invalid argument to --min-api: " + error)); | |
| } | |
| }); | |
| } | |
| public enum OutputOptionType { | |
| ZIP_OR_EXISTING_DIR( | |
| "<path>", "Output result in <path>. <path> must be a zip file or an existing directory."), | |
| FILE("<file>", "Output result in <file>."); | |
| private final String paramLabel; | |
| private final String description; | |
| OutputOptionType(String paramLabel, String description) { | |
| this.paramLabel = paramLabel; | |
| this.description = description; | |
| } | |
| } | |
| public static <B> Consumer<CliParser<B>> addOutputOption( | |
| Predicate<B> hasSetOutputPathAlready, | |
| BiConsumer<B, Path> action, | |
| BiConsumer<B, String> errorHandler, | |
| OutputOptionType outputOptionType) { | |
| return parser -> | |
| parser.option1( | |
| "--output", | |
| outputOptionType.paramLabel, | |
| outputOptionType.description, | |
| (state, arg) -> { | |
| if (hasSetOutputPathAlready.test(state)) { | |
| errorHandler.accept(state, "Cannot set multiple --output options"); | |
| } else { | |
| action.accept(state, Paths.get(arg)); | |
| } | |
| }); | |
| } | |
| public static <B> Consumer<CliParser<B>> addThreadCountOption( | |
| BiConsumer<B, Integer> action, | |
| Function<B, ? extends DiagnosticsHandler> getDiagnosticsHandler, | |
| Function<B, Origin> getOrigin) { | |
| return parser -> | |
| parser.option1( | |
| "--thread-count", | |
| "<number>", | |
| "Use <number> of threads. If not specified the number will be based on heuristics" | |
| + " taking the number of cores into account.", | |
| (state1, arg) -> | |
| parsePositiveInt( | |
| arg, | |
| threadCount -> action.accept(state1, threadCount), | |
| error1 -> | |
| getDiagnosticsHandler | |
| .apply(state1) | |
| .error( | |
| new StringDiagnostic( | |
| "Invalid argument to --thread-count: " + error1, | |
| getOrigin.apply(state1))))); | |
| } | |
| public static <B> Consumer<CliParser<B>> addVersionOption(Consumer<B> action) { | |
| return parser -> parser.option0("--version", "Print version.", action, "-v"); | |
| } | |
| private enum AssertionTransformationType { | |
| ENABLE, | |
| DISABLE, | |
| PASSTHROUGH, | |
| HANDLER | |
| } | |
| private static AssertionsConfiguration.Builder prepareBuilderForScope( | |
| AssertionsConfiguration.Builder builder, | |
| AssertionTransformationType transformation, | |
| MethodReference assertionHandler) { | |
| switch (transformation) { | |
| case ENABLE: | |
| return builder.setCompileTimeEnable(); | |
| case DISABLE: | |
| return builder.setCompileTimeDisable(); | |
| case PASSTHROUGH: | |
| return builder.setPassthrough(); | |
| case HANDLER: | |
| return builder.setAssertionHandler(assertionHandler); | |
| default: | |
| throw new Unreachable(); | |
| } | |
| } | |
| private static <C extends BaseCompilerCommand, B extends BaseCompilerCommand.Builder<C, B>> | |
| void addAssertionTransformation( | |
| B builder, | |
| AssertionTransformationType transformation, | |
| MethodReference assertionHandler, | |
| String scope) { | |
| if (scope == null) { | |
| builder.addAssertionsConfiguration( | |
| b -> prepareBuilderForScope(b, transformation, assertionHandler).setScopeAll().build()); | |
| } else { | |
| assert !scope.isEmpty(); | |
| String packageAssertionPostfix = "..."; | |
| if (scope.endsWith(packageAssertionPostfix)) { | |
| builder.addAssertionsConfiguration( | |
| b -> | |
| prepareBuilderForScope(b, transformation, assertionHandler) | |
| .setScopePackage( | |
| scope.substring(0, scope.length() - packageAssertionPostfix.length())) | |
| .build()); | |
| } else { | |
| builder.addAssertionsConfiguration( | |
| b -> | |
| prepareBuilderForScope(b, transformation, assertionHandler) | |
| .setScopeClass(scope) | |
| .build()); | |
| } | |
| } | |
| } | |
| private static <C extends BaseCompilerCommand, B extends BaseCompilerCommand.Builder<C, B>> | |
| String parseAssertionScope(B builder, String suffix, Origin origin) { | |
| if (suffix.isEmpty()) { | |
| return null; | |
| } | |
| if (suffix.equals(":")) { | |
| throw builder.fatalError(new StringDiagnostic("Missing optional argument", origin)); | |
| } | |
| if (!suffix.startsWith(":")) { | |
| builder.error(new StringDiagnostic("Illegal assertion scope: " + suffix, origin)); | |
| return null; | |
| } | |
| String classOrPackageScope = suffix.substring(1); | |
| if (classOrPackageScope.contains(";") | |
| || classOrPackageScope.contains("[") | |
| || classOrPackageScope.contains("/")) { | |
| builder.error( | |
| new StringDiagnostic("Illegal assertion scope: " + classOrPackageScope, origin)); | |
| } | |
| return classOrPackageScope; | |
| } | |
| private static <C extends BaseCompilerCommand, B extends BaseCompilerCommand.Builder<C, B>> | |
| Pair<MethodReference, String> parseAssertionHandler(B builder, String suffix, Origin origin) { | |
| if (suffix.isEmpty() || suffix.equals(":")) { | |
| throw builder.fatalError( | |
| new StringDiagnostic("Missing required argument <handler method>", origin)); | |
| } | |
| if (!suffix.startsWith(":")) { | |
| throw builder.fatalError( | |
| new StringDiagnostic("Missing required argument <handler method>", origin)); | |
| } | |
| String remaining = suffix.substring(1); | |
| int index = remaining.indexOf(':'); | |
| if (index == 0) { | |
| throw builder.fatalError( | |
| new StringDiagnostic("Missing required argument <handler method>", origin)); | |
| } | |
| String assertionsHandlerString = index > 0 ? remaining.substring(0, index) : remaining; | |
| int lastDotIndex = assertionsHandlerString.lastIndexOf('.'); | |
| if (assertionsHandlerString.length() < 3 | |
| || lastDotIndex <= 0 | |
| || lastDotIndex == assertionsHandlerString.length() - 1 | |
| || !DescriptorUtils.isValidJavaType(assertionsHandlerString.substring(0, lastDotIndex))) { | |
| throw builder.fatalError( | |
| new StringDiagnostic( | |
| "Invalid argument <handler method>: " + assertionsHandlerString, origin)); | |
| } | |
| MethodReference assertionsHandler = | |
| Reference.methodFromDescriptor( | |
| DescriptorUtils.javaTypeToDescriptor( | |
| assertionsHandlerString.substring(0, lastDotIndex)), | |
| assertionsHandlerString.substring(lastDotIndex + 1), | |
| "(Ljava/lang/Throwable;)V"); | |
| String scopeSuffix = remaining.substring(assertionsHandlerString.length()); | |
| String scope = parseAssertionScope(builder, scopeSuffix, origin); | |
| return Pair.create(assertionsHandler, scope); | |
| } | |
| } |