AbstractPluginMojo.java

/*
 * Copyright (c) 2025-2026, Marc Mazas <mazas.marc@gmail.com>.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *     * Redistributions of source code must retain the above copyright notice,
 *       this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above copyright
 *       notice, this list of conditions and the following disclaimer in the
 *       documentation and/or other materials provided with the distribution.
 *     * Neither the names of the copyright holders nor the names of its
 *       contributors may be used to endorse or promote products derived from
 *       this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
 * THE POSSIBILITY OF SUCH DAMAGE.
 */
package org.javacc.mojo;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.util.FileUtils;

/**
 * Super class, managing common plugin-level parameters and methods, of the concrete mojos that
 * generate parser and tree code files from the grammar files.
 *
 * <p>
 * Each subclass manages a goal that
 *
 * <ul>
 * <li>manages the corresponding processor(s) command line arguments (options) as maven parameters,
 * with the help of corresponding beans, and
 * <li>triggers execution of one or more processors
 * </ul>
 *
 * <p>
 * One can set a specific log level for all classes of this plugin (without setting the global
 * <code>-X</code> argument) through the (standard maven SLF4J Simple configuration) property<br>
 * <code>-Dorg.slf4j.simpleLogger.log.org.javacc.mojo=debug</code>.<br>
 *
 * @since 3.8.0
 * @author Maͫzͣaͬsͨ
 * @see AbstractProcessor
 * @see AbstractArgumentsBean
 * @see AbstractPluginReport
 */
public abstract class AbstractPluginMojo extends AbstractMojo {
  
  /** The current Maven project. */
  @Parameter(defaultValue = "${project}", readonly = true, required = true) //
  protected MavenProject project;
  
  /**
   * The set of Ant-like exclusion patterns used to prevent certain files from being processed.<br>
   * By default, this set is empty such that no files are excluded.
   */
  @Parameter //
  protected String[] excludes;
  
  /**
   * The fail on plugin error flag.<br>
   * It governs how the plugin will handle the errors it encountered on general configuration (i.e.
   * parameters not related to grammars).<br>
   * Possible values are <code>true</code> and <code>false</code>.<br>
   * On the first error: if set to <code>false</code>, the error message is displayed but the plugin
   * will not report an error for the current execution (i.e. it will continue with the next
   * execution);<br>
   * if set to <code>true</code> the error message is displayed and the plugin will report an error
   * for the build.
   */
  @Parameter(property = "javacc.failOnPluginError", defaultValue = "true") //
  protected Boolean failOnPluginError;
  
  /**
   * The fail on grammar error parameter.<br>
   * It governs how the plugin will handle the errors it encountered while trying to read a grammar
   * file to retrieve the parser name (and the parser package for languages that use it) (in an
   * execution the plugin may process zero, one or many grammars).<br>
   * Possible values are <code>first</code>, <code>last</code> and <code>ignore</code>.<br>
   * If set to <code>first</code>, the error message is displayed, the plugin will stop processing
   * other grammars and will report an error for the build;<br>
   * if set to <code>last</code>, the error message is displayed, the plugin will continue
   * processing other grammars and at the end it will report an error for the build;<br>
   * if set to <code>ignore</code> the error message(s) is(are) displayed but the plugin will not
   * report an error for the current execution (i.e. it will continue with the next execution).
   */
  @Parameter(property = "javacc.failOnGrammarError", defaultValue = "first") //
  protected String failOnGrammarError;
  
  /**
   * The fail on processor error parameter.<br>
   * It governs how the plugin will handle the errors returned by the processor invocations and the
   * plugin post-processor copy operations (in an execution the plugin may process zero, one or many
   * grammars and invoke one or more processors for each).<br>
   * Possible values are <code>first</code>, <code>last</code> and <code>ignore</code>.<br>
   * If set to <code>first</code>, the error message is displayed, the plugin will stop processing
   * other grammars and will report an error for the build;<br>
   * if set to <code>last</code>, the error message is displayed, the plugin will continue
   * processing other grammars and at the end it will report an error for the build;<br>
   * if set to <code>ignore</code> the error message(s) is(are) displayed but the plugin will not
   * report an error for the current execution (i.e. it will continue with the next execution).
   */
  @Parameter(property = "javacc.failOnProcessorError", defaultValue = "first") //
  protected String failOnProcessorError;
  
