ArgHelpImpl.java

package net.morimekta.terminal.args.impl;

import net.morimekta.io.tty.TTY;
import net.morimekta.strings.StringUtil;
import net.morimekta.strings.chr.Unicode;
import net.morimekta.strings.io.IndentedPrintWriter;
import net.morimekta.terminal.args.Arg;
import net.morimekta.terminal.args.ArgHelp;
import net.morimekta.terminal.args.ArgParser;
import net.morimekta.terminal.args.Flag;
import net.morimekta.terminal.args.Option;
import net.morimekta.terminal.args.SubCommandSet;

import java.io.PrintStream;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

import static java.util.Objects.requireNonNull;

public class ArgHelpImpl implements ArgHelp {
    public ArgHelpImpl(ArgParser argParser,
                       String singleLineUsagePrefix,
                       Comparator<Option> optionComparator,
                       boolean showDefaults,
                       boolean showHiddenOptions,
                       boolean showHiddenArguments,
                       boolean showHiddenSubCommands,
                       boolean showSubCommands,
                       String subCommandsHeader,
                       int usageWidth) {
        this.argParser = argParser;
        this.singleLineUsagePrefix = singleLineUsagePrefix;
        this.optionComparator = optionComparator;
        this.showDefaults = showDefaults;
        this.showHiddenOptions = showHiddenOptions;
        this.showHiddenArguments = showHiddenArguments;
        this.showHiddenSubCommands = showHiddenSubCommands;
        this.showSubCommands = showSubCommands;
        this.subCommandsHeader = subCommandsHeader;
        this.usageWidth = usageWidth;
    }

    @Override
    public void printSingleLineUsage(PrintStream stream) {
        var writer = new IndentedPrintWriter(stream);
        printSingleLineUsageInternal(writer, 0, 0, false);
        writer.flush();
    }

    @Override
    public void printPreamble(PrintStream stream) {
        var writer = new IndentedPrintWriter(stream);
        printPreambleInternal(writer);
        writer.flush();
    }

    @Override
    public void printHelp(PrintStream stream) {
        var writer = new IndentedPrintWriter(stream);
        printPreambleInternal(writer);
        writer.println();
        printUsageInternal(writer);
        writer.flush();
    }

    public static class BuilderImpl implements ArgHelp.Builder {
        public BuilderImpl(ArgParser parser) {
            this.argParser = requireNonNull(parser, "parser == null");
            this.singleLineUsagePrefix = "Usage: ";
            this.showDefaults = true;
            this.showSubCommands = true;
            this.subCommandsHeader = "Available sub-commands:";
        }

        @Override
        public ArgHelp build() {
            int width = usageWidth;
            if (width == 0) {
                try {
                    width = new TTY().getTerminalSize().cols;
                } catch (UncheckedIOException e) {
                    // fall back to fixed 80 width.
                    width = 80;
                }
            }
            return new ArgHelpImpl(argParser,
                                   singleLineUsagePrefix,
                                   optionComparator,
                                   showDefaults,
                                   showHiddenOptions,
                                   showHiddenArguments,
                                   showHiddenSubCommands,
                                   showSubCommands,
                                   subCommandsHeader,
                                   width);
        }

        @Override
        public Builder singleLineUsagePrefix(String prefix) {
            this.singleLineUsagePrefix = prefix;
            return this;
        }

        @Override
        public Builder withOptionsComparator(Comparator<Option> comparator) {
            this.optionComparator = comparator;
            return this;
        }

        @Override
        public Builder showDefaults(boolean show) {
            this.showDefaults = show;
            return this;
        }

        @Override
        public Builder showHiddenOptions(boolean show) {
            this.showHiddenOptions = show;
            return this;
        }

        @Override
        public Builder showHiddenArguments(boolean show) {
            this.showHiddenArguments = show;
            return this;
        }

        @Override
        public Builder showHiddenSubCommands(boolean show) {
            this.showHiddenSubCommands = show;
            return this;
        }

        @Override
        public Builder showSubCommands(boolean show) {
            this.showSubCommands = show;
            return this;
        }

        @Override
        public Builder subCommandsHeader(String header) {
            subCommandsHeader = header;
            return this;
        }

        @Override
        public Builder usageWidth(int width) {
            this.usageWidth = width;
            return this;
        }

