1   /*
2    * RDFpro - An extensible tool for building stream-oriented RDF processing libraries.
3    * 
4    * Written in 2015 by Francesco Corcoglioniti with support by Alessio Palmero Aprosio and Marco
5    * Rospocher. Contact info on http://rdfpro.fbk.eu/
6    * 
7    * To the extent possible under law, the authors have dedicated all copyright and related and
8    * neighboring rights to this software to the public domain worldwide. This software is
9    * distributed without any warranty.
10   * 
11   * You should have received a copy of the CC0 Public Domain Dedication along with this software.
12   * If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
13   */
14  package eu.fbk.rdfpro.util;
15  
16  import java.io.BufferedReader;
17  import java.io.IOException;
18  import java.lang.reflect.Method;
19  import java.lang.reflect.Modifier;
20  import java.nio.charset.Charset;
21  import java.nio.file.Files;
22  import java.nio.file.Path;
23  import java.nio.file.Paths;
24  import java.util.HashMap;
25  import java.util.Map;
26  import java.util.Objects;
27  import java.util.regex.Pattern;
28  
29  import javax.script.Invocable;
30  import javax.script.ScriptEngine;
31  import javax.script.ScriptEngineManager;
32  import javax.script.ScriptException;
33  
34  import org.slf4j.Logger;
35  import org.slf4j.LoggerFactory;
36  
37  import info.aduna.text.ASCIIUtil;
38  
39  public final class Scripting {
40  
41      private static final Logger LOGGER = LoggerFactory.getLogger(Scripting.class);
42  
43      private static final ScriptEngineManager MANAGER = new ScriptEngineManager();
44  
45      private static final Pattern LANGUAGE_PATTERN = Pattern.compile("[a-z]+");
46  
47      private static final Map<String, String> INCLUDES;
48  
49      static {
50          final Map<String, String> includes = new HashMap<>();
51          for (final String property : Environment.getPropertyNames()) {
52              if (property.startsWith("scripting")) {
53                  final String[] tokens = property.split("\\.");
54                  if (tokens.length == 4 && tokens[2].equals("includes")) {
55                      try {
56                          final String language = tokens[1];
57                          final String location = Environment.getProperty(property);
58                          if (!LANGUAGE_PATTERN.matcher(language).matches()) {
59                              throw new Error("Invalid language: " + language);
60                          }
61                          final StringBuilder builder = new StringBuilder();
62                          try (BufferedReader reader = new BufferedReader(IO.utf8Reader(IO
63                                  .read(location)))) {
64                              String line;
65                              while ((line = reader.readLine()) != null) {
66                                  builder.append(line).append("\n");
67                              }
68                          } catch (final IOException ex) {
69                              throw new Error("Could not read " + location);
70                          }
71                          final String newInclude = builder.toString();
72                          final String oldInclude = includes.get(language);
73                          includes.put(language, oldInclude == null ? newInclude : oldInclude
74                                  + newInclude);
75                          LOGGER.debug("Loaded {}", location);
76                      } catch (final Throwable ex) {
77                          LOGGER.error("Failed to process include " + property, ex);
78                      }
79                  }
80              }
81          }
82          INCLUDES = includes;
83      }
84  
85      public static boolean isScript(final String spec) {
86          final int index = spec.indexOf(':');
87          if (index > 0) {
88              final String language = spec.substring(0, 1);
89              return LANGUAGE_PATTERN.matcher(language).matches();
90          }
91          return false;
92      }
93  
94      public static <T> T compile(final Class<T> interfaceClass, final String spec,
95              final String... parameterNames) {
96  
97          // Validate parameters
98          Objects.requireNonNull(interfaceClass);
99          Objects.requireNonNull(spec);
100         if (!interfaceClass.isInterface()) {
101             throw new IllegalArgumentException("Class " + interfaceClass.getName()
102                     + " is not an interface");
103         }
104 
105         // Identify the single abstract method in the interface (throw error if more than one)
106         Method method = null;
107         for (final Method m : interfaceClass.getMethods()) {
108             if (Modifier.isAbstract(m.getModifiers())) {
109                 if (method != null) {
110                     throw new IllegalArgumentException(interfaceClass
111                             + " defines multiple methods");
112                 }
113                 method = m;
114             }
115         }
116 
117         // Check that method parameters match the supplied parameter names
118         if (method.getParameterCount() != parameterNames.length) {
119             throw new IllegalArgumentException("Signature of method " + method
120                     + " does not match parameters " + String.join(", ", parameterNames));
121         }
122 
123         // Extract script language and script expression from the specification
124         final int index = spec.indexOf(':');
125         final String language = index < 0 ? null : spec.substring(0, index);
126         if (language == null || !LANGUAGE_PATTERN.matcher(language).matches()) {
127             throw new IllegalArgumentException("Not a valid script specification: " + spec);
128         }
129         String expression = spec.substring(index + 1);
130 
131         // In case the expression points to a script file, load its content
132         try {
133             final Path path = Paths.get(expression);
134             if (Files.isReadable(path)) {
135                 expression = new String(Files.readAllBytes(path), Charset.forName("UTF-8"));
136             }
137         } catch (final Throwable ex) {
138             // ignore
139         }
140 
141         // Rewrite <URI> and QNames in the script expression
142         expression = rewrite(expression);
143 
144         try {
145             // Try to extract the interface from the script as it is
146             LOGGER.debug("Compiling expression:\n{}", expression);
147             return compileHelper(interfaceClass, expression, language);
148 
149         } catch (final Throwable ex1) {
150             try {
151                 // On failure, add the interface single function declaration before the script
152                 // body and try again
153                 expression = wrap(method, expression, language, parameterNames);
154                 LOGGER.debug("Compiling wrapped expression:\n{}", expression);
155                 return compileHelper(interfaceClass, expression, language);
156 
157             } catch (final Throwable ex2) {
158                 // On failure, report all the errors to help debugging
159                 final StringBuilder builder = new StringBuilder();
160                 builder.append("Cannot extract ").append(interfaceClass).append(" from script");
161                 builder.append("\nError when compiled as is:\n").append(ex1.getMessage());
162                 builder.append("\nError when wrapped in function:\n").append(ex2.getMessage());
163                 builder.append("\nScript spec is:\n").append(spec);
164                 throw new IllegalArgumentException(builder.toString());
165             }
166         }
167     }
168 
169     private static <T> T compileHelper(final Class<T> interfaceClass, final String expression,
170             final String language) throws ScriptException {
171 
172         // Retrieve a script engine for the given language
173         final ScriptEngine engine = MANAGER.getEngineByExtension(language);
174         if (!(engine instanceof Invocable)) {
175             throw new UnsupportedOperationException("Unsupported script language: " + language);
176         }
177 
178         // Evaluate includes, if any
179         final String include = INCLUDES.get(language);
180         if (include != null) {
181             engine.eval(include);
182         }
183 
184         // Evaluate the script
185         engine.eval(expression);
186 
187         // Extract an implementation of the class that delegates to the compiled script
188         return interfaceClass.cast(((Invocable) engine).getInterface(interfaceClass));
189     }
190 
191     private static String wrap(final Method method, final String expression,
192             final String language, final String... parameterNames) {
193 
194         if ("js".equals(language)) {
195             final StringBuilder builder = new StringBuilder();
196             builder.append("function ");
197             builder.append(method.getName());
198             builder.append("(");
199             builder.append(String.join(", ", parameterNames));
200             builder.append(") {\n");
201             builder.append(expression);
202             if (method.getReturnType() != null && !method.getReturnType().equals(Void.class)) {
203                 builder.append("\nthrow \"missing return statement in supplied script\";");
204             }
205             builder.append("\n}");
206             return builder.toString();
207 
208         } else if ("groovy".equals(language)) {
209             final StringBuilder builder = new StringBuilder();
210             builder.append("def ");
211             builder.append(method.getName());
212             builder.append("(");
213             builder.append(String.join(", ", parameterNames));
214             builder.append(") {\n");
215             builder.append(expression);
216             builder.append("\n}");
217             return builder.toString();
218 
219         } else {
220             throw new UnsupportedOperationException(
221                     "Wrapping within a function is unsupported for language " + language);
222         }
223     }
224 
225     private static String rewrite(final String script) {
226 
227         final StringBuilder builder = new StringBuilder();
228         final int length = script.length();
229         int i = 0;
230 
231         try {
232             while (i < length) {
233                 char c = script.charAt(i);
234                 if (c == '<') {
235                     final int end = parseURI(script, i);
236                     if (end >= 0) {
237                         final String uri = script.substring(i, end);
238                         builder.append("(new org.openrdf.model.impl.URIImpl(\"").append(uri)
239                                 .append("\"))");
240                         i = end;
241                     } else {
242                         builder.append(c);
243                         ++i;
244                     }
245 
246                 } else if (isPN_CHARS_BASE(c)) {
247                     final int end = parseQName(script, i);
248                     if (end >= 0) {
249                         final String uri = Statements.parseValue(script.substring(i, end),
250                                 Namespaces.DEFAULT).stringValue();
251                         builder.append("(new org.openrdf.model.impl.URIImpl(\"").append(uri)
252                                 .append("\"))");
253                         i = end;
254                     } else {
255                         do {
256                             builder.append(c);
257                             c = script.charAt(++i);
258                         } while (Character.isLetterOrDigit(c));
259                     }
260 
261                 } else if (c == '\'' || c == '\"') {
262                     final char d = c; // delimiter
263                     builder.append(d);
264                     do {
265                         c = script.charAt(++i);
266                         builder.append(c);
267                     } while (c != d || script.charAt(i - 1) == '\\');
268                     ++i;
269 
270                 } else {
271                     builder.append(c);
272                     ++i;
273                 }
274             }
275         } catch (final Exception ex) {
276             throw new IllegalArgumentException("Illegal URI escaping near offset " + i, ex);
277         }
278 
279         return builder.toString();
280     }
281 
282     // Following code can be factored in Statements
283     // rewriteRDFTerms(String, Function<Value, String>)
284 
285     private static int parseURI(final String string, int i) {
286         final int len = string.length();
287         if (string.charAt(i) != '<') {
288             return -1;
289         }
290         for (++i; i < len; ++i) {
291             final char c = string.charAt(i);
292             if (c == '<' || c == '\"' || c == '{' || c == '}' || c == '|' || c == '^' || c == '`'
293                     || c == '\\' || c == ' ') {
294                 return -1;
295             }
296             if (c == '>') {
297                 return i + 1;
298             }
299         }
300         return -1;
301     }
302 
303     private static int parseQName(final String string, int i) {
304         final int len = string.length();
305         char c;
306         if (!isPN_CHARS_BASE(string.charAt(i))) {
307             return -1;
308         }
309         for (; i < len; ++i) {
310             c = string.charAt(i);
311             if (!isPN_CHARS(c) && c != '.') {
312                 break;
313             }
314         }
315         if (string.charAt(i - 1) == '.' || string.charAt(i) != ':' || i == len - 1) {
316             return -1;
317         }
318         c = string.charAt(++i);
319         if (!isPN_CHARS_U(c) && c != ':' && c != '%' && !Character.isDigit(c)) {
320             return -1;
321         }
322         for (; i < len; ++i) {
323             c = string.charAt(i);
324             if (!isPN_CHARS(c) && c != '.' && c != ':' && c != '%') {
325                 break;
326             }
327         }
328         if (string.charAt(i - 1) == '.') {
329             return -1;
330         }
331         return i;
332     }
333 
334     private static boolean isPN_CHARS(final int c) {
335         return isPN_CHARS_U(c) || ASCIIUtil.isNumber(c) || c == 45 || c == 183 || c >= 768
336                 && c <= 879 || c >= 8255 && c <= 8256;
337     }
338 
339     private static boolean isPN_CHARS_U(final int c) {
340         return isPN_CHARS_BASE(c) || c == 95;
341     }
342 
343     private static boolean isPN_CHARS_BASE(final int c) {
344         return ASCIIUtil.isLetter(c) || c >= 192 && c <= 214 || c >= 216 && c <= 246 || c >= 248
345                 && c <= 767 || c >= 880 && c <= 893 || c >= 895 && c <= 8191 || c >= 8204
346                 && c <= 8205 || c >= 8304 && c <= 8591 || c >= 11264 && c <= 12271 || c >= 12289
347                 && c <= 55295 || c >= 63744 && c <= 64975 || c >= 65008 && c <= 65533
348                 || c >= 65536 && c <= 983039;
349     }
350 
351 }