  /**
   * The set of Ant-like inclusion patterns used to select files from the source directory for
   * processing.<br>
   * By default, the patterns are:<br>
   * <code>**&#47;*.jj</code> for the <code>javacc</code> goal,<br>
   * <code>**&#47;*.jjt</code> for the <code>jjtree</code> and <code>jjtree-javacc</code> goals,<br>
   * <code>**&#47;*.jtb</code> for the <code>jtb</code> and <code>jtb-javacc</code> goals.
   */
  @Parameter //
  protected String[] includes = null;
  
  /**
   * The keep the intermediate directory(ies) flag.<br>
   * If set to true, the intermediate directory(ies) will not be deleted, which may sometimes be
   * handy for plugin or processor debug purposes.
   */
  @Parameter(property = "javacc.keepIntermediateDirectory", defaultValue = "false") //
  protected Boolean keepIntermediateDirectory;
  
  /**
   * The skip processing flag.<br>
   * If true, no goal will not be executed.
   */
  @Parameter(property = "javacc.skip", defaultValue = "false") //
  protected Boolean skip;
  
  /**
   * The directory where the grammar files are located.<br>
   * It must exist and be a directory, otherwise a plugin error will be raised.<br>
   * This directory will be recursively scanned for input files to pass to JavaCC.<br>
   * If one wants a pom to process more than one source directory, he must configure multiple
   * executions with different source directories.<br>
   * Note: we could have implemented a list of source directories (as in
   * {@link AbstractPluginReport}, but most of the time different source directories will need
   * different configurations, so the user would still have to configure multiple executions.<br>
   * The parameters <code>includes</code> and <code>excludes</code> can be used to select a subset
   * of the files.<br>
   * If not an absolute path, maven internals considers it is relative to <code>${basedir}</code>
   * and converts it accordingly to an absolute path (i.e. <code>src/main/javacc</code> will be
   * considered as <code>${basedir}/src/main/javacc</code>, but <code>/src/main/javacc</code> will
   * be considered as an absolute path, usually leading to an error.<br>
   * The default value, adequate for a user written grammar, but not for a generated (by a
   * preprocessor) grammar, is:<br>
   * <code>${basedir}/src/main/javacc</code> for JavaCC,<br>
   * <code>${basedir}/src/main/jjtree</code> for JJTree,<br>
   * <code>${basedir}/src/main/jtb</code> for JTB.
   */
  @Parameter(property = "javacc.sourceDirectory") //
  protected File sourceDirectory = null;
  
  /**
   * The delta in milliseconds of the last modification timestamps for testing whether a grammar
   * file needs regeneration.<br>
   * If set to a negative value, no comparison will be performed and grammars will always be passed
   * to the processor.<br>
   * Otherwise a grammar file will be passed to the processor if the sum of the main generated file
   * timestamp plus this delta is lower than the grammar file timestamp or than the more recent of
   * the dependent jars timestamps.
   */
  @Parameter(property = "javacc.timestampDeltaMs", defaultValue = "0") //
  protected Long timestampDeltaMs;
  
  /**
   * The set of compile source roots whose contents are not generated as part of the goal by the
   * current processor, i.e. those that usually reside somewhere below "${basedir}/src" in the
   * project structure.<br>
   * Files in these source roots are owned by the user and must not be overwritten with generated
   * files.
   */
  protected List<File> nonGeneratedCompileSourceRoots;
  
  /**
   * Abstract getter.
   *
   * @return the grammar file encoding option value, or the default encoding if no option, never
   *         <code>null</code>
   */
  protected abstract String getGrammarFileEncoding();
  
  /**
   * Abstract getter.
   *
   * @return The output language option value, or the default language if no option, never <code>
   *     null</code>
   */
  protected abstract Language getLanguage();
  
