All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.sigpwned.just.args.JustArgs Maven / Gradle / Ivy

The newest version!
/*-
 * =================================LICENSE_START==================================
 * just-args
 * ====================================SECTION=====================================
 * Copyright (C) 2025 Andy Boothe
 * ====================================SECTION=====================================
 * This is free and unencumbered software released into the public domain.
 * 
 * Anyone is free to copy, modify, publish, use, compile, sell, or
 * distribute this software, either in source code form or as a compiled
 * binary, for any purpose, commercial or non-commercial, and by any
 * means.
 * 
 * In jurisdictions that recognize copyright laws, the author or authors
 * of this software dedicate any and all copyright interest in the
 * software to the public domain. We make this dedication for the benefit
 * of the public at large and to the detriment of our heirs and
 * successors. We intend this dedication to be an overt act of
 * relinquishment in perpetuity of all present and future rights to this
 * software under copyright law.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
 * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
 * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
 * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 * OTHER DEALINGS IN THE SOFTWARE.
 * 
 * For more information, please refer to 
 * ==================================LICENSE_END===================================
 */
package com.sigpwned.just.args;

import static java.util.Collections.unmodifiableList;
import static java.util.Collections.unmodifiableMap;
import static java.util.stream.Collectors.counting;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;
import java.text.CharacterIterator;
import java.text.StringCharacterIterator;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;

/**
 * A single-class library for parsing command line arguments in Java 8.
 */
public final class JustArgs {
  private JustArgs() {}

  /**
   * Represents the result of parsing command line arguments.
   */
  public static class ParsedArgs {
    private final List args;
    private final Map> options;
    private final Map> flags;

    /**
     * Constructs a new ParsedArgs object.
     *
     * @param args the positional arguments
     * @param options a map from option-name to a list of string values
     * @param flags a map from flag-name to a list of boolean values
     */
    public ParsedArgs(List args, Map> options,
        Map> flags) {
      this.args = unmodifiableList(args);
      this.options = unmodifiableMapOfLists(options);
      this.flags = unmodifiableMapOfLists(flags);
    }

    /**
     * Returns the list of positional arguments
     */
    public List getArgs() {
      return args;
    }

    /**
     * Returns the map of options. Keys are option names; values are the list of values provided for
     * that option.
     */
    public Map> getOptions() {
      return options;
    }

    /**
     * Returns the map of flags. Keys are flag names; values are the list of boolean occurrences
     * (e.g., repeated flags).
     */
    public Map> getFlags() {
      return flags;
    }

    @Override
    public int hashCode() {
      return Objects.hash(args, flags, options);
    }

    @Override
    public boolean equals(Object obj) {
      if (this == obj)
        return true;
      if (obj == null)
        return false;
      if (getClass() != obj.getClass())
        return false;
      ParsedArgs other = (ParsedArgs) obj;
      return Objects.equals(args, other.args) && Objects.equals(flags, other.flags)
          && Objects.equals(options, other.options);
    }

    @Override
    public String toString() {
      return "ParsedArgs [args=" + args + ", options=" + options + ", flags=" + flags + "]";
    }
  }

  /**
   * Thrown when a syntactic error in the arguments is encountered.
   */
  @SuppressWarnings("serial")
  public static class IllegalSyntaxException extends IllegalArgumentException {
    private final int index;

    public IllegalSyntaxException(int index, String message) {
      super(message);
      this.index = index;
      if (index < 0)
        throw new IllegalArgumentException("index must be at least 0");
    }

    public int getIndex() {
      return index;
    }
  }

