001//
002// MIT License
003//
004// Copyright (c) 2021 Alexander Söderberg & Contributors
005//
006// Permission is hereby granted, free of charge, to any person obtaining a copy
007// of this software and associated documentation files (the "Software"), to deal
008// in the Software without restriction, including without limitation the rights
009// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
010// copies of the Software, and to permit persons to whom the Software is
011// furnished to do so, subject to the following conditions:
012//
013// The above copyright notice and this permission notice shall be included in all
014// copies or substantial portions of the Software.
015//
016// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
017// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
018// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
019// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
020// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
021// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
022// SOFTWARE.
023//
024package cloud.commandframework.arguments.compound;
025
026import cloud.commandframework.arguments.CommandArgument;
027import cloud.commandframework.arguments.flags.CommandFlag;
028import cloud.commandframework.arguments.parser.ArgumentParseResult;
029import cloud.commandframework.arguments.parser.ArgumentParser;
030import cloud.commandframework.captions.Caption;
031import cloud.commandframework.captions.CaptionVariable;
032import cloud.commandframework.captions.StandardCaptionKeys;
033import cloud.commandframework.context.CommandContext;
034import cloud.commandframework.exceptions.parsing.ParserException;
035import cloud.commandframework.keys.CloudKey;
036import cloud.commandframework.keys.SimpleCloudKey;
037import io.leangen.geantyref.TypeToken;
038import org.checkerframework.checker.nullness.qual.NonNull;
039
040import java.util.Collection;
041import java.util.Collections;
042import java.util.HashSet;
043import java.util.LinkedList;
044import java.util.List;
045import java.util.Locale;
046import java.util.Optional;
047import java.util.Queue;
048import java.util.Set;
049import java.util.function.BiFunction;
050import java.util.regex.Matcher;
051import java.util.regex.Pattern;
052
053/**
054 * Container for flag parsing logic. This should not be be used directly.
055 * Internally, a flag argument is a special case of a {@link CompoundArgument}.
056 *
057 * @param <C> Command sender type
058 */
059public final class FlagArgument<C> extends CommandArgument<C, Object> {
060
061    private static final Pattern FLAG_ALIAS_PATTERN = Pattern.compile(" -(?<name>([A-Za-z]+))");
062    private static final Pattern FLAG_PRIMARY_PATTERN = Pattern.compile(" --(?<name>([A-Za-z]+))");
063
064    /**
065     * Dummy object that indicates that flags were parsed successfully
066     */
067    public static final Object FLAG_PARSE_RESULT_OBJECT = new Object();
068    /**
069     * Meta data for the last argument that was suggested
070     *
071     * @deprecated Use {@link #FLAG_META_KEY} instead
072     */
073    @Deprecated
074    public static final String FLAG_META = "__last_flag__";
075    /**
076     * Meta data for the last argument that was suggested
077     */
078    public static final CloudKey<String> FLAG_META_KEY = SimpleCloudKey.of("__last_flag__", TypeToken.get(String.class));
079
080    private static final String FLAG_ARGUMENT_NAME = "flags";
081
082    private final Collection<@NonNull CommandFlag<?>> flags;
083
084    /**
085     * Construct a new flag argument
086     *
087     * @param flags Flags
088     */
089    public FlagArgument(final Collection<CommandFlag<?>> flags) {
090        super(
091                false,
092                FLAG_ARGUMENT_NAME,
093                new FlagArgumentParser<>(flags.toArray(new CommandFlag<?>[0])),
094                Object.class
095        );
096        this.flags = flags;
097    }
098
099    /**
100     * Get the flags registered in the argument
101     *
102     * @return Unmodifiable view of flags
103     */
104    public @NonNull Collection<@NonNull CommandFlag<?>> getFlags() {
105        return Collections.unmodifiableCollection(this.flags);
106    }
107
108
109    public static final class FlagArgumentParser<C> implements ArgumentParser<C, Object> {
110
111        private final CommandFlag<?>[] flags;
112
113        private FlagArgumentParser(final @NonNull CommandFlag<?>[] flags) {
114            this.flags = flags;
115        }
116
117        @Override
118        public @NonNull ArgumentParseResult<@NonNull Object> parse(
119                final @NonNull CommandContext<@NonNull C> commandContext,
120                final @NonNull Queue<@NonNull String> inputQueue
121        ) {
122            final FlagParser parser = new FlagParser();
123            return parser.parse(commandContext, inputQueue);
124        }
125
126        /**
127         * Parse command input to figure out what flag is currently being
128         * typed at the end of the input queue. If no flag value is being
129         * inputed, returns {@link Optional#empty()}.<br>
130         * <br>
131         * Will consume all but the last element from the input queue.
132         *
133         * @param commandContext Command context
134         * @param inputQueue The input queue of arguments
135         * @return current flag being typed, or <i>empty()</i> if none is
136         */
137        public @NonNull Optional<String> parseCurrentFlag(
138                final @NonNull CommandContext<@NonNull C> commandContext,
139                final @NonNull Queue<@NonNull String> inputQueue
140        ) {
141            /* If empty, nothing to do */
142            if (inputQueue.isEmpty()) {
143                return Optional.empty();
144            }
145
146            /* Before parsing, retrieve the last known input of the queue */
147            String lastInputValue = "";
148            for (String input : inputQueue) {
149                lastInputValue = input;
150            }
151
152            /* Parse, but ignore the result of parsing */
153            final FlagParser parser = new FlagParser();
154            parser.parse(commandContext, inputQueue);
155
156            /*
157             * Remove all but the last element from the command input queue
158             * If the parser parsed the entire queue, restore the last typed
159             * input obtained earlier.
160             */
161            if (inputQueue.isEmpty()) {
162                inputQueue.add(lastInputValue);
163            } else {
164                while (inputQueue.size() > 1) {
165                    inputQueue.remove();
166                }
167            }
168
169            /*
170             * Map to name of the flag.
171             *
172             * Note: legacy API made it that FLAG_META stores not the flag name,
173             * but the - or -- prefixed name or alias of the flag(s) instead.
174             * This can be removed in the future.
175             */
176            //return parser.currentFlagBeingParsed.map(CommandFlag::getName);
177            return parser.currentFlagNameBeingParsed;
178        }
179
180        @Override
181        @SuppressWarnings({"unchecked", "rawtypes"})
182        public @NonNull List<@NonNull String> suggestions(
183                final @NonNull CommandContext<C> commandContext,
184                final @NonNull String input
185        ) {
186            /* Check if we have a last flag stored */
187            final String lastArg = commandContext.getOrDefault(FLAG_META_KEY, "");
188            if (lastArg.isEmpty() || !lastArg.startsWith("-")) {
189                final String rawInput = commandContext.getRawInputJoined();
190                /* Collection containing all used flags */
191                final List<CommandFlag<?>> usedFlags = new LinkedList<>();
192                /* Find all "primary" flags, using --flag */
193                final Matcher primaryMatcher = FLAG_PRIMARY_PATTERN.matcher(rawInput);
194                while (primaryMatcher.find()) {
195                    final String name = primaryMatcher.group("name");
196                    for (final CommandFlag<?> flag : this.flags) {
197                        if (flag.getName().equalsIgnoreCase(name)) {
198                            usedFlags.add(flag);
199                            break;
200                        }
201                    }
202                }
203                /* Find all alias flags */
204                final Matcher aliasMatcher = FLAG_ALIAS_PATTERN.matcher(rawInput);
205                while (aliasMatcher.find()) {
206                    final String name = aliasMatcher.group("name");
207                    for (final CommandFlag<?> flag : this.flags) {
208                        for (final String alias : flag.getAliases()) {
209                            /* Aliases are single-char strings */
210                            if (name.contains(alias)) {
211                                usedFlags.add(flag);
212                                break;
213                            }
214                        }
215                    }
216                }
217                /* Suggestions */
218                final List<String> strings = new LinkedList<>();
219                /* Recommend "primary" flags */
220                for (final CommandFlag<?> flag : this.flags) {
221                    if (usedFlags.contains(flag)) {
222                        continue;
223                    }
224                    strings.add(
225                            String.format(
226                                    "--%s",
227                                    flag.getName()
228                            )
229                    );
230                }
231                /* Recommend aliases */
232                final boolean suggestCombined = input.length() > 1 && input.charAt(0) == '-' && input.charAt(1) != '-';
233                for (final CommandFlag<?> flag : this.flags) {
234                    if (usedFlags.contains(flag)) {
235                        continue;
236                    }
237                    for (final String alias : flag.getAliases()) {
238                        if (suggestCombined && flag.getCommandArgument() == null) {
239                            strings.add(
240                                    String.format(
241                                            "%s%s",
242                                            input,
243                                            alias
244                                    )
245                            );
246                        } else {
247                            strings.add(
248                                    String.format(
249                                            "-%s",
250                                            alias
251                                    )
252                            );
253                        }
254                    }
255                }
256                /* If we are suggesting the combined flag, then also suggest the current input */
257                if (suggestCombined) {
258                    strings.add(input);
259                }
260                return strings;
261            } else {
262                CommandFlag<?> currentFlag = null;
263                if (lastArg.startsWith("--")) {
264                    final String flagName = lastArg.substring(2);
265                    for (final CommandFlag<?> flag : this.flags) {
266                        if (flagName.equalsIgnoreCase(flag.getName())) {
267                            currentFlag = flag;
268                            break;
269                        }
270                    }
271                } else if (lastArg.startsWith("-")) {
272                    final String flagName = lastArg.substring(1);
273                    for (final CommandFlag<?> flag : this.flags) {
274                        for (final String alias : flag.getAliases()) {
275                            if (alias.equalsIgnoreCase(flagName)) {
276                                currentFlag = flag;
277                                break;
278                            }
279                        }
280                    }
281                }
282                if (currentFlag != null && currentFlag.getCommandArgument() != null) {
283                    return (List<String>) ((BiFunction) currentFlag.getCommandArgument().getSuggestionsProvider())
284                            .apply(commandContext, input);
285                }
286            }
287            commandContext.store(FLAG_META_KEY, "");
288            return suggestions(commandContext, input);
289        }
290
291        /**
292         * Helper class to parse the command input queue into flags
293         * and flag values. On failure the intermediate results
294         * can be obtained, which are used for providing suggestions.
295         */
296        private class FlagParser {
297            /** The current flag whose value is being parsed */
298            @SuppressWarnings("unused")
299            private Optional<CommandFlag<?>> currentFlagBeingParsed = Optional.empty();
300            /**
301             * The name of the current flag being parsed, can be obsoleted in the future.
302             * This name includes the - or -- prefix.
303             */
304            private Optional<String> currentFlagNameBeingParsed = Optional.empty();
305
306            @SuppressWarnings({"unchecked", "rawtypes"})
307            public @NonNull ArgumentParseResult<@NonNull Object> parse(
308                    final @NonNull CommandContext<@NonNull C> commandContext,
309                    final @NonNull Queue<@NonNull String> inputQueue
310            ) {
311                /*
312                This argument must necessarily be the last so we can just consume all remaining input. This argument type
313                is similar to a greedy string in that sense. But, we need to keep all flag logic contained to the parser
314                 */
315                final Set<CommandFlag<?>> parsedFlags = new HashSet<>();
316                CommandFlag<?> currentFlag = null;
317                String currentFlagName = null;
318
319                String string;
320                while ((string = inputQueue.peek()) != null) {
321                    /* No longer typing the value of the current flag */
322                    this.currentFlagBeingParsed = Optional.empty();
323                    this.currentFlagNameBeingParsed = Optional.empty();
324
325                    /* Parse next flag name to set */
326                    if (string.startsWith("-") && currentFlag == null) {
327                        /* Remove flag argument from input queue */
328                        inputQueue.poll();
329
330                        if (string.startsWith("--")) {
331                            final String flagName = string.substring(2);
332                            for (final CommandFlag<?> flag : FlagArgumentParser.this.flags) {
333                                if (flagName.equalsIgnoreCase(flag.getName())) {
334                                    currentFlag = flag;
335                                    currentFlagName = string;
336                                    break;
337                                }
338                            }
339                        } else {
340                            final String flagName = string.substring(1);
341                            if (flagName.length() > 1) {
342                                boolean oneAdded = false;
343                                /* This is a multi-alias flag, find all flags that apply */
344                                for (final CommandFlag<?> flag : FlagArgumentParser.this.flags) {
345                                    if (flag.getCommandArgument() != null) {
346                                        continue;
347                                    }
348                                    for (final String alias : flag.getAliases()) {
349                                        if (flagName.toLowerCase(Locale.ENGLISH).contains(alias.toLowerCase(Locale.ENGLISH))) {
350                                            if (parsedFlags.contains(flag)) {
351                                                return ArgumentParseResult.failure(new FlagParseException(
352                                                        string,
353                                                        FailureReason.DUPLICATE_FLAG,
354                                                        commandContext
355                                                ));
356                                            }
357                                            parsedFlags.add(flag);
358                                            commandContext.flags().addPresenceFlag(flag);
359                                            oneAdded = true;
360                                            break;
361                                        }
362                                    }
363                                }
364                                /* We need to parse at least one flag */
365                                if (!oneAdded) {
366                                    return ArgumentParseResult.failure(new FlagParseException(
367                                            string,
368                                            FailureReason.NO_FLAG_STARTED,
369                                            commandContext
370                                    ));
371                                }
372                                continue;
373                            } else {
374                                for (final CommandFlag<?> flag : FlagArgumentParser.this.flags) {
375                                    for (final String alias : flag.getAliases()) {
376                                        if (alias.equalsIgnoreCase(flagName)) {
377                                            currentFlag = flag;
378                                            currentFlagName = string;
379                                            break;
380                                        }
381                                    }
382                                }
383                            }
384                        }
385                        if (currentFlag == null) {
386                            return ArgumentParseResult.failure(new FlagParseException(
387                                    string,
388                                    FailureReason.UNKNOWN_FLAG,
389                                    commandContext
390                            ));
391                        } else if (parsedFlags.contains(currentFlag)) {
392                            return ArgumentParseResult.failure(new FlagParseException(
393                                    string,
394                                    FailureReason.DUPLICATE_FLAG,
395                                    commandContext
396                            ));
397                        }
398                        parsedFlags.add(currentFlag);
399                        if (currentFlag.getCommandArgument() == null) {
400                            /* It's a presence flag */
401                            commandContext.flags().addPresenceFlag(currentFlag);
402                            /* We don't want to parse a value for this flag */
403                            currentFlag = null;
404                        }
405                    } else {
406                        if (currentFlag == null) {
407                            return ArgumentParseResult.failure(new FlagParseException(
408                                    string,
409                                    FailureReason.NO_FLAG_STARTED,
410                                    commandContext
411                            ));
412                        } else {
413                            /* Mark this flag as the one currently being typed */
414                            this.currentFlagBeingParsed = Optional.of(currentFlag);
415                            this.currentFlagNameBeingParsed = Optional.of(currentFlagName);
416
417                            final ArgumentParseResult<?> result =
418                                    ((CommandArgument) currentFlag.getCommandArgument())
419                                            .getParser()
420                                            .parse(
421                                                    commandContext,
422                                                    inputQueue
423                                            );
424                            if (result.getFailure().isPresent()) {
425                                return ArgumentParseResult.failure(result.getFailure().get());
426                            } else if (result.getParsedValue().isPresent()) {
427                                final CommandFlag erasedFlag = currentFlag;
428                                final Object value = result.getParsedValue().get();
429                                commandContext.flags().addValueFlag(erasedFlag, value);
430                                currentFlag = null;
431                            } else {
432                                throw new IllegalStateException("Neither result or value were present. Panicking.");
433                            }
434                        }
435                    }
436                }
437
438                /* Queue ran out while a flag argument needs to be parsed still */
439                if (currentFlag != null) {
440                    return ArgumentParseResult.failure(new FlagParseException(
441                            currentFlag.getName(),
442                            FailureReason.MISSING_ARGUMENT,
443                            commandContext
444                    ));
445                }
446
447                /* We've consumed everything */
448                return ArgumentParseResult.success(FLAG_PARSE_RESULT_OBJECT);
449            }
450        }
451    }
452
453    /**
454     * Flag parse exception
455     */
456    public static final class FlagParseException extends ParserException {
457
458        private static final long serialVersionUID = -7725389394142868549L;
459        private final String input;
460
461        /**
462         * Construct a new flag parse exception
463         *
464         * @param input         Input
465         * @param failureReason The reason of failure
466         * @param context       Command context
467         */
468        public FlagParseException(
469                final @NonNull String input,
470                final @NonNull FailureReason failureReason,
471                final @NonNull CommandContext<?> context
472        ) {
473            super(
474                    FlagArgument.FlagArgumentParser.class,
475                    context,
476                    failureReason.getCaption(),
477                    CaptionVariable.of("input", input),
478                    CaptionVariable.of("flag", input)
479            );
480            this.input = input;
481        }
482
483        /**
484         * Get the supplied input
485         *
486         * @return String value
487         */
488        public String getInput() {
489            return input;
490        }
491
492    }
493
494    /**
495     * Reasons for which flag parsing may fail
496     */
497    public enum FailureReason {
498
499        UNKNOWN_FLAG(StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_FLAG_UNKNOWN_FLAG),
500        DUPLICATE_FLAG(StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_FLAG_DUPLICATE_FLAG),
501        NO_FLAG_STARTED(StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_FLAG_NO_FLAG_STARTED),
502        MISSING_ARGUMENT(StandardCaptionKeys.ARGUMENT_PARSE_FAILURE_FLAG_MISSING_ARGUMENT);
503
504        private final Caption caption;
505
506        FailureReason(final @NonNull Caption caption) {
507            this.caption = caption;
508        }
509
510        /**
511         * Get the caption used for this failure reason
512         *
513         * @return The caption
514         */
515        public @NonNull Caption getCaption() {
516            return this.caption;
517        }
518
519    }
520
521}