  @Override
  public void execute() throws MojoExecutionException {
    
    if (skip) {
      getLog().info("Skipping processing as requested");
      return;
    }
    
    try {
      checkOptions();
      initProcessor();
    }
    catch (final PluginException e) {
      // failOnPluginError == 'true' will throw a MojoExecutionException
      handlePluginException(e);
      // failOnPluginError == 'false'
      return;
    }
    
    List<GrammarInfo> grammarInfos = null;
    try {
      grammarInfos = scanForGrammars();
    }
    catch (final GrammarException e) {
      // 'ignore' for failOnGrammarError should not lead to throwing a GrammarException
      // 'first' and 'last' for failOnGrammarError must lead to throw a MojoExecutionException
      getLog().error(e.getMessage() + " while failOnGrammarError is '" + failOnGrammarError + "'");
      throw new MojoExecutionException(e.getMessage(), e.getCause());
    }
    
    if (grammarInfos == null //
        || grammarInfos.size() == 0) {
      
      getLog().info("Nothing to generate in source directory '" + sourceDirectory + "': "
          + (grammarInfos == null ? "no grammars" : "all generated parsers are up to date"));
      // here we still need to add the compile source roots so we do not return
      
    } else {
      
      try {
        determineNonGeneratedCompileSourceRoots();
      }
      catch (final PluginException e) {
        // note-jacoco: quite impossible to set a test case for here
        // failOnPluginError == 'true' will throw a MojoExecutionException
        handlePluginException(e);
        // failOnPluginError == 'false'
        return;
      }
      
      int nb = 0;
      for (final GrammarInfo gi : grammarInfos) {
        try {
          processGrammar(gi);
          nb++;
        }
        catch (final ProcessorException e) {
          handleProcessorException(e);
        }
      }
      getLog().info("Processed " + nb + " grammar(s) successfully and " + (grammarInfos.size() - nb)
          + " with errors");
      
      if (processorErrorToIgnore) {
        // failOnProcessorError == 'ignore'
        return;
      } else if (processorErrorFailure) {
        // failOnProcessorError == 'last'
        throw new MojoExecutionException(
            "Returning a build error as encountered one or more processor exceptions");
      }
    }
    
    for (final File outputDirectory : getGoalOutputDirectories()) {
      addCompileSourceRoot(outputDirectory);
    }
  }
  
  /** True to tell the caller to ignore the grammar processing error. */
  private boolean processorErrorToIgnore = false;
  
  /**
   * True to tell {@link #execute()} to return a build failure on grammar processing 'last' error.
   */
  private boolean processorErrorFailure = false;
  
  /**
   * Transforms the plugin configuration exception in a build error if the plugin is configured to
   * fail on plugin configuration errors, otherwise just logs the error.
   *
   * @param e - the plugin configuration exception
   * @throws MojoExecutionException if the plugin is configured to fail on plugin configuration
   *           error
   */
  private void handlePluginException(final PluginException e) throws MojoExecutionException {
    if (failOnPluginError) {
      getLog().error(e.getMessage());
      throw new MojoExecutionException(e.getMessage(), e.getCause());
    } else {
      getLog().error(e.getMessage());
      getLog().info("Continuing to next execution as failOnPluginError is set to 'false'");
    }
  }
  
  /**
   * Transforms the grammar processing exception in a build error if the plugin is configured to
   * fail on 'first' grammar processing error, otherwise logs the error and sets flags for 'last'
   * and 'ignore' values.
   *
   * @param e - the grammar processing exception
   * @throws MojoExecutionException if the plugin is configured to fail on grammar processing error
   */
  private void handleProcessorException(final ProcessorException e) throws MojoExecutionException {
    if ("first".equals(failOnProcessorError)) {
      getLog().error(e.getMessage());
      throw new MojoExecutionException(e.getMessage(), e.getCause());
    } else if ("last".equals(failOnProcessorError)) {
      processorErrorFailure = true;
      getLog().error(e.getMessage());
      getLog().info("Continuing current execution as failOnProcessorError is set to 'last'");
    } else { // 'ignore'
      processorErrorToIgnore = true;
      getLog().error(e.getMessage());
      getLog().info("Continuing to next execution as failOnProcessorError is set to 'ignore'");
    }
  }
  
