/* * Copyright (C) 2014 Facebook, Inc. * * 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.facebook.tools.io; import com.facebook.tools.subprocess.SubprocessBuilder; import java.io.Console; import java.io.PrintStream; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.LinkedHashMap; import java.util.Map; /** * Container for standard input/output-related objects. Mimics {@link java.lang.System} with the * following public fields: * <dl> * <dt>{@link #out}</dt> * <dd>A {@link com.facebook.tools.io.StatusPrintStream} instance for stdout-like output</dd> * <dt>{@link #err}</dt> * <dd>A {@link com.facebook.tools.io.PrintStreamPlus} instance for stderr-like output</dd> * <dt>{@link #in}</dt> * <dd>A {@link com.facebook.tools.io.Input} instance for stdin-like input</dd> * </dl> * <p/> * The {@link #ask(Enum, String, Object...)} and {@link #ask(Enum, String)} methods can be used to * easily prompt for decisions. * <p/> * Also included is a {@link #subprocess} builder for spawing new processes. * <p/> * Some behavior depends on whether Java is being run on an interactive terminal or spawned from * another process, e.g., {@literal java -jar my-tool.jar} vs {@literal java -jar my-tool.jar | wc}. * If interactive, output to {@link #err} inserts * <a href="http://en.wikipedia.org/wiki/ANSI_escape_code#Colors">ANSII escape codes</a> to render * error output as white text on a bright red background. Additional escape codes are used so that * consecutive {@link com.facebook.tools.io.Status} methods overwrite previous ones, making them * appropriate for outputting status that would otherwise be too spammy, e.g., * {@code io.out.statusf("Finished %s of %s", done, total);}. * If non-interactive, no escape codes are inserted, and {@link com.facebook.tools.io.Status} * methods do nothing. */ public class IO { private static final String WHITE_ON_RED = "\033[1;37;41m"; private static final String DEFAULT_COLORS = "\033[0m"; public final StatusPrintStream out; public final PrintStreamPlus err; public final Input in; public final SubprocessBuilder subprocess; public IO(PrintStream out, PrintStream err, Input in, SubprocessBuilder subprocess) { Console console = System.console(); if (console == null) { this.out = new NoninteractiveStatusPrintStream(out); this.err = new NoninteractiveStatusPrintStream(err); } else { ConsoleStatus status = new ConsoleStatus(console.writer()); this.out = new InteractiveStatusPrintStream(out, status, DEFAULT_COLORS); this.err = new InteractiveStatusPrintStream(err, status, WHITE_ON_RED); Runtime.getRuntime().addShutdownHook( new Thread( new Runnable() { @Override public void run() { // reset console color and erase final status line IO.this.out.print(""); } } ) ); } this.in = in; this.subprocess = subprocess; } /** * Creates a new container using {@link java.lang.System#out}, {@link java.lang.System#err}, * and {@link java.lang.System#in}. */ public IO() { this(System.out, System.err, new InputStreamInput(System.in)); } public IO(PrintStream out, PrintStream err, Input in) { this(out, err, in, new SubprocessBuilder()); } /** * Convenience method equivalent to <code>ask(defaultValue, String.format(format, args))</code>. */ public <T extends Enum> T ask(T defaultValue, String format, Object... args) { return ask(defaultValue, String.format(format, args)); } /** * Prompts the user to make a decision. For example: * <pre><code> * if (io.ask(YesNo.YES, "Are you sure you want to dance").isYes()) { * io.out.println("Dance party!!"); * } else { * io.out.println("Some other time, then…"); * } * <p/> * </code></pre> * <pre><tt> * > Are you sure you want to dance? [Y/n] x * > Are you sure you want to dance? [Y/n] this is not a valid response * > Are you sure you want to dance? [Y/n] y * > Dance party!! * </tt></pre> * The options shown are determined by {@code defaultValue} which must be an {@code enum} with * its members annotated with {@link com.facebook.tools.io.Answer}. The first * {@link com.facebook.tools.io.Answer#value()} is show in the brackets after the prompt, but any * of the values is accepted. The values must all be lower-case; the default value, i.e., the one * used if the user simply hits enter, is capitalized. * * @param defaultValue value to use if no input is given * @param prompt message to show user * @return the {@code enum} value corresponding to the user's input */ public <T extends Enum> T ask(T defaultValue, String prompt) { Class<T> enumClass = defaultValue.getDeclaringClass(); Field[] fields = enumClass.getFields(); Map<String, T> answers = new LinkedHashMap<>(); StringBuilder displayValues = new StringBuilder(16); for (Field field : fields) { Answer annotation = field.getAnnotation(Answer.class); if (annotation != null) { int modifiers = field.getModifiers(); if (enumClass.isAssignableFrom(field.getType()) && Modifier.isStatic(modifiers) && Modifier.isPublic(modifiers)) { String fieldName = enumClass.getName() + "." + field.getName(); T answer; try { answer = (T) field.get(enumClass); } catch (IllegalAccessException | RuntimeException e) { throw new IllegalArgumentException("Error accessing " + fieldName, e); } String[] values = annotation.value(); if (values == null || values.length == 0) { throw new NullPointerException("Missing values for " + fieldName); } for (String value : values) { if (value == null) { throw new NullPointerException("Null value for " + fieldName); } if (!value.equals(value.trim().toLowerCase())) { throw new IllegalArgumentException( String.format("Values must be all lower-case, but got %s for %s", value, fieldName) ); } T existing = answers.put(value, answer); if (existing != null) { throw new IllegalArgumentException( String.format("Duplicate value %s for %s", value, fieldName) ); } } if (displayValues.length() > 0) { displayValues.append("/"); } if (defaultValue == answer) { displayValues.append(values[0].toUpperCase()); } else { displayValues.append(values[0]); } } } } if (answers.isEmpty()) { throw new IllegalArgumentException("No fields annotated with @PromptAnswer in " + enumClass); } String fullPrompt = String.format("%s [%s]? ", prompt, displayValues); T answer; do { out.print(fullPrompt); out.flush(); String response = in.readLine(); response = response == null ? "" : response.trim().toLowerCase(); if (response.isEmpty()) { answer = defaultValue; } else { answer = answers.get(response); } } while (answer == null); return answer; } }