View Javadoc
1   /**
2    * This file Copyright (c) 2010-2015 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.module.groovy.support.classes;
35  
36  import groovy.lang.GroovyClassLoader;
37  
38  import info.magnolia.cms.core.HierarchyManager;
39  import info.magnolia.module.groovy.support.HierarchyManagerProvider;
40  
41  import java.io.IOException;
42  import java.net.URL;
43  import java.net.URLConnection;
44  import java.security.CodeSource;
45  import java.util.List;
46  
47  import javax.jcr.RepositoryException;
48  
49  import org.apache.commons.lang.StringUtils;
50  import org.codehaus.groovy.ast.ASTNode;
51  import org.codehaus.groovy.ast.ClassNode;
52  import org.codehaus.groovy.ast.builder.AstBuilder;
53  import org.codehaus.groovy.control.CompilationFailedException;
54  import org.codehaus.groovy.control.CompilationUnit;
55  import org.codehaus.groovy.control.CompilationUnit.SourceUnitOperation;
56  import org.codehaus.groovy.control.CompilePhase;
57  import org.codehaus.groovy.control.CompilerConfiguration;
58  import org.codehaus.groovy.control.Phases;
59  import org.codehaus.groovy.control.SourceUnit;
60  import org.codehaus.groovy.control.io.URLReaderSource;
61  import org.slf4j.Logger;
62  import org.slf4j.LoggerFactory;
63  
64  /**
65   * Magnolia class loader which extends {@link GroovyClassLoader} and internally uses {@link MgnlGroovyResourceLoader}.
66   *
67   * @see MgnlGroovyClassLoader#isSourceNewer(URL, Class)
68   * @see MgnlGroovyClassLoader.AddDefaultImportOperation
69   * @see MgnlGroovyClassLoader.PackageAndClassNameConsistencyOperation
70   */
71  public final class MgnlGroovyClassLoader extends GroovyClassLoader {
72      private static final Logger log = LoggerFactory.getLogger(MgnlGroovyClassLoader.class);
73  
74      private static final String SCRIPTS = "scripts";
75  
76      private final HierarchyManagerProvider hmp;
77  
78      private boolean compileTimeChecks;
79  
80      private String path;
81  
82      /**
83       * @param hmp {@link HierarchyManagerProvider} - it must not be null
84       */
85      public MgnlGroovyClassLoader(HierarchyManagerProvider hmp) {
86          if (hmp == null) {
87              throw new IllegalArgumentException("HierarchyManagerProvider can not be null");
88          }
89          this.hmp = hmp;
90          this.setShouldRecompile(true);
91          this.setResourceLoader(new MgnlGroovyResourceLoader(super.getResourceLoader(), hmp, SCRIPTS));
92      }
93  
94      @Override
95      protected CompilationUnit createCompilationUnit(CompilerConfiguration config, CodeSource codeSource) {
96          CompilationUnit cu = super.createCompilationUnit(config, codeSource);
97          if (compileTimeChecks) {
98              log.debug("enforcing compile-time checks...");
99              /*
100              * "Generally speaking, there is more type information available later in the phases. If your transformation is concerned with
101              * reading the AST, then a later phase where information is more plentiful might be a good choice. If your transformation is
102              * concerned with writing AST, then an earlier phase where the tree is more sparse might be more convenient."
103              * see http://groovy.codehaus.org/Compiler+Phase+Guide
104              */
105             cu.addPhaseOperation(new PackageAndClassNameConsistencyOperation(hmp, path), Phases.CONVERSION);
106         }
107 
108         cu.addPhaseOperation(new AddDefaultImportOperation(), Phases.CONVERSION);
109         return cu;
110     }
111 
112     /**
113      * <em>Source newer</em> in our case means that the groovy source
114      * representing a certain class in the <em>scripts</em> repository is more recent than that of
115      * the corresponding class currently loaded in this classloader (i.e. it has been changed).
116      * Please note that if source protocol is <em>file</em>, then <strong>recompilation is forced</strong>
117      * otherwise the caller gets the old class in the current classloader which might be the compiled script
118      * from the scripts repository if the latter (the script, that is) was just disabled.
119      */
120     @Override
121     protected boolean isSourceNewer(URL source, Class cls) throws IOException {
122         long lastMod = -1;
123         if ("file".equals(source.getProtocol())) {
124             return true;
125         } else {
126             URLConnection conn = source.openConnection();
127             lastMod = conn.getLastModified();
128             conn.getInputStream().close();
129         }
130         boolean isNewer = lastMod > getTimeStamp(cls);
131         if (isNewer) {
132             log.info("{} source has changed", cls.getName());
133         }
134         return isNewer;
135     }
136 
137     /**
138      * Checks that the given source compiles correctly and, in case of a script which has to act as a
139      * class, that some consistency constraints imposed by our classloading mechanism are enforced. Throws a {@link CompilationFailedException} in case of compilation failure.
140      * For the applied constraints, see {@link PackageAndClassNameConsistencyOperation}
141      *
142      * @param source - String the Groovy source
143      * @param enforceCompileChecks - boolean if <code>true</code> enforce additional compile time checks
144      * @param path - String the path to the script in the repository. The substring after the last '/' is assumed to be the script name itself. Will be ignored if <em>enforceCompileChecks</em> is <code>false</code>. Cannot be <code>null</code> if <em>enforceCompileChecks</em> is <code>true</code>.
145      */
146     public final void verify(final String source, final boolean enforceCompileChecks, final String path) throws CompilationFailedException {
147         this.compileTimeChecks = enforceCompileChecks;
148         this.path = path;
149         if (compileTimeChecks && path == null) {
150             throw new IllegalArgumentException("When compilation checks are enforced, mgnlPath cannot be null");
151         }
152         parseClass(source);
153 
154     }
155 
156     private static final class AddDefaultImportOperation extends SourceUnitOperation {
157 
158         @Override
159         public void call(SourceUnit source) throws CompilationFailedException {
160             // yes, to import all the classes of a certain package you have to omit the * at the end
161             // see http://jira.codehaus.org/browse/GROOVY-3041
162             log.debug("adding default imports...");
163             source.getAST().addStarImport("info.magnolia.context.");
164             source.getAST().addStarImport("info.magnolia.cms.core.");
165             source.getAST().addStarImport("info.magnolia.cms.util.");
166             source.getAST().addStarImport("info.magnolia.jcr.util.");
167             source.getAST().addStarImport("info.magnolia.module.groovy.support.");
168             source.getAST().addStarImport("info.magnolia.module.groovy.xml.");
169         }
170     }
171 
172     /**
173      * Checks package and class name consistency on source code during Groovy's
174      * compilation phase. The checks performed are
175      * <ol>
176      * <li>Ensure that the class declares a package, unless it is being saved at root level of the <code>scripts</code> repository
177      * <li>Ensure that the package matches the path to the source in Magnolia's scripts repository
178      * <li>Ensure that the source contains at least one class named as the script itself.
179      * </ol>
180      */
181     private static final class PackageAndClassNameConsistencyOperation extends SourceUnitOperation {
182         private final HierarchyManagerProvider hmp;
183         private final String mgnlPath;
184 
185         public PackageAndClassNameConsistencyOperation(HierarchyManagerProvider hmp, String mgnlPath) {
186             this.hmp = hmp;
187             this.mgnlPath = mgnlPath;
188         }
189 
190         @Override
191         public void call(SourceUnit source) throws CompilationFailedException {
192             final String parentPath = StringUtils.defaultIfEmpty(StringUtils.substringBeforeLast(mgnlPath, "/"), "/");
193             final String scriptName = StringUtils.substringAfterLast(mgnlPath, "/");
194             final boolean isParentRoot = "/".equals(parentPath);
195             String packageName = null;
196 
197             log.debug("parent path is {}, script name is {}, isParentRoot? {}", new Object[] { parentPath, scriptName, isParentRoot });
198 
199             // do the following checks only if the the parent node is other than root
200             if (!isParentRoot) {
201                 if (!source.getAST().hasPackageName()) {
202                     String msg = scriptName + " compilation failed: you must specify a package for your class.";
203                     log.warn(msg);
204                     throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
205                 }
206                 packageName = source.getAST().getPackageName();
207                 final String path = "/" + packageName.replace('.', '/');
208                 if (!path.equals(parentPath + "/")) {
209                     String msg = scriptName
210                             + " compilation failed: class package '"
211                             + packageName
212                             + "' does not match parent node  '"
213                             + parentPath
214                             + "' path in the scripts repository";
215                     log.warn(msg);
216                     throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
217                 }
218 
219                 final HierarchyManager hm = hmp.getHierarchyManager(SCRIPTS);
220                 if (!hm.isExist(path)) {
221                     String msg = scriptName
222                             + " compilation failed: class package '"
223                             + packageName
224                             + "' does not match an existing '"
225                             + path
226                             + "' path in the scripts repository";
227                     log.warn(msg);
228                     throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
229                 }
230             }
231             // we don't need to check that class A used in class B (included via URLReaderSource) declare class A in current script
232             if (source.getSource() instanceof URLReaderSource) {
233                 return;
234             }
235 
236             // now checking that at least one class declared in the source matches the source file name
237             boolean match = false;
238             final String fullyQualifiedClassName = packageName != null ? packageName + scriptName : scriptName;
239             for (ClassNode cn : source.getAST().getClasses()) {
240                 if (fullyQualifiedClassName.equals(cn.getName())) {
241                     log.debug("found a matching class name {}, we can proceed", fullyQualifiedClassName);
242                     match = true;
243                     break;
244                 }
245             }
246             if (!match) {
247                 String msg = fullyQualifiedClassName + " should declare at least one class named " + scriptName;
248                 log.warn(msg);
249                 throw new CompilationFailedException(source.getPhase(), source, new Throwable(msg));
250             }
251         }
252     }
253 
254     /**
255      * Utility method to resolve the script or class name from source.
256      */
257     public static final String resolveScriptNameFromSource(final String source) throws RepositoryException {
258         final List<ASTNode> nodes = new AstBuilder().buildFromString(CompilePhase.CONVERSION, source);
259         for (ASTNode astNode : nodes) {
260             if (astNode instanceof ClassNode) {
261                 ClassNode cn = (ClassNode) astNode;
262                 String name = cn.getNameWithoutPackage();
263                 if (!name.startsWith("script")) {
264                     return name;
265                 }
266             }
267         }
268         return "untitledGroovyScript";
269     }
270 }