source: josm/trunk/src/org/openstreetmap/josm/tools/OptionParser.java @ 14415

Last change on this file since 14415 was 14415, checked in by michael2402, 3 months ago

See #16866: Drop getopt, use own option parser.

File size: 11.9 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.tools;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.util.HashMap;
7import java.util.LinkedList;
8import java.util.List;
9import java.util.Map.Entry;
10import java.util.Objects;
11import java.util.function.Consumer;
12import java.util.stream.Collectors;
13
14/**
15 * A replacement of getopt.
16 * <p>
17 * Allows parsing command line options
18 *
19 * @author Michael Zangl
20 * @since 14415
21 */
22public class OptionParser {
23
24    private HashMap<String, AvailableOption> availableOptions = new HashMap<>();
25    private final String program;
26
27    /**
28     * Create a new option parser.
29     * @param program The program name.
30     */
31    public OptionParser(String program) {
32        Objects.requireNonNull(program, "program name must be provided");
33        this.program = program;
34    }
35
36    /**
37     * Adds an alias for the long option --optionName to the short version -name
38     * @param optionName The long option
39     * @param shortName The short version
40      * @return this {@link OptionParser}
41    */
42    public OptionParser addShortAlias(String optionName, String shortName) {
43        if (!shortName.matches("\\w")) {
44            throw new IllegalArgumentException("Short name " + shortName + " must be one character");
45        }
46        if (availableOptions.containsKey("-" + shortName)) {
47            throw new IllegalArgumentException("Short name " + shortName + " is already used");
48        }
49        AvailableOption longDefinition = availableOptions.get("--" + optionName);
50        if (longDefinition == null) {
51            throw new IllegalArgumentException("No long definition for " + optionName
52                    + " was defined. Define the long definition first before creating " + "a short definition for it.");
53        }
54        availableOptions.put("-" + shortName, longDefinition);
55        return this;
56    }
57
58    /**
59     * Adds an option that may be used as a flag, e.g. --debug
60     * @param optionName The parameter name
61     * @param handler The handler that is called when the flag is encountered.
62     * @return this {@link OptionParser}
63     */
64    public OptionParser addFlagParameter(String optionName, Runnable handler) {
65        checkOptionName(optionName);
66        availableOptions.put("--" + optionName, new AvailableOption() {
67            @Override
68            public void runFor(String parameter) {
69                handler.run();
70            }
71        });
72        return this;
73    }
74
75    private void checkOptionName(String optionName) {
76        if (!optionName.matches("\\w([\\w-]*\\w)?")) {
77            throw new IllegalArgumentException("Illegal option name: " + optionName);
78        }
79        if (availableOptions.containsKey("--" + optionName)) {
80            throw new IllegalArgumentException("The option --" + optionName + " is already registered");
81        }
82    }
83
84    /**
85     * Add a parameter that expects a string attribute. E.g.: --config=/path/to/file
86     * @param optionName The name of the parameter.
87     * @param count The number of times the parameter may occur.
88     * @param handler A function that gets the current object and the parameter.
89     *                It should throw an {@link OptionParseException} if the parameter cannot be handled / is invalid.
90     * @return this {@link OptionParser}
91     */
92    public OptionParser addArgumentParameter(String optionName, OptionCount count, Consumer<String> handler) {
93        checkOptionName(optionName);
94        availableOptions.put("--" + optionName, new AvailableOption() {
95            @Override
96            public boolean requiresParameter() {
97                return true;
98            }
99
100            @Override
101            public OptionCount getRequiredCount() {
102                return count;
103            }
104
105            @Override
106            public void runFor(String parameter) {
107                Objects.requireNonNull(parameter, "parameter");
108                handler.accept(parameter);
109            }
110        });
111        return this;
112    }
113
114    /**
115     * Same as {@link #parseOptions(List)}, but exits if option parsing fails.
116     * @param arguments The options
117     * @return The remaining program arguments that are no options.
118     */
119    public List<String> parseOptionsOrExit(List<String> arguments) {
120        try {
121            return parseOptions(arguments);
122        } catch (OptionParseException e) {
123            System.err.println(e.getLocalizedMessage());
124            System.exit(1);
125            // unreachable, but makes compilers happy
126            throw e;
127        }
128    }
129
130    /**
131     * Parses the options.
132     * <p>
133     * It first checks if all required options are present, if all options are known and validates the option count.
134     * <p>
135     * Then, all option handlers are called in the order in which the options are encountered.
136     * @param arguments Program arguments
137     * @return The remaining program arguments that are no options.
138     * @throws OptionParseException The error to display if option parsing failed.
139     */
140    public List<String> parseOptions(List<String> arguments) {
141        LinkedList<String> toHandle = new LinkedList<>(arguments);
142        List<String> remainingArguments = new LinkedList<>();
143        boolean argumentOnlyMode = false;
144        List<FoundOption> options = new LinkedList<>();
145
146        while (!toHandle.isEmpty()) {
147            String next = toHandle.removeFirst();
148            if (argumentOnlyMode || !next.matches("-.+")) {
149                // argument found, add it to arguments list
150                remainingArguments.add(next);
151            } else if ("--".equals(next)) {
152                // we are done, the remaining should be used as arguments.
153                argumentOnlyMode = true;
154            } else {
155                if (next.matches("-\\w\\w+")) {
156                    // special case: concatenated short options like -hv
157                    // We handle them as if the user wrote -h -v by just scheduling the remainder for the next loop.
158                    toHandle.addFirst("-" + next.substring(2));
159                    next = next.substring(0, 2);
160                }
161
162                String[] split = next.split("=", 2);
163                String optionName = split[0];
164                AvailableOption definition = findParameter(optionName);
165                String parameter = null;
166                if (definition.requiresParameter()) {
167                    if (split.length > 1) {
168                        parameter = split[1];
169                    } else {
170                        if (toHandle.isEmpty() || toHandle.getFirst().equals("--")) {
171                            throw new OptionParseException(tr("{0}: option ''{1}'' requires an argument", program));
172                        }
173                        parameter = toHandle.removeFirst();
174                    }
175                } else if (split.length > 1) {
176                    throw new OptionParseException(
177                            tr("{0}: option ''{1}'' does not allow an argument", program, optionName));
178                }
179                options.add(new FoundOption(optionName, definition, parameter));
180            }
181        }
182
183        // Count how often they are used
184        availableOptions.values().stream().distinct().forEach(def -> {
185            long count = options.stream().filter(p -> def.equals(p.option)).count();
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"));
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"));
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                String message;
201                // Just add a nicer error message
202                if (option.parameter == null) {
203                    message = tr("{0}: Error while handling option ''{1}''", program, option.optionName);
204                } else {
205                    message = tr("{0}: Invalid value {2} for option ''{1}''", program, option.optionName,
206                            option.parameter);
207                }
208                if (!e.getLocalizedMessage().isEmpty()) {
209                    message += ": " + e.getLocalizedMessage().isEmpty();
210                }
211                throw new OptionParseException(message);
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));
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 int min;
254        private int max;
255
256        OptionCount(int min, int max) {
257            this.min = min;
258            this.max = max;
259
260        }
261    }
262
263    protected abstract class AvailableOption {
264
265        public boolean requiresParameter() {
266            return false;
267        }
268
269        public OptionCount getRequiredCount() {
270            return OptionCount.OPTIONAL;
271        }
272
273        /**
274         * Called once if the parameter is encountered, afer basic validation.
275         * @param parameter The parameter if {@link #requiresParameter()} is true, <code>null</code> otherwise.
276         */
277        public abstract void runFor(String parameter);
278
279    }
280
281    private static class FoundOption {
282        private final String optionName;
283        private final AvailableOption option;
284        private final String parameter;
285
286        FoundOption(String optionName, AvailableOption option, String parameter) {
287            this.optionName = optionName;
288            this.option = option;
289            this.parameter = parameter;
290        }
291    }
292
293    /**
294     * @author Michael Zangl
295     */
296    public static class OptionParseException extends RuntimeException {
297        // Don't rely on JAVA handling this correctly.
298        private final String localizedMessage;
299
300        /**
301         * Create an empty error with no description
302         */
303        public OptionParseException() {
304            super();
305            localizedMessage = "";
306        }
307
308        /**
309         * @param localizedMessage The message to display to the user.
310         */
311        public OptionParseException(String localizedMessage) {
312            super(localizedMessage);
313            this.localizedMessage = localizedMessage;
314        }
315
316        /**
317         * @param localizedMessage The message to display to the user.
318         * @param t The error that caused this message to be displayed.
319         */
320        public OptionParseException(String localizedMessage, Throwable t) {
321            super(localizedMessage, t);
322            this.localizedMessage = localizedMessage;
323        }
324
325        @Override
326        public String getLocalizedMessage() {
327            return localizedMessage;
328        }
329    }
330}
Note: See TracBrowser for help on using the repository browser.