diff --git a/src/main/java/picocli/CommandLine.java b/src/main/java/picocli/CommandLine.java index ce240888b..14397248d 100644 --- a/src/main/java/picocli/CommandLine.java +++ b/src/main/java/picocli/CommandLine.java @@ -157,14 +157,25 @@ public class CommandLine { private List versionLines = new ArrayList(); /** - * Constructs a new {@code CommandLine} interpreter with the specified annotated object. + * Constructs a new {@code CommandLine} interpreter with the specified annotated object and a default subcommand factory. * When the {@link #parse(String...)} method is called, fields of the specified object that are annotated * with {@code @Option} or {@code @Parameters} will be initialized based on command line arguments. * @param command the object to initialize from the command line arguments * @throws InitializationException if the specified command object does not have a {@link Command}, {@link Option} or {@link Parameters} annotation */ public CommandLine(Object command) { - interpreter = new Interpreter(command); + this(command, new DefaultFactory()); + } + /** + * Constructs a new {@code CommandLine} interpreter with the specified annotated object and object factory. + * When the {@link #parse(String...)} method is called, fields of the specified object that are annotated + * with {@code @Option} or {@code @Parameters} will be initialized based on command line arguments. + * @param command the object to initialize from the command line arguments + * @param factory the factory used to create instances of {@linkplain Command#subcommands() subcommands} that are registered declaratively with annotation attributes + * @throws InitializationException if the specified command object does not have a {@link Command}, {@link Option} or {@link Parameters} annotation + */ + public CommandLine(Object command, IFactory factory) { + interpreter = new Interpreter(command, factory); } /** Registers a subcommand with the specified name. For example: @@ -209,7 +220,7 @@ public CommandLine(Object command) { * @see Command#subcommands() */ public CommandLine addSubcommand(String name, Object command) { - CommandLine subcommandLine = toCommandLine(command); + CommandLine subcommandLine = toCommandLine(command, interpreter.factory); subcommandLine.parent = this; interpreter.commands.put(name, subcommandLine); subcommandLine.interpreter.initParentCommand(this.interpreter.command); @@ -338,7 +349,7 @@ public List getUnmatchedArguments() { * @since 0.9.7 */ public static T populateCommand(T command, String... args) { - CommandLine cli = toCommandLine(command); + CommandLine cli = toCommandLine(command, new DefaultFactory()); cli.parse(args); return command; } @@ -675,7 +686,7 @@ public List parseWithHandlers(IParseResultHandler handler, PrintStream o * @throws IllegalArgumentException if the specified command object does not have a {@link Command}, {@link Option} or {@link Parameters} annotation */ public static void usage(Object command, PrintStream out) { - toCommandLine(command).usage(out); + toCommandLine(command, new DefaultFactory()).usage(out); } /** @@ -687,7 +698,7 @@ public static void usage(Object command, PrintStream out) { * @throws IllegalArgumentException if the specified command object does not have a {@link Command}, {@link Option} or {@link Parameters} annotation */ public static void usage(Object command, PrintStream out, Help.Ansi ansi) { - toCommandLine(command).usage(out, ansi); + toCommandLine(command, new DefaultFactory()).usage(out, ansi); } /** @@ -699,7 +710,7 @@ public static void usage(Object command, PrintStream out, Help.Ansi ansi) { * @throws IllegalArgumentException if the specified command object does not have a {@link Command}, {@link Option} or {@link Parameters} annotation */ public static void usage(Object command, PrintStream out, Help.ColorScheme colorScheme) { - toCommandLine(command).usage(out, colorScheme); + toCommandLine(command, new DefaultFactory()).usage(out, colorScheme); } /** @@ -1068,7 +1079,7 @@ public CommandLine setExpandAtFiles(boolean expandAtFiles) { private static boolean empty(Text txt) { return txt == null || txt.plain.toString().trim().length() == 0; } private static String str(String[] arr, int i) { return (arr == null || arr.length == 0) ? "" : arr[i]; } private static boolean isBoolean(Class type) { return type == Boolean.class || type == Boolean.TYPE; } - private static CommandLine toCommandLine(Object obj) { return obj instanceof CommandLine ? (CommandLine) obj : new CommandLine(obj);} + private static CommandLine toCommandLine(Object obj, IFactory factory) { return obj instanceof CommandLine ? (CommandLine) obj : new CommandLine(obj, factory);} private static boolean isMultiValue(Field field) { return isMultiValue(field.getType()); } private static boolean isMultiValue(Class cls) { return cls.isArray() || Collection.class.isAssignableFrom(cls) || Map.class.isAssignableFrom(cls); } private static Class[] getTypeAttribute(Field field) { @@ -1706,6 +1717,31 @@ public interface ITypeConverter { */ K convert(String value) throws Exception; } + + /** + * Subcommands registered declaratively on a parent command with the {@link Command#subcommands()} annotation + * are instantiated by a factory. + */ + public interface IFactory { + /** + * Creates and returns an instance of the specified class. + * @param cls the class to instantiate + * @return the new instance + * @throws Exception an exception detailing what went wrong when creating the instance + */ + Object create(Class cls) throws Exception; + } + private static class DefaultFactory implements IFactory { + public Object create(Class cls) throws Exception { + try { + return cls.newInstance(); + } catch (Exception ex) { + Constructor constructor = cls.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + } + } /** Describes the number of parameters required and accepted by an option or a positional parameter. * @since 0.9.7 */ @@ -1941,11 +1977,63 @@ private class Interpreter { private final List requiredFields = new ArrayList(); private final List positionalParametersFields = new ArrayList(); private final Object command; + private final IFactory factory; private boolean isHelpRequested; private String separator = Help.DEFAULT_SEPARATOR; private int position; - Interpreter(Object command) { + Interpreter(Object command, IFactory factory) { + this.command = Assert.notNull(command, "command"); + this.factory = Assert.notNull(factory, "factory"); + Class cls = command.getClass(); + String declaredName = null; + String declaredSeparator = null; + boolean hasCommandAnnotation = false; + while (cls != null) { + init(cls, requiredFields, optionName2Field, singleCharOption2Field, positionalParametersFields); + if (cls.isAnnotationPresent(Command.class)) { + hasCommandAnnotation = true; + Command cmd = cls.getAnnotation(Command.class); + declaredSeparator = (declaredSeparator == null) ? cmd.separator() : declaredSeparator; + declaredName = (declaredName == null) ? cmd.name() : declaredName; + CommandLine.this.versionLines.addAll(Arrays.asList(cmd.version())); + + for (Class sub : cmd.subcommands()) { + Command subCommand = sub.getAnnotation(Command.class); + if (subCommand == null || Help.DEFAULT_COMMAND_NAME.equals(subCommand.name())) { + throw new InitializationException("Subcommand " + sub.getName() + + " is missing the mandatory @Command annotation with a 'name' attribute"); + } + try { + CommandLine commandLine = toCommandLine(factory.create(sub), factory); + commandLine.parent = CommandLine.this; + commands.put(subCommand.name(), commandLine); + commandLine.interpreter.initParentCommand(command); + } + catch (InitializationException ex) { throw ex; } + catch (NoSuchMethodException ex) { throw new InitializationException("Cannot instantiate subcommand " + + sub.getName() + ": the class has no constructor", ex); } + catch (Exception ex) { + throw new InitializationException("Could not instantiate and add subcommand " + + sub.getName() + ": " + ex, ex); + } + } + } + cls = cls.getSuperclass(); + } + separator = declaredSeparator != null ? declaredSeparator : separator; + CommandLine.this.commandName = declaredName != null ? declaredName : CommandLine.this.commandName; + Collections.sort(positionalParametersFields, new PositionalParametersSorter()); + validatePositionalParameters(positionalParametersFields); + + if (positionalParametersFields.isEmpty() && optionName2Field.isEmpty() && !hasCommandAnnotation) { + throw new InitializationException(command + " (" + command.getClass() + + ") is not a command: it has no @Command, @Option or @Parameters annotations"); + } + registerBuiltInConverters(); + } + + private void registerBuiltInConverters() { converterRegistry.put(Object.class, new BuiltIn.StringConverter()); converterRegistry.put(String.class, new BuiltIn.StringConverter()); converterRegistry.put(StringBuilder.class, new BuiltIn.StringBuilderConverter()); @@ -2002,55 +2090,6 @@ private class Interpreter { BuiltIn.registerIfAvailable(converterRegistry, tracer, "java.time.ZoneOffset", "of", String.class); BuiltIn.registerIfAvailable(converterRegistry, tracer, "java.nio.file.Path", "java.nio.file.Paths", "get", String.class, String[].class); - - this.command = Assert.notNull(command, "command"); - Class cls = command.getClass(); - String declaredName = null; - String declaredSeparator = null; - boolean hasCommandAnnotation = false; - while (cls != null) { - init(cls, requiredFields, optionName2Field, singleCharOption2Field, positionalParametersFields); - if (cls.isAnnotationPresent(Command.class)) { - hasCommandAnnotation = true; - Command cmd = cls.getAnnotation(Command.class); - declaredSeparator = (declaredSeparator == null) ? cmd.separator() : declaredSeparator; - declaredName = (declaredName == null) ? cmd.name() : declaredName; - CommandLine.this.versionLines.addAll(Arrays.asList(cmd.version())); - - for (Class sub : cmd.subcommands()) { - Command subCommand = sub.getAnnotation(Command.class); - if (subCommand == null || Help.DEFAULT_COMMAND_NAME.equals(subCommand.name())) { - throw new InitializationException("Subcommand " + sub.getName() + - " is missing the mandatory @Command annotation with a 'name' attribute"); - } - try { - Constructor constructor = sub.getDeclaredConstructor(); - constructor.setAccessible(true); - CommandLine commandLine = toCommandLine(constructor.newInstance()); - commandLine.parent = CommandLine.this; - commands.put(subCommand.name(), commandLine); - commandLine.interpreter.initParentCommand(command); - } - catch (InitializationException ex) { throw ex; } - catch (NoSuchMethodException ex) { throw new InitializationException("Cannot instantiate subcommand " + - sub.getName() + ": the class has no constructor", ex); } - catch (Exception ex) { - throw new InitializationException("Could not instantiate and add subcommand " + - sub.getName() + ": " + ex, ex); - } - } - } - cls = cls.getSuperclass(); - } - separator = declaredSeparator != null ? declaredSeparator : separator; - CommandLine.this.commandName = declaredName != null ? declaredName : CommandLine.this.commandName; - Collections.sort(positionalParametersFields, new PositionalParametersSorter()); - validatePositionalParameters(positionalParametersFields); - - if (positionalParametersFields.isEmpty() && optionName2Field.isEmpty() && !hasCommandAnnotation) { - throw new InitializationException(command + " (" + command.getClass() + - ") is not a command: it has no @Command, @Option or @Parameters annotations"); - } } private void initParentCommand(Object parent) { @@ -2970,9 +3009,13 @@ static void registerIfAvailable(Map, ITypeConverter> registry, Trace Method method = factory.getDeclaredMethod(factoryMethodName, paramTypes); registry.put(cls, new ReflectionConverter(method, paramTypes)); } catch (Exception e) { - tracer.info("Could not register converter for %s: %s%n", fqcn, e.toString()); + if (!traced.contains(fqcn)) { + tracer.debug("Could not register converter for %s: %s%n", fqcn, e.toString()); + } + traced.add(fqcn); } } + static Set traced = new HashSet(); static class ReflectionConverter implements ITypeConverter { private final Method method; private Class[] paramTypes; diff --git a/src/test/java/picocli/CommandLineTest.java b/src/test/java/picocli/CommandLineTest.java index d0680e95e..19def8abf 100644 --- a/src/test/java/picocli/CommandLineTest.java +++ b/src/test/java/picocli/CommandLineTest.java @@ -24,6 +24,7 @@ import java.io.OutputStream; import java.io.PrintStream; import java.io.UnsupportedEncodingException; +import java.lang.reflect.Field; import java.math.BigDecimal; import java.net.InetAddress; import java.net.Socket; @@ -97,6 +98,52 @@ public class CommandLineTest { private static void setTraceLevel(String level) { System.setProperty("picocli.trace", level); } + + @Test(expected = NullPointerException.class) + public void testConstructorRejectsNullObject() { + new CommandLine(null); + } + @Test(expected = NullPointerException.class) + public void testConstructorRejectsNullFactory() { + new CommandLine(new CompactFields(), null); + } + @Test + public void testFactory() { + final Sub1_testDeclarativelyAddSubcommands sub1Command = new Sub1_testDeclarativelyAddSubcommands(); + final SubSub1_testDeclarativelyAddSubcommands subsub1Command = new SubSub1_testDeclarativelyAddSubcommands(); + IFactory factory = new IFactory() { + public Object create(Class cls) throws Exception { + if (cls == Sub1_testDeclarativelyAddSubcommands.class) { + return sub1Command; + } + if (cls == SubSub1_testDeclarativelyAddSubcommands.class) { + return subsub1Command; + } + throw new IllegalStateException(); + } + }; + CommandLine commandLine = new CommandLine(new MainCommand_testDeclarativelyAddSubcommands(), factory); + CommandLine sub1 = commandLine.getSubcommands().get("sub1"); + assertSame(sub1Command, sub1.getCommand()); + + CommandLine subsub1 = sub1.getSubcommands().get("subsub1"); + assertSame(subsub1Command, subsub1.getCommand()); + } + @Test + public void testFailingFactory() { + IFactory factory = new IFactory() { + public Object create(Class cls) throws Exception { + throw new IllegalStateException("bad class"); + } + }; + try { + new CommandLine(new MainCommand_testDeclarativelyAddSubcommands(), factory); + } catch (InitializationException ex) { + assertEquals("Could not instantiate and add subcommand " + + "picocli.CommandLineTest$Sub1_testDeclarativelyAddSubcommands: " + + "java.lang.IllegalStateException: bad class", ex.getMessage()); + } + } @Test public void testVersion() { assertEquals("2.2.0-SNAPSHOT", CommandLine.VERSION); @@ -566,8 +613,15 @@ public void testDoubleDashSeparatesPositionalParameters() { verifyCompact(compact, false, false, "out", fileArray("-r", "-v", "p1", "p2")); } + private static void clearBuiltInTracingCache() throws Exception { + Field field = Class.forName("picocli.CommandLine$BuiltIn").getDeclaredField("traced"); + field.setAccessible(true); + Collection collection = (Collection) field.get(null); + collection.clear(); + } @Test - public void testDebugOutputForDoubleDashSeparatesPositionalParameters() throws UnsupportedEncodingException { + public void testDebugOutputForDoubleDashSeparatesPositionalParameters() throws Exception { + clearBuiltInTracingCache(); PrintStream originalErr = System.err; ByteArrayOutputStream baos = new ByteArrayOutputStream(2500); System.setErr(new PrintStream(baos)); @@ -581,6 +635,23 @@ public void testDebugOutputForDoubleDashSeparatesPositionalParameters() throws U } else { System.setProperty(PROPERTY, old); } + String prefix8 = String.format("" + + "[picocli DEBUG] Could not register converter for java.time.Duration: java.lang.ClassNotFoundException: java.time.Duration%n" + + "[picocli DEBUG] Could not register converter for java.time.Instant: java.lang.ClassNotFoundException: java.time.Instant%n" + + "[picocli DEBUG] Could not register converter for java.time.LocalDate: java.lang.ClassNotFoundException: java.time.LocalDate%n" + + "[picocli DEBUG] Could not register converter for java.time.LocalDateTime: java.lang.ClassNotFoundException: java.time.LocalDateTime%n" + + "[picocli DEBUG] Could not register converter for java.time.LocalTime: java.lang.ClassNotFoundException: java.time.LocalTime%n" + + "[picocli DEBUG] Could not register converter for java.time.MonthDay: java.lang.ClassNotFoundException: java.time.MonthDay%n" + + "[picocli DEBUG] Could not register converter for java.time.OffsetDateTime: java.lang.ClassNotFoundException: java.time.OffsetDateTime%n" + + "[picocli DEBUG] Could not register converter for java.time.OffsetTime: java.lang.ClassNotFoundException: java.time.OffsetTime%n" + + "[picocli DEBUG] Could not register converter for java.time.Period: java.lang.ClassNotFoundException: java.time.Period%n" + + "[picocli DEBUG] Could not register converter for java.time.Year: java.lang.ClassNotFoundException: java.time.Year%n" + + "[picocli DEBUG] Could not register converter for java.time.YearMonth: java.lang.ClassNotFoundException: java.time.YearMonth%n" + + "[picocli DEBUG] Could not register converter for java.time.ZonedDateTime: java.lang.ClassNotFoundException: java.time.ZonedDateTime%n" + + "[picocli DEBUG] Could not register converter for java.time.ZoneId: java.lang.ClassNotFoundException: java.time.ZoneId%n" + + "[picocli DEBUG] Could not register converter for java.time.ZoneOffset: java.lang.ClassNotFoundException: java.time.ZoneOffset%n"); + String prefix7 = String.format("" + + "[picocli DEBUG] Could not register converter for java.nio.file.Path: java.lang.ClassNotFoundException: java.nio.file.Path%n"); String expected = String.format("" + "[picocli INFO] Parsing 6 command line args [-oout, --, -r, -v, p1, p2]%n" + "[picocli DEBUG] Initializing %1$s$CompactFields: 3 options, 1 positional parameters, 0 required, 0 subcommands.%n" + @@ -611,6 +682,12 @@ public void testDebugOutputForDoubleDashSeparatesPositionalParameters() throws U CommandLineTest.class.getName(), new File("/home/rpopma/picocli")); String actual = new String(baos.toByteArray(), "UTF8"); //System.out.println(actual); + if (System.getProperty("java.version").compareTo("1.7.0") < 0) { + expected = prefix7 + expected; + } + if (System.getProperty("java.version").compareTo("1.8.0") < 0) { + expected = prefix8 + expected; + } assertEquals(expected, actual); } @@ -1468,6 +1545,7 @@ public void testTracingInfoWithSubCommands() throws Exception { } @Test public void testTracingDebugWithSubCommands() throws Exception { + clearBuiltInTracingCache(); PrintStream originalErr = System.err; ByteArrayOutputStream baos = new ByteArrayOutputStream(2500); System.setErr(new PrintStream(baos)); @@ -1482,6 +1560,23 @@ public void testTracingDebugWithSubCommands() throws Exception { } else { System.setProperty(PROPERTY, old); } + String prefix8 = String.format("" + + "[picocli DEBUG] Could not register converter for java.time.Duration: java.lang.ClassNotFoundException: java.time.Duration%n" + + "[picocli DEBUG] Could not register converter for java.time.Instant: java.lang.ClassNotFoundException: java.time.Instant%n" + + "[picocli DEBUG] Could not register converter for java.time.LocalDate: java.lang.ClassNotFoundException: java.time.LocalDate%n" + + "[picocli DEBUG] Could not register converter for java.time.LocalDateTime: java.lang.ClassNotFoundException: java.time.LocalDateTime%n" + + "[picocli DEBUG] Could not register converter for java.time.LocalTime: java.lang.ClassNotFoundException: java.time.LocalTime%n" + + "[picocli DEBUG] Could not register converter for java.time.MonthDay: java.lang.ClassNotFoundException: java.time.MonthDay%n" + + "[picocli DEBUG] Could not register converter for java.time.OffsetDateTime: java.lang.ClassNotFoundException: java.time.OffsetDateTime%n" + + "[picocli DEBUG] Could not register converter for java.time.OffsetTime: java.lang.ClassNotFoundException: java.time.OffsetTime%n" + + "[picocli DEBUG] Could not register converter for java.time.Period: java.lang.ClassNotFoundException: java.time.Period%n" + + "[picocli DEBUG] Could not register converter for java.time.Year: java.lang.ClassNotFoundException: java.time.Year%n" + + "[picocli DEBUG] Could not register converter for java.time.YearMonth: java.lang.ClassNotFoundException: java.time.YearMonth%n" + + "[picocli DEBUG] Could not register converter for java.time.ZonedDateTime: java.lang.ClassNotFoundException: java.time.ZonedDateTime%n" + + "[picocli DEBUG] Could not register converter for java.time.ZoneId: java.lang.ClassNotFoundException: java.time.ZoneId%n" + + "[picocli DEBUG] Could not register converter for java.time.ZoneOffset: java.lang.ClassNotFoundException: java.time.ZoneOffset%n"); + String prefix7 = String.format("" + + "[picocli DEBUG] Could not register converter for java.nio.file.Path: java.lang.ClassNotFoundException: java.nio.file.Path%n"); String expected = String.format("" + "[picocli INFO] Parsing 8 command line args [--git-dir=/home/rpopma/picocli, commit, -m, \"Fixed typos\", --, src1.java, src2.java, src3.java]%n" + "[picocli DEBUG] Initializing %1$s$Git: 3 options, 0 positional parameters, 0 required, 11 subcommands.%n" + @@ -1513,6 +1608,12 @@ public void testTracingDebugWithSubCommands() throws Exception { Demo.class.getName(), new File("/home/rpopma/picocli")); String actual = new String(baos.toByteArray(), "UTF8"); //System.out.println(actual); + if (System.getProperty("java.version").compareTo("1.7.0") < 0) { + expected = prefix7 + expected; + } + if (System.getProperty("java.version").compareTo("1.8.0") < 0) { + expected = prefix8 + expected; + } assertEquals(expected, actual); } @Test