/******************************************************************************* * Copyright (c) 2012 VMWare, Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * VMWare, Inc. - initial API and implementation *******************************************************************************/ package org.grails.ide.eclipse.runonserver; import java.io.File; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.util.ArrayList; import java.util.List; import java.util.Properties; import org.apache.commons.io.FileUtils; import org.eclipse.core.resources.IContainer; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.IResourceDelta; import org.eclipse.core.resources.IResourceDeltaVisitor; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.Assert; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.NullProgressMonitor; import org.eclipse.core.runtime.Path; import org.eclipse.core.runtime.Platform; import org.eclipse.core.runtime.Status; import org.eclipse.jdt.core.IJavaProject; import org.eclipse.jdt.core.JavaCore; import org.eclipse.jdt.core.JavaModelException; import org.eclipse.jst.server.core.IWebModule; import org.eclipse.wst.server.core.IModule; import org.eclipse.wst.server.core.model.IModuleFolder; import org.eclipse.wst.server.core.model.IModuleResource; import org.eclipse.wst.server.core.util.ModuleFile; import org.eclipse.wst.server.core.util.ModuleFolder; import org.eclipse.wst.server.core.util.ProjectModule; import org.grails.ide.eclipse.commands.GrailsCommand; import org.grails.ide.eclipse.commands.GrailsCommandFactory; import org.grails.ide.eclipse.core.GrailsCoreActivator; import org.grails.ide.eclipse.core.model.GrailsBuildSettingsHelper; import org.springsource.ide.eclipse.commons.core.ZipFileUtil; /** * @author Kris De Volder * @author Andrew Eisenberg * @author Andy Clement * @author Christian Dupuis * @since 2.5.1 */ public class GrailsAppModuleDelegate extends ProjectModule implements IWebModule { /** * The target directory used by Grails itself to build compile for a war build. */ private static final String WAR_OUTPUT_FOLDER = "web-app/WEB-INF/classes"; /** * If this flag is turned on, we will try to incrementally replace stuff from the workspace into * the deployed app. * <p> * If it is turned of, every redeploy will require a full rebuild of the war file and we also * run 'grails clean' before building the war. * <p> * The flag turned of is more robust / reliable and uses only exactly what Grails would * create in the war file when invoked from the commandline. However, it builds war files * much more frequently, which can be annoying to users. */ private boolean incremental = true; //Can be set via preferences page (setting this to false, // provides a workaround for users experiencing problems for (yet to discover) similar to // STS-1518, STS-1539, STS-1913, ... public interface IResourceMatcher { boolean isMatch(IModuleResource child); } private static final boolean DEBUG = (""+Platform.getLocation()).contains("kdvolder"); //false; private void debug(String string) { if (DEBUG) System.out.println("GrailsAppModuleDelegate: " + this.getProject().getName()+": " + string); } /** * Reference to the warFile if it has been created. (So we only create it once unless something has changed). */ private File cachedWarFile = null; /** * Where we keep an "exploded" copy of the war file. */ private File explodedWarFile = null; /** * Flag is used to avoid repeated war build failures. */ boolean warBuildFailed = false; /** * Cached copy of the "deployed" IModuleResource references. */ private IModuleResource[] cachedMembers = null; public GrailsAppModuleDelegate(IModule module) { super(module.getProject()); incremental = RunOnServerProperties.getIncremental(getProject()); } @Override public boolean isSingleRootStructure() { return false; } @Override public IModuleResource[] members() throws CoreException { debug("members() called"); cacheMembers(); if (cachedMembers==null) { return new IModuleResource[0]; } else { return cachedMembers; } } /** * This method gets called whenever any resources in the project get changed. The root of the provided delta * is the project associated with this GrailsApp. */ public void projectChanged(IResourceDelta delta) { try { if (!cacheAlreadyClear()) { IResourceDeltaVisitor handler = new GrailsAppModuleDeltaVisitor(); delta.accept(handler); } } catch (CoreException e) { GrailsCoreActivator.log("Problem processing changes to project", e); } } /** Resource changes in paths starting with these Strings will be ignored */ private String[] getIgnoredPaths() { String javaOutputFolder = null; try { IPath outPath = JavaCore.create(getProject()).getOutputLocation(); javaOutputFolder = outPath.removeFirstSegments(1).toString(); } catch (JavaModelException e) { GrailsCoreActivator.log(e); } List<String> ignore = new ArrayList<String>(5); ignore.add("target"); ignore.add("test"); ignore.add(WAR_OUTPUT_FOLDER); if (javaOutputFolder!=null) { ignore.add(javaOutputFolder); } return ignore.toArray(new String[ignore.size()]); } /** Resource changes in paths starting with these Strings will be treated as "selectively reloadable" * (but only if INCREMENTAL is turned on */ private String[] getReloadablePaths() { return new String[] { "src", "grails-app/domain", "grails-app/controllers", "grails-app/views" }; }; private static final String TIME_STAMP_FILE_NAME = "__STS_TIME_STAMP__"; private class GrailsAppModuleDeltaVisitor implements IResourceDeltaVisitor { boolean abort = false; //Compute ignored paths only once per visitor private String[] ignoredPaths = getIgnoredPaths(); //Compute reloadable paths only once per visitor and only if we need it private String[] reloadablePaths = incremental ? getReloadablePaths() : null; public boolean visit(IResourceDelta delta) throws CoreException { if (abort) { debug("Delta: "+delta.getResource()+ " ABORTED"); return false; //Stop visitor NOW! } if (ignoreChangesIn(delta.getResource())) { debug("Delta: "+delta.getResource()+ " => IGNORE"); return false; } else if (isReloadable(delta.getKind(), delta.getResource())) { debug("Delta: "+delta.getResource()+ " => clear only the module tree cache"); clearMembersCache(); return false; } else if (isRebuildWar(delta.getResource())) { debug("Delta: "+delta.getResource()+ " => clear all caches"); clearCaches(); abort = true; return false; } else { debug("Delta: "+delta.getResource()+ " => look deeper"); return true; // Look deeper in the tree. } } /** * If this returns true, it means any changes to this resource can be handled by * selective reloading, so it doesn't require a rebuild of the war file. * @param deltaKind */ private boolean isReloadable(int deltaKind, IResource resource) { if (!incremental) return false; // Ignore any changes below "target" for (String prefix : reloadablePaths) { if (new Path(prefix).isPrefixOf(resource.getProjectRelativePath())) { //In present implementation, we can't properly deal with added or removed // resources, so only changed resource are handled incrementally. // See: https://issuetracker.springsource.com/browse/STS-1339 return resource.getType()==IResource.FILE && deltaKind==IResourceDelta.CHANGED; } } return false; } /** * We'll prune deltas below if this returns true. */ private boolean ignoreChangesIn(IResource resource) { // Ignore any changes below "target" for (String ignore : ignoredPaths) { if (new Path(ignore).isPrefixOf(resource.getProjectRelativePath())) { return true; } } return false; } private boolean isRebuildWar(IResource resource) { //For now, if any file changes percolate through to this test (so they are not accounted for in any other way) then we // rebuild the .war file. return resource.getType() == IResource.FILE; } } ///////////////////////////////////////////////////////////////////////////////////// // Helper code private void cacheMembers() throws CoreException { if ((cachedMembers==null || cachedWarFile==null)) { debug("Recomputing cachedMembers"); cacheWarFile(); //Ensure the exploded war file is there. if (explodedWarFile==null || ! explodedWarFile.exists() ) { debug("For some reason there is no exploded war file... (war command failed?)"); return; } cachedMembers = getDirectoryResources(Path.EMPTY, explodedWarFile); if (incremental) { IJavaProject javaProject = JavaCore.create(getProject()); IPath outputLocation = javaProject.getOutputLocation(); IFolder outputFolder = ResourcesPlugin.getWorkspace().getRoot().getFolder(outputLocation); IFolder viewsFolder = getProject().getFolder(new Path("grails-app/views")); // Replace the stuff from the grails war with whatever stuff we have in our output folder... // However, any files that exist in the .war but not our folder will not be touched. replace(cachedMembers, "WEB-INF/classes", outputFolder); replace(cachedMembers, "WEB-INF/grails-app/views", viewsFolder); removePrecompiledGSPs(); // TODO: KDV: (deploy) write some code to produce a report of what we are still borrowing from the grails war file. // If we can make the report empty then the war file is obsolete. // if (DEBUG) { // debug(">>> entries in WEB-INF/classes taken from the grails war file"); // reportWarEntries(findFolder(cachedMembers, new Path("WEB-INF/classes"))); // debug("<<< entries in WEB-INF/classes taken from the grails war file"); // } } } } /** * Removes stuff relating to precompiled .gsp files from the deployed resources. * This should have the effect of making grails fallback do dynamic gsp processing. */ private void removePrecompiledGSPs() { // Remove: the gsp folder that contains the "views.properties" file listing precompiled gsps cachedMembers = remove(cachedMembers, "WEB-INF/classes/gsp"); // Remove: precompiled gsp classes and data ModuleFolder classes = findFolder(cachedMembers, new Path("WEB-INF/classes")); removeFiles(classes, new IResourceMatcher() { public boolean isMatch(IModuleResource resource) { String name = resource.getName(); return name.startsWith("gsp_") || name.startsWith("___LineNumberPlaceholder"); } }); } private void removeFiles(ModuleFolder folder, IResourceMatcher remove) { IModuleResource[] members = folder.members(); if (members!=null && members.length>0) { List<IModuleResource> keepMembers = new ArrayList<IModuleResource>(members.length); for (IModuleResource child : members) { if (!remove.isMatch(child)) { keepMembers.add(child); } if (child instanceof ModuleFolder) { removeFiles((ModuleFolder) child, remove); } } if (keepMembers.size()!=members.length) { folder.setMembers(keepMembers.toArray(new IModuleResource[keepMembers.size()])); } } } private boolean inCflow(String string) { StringWriter trace = new StringWriter(); new Exception().printStackTrace(new PrintWriter(trace)); return trace.toString().contains(string); } private void reportWarEntries(IModuleResource root) { if (root instanceof ModuleFile) { ModuleFile file = (ModuleFile)root; File jFile = (File) file.getAdapter(File.class); if (jFile!=null) { debug(file.getModuleRelativePath()+"/"+file.getName()); } } else if (root instanceof ModuleFolder) { ModuleFolder folder = (ModuleFolder)root; IContainer container = (IContainer) folder.getAdapter(IContainer.class); if (container==null) { debug(folder.getModuleRelativePath()+"/"+folder.getName()+"/"); } for (IModuleResource m : folder.members()) { reportWarEntries(m); } } else { debug("Unknown type: "+root); } } /** * Similar to the method provided by {@link ProjectModule} but accepts an java.io.File instead on a IContainer. * <p> * Create IModuleResource objects for anything inside of a given directory. */ private IModuleResource[] getDirectoryResources(IPath pathToHere, File dir) { Assert.isLegal(dir.isDirectory()); File[] files = dir.listFiles(); IModuleResource[] resources = new IModuleResource[files.length]; for (int i = 0; i < resources.length; i++) { File file = files[i]; if (file.isDirectory()) { ModuleFolder folder = new ModuleFolder(null, file.getName(), pathToHere); folder.setMembers(getDirectoryResources(pathToHere.append(file.getName()), file)); resources[i] = folder; } else { //Ordinary file (not dir) resources[i] = new ModuleFile(file, file.getName(), pathToHere); } //debug("warElement: "+resources[i]); } return resources; } private void replace(IModuleResource[] resources, String pathStr, IFolder outputFolder) throws CoreException { IPath path = new Path(pathStr); ModuleFolder folder = findFolder(resources, path); folder.setMembers(merge(folder.members(), getModuleResources(path, outputFolder))); } private IModuleResource[] merge(IModuleResource[] ls, IModuleResource[] rs) { for (IModuleResource r : rs) { ls = merge(ls, r); } return ls; } private IModuleResource[] merge(IModuleResource[] ls, IModuleResource r) { int i = findIndex(ls, r.getName()); if (i < ls.length) { //There's an existing left element to replace or insert into IModuleResource l = ls[i]; ls[i] = r; if (l instanceof ModuleFolder && r instanceof ModuleFolder) { //debug("Merging: "+l+" & "+r); // Merge the two if both are folders (otherwise replace) ModuleFolder l_folder = (ModuleFolder) l; ModuleFolder r_folder = (ModuleFolder) r; r_folder.setMembers(merge(l_folder.members(), r_folder.members())); } else { //debug("Replacing: "+l+" by "+r); } ls[i] = r; } else { //Add new left entry ls = RunOnServerPlugin.copyOf(ls, ls.length+1); //debug("Adding: "+r); ls[ls.length-1] = r; } return ls; } private ModuleFolder findFolder(IModuleResource[] resources, IPath path) { int i = findIndex(resources, path); Assert.isTrue(i < resources.length, "Couldn't find "+path); if (path.segmentCount()==1) return (ModuleFolder) resources[i]; else { Assert.isTrue(resources[i] instanceof IModuleFolder); return findFolder(((IModuleFolder)resources[i]).members(), path.removeFirstSegments(1)); } } /** * Returns true if our caches are completely clear, in that case there will be * no need to do any change listening (since clearing the cache again will not * have any effect anyway). */ private boolean cacheAlreadyClear() { //Note: only checking war cache is enough, since when clearing that cache is // we always also clear the members cache. return cachedWarFile==null || !cachedWarFile.exists(); } /** * Build the war file, unless it was already build (and not invalidated since then). */ private void cacheWarFile() { if (!warBuildFailed && (cachedWarFile==null || !cachedWarFile.exists())) { //If we are only called to process a resource delta and the explodedWar folder exist, we can possibly //get away with using a stale copy of the exploded war (most changes will still be detected because of the stuff we //substitute into the war from the workspace). //Caveat: it is possible some changes won't be detected and won't be published to the server. We have to work at // minimising these cases. try { // if (DEBUG) { // new Exception().printStackTrace(System.out); // } if (!exists(explodedWarFile) || !inCflow("Server$ResourceChangeJob.run")) { IProject project = getProject(); debug("Rebuilding warFile for "+project); // ISchedulingRule rule = Job.getJobManager().currentRule(); // debug("Current scheduling rule = "+rule); // if (rule!=null && !rule.contains(ResourcesPlugin.getWorkspace().getRuleFactory().buildRule())) { // // This could be a problem.... race conditions likely if we do our thing while builds are possible! // GrailsCoreActivator.log(new Error("Possible race condition detected")); // } File warFile = getWarFile(project); if (!incremental) { if (isOverlappingOutputFolders()) { //We can skip this if we have properly setup project with our own private output folder GrailsCommand cleanCommand = GrailsCommandFactory.clean(getProject()); cleanCommand.synchExec(); } } GrailsCommand warCommand = GrailsCommandFactory.war(getProject(), getEnv(), warFile); warCommand.synchExec(); Assert.isTrue(warFile.exists()); explodedWarFile = getExplodedWarFile(getProject()); if (explodedWarFile.exists()) { try { FileUtils.deleteDirectory(explodedWarFile); } catch (IOException e) { // Log and try to proceed anyway GrailsCoreActivator.log(e); } } try { ZipFileUtil.unzip(warFile.toURI().toURL(), explodedWarFile, new NullProgressMonitor()); } catch (Exception e) { throw new CoreException(new Status(IStatus.ERROR, RunOnServerPlugin.PLUGIN_ID, "Could not unpack the war file: "+warFile)); } cachedWarFile = warFile; debug("DONE Rebuilding warFile for "+project); } } catch (Throwable e) { //Catch and log any problems, if they propagate to WTP they can get lost without a trace. GrailsCoreActivator.log("A problem occurred building the war file for "+getProject(), e); } } } /** * @return true is Greclipse output folder and the grails war output folder are being shared. * (This is really not desirable, but we have to deal with it). */ private boolean isOverlappingOutputFolders() { IJavaProject javaProject = JavaCore.create(getProject()); try { IPath javaOutputLocation = javaProject.getOutputLocation().removeFirstSegments(1); //Drop project name IPath warOutputLocation = new Path(WAR_OUTPUT_FOLDER); return javaOutputLocation.equals(warOutputLocation); } catch (JavaModelException e) { GrailsCoreActivator.log(e); return true; //Assume the worst and proceed. } } private String getEnv() { IProject project = getProject(); return RunOnServerProperties.getEnv(project); } private boolean exists(File file) { return file!=null && file.exists(); } /** * This method determines the location of (where to create) the .war file. * <p> * Note: public for testing purposes only. */ public static File getWarFile(IProject project) { IPath stage = getStagingArea(); IPath explodedLocation = stage.append(project.getName()+".war"); return explodedLocation.toFile(); } /** * This method determines the location of (where to create) the explodedWarFile */ private File getExplodedWarFile(IProject project) { IPath stagingArea = getStagingArea(); IPath explodedLocation = stagingArea.append(project.getName()+"/exploded"); return explodedLocation.toFile(); } private static IPath getStagingArea() { return RunOnServerPlugin.getDefault().getStagingArea(); } public void clearCaches() { debug("Clearing caches: members + warFile"); touchTimeStamp(); // cachedMembers = null; Note: not actually cleared, it is "implied" to be invalid because cachedWarFile == null cachedWarFile = null; warBuildFailed = false; } private void touchTimeStamp() { File file = getTimeStampFile(); if (file!=null) { try { FileUtils.touch(file); } catch (IOException e) { GrailsCoreActivator.log("Problems touching time stamp file in Grails RunOnServer", e); } } } /** * The time stamp file is a dummy file that we change whenever the war file cache is cleared. * This will ensure that even when we don't rebuild the war when we really should (see cacheWarFile method) * we still have at least one changed file that makes it into the result returned by 'members'. * <p> * We need to do this because otherwise if WTP doesn't see any changes, it won't ask again * for the module members when time comes to actually deploy and build the war file. * <p> * Note: the time stamp file isn't deployed to the server because when the stamp is there, * a war should be built before the next deploy and the stamp will be erased in the process. */ private File getTimeStampFile() { File file = explodedWarFile; //It's ok not to have a time stamp if the explodedWar doesn't exist, since this // fact alone will ensure that the war will be built. if (file!=null) { file = new File(file, TIME_STAMP_FILE_NAME); } return file; } public void clearMembersCache() { debug("Clearing caches: members"); cachedMembers = null; } /** * Given an array of "to be published" resources. Add a given IResource to that array at a specified location within * the "publish tree". * * @throws CoreException */ private IModuleResource[] add(IModuleResource[] resources, IResource rsrcToAdd, String path) throws CoreException { return add(resources, rsrcToAdd, new Path(path), Path.EMPTY); } private IModuleResource[] add(IModuleResource[] resources, IResource rsrcToAdd, IPath path, IPath pathToHere) throws CoreException { int i = findIndex(resources, path); if (i < resources.length) { //Found what we were searching for Assert.isTrue(path.segmentCount()>1, "Adding something that is already there isn't supported/allowed"); addToChildren((ModuleFolder)resources[i], rsrcToAdd, path.removeFirstSegments(1), pathToHere.append(path.segment(0))); } else { //Didn't find it so we must create something here. Assert.isTrue(path.segmentCount()==1, "Auto creationg of intervening folders is not supported (yet)"); resources = RunOnServerPlugin.copyOf(resources, resources.length+1); if (rsrcToAdd instanceof IContainer) { ModuleFolder addIt = new ModuleFolder((IContainer) rsrcToAdd, path.segment(0), pathToHere); addIt.setMembers(getModuleResources(pathToHere.append(path), (IContainer) rsrcToAdd)); resources[resources.length-1] = addIt; } else { ModuleFile addIt = new ModuleFile((IFile)rsrcToAdd, path.segment(0), pathToHere); resources[resources.length-1] = addIt; } } return resources; } /** * Add a given resource to the children of a moduleFolder, at a given relative path location in the module tree. * @param path Path starting from the given moduleFolder (does not include the name of the moduleFolder itself) * @param pathToHere Path leading upto this location, including the name of the moduleFolder itself. * @throws CoreException */ private void addToChildren(ModuleFolder moduleFolder, IResource rsrcToAdd, IPath path, IPath pathToHere) throws CoreException { IModuleResource[] children = moduleFolder.members(); children = add(children, rsrcToAdd, path, pathToHere); moduleFolder.setMembers(children); } /** * Given an array of "to be published" resources, recursively search for and remove a given resource * (as indicated by a path String). * <p> * If the resource this path String leads to is a folder than the folder and everything inside of it are removed. * <p> * If the path doesn't lead to a resource, then nothing is removed. * * @return May return either a copy of the array or the same array modified by a side effect. (Depending on whether * the length of the array had to change (lenght may not need to change if resource was removed from a child * of one of the elements. */ private IModuleResource[] remove(IModuleResource[] resources, String pathToRemove) { return remove(resources, new Path(pathToRemove)); } private IModuleResource[] remove(IModuleResource[] resources, IPath path) { int i = findIndex(resources, path); if (i < resources.length) { //Found what we were searching for if (path.segmentCount()==1) { //This is the one to delete! IModuleResource[] result = new IModuleResource[resources.length-1]; System.arraycopy(resources, 0, result, 0, i); // Copy upto i into new array. System.arraycopy(resources, i+1, result, i, result.length-i); return result; } else { // Must delete something deeper down removeFromChildren((ModuleFolder)resources[i], path.removeFirstSegments(1)); return resources; } } else { // We couldn't find the path so don't delete anything return resources; } } private int findIndex(IModuleResource[] resources, IPath path) { String search = path.segment(0); return findIndex(resources, search); } private int findIndex(IModuleResource[] resources, String search) { int i = 0; while (i < resources.length && !resources[i].getName().equals(search)) { i++; } return i; } /** * Remove a resource from the children of this folder * @param folder * @param pathToRemove Path to target resource, not including the name of the folder. */ private void removeFromChildren(ModuleFolder folder, IPath pathToRemove) { IModuleResource[] children = folder.members(); children = remove(children, pathToRemove); folder.setMembers(children); } /////////////////////////////////////////////////////////////////////////////////// /// IWebModule implementation public IContainer[] getResourceFolders() { return new IContainer[0]; } public IContainer[] getJavaOutputFolders() { return new IContainer[0]; } public boolean isBinary() { return false; } public String getContextRoot() { Properties props = GrailsBuildSettingsHelper.getApplicationProperties(getProject()); if (props!=null) { String root = props.getProperty("app.context"); if (root!=null) { return root; } } return getName(); } public String getContextRoot(IModule earModule) { return getContextRoot(); } public IModule[] getModules() { return new IModule[0]; } public String getURI(IModule module) { return null; } /** * Called when the 'env' property for the project associated with this delegate changes. */ public void envChanged(String oldEnv, String newEnv) { clearCaches(); // Must force a 'hard' clear, because otherwise the env change won't be noticed by WTP. } /** * Called when the 'incremental' property for the project associated with this delegate changes. */ public void incrementalChanged(boolean old, boolean isIncremental) { this.incremental = isIncremental; clearCaches(); } }