/* This file is part of leafdigital leafChat. leafChat is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. leafChat is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with leafChat. If not, see <http://www.gnu.org/licenses/>. Copyright 2012 Samuel Marshall. */ package com.leafdigital.scripting; import java.io.*; import java.lang.reflect.InvocationTargetException; import java.util.*; import java.util.jar.*; import javax.swing.SwingUtilities; import org.w3c.dom.*; import util.*; import util.xml.*; import leafchat.core.api.*; /** * Represents a 'script', an XML file defining a collection of actions expressed * in a combination of data and Java code, which can be compiled into a * leafChat plugin. */ public class Script { private final static boolean DEBUG_PRINT_SOURCE = false; private PluginContext context; /** Script file (.leafChatScript) - may not exist yet */ private File f; /** Corresponding jar file (.leafChatScript.jar) - may not exist yet */ private File jar; /** Errors file - may not exist */ private File errors,errorsSource; /** If true, this script is loaded and runs */ private boolean enabled=true; /** If currently loaded, the script's PluginInfo is saved here */ private PluginInfo installed=null; /** True if changes have been made that require save */ private boolean changed=false; /** List of items in the script */ private LinkedList<ScriptItem> items; /** List of dependencies (does not include system plugins) */ private LinkedList<Dependency> dependencies; /** Information for a dependency */ private static class Dependency { String packageName; int version; } static final String ALLOWED_NAMES="[A-Za-z 0-9]+\\.leafChatScript"; private LinkedList<StateListener> stateListeners = new LinkedList<StateListener>(); /** * Construct script from a particular file, which may exist or not. If it * exists, items from the file will be loaded. * @param context Plugin context * @param f File to load * @throws GeneralException Any problems loading the file */ Script(PluginContext context,File f) throws GeneralException { // Ensure name is standard and safe (it is used to generate classnames) if(!f.getName().matches(ALLOWED_NAMES)) throw new GeneralException("Invalid script filename: "+f.getName()); this.context=context; this.f=f; this.jar=new File(f.getPath()+".jar"); this.errors=new File(f.getPath()+".errors.xml"); this.errorsSource=new File(f.getPath()+".errors.java"); load(); if(enabled && jar.exists()) install(); } /** Number of attempts to delete files (which tend to be inexplicably locked) */ private final static int DELETEATTEMPTS=3; private void internalDelete(File going,boolean gc) throws GeneralException { if(going.delete()) return; for(int i=0;i<DELETEATTEMPTS;i++) { try { if(gc) System.gc(); Thread.sleep(250); } catch(InterruptedException ie) { } going.delete(); if(!going.exists()) return; } throw new GeneralException("Failed to delete "+going); } /** * Deletes the script from disk and memory. * @throws GeneralException Any error */ void delete() throws GeneralException { uninstall(); internalDelete(f,false); if(hasJar()) { internalDelete(jar,true); } if(hasErrors()) { internalDelete(errors,false); internalDelete(errorsSource,false); } } PluginContext getContext() { return context; } /** Number of errors */ private int errorCount=0; /** @return Number of errors in error file, or 0 if none */ int getErrorCount() { return errorCount; } /** * In-memory initialises any errors from the given file. * @param resultFile Eclipse compiler result file * @param source Original source file * @return True if compile was a success, false if it failed * @throws GeneralException Any error */ private boolean readResultFile(File resultFile,File source) throws GeneralException { // Clear existing error markings for(ScriptItem item : items) { item.clearErrors(); } // Load results try { String resultString; try { resultString=IOUtils.loadStringPlatformEncoding(new FileInputStream(resultFile)); } catch(IOException e) { throw new BugException(e); } resultString=resultString.replaceAll("<!DOCTYPE[^>]*>",""); Document results=XML.parse(resultString); Element stats=XML.getChild(results.getDocumentElement(),"stats"); if(XML.hasChild(stats,"problem_summary")) errorCount=XML.getIntAttribute(XML.getChild(stats,"problem_summary"), "errors"); else errorCount=0; if(errorCount==0) return true; try { // Load source file String[] code=IOUtils.loadString(new FileInputStream(source)).split("\n"); // Loop through all the problems Element[] problems=XML.getChildren(XML.getChild(XML.getChild( XML.getChild(results.getDocumentElement(),"sources"),"source"),"problems"),"problem"); for(int i=0;i<problems.length;i++) { if(problems[i].getAttribute("severity").equals("ERROR")) { String message=""; if(XML.hasChild(problems[i],"message")) { message=XML.getRequiredAttribute(XML.getChild(problems[i],"message"),"value"); } markError(code,Integer.parseInt(problems[i].getAttribute("line")),message); } } } catch(IOException e) { throw new GeneralException(e); } return false; } catch(XMLException e) { throw new BugException(e); } } private boolean markError(String[] code,int line,String message) { // Find error location, converted into item and user code line int item=-1,userCode=-2; for(int check=line-1;check>=0;check--) { String checkLine=code[check]; if(checkLine.startsWith("// ----^")) // Ooops, looks like we weren't in an item { return false; } if(checkLine.startsWith("// ----v")) // Aha, found item we're in { item=Integer.parseInt(checkLine.substring(8)); break; } if(userCode==-2) // Not ascertained user code yet { if(checkLine.startsWith("// ====v")) // Aha, found user code we're in { userCode=Integer.parseInt(checkLine.substring(8)); } if(checkLine.startsWith("// ====^")) // Ooops, not in user code { userCode=-1; } } } if(item==-1) return false; getItems()[item].markError(userCode>=0 ? userCode : ScriptItem.NOTINUSERCODE,message); return true; } /** * Installs the script into the system. Script jar must exist. Does nothing * if already installed. * @throws GeneralException If jar doesn't exist or something else is wrong */ private void install() throws GeneralException { if(installed!=null) return; if(!jar.exists()) throw new GeneralException("Can't install script when jar file doesn't exist: "+jar); PluginList pl=context.getSingle(PluginList.class); installed=pl.loadPluginFile(jar); } /** * Uninstalls script from system. Does nothing if not already installed * @throws GeneralException Any errors while uninstalling */ private void uninstall() throws GeneralException { if(installed==null) return; PluginList pl=context.getSingle(PluginList.class); pl.unloadPluginFile(installed); installed=null; System.gc(); try { Thread.sleep(500); } catch(InterruptedException ie) { } System.gc(); } /** * Saves the current state of the Script object to the file. * @throws GeneralException */ private void internalSave() throws GeneralException { try { File target=new File(f.getPath()+".new"); Document d=XML.newDocument("script"); Element root=d.getDocumentElement(); root.setAttribute("version","1"); root.setAttribute("enabled",enabled?"y":"n"); Element dependenciesRoot=XML.createChild(root,"dependencies"); for(Dependency dep : dependencies) { Element api=XML.createChild(dependenciesRoot,"api"); XML.setText(XML.createChild(api,"package"),dep.packageName); XML.setText(XML.createChild(api,"version"),dep.version+""); } Element itemRoot=XML.createChild(root,"items"); for(ScriptItem item : items) { item.save(XML.createChild(itemRoot,item.getTag())); } XML.save(target,d); // If there is an old file, rename it before deleting it in case that // avoids rare problem on Windows where we can't rename to the target // immediately after deleting it. (Speculation based on automatic error // report.) File old = new File(f.getPath() + ".old"); if(old.exists()) { if(!old.delete()) { throw new IOException("Failed to delete old script"); } } if(f.exists()) { if(!f.renameTo(old)) { throw new IOException("Failed to rename existing script"); } old.delete(); // Not too bothered if this one fails. } if(!target.renameTo(f)) { throw new IOException("Couldn't rename to final destination from "+target); } markUnchanged(); } catch(IOException e) { throw new GeneralException("Script file "+f+" could not be saved: "+e.getMessage(),e); } } static interface StateListener { /** * Called when the script's state (enabled/disabled, modified/unmodified) * changes. * @param s Script that has changed */ public void scriptStateChanged(Script s); } synchronized void addStateListener(StateListener l) { stateListeners.add(l); } synchronized void removeStateListener(StateListener l) { stateListeners.remove(l); } /** Sets the changed flag */ synchronized void markChanged() { if(changed) return; changed=true; fireStateListeners(); } synchronized private void fireStateListeners() { StateListener[] listeners=stateListeners.toArray(new StateListener[stateListeners.size()]); for(int i=0;i<listeners.length;i++) { listeners[i].scriptStateChanged(this); } } synchronized private void markUnchanged() { if(!changed) return; changed=false; fireStateListeners(); } /** @return True if script has changed and not been saved */ boolean isChanged() { return changed; } /** @return True if script is enabled for use */ boolean isEnabled() { return enabled; } /** @return True if a compiled jar file exists */ boolean hasJar() { return jar.exists(); } /** @return Main script file */ File getFile() { return f; } /** @return True if an error report file exists */ boolean hasErrors() { return errors.exists() && errorsSource.exists(); } /** @return Name of script */ String getName() { return f.getName().replaceAll("\\.leafChatScript$",""); } /** * Saves Java source code corresponding to this script into the compile folder. * Automatically called by {@link #compile()}. * @param compileFolder Base folder for compile * @return Class name * @throws IOException File error saving source */ private String saveSource(File compileFolder) throws IOException { String source; try { source=IOUtils.loadString(getClass().getResourceAsStream("userscript.source.txt")); } catch(IOException e) { throw new BugException("Failed to load script template"); } // Do imports for system plugins PluginList pl=context.getSingle(PluginList.class); PluginInfo[] info=pl.getPluginList(); StringBuffer sb=new StringBuffer(); for(int i=0;i<info.length;i++) { if(info[i].isSystem()) { PluginExport[] exports=info[i].getPluginExports(); for(int j=0;j<exports.length;j++) { sb.append("import "+exports[j].getPackage()+".*;\n"); } } } source=StringUtils.replace(source,"%%SYSTEMIMPORTS%%",sb.toString()); // Do imports for specifically-marked dependency API packages sb=new StringBuffer(); for(Dependency dep : dependencies) { sb.append("import "+dep.packageName+".*;\n"); } source=StringUtils.replace(source,"%%DEPENDENCYIMPORTS%%",sb.toString()); // Any other imports? sb=new StringBuffer(); for(ScriptItem item : items) { if(!item.isEnabled()) continue; sb.append(markOwningItem(item,item.getSourceImports())); } source=StringUtils.replace(source,"%%ITEMIMPORTS%%",sb.toString()); // Class name String className= "UserScript"+ StringUtils.capitalise(f.getName().replaceAll("\\..*$","")). replaceAll("[^A-Za-z0-9]",""); source=StringUtils.replace(source,"%%CLASSNAME%%",className); // Fields sb=new StringBuffer(); for(ScriptItem item : items) { if(!item.isEnabled()) continue; sb.append(markOwningItem(item,item.getSourceFields())); } source=StringUtils.replace(source,"%%ITEMFIELDS%%",sb.toString()); // Init method sb=new StringBuffer(); for(ScriptItem item : items) { if(!item.isEnabled()) continue; sb.append(markOwningItem(item,item.getSourceInit())); } source=StringUtils.replace(source,"%%ITEMINIT%%",sb.toString()); // Close method sb=new StringBuffer(); for(ScriptItem item : items) { if(!item.isEnabled()) continue; sb.append(markOwningItem(item,item.getSourceClose())); } source=StringUtils.replace(source,"%%ITEMCLOSE%%",sb.toString()); // Methods sb=new StringBuffer(); for(ScriptItem item : items) { if(!item.isEnabled()) continue; sb.append(markOwningItem(item,item.getSourceMethods())); } source=StringUtils.replace(source,"%%ITEMMETHODS%%",sb.toString()); if(DEBUG_PRINT_SOURCE) { System.out.println("\n\n"+source+"\n\n"); } File target=new File(compileFolder,className+".java"); IOUtils.saveString(source,new FileOutputStream(target)); return className; } /** * Marks source code that belongs to a particular item. * @param item Item that owns code * @param text Source being marked * @return New source */ private static String markOwningItem(ScriptItem item,String text) { if(text==null) return ""; else { // Trim \n from start and end of string just to tidy it up (and don't // call trim() because that gets rid of indent tabs). while(text.startsWith("\n")) text=text.substring(1); while(text.endsWith("\n")) text=text.substring(0,text.length()-1); return "// ----v"+item.getIndex() +"\n"+ text+"\n// ----^"+item.getIndex()+"\n"; } } private final static Object COMPILESYNCH=new Object(); /** * Saves source and compiles script to a temporary jar file. Call * {@link #setupJar()} to make this jar into the real one. * @return True if compile was successful, false if there were errors. * @throws GeneralException */ private boolean compile() throws GeneralException { File compile=null; try { // Create compilation folder String rand=(Math.random()+"").replaceAll("\\.","").replaceFirst("^0",""); compile=new File(System.getProperty("java.io.tmpdir"),"leafChat.compile."+rand); compile.mkdirs(); // Save file in folder String className=saveSource(compile); File java=new File(compile,className+".java"); // Work out classpath PluginList pl=context.getSingle(PluginList.class); String classPath = ""; for(File file : pl.getCoreJars()) { if(classPath.length() > 0) { classPath += System.getProperty("path.separator"); } classPath += file.getAbsolutePath(); } PluginInfo[] plugins=pl.getPluginList(); for(int i=0;i<plugins.length;i++) { classPath+=System.getProperty("path.separator")+ plugins[i].getJar().getAbsolutePath(); } // Compile it File buildErrors=getBuildErrors(); synchronized(COMPILESYNCH) { org.eclipse.jdt.internal.compiler.batch.Main.main(new String[] { "-nowarn","-classpath",classPath, "-sourcepath",compile.getAbsolutePath(),"-d",compile.getAbsolutePath(), "-target","1.4","-encoding", "UTF-8","-log",buildErrors.getAbsolutePath(),"-noExit", java.getAbsolutePath()}); } if(!readResultFile(buildErrors,java)) { // Copy source to store it IOUtils.copy(new FileInputStream(java),new FileOutputStream(getBuildErrorsSource()),true); return false; } // Delete errors file, no errors buildErrors.delete(); // Uninstall existing version, if loaded uninstall(); // Put classes in jar file JarOutputStream jos=new JarOutputStream(new FileOutputStream( getBuildJar())); makeJar(jos,compile,""); // Build plugininfo String xml=IOUtils.loadString(ScriptingTool.class.getResourceAsStream("userscript.metadata.xml")); // Class name xml=xml.replaceAll("%%CLASSNAME%%",className); // Dependencies StringBuffer dependencyXML=new StringBuffer(); PluginInfo[] info=pl.getPluginList(); for(int i=0;i<info.length;i++) { if(!info[i].isSystem()) continue; // Only require all system plugins PluginExport[] exports=info[i].getPluginExports(); for(int j=0;j<exports.length;j++) { dependencyXML.append("<api><package>"+ exports[j].getPackage()+"</package><version>"+ exports[j].getMaxVersion()+ "</version></api>"); } } for(Dependency dep : dependencies) { dependencyXML.append("<api><package>"+ dep.packageName+"</package><version>"+ dep.version+ "</version></api>"); } xml=xml.replaceAll("%%DEPENDENCIES%%",dependencies.toString()); // Save plugin info JarEntry je=new JarEntry("plugininfo.xml"); jos.putNextEntry(je); IOUtils.copy(new ByteArrayInputStream(xml.getBytes("UTF-8")),jos,false); jos.closeEntry(); // Finish jar file jos.close(); // Successful compile and jar return true; } catch(IOException e) { throw new GeneralException(e); } finally { // Clean up compile folder try { if(compile.exists()) IOUtils.recursiveDelete(compile); } catch(IOException ioe) { } } } /** @return Jar file created in temporary builds */ private File getBuildJar() { return new File(jar.getParentFile(),jar.getName()+".new"); } /** @return Errors file created in temporary builds */ private File getBuildErrors() { return new File(errors.getParentFile(),errors.getName()+".new.xml"); } /** @return Source file created in temporary builds */ private File getBuildErrorsSource() { return new File(errorsSource.getParentFile(),errorsSource.getName()+".new.java"); } /** * Adds entries to a jar file from a disk folder. Does not close stream. * @param jos Stream into which new entries will be stuffed * @param folder Folder to search * @param prefix Used in recursive calls, initial value "" * @throws IOException */ private static void makeJar(JarOutputStream jos,File folder,String prefix) throws IOException { File[] files=IOUtils.listFiles(folder); for(int i=0;i<files.length;i++) { if(files[i].isDirectory()) makeJar(jos,files[i],prefix+files[i].getName()+"/"); else if(files[i].getName().endsWith(".class")) { JarEntry je=new JarEntry(prefix+files[i].getName()); jos.putNextEntry(je); IOUtils.copy(new FileInputStream(files[i]),jos,false); jos.closeEntry(); } } } /** * Enables or disables the script. This can only be done when the script is * not being edited i.e. when isChanged() returns false. The change to enable * state is saved directly to disk. * @param enabled New enable state * @throws GeneralException If there is an error when compiling the script * @throws BugException If you call this when there are unsaved changes */ void setEnabled(boolean enabled) throws GeneralException { if(this.enabled==enabled) return; if(isChanged()) throw new BugException("Cannot alter enabled setting while changes are unsaved"); if(enabled) { if(!jar.exists()) { if(compile()) setupJar(); else throw new GeneralException("Cannot enable script that has errors"); } this.enabled=true; internalSave(); install(); } else { this.enabled=false; internalSave(); uninstall(); } fireStateListeners(); } /** * Interface for continuation so that UI can make it do something after * save. */ public interface SaveContinuation { /** * Called after save with expected result. * @param success True if save is successful */ public void afterSave(boolean success); /** * Called after save with unexpected error. * @param t Error exception */ public void afterSave(Throwable t); } /** * Compiles and saves the script. If there is an error, passes false * to the continuation, in which case you can show UI then {@link #saveAndDisable()}. * @param sc Continuation which controls what happens after save * @throws GeneralException If something goes wrong (other than compile error) */ void save(final SaveContinuation sc) throws GeneralException { (new Thread(new Runnable() { @Override public void run() { try { if(compile()) { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { try { deleteErrors(); internalSave(); uninstall(); setupJar(); if(isEnabled()) install(); sc.afterSave(true); } catch(Throwable t) { sc.afterSave(t); } } }); } else { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { sc.afterSave(false); } }); } } catch(final Throwable t) { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { sc.afterSave(t); } }); } } },"Script compile thread")).start(); } /** * Disables the script and saves it, without compiling. May only be called * if errors occurred during compile. * @throws GeneralException If anything goes wrong with save or there wasn't * an errors file */ void saveAndDisable() throws GeneralException { enabled=false; internalSave(); uninstall(); deleteJar(); setupErrors(); } /** * Renames the build jar file to replace the current actual jar. * @throws GeneralException Any error */ private void setupJar() throws GeneralException { deleteJar(); if(!getBuildJar().renameTo(jar)) throw new GeneralException("Failed to install new version of script file "+jar); deleteErrors(); } /** * Renames the build errors file to replace the current actual errors file. * @throws GeneralException Any error */ private void setupErrors() throws GeneralException { deleteErrors(); if(!getBuildErrors().renameTo(errors)) throw new GeneralException("Failed to update stored errors "+errors); if(!getBuildErrorsSource().renameTo(errorsSource)) throw new GeneralException("Failed to update stored errors source "+errorsSource); deleteJar(); } /** * Deletes current script jar file. * @throws GeneralException Any error */ private void deleteJar() throws GeneralException { if(!jar.exists()) { return; } File oldJar = new File(jar.getPath() + ".old"); if(oldJar.exists()) { if(!oldJar.delete()) { throw new GeneralException("Failed to delete old script file " + oldJar); } } if(!jar.renameTo(oldJar)) { throw new GeneralException("Failed to rename script file to old file " + oldJar); } oldJar.delete(); // Doesn't actually matter if this one fails } /** * Deletes current script errors file. * @throws GeneralException Any error */ private void deleteErrors() throws GeneralException { if(!errors.exists()) return; if(!errors.delete()) throw new GeneralException("Failed to delete script errors file "+errors); if(!errorsSource.delete()) throw new GeneralException("Failed to delete script errors source file "+errorsSource); } ScriptItem[] getItems() { return items.toArray(new ScriptItem[items.size()]); } /** * Adds a new item to the script. * @param newItem New item */ public void addItem(ScriptItem newItem) { items.add(newItem); markChanged(); } /** * Deletes an item from the script. * @param item Item to be deleted */ public void deleteItem(ScriptItem item) { int index=item.getIndex(); items.remove(item); for(;index<items.size();index++) { items.get(index).setIndex(index); } markChanged(); } /** * Reverts any changes, returning to the on-disk version. * @throws GeneralException Any error */ public void load() throws GeneralException { LinkedList<ScriptItem> itemsBefore = items; LinkedList<Dependency> dependenciesBefore = dependencies; items = new LinkedList<ScriptItem>(); dependencies = new LinkedList<Dependency>(); if(f.exists()) { try { // Load file and check basic elements Document d=XML.parse(f); Element root=d.getDocumentElement(); if(!root.getTagName().equals("script")) throw new XMLException("Expected <script> tag"); if(XML.getIntAttribute(root,"version")!=1) throw new XMLException("Version not supported"); enabled=XML.getRequiredAttribute(root,"enabled").equals("y"); // Get dependencies Element[] dependencyElements=XML.getChildren(XML.getChild(root,"dependencies")); for(int i=0;i<dependencyElements.length;i++) { if(!dependencyElements[i].getTagName().equals("api")) throw new XMLException("Unexpected tag inside dependencies: <"+dependencyElements[i].getTagName()+">"); Dependency dep=new Dependency(); dep.packageName=XML.getChildText(dependencyElements[i],"package"); try { dep.version=Integer.parseInt(XML.getChildText(dependencyElements[i],"version")); dependencies.add(dep); } catch(NumberFormatException e) { throw new XMLException("Invalid version inside dependencies"); } } // Get all the items Element[] itemElements=XML.getChildren(XML.getChild(root,"items")); for(int i=0;i<itemElements.length;i++) { Element item=itemElements[i]; Element el=item; String className= "com.leafdigital.scripting.Item"+StringUtils.capitalise(el.getTagName()); try { items.add( Class.forName(className).asSubclass(ScriptItem.class).getConstructor( Script.class, Element.class, int.class). newInstance(this, el, i)); } catch(InstantiationException e) { throw new BugException("Incorrect class definition in "+className,e); } catch(IllegalAccessException e) { throw new BugException("Non-public constructor in "+className,e); } catch(InvocationTargetException e) { if(e.getCause() instanceof XMLException) throw (XMLException)e.getCause(); if(e.getCause() instanceof GeneralException) throw (GeneralException)e.getCause(); if(e.getCause() instanceof BugException) throw (BugException)e.getCause(); throw new BugException("Unexpected exception in "+className,e.getCause()); } catch(NoSuchMethodException e) { throw new BugException("Missing constructor in "+className,e); } catch(ClassNotFoundException e) { throw new XMLException("Unknown item type: <"+el.getTagName()+">"); } } if(hasErrors()) readResultFile(errors,errorsSource); markUnchanged(); } catch(XMLException e) { throw new GeneralException("Script file "+f+" cannot be loaded: "+e.getMessage(),e); } catch(GeneralException e) { throw new GeneralException("Script file "+f+" cannot be loaded: "+e.getMessage(),e); } catch(BugException e) { throw new BugException("Script file "+f+" cannot be loaded: "+e.getMessage(),e); } finally { // If a revert fails, put it back to what it was before if(isChanged()) { items=itemsBefore; dependencies=dependenciesBefore; } } } } }