001package daikon.split;
002
003import java.io.ByteArrayOutputStream;
004import java.io.File;
005import java.io.IOException;
006import java.io.UncheckedIOException;
007import java.util.ArrayList;
008import java.util.Arrays;
009import java.util.HashSet;
010import java.util.List;
011import java.util.regex.Matcher;
012import java.util.regex.Pattern;
013import java.util.regex.PatternSyntaxException;
014import org.apache.commons.exec.CommandLine;
015import org.apache.commons.exec.DefaultExecuteResultHandler;
016import org.apache.commons.exec.DefaultExecutor;
017import org.apache.commons.exec.ExecuteException;
018import org.apache.commons.exec.ExecuteWatchdog;
019import org.apache.commons.exec.PumpStreamHandler;
020import org.checkerframework.checker.index.qual.Positive;
021import org.checkerframework.checker.nullness.qual.NonNull;
022import org.checkerframework.checker.regex.qual.Regex;
023import org.checkerframework.common.value.qual.MinLen;
024
025/**
026 * This class has method {@link #compileFiles(List)} that compiles Java source files. It invokes a
027 * user-specified external command, such as {@code javac} or {@code jikes}.
028 */
029public final class FileCompiler {
030
031  /** The Runtime of the JVM. */
032  public static Runtime runtime = java.lang.Runtime.getRuntime();
033
034  /** Matches the names of Java source files. Match group 1 is the complete filename. */
035  static @Regex(1) Pattern java_filename_pattern;
036
037  /**
038   * External command used to compile Java files, and command-line arguments. Guaranteed to be
039   * non-empty.
040   */
041  private String @MinLen(1) [] compiler;
042
043  /** Time limit for compilation jobs. */
044  private long timeLimit;
045
046  static {
047    try {
048      @Regex(1) String java_filename_re
049          // A javac error message may consist of several lines of output.
050          // The filename will be found at the beginning of the first line,
051          // the additional lines of information will all be indented.
052          // (?m) turns on MULTILINE mode so the first "^" matches the
053          // start of each error line output by javac. The blank space after
054          // the second "^" is intentional; together with the first "^", this
055          // says a filename can only be found at the start of a non-indented
056          // line as noted above.
057          = "(?m)^([^ ]+?\\.java)";
058      java_filename_pattern = Pattern.compile(java_filename_re);
059    } catch (PatternSyntaxException me) {
060      me.printStackTrace();
061      throw new Error("Error in regexp", me);
062    }
063  }
064
065  /**
066   * Creates a new FileCompiler. Compared to {@link #FileCompiler(String,long)}, this constructor
067   * permits spaces and other special characters in the command and arguments.
068   *
069   * @param compiler an array of Strings representing a command that runs a Java compiler (it could
070   *     be the full path name or whatever is used on the commandline), plus any command-line
071   *     options
072   * @param timeLimit the maximum permitted compilation time, in msec
073   */
074  public FileCompiler(String @MinLen(1) [] compiler, @Positive long timeLimit) {
075    if (compiler.length == 0) {
076      throw new Error("no compile command was provided");
077    }
078
079    this.compiler = compiler;
080    this.timeLimit = timeLimit;
081  }
082
083  /**
084   * Creates a new FileCompiler. Compared to {@link #FileCompiler(String,long)}, this constructor
085   * permits spaces and other special characters in the command and arguments.
086   *
087   * @param compiler a list of Strings representing a command that runs a Java compiler (it could be
088   *     the full path name or whatever is used on the commandline), plus any command-line options
089   * @param timeLimit the maximum permitted compilation time, in msec
090   */
091  @SuppressWarnings("value") // no index checker list support
092  public FileCompiler(/*(at)MinLen(1)*/ ArrayList<String> compiler, @Positive long timeLimit) {
093    this(compiler.toArray(new String[0]), timeLimit);
094  }
095
096  /**
097   * Creates a new FileCompiler.
098   *
099   * @param compiler a command that runs a Java compiler; for instance, it could be the full path
100   *     name or whatever is used on the commandline. It may contain command-line arguments, and is
101   *     split on spaces.
102   * @param timeLimit the maximum permitted compilation time, in msec
103   */
104  public FileCompiler(String compiler, @Positive long timeLimit) {
105    this(compiler.trim().split(" +"), timeLimit);
106  }
107
108  /**
109   * Compiles the files given by fileNames. Returns the error output.
110   *
111   * @param fileNames paths to the files to be compiled as Strings
112   * @return the error output from compiling the files
113   * @throws IOException if there is a problem reading a file
114   */
115  public String compileFiles(List<String> fileNames) throws IOException {
116
117    // System.out.printf("compileFiles: %s%n", fileNames);
118
119    // Start a process to compile all of the files (in one command)
120    String compile_errors = compile_source(fileNames);
121
122    // javac tends to stop without completing the compilation if there
123    // is an error in one of the files.  Remove all the erring files
124    // and recompile only the good ones.
125    if (compiler[0].indexOf("javac") != -1) {
126      recompile_without_errors(fileNames, compile_errors);
127    }
128
129    return compile_errors;
130  }
131
132  /**
133   * Returns the error output from compiling the files.
134   *
135   * @param filenames the paths of the Java source to be compiled as Strings
136   * @return the error output from compiling the files
137   * @throws Error if an empty list of filenames is provided
138   */
139  private String compile_source(List<String> filenames) throws IOException {
140    /* Apache Commons Exec objects */
141    CommandLine cmdLine;
142    DefaultExecuteResultHandler resultHandler;
143    DefaultExecutor executor;
144    ExecuteWatchdog watchdog;
145    ByteArrayOutputStream outStream;
146    ByteArrayOutputStream errStream;
147    PumpStreamHandler streamHandler;
148    String compile_errors;
149    @SuppressWarnings("UnusedVariable") // for debugging
150    String compile_output;
151
152    if (filenames.size() == 0) {
153      throw new Error("no files to compile were provided");
154    }
155
156    cmdLine = new CommandLine(compiler[0]); // constructor requires executable name
157    // add rest of compiler command arguments
158    @SuppressWarnings("nullness") // arguments are in range, so result array contains no nulls
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 = new DefaultExecutor();
166    watchdog = new ExecuteWatchdog(timeLimit);
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.size() > 0) {
253        compile_source(retry);
254      }
255    }
256  }
257
258  /**
259   * Return 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 extention.");
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}