From b844c63736fb92a3d357458b9c8a4bb8b020ad79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Jeschke?= Date: Wed, 18 Mar 2015 21:06:35 +0100 Subject: [PATCH] Adding a more versatile command line interface. --- .../rjeschke/txtmark/cmd/CmdArgument.java | 76 ++ .../rjeschke/txtmark/cmd/CmdLineParser.java | 691 ++++++++++++++++++ .../com/github/rjeschke/txtmark/cmd/Run.java | 142 ++++ .../txtmark/cmd/TxtmarkArguments.java | 37 + 4 files changed, 946 insertions(+) create mode 100644 src/main/java/com/github/rjeschke/txtmark/cmd/CmdArgument.java create mode 100644 src/main/java/com/github/rjeschke/txtmark/cmd/CmdLineParser.java create mode 100644 src/main/java/com/github/rjeschke/txtmark/cmd/Run.java create mode 100644 src/main/java/com/github/rjeschke/txtmark/cmd/TxtmarkArguments.java diff --git a/src/main/java/com/github/rjeschke/txtmark/cmd/CmdArgument.java b/src/main/java/com/github/rjeschke/txtmark/cmd/CmdArgument.java new file mode 100644 index 0000000..8e51441 --- /dev/null +++ b/src/main/java/com/github/rjeschke/txtmark/cmd/CmdArgument.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2013-2015 René Jeschke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.rjeschke.txtmark.cmd; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for command line parsing. + * + * @author René Jeschke (rene_jeschke@yahoo.de) + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@interface CmdArgument +{ + /** + * Long name for argument. Default is 'none'. Either one or both of {@code l}, {@code s} need to be provided. + */ + String l() default ""; + + /** + * Short name (character) for argument. Default is 'none'. Either one or both of {@code l}, {@code s} need to be provided. + */ + char s() default '\0'; + + /** + * A description for this argument. Default is 'none'. + */ + String desc() default ""; + + /** + * List item separator. Default is ','. + */ + char listSep() default ','; + + /** + * Class for List-type arguments. Default is {@code String.class}. + */ + Class listType() default String.class; + + /** + * Set to {@code true} if this is a switch. Requires a {@code boolean} field which gets set to {@code true} when this argument is provided. + */ + boolean isSwitch() default false; + + /** + * Set to {@code true} if this is a required argument. + */ + boolean required() default false; + + /** + * Set to {@code true} to set this as a catch-all argument. Requires a {@code List} field and will parse all arguments following this switch into the list. + */ + boolean catchAll() default false; + + /** + * Set to {@code false} to disable automatic default value printing for this argument. + */ + boolean printDefault() default true; +} diff --git a/src/main/java/com/github/rjeschke/txtmark/cmd/CmdLineParser.java b/src/main/java/com/github/rjeschke/txtmark/cmd/CmdLineParser.java new file mode 100644 index 0000000..f51b480 --- /dev/null +++ b/src/main/java/com/github/rjeschke/txtmark/cmd/CmdLineParser.java @@ -0,0 +1,691 @@ +/* + * Copyright (C) 2013-2015 René Jeschke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.rjeschke.txtmark.cmd; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; + +/** + * Generic command line parser. + * + * This is a copy from neetutils-base. + * + * @author René Jeschke (rene_jeschke@yahoo.de) + */ +final class CmdLineParser +{ + private CmdLineParser() + { + // meh! + } + + enum Type + { + UNSUPPORTED, STRING, BYTE, SHORT, INT, LONG, FLOAT, DOUBLE, LIST, BOOL; + } + + final static HashMap, Type> TYPE_MAP = new HashMap, Type>(); + final static Class[] TYPE_CLASS_LIST = Colls.> objArray(String.class, byte.class, + Byte.class, short.class, Short.class, int.class, + Integer.class, long.class, Long.class, float.class, + Float.class, double.class, Double.class, List.class, + Boolean.class, boolean.class); + final static Type[] TYPE_TYPE_LIST = Colls.objArray(Type.STRING, Type.BYTE, Type.BYTE, + Type.SHORT, + Type.SHORT, Type.INT, Type.INT, Type.LONG, Type.LONG, + Type.FLOAT, Type.FLOAT, Type.DOUBLE, Type.DOUBLE, + Type.LIST, Type.BOOL, Type.BOOL); + + final static HashSet BOOL_TRUE = new HashSet(Colls.list("on", "true", "yes")); + final static HashSet BOOL_FALSE = new HashSet(Colls.list("off", "false", "no")); + + static + { + for (int i = 0; i < TYPE_CLASS_LIST.length; i++) + { + TYPE_MAP.put(TYPE_CLASS_LIST[i], TYPE_TYPE_LIST[i]); + } + } + + static Type getTypeFor(final Class clazz) + { + final Type type = TYPE_MAP.get(clazz); + + if (type != null) + { + return type; + } + + if (Classes.implementsInterface(clazz, List.class)) + { + return Type.LIST; + } + + return Type.UNSUPPORTED; + } + + static String defaultToString(final Object value, final Type type, final Arg arg) + { + if (value == null || arg.isSwitch || arg.catchAll || !arg.printDefault) + { + return null; + } + + if (type == Type.LIST) + { + final List list = (List)value; + + if (list.isEmpty()) + { + return null; + } + + final StringBuilder sb = new StringBuilder(); + final Once once = Once.of("", Character.toString(arg.itemSep)); + for (final Object o : list) + { + sb.append(once.get()); + sb.append(o.toString()); + } + return sb.toString(); + } + + return value.toString(); + } + + private static void parseArgs(final Object[] objs, final List allArgs, final HashMap shortArgs, + final HashMap longArgs) + throws IOException + { + for (final Object obj : objs) + { + final Class cl = obj.getClass(); + final Field[] fields = cl.getDeclaredFields(); + + for (final Field f : fields) + { + if (f.isAnnotationPresent(CmdArgument.class)) + { + final Arg arg = new Arg(f.getAnnotation(CmdArgument.class), obj, f); + + if (arg.type == Type.UNSUPPORTED) + { + throw new IOException("Unsupported parameter type: " + f.getType().getCanonicalName() + + " for: " + arg); + } + + if (arg.listType == Type.UNSUPPORTED || arg.listType == Type.LIST) + { + throw new IOException("Unsupported list type: " + f.getType().getCanonicalName() + " for: " + + arg); + } + + if (Strings.isEmpty(arg.s) && Strings.isEmpty(arg.l)) + { + throw new IOException("Missing parameter name"); + } + + if (!Strings.isEmpty(arg.s)) + { + if (shortArgs.containsKey(arg.s)) + { + throw new IOException("Duplicate short argument: -" + arg.s); + } + shortArgs.put(arg.s, arg); + } + + if (!Strings.isEmpty(arg.l)) + { + if (longArgs.containsKey(arg.l)) + { + throw new IOException("Duplicate long argument: --" + arg.l); + } + longArgs.put(arg.l, arg); + } + + if (arg.isCatchAll() && arg.type != Type.LIST) + { + throw new IOException("Parameter '" + arg + "' requires a List field."); + } + + if (arg.isSwitch && arg.type != Type.BOOL) + { + throw new IOException("Parameter '" + arg + "' requires a Boolean/boolean field."); + } + + allArgs.add(arg); + } + } + } + } + + /** + * Generates a formatted help (Unix-style) for the given argument objects. + * + * @param columnWidth + * Maximum column width. Words get wrapped at spaces. + * @param sort + * Set {@code true} to sort arguments before printing. + * @param objs + * One or more objects with annotated public fields. + * @return The formatted argument help text. + * @throws IOException + * if a parsing error occurred. + * @see CmdArgument + */ + public static String generateHelp(final int columnWidth, final boolean sort, final Object... objs) + throws IOException + { + final List allArgs = Colls.list(); + final HashMap shortArgs = new HashMap(); + final HashMap longArgs = new HashMap(); + + parseArgs(objs, allArgs, shortArgs, longArgs); + + int minArgLen = 0; + + for (final Arg a : allArgs) + { + int len = a.toString().length(); + if (!a.isSwitch) + { + ++len; + len += a.getResolvedType().toString().length(); + if (a.isCatchAll()) + { + ++len; + } + else if (a.isList()) + { + len += 6; + } + } + minArgLen = Math.max(minArgLen, len); + } + minArgLen += 2; + if (sort) + { + Collections.sort(allArgs); + } + + final StringBuilder sb = new StringBuilder(); + + for (final Arg a : allArgs) + { + final StringBuilder line = new StringBuilder(); + line.append(' '); + line.append(a); + if (!a.isSwitch) + { + line.append(' '); + line.append(a.getResolvedType().toString().toLowerCase()); + if (a.isCatchAll()) + { + line.append('s'); + } + else if (a.isList()) + { + line.append('['); + line.append(a.itemSep); + line.append("...]"); + } + } + while (line.length() < minArgLen) + { + line.append(' '); + } + + line.append(':'); + + final StringBuilder desc = new StringBuilder(a.desc.trim()); + + final String defVal = defaultToString(a.safeFieldGet(), a.type, a); + + if (defVal != null) + { + desc.append(" Default is: '"); + desc.append(defVal); + desc.append("'."); + } + + final List toks = Strings.split(desc.toString(), ' '); + + for (final String s : toks) + { + if (line.length() + s.length() + 1 > columnWidth) + { + sb.append(line); + sb.append('\n'); + line.setLength(0); + while (line.length() <= minArgLen) + { + line.append(' '); + } + line.append(' '); + } + line.append(' '); + line.append(s); + } + + if (line.length() > minArgLen) + { + sb.append(line); + sb.append('\n'); + } + } + + return sb.toString(); + } + + /** + * Parses command line arguments. + * + * @param args + * Array of arguments, like the ones provided by + * {@code void main(String[] args)} + * @param objs + * One or more objects with annotated public fields. + * @return A {@code List} containing all unparsed arguments (i.e. arguments + * that are no switches) + * @throws IOException + * if a parsing error occurred. + * @see CmdArgument + */ + public static List parse(final String[] args, final Object... objs) throws IOException + { + final List ret = Colls.list(); + + final List allArgs = Colls.list(); + final HashMap shortArgs = new HashMap(); + final HashMap longArgs = new HashMap(); + + parseArgs(objs, allArgs, shortArgs, longArgs); + + for (int i = 0; i < args.length; i++) + { + final String s = args[i]; + + final Arg a; + + if (s.startsWith("--")) + { + a = longArgs.get(s.substring(2)); + if (a == null) + { + throw new IOException("Unknown switch: " + s); + } + } + else if (s.startsWith("-")) + { + a = shortArgs.get(s.substring(1)); + if (a == null) + { + throw new IOException("Unknown switch: " + s); + } + } + else + { + a = null; + ret.add(s); + } + + if (a != null) + { + if (a.isSwitch) + { + a.setField("true"); + } + else + { + if (i + 1 >= args.length) + { + System.out.println("Missing parameter for: " + s); + } + if (a.isCatchAll()) + { + final List ca = Colls.list(); + for (++i; i < args.length; ++i) + { + ca.add(args[i]); + } + a.setCatchAll(ca); + } + else + { + ++i; + a.setField(args[i]); + } + } + a.setPresent(); + } + } + + for (final Arg a : allArgs) + { + if (!a.isOk()) + { + throw new IOException("Missing mandatory argument: " + a); + } + } + + return ret; + } + + private static class Arg implements Comparable + { + final String s; + final String l; + final String id; + final String desc; + final char itemSep; + final boolean isSwitch; + final boolean required; + final boolean catchAll; + final boolean printDefault; + final Type type; + final Type listType; + boolean present = false; + final Object object; + final Field field; + + public Arg(final CmdArgument arg, final Object obj, final Field field) + { + this.s = arg.s() == 0 ? "" : Character.toString(arg.s()); + this.l = arg.l(); + this.desc = arg.desc(); + this.isSwitch = arg.isSwitch(); + this.required = arg.required(); + this.catchAll = arg.catchAll(); + this.itemSep = arg.listSep(); + this.printDefault = arg.printDefault(); + this.id = this.s + "/" + this.l; + + this.object = obj; + this.field = field; + this.type = getTypeFor(this.field.getType()); + this.listType = getTypeFor(arg.listType()); + } + + public Type getResolvedType() + { + return this.isList() ? this.listType : this.type; + } + + public boolean isCatchAll() + { + return this.catchAll; + } + + public boolean isList() + { + return this.type == Type.LIST; + } + + public void setCatchAll(final List list) throws IOException + { + this.setListField(list); + } + + public void setListField(final List list) throws IOException + { + try + { + if (this.listType == Type.STRING) + { + this.field.set(this.object, list); + } + else + { + final List temp = Colls.list(); + for (final String i : list) + { + temp.add(this.toObject(i, this.listType)); + } + this.field.set(this.object, temp); + } + } + catch (final IllegalArgumentException e) + { + throw new IOException("Failed to write value", e); + } + catch (final IllegalAccessException e) + { + throw new IOException("Failed to write value", e); + } + } + + Object safeFieldGet() + { + try + { + return this.field.get(this.object); + } + catch (final Exception e) + { + return null; + } + } + + private Object toObject(final String value, final Type type) throws IOException + { + try + { + switch (type) + { + case STRING: + return value; + case BYTE: + return Byte.parseByte(value); + case SHORT: + return Short.parseShort(value); + case INT: + return Integer.parseInt(value); + case LONG: + return Long.parseLong(value); + case FLOAT: + return Float.parseFloat(value); + case DOUBLE: + return Double.parseDouble(value); + case BOOL: + if (BOOL_TRUE.contains(value.toLowerCase())) + { + return true; + } + if (BOOL_FALSE.contains(value.toLowerCase())) + { + return false; + } + throw new IOException("Illegal bool value for:" + this.toString()); + default: + throw new IOException("Illegal type: " + type.toString().toLowerCase()); + } + } + catch (final Throwable t) + { + throw new IOException("Parsing error for: " + this.toString() + "; '" + value + "'", t); + } + } + + public void setField(final String value) throws IOException + { + try + { + if (this.isList()) + { + this.setListField(Strings.split(value, this.itemSep)); + } + else + { + this.field.set(this.object, this.toObject(value, this.type)); + } + } + catch (final IllegalArgumentException e) + { + throw new IOException("Failed to write field: " + this.field.getName(), e); + } + catch (final IllegalAccessException e) + { + throw new IOException("Failed to write field: " + this.field.getName(), e); + } + } + + public void setPresent() + { + this.present = true; + } + + public boolean isOk() + { + return !this.required || this.present; + } + + @Override + public int hashCode() + { + return this.id.hashCode(); + } + + @Override + public boolean equals(final Object obj) + { + if (obj instanceof Arg) + { + return this.id.equals(((Arg)obj).id); + } + return false; + } + + @Override + public String toString() + { + if (Strings.isEmpty(this.s)) + { + return " --" + this.l; + } + if (Strings.isEmpty(this.l)) + { + return "-" + this.s; + } + return "-" + this.s + ", --" + this.l; + } + + @Override + public int compareTo(final Arg o) + { + final String a = Strings.isEmpty(this.s) ? this.l : this.s; + final String b = Strings.isEmpty(o.s) ? o.l : o.s; + return a.compareTo(b); + } + } + + private static class Once + { + private final T first; + private final T next; + private boolean isFirst = true; + + public Once(final T first, final T next) + { + this.first = first; + this.next = next; + } + + public static Once of(final T first, final T next) + { + return new Once(first, next); + } + + public T get() + { + if (this.isFirst) + { + this.isFirst = false; + return this.first; + } + + return this.next; + } + } + + private final static class Colls + { + final static T[] objArray(final T... ts) + { + return ts; + } + + final static List list(final A... coll) + { + final List ret = new ArrayList(coll.length); + for (int i = 0; i < coll.length; i++) + { + ret.add(coll[i]); + } + return ret; + } + } + + private final static class Classes + { + final static boolean implementsInterface(final Class clazz, final Class interfce) + { + for (final Class c : clazz.getInterfaces()) + { + if (c.equals(interfce)) + { + return true; + } + } + + return false; + } + } + + private final static class Strings + { + public final static boolean isEmpty(final String str) + { + return str == null || str.isEmpty(); + } + + public final static List split(final String str, final char ch) + { + final List ret = Colls.list(); + + if (str != null) + { + int s = 0, e = 0; + while (e < str.length()) + { + if (str.charAt(e) == ch) + { + ret.add(str.substring(s, e)); + s = e + 1; + } + e++; + } + ret.add(str.substring(s, e)); + } + + return ret; + } + } +} diff --git a/src/main/java/com/github/rjeschke/txtmark/cmd/Run.java b/src/main/java/com/github/rjeschke/txtmark/cmd/Run.java new file mode 100644 index 0000000..9ec4612 --- /dev/null +++ b/src/main/java/com/github/rjeschke/txtmark/cmd/Run.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2013-2015 René Jeschke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.rjeschke.txtmark.cmd; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; + +import com.github.rjeschke.txtmark.Configuration; +import com.github.rjeschke.txtmark.Processor; + +public class Run +{ + public static void printUsage() + { + try + { + System.out.println("Usage: txtmark [options] [input-file]"); + System.out.println("Options:"); + System.out.println(CmdLineParser.generateHelp(80, false, new TxtmarkArguments())); + } + catch (final IOException e) + { + // + } + } + + public static void main(final String[] args) + { + final TxtmarkArguments ta = new TxtmarkArguments(); + List rest = new ArrayList(); + + boolean parseError = false; + try + { + rest = CmdLineParser.parse(args, ta); + } + catch (final IOException e) + { + parseError = true; + } + + if (ta.printHelp || parseError) + { + printUsage(); + System.exit(parseError ? 1 : 0); + } + + final Configuration.Builder cfgBuilder = Configuration.builder(); + cfgBuilder.setEncoding(ta.encoding) + .setEnablePanicMode(ta.panicMode) + .setSafeMode(ta.safeMode); + + if (ta.forceExtendedProfile) + { + cfgBuilder.forceExtentedProfile(); + } + + final Configuration config = cfgBuilder.build(); + boolean processOk = true; + InputStream input = null; + Writer output = null; + try + { + final String inFile = rest.isEmpty() ? "--" : rest.get(0); + final String outFile = ta.outFile; + + if (inFile.equals("--")) + { + input = System.in; + } + else + { + input = new FileInputStream(inFile); + } + + final String result = Processor.process(input, config); + + if (outFile == null) + { + output = new OutputStreamWriter(System.out, ta.encoding); + } + else + { + output = new OutputStreamWriter(new FileOutputStream(outFile), ta.encoding); + } + + output.write(result); + } + catch (final IOException e) + { + processOk = false; + System.err.println("Exception: " + e.toString()); + e.printStackTrace(System.err); + } + finally + { + if (input != null) + { + try + { + input.close(); + } + catch (final IOException e) + { + // ignore + } + } + if (output != null) + { + try + { + output.close(); + } + catch (final IOException e) + { + // ignore + } + } + } + + System.exit(processOk ? 0 : 1); + } +} diff --git a/src/main/java/com/github/rjeschke/txtmark/cmd/TxtmarkArguments.java b/src/main/java/com/github/rjeschke/txtmark/cmd/TxtmarkArguments.java new file mode 100644 index 0000000..732a4d2 --- /dev/null +++ b/src/main/java/com/github/rjeschke/txtmark/cmd/TxtmarkArguments.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2013-2015 René Jeschke + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.github.rjeschke.txtmark.cmd; + +class TxtmarkArguments +{ + @CmdArgument(l = "help", s = 'h', isSwitch = true, desc = "prints a summary of command line arguments.") + public boolean printHelp = false; + + @CmdArgument(l = "extended", isSwitch = true, desc = "forces extended profile") + public boolean forceExtendedProfile = false; + + @CmdArgument(l = "panic-mode", isSwitch = true, desc = "enables panic mode") + public boolean panicMode = false; + + @CmdArgument(l = "safe-mode", isSwitch = true, desc = "enables safe mode") + public boolean safeMode = false; + + @CmdArgument(l = "encoding", desc = "sets the IO encoding.") + public String encoding = "UTF-8"; + + @CmdArgument(l = "out-file", s = 'o', desc = "specifies output filename") + public String outFile = null; +}