  /**
   * Parses Java command line arguments.
   *
   * 

* An "option" is a switch that takes a value. A switch can be short (e.g., {@code -o}) or long * (e.g., {@code --option}). A value can always be given as the next argument after the switch. A * value can also be given to a long switch in attached style using an {@code =} sign (e.g., * {@code --option=value}). * *

* A "flag" is a switch that has an implicit boolean value. A flag is represented by a short * switch or a long switch. A positive flag has the value {@link Boolean#TRUE} and a negative flag * has the value {@link Boolean#FALSE}. They are syntactically equivalent, so names should be * chosen carefully. By convention, short positive flags are lowercase (e.g., {@code -x}) whereas * short negative flags are uppercase (e.g., {@code -X}), whereas long negative flags start with a * negative prefix (e.g., {@code --no-flag}), whereas long positive flags do not (e.g., * {@code --flag}). * *

* Short flags, positive or negative, can appear in "batches." For example, {@code -abc} is * equivalent to {@code -a -b -c}. The last short switch in a batch can be an option that takes a * value (e.g., {@code -abc value} is equivalent to {@code -a -b -c value}), but all other short * switches in the batch must be flags. * *

* A positional argument is any value which is not a switch or a parameter to an option. The * parser supports the interleaving of switches and parameters throughout the command arguments. * The special token {@code --} can be used to indicate that all subsequent arguments are * positional arguments, even if they look like switches. * * @param args the command line arguments to parse, generally from the main function * @param shortOptionNames a map from valid short option names to the string to use to collect * values into the options result. Keys in shortOptionNames must not appear in any other * option or flag name map in character form. If a short option name does not appear in * shortOptionNames, then it is not a valid short option. * @param longOptionNames a map from valid long option names to the string to use to collect * values into the options result. Keys in longOptionNames must not appear in any other * option or flag name map in string form. If a long option name does not appear in * longOptionNames, then it is not a valid long option. * @param shortPositiveFlagNames a map from valid short positive flag names to the string to use * to collect values into the flags result. Keys in shortPositiveFlagNames must not appear * in any other option or flag name map in character form. If a short positive flag name * does not appear in shortPositiveFlagNames, then it is not a valid short positive flag * name. A positive flag takes the value of {@link Boolean#TRUE}. * @param longPositiveFlagNames a map from valid long positive flag names to the string to use to * collect values into the flags result. Keys in longPositiveFlagNames must not appear in * any other option or flag name map in string form. If a long positive flag name does not * appear in longPositiveFlagNames, then it is not a valid long positive flag name. A * positive flag takes the value of {@link Boolean#TRUE}. * @param shortNegativeFlagNames a map from valid short negative flag names to the string to use * to collect values into the flags result. Keys in shortNegativeFlagNames must not appear * in any other option or flag name map in character form. If a short negative flag name * does not appear in shortNegativeFlagNames, then it is not a valid short negative flag * name. A negative flag takes the value of {@link Boolean#FALSE}. * @param longNegativeFlagNames a map from valid long negative flag names to the string to use to * collect values into the flags result. Keys in longNegativeFlagNames must not appear in * any other option or flag name map in string form. If a long negative flag name does not * appear in longNegativeFlagNames, then it is not a valid long negative flag name. A * negative flag takes the value of {@link Boolean#FALSE}. * @return the parsed arguments * * @throws NullPointerException if any argument is null * @throws IllegalArgumentException if any short option or flag name appears in more than one of * shortOptionNames, shortPositiveFlagNames, and shortNegativeFlagNames; or if any long * option or flag name appears in more than one of longOptionNames, longPositiveFlagNames, * and longNegativeFlagNames. * @throws IllegalSyntaxException if any short switch is not an element in shortOptionNames, * shortPositiveFlagNames, or shortNegativeFlagNames; or if any long switch is not an * element in longOptionNames, longPositiveFlagNames, or longNegativeFlagNames; or if any * option switch does not have a value; or if any flag switch has a value. */ public static ParsedArgs parseArgs(List args, Map shortOptionNames, Map longOptionNames, Map shortPositiveFlagNames, Map longPositiveFlagNames, Map shortNegativeFlagNames, Map longNegativeFlagNames) { if (args == null) throw new NullPointerException(); if (shortOptionNames == null) throw new NullPointerException(); if (longOptionNames == null) throw new NullPointerException(); if (shortPositiveFlagNames == null) throw new NullPointerException(); if (longPositiveFlagNames == null) throw new NullPointerException(); if (shortNegativeFlagNames == null) throw new NullPointerException(); if (longNegativeFlagNames == null) throw new NullPointerException(); final Set duplicateShortKeys = duplicates(shortOptionNames.keySet(), shortPositiveFlagNames.keySet(), shortNegativeFlagNames.keySet()); if (!duplicateShortKeys.isEmpty()) throw new IllegalArgumentException("Duplicate short switch keys: " + duplicateShortKeys); final Set duplicateLongKeys = duplicates(longOptionNames.keySet(), longPositiveFlagNames.keySet(), longNegativeFlagNames.keySet()); if (!duplicateLongKeys.isEmpty()) throw new IllegalArgumentException("Duplicate long switch keys: " + duplicateLongKeys); // Prepare result holders List positionalArgs = new ArrayList<>(); Map> options = new LinkedHashMap<>(); Map> flags = new LinkedHashMap<>(); // // Helper to add an option value to the map // // e.g. if the "optionName" is "output", store the value in options.get("output") // java.util.function.BiConsumer addOption = (optionName, value) -> { // options.computeIfAbsent(optionName, k -> new ArrayList<>()).add(value); // }; // // // Helper to add a flag occurrence // // e.g. if the "flagName" is "verbose", store true in flags.get("verbose") // java.util.function.BiConsumer addFlag = (flagName, boolVal) -> { // flags.computeIfAbsent(flagName, k -> new ArrayList<>()).add(boolVal); // }; boolean separated = false; final ListIterator iterator = args.listIterator(); while (iterator.hasNext()) { final String arg = iterator.next(); // If the argument is exactly `--`, all subsequent are positional if ("--".equals(arg) && separated == false) { separated = true; continue; } // If we've already encountered `--`, everything is a positional arg if (separated) { positionalArgs.add(arg); continue; } // Check if it looks like a short or long switch if (arg.startsWith("--")) { // LONG SWITCH // Could be a flag or an option // Also check if it's in the form --key=value final String switchName; final String attachedValue; final int equalsAt = arg.indexOf('=', 2); if (equalsAt != -1) { switchName = arg.substring(2, equalsAt); attachedValue = arg.substring(equalsAt + 1, arg.length()); } else { switchName = arg.substring(2, arg.length()); attachedValue = null; } // Now see if it's a valid option name if (longOptionNames.containsKey(switchName)) { // It's an option final String optionName = longOptionNames.get(switchName); // If we have an attached value (--key=value), use that. Otherwise, use next argument. final String value; if (attachedValue != null) { value = attachedValue; } else { if (!iterator.hasNext()) { // Options must have a value. If there is none, that's a syntax error. throw new IllegalSyntaxException(iterator.previousIndex(), "Option --" + switchName + " requires a value but none given"); } value = iterator.next(); } options.computeIfAbsent(optionName, k -> new ArrayList<>()).add(value); } else if (longPositiveFlagNames.containsKey(switchName)) { if (attachedValue != null) { // Flags do not take a value throw new IllegalSyntaxException(iterator.previousIndex(), "Flag --" + switchName + " does not take a value"); } final String flagName = longPositiveFlagNames.get(switchName); flags.computeIfAbsent(flagName, k -> new ArrayList<>()).add(Boolean.TRUE); } else if (longNegativeFlagNames.containsKey(switchName)) { if (attachedValue != null) { // Flags do not take a value throw new IllegalSyntaxException(iterator.previousIndex(), "Flag --" + switchName + " does not take a value"); } final String flagName = longPositiveFlagNames.get(switchName); flags.computeIfAbsent(flagName, k -> new ArrayList<>()).add(Boolean.FALSE); } else { // Not recognized throw new IllegalSyntaxException(iterator.previousIndex(), "Unrecognized long switch --" + switchName); } } else if (arg.startsWith("-") && arg.length() > 1) { // SHORT SWITCH(ES) // e.g. -abc or -o // We'll parse each character except possibly the last if it's an option final CharacterIterator itc = new StringCharacterIterator(arg); // If there's only 1 char, it's either a flag or an option // If multiple chars, all but last must be flags // Yes, this approach DOES skip the first char. Yes, it is tested. No, it is not a typo. for (char switchName = itc.next(); switchName != CharacterIterator.DONE; switchName = itc.next()) { // If this char is recognized as an option (and it's the last char in the batch), // then we parse the next argument for the option value if (shortOptionNames.containsKey(switchName)) { final boolean isLastChar = itc.next() == CharacterIterator.DONE; itc.previous(); // Go back to the last char if (!isLastChar) { throw new IllegalSyntaxException(iterator.previousIndex(), "Option -" + switchName + " must be the last character in the batch"); } // It's an option final String optionName = shortOptionNames.get(switchName); // We look for the next argument if (!iterator.hasNext()) { throw new IllegalSyntaxException(iterator.previousIndex(), "Option -" + switchName + " requires a value but none given"); } final String nextVal = iterator.next(); options.computeIfAbsent(optionName, k -> new ArrayList<>()).add(nextVal); } else if (shortPositiveFlagNames.containsKey(switchName)) { final String flagName = shortPositiveFlagNames.get(switchName); flags.computeIfAbsent(flagName, k -> new ArrayList<>()).add(Boolean.TRUE); } else if (shortNegativeFlagNames.containsKey(switchName)) { final String flagName = shortNegativeFlagNames.get(switchName); flags.computeIfAbsent(flagName, k -> new ArrayList<>()).add(Boolean.FALSE); } else { throw new IllegalSyntaxException(iterator.previousIndex(), "Unrecognized short switch -" + switchName); } } } else { // POSITIONAL ARG positionalArgs.add(arg); } } return new ParsedArgs(positionalArgs, options, flags); } @SafeVarargs private static Set duplicates(Set... sets) { return Arrays.stream(sets).flatMap(Set::stream) .collect(groupingBy(Function.identity(), counting())).entrySet().stream() .filter(e -> e.getValue() > 1L).map(Map.Entry::getKey).collect(toSet()); } /** * Returns an unmodifiable copy of the given map of lists. * * @param mapOfLists the map of lists to copy * @return an unmodifiable copy of the given map of lists */ private static Map> unmodifiableMapOfLists(Map> mapOfLists) { return unmodifiableMap( mapOfLists.entrySet().stream().map(e -> entry(e.getKey(), copyOf(e.getValue()))) .collect(toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> { // This should never happen, since we started with a valid map throw new IllegalArgumentException("map contains duplicate keys"); }, LinkedHashMap::new))); } /** * Returns an unmodifiable copy of the given list. * * @param the type of the elements in the list * @param xs the list to copy * @return an unmodifiable copy of the given list * * @throws NullPointerException if the list is null */ private static List copyOf(List xs) { if (xs == null) throw new NullPointerException(); return Collections.unmodifiableList(new ArrayList<>(xs)); } /** * Creates a new {@link Map.Entry} with the given key and value. * * @param the type of the key * @param the type of the value * @param key the key * @param value the value * @return a new {@link Map.Entry} with the given key and value */ private static Map.Entry entry(K key, V value) { return new java.util.AbstractMap.SimpleImmutableEntry<>(key, value); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy