View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.shared.scriptinterpreter;
20  
21  import java.io.Closeable;
22  import java.io.File;
23  import java.io.IOException;
24  import java.io.PrintStream;
25  import java.nio.file.Files;
26  import java.util.HashMap;
27  import java.util.LinkedHashMap;
28  import java.util.List;
29  import java.util.Locale;
30  import java.util.Map;
31  
32  import org.apache.commons.io.FilenameUtils;
33  import org.slf4j.Logger;
34  import org.slf4j.LoggerFactory;
35  
36  /**
37   * Runs pre-/post-build hook scripts.
38   *
39   * @author Benjamin Bentmann
40   */
41  public class ScriptRunner implements Closeable {
42  
43      private static final Object LOCK = new Object();
44  
45      private static final Logger LOG = LoggerFactory.getLogger(ScriptRunner.class);
46  
47      /**
48       * The supported script interpreters, indexed by the lower-case file extension of their associated script files,
49       * never <code>null</code>.
50       */
51      private final Map<String, ScriptInterpreter> scriptInterpreters;
52  
53      /**
54       * The common set of global variables to pass into the script interpreter, never <code>null</code>.
55       */
56      private final Map<String, Object> globalVariables;
57  
58      /**
59       * The file encoding of the hook scripts or <code>null</code> to use platform encoding.
60       */
61      private String encoding;
62  
63      /**
64       * Creates a new script runner with BSH and Groovy interpreters.
65       */
66      public ScriptRunner() {
67          scriptInterpreters = new LinkedHashMap<>();
68          scriptInterpreters.put("bsh", new BeanShellScriptInterpreter());
69          scriptInterpreters.put("groovy", new GroovyScriptInterpreter());
70          globalVariables = new HashMap<>();
71      }
72  
73      /**
74       * Add new script Interpreter
75       *
76       * @param id The Id of interpreter
77       * @param scriptInterpreter the Script Interpreter implementation
78       */
79      public void addScriptInterpreter(String id, ScriptInterpreter scriptInterpreter) {
80          scriptInterpreters.put(id, scriptInterpreter);
81      }
82  
83      /**
84       * Sets a global variable for the script interpreter.
85       *
86       * @param name The name of the variable, must not be <code>null</code>.
87       * @param value The value of the variable, may be <code>null</code>.
88       */
89      public void setGlobalVariable(String name, Object value) {
90          this.globalVariables.put(name, value);
91      }
92  
93      /**
94       * Sets the additional class path for the hook scripts. Note that the provided list is copied, so any later changes
95       * will not affect the scripts.
96       *
97       * @param classPath The additional class path for the script interpreter, may be <code>null</code> or empty if only
98       * the plugin realm should be used for the script evaluation. If specified, this class path will precede the
99       * artifacts from the plugin class path.
100      */
101     public void setClassPath(List<String> classPath) {
102         if (classPath != null && !classPath.isEmpty()) {
103             scriptInterpreters.values().forEach(scriptInterpreter -> scriptInterpreter.setClassPath(classPath));
104         }
105     }
106 
107     /**
108      * Sets the file encoding of the hook scripts.
109      *
110      * @param encoding The file encoding of the hook scripts, may be <code>null</code> or empty to use the platform's
111      *                 default encoding.
112      */
113     public void setScriptEncoding(String encoding) {
114         this.encoding = encoding != null && !encoding.isEmpty() ? encoding : null;
115     }
116 
117     /**
118      * Runs the specified hook script (after resolution).
119      *
120      * @param scriptDescription The description of the script to use for logging, must not be <code>null</code>.
121      * @param basedir The base directory of the project, must not be <code>null</code>.
122      * @param relativeScriptPath The path to the script relative to the project base directory, may be <code>null</code>
123      *            to skip the script execution and may not have extensions (resolution will search).
124      * @param context The key-value storage used to share information between hook scripts, may be <code>null</code>.
125      * @param logger The logger to redirect the script output to, may be <code>null</code> to use stdout/stderr.
126      * @throws IOException If an I/O error occurred while reading the script file.
127      * @throws ScriptException If the script did not return <code>true</code> of threw an exception.
128      */
129     public void run(
130             final String scriptDescription,
131             final File basedir,
132             final String relativeScriptPath,
133             final Map<String, ?> context,
134             final ExecutionLogger logger)
135             throws IOException, ScriptException {
136         if (relativeScriptPath == null) {
137             LOG.debug("{}: relativeScriptPath is null, not executing script", scriptDescription);
138             return;
139         }
140 
141         final File scriptFile = resolveScript(new File(basedir, relativeScriptPath));
142 
143         if (!scriptFile.exists()) {
144             LOG.debug(
145                     "{} : no script '{}' found in directory {}",
146                     scriptDescription,
147                     relativeScriptPath,
148                     basedir.getAbsolutePath());
149             return;
150         }
151 
152         executeRun(scriptDescription, scriptFile, context, logger);
153     }
154 
155     /**
156      * Runs the specified hook script.
157      *
158      * @param scriptDescription The description of the script to use for logging, must not be <code>null</code>.
159      * @param scriptFile The path to the script, may be <code>null</code> to skip the script execution.
160      * @param context The key-value storage used to share information between hook scripts, may be <code>null</code>.
161      * @param logger The logger to redirect the script output to, may be <code>null</code> to use stdout/stderr.
162      * @throws IOException         If an I/O error occurred while reading the script file.
163      * @throws ScriptException If the script did not return <code>true</code> of threw an exception.
164      */
165     public void run(
166             final String scriptDescription, File scriptFile, final Map<String, ?> context, final ExecutionLogger logger)
167             throws IOException, ScriptException {
168 
169         if (!scriptFile.exists()) {
170             LOG.debug("{} : script file not found in directory {}", scriptDescription, scriptFile.getAbsolutePath());
171             return;
172         }
173 
174         executeRun(scriptDescription, scriptFile, context, logger);
175     }
176 
177     private void executeRun(
178             final String scriptDescription, File scriptFile, final Map<String, ?> context, final ExecutionLogger logger)
179             throws IOException, ScriptException {
180         ScriptInterpreter interpreter = getInterpreter(scriptFile);
181         if (LOG.isDebugEnabled()) {
182             String name = interpreter.getClass().getName();
183             name = name.substring(name.lastIndexOf('.') + 1);
184             LOG.debug("Running script with {} :{}", name, scriptFile);
185         }
186 
187         String script;
188         try {
189             byte[] bytes = Files.readAllBytes(scriptFile.toPath());
190             if (encoding != null) {
191                 script = new String(bytes, encoding);
192             } else {
193                 script = new String(bytes);
194             }
195         } catch (IOException e) {
196             String errorMessage =
197                     "error reading " + scriptDescription + " " + scriptFile.getPath() + ", " + e.getMessage();
198             throw new IOException(errorMessage, e);
199         }
200 
201         Object result;
202         try {
203             if (logger != null) {
204                 logger.consumeLine("Running " + scriptDescription + ": " + scriptFile);
205             }
206 
207             PrintStream out = (logger != null) ? logger.getPrintStream() : null;
208 
209             Map<String, Object> scriptVariables = new HashMap<>(this.globalVariables);
210             scriptVariables.put("basedir", scriptFile.getParentFile());
211             scriptVariables.put("context", context);
212 
213             synchronized (LOCK) {
214                 result = interpreter.evaluateScript(script, scriptVariables, out);
215             }
216             if (logger != null) {
217                 logger.consumeLine("Finished " + scriptDescription + ": " + scriptFile);
218             }
219         } catch (ScriptEvaluationException e) {
220             Throwable t = (e.getCause() != null) ? e.getCause() : e;
221             if (logger != null) {
222                 t.printStackTrace(logger.getPrintStream());
223             }
224             throw e;
225         }
226 
227         if (!(result == null || Boolean.parseBoolean(String.valueOf(result)))) {
228             throw new ScriptReturnException("The " + scriptDescription + " returned " + result + ".", result);
229         }
230     }
231 
232     /**
233      * Gets the effective path to the specified script. For convenience, we allow to specify a script path as "verify"
234      * and have the plugin auto-append the file extension to search for "verify.bsh" and "verify.groovy".
235      *
236      * @param scriptFile The script file to resolve, may be <code>null</code>.
237      * @return The effective path to the script file or <code>null</code> if the input was <code>null</code>.
238      */
239     private File resolveScript(File scriptFile) {
240         if (scriptFile != null && !scriptFile.exists()) {
241             for (String ext : this.scriptInterpreters.keySet()) {
242                 File candidateFile = new File(scriptFile.getPath() + '.' + ext);
243                 if (candidateFile.exists()) {
244                     scriptFile = candidateFile;
245                     break;
246                 }
247             }
248         }
249         return scriptFile;
250     }
251 
252     /**
253      * Determines the script interpreter for the specified script file by looking at its file extension. In this
254      * context, file extensions are considered case-insensitive. For backward compatibility with plugin versions 1.2-,
255      * the BeanShell interpreter will be used for any unrecognized extension.
256      *
257      * @param scriptFile The script file for which to determine an interpreter, must not be <code>null</code>.
258      * @return The script interpreter for the file, never <code>null</code>.
259      */
260     private ScriptInterpreter getInterpreter(File scriptFile) {
261         String ext = FilenameUtils.getExtension(scriptFile.getName()).toLowerCase(Locale.ENGLISH);
262         ScriptInterpreter interpreter = scriptInterpreters.get(ext);
263         if (interpreter == null) {
264             interpreter = scriptInterpreters.get("bsh");
265         }
266         return interpreter;
267     }
268 
269     /**
270      * Closes this script interpreter and releases any system resources associated with it.
271      *
272      * @throws IOException if an I/O error occurs.
273      */
274     @Override
275     public void close() throws IOException {
276         for (ScriptInterpreter scriptInterpreter : scriptInterpreters.values()) {
277             scriptInterpreter.close();
278         }
279         scriptInterpreters.clear();
280     }
281 }