  /**
   * Checks valid values for different options.
   *
   * @throws PluginException if sourceDirectory does not exist or is not a directory
   */
  void checkOptions() throws PluginException {
    
    /* sourceDirectory */
    // here it can be null; if so it will be initialized to a default value by each processor;
    // and if not null, maven internals seems to always convert relative paths to an absolute ones
    getLog().debug("sourceDirectory is '" + sourceDirectory + "'");
    if (sourceDirectory != null) {
      if (!sourceDirectory.exists()) {
        throw new PluginException("sourceDirectory '" + sourceDirectory + "' does not exist");
      } else if (!sourceDirectory.isDirectory()) {
        throw new PluginException("sourceDirectory '" + sourceDirectory + "' is not a directory");
      }
    }
    
    /* timestampDeltaMs */
    if (timestampDeltaMs < 0L) {
      getLog().info("negative timestampDeltaMs '" + timestampDeltaMs
          + "', so grammars will always be processed");
    } else {
      getLog().debug("timestampDeltaMs is '" + timestampDeltaMs + "'");
    }
    
    /* failOnPluginError */
    getLog().debug("failOnPluginError is '" + failOnPluginError + "'");
    
    /* failOnGrammarError */
    switch (failOnGrammarError.toLowerCase()) {
    case "first":
    case "last":
    case "ignore":
      failOnGrammarError = failOnGrammarError.toLowerCase();
      getLog().debug("failOnGrammarError is '" + failOnGrammarError + "'");
      break;
    default:
      getLog().warn("invalid value '" + failOnGrammarError
          + "' for failOnGrammarError parameter; must be 'first', 'last' or 'ignore';"
          + " kept to default 'first'");
      failOnGrammarError = "first";
      break;
    }
    
    /* failOnProcessorError */
    switch (failOnProcessorError.toLowerCase()) {
    case "first":
    case "last":
    case "ignore":
      failOnProcessorError = failOnProcessorError.toLowerCase();
      getLog().debug("failOnProcessorError is '" + failOnProcessorError + "'");
      break;
    default:
      getLog().warn("invalid value '" + failOnProcessorError
          + "' for failOnProcessorError parameter; must be 'first', 'last' or 'ignore';"
          + " kept to default 'first'");
      failOnProcessorError = "first";
      break;
    }
  }
  
  /**
   * Checks for consistent options "grammar file encoding" and "language" between the preprocessor
   * and the javacc processor.
   *
   * @param b1 - the preprocessor arguments bean
   * @param b2 - the javacc arguments bean
   * @param preproc - the preprocessor name
   * @throws PluginException if inconsistent option(s)
   */
  void checkConsistentSpecificOptions(final AbstractArgumentsBean b1,
      final AbstractArgumentsBean b2, final String preproc) throws PluginException {
    
    boolean hasErr = false;
    
    final String jjtGFE = b1.grammarFileEncodingOptionValue;
    final String jjGFE = b2.grammarFileEncodingOptionValue;
    final String defGFE = AbstractArgumentsBean.defaultGrammarFileEncoding;
    // negated condition of consistent explicit and default option values
    if (!(jjtGFE != null && (jjGFE == null || jjtGFE.equals(jjGFE))
        || jjtGFE == null && (jjGFE == null || jjGFE.equals(defGFE)))) {
      hasErr = true;
      getLog().warn("Grammar file encodings are inconsistent: " + preproc + ": '"
          + (jjtGFE != null ? jjtGFE + "'" : defGFE + "' (default)") + ", javacc: '"
          + (jjGFE != null ? jjGFE + "'" : defGFE + "' (default)"));
    }
    
    final Language jjtLang = b1.languageOptionValue;
    final Language jjLang = b2.languageOptionValue;
    final Language defLang = AbstractArgumentsBean.defaultLanguage;
    // negated condition of consistent explicit and default option values
    if (!(jjtLang != null && (jjLang == null || jjtLang.equals(jjLang))
        || jjtLang == null && (jjLang == null || jjLang.equals(defLang)))) {
      hasErr = true;
      getLog().warn("Languages are inconsistent: " + preproc + ": '"
          + (jjtLang != null ? jjtLang + "'" : defLang + "' (default)") + ", javacc: '"
          + (jjLang != null ? jjLang + "'" : defLang + "' (default)"));
    }
    
    if (hasErr) {
      throw new PluginException("Inconsistent option(s)");
    }
  }
  
