001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.util.HashMap;
007import java.util.LinkedList;
008import java.util.List;
009import java.util.Map.Entry;
010import java.util.Objects;
011import java.util.function.Consumer;
012import java.util.stream.Collectors;
013
014/**
015 * A replacement of getopt.
016 * <p>
017 * Allows parsing command line options
018 *
019 * @author Michael Zangl
020 * @since 14415
021 */
022public class OptionParser {
023
024    private final HashMap<String, AvailableOption> availableOptions = new HashMap<>();
025    private final String program;
026
027    /**
028     * Create a new option parser.
029     * @param program The program name.
030     */
031    public OptionParser(String program) {
032        Objects.requireNonNull(program, "program name must be provided");
033        this.program = program;
034    }
035
036    /**
037     * Adds an alias for the long option --optionName to the short version -name
038     * @param optionName The long option
039     * @param shortName The short version
040      * @return this {@link OptionParser}
041    */
042    public OptionParser addShortAlias(String optionName, String shortName) {
043        if (!shortName.matches("\\w")) {
044            throw new IllegalArgumentException("Short name '" + shortName + "' must be one character");
045        }
046        if (availableOptions.containsKey("-" + shortName)) {
047            throw new IllegalArgumentException("Short name '" + shortName + "' is already used");
048        }
049        AvailableOption longDefinition = availableOptions.get("--" + optionName);
050        if (longDefinition == null) {
051            throw new IllegalArgumentException("No long definition for " + optionName
052                    + " was defined. Define the long definition first before creating " + "a short definition for it.");
053        }
054        availableOptions.put("-" + shortName, longDefinition);
055        return this;
056    }
057
058    /**
059     * Adds an option that may be used as a flag, e.g. --debug
060     * @param optionName The parameter name
061     * @param handler The handler that is called when the flag is encountered.
062     * @return this {@link OptionParser}
063     */
064    public OptionParser addFlagParameter(String optionName, Runnable handler) {
065        checkOptionName(optionName);
066        availableOptions.put("--" + optionName, parameter -> handler.run());
067        return this;
068    }
069
070    private void checkOptionName(String optionName) {
071        if (!optionName.matches("\\w([\\w-]*\\w)?")) {
072            throw new IllegalArgumentException("Illegal option name: '" + optionName + "'");
073        }
074        if (availableOptions.containsKey("--" + optionName)) {
075            throw new IllegalArgumentException("The option '--" + optionName + "' is already registered");
076        }
077    }
078
079    /**
080     * Add a parameter that expects a string attribute. E.g.: --config=/path/to/file
081     * @param optionName The name of the parameter.
082     * @param count The number of times the parameter may occur.
083     * @param handler A function that gets the current object and the parameter.
084     *                It should throw an {@link OptionParseException} if the parameter cannot be handled / is invalid.
085     * @return this {@link OptionParser}
086     */
087    public OptionParser addArgumentParameter(String optionName, OptionCount count, Consumer<String> handler) {
088        checkOptionName(optionName);
089        availableOptions.put("--" + optionName, new AvailableOption() {
090            @Override
091            public boolean requiresParameter() {
092                return true;
093            }
094
095            @Override
096            public OptionCount getRequiredCount() {
097                return count;
098            }
099
100            @Override
101            public void runFor(String parameter) {
102                Objects.requireNonNull(parameter, "parameter");
103                handler.accept(parameter);
104            }
105        });
106        return this;
107    }
108
109    /**
110     * Same as {@link #parseOptions(List)}, but exits if option parsing fails.
111     * @param arguments The options
112     * @return The remaining program arguments that are no options.
113     */
114    public List<String> parseOptionsOrExit(List<String> arguments) {
115        try {
116            return parseOptions(arguments);
117        } catch (OptionParseException e) {
118            System.err.println(e.getLocalizedMessage());
119            System.exit(1);
120            // unreachable, but makes compilers happy
121            throw e;
122        }
123    }
124
125    /**
126     * Parses the options.
127     * <p>
128     * It first checks if all required options are present, if all options are known and validates the option count.
129     * <p>
130     * Then, all option handlers are called in the order in which the options are encountered.
131     * @param arguments Program arguments
132     * @return The remaining program arguments that are no options.
133     * @throws OptionParseException The error to display if option parsing failed.
134     */
135    public List<String> parseOptions(List<String> arguments) {
136        LinkedList<String> toHandle = new LinkedList<>(arguments);
137        List<String> remainingArguments = new LinkedList<>();
138        boolean argumentOnlyMode = false;
139        List<FoundOption> options = new LinkedList<>();
140
141        while (!toHandle.isEmpty()) {
142            String next = toHandle.removeFirst();
143            if (argumentOnlyMode || !next.matches("-.+")) {
144                // argument found, add it to arguments list
145                remainingArguments.add(next);
146            } else if ("--".equals(next)) {
147                // we are done, the remaining should be used as arguments.
148                argumentOnlyMode = true;
149            } else {
150                if (next.matches("-\\w\\w+")) {
151                    // special case: concatenated short options like -hv
152                    // We handle them as if the user wrote -h -v by just scheduling the remainder for the next loop.
153                    toHandle.addFirst("-" + next.substring(2));
154                    next = next.substring(0, 2);
155                }
156
157                String[] split = next.split("=", 2);
158                String optionName = split[0];
159                AvailableOption definition = findParameter(optionName);
160                String parameter = null;
161                if (definition.requiresParameter()) {
162                    if (split.length > 1) {
163                        parameter = split[1];
164                    } else {
165                        if (toHandle.isEmpty() || "--".equals(toHandle.getFirst())) {
166                            throw new OptionParseException(tr("{0}: option ''{1}'' requires an argument", program, optionName));
167                        }
168                        parameter = toHandle.removeFirst();
169                    }
170                } else if (split.length > 1) {
171                    throw new OptionParseException(
172                            tr("{0}: option ''{1}'' does not allow an argument", program, optionName));
173                }
174                options.add(new FoundOption(optionName, definition, parameter));
175            }
176        }
177
178        // Count how often they are used
179        availableOptions.values().stream().distinct().forEach(def -> {
180            long count = options.stream().filter(p -> def.equals(p.option)).count();
181            String optionName = availableOptions.entrySet().stream()
182                    .filter(entry -> def.equals(entry.getValue()))
183                    .map(Entry::getKey)
184                    .findFirst()
185                    .orElse("?");
186            if (count < def.getRequiredCount().min) {
187                // min may be 0 or 1 at the moment
188                throw new OptionParseException(tr("{0}: option ''{1}'' is required", program, optionName));
189            } else if (count > def.getRequiredCount().max) {
190                // max may be 1 or MAX_INT at the moment
191                throw new OptionParseException(tr("{0}: option ''{1}'' may not appear multiple times", program, optionName));
192            }
193        });
194
195        // Actually apply the parameters.
196        for (FoundOption option : options) {
197            try {
198                option.option.runFor(option.parameter);
199            } catch (OptionParseException e) {
200                StringBuilder message = new StringBuilder();
201                // Just add a nicer error message
202                if (option.parameter == null) {
203                    message.append(tr("{0}: Error while handling option ''{1}''", program, option.optionName));
204                } else {
205                    message.append(tr("{0}: Invalid value {2} for option ''{1}''", program, option.optionName,
206                            option.parameter));
207                }
208                if (!e.getLocalizedMessage().isEmpty()) {
209                    message.append(": ").append(e.getLocalizedMessage().isEmpty());
210                }
211                throw new OptionParseException(message.toString(), e);
212            }
213        }
214        return remainingArguments;
215    }
216
217    private AvailableOption findParameter(String optionName) {
218        AvailableOption exactMatch = availableOptions.get(optionName);
219        if (exactMatch != null) {
220            return exactMatch;
221        } else if (optionName.startsWith("--")) {
222            List<AvailableOption> alternatives = availableOptions.entrySet().stream()
223                    .filter(entry -> entry.getKey().startsWith(optionName)).map(Entry::getValue).distinct()
224                    .collect(Collectors.toList());
225
226            if (alternatives.size() == 1) {
227                return alternatives.get(0);
228            } else if (alternatives.size() > 1) {
229                throw new OptionParseException(tr("{0}: option ''{1}'' is ambiguous", program, optionName));
230            }
231        }
232        throw new OptionParseException(tr("{0}: unrecognized option ''{1}''", program, optionName));
233    }
234
235    /**
236     * How often an option may / must be specified on the command line.
237     * @author Michael Zangl
238     */
239    public enum OptionCount {
240        /**
241         * The option may be specified once
242         */
243        OPTIONAL(0, 1),
244        /**
245         * The option is required exactly once
246         */
247        REQUIRED(1, 1),
248        /**
249         * The option may be specified multiple times
250         */
251        MULTIPLE(0, Integer.MAX_VALUE);
252
253        private final int min;
254        private final int max;
255
256        OptionCount(int min, int max) {
257            this.min = min;
258            this.max = max;
259
260        }
261    }
262
263    protected interface AvailableOption {
264
265        /**
266         * Determines if this option requires a parameter.
267         * @return {@code true} if this option requires a parameter ({@code false} by default)
268         */
269        default boolean requiresParameter() {
270            return false;
271        }
272
273        /**
274         * Determines how often this option may / must be specified on the command line.
275         * @return how often this option may / must be specified on the command line
276         */
277        default OptionCount getRequiredCount() {
278            return OptionCount.OPTIONAL;
279        }
280
281        /**
282         * Called once if the parameter is encountered, afer basic validation.
283         * @param parameter The parameter if {@link #requiresParameter()} is true, <code>null</code> otherwise.
284         */
285        void runFor(String parameter);
286    }
287
288    private static class FoundOption {
289        private final String optionName;
290        private final AvailableOption option;
291        private final String parameter;
292
293        FoundOption(String optionName, AvailableOption option, String parameter) {
294            this.optionName = optionName;
295            this.option = option;
296            this.parameter = parameter;
297        }
298    }
299
300    /**
301     * Exception thrown when an option cannot be parsed.
302     * @author Michael Zangl
303     */
304    public static class OptionParseException extends RuntimeException {
305        // Don't rely on JAVA handling this correctly.
306        private final String localizedMessage;
307
308        /**
309         * Create an empty error with no description
310         */
311        public OptionParseException() {
312            super();
313            localizedMessage = "";
314        }
315
316        /**
317         * Create an error with a localized description
318         * @param localizedMessage The message to display to the user.
319         */
320        public OptionParseException(String localizedMessage) {
321            super(localizedMessage);
322            this.localizedMessage = localizedMessage;
323        }
324
325        /**
326         * Create an error with a localized description and a root cause
327         * @param localizedMessage The message to display to the user.
328         * @param t The error that caused this message to be displayed.
329         */
330        public OptionParseException(String localizedMessage, Throwable t) {
331            super(localizedMessage, t);
332            this.localizedMessage = localizedMessage;
333        }
334
335        @Override
336        public String getLocalizedMessage() {
337            return localizedMessage;
338        }
339    }
340}