From bf30e2de90d429de5fcfca25eb77743c787d027c Mon Sep 17 00:00:00 2001 From: Dave Syer Date: Tue, 28 May 2013 16:59:07 +0100 Subject: [PATCH] [bs-135] Add support for closure-style options declarations E.g. options { option "foo", "Foo set" option "bar", "Bar has an argument of type int" withOptionalArg() ofType Integer } println "Hello ${options.nonOptionArguments()}: " + "${options.has('foo')} ${options.valueOf('bar')}" [#50427095] [bs-135] Plugin model for spring commands --- .../bootstrap/cli/command/OptionHandler.java | 20 +++- .../cli/command/OptionParsingCommand.java | 2 +- .../bootstrap/cli/command/ScriptCommand.java | 69 ++++++++--- .../command/ScriptCompilationCustomizer.java | 113 +++++++++++++++++- .../cli/command/ScriptCommandTests.java | 60 +++++++++- .../test/resources/commands/closure.groovy | 6 + .../test/resources/commands/command.groovy | 18 +++ .../test/resources/commands/handler.groovy | 19 +++ .../src/test/resources/commands/mixin.groovy | 6 + .../test/resources/commands/runnable.groovy | 11 ++ .../src/test/resources/commands/test.groovy | 24 +--- 11 files changed, 305 insertions(+), 43 deletions(-) create mode 100644 spring-bootstrap-cli/src/test/resources/commands/closure.groovy create mode 100644 spring-bootstrap-cli/src/test/resources/commands/command.groovy create mode 100644 spring-bootstrap-cli/src/test/resources/commands/handler.groovy create mode 100644 spring-bootstrap-cli/src/test/resources/commands/mixin.groovy create mode 100644 spring-bootstrap-cli/src/test/resources/commands/runnable.groovy diff --git a/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/OptionHandler.java b/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/OptionHandler.java index a745a027bee..85166bdc8d4 100644 --- a/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/OptionHandler.java +++ b/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/OptionHandler.java @@ -15,6 +15,8 @@ */ package org.springframework.bootstrap.cli.command; +import groovy.lang.Closure; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -28,9 +30,10 @@ import joptsimple.OptionSpecBuilder; * @author Dave Syer * */ -public abstract class OptionHandler { +public class OptionHandler { private OptionParser parser; + private Closure closure; public OptionSpecBuilder option(String name, String description) { return getParser().accepts(name, description); @@ -40,7 +43,7 @@ public abstract class OptionHandler { return getParser().acceptsAll(aliases, description); } - private OptionParser getParser() { + public OptionParser getParser() { if (this.parser == null) { this.parser = new OptionParser(); options(); @@ -48,14 +51,23 @@ public abstract class OptionHandler { return this.parser; } - protected abstract void options(); + protected void options() { + if (this.closure != null) { + this.closure.call(); + } + } + + public void setOptions(Closure closure) { + this.closure = closure; + } public final void run(String... args) throws Exception { OptionSet options = getParser().parse(args); run(options); } - protected abstract void run(OptionSet options) throws Exception; + protected void run(OptionSet options) throws Exception { + } public String getHelp() { OutputStream out = new ByteArrayOutputStream(); diff --git a/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/OptionParsingCommand.java b/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/OptionParsingCommand.java index c612eaf770c..64af6fce863 100644 --- a/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/OptionParsingCommand.java +++ b/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/OptionParsingCommand.java @@ -19,7 +19,7 @@ package org.springframework.bootstrap.cli.command; import org.springframework.bootstrap.cli.Command; /** - * Base class for any {@link Command}s that use an {@link OptionHandler}. + * Base class for a {@link Command} that uses an {@link OptionHandler}. * * @author Phillip Webb * @author Dave Syer diff --git a/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/ScriptCommand.java b/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/ScriptCommand.java index b500bde2185..d5fd3a0117e 100644 --- a/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/ScriptCommand.java +++ b/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/ScriptCommand.java @@ -15,12 +15,18 @@ */ package org.springframework.bootstrap.cli.command; +import groovy.lang.Closure; +import groovy.lang.GroovyObjectSupport; +import groovy.lang.MetaClass; +import groovy.lang.MetaMethod; import groovy.lang.Script; import java.io.File; import java.io.IOException; import java.net.URL; +import joptsimple.OptionParser; + import org.apache.ivy.util.FileUtil; import org.codehaus.groovy.control.CompilationFailedException; import org.springframework.bootstrap.cli.Command; @@ -79,16 +85,32 @@ public class ScriptCommand implements Command { @Override public void run(String... args) throws Exception { - if (getMain() instanceof Command) { - ((Command) getMain()).run(args); - } else if (getMain() instanceof OptionHandler) { + run(getMain(), args); + } + + private void run(Object main, String[] args) throws Exception { + if (main instanceof Command) { + ((Command) main).run(args); + } else if (main instanceof OptionHandler) { ((OptionHandler) getMain()).run(args); - } else if (this.main instanceof Runnable) { - ((Runnable) this.main).run(); - } else if (this.main instanceof Script) { + } else if (main instanceof Closure) { + ((Closure) main).call((Object[]) args); + } else if (main instanceof Runnable) { + ((Runnable) main).run(); + } else if (main instanceof Script) { Script script = (Script) this.main; script.setProperty("args", args); - script.run(); + if (this.main instanceof GroovyObjectSupport) { + GroovyObjectSupport object = (GroovyObjectSupport) this.main; + if (object.getMetaClass().hasProperty(object, "parser") != null) { + OptionParser parser = (OptionParser) object.getProperty("parser"); + if (parser != null) { + script.setProperty("options", parser.parse(args)); + } + } + } + Object result = script.run(); + run(result, args); } } @@ -117,6 +139,16 @@ public class ScriptCommand implements Command { throw new IllegalStateException("Cannot create main class: " + this.name, e); } + if (this.main instanceof OptionHandler) { + ((OptionHandler) this.main).options(); + } else if (this.main instanceof GroovyObjectSupport) { + GroovyObjectSupport object = (GroovyObjectSupport) this.main; + MetaClass metaClass = object.getMetaClass(); + MetaMethod options = metaClass.getMetaMethod("options", null); + if (options != null) { + options.doMethodInvoke(this.main, null); + } + } } return this.main; } @@ -144,16 +176,25 @@ public class ScriptCommand implements Command { } private File locateSource(String name) { - String resource = "commands/" + name + ".groovy"; + String resource = name; + if (!name.endsWith(".groovy")) { + resource = "commands/" + name + ".groovy"; + } URL url = getClass().getClassLoader().getResource(resource); File file = null; if (url != null) { - try { - file = File.createTempFile(name, ".groovy"); - FileUtil.copy(url, file, null); - } catch (IOException e) { - throw new IllegalStateException("Could not create temp file for source: " - + name); + if (url.toString().startsWith("file:")) { + file = new File(url.toString().substring("file:".length())); + } else { + // probably in JAR file + try { + file = File.createTempFile(name, ".groovy"); + file.deleteOnExit(); + FileUtil.copy(url, file, null); + } catch (IOException e) { + throw new IllegalStateException( + "Could not create temp file for source: " + name); + } } } else { String home = System.getProperty("SPRING_HOME", System.getenv("SPRING_HOME")); diff --git a/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/ScriptCompilationCustomizer.java b/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/ScriptCompilationCustomizer.java index f6c27fa9f97..22c0ff2e413 100644 --- a/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/ScriptCompilationCustomizer.java +++ b/spring-bootstrap-cli/src/main/java/org/springframework/bootstrap/cli/command/ScriptCompilationCustomizer.java @@ -15,16 +15,40 @@ */ package org.springframework.bootstrap.cli.command; +import groovy.lang.Mixin; + +import java.util.ArrayList; +import java.util.List; + +import joptsimple.OptionParser; +import joptsimple.OptionSet; +import joptsimple.OptionSpecBuilder; + +import org.codehaus.groovy.ast.AnnotationNode; +import org.codehaus.groovy.ast.ClassHelper; import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.MethodNode; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.expr.ArgumentListExpression; +import org.codehaus.groovy.ast.expr.ClassExpression; +import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.stmt.BlockStatement; +import org.codehaus.groovy.ast.stmt.ExpressionStatement; +import org.codehaus.groovy.ast.stmt.Statement; import org.codehaus.groovy.classgen.GeneratorContext; import org.codehaus.groovy.control.CompilationFailedException; import org.codehaus.groovy.control.CompilePhase; import org.codehaus.groovy.control.SourceUnit; import org.codehaus.groovy.control.customizers.CompilationCustomizer; import org.codehaus.groovy.control.customizers.ImportCustomizer; +import org.objectweb.asm.Opcodes; import org.springframework.bootstrap.cli.Command; /** + * Customizer for the compilation of CLI commands. + * * @author Dave Syer * */ @@ -37,10 +61,22 @@ public class ScriptCompilationCustomizer extends CompilationCustomizer { @Override public void call(SourceUnit source, GeneratorContext context, ClassNode classNode) throws CompilationFailedException { - // AnnotationNode mixin = new AnnotationNode(ClassHelper.make(Mixin.class)); - // mixin.addMember("value", - // new ClassExpression(ClassHelper.make(OptionHandler.class))); - // classNode.addAnnotation(mixin); + addOptionHandlerMixin(classNode); + overrideOptionsMethod(source, classNode); + addImports(source, context, classNode); + } + + /** + * Add imports to the class node to make writing simple commands easier. No need to + * import {@link OptionParser}, {@link OptionSet}, {@link Command} or + * {@link OptionHandler}. + * + * @param source the source node + * @param context the current context + * @param classNode the class node to manipulate + */ + private void addImports(SourceUnit source, GeneratorContext context, + ClassNode classNode) { ImportCustomizer importCustomizer = new ImportCustomizer(); importCustomizer.addImports("joptsimple.OptionParser", "joptsimple.OptionSet", OptionParsingCommand.class.getCanonicalName(), @@ -48,4 +84,73 @@ public class ScriptCompilationCustomizer extends CompilationCustomizer { importCustomizer.call(source, context, classNode); } + /** + * If the script defines a block in this form: + * + *
+	 * options {
+	 *   option "foo", "My Foo option"
+	 *   option "bar", "Bar has a value" withOptionalArg() ofType Integer
+	 * }
+	 * 
+ * + * Then the block is taken and used to override the {@link OptionHandler#options()} + * method. In the example "option" is a call to + * {@link OptionHandler#option(String, String)}, and hence returns an + * {@link OptionSpecBuilder}. Makes a nice readable DSL for adding options. + * + * @param source the source node + * @param classNode the class node to manipulate + */ + private void overrideOptionsMethod(SourceUnit source, ClassNode classNode) { + + BlockStatement block = source.getAST().getStatementBlock(); + List statements = block.getStatements(); + + for (Statement statement : new ArrayList(statements)) { + if (statement instanceof ExpressionStatement) { + ExpressionStatement expr = (ExpressionStatement) statement; + Expression expression = expr.getExpression(); + if (expression instanceof MethodCallExpression) { + MethodCallExpression method = (MethodCallExpression) expression; + if (method.getMethod().getText().equals("options")) { + expression = method.getArguments(); + if (expression instanceof ArgumentListExpression) { + ArgumentListExpression arguments = (ArgumentListExpression) expression; + expression = arguments.getExpression(0); + + if (expression instanceof ClosureExpression) { + ClosureExpression closure = (ClosureExpression) expression; + classNode.addMethod(new MethodNode("options", + Opcodes.ACC_PROTECTED, ClassHelper.VOID_TYPE, + new Parameter[0], new ClassNode[0], closure + .getCode())); + statements.remove(statement); + } + + } + } + } + } + } + + } + + /** + * Add {@link OptionHandler} as a mixin to the class node if it doesn't already + * declare it as a super class. + * + * @param classNode the class node to manipulate + */ + private void addOptionHandlerMixin(ClassNode classNode) { + // If we are not an OptionHandler then add that class as a mixin + if (!classNode.isDerivedFrom(ClassHelper.make(OptionHandler.class)) + && !classNode.isDerivedFrom(ClassHelper.make("OptionHandler"))) { + AnnotationNode mixin = new AnnotationNode(ClassHelper.make(Mixin.class)); + mixin.addMember("value", + new ClassExpression(ClassHelper.make(OptionHandler.class))); + classNode.addAnnotation(mixin); + } + } + } diff --git a/spring-bootstrap-cli/src/test/java/org/springframework/bootstrap/cli/command/ScriptCommandTests.java b/spring-bootstrap-cli/src/test/java/org/springframework/bootstrap/cli/command/ScriptCommandTests.java index 11ce5ebd302..21c1c997909 100644 --- a/spring-bootstrap-cli/src/test/java/org/springframework/bootstrap/cli/command/ScriptCommandTests.java +++ b/spring-bootstrap-cli/src/test/java/org/springframework/bootstrap/cli/command/ScriptCommandTests.java @@ -15,11 +15,13 @@ */ package org.springframework.bootstrap.cli.command; +import groovy.lang.GroovyObjectSupport; import groovy.lang.Script; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertTrue; /** @@ -44,6 +46,30 @@ public class ScriptCommandTests { ((String[]) ((Script) command.getMain()).getProperty("args"))[0]); } + @Test + public void testLocateFile() throws Exception { + ScriptCommand command = new ScriptCommand( + "src/test/resources/commands/script.groovy"); + command.setPaths(new String[] { "." }); + command.run("World"); + assertEquals("World", + ((String[]) ((Script) command.getMain()).getProperty("args"))[0]); + } + + @Test + public void testRunnable() throws Exception { + ScriptCommand command = new ScriptCommand("runnable"); + command.run("World"); + assertTrue(executed); + } + + @Test + public void testClosure() throws Exception { + ScriptCommand command = new ScriptCommand("closure"); + command.run("World"); + assertTrue(executed); + } + @Test public void testCommand() throws Exception { ScriptCommand command = new ScriptCommand("command"); @@ -52,12 +78,42 @@ public class ScriptCommandTests { assertTrue(executed); } + @Test + public void testDuplicateClassName() throws Exception { + ScriptCommand command1 = new ScriptCommand("handler"); + ScriptCommand command2 = new ScriptCommand("command"); + assertNotSame(command1.getMain().getClass(), command2.getMain().getClass()); + assertEquals(command1.getMain().getClass().getName(), command2.getMain() + .getClass().getName()); + } + @Test public void testOptions() throws Exception { - ScriptCommand command = new ScriptCommand("test"); - command.run("World", "--foo"); + ScriptCommand command = new ScriptCommand("handler"); String out = ((OptionHandler) command.getMain()).getHelp(); assertTrue("Wrong output: " + out, out.contains("--foo")); + command.run("World", "--foo"); + assertTrue(executed); + } + + @Test + public void testMixin() throws Exception { + ScriptCommand command = new ScriptCommand("mixin"); + GroovyObjectSupport object = (GroovyObjectSupport) command.getMain(); + String out = (String) object.getProperty("help"); + assertTrue("Wrong output: " + out, out.contains("--foo")); + command.run("World", "--foo"); + assertTrue(executed); + } + + @Test + public void testMixinWithBlock() throws Exception { + ScriptCommand command = new ScriptCommand("test"); + GroovyObjectSupport object = (GroovyObjectSupport) command.getMain(); + String out = (String) object.getProperty("help"); + System.err.println(out); + assertTrue("Wrong output: " + out, out.contains("--foo")); + command.run("World", "--foo", "--bar=2"); assertTrue(executed); } diff --git a/spring-bootstrap-cli/src/test/resources/commands/closure.groovy b/spring-bootstrap-cli/src/test/resources/commands/closure.groovy new file mode 100644 index 00000000000..fab55c40eee --- /dev/null +++ b/spring-bootstrap-cli/src/test/resources/commands/closure.groovy @@ -0,0 +1,6 @@ +def run = { msg -> + org.springframework.bootstrap.cli.command.ScriptCommandTests.executed = true + println "Hello ${msg}" +} + +run \ No newline at end of file diff --git a/spring-bootstrap-cli/src/test/resources/commands/command.groovy b/spring-bootstrap-cli/src/test/resources/commands/command.groovy new file mode 100644 index 00000000000..0a5cac19c7d --- /dev/null +++ b/spring-bootstrap-cli/src/test/resources/commands/command.groovy @@ -0,0 +1,18 @@ +package org.test.command + +class TestCommand implements Command { + + String name = "foo" + + String description = "My script command" + + String help = "No options" + + String usageHelp = "Not very useful" + + void run(String... args) { + org.springframework.bootstrap.cli.command.ScriptCommandTests.executed = true + println "Hello ${args[0]}" + } + +} diff --git a/spring-bootstrap-cli/src/test/resources/commands/handler.groovy b/spring-bootstrap-cli/src/test/resources/commands/handler.groovy new file mode 100644 index 00000000000..4f9131ff5d7 --- /dev/null +++ b/spring-bootstrap-cli/src/test/resources/commands/handler.groovy @@ -0,0 +1,19 @@ +package org.test.command + +@Grab("org.eclipse.jgit:org.eclipse.jgit:2.3.1.201302201838-r") +import org.eclipse.jgit.api.Git + +class TestCommand extends OptionHandler { + + void options() { + option "foo", "Foo set" + } + + void run(OptionSet options) { + // Demonstrate use of Grape.grab to load dependencies before running + println "Clean : " + Git.open(".." as File).status().call().isClean() + org.springframework.bootstrap.cli.command.ScriptCommandTests.executed = true + println "Hello ${options.nonOptionArguments()}: ${options.has('foo')}" + } + +} diff --git a/spring-bootstrap-cli/src/test/resources/commands/mixin.groovy b/spring-bootstrap-cli/src/test/resources/commands/mixin.groovy new file mode 100644 index 00000000000..e5c80a41d54 --- /dev/null +++ b/spring-bootstrap-cli/src/test/resources/commands/mixin.groovy @@ -0,0 +1,6 @@ +void options() { + option "foo", "Foo set" +} + +org.springframework.bootstrap.cli.command.ScriptCommandTests.executed = true +println "Hello ${options.nonOptionArguments()}: ${options.has('foo')}" diff --git a/spring-bootstrap-cli/src/test/resources/commands/runnable.groovy b/spring-bootstrap-cli/src/test/resources/commands/runnable.groovy new file mode 100644 index 00000000000..02131f68525 --- /dev/null +++ b/spring-bootstrap-cli/src/test/resources/commands/runnable.groovy @@ -0,0 +1,11 @@ +class TestCommand implements Runnable { + def msg + TestCommand(String msg) { + this.msg = msg + } + void run() { + org.springframework.bootstrap.cli.command.ScriptCommandTests.executed = true + println "Hello ${msg}" + } +} +new TestCommand(args[0]) \ No newline at end of file diff --git a/spring-bootstrap-cli/src/test/resources/commands/test.groovy b/spring-bootstrap-cli/src/test/resources/commands/test.groovy index 4f9131ff5d7..e74e6c7a48a 100644 --- a/spring-bootstrap-cli/src/test/resources/commands/test.groovy +++ b/spring-bootstrap-cli/src/test/resources/commands/test.groovy @@ -1,19 +1,7 @@ -package org.test.command - -@Grab("org.eclipse.jgit:org.eclipse.jgit:2.3.1.201302201838-r") -import org.eclipse.jgit.api.Git - -class TestCommand extends OptionHandler { - - void options() { - option "foo", "Foo set" - } - - void run(OptionSet options) { - // Demonstrate use of Grape.grab to load dependencies before running - println "Clean : " + Git.open(".." as File).status().call().isClean() - org.springframework.bootstrap.cli.command.ScriptCommandTests.executed = true - println "Hello ${options.nonOptionArguments()}: ${options.has('foo')}" - } - +options { + option "foo", "Foo set" + option "bar", "Bar has an argument of type int" withOptionalArg() ofType Integer } + +org.springframework.bootstrap.cli.command.ScriptCommandTests.executed = true +println "Hello ${options.nonOptionArguments()}: ${options.has('foo')} ${options.valueOf('bar')}"