  /**
   * Initializes (some default values specific to the processor(s) and those ones).
   *
   * @throws PluginException if invalid option value
   */
  protected abstract void initProcessor() throws PluginException;
  
  /**
   * Scans the configured source directory for grammar files which need processing.
   *
   * @return a list of grammar infos describing the found grammar files, may be <code>null</code> if
   *         no grammar in the directory, and may be empty if no stale grammar found
   * @throws GrammarException if some grammar file could not be read or parsed
   */
  List<GrammarInfo> scanForGrammars() throws GrammarException {
    getLog().debug("Scanning for grammars in '" + sourceDirectory + "'");
    final GrammarDirectoryScanner gds = //
        new GrammarDirectoryScanner(getLog(), getLanguage(), getGrammarFileEncoding());
    gds.dsSetExcludes(excludes);
    gds.dsSetIncludes(includes);
    gds.dsSetBasedir(sourceDirectory);
    gds.setJarsLastTS(computeLastTS());
    gds.setOutputDirectories(getProcessorOutputDirectories());
    gds.setTimestampDeltaMs(timestampDeltaMs);
    final List<GrammarInfo> grammarInfos = gds.scanForGrammars(failOnGrammarError);
    getLog().debug("Found grammars: "
        + (grammarInfos == null ? "none" : Arrays.toString(grammarInfos.toArray())));
    return grammarInfos;
  }
  
  /**
   * Computes the most recent lastModified timestamp of the dependencies jars.
   *
   * @return the most recent lastModified timestamp of the dependencies jars
   */
  long computeLastTS() {
    long lastTS = 0L;
    // core
    lastTS = updateTS("META-INF/maven/org.javacc/core", lastTS);
    // generator
    final Language lang = getLanguage();
    if (lang == null) {
      // may be normal if running a tool's version < 8
      // note-jacoco: no test case set for here
      getLog().info("No code generator configured, check if normal (i.e. running version < 8)");
    } else {
      lastTS = updateTS("META-INF/maven/org.javacc.generator/" + lang.subDir, lastTS);
      // custom template(s)
      lastTS = updateTS("templates/" + lang.subDir, lastTS);
    }
    return lastTS;
  }
  
  /**
   * Updates the most recent lastModified timestamp of the dependencies jars with the jar of a given
   * resource.
   *
   * @param name - the name of a resource included in a jar
   * @param inLastTS - the current timestamp value
   * @return the updated timestamp value: the one of the jar containing the given resource if more
   *         recent than the current one, otherwise the current one
   */
  long updateTS(final String name, final long inLastTS) {
    long outLastTS = inLastTS;
    if (name != null) {
      final URL url = Thread.currentThread().getContextClassLoader().getResource(name);
      getLog().debug("Found url '" + url + "' containing resource '" + name + "'");
      if (url != null) {
        // urlName =
        // jar:file:/C:/Users/.../java/8.1.0-SNAPSHOT/java-8.1.0-SNAPSHOT.jar!/META-INF/maven/org.javacc.generator/java
        final String urlName = url.getPath();
        // jarName =
        // file:/C:/Users/.../java/8.1.0-SNAPSHOT/java-8.1.0-SNAPSHOT.jar!/META-INF/maven/org.javacc.generator/java
        // must remove leading "file:" and trailing "!..."
        final String jarName = urlName.substring(6, urlName.indexOf('!'));
        final long ts = new File(jarName).lastModified();
        getLog().debug("LastModified timestamp of '" + jarName + "' is: " + ts);
        if (ts == 0L) {
          // note-jacoco: not found a way to set a test case for here
          getLog().warn("Non existing or badly extracted jar '" + jarName + "' from existing url '"
              + urlName + "'");
        } else {
          if (ts > outLastTS) {
            outLastTS = ts;
          }
        }
      } else {
        // note-jacoco: no test case set for here
        getLog()
            .warn("No dependent jar found for resource '" + name + "'; check plugin configuration");
      }
    }
    return outLastTS;
  }
  