        private final ArgParser          argParser;
        private       String             singleLineUsagePrefix;
        private       Comparator<Option> optionComparator;
        private       boolean            showDefaults;
        private       boolean            showHiddenOptions;
        private       boolean            showHiddenArguments;
        private       boolean            showHiddenSubCommands;
        private       boolean            showSubCommands;
        private       int                usageWidth;
        private       String             subCommandsHeader;
    }

    // ------------------

    public void printSingleLineUsageInternal(IndentedPrintWriter writer,
                                             int prefixLength,
                                             int indentLength,
                                             boolean pretty) {
        List<Option> options = argParser.getOptions();
        if (optionComparator != null) {
            options = new ArrayList<>(options);
            options.sort(optionComparator);
        }

        List<String> entries = new ArrayList<>();

        // first just list up all the unary short opts.
        StringBuilder sh = new StringBuilder();
        // Only include the first short name form the flag.
        options.stream()
               .filter(opt -> opt instanceof Flag)
               .filter(opt -> showHiddenOptions || !opt.isHidden())
               .filter(opt -> opt.getShortNames().length() > 0)
               .forEachOrdered(opt -> sh.append(opt.getShortNames().charAt(0)));
        if (sh.length() > 0) {
            entries.add("[-" + sh + ']');
        }

        for (Option opt : options) {
            if (opt.isHidden() && !showHiddenOptions) {
                continue;
            }
            if (opt instanceof Flag && opt.getShortNames().length() > 0) {
                // already included as short opt.
                continue;
            }
            String usage = opt.getSingleLineUsage();
            if (usage != null) {
                entries.add(usage);
            }
        }
        for (Arg arg : argParser.getArguments()) {
            if (arg.isHidden() && !showHiddenArguments) {
                continue;
            }
            String usage = arg.getSingleLineUsage();
            if (usage != null) {
                entries.add(usage);
            }
        }

        var scs = argParser.getSubCommandSet();
        if (scs != null) {
            if (!scs.isHidden() || showHiddenArguments) {
                String usage = scs.getSingleLineUsage();
                if (usage != null) {
                    entries.add(usage);
                }
            }
        }

        boolean first = true;
        if (pretty) {
            int pos = prefixLength;
            for (String entry : entries) {
                if (pos + 1 + entry.length() > usageWidth) {
                    pos = indentLength;
                    writer.appendln();
                } else if (first) {
                    first = false;
                } else {
                    writer.append(' ');
                    ++pos;
                }
                writer.append(entry);
                pos += entry.length();
            }
        } else {
            for (String entry : entries) {
                if (first) {
                    first = false;
                } else {
                    writer.append(' ');
                }
                writer.append(entry);
            }
        }
    }

    private void printPreambleInternal(IndentedPrintWriter writer) {
        writer.append(argParser.getDescription())
              .append(" - ")
              .append(argParser.getVersion())
              .println();
        int indent = Math.max(4, singleLineUsagePrefix.length() + argParser.getProgram().length() + 1);
        writer.append(singleLineUsagePrefix);
        writer.append(argParser.getProgram());
        writer.append(' ');
        writer.begin(" ".repeat(indent));
        printSingleLineUsageInternal(writer, singleLineUsagePrefix.length(), indent, true);
        writer.end();
        writer.println();
    }

    private void printUsageInternal(IndentedPrintWriter writer) {
        List<Option> options = argParser.getOptions();
        if (optionComparator != null) {
            options = new ArrayList<>(options);
            options.sort(optionComparator);
        }

        int prefixLen = 0;
        for (Option opt : options) {
            prefixLen = Math.max(prefixLen, opt.getPrefix().length());
        }
        for (Arg arg : argParser.getArguments()) {
            prefixLen = Math.max(prefixLen, arg.getPrefix().length());
        }

        prefixLen = Math.min(prefixLen, (usageWidth / 3) - USAGE_EXTRA_CHARS);
        String indent = " ".repeat(prefixLen + USAGE_EXTRA_CHARS);

        boolean first = true;
        for (Option opt : options) {
            first = maybePrintSingleUsageEntry(writer, showHiddenOptions, usageWidth, prefixLen, indent, first, opt);
        }

        for (Arg arg : argParser.getArguments()) {
            first = maybePrintSingleUsageEntry(writer, showHiddenArguments, usageWidth, prefixLen, indent, first, arg);
        }

        var scs = argParser.getSubCommandSet();
        if (scs != null) {
            first = maybePrintSingleUsageEntry(writer, showHiddenArguments, usageWidth, prefixLen, indent, first, scs);
        }

        if (showSubCommands && argParser.getSubCommandSet() != null) {
            if (!first) {
                writer.newline();
            }
            writer.appendln(subCommandsHeader);
            writer.newline();
            writer.appendln();
            printUsageInternal(writer, argParser.getSubCommandSet());

            // to NOT print an extra newline at end of usage.
            first = true;
        }

        // if nothing printed, complete nothing.
        if (!first) {
            writer.newline();
        }
    }

