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.isEmpty()) {
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    @NonNull String[] args = Arrays.copyOfRange(compiler, 1, compiler.length);
160    cmdLine.addArguments(args);
161    // add file name arguments
162    cmdLine.addArguments(filenames.toArray(new String[0]));
163
164    resultHandler = new DefaultExecuteResultHandler();
165    executor = DefaultExecutor.builder().get();
166    watchdog = ExecuteWatchdog.builder().setTimeout(Duration.ofMillis(timeLimit)).get();
167    executor.setWatchdog(watchdog);
168    outStream = new ByteArrayOutputStream();
169    errStream = new ByteArrayOutputStream();
170    streamHandler = new PumpStreamHandler(outStream, errStream);
171    executor.setStreamHandler(streamHandler);
172
173    // System.out.println(); System.out.println("executing compile command: " + cmdLine);
174    try {
175      executor.execute(cmdLine, resultHandler);
176    } catch (IOException e) {
177      throw new UncheckedIOException("exception starting process: " + cmdLine, e);
178    }
179
180    int exitValue = -1;
181    try {
182      resultHandler.waitFor();
183      exitValue = resultHandler.getExitValue();
184    } catch (InterruptedException e) {
185      // Ignore exception, but watchdog.killedProcess() records that the process timed out.
186    }
187    boolean timedOut = executor.isFailure(exitValue) && watchdog.killedProcess();
188
189    try {
190      @SuppressWarnings("DefaultCharset") // toString(Charset) was introduced in Java 10
191      String compile_errors_tmp = errStream.toString();
192      compile_errors = compile_errors_tmp;
193    } catch (RuntimeException e) {
194      throw new Error("Exception getting process error output", e);
195    }
196
197    try {
198      @SuppressWarnings("DefaultCharset") // toString(Charset) was introduced in Java 10
199      String compile_output_tmp = errStream.toString();
200      compile_output = compile_output_tmp;
201    } catch (RuntimeException e) {
202      throw new Error("Exception getting process standard output", e);
203    }
204
205    if (timedOut) {
206      // Print stderr and stdout if there is an unexpected exception (timeout).
207      System.out.println("Compile timed out after " + timeLimit + " msecs");
208      // System.out.println ("Compile errors: " + compile_errors);
209      // System.out.println ("Compile output: " + compile_output);
210      ExecuteException e = resultHandler.getException();
211      if (e != null) {
212        e.printStackTrace();
213      }
214      runtime.exit(1);
215    }
216    return compile_errors;
217  }
218
219  /**
220   * Examine the errorString to identify the files that cannot compile, then recompile all the other
221   * files. This function is necessary when compiling with javac because javac does not compile all
222   * the files supplied to it if some of them contain errors. So some "good" files end up not being
223   * compiled.
224   *
225   * @param fileNames all the files that were attempted to be compiled
226   * @param errorString the error string that indicates which files could not be compiled
227   */
228  private void recompile_without_errors(List<String> fileNames, String errorString)
229      throws IOException {
230    // search the error string and extract the files with errors.
231    if (errorString != null) {
232      HashSet<String> errorClasses = new HashSet<>();
233      Matcher m = java_filename_pattern.matcher(errorString);
234      while (m.find()) {
235        @SuppressWarnings(
236            "nullness") // Regex Checker imprecision: find() guarantees that group 1 exists
237        @NonNull String sansExtension = m.group(1);
238        errorClasses.add(sansExtension);
239      }
240      // Collect all the files that were not compiled into retry
241      List<String> retry = new ArrayList<>();
242      for (String sourceFileName : fileNames) {
243        sourceFileName = sourceFileName.trim();
244        String classFilePath = getClassFilePath(sourceFileName);
245        if (!fileExists(classFilePath)) {
246          if (!errorClasses.contains(sourceFileName)) {
247            retry.add(sourceFileName);
248          }
249        }
250      }
251
252      if (!retry.isEmpty()) {
253        compile_source(retry);
254      }
255    }
256  }
257
258  /**
259   * Returns the file path to where a class file for a source file at sourceFilePath would be
260   * generated.
261   *
262   * @param sourceFilePath the path to the .java file
263   * @return the path to the corresponding .class file
264   */
265  private static String getClassFilePath(String sourceFilePath) {
266    int index = sourceFilePath.lastIndexOf('.');
267    if (index == -1) {
268      throw new IllegalArgumentException(
269          "sourceFilePath: " + sourceFilePath + " must end with an extension.");
270    }
271    return sourceFilePath.substring(0, index) + ".class";
272  }
273
274  /**
275   * Returns true if the given file exists.
276   *
277   * @param pathName path to check for existence
278   * @return true iff the file exists
279   */
280  private static boolean fileExists(String pathName) {
281    return new File(pathName).exists();
282  }
283}