  /**
   * Abstract getter.
   *
   * @return the array of absolute paths to the directories where the (current) processor will
   *         output its generated files, never <code>null</code>
   */
  protected abstract File[] getProcessorOutputDirectories();
  
  /**
   * Abstract getter.
   *
   * @return the array of the goal mojo's processor(s) output directories that will be registered as
   *         compile source roots in the project, never <code>null</code>
   */
  protected abstract File[] getGoalOutputDirectories();
  
  /**
   * Registers the specified directory as a compile source root for the current project.
   *
   * @param directory - the absolute path to the directory to add, must not be <code>null</code>
   */
  void addCompileSourceRoot(final File directory) {
    // project looks never null
    project.addCompileSourceRoot(directory.getAbsolutePath());
    getLog().debug(
        "Added (output) directory '" + directory.getAbsolutePath() + "' as a compile source root");
  }
  
  /**
   * Determines those compile source roots of the project that do not reside below the project's
   * build directories.<br>
   * These compile source roots are assumed to contain hand-crafted sources that must not be
   * overwritten with generated files.<br>
   * In most cases, this is simply "${project.build.sourceDirectory}".
   *
   * @throws PluginException if the compile source roots could not be determined (because of an
   *           error in getting a canonical path)
   */
  void determineNonGeneratedCompileSourceRoots() throws PluginException {
    nonGeneratedCompileSourceRoots = new ArrayList<File>();
    try {
      final String targetPrefix = new File(project.getBuild().getDirectory()).getCanonicalPath()
          + File.separator;
      getLog().debug("sourceDirectory = '" + sourceDirectory + "'");
      for (final String csRoot : project.getCompileSourceRoots()) {
        final File compileSourceRoot = new File(csRoot);
        // maven internals makes compileSourceRoot absolute
        final String compileSourceRootPath = compileSourceRoot.getCanonicalPath();
        if (compileSourceRoot.getAbsolutePath().equals(sourceDirectory.getAbsolutePath()) //
            || !compileSourceRootPath.startsWith(targetPrefix)) {
          nonGeneratedCompileSourceRoots.add(compileSourceRoot);
          getLog().debug("compile source root: '" + compileSourceRoot + "' is a non generated one");
        } else {
          getLog().debug("compile source root: '" + compileSourceRoot + "' is a generated one");
        }
      }
    }
    catch (final IOException | SecurityException e) {
      // note-jacoco: quite impossible to set a test case for here
      throw new PluginException("Failed to determine non-generated source roots", e);
    }
  }
  
  /**
   * Tells the subclass to process the specified grammar file (it may execute one or more
   * processors).
   *
   * @param grammarInfo - the grammar info describing the grammar file to process, must not be
   *          <code>null</code>
   * @throws ProcessorException if the invocation of the processor(s) failed or if the processor(s)
   *           reported a non-zero exit code or if the generated files could not be copied
   */
  protected abstract void processGrammar(GrammarInfo grammarInfo) throws ProcessorException;
  
