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 }