Skip to content

Commit

Permalink
Merge pull request #125 from gdgib/G2-1551-ArgParse
Browse files Browse the repository at this point in the history
G2-1551 Generalized argument parser
  • Loading branch information
gdgib committed Mar 30, 2024
2 parents 390ea0b + 1444213 commit a2f3ca8
Show file tree
Hide file tree
Showing 18 changed files with 696 additions and 3 deletions.
5 changes: 5 additions & 0 deletions gb-argparse/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/target/
/.settings/
/.project
/.classpath
/.factorypath
25 changes: 25 additions & 0 deletions gb-argparse/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>
<artifactId>gb-argparse</artifactId>

<parent>
<groupId>com.g2forge.gearbox</groupId>
<artifactId>gb-project</artifactId>
<version>0.0.10-SNAPSHOT</version>
<relativePath>../gb-project/pom.xml</relativePath>
</parent>

<name>Gearbox ArgParse</name>
<description>A simple command line argument parsing library.</description>

<dependencies>
<dependency>
<groupId>com.g2forge.habitat</groupId>
<artifactId>ha-metadata</artifactId>
<version>${habitat.version}</version>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.g2forge.gearbox.argparse;

import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

@Documented
@Retention(RUNTIME)
@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD })
public @interface ArgumentHelp {
public String value();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.g2forge.gearbox.argparse;

public class ArgumentHelpException extends RuntimeException {
public ArgumentHelpException(String message) {
super(message);
}

private static final long serialVersionUID = 8436426521191021803L;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package com.g2forge.gearbox.argparse;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;

import com.g2forge.alexandria.java.core.helpers.HCollection;
import com.g2forge.alexandria.java.core.helpers.HStream;
import com.g2forge.alexandria.java.fluent.optional.IOptional;
import com.g2forge.alexandria.java.function.IFunction1;
import com.g2forge.alexandria.java.text.HString;
import com.g2forge.habitat.metadata.value.predicate.IPredicate;
import com.g2forge.habitat.metadata.value.subject.ISubject;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class ArgumentParser<T> {
@Data
@Builder(toBuilder = true)
@RequiredArgsConstructor
protected static class ParameterParserInfo {
protected final int index;

protected final IParameterParser parser;
}

protected static final Set<String> STANDARD_HELP_ARGUMENTS = HCollection.asSet("/h", "/?", "-h", "-help", "--help");

public enum HelpArguments {
STANDARD {
@Override
public boolean isHelp(List<String> arguments) {
if (arguments.size() == 1) return STANDARD_HELP_ARGUMENTS.contains(arguments.get(0));
return false;
}
},
EMPTY {
@Override
public boolean isHelp(List<String> arguments) {
return arguments.isEmpty();
}
};

public abstract boolean isHelp(List<String> arguments);
}

protected final Class<T> type;

protected final Set<HelpArguments> help;

public ArgumentParser(Class<T> type) {
this(type, EnumSet.of(HelpArguments.STANDARD));
}

@Getter(lazy = true, value = AccessLevel.PROTECTED)
private final Constructor<T> constructor = findConstructor();

public T parse(List<String> arguments) {
final IArgumentsParser argumentsParser = getArgumentsParser();

final boolean help = getHelp().stream().filter(helpArguments -> helpArguments.isHelp(arguments)).findAny().isPresent();
if (help) throw new ArgumentHelpException(argumentsParser.generateHelp());

final Object[] parsed = argumentsParser.apply(arguments);
return create(parsed);
}

@Getter(lazy = true, value = AccessLevel.PROTECTED)
private final IArgumentsParser argumentsParser = computeArgumentsParser();

protected ArgumentsParser computeArgumentsParser() {
final Parameter[] parameterActuals = getConstructor().getParameters();
final List<IParameterInfo> parameterInfos = new ArrayList<>();
for (int i = 0; i < parameterActuals.length; i++) {
parameterInfos.add(new IParameterInfo.ParameterInfoAdapter(i, parameterActuals[i]));
}
final ArgumentsParser argumentsParser = new ArgumentsParser(parameterInfos);
return argumentsParser;
}

private T create(final Object[] parsed) {
final Constructor<T> constructor = getConstructor();
try {
return constructor.newInstance(parsed);
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
throw new RuntimeException(e);
}
}

private Constructor<T> findConstructor() {
final Constructor<?>[] constructors = type.getDeclaredConstructors();
if (constructors.length != 1) throw new IllegalArgumentException(String.format("Argument type %1$s has %2$d constructors, only single constructor types are supported (for now).", type, constructors.length));

@SuppressWarnings({ "rawtypes", "unchecked" })
final Constructor<T> constructor = (Constructor) constructors[0];
return constructor;
}

public static <T> T parse(Class<T> type, List<String> arguments) {
return new ArgumentParser<>(type).parse(arguments);
}

protected interface IArgumentsParser extends IFunction1<List<String>, Object[]> {
public String generateHelp();
}

protected static class ArgumentsParser implements IArgumentsParser {
protected final List<? extends IParameterInfo> parameters;

/** An in-order list of the positional parameters. May have different size than the orignal parameters as some may be named. */
protected final List<ParameterParserInfo> positional;

/** A map from their names to the named parameters. May have different size than the original parameters as some may be positional. */
protected final Map<String, ParameterParserInfo> named;

/** A list of parsers, one for each input parameter. */
protected final List<IParameterParser> parsers;

public ArgumentsParser(final List<? extends IParameterInfo> parameters) {
this(StandardParameterParserFactory.create(), parameters);
}

public ArgumentsParser(final IParameterParserFactory parameterParserFactory, final List<? extends IParameterInfo> parameters) {
// Parse the parameter model from the constructor
this.parameters = parameters;
positional = new ArrayList<>();
named = new HashMap<>();
parsers = new ArrayList<>();
for (int i = 0; i < parameters.size(); i++) {
final IParameterInfo parameter = parameters.get(i);

final IParameterParser parameterTypeParser = parameterParserFactory.apply(parameter);
parsers.add(parameterTypeParser);

final ParameterParserInfo info = new ParameterParserInfo(i, parameterTypeParser);
final ISubject subject = parameter.getSubject();
final NamedParameter annotation = subject.get(NamedParameter.class);
if (annotation != null) named.put(annotation.value(), info);
else positional.add(info);
}
}

public Object[] apply(List<String> arguments) {
final Object[] parsed = new Object[parameters.size()];
final boolean[] set = new boolean[parameters.size()];
int p = 0;
// Parse the arguments
for (final ListIterator<String> argumentIterator = arguments.listIterator(); argumentIterator.hasNext();) {
final int argumentIndex = argumentIterator.nextIndex();
final String argument = argumentIterator.next();
try {
boolean foundNamed = false;
for (Map.Entry<String, ParameterParserInfo> entry : named.entrySet()) {
if (argument.startsWith(entry.getKey())) {
final ParameterParserInfo info = entry.getValue();
final int parameterIndex = info.getIndex();
parsed[parameterIndex] = info.getParser().parse(parameters.get(parameterIndex), argumentIterator);
set[parameterIndex] = true;
foundNamed = true;
break;
}
}
if (!foundNamed) {
argumentIterator.previous();
final ParameterParserInfo info = positional.get(p++);
final int index = info.getIndex();
parsed[index] = info.getParser().parse(parameters.get(index), argumentIterator);
set[index] = true;
}
} catch (Throwable throwable) {
throw new UnparseableArgumentException(argumentIndex, argument, throwable);
}
}

// Fill in any unparsed parameters with defaults
for (IParameterInfo parameter : parameters) {
if (!set[parameter.getIndex()]) {
final IOptional<Object> defaultValue = parsers.get(parameter.getIndex()).getDefault(parameter);
if (defaultValue.isEmpty()) throw new UnspecifiedParameterException(parameter);
else {
parsed[parameter.getIndex()] = defaultValue.get();
set[parameter.getIndex()] = true;
}
}
}
return parsed;
}

@Override
public String generateHelp() {
final StringBuilder retVal = new StringBuilder();
final Map<String, String> positionalHelp = new LinkedHashMap<>();
for (ParameterParserInfo info : positional) {
final IParameterInfo parameter = parameters.get(info.getIndex());
if (!retVal.isEmpty()) retVal.append(' ');
retVal.append('<').append(parameter.getName()).append('>');

final IPredicate<ArgumentHelp> predicate = parameter.getSubject().bind(ArgumentHelp.class);
if (predicate.isPresent()) positionalHelp.put(parameter.getName(), predicate.get0().value());
}
final boolean hasNamed = named.isEmpty();
if (!hasNamed && !retVal.isEmpty()) retVal.append(" [...]");

if (!positionalHelp.isEmpty() || !hasNamed) {
if (!retVal.isEmpty()) retVal.append("\n");
final int padded = HStream.concat(positionalHelp.keySet().stream(), named.keySet().stream()).mapToInt(String::length).max().getAsInt();

if (!positionalHelp.isEmpty()) {
for (Map.Entry<String, String> entry : positionalHelp.entrySet()) {
if (!retVal.isEmpty()) retVal.append('\n');
retVal.append(entry.getKey()).append(' ').append(entry.getValue());
}
}

if (!hasNamed) {
for (Map.Entry<String, ParameterParserInfo> entry : named.entrySet()) {
if (!retVal.isEmpty()) retVal.append('\n');
final IParameterInfo parameter = parameters.get(entry.getValue().getIndex());
retVal.append(HString.pad(entry.getKey(), " ", padded));
final ArgumentHelp argumentHelp = parameter.getSubject().get(ArgumentHelp.class);
if (argumentHelp != null) retVal.append(' ').append(argumentHelp.value());
}
}
}

return retVal.toString();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.g2forge.gearbox.argparse;

import java.nio.file.Paths;
import java.util.ListIterator;

import com.g2forge.alexandria.java.fluent.optional.IOptional;
import com.g2forge.alexandria.java.fluent.optional.NullableOptional;

public enum BasicParameterParser implements IParameterParser {
BOOLEAN {
@Override
public IOptional<Object> getDefault(IParameterInfo parameter) {
if (parameter.getSubject().bind(NamedParameter.class).isPresent()) return NullableOptional.of(false);
return NullableOptional.empty();
}

@Override
public Object parse(IParameterInfo parameter, ListIterator<String> argumentIterator) {
if (parameter.getSubject().bind(NamedParameter.class).isPresent()) return true;
else return Boolean.valueOf(argumentIterator.next());
}
},
PATH {
@Override
public IOptional<Object> getDefault(IParameterInfo parameter) {
if (parameter.getSubject().bind(NamedParameter.class).isPresent()) return NullableOptional.of(null);
return NullableOptional.empty();
}

@Override
public Object parse(IParameterInfo parameter, ListIterator<String> argumentIterator) {
return Paths.get(argumentIterator.next());
}
},
STRING {
@Override
public IOptional<Object> getDefault(IParameterInfo parameter) {
if (parameter.getSubject().bind(NamedParameter.class).isPresent()) return NullableOptional.of(null);
return NullableOptional.empty();
}

@Override
public Object parse(IParameterInfo parameter, ListIterator<String> argumentIterator) {
return argumentIterator.next();
}
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.g2forge.gearbox.argparse;

/**
* An optional marker interface for all types meant to be parsed as command line arguments.
*/
public interface IArgumentsType {}
Loading

0 comments on commit a2f3ca8

Please sign in to comment.