  /**
   * Runs a processor on a specified grammar file.
   *
   * @param grammarInfo - the grammar info describing the grammar file to process, must not be
   *          <code>null</code>
   * @param intermediateDirectories - the array of intermediate output directories where the
   *          processor will write the generated files (instead of the configured output
   *          directories, see {@link #copyGrammarOutput(File, File, String, boolean)})
   * @param copyGenFiles - true to copy the generated files, false otherwise
   * @throws ProcessorException if the invocation of the processor failed or if the processor
   *           reported a non-zero exit code or if the generated files could not be copied
   */
  void runProcessorOnGrammar(final GrammarInfo grammarInfo, final File[] intermediateDirectories,
      final boolean copyGenFiles) throws ProcessorException {
    final File gramFile = grammarInfo.getAbsoluteGrammarFile();
    final File gramDirectory = gramFile.getParentFile();
    // the output directories may be sub directories of the given intermediate directories
    final File[] intermedOutputDirectories = new File[intermediateDirectories.length];
    for (int i = 0; i < intermediateDirectories.length; i++) {
      intermedOutputDirectories[i] = (new File(intermediateDirectories[i],
          grammarInfo.getParserSubDirectory())).getAbsoluteFile();
    }
    
    // run the processor to generate files
    runProcessor(gramFile, intermediateDirectories);
    
    // copy generated files
    for (int i = 0; i < intermediateDirectories.length; i++) {
      getLog().debug("1st copyGrammarOutput: i=" + i + ", gi.subd='"
          + grammarInfo.getParserSubDirectory() + "':");
      copyGrammarOutput(intermediateDirectories[i], getProcessorOutputDirectories()[i],
          grammarInfo.getParserSubDirectory(),
          // "*",
          copyGenFiles);
    }
    
    getLog().debug("2nd copyGrammarOutput: copyGenFiles=" + copyGenFiles + ", gi.srcd='"
        + grammarInfo.getSourceDirectory() + "', ingcsr='"
        + isNonGeneratedCompileSourceRoot(grammarInfo.getSourceDirectory()) + "':");
    if (copyGenFiles && //
        !isNonGeneratedCompileSourceRoot(grammarInfo.getSourceDirectory())) {
      // if asked to copy the generated files and if the grammar does not reside in a declared
      // source root, copy source files which are beside the grammar
      copyGrammarOutput( //
          gramDirectory, //
          getProcessorOutputDirectories()[0], //
          grammarInfo.getParserSubDirectory(), //
          // "*",
          false);
    } else {
      // but if the grammar does reside in a declared source root,
      // do not copy them (otherwise we would get duplicated classes)
    }
    
    if (!keepIntermediateDirectory) {
      deleteIntermediateDirectories(intermediateDirectories);
    } else {
      getLog().info("Intermediate directory(ies) '" + displayDirectories(intermediateDirectories)
          + "' not deleted as requested");
    }
  }
  
  /**
   * @param directories - an array of directories
   * @return the string of the comma separated list of directories
   */
  protected static String displayDirectories(final File[] directories) {
    if (directories == null) {
      return null;
    }
    String msg = "";
    for (int i = 0; i < directories.length; i++) {
      try {
        msg += directories[i].getCanonicalPath();
      }
      catch (final IOException e) {
        msg += "IOException on element of intermediateDirectories";
      }
      if (i < directories.length - 1) {
        msg += ", ";
      }
    }
    return msg;
  }
  
  /**
   * Runs a processor on a given grammar file generating files into the given output directories.
   *
   * @param grammar - the grammar file
   * @param outputDirectories - the output directories
   * @throws ProcessorException if the invocation of the processor failed or if the processor
   *           reported a non-zero exit code
   */
  protected abstract void runProcessor(final File grammar, final File[] outputDirectories)
      throws ProcessorException;
  
