001package daikon.split;
002
003import java.io.ByteArrayOutputStream;
004import java.io.File;
005import java.io.IOException;
006import java.io.UncheckedIOException;
007import java.time.Duration;
008import java.util.ArrayList;
009import java.util.Arrays;
010import java.util.HashSet;
011import java.util.List;
012import java.util.regex.Matcher;
013import java.util.regex.Pattern;
014import java.util.regex.PatternSyntaxException;
015import org.apache.commons.exec.CommandLine;
016import org.apache.commons.exec.DefaultExecuteResultHandler;
017import org.apache.commons.exec.DefaultExecutor;
018import org.apache.commons.exec.ExecuteException;
019import org.apache.commons.exec.ExecuteWatchdog;
020import org.apache.commons.exec.PumpStreamHandler;
021import org.checkerframework.checker.index.qual.Positive;
022import org.checkerframework.checker.nullness.qual.NonNull;
023import org.checkerframework.checker.regex.qual.Regex;
024import org.checkerframework.common.value.qual.MinLen;
025
026/**
027 * This class has method {@link #compileFiles(List)} that compiles Java source files. It invokes a
028 * user-specified external command, such as {@code javac} or {@code jikes}.
029 */
030public final class FileCompiler {
031
032  /** The Runtime of the JVM. */
033  public static Runtime runtime = java.lang.Runtime.getRuntime();
034
035  /** Matches the names of Java source files. Match group 1 is the complete filename. */
036  static @Regex(1) Pattern java_filename_pattern;
037
038  /**
039   * External command used to compile Java files, and command-line arguments. Guaranteed to be
040   * non-empty.
041   */
042  private String @MinLen(1) [] compiler;
043
044  /** Time limit for compilation jobs. */
045  private long timeLimit;
046
047  static {
048    try {
049      @Regex(1) String java_filename_re
050          // A javac error message may consist of several lines of output.
051          // The filename will be found at the beginning of the first line,
052          // the additional lines of information will all be indented.
053          // (?m) turns on MULTILINE mode so the first "^" matches the
054          // start of each error line output by javac. The blank space after
055          // the second "^" is intentional; together with the first "^", this
056          // says a filename can only be found at the start of a non-indented
057          // line as noted above.
058          = "(?m)^([^ ]+?\\.java)";
059      java_filename_pattern = Pattern.compile(java_filename_re);
060    } catch (PatternSyntaxException me) {
061      me.printStackTrace();
062      throw new Error("Error in regexp", me);
063    }
064  }
065
066  /**
067   * Creates a new FileCompiler. Compared to {@link #FileCompiler(String,long)}, this constructor
068   * permits spaces and other special characters in the command and arguments.
069   *
070   * @param compiler an array of Strings representing a command that runs a Java compiler (it could
071   *     be the full path name or whatever is used on the commandline), plus any command-line
072   *     options
073   * @param timeLimit the maximum permitted compilation time, in msec
074   */
075  public FileCompiler(String @MinLen(1) [] compiler, @Positive long timeLimit) {
076    if (compiler.length == 0) {
077      throw new Error("no compile command was provided");
078    }
079
080    this.compiler = compiler;
081    this.timeLimit = timeLimit;
082  }
083
084  /**
085   * Creates a new FileCompiler. Compared to {@link #FileCompiler(String,long)}, this constructor
086   * permits spaces and other special characters in the command and arguments.
087   *
088   * @param compiler a list of Strings representing a command that runs a Java compiler (it could be
089   *     the full path name or whatever is used on the commandline), plus any command-line options
090   * @param timeLimit the maximum permitted compilation time, in msec
091   */
092  @SuppressWarnings("value") // no index checker list support
093  public FileCompiler(/*(at)MinLen(1)*/ List<String> compiler, @Positive long timeLimit) {
094    this(compiler.toArray(new String[0]), timeLimit);
095  }
096
097  /**
098   * Creates a new FileCompiler.
099   *
100   * @param compiler a command that runs a Java compiler; for instance, it could be the full path
101   *     name or whatever is used on the commandline. It may contain command-line arguments, and is
102   *     split on spaces.
103   * @param timeLimit the maximum permitted compilation time, in msec
104   */
105  public FileCompiler(String compiler, @Positive long timeLimit) {
106    this(compiler.trim().split(" +"), timeLimit);
107  }
108
109  /**
110   * Compiles the files given by fileNames. Returns the error output.
111   *
112   * @param fileNames paths to the files to be compiled as Strings
113   * @return the error output from compiling the files
114   * @throws IOException if there is a problem reading a file
115   */
116  public String compileFiles(List<String> fileNames) throws IOException {
117
118    // System.out.printf("compileFiles: %s%n", fileNames);
119
120    // Start a process to compile all of the files (in one command)
121    String compile_errors = compile_source(fileNames);
122
123    // javac tends to stop without completing the compilation if there
124    // is an error in one of the files.  Remove all the erring files
125    // and recompile only the good ones.
126    if (compiler[0].indexOf("javac") != -1) {
127      recompile_without_errors(fileNames, compile_errors);
128    }
129
130    return compile_errors;
131  }
132
133  /**
134   * Returns the error output from compiling the files.
135   *
136   * @param filenames the paths of the Java source to be compiled as Strings
137   * @return the error output from compiling the files
138   * @throws Error if an empty list of filenames is provided
139   */
140  private String compile_source(List<String> filenames) throws IOException {
141    /* Apache Commons Exec objects */
142    CommandLine cmdLine;
143    DefaultExecuteResultHandler resultHandler;
144    DefaultExecutor executor;
145    ExecuteWatchdog watchdog;
146    ByteArrayOutputStream outStream;
147    ByteArrayOutputStream errStream;
148    PumpStreamHandler streamHandler;
149    String compile_errors;
150    @SuppressWarnings("UnusedVariable") // for debugging
151    String compile_output;
152
153    if (filenames.size() == 0) {
154      throw new Error("no files to compile were provided");
155    }
156
157    cmdLine = new CommandLine(compiler[0]); // constructor requires executable name
158    // add rest of compiler command arguments
159    @SuppressWarnings("nullness") // arguments are in range, so result array contains no nulls
160    @NonNull String[] args = Arrays.copyOfRange(compiler, 1, compiler.length);
161    cmdLine.addArguments(args);
162    // add file name arguments
163    cmdLine.addArguments(filenames.toArray(new String[0]));
164
165    resultHandler = new DefaultExecuteResultHandler();
166    executor = DefaultExecutor.builder().get();
167    watchdog = ExecuteWatchdog.builder().setTimeout(Duration.ofMillis(timeLimit)).get();
168    executor.setWatchdog(watchdog);
169    outStream = new ByteArrayOutputStream();
170    errStream = new ByteArrayOutputStream();
171    streamHandler = new PumpStreamHandler(outStream, errStream);
172    executor.setStreamHandler(streamHandler);
173
174    // System.out.println(); System.out.println("executing compile command: " + cmdLine);
175    try {
176      executor.execute(cmdLine, resultHandler);
177    } catch (IOException e) {
178      throw new UncheckedIOException("exception starting process: " + cmdLine, e);
179    }
180
181    int exitValue = -1;
182    try {
183      resultHandler.waitFor();
184      exitValue = resultHandler.getExitValue();
185    } catch (InterruptedException e) {
186      // Ignore exception, but watchdog.killedProcess() records that the process timed out.
187    }
188    boolean timedOut = executor.isFailure(exitValue) && watchdog.killedProcess();
189
190    try {
191      @SuppressWarnings("DefaultCharset") // toString(Charset) was introduced in Java 10
192      String compile_errors_tmp = errStream.toString();
193      compile_errors = compile_errors_tmp;
194    } catch (RuntimeException e) {
195      throw new Error("Exception getting process error output", e);
196    }
197
198    try {
199      @SuppressWarnings("DefaultCharset") // toString(Charset) was introduced in Java 10
200      String compile_output_tmp = errStream.toString();
201      compile_output = compile_output_tmp;
202    } catch (RuntimeException e) {
203      throw new Error("Exception getting process standard output", e);
204    }
205
206    if (timedOut) {
207      // Print stderr and stdout if there is an unexpected exception (timeout).
208      System.out.println("Compile timed out after " + timeLimit + " msecs");
209      // System.out.println ("Compile errors: " + compile_errors);
210      // System.out.println ("Compile output: " + compile_output);
211      ExecuteException e = resultHandler.getException();
212      if (e != null) {
213        e.printStackTrace();
214      }
215      runtime.exit(1);
216    }
217    return compile_errors;
218  }
219
220  /**
221   * Examine the errorString to identify the files that cannot compile, then recompile all the other
222   * files. This function is necessary when compiling with javac because javac does not compile all
223   * the files supplied to it if some of them contain errors. So some "good" files end up not being
224   * compiled.
225   *
226   * @param fileNames all the files that were attempted to be compiled
227   * @param errorString the error string that indicates which files could not be compiled
228   */
229  private void recompile_without_errors(List<String> fileNames, String errorString)
230      throws IOException {
231    // search the error string and extract the files with errors.
232    if (errorString != null) {
233      HashSet<String> errorClasses = new HashSet<>();
234      Matcher m = java_filename_pattern.matcher(errorString);
235      while (m.find()) {
236        @SuppressWarnings(
237            "nullness") // Regex Checker imprecision: find() guarantees that group 1 exists
238        @NonNull String sansExtension = m.group(1);
239        errorClasses.add(sansExtension);
240      }
241      // Collect all the files that were not compiled into retry
242      List<String> retry = new ArrayList<>();
243      for (String sourceFileName : fileNames) {
244        sourceFileName = sourceFileName.trim();
245        String classFilePath = getClassFilePath(sourceFileName);
246        if (!fileExists(classFilePath)) {
247          if (!errorClasses.contains(sourceFileName)) {
248            retry.add(sourceFileName);
249          }
250        }
251      }
252
253      if (retry.size() > 0) {
254        compile_source(retry);
255      }
256    }
257  }
258
259  /**
260   * Return the file path to where a class file for a source file at sourceFilePath would be
261   * generated.
262   *
263   * @param sourceFilePath the path to the .java file
264   * @return the path to the corresponding .class file
265   */
266  private static String getClassFilePath(String sourceFilePath) {
267    int index = sourceFilePath.lastIndexOf('.');
268    if (index == -1) {
269      throw new IllegalArgumentException(
270          "sourceFilePath: " + sourceFilePath + " must end with an extention.");
271    }
272    return sourceFilePath.substring(0, index) + ".class";
273  }
274
275  /**
276   * Returns true if the given file exists.
277   *
278   * @param pathName path to check for existence
279   * @return true iff the file exists
280   */
281  private static boolean fileExists(String pathName) {
282    return new File(pathName).exists();
283  }
284}