    private void printUsageInternal(IndentedPrintWriter writer, SubCommandSet<?> subCommandSet) {
        int prefixLen = 0;
        for (var cmd : subCommandSet.getSubCommands()) {
            prefixLen = Math.max(prefixLen, cmd.getName().length());
        }
        prefixLen = Math.min(prefixLen, (usageWidth / 3) - USAGE_EXTRA_CHARS);
        String indent = " ".repeat(prefixLen + USAGE_EXTRA_CHARS);

        boolean first = true;
        for (var arg : subCommandSet.getSubCommands()) {
            if (arg.isHidden() && !showHiddenSubCommands) {
                continue;
            }

            String prefix = arg.getName();
            String usage = arg.getUsage();

            if (first) {
                first = false;
            } else {
                writer.appendln();
            }
            writer.begin(indent);

            printSingleUsageEntry(writer, prefix, usage, prefixLen, usageWidth);

            writer.end();
        }

        writer.newline()
              .flush();
    }

    private boolean maybePrintSingleUsageEntry(
            IndentedPrintWriter writer,
            boolean showHidden,
            int usageWidth,
            int prefixLen,
            String indent,
            boolean first,
            Arg arg) {
        if (arg.isHidden() && !showHidden) {
            return first;
        }

        String prefix = arg.getPrefix();
        String usage = arg.getUsage();
        if (showDefaults && arg.getDefaultValue() != null) {
            usage = usage + " (default:" + Unicode.NBSP + arg.getDefaultValue() + ")";
        }

        if (!first) {
            writer.appendln();
        }
        writer.begin(indent);

        printSingleUsageEntry(writer, prefix, usage, prefixLen, usageWidth);

        writer.end();
        return false;
    }

    private static void printSingleUsageEntry(
            IndentedPrintWriter writer,
            String prefix,
            String usage,
            int prefixLen,
            int usageWidth) {
        String[] descLines;
        int printLinesFrom = 0;

        writer.append(" ");

        if (prefix.length() > prefixLen) {
            writer.append(prefix);
            if (prefix.length() > (prefixLen * 1.7)) {
                descLines = StringUtil.wrap(usage, usageWidth - prefixLen - USAGE_EXTRA_CHARS).split("\\r?\\n");
            } else {
                writer.append(" : ");

                String[] tmp = StringUtil.wrap(usage, usageWidth - prefix.length() - USAGE_EXTRA_CHARS)
                                         .split("\\r?\\n", 2);
                writer.append(tmp[0]);

                if (tmp.length > 1) {
                    descLines = StringUtil.wrap(tmp[1].replaceAll("\\r?\\n", " "),
                                                usageWidth - prefixLen - USAGE_EXTRA_CHARS).split("\\r?\\n");
                } else {
                    descLines = new String[0];
                }
            }
        } else {
            writer.append(StringUtil.rightPad(prefix, prefixLen));
            writer.append(" : ");
            descLines = StringUtil.wrap(usage, usageWidth - prefixLen - USAGE_EXTRA_CHARS).split("\\r?\\n");
            writer.append(descLines[0]);
            printLinesFrom = 1;
        }

        for (int i = printLinesFrom; i < descLines.length; ++i) {
            writer.appendln(descLines[i]);
        }
    }

    private static final int USAGE_EXTRA_CHARS = 4;

    private final ArgParser          argParser;
    private final String             singleLineUsagePrefix;
    private final Comparator<Option> optionComparator;
    private final boolean            showDefaults;
    private final boolean            showHiddenOptions;
    private final boolean            showHiddenArguments;
    private final boolean            showHiddenSubCommands;
    private final boolean            showSubCommands;
    private final String             subCommandsHeader;
    private final int                usageWidth;
}