  /**
   * Scans a given origin directory and its subdirectories for generated files and copies them under
   * the given destination directory, taking in account the parser directory deriving from the
   * parser package if any.<br>
   * It is intended that the resulting destination directory will be a compile source root.<br>
   * An output file is only copied if it doesn't already exist in a compile source root.<br>
   * This prevents duplicate class errors during compilation in case the user provided customized
   * files in a compile source directory like <code>src/main/java</code> or similar.
   *
   * @param origin - the (absolute) path to the directory to scan for the to-be-copied generated
   *          output files, must not be <code>null</code>
   * @param destination - the (absolute) path to the destination directory into which the output
   *          files should be copied, must not be <code>null</code>
   * @param subDirectory - the name of the destination sub directory for the output files, must not
   *          be <code>null</code>, and must be empty or terminated by a file separator
   * @param copyAnnotatedFile - true to also copy the jj file (as generated / annotated), false
   *          otherwise
   * @throws ProcessorException if the generated files could not be copied
   */
  void copyGrammarOutput(final File origin, final File destination, final String subDirectory,
      final boolean copyAnnotatedFile) throws ProcessorException {
    
    final List<File> tbcFiles;
    String ext = "";
    final Language lang = getLanguage();
    try {
      ext = "**/*" + lang.extension;
      tbcFiles = FileUtils.getFiles(origin, ext, null);
      if (copyAnnotatedFile) {
        ext = "**/*.jj";
        tbcFiles.addAll(FileUtils.getFiles(origin, ext, null));
      }
      if (lang.otherExtensions != null) {
        ext = "**/*" + lang.otherExtensions;
        tbcFiles.addAll(FileUtils.getFiles(origin, ext, null));
      }
    }
    catch (final IOException e) {
      throw new ProcessorException(
          "Failed to get generated files '" + ext + "' within '" + origin + "'", e);
    }
    
    for (final File tbcFile : tbcFiles) {
      final String gf = tbcFile.getPath().substring(1 + origin.getPath().length());
      final String outputPath = subDirectory + gf;
      final File outputFile = new File(destination, outputPath);
      final File sourceFile = findIfIsNonGeneratedSourceFile(outputPath);
      
      if (sourceFile == null) {
        try {
          FileUtils.copyFile(tbcFile, outputFile);
          getLog().debug("Copied generated file '" + tbcFile + "' to '" + outputFile + "'");
        }
        catch (final IOException e) {
          throw new ProcessorException(
              "Failed to copy generated file '" + tbcFile + "' to '" + outputFile + "'", e);
        }
      } else {
        getLog().debug("Skipping copying user file '" + outputPath
            + "' as custom or generated one '" + sourceFile + "' exists");
      }
    }
  }
  
  /**
   * Determines whether a given file exists in any of the non generated compile source roots
   * registered with the current Maven project or in any of the processor output directories.
   *
   * @param filename - the name of the file to check, relative to a compile source root or a
   *          processor output directory, must not be <code>
   *     null</code>
   * @return the (absolute) path to the existing source file if any, <code>null</code> otherwise
   */
  File findIfIsNonGeneratedSourceFile(final String filename) {
    for (final File nonGeneratedCompileSourceRoot : nonGeneratedCompileSourceRoots) {
      final File sourceFile = new File(nonGeneratedCompileSourceRoot, filename);
      if (sourceFile.exists()) {
        return sourceFile;
      }
    }
    for (final File procOutDir : getProcessorOutputDirectories()) {
      final File sourceFile = new File(procOutDir, filename);
      if (sourceFile.exists()) {
        return sourceFile;
      }
    }
    return null;
  }
  
  /**
   * True if the specified directory is a non generated compile source root of the current project,
   * false otherwise.
   *
   * @param dir - the directory to check, must not be <code>null</code>
   * @return <code>true</code> if the specified directory is a non generated compile source root of
   *         the project, <code>false</code> otherwise
   */
  boolean isNonGeneratedCompileSourceRoot(final File dir) {
    return nonGeneratedCompileSourceRoots.contains(dir);
  }
  
  /**
   * Deletes the specified intermediate directories.
   *
   * @param dirs - the directory to delete, must not be <code>null</code>
   */
  void deleteIntermediateDirectories(final File[] dirs) {
    for (final File dir : dirs) {
      try {
        FileUtils.deleteDirectory(dir);
        getLog().debug("Deleted intermediate directory '" + dir + "'");
      }
      catch (final IOException e) {
        // note-jacoco: quite impossible to set a test case for here
        getLog().warn("Failed to delete intermediate directory '" + dir + "'", e);
      }
    }
  }
}