diff --git a/src/main/java/seedu/address/commons/util/StringUtil.java b/src/main/java/seedu/address/commons/util/StringUtil.java index 675f7439965..eaf78a2c5a5 100644 --- a/src/main/java/seedu/address/commons/util/StringUtil.java +++ b/src/main/java/seedu/address/commons/util/StringUtil.java @@ -86,4 +86,78 @@ public static String formatWithNullFallback(String format, Object... values) { return String.format(format, values); } + + /** + * Returns true if the inputString is a fuzzy match of the targetString, + * false otherwise. + * + *

+ * A fuzzy search is an approximate search algorithm. This implementation computes a fuzzy match by determining + * if there exists a subsequence match in linear time. + *

+ * + *

+ * As an example, "abc" is considered to be a fuzzy match of "aa1b2ccc", since one may + * construct the subsequence "abc" by removing extra characters "a1", "2cc" + * from aa1b2ccc. + *

+ * + * @param inputString The partial fuzzy input string. + * @param targetString The target string to check if the input fuzzily matches with. + * @return true if the input fuzzy-matches (is fuzzily contained in) the target, false otherwise. + */ + public static boolean isFuzzyMatch(String inputString, String targetString) { + if (inputString == null || targetString == null) { + return inputString == null && targetString == null; // both null => true, otherwise false + } + + int inputI = 0; + int targetI = 0; + + while (inputI < inputString.length() && targetI < targetString.length()) { + char c = inputString.charAt(inputI); + char t = targetString.charAt(targetI); + + if (c == t) { + inputI++; + } + targetI++; + } + + return inputI >= inputString.length(); + } + + /** + * Returns a score representing how close it is to matching characters at the beginning of the target. + * The higher the value, the better it is. A failed match will have {@code -targetString.length()}, while + * a complete match will have {@code inputString.length()}. + *

+ */ + public static int getFuzzyMatchScore(String inputString, String targetString) { + if (inputString == null || targetString == null) { + return 0; + } + + int inputI = 0; + int targetI = 0; + + int errorCount = 0; + + while (inputI < inputString.length() && targetI < targetString.length()) { + char c = inputString.charAt(inputI); + char t = targetString.charAt(targetI); + + if (c == t) { + errorCount += targetI - inputI - errorCount; + inputI++; + } + targetI++; + } + + if (inputI < inputString.length()) { + return -targetString.length(); + } + + return inputI - errorCount; + } } diff --git a/src/main/java/seedu/address/logic/Logic.java b/src/main/java/seedu/address/logic/Logic.java index d7262dbdd56..430c98f41b2 100644 --- a/src/main/java/seedu/address/logic/Logic.java +++ b/src/main/java/seedu/address/logic/Logic.java @@ -1,6 +1,7 @@ package seedu.address.logic; import java.nio.file.Path; +import java.util.stream.Stream; import javafx.collections.ObservableList; import seedu.address.commons.core.GuiSettings; @@ -23,6 +24,13 @@ public interface Logic { */ CommandResult execute(String commandText) throws CommandException, ParseException; + /** + * Parses the command and returns any autocompletion results. + * @param commandText The command as entered by the user. + * @return the result of the command execution. + */ + Stream generateCompletions(String commandText); + /** * Returns the AddressBook. * diff --git a/src/main/java/seedu/address/logic/LogicManager.java b/src/main/java/seedu/address/logic/LogicManager.java index de120e152b9..7cbd4b7a3c1 100644 --- a/src/main/java/seedu/address/logic/LogicManager.java +++ b/src/main/java/seedu/address/logic/LogicManager.java @@ -4,6 +4,7 @@ import java.nio.file.AccessDeniedException; import java.nio.file.Path; import java.util.logging.Logger; +import java.util.stream.Stream; import javafx.collections.ObservableList; import seedu.address.commons.core.GuiSettings; @@ -11,7 +12,7 @@ import seedu.address.logic.commands.Command; import seedu.address.logic.commands.CommandResult; import seedu.address.logic.commands.exceptions.CommandException; -import seedu.address.logic.parser.AddressBookParser; +import seedu.address.logic.parser.AppParser; import seedu.address.logic.parser.exceptions.ParseException; import seedu.address.model.Model; import seedu.address.model.ReadOnlyAddressBook; @@ -31,7 +32,7 @@ public class LogicManager implements Logic { private final Model model; private final Storage storage; - private final AddressBookParser addressBookParser; + private final AppParser appParser; /** * Constructs a {@code LogicManager} with the given {@code Model} and {@code Storage}. @@ -39,7 +40,7 @@ public class LogicManager implements Logic { public LogicManager(Model model, Storage storage) { this.model = model; this.storage = storage; - addressBookParser = new AddressBookParser(); + appParser = new AppParser(); } @Override @@ -47,7 +48,7 @@ public CommandResult execute(String commandText) throws CommandException, ParseE logger.info("----------------[USER COMMAND][" + commandText + "]"); CommandResult commandResult; - Command command = addressBookParser.parseCommand(commandText); + Command command = appParser.parseCommand(commandText); commandResult = command.execute(model); try { @@ -61,6 +62,13 @@ public CommandResult execute(String commandText) throws CommandException, ParseE return commandResult; } + @Override + public Stream generateCompletions(String commandText) { + return appParser + .parseCompletionGenerator(commandText) + .generateCompletions(commandText, model); + } + @Override public ReadOnlyAddressBook getAddressBook() { return model.getAddressBook(); diff --git a/src/main/java/seedu/address/logic/autocomplete/AutocompleteGenerator.java b/src/main/java/seedu/address/logic/autocomplete/AutocompleteGenerator.java new file mode 100644 index 00000000000..5ac24d9d05b --- /dev/null +++ b/src/main/java/seedu/address/logic/autocomplete/AutocompleteGenerator.java @@ -0,0 +1,167 @@ +package seedu.address.logic.autocomplete; + +import java.util.Comparator; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import seedu.address.commons.util.StringUtil; +import seedu.address.logic.parser.Flag; +import seedu.address.model.Model; + +/** + * Creates a generator based on the given supplier or reference commands, so as it can generate + * auto-completions when requested. + */ +public class AutocompleteGenerator { + + /** An autocompletion generator that generates no results. */ + public static final AutocompleteGenerator NO_RESULTS = new AutocompleteGenerator(Stream::empty); + + + /** A comparator used to order fuzzily matched strings where better matches against the input go first. */ + private static final Function> TEXT_FUZZY_MATCH_COMPARATOR = (input) -> (s1, s2) -> { + // Get how well s1 is ahead of s2 (note: higher is better). + int score = StringUtil.getFuzzyMatchScore(input, s1) - StringUtil.getFuzzyMatchScore(input, s2); + + return -score; // -ve implies s1 < s2 + }; + + /** A comparator used to order fuzzily matched flags where better matches against the input go first. */ + private static final Function> FLAG_FUZZY_MATCH_COMPARATOR = (input) -> (f1, f2) -> { + // Get how well f1 is ahead of f2 in both metrics (note: higher is better). + int score = Math.max( + StringUtil.getFuzzyMatchScore(input, f1.getFlagString()), + StringUtil.getFuzzyMatchScore(input, f1.getFlagAliasString()) + ) - Math.max( + StringUtil.getFuzzyMatchScore(input, f2.getFlagString()), + StringUtil.getFuzzyMatchScore(input, f2.getFlagAliasString()) + ); + + return -score; // -ve implies f1 < f2 + }; + + + + /** The cached instance of the result evaluation function. */ + private final BiFunction> resultEvaluationFunction; + + /** + * Constructs an autocomplete generator based on the given set of reference full command strings. + */ + public AutocompleteGenerator(Stream referenceCommands) { + this(() -> referenceCommands); + } + + /** + * Constructs an autocomplete generator based on the given supplier of full command strings. + */ + public AutocompleteGenerator(Supplier> referenceCommandsSupplier) { + resultEvaluationFunction = (partialCommand, model) -> { + if (partialCommand == null) { + return Stream.empty(); + } + + return referenceCommandsSupplier.get() + .filter(term -> StringUtil.isFuzzyMatch(partialCommand, term)) + .sorted(TEXT_FUZZY_MATCH_COMPARATOR.apply(partialCommand)) + .distinct(); + }; + } + + /** + * Constructs an autocomplete generator based on the given {@link AutocompleteSupplier}. + */ + public AutocompleteGenerator(AutocompleteSupplier supplier) { + resultEvaluationFunction = (partialCommand, model) -> { + + PartitionedCommand command = new PartitionedCommand(partialCommand == null ? "" : partialCommand); + String trailingText = command.getTrailingText(); + + // Compute the possible flags and flag-values. + Stream possibleFlags = getPossibleFlags(command, supplier) + .filter(flag -> StringUtil.isFuzzyMatch(trailingText, flag.getFlagString()) + || StringUtil.isFuzzyMatch(trailingText, flag.getFlagAliasString())) + .sorted(FLAG_FUZZY_MATCH_COMPARATOR.apply(trailingText)) + .map(Flag::getFlagString); + + Stream possibleValues = getPossibleValues(command, supplier, model) + .map(s -> s.filter(term -> StringUtil.isFuzzyMatch(trailingText, term)) + .sorted(TEXT_FUZZY_MATCH_COMPARATOR.apply(trailingText))) + .orElse(possibleFlags); + + // Decide which stream to use based on whether it's of a flag syntax or not. + Stream possibleTerminalValues; + if (command.hasFlagSyntaxPrefixInTrailingText()) { + possibleTerminalValues = possibleFlags; + } else { + possibleTerminalValues = possibleValues; + } + + // Return the results as a full completion string, distinct. + return possibleTerminalValues + .map(command::toStringWithNewTrailingTerm) + .distinct(); + }; + } + + /** + * Generates a stream of completions based on the partial command given and no model. + * Note that omitting the model may limit the ability to return useful results. + */ + public Stream generateCompletions(String command) { + return resultEvaluationFunction.apply(command, null); + } + + /** + * Generates a stream of completions based on the partial command given and the model. + */ + public Stream generateCompletions(String command, Model model) { + return resultEvaluationFunction.apply(command, model); + } + + + + + /** + * Obtains the set of possible flags based on the partitioned command and supplier. + */ + private static Stream getPossibleFlags( + PartitionedCommand command, + AutocompleteSupplier supplier + ) { + Flag[] allPossibleFlags = supplier.getAllPossibleFlags().toArray(Flag[]::new); + + Set existingCommandFlags = command.getConfirmedFlagStrings() + .stream() + .map(flagStr -> Flag.findMatch(flagStr, allPossibleFlags)) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + + + return supplier.getOtherPossibleFlagsAsideFromFlagsPresent(existingCommandFlags).stream(); + } + + /** + * Obtains the optional set of possible values based on the partitioned command, supplier, and model. + * If this optional is empty, that means it is explicitly specified that the flag cannot accept values. + */ + private static Optional> getPossibleValues( + PartitionedCommand command, + AutocompleteSupplier supplier, + Model model + ) { + return supplier.getValidValues( + Flag.parseOptional( + command.getLastConfirmedFlagString().orElse(null) + ).orElse(null), + model + ); + } + +} diff --git a/src/main/java/seedu/address/logic/autocomplete/AutocompleteSupplier.java b/src/main/java/seedu/address/logic/autocomplete/AutocompleteSupplier.java new file mode 100644 index 00000000000..820109a3441 --- /dev/null +++ b/src/main/java/seedu/address/logic/autocomplete/AutocompleteSupplier.java @@ -0,0 +1,145 @@ +package seedu.address.logic.autocomplete; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import seedu.address.logic.autocomplete.data.AutocompleteDataSet; +import seedu.address.logic.parser.Flag; +import seedu.address.model.Model; + +/** + * Supplies autocompletion details for arbitrary commands. + */ +public class AutocompleteSupplier { + + private final AutocompleteDataSet flags; + private final Map values; + + /** + * Constructs an autocomplete supplier that is capable of supplying useful details for autocompleting some command. + * The ordering supplied via the parameters are followed when generating results. + * + * @param flags The set of flags that should be used as part of the autocomplete results. + * @param values A map of auto-completable values for each flag that may be obtained via a model. + */ + public AutocompleteSupplier( + AutocompleteDataSet flags, + Map values + ) { + // Create new copies to prevent external modification. + this.flags = flags.copy(); + this.values = new LinkedHashMap<>(values); + } + + /** + * Constructs an autocomplete supplier that is capable of supplying useful details for autocompleting some command. + * The ordering supplied via the parameters are followed when generating results. + * + * @param flags The set of flags that should be used as part of the autocomplete results. + * + * @see #AutocompleteSupplier(AutocompleteDataSet, Map) + */ + public static AutocompleteSupplier from(AutocompleteDataSet flags) { + return new AutocompleteSupplier(flags, Map.of()); + } + + /** + * Constructs an autocomplete supplier that is capable of supplying useful details for autocompleting some command. + * The ordering supplied via the parameters are followed when generating results. + * + * @param flagSets The sets of flags that should be used together as part of the autocomplete results. + */ + @SafeVarargs + public static AutocompleteSupplier from(AutocompleteDataSet... flagSets) { + return from(AutocompleteDataSet.concat(flagSets)); + } + + /** + * Constructs an autocomplete supplier that is capable of supplying all the unique flags (flags which may appear + * at most once) for autocompleting some command. + * The ordering supplied via the parameters are followed when generating results. + * + * @param uniqueFlags A set of flags that each may appear at most once in the command. + */ + public static AutocompleteSupplier fromUniqueFlags(Flag... uniqueFlags) { + return AutocompleteSupplier.from( + AutocompleteDataSet.onceForEachOf(uniqueFlags) + ); + } + + /** + * Constructs an autocomplete supplier that is capable of supplying all the repeatable flags (flags which may + * appear any number of times in the command) for autocompleting some command. + * The ordering supplied via the parameters are followed when generating results. + * + * @param repeatableFlags A set of flags that may appear at any point in the command any number of times. + */ + public static AutocompleteSupplier fromRepeatableFlags(Flag... repeatableFlags) { + return AutocompleteSupplier.from( + AutocompleteDataSet.anyNumberOf(repeatableFlags) + ); + } + + /** + * Returns a set of all possible flags that can be used in the command. + * The set has predictable iteration order: it follows the ordering supplied via the original inputs. + */ + public Set getAllPossibleFlags() { + return flags.copy(); + } + + /** + * Returns a set of other possible flags given the list of flags that are already present in the command. + * If there are conflicting constraints specified, this will use the tightest possible constraint. + * The set has predictable iteration order: it follows the ordering supplied via the original inputs. + */ + public Set getOtherPossibleFlagsAsideFromFlagsPresent(Set flagsPresent) { + return flags.getElementsAfterConsuming(flagsPresent); + } + + /** + * Returns an optional stream of possible values for a flag when computed against a given model. + * If this optional is empty, then this flag is explicitly specified to not have any values, + * and not just the lack of completion suggestions. + * + * @param flag The flag to check against. This may be null to represent the preamble. + * @param model The model to be supplied for generation. This may be null if model-data is not essential + * for any purpose. + */ + public Optional> getValidValues(Flag flag, Model model) { + try { + return Optional.ofNullable( + this.values.getOrDefault(flag, m -> Stream.of()) + ).map(x -> x.apply(model)); + + } catch (RuntimeException e) { + // Guard against errors like NPEs due to supplied lambdas not handling them. + e.printStackTrace(); + // We simply return that we don't know what to auto-complete by. + return Optional.of(Stream.of()); + } + } + + /** + * Configures the set of flags within this autocomplete supplier using the given {@code operator}. + * This also returns {@code this} instance, which is useful for chaining. + */ + public AutocompleteSupplier configureFlagSet(Consumer> operator) { + operator.accept(this.flags); + return this; + } + + /** + * Configures the map of values within this autocomplete supplier using the given {@code operator}. + * This also returns {@code this} instance, which is useful for chaining. + */ + public AutocompleteSupplier configureValueMap(Consumer> operator) { + operator.accept(this.values); + return this; + } + +} diff --git a/src/main/java/seedu/address/logic/autocomplete/FlagValueSupplier.java b/src/main/java/seedu/address/logic/autocomplete/FlagValueSupplier.java new file mode 100644 index 00000000000..6786a18d8d5 --- /dev/null +++ b/src/main/java/seedu/address/logic/autocomplete/FlagValueSupplier.java @@ -0,0 +1,26 @@ +package seedu.address.logic.autocomplete; + + +import java.util.function.Function; +import java.util.stream.Stream; + +import seedu.address.model.Model; + +/** + * Supplies a list of values for auto-completing a flag's value, given the model. + * + *

+ * Both the input and output {@link Model} may be null. + *

+ *
    + *
  • If the model is available, use the available information to best compute a stream of values.
  • + *
  • If the model is null, make do with the available information to return the result.
  • + *
+ *
    + *
  • If there are no known values to complete, return an empty list.
  • + *
  • If there are no values to consider at all (e.g., flag doesn't accept values), return null.
  • + *
+ * */ +public interface FlagValueSupplier extends Function> { + // Inherited implementation. +} diff --git a/src/main/java/seedu/address/logic/autocomplete/PartitionedCommand.java b/src/main/java/seedu/address/logic/autocomplete/PartitionedCommand.java new file mode 100644 index 00000000000..38be22ffe28 --- /dev/null +++ b/src/main/java/seedu/address/logic/autocomplete/PartitionedCommand.java @@ -0,0 +1,176 @@ +package seedu.address.logic.autocomplete; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import seedu.address.logic.parser.Flag; + +/** + * A wrapper around a command string as partitions, useful for computing results for autocompletion. + */ +class PartitionedCommand { + private final String name; + private final String middleText; + private final String trailingText; + + private final boolean hasFlagSyntaxPrefixInTrailingText; + private final List confirmedFlagStrings; + + /** + * Initializes and prepares the given command as distinct partitions. + */ + PartitionedCommand(String partialCommand) { + List words = List.of(partialCommand.split(" ", -1)); // -1 stops stripping adjacent spaces. + + if (words.size() <= 1) { + this.name = ""; + this.middleText = ""; + this.trailingText = words.isEmpty() ? "" : words.get(0); + this.hasFlagSyntaxPrefixInTrailingText = false; + this.confirmedFlagStrings = List.of(); + return; + } + + // Compute whether the trailing term is likely a flag part. + this.hasFlagSyntaxPrefixInTrailingText = + words.get(words.size() - 1).startsWith(Flag.DEFAULT_PREFIX) + || words.get(words.size() - 1).startsWith(Flag.DEFAULT_ALIAS_PREFIX); + + // Compute all matched flags. + this.confirmedFlagStrings = words.subList(1, words.size() - 1) + .stream() + .filter(Flag::isFlagSyntax) + .collect(Collectors.toUnmodifiableList()); + + // Compute rightmost index of flag. + int lastKnownFlagIndex = words.size() - 1; + while (lastKnownFlagIndex > 0) { + if (Flag.isFlagSyntax(words.get(lastKnownFlagIndex))) { + break; + } + lastKnownFlagIndex--; + } + + // Compute trailing text's start index. It must be at least 1 (i.e., after the command name). + // + // - if the rightmost term is possibly a new flag, the trailing text should be that one. + // e.g., "name --flag1 abc def --fl" should be split to ("name", "--flag abc def", "--fl") + // + // - otherwise, it should be the text beyond the last known flag location. + // e.g., "name --flag1 abc def" should be split to ("name", "--flag", "abc def") + int trailingTextStartIndex = Math.max( + 1, + this.hasFlagSyntaxPrefixInTrailingText + ? words.size() - 1 + : lastKnownFlagIndex + 1 + ); + + // Compute the name, middle, trailing parts by splicing the appropriate ranges. + this.name = words.get(0); + this.middleText = String.join(" ", words.subList(1, trailingTextStartIndex)); + this.trailingText = String.join(" ", words.subList(trailingTextStartIndex, words.size())); + } + + /** + * Gets the command name. + */ + public String getName() { + return name; + } + + /** + * Gets the leading text. + * It is the front part of the command that should not be modified as part of autocompletion. + */ + public String getLeadingText() { + return String.join(" ", List.of(name, middleText)); + } + + /** + * Gets the middle text. + * It is the part after name but before leadingText. + */ + public String getMiddleText() { + return middleText; + } + + /** + * Gets the leading text. + * It is the part of the text that may be autocompleted, either by extending or replacing it. + */ + public String getTrailingText() { + return trailingText; + } + + /** + * Gets the rightmost flag string detected in this command. + */ + public Optional getLastConfirmedFlagString() { + return confirmedFlagStrings.isEmpty() + ? Optional.empty() + : Optional.of(confirmedFlagStrings.get(confirmedFlagStrings.size() - 1)); + } + + /** + * Gets all flags detected in this command. + */ + public List getConfirmedFlagStrings() { + return this.confirmedFlagStrings; + } + + /** + * Returns true if the trailing text seems to have signs of a flag prefix, false otherwise. + */ + public boolean hasFlagSyntaxPrefixInTrailingText() { + return this.hasFlagSyntaxPrefixInTrailingText; + } + + /** + * Returns the current command as a string, but with the trailing term replaced. + * This is useful to generate an autocompleted result. + */ + public String toStringWithNewTrailingTerm(String newTrailingTerm) { + return Stream.of(name, middleText, newTrailingTerm) + .filter(s -> !s.isEmpty()) + .collect(Collectors.joining(" ")) + .trim(); + } + + /** + * Returns the current command as a string. + * This is usually equivalent to the original input string. + */ + @Override + public String toString() { + return Stream.of(name, middleText, trailingText) + .filter(s -> !s.isEmpty()) + .collect(Collectors.joining(" ")) + .trim(); + } + + /** + * Returns true if the command partitions for this and the other command are equal, false otherwise. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + seedu.address.logic.autocomplete.PartitionedCommand + other = (seedu.address.logic.autocomplete.PartitionedCommand) o; + return Objects.equals(name, other.name) + && Objects.equals(middleText, other.middleText) + && Objects.equals(trailingText, other.trailingText); + } + + @Override + public int hashCode() { + return Objects.hash(name, middleText, trailingText); + } +} diff --git a/src/main/java/seedu/address/logic/autocomplete/data/AutocompleteConstraint.java b/src/main/java/seedu/address/logic/autocomplete/data/AutocompleteConstraint.java new file mode 100644 index 00000000000..702ea3f9890 --- /dev/null +++ b/src/main/java/seedu/address/logic/autocomplete/data/AutocompleteConstraint.java @@ -0,0 +1,133 @@ +package seedu.address.logic.autocomplete.data; + +import java.util.Collection; +import java.util.Set; + +/** + * Constraints autocompletion to a certain restriction. + * This functional interface takes in an input and a {@link Set} of existing elements, + * and determines whether the input should be allowed to be added among the existing elements. + */ +@FunctionalInterface +public interface AutocompleteConstraint { + + boolean isAllowed(T input, Set existingElements); + + // Constraint operators + + /** + * Creates a constraint that returns true as long as any given constraints return true. + */ + static AutocompleteConstraint anyOf(Collection> constraints) { + return (input, existingElements) -> { + for (var c : constraints) { + if (c.isAllowed(input, existingElements)) { + return true; + } + } + return false; + }; + } + + /** + * Creates a constraint that returns true as long as all given constraints return true. + */ + static AutocompleteConstraint allOf(Collection> constraints) { + return (input, existingElements) -> { + for (var c : constraints) { + if (!c.isAllowed(input, existingElements)) { + return false; + } + } + return true; + }; + } + + // Simple constraint templates + + /** + * Creates a constraint that enforces all provided {@code items} each may exist at most once. + */ + @SafeVarargs + static AutocompleteConstraint onceForEachOf(T... items) { + Set itemsSet = Set.of(items); + + return (input, existingElements) -> { + if (!itemsSet.contains(input)) { + // Not part of consideration. True by default. + return true; + } + return !existingElements.contains(input); // Input does not exists <--> input can exist. + }; + } + + /** + * Creates a constraint that enforces at most one item within the entire {@code items} may exist at a time. + */ + @SafeVarargs + static AutocompleteConstraint oneAmongAllOf(T... items) { + Set itemsSet = Set.of(items); + + return (input, existingElements) -> { + if (!itemsSet.contains(input)) { + // Not part of consideration. True by default. + return true; + } + + for (T item : items) { + if (existingElements.contains(item)) { + // Some set element is already present -> no more allowed. + return false; + } + } + + return true; + }; + } + + // Advanced relational constraint templates + + /** + * Represents an item that will be part of the constraint. + * This exists to improve readability of relational factory methods, + * along the lines of {@code .where(x).isSomethingTo(y...)} + */ + class Item { + + final T item; + + private Item(T item) { + this.item = item; + } + + /** + * Creates a constraint that enforces that {@code prerequisite}, i.e. this item, + * must be present before any of {@code dependents} may exist. + */ + @SafeVarargs + public final AutocompleteConstraint isPrerequisiteFor(T... dependents) { + T prerequisite = this.item; + + Set dependentsSet = Set.of(dependents); + + return (input, existingElements) -> { + if (!dependentsSet.contains(input)) { + // Not part of dependents. True by default. + return true; + } + return existingElements.contains(prerequisite); // Prerequisite exists <--> dependents can exist. + }; + } + } + + /** + * Represents an item that is a part of a constraint relationship. + * This should be immediately followed by the relevant constraint methods, + * along the lines of {@code .where(x).isSomethingTo(y...)} + */ + static Item where(T item) { + return new Item(item); + } + + +} diff --git a/src/main/java/seedu/address/logic/autocomplete/data/AutocompleteDataSet.java b/src/main/java/seedu/address/logic/autocomplete/data/AutocompleteDataSet.java new file mode 100644 index 00000000000..a70ddabf118 --- /dev/null +++ b/src/main/java/seedu/address/logic/autocomplete/data/AutocompleteDataSet.java @@ -0,0 +1,277 @@ +package seedu.address.logic.autocomplete.data; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A set of items that can be used for autocompletion with any corresponding constraints on them. + * + *

+ * Suppose a command already has specific items, and some items have restrictions such as being only able to + * be used once. Then, one may specify the constraints when setting up this set, and then any time determine + * what items are still available for autocompletion by passing items already used into + * {@link #getElementsAfterConsuming(Set)}. + *

+ * + *

+ * The convenience factory methods {@link #onceForEachOf}, {@link #oneAmongAllOf} and {@link #anyNumberOf} + * may be useful to quickly create a set of items with these common constraints, while convenience methods like + * {@link #concat}, {@link #addDependents}, {@link #addConstraints} can be used for chaining and composing rules. + *

+ * + *

+ * This is a subclass of {@link LinkedHashSet}, so it inherits all its properties like its set functions and + * preservation of insertion order. + *

+ */ +public final class AutocompleteDataSet extends LinkedHashSet { + + private final Set> constraints = new LinkedHashSet<>(); + + /** + * Creates an empty {@link AutocompleteDataSet}. + */ + public AutocompleteDataSet() { + super(); + } + + /** + * Creates a {@link AutocompleteDataSet} with the given elements and constraints. + * + *

+ * This is mainly useful if your rules are complex. Otherwise, the convenience factory + * methods {@link #onceForEachOf}, {@link #oneAmongAllOf} and {@link #anyNumberOf} + * may be useful to quickly create an instance with these common constraints. + *

+ * + * @param collection The collection of items. + * @param constraints The constraints for the given items. + * + * @see #onceForEachOf + * @see #oneAmongAllOf + * @see #anyNumberOf + */ + private AutocompleteDataSet( + Collection collection, + Collection> constraints + ) { + super(collection); + this.constraints.addAll(constraints); + } + + /** + * Creates an {@link AutocompleteDataSet} with the given elements, + * and the constraint that each given element may exist at most once in a command. + */ + @SafeVarargs + public static AutocompleteDataSet onceForEachOf(T... items) { + return new AutocompleteDataSet( + List.of(items), + List.of(AutocompleteConstraint.onceForEachOf(items)) + ); + } + + /** + * Creates an {@link AutocompleteDataSet} with the given elements, + * and the constraint that only one of the given elements may exist in a command. + */ + @SafeVarargs + public static AutocompleteDataSet oneAmongAllOf(T... items) { + return new AutocompleteDataSet( + List.of(items), + List.of(AutocompleteConstraint.oneAmongAllOf(items)) + ); + } + + /** + * Creates an {@link AutocompleteDataSet} with the given elements, + * and the constraint that all given elements may exist any number of times in a command. + */ + @SafeVarargs + public static AutocompleteDataSet anyNumberOf(T... items) { + return new AutocompleteDataSet( + List.of(items), + List.of() + ); + } + + /** + * Concatenates all provided {@link AutocompleteDataSet}s. + */ + @SafeVarargs + public static AutocompleteDataSet concat(AutocompleteDataSet... sets) { + return new AutocompleteDataSet( + Arrays.stream(sets).flatMap(Collection::stream).collect(Collectors.toList()), + Arrays.stream(sets).flatMap(s -> s.constraints.stream()).collect(Collectors.toList()) + ); + } + + /** + * Returns a copy of the current instance. + */ + public AutocompleteDataSet copy() { + return new AutocompleteDataSet(this, constraints); + } + + + + /** + * Updates the current set to include {@code dependencies}, with the condition that + * "some element in {@code this} current set exists" is a prerequisite for + * elements in {@code dependencies} to exist in a command. + */ + @SafeVarargs + public final AutocompleteDataSet addDependents(AutocompleteDataSet... dependencies) { + + AutocompleteDataSet mergedDependencies = AutocompleteDataSet.concat(dependencies); + + // Create a dependency array + // - The unchecked cast is required for generics since generic arrays cannot be made. + @SuppressWarnings("unchecked") + T[] dependencyArray = mergedDependencies.toArray((T[]) new Object[mergedDependencies.size()]); + + // Add the constraints to enforce dependency relationship + this.addConstraint(AutocompleteConstraint.anyOf(this.stream() + .map(item -> AutocompleteConstraint.where(item).isPrerequisiteFor(dependencyArray)) + .collect(Collectors.toList()) + )); + + // Once done, add all elements and constraints, ordered after existing elements in the set. + this.addElements(mergedDependencies.getElements()); + this.addConstraints(mergedDependencies.getConstraints()); + + return this; + } + + + /** + * Adds a given constraint to the evaluation rules. + * + * @param constraint The constraint to add. + * @return A reference to {@code this} instance, useful for chaining. + */ + public AutocompleteDataSet addConstraint(AutocompleteConstraint constraint) { + this.constraints.add(constraint); + return this; + } + + /** + * Removes a given constraint from the evaluation rules. + * + * @param constraint The constraint to remove. + * @return A reference to {@code this} instance, useful for chaining. + */ + public AutocompleteDataSet removeConstraint(AutocompleteConstraint constraint) { + this.constraints.remove(constraint); + return this; + } + + /** + * Adds a given collection of constraints to the evaluation rules. + * + * @param constraints The constraints to add. + * @return A reference to {@code this} instance, useful for chaining. + */ + public AutocompleteDataSet addConstraints(Collection> constraints) { + this.constraints.addAll(constraints); + return this; + } + + /** + * Removes a given collection of constraints from the evaluation rules. + * + * @param constraints The constraints to remove. + * @return A reference to {@code this} instance, useful for chaining. + */ + public AutocompleteDataSet removeConstraints(Collection> constraints) { + this.constraints.removeAll(constraints); + return this; + } + + /** + * Returns the current set of constraints applied for evaluating possible auto-completions. + */ + public Set> getConstraints() { + return Collections.unmodifiableSet(constraints); + } + + + + /** + * Adds the item to the set. + * Equivalent to {@link #add}, but returns {@code this}, so is useful for chaining. + */ + public AutocompleteDataSet addElement(T e) { + this.add(e); + return this; + } + + /** + * Removes the item from the set. + * Equivalent to {@link #remove}, but returns {@code this}, so is useful for chaining. + */ + public AutocompleteDataSet removeElement(T e) { + this.remove(e); + return this; + } + + /** + * Adds the items to the set. + * Equivalent to {@link #addAll}, but returns {@code this}, so is useful for chaining. + */ + public AutocompleteDataSet addElements(Collection e) { + this.addAll(e); + return this; + } + + /** + * Removes the items from the set. + * Equivalent to {@link #removeAll}, but returns {@code this}, so is useful for chaining. + */ + public AutocompleteDataSet removeElements(Collection e) { + this.removeAll(e); + return this; + } + + /** + * Returns the elements in this instance in a new {@link Set} instance. + * Properties like iteration order are preserved. + */ + public Set getElements() { + return new LinkedHashSet<>(this); + } + + + /** + * Returns the elements remaining by supposing {@code existingElements} are present and applying all + * configured {@link AutocompleteConstraint}s, as a new {@link Set} instance. + */ + public Set getElementsAfterConsuming(Set existingElements) { + Set resultsSet = new LinkedHashSet<>(); + for (T item: this) { + if (constraints.stream().allMatch(c -> c.isAllowed(item, existingElements))) { + resultsSet.add(item); + } + } + return resultsSet; + } + + + @Override + public boolean equals(Object o) { + + // instanceof checks null implicitly. + if (!(o instanceof AutocompleteDataSet)) { + return false; + } + + AutocompleteDataSet otherSet = (AutocompleteDataSet) o; + + return super.equals(o) && this.constraints.equals(otherSet.constraints); + } +} diff --git a/src/main/java/seedu/address/logic/commands/AddCommand.java b/src/main/java/seedu/address/logic/commands/AddCommand.java index 8669419412a..f3a57a921ab 100644 --- a/src/main/java/seedu/address/logic/commands/AddCommand.java +++ b/src/main/java/seedu/address/logic/commands/AddCommand.java @@ -16,6 +16,7 @@ import static seedu.address.logic.parser.CliSyntax.FLAG_URL; import java.util.HashSet; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.logging.Logger; @@ -23,6 +24,9 @@ import seedu.address.commons.core.LogsCenter; import seedu.address.commons.util.ToStringBuilder; import seedu.address.logic.Messages; +import seedu.address.logic.autocomplete.AutocompleteSupplier; +import seedu.address.logic.autocomplete.data.AutocompleteConstraint; +import seedu.address.logic.autocomplete.data.AutocompleteDataSet; import seedu.address.logic.commands.exceptions.CommandException; import seedu.address.model.Model; import seedu.address.model.contact.Address; @@ -31,6 +35,7 @@ import seedu.address.model.contact.Id; import seedu.address.model.contact.Name; import seedu.address.model.contact.Phone; +import seedu.address.model.contact.Type; import seedu.address.model.contact.Url; import seedu.address.model.tag.Tag; @@ -41,6 +46,38 @@ public class AddCommand extends Command { public static final String COMMAND_WORD = "add"; + public static final AutocompleteSupplier AUTOCOMPLETE_SUPPLIER = AutocompleteSupplier.from( + AutocompleteDataSet.oneAmongAllOf( + FLAG_ORGANIZATION, FLAG_RECRUITER + ).addDependents( + AutocompleteDataSet.onceForEachOf( + FLAG_NAME, FLAG_ID, + FLAG_PHONE, FLAG_EMAIL, FLAG_ADDRESS, FLAG_URL, + FLAG_STATUS, FLAG_POSITION, + FLAG_ORGANIZATION_ID + ), + AutocompleteDataSet.anyNumberOf(FLAG_TAG) + ).addConstraints(List.of( + AutocompleteConstraint.where(FLAG_ORGANIZATION) + .isPrerequisiteFor(FLAG_STATUS, FLAG_POSITION), + AutocompleteConstraint.where(FLAG_RECRUITER) + .isPrerequisiteFor(FLAG_ORGANIZATION_ID) + )) + ).configureValueMap(m -> { + // Add value autocompletion for: + m.put(FLAG_ORGANIZATION_ID, + model -> model.getAddressBook().getContactList().stream() + .filter(c -> c.getType() == Type.ORGANIZATION) + .map(o -> o.getId().value) + ); + + // Disable value autocompletion for: + m.put(null /* preamble */, null); + m.put(FLAG_ORGANIZATION, null); + m.put(FLAG_RECRUITER, null); + }); + + public static final String MESSAGE_ORGANIZATION_USAGE = "Adds an organization. " + "Parameters: " + FLAG_ORGANIZATION + " " diff --git a/src/main/java/seedu/address/logic/commands/Command.java b/src/main/java/seedu/address/logic/commands/Command.java index 64f18992160..f33530953f3 100644 --- a/src/main/java/seedu/address/logic/commands/Command.java +++ b/src/main/java/seedu/address/logic/commands/Command.java @@ -1,5 +1,13 @@ package seedu.address.logic.commands; +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import seedu.address.logic.autocomplete.AutocompleteSupplier; import seedu.address.logic.commands.exceptions.CommandException; import seedu.address.model.Model; @@ -17,4 +25,75 @@ public abstract class Command { */ public abstract CommandResult execute(Model model) throws CommandException; + + + /** + * Obtains the {@code AUTOCOMPLETE_SUPPLIER} which can supply autocompletion results for a given command. + * + * @param cls The subclass of {@link Command} to obtain the autocomplete supplier for. + * @return The autocomplete supplier as an optional. + */ + public static Optional getAutocompleteSupplier(Class cls) { + AutocompleteSupplier supplier = null; + try { + Field field = cls.getDeclaredField("AUTOCOMPLETE_SUPPLIER"); + Object data = field.get(cls); + if (data instanceof AutocompleteSupplier) { + supplier = (AutocompleteSupplier) data; + } + + } catch (NoSuchFieldException | IllegalAccessException e) { + // No available autocompletion results... too bad. + } + + return Optional.ofNullable(supplier); + } + + /** + * Obtains the {@code COMMAND_WORD} which represents the command name for a given command. + * + * @param cls The subclass of {@link Command} to obtain the command word for. + * @return The command word as an optional string. + */ + public static Optional getCommandWord(Class cls) { + String value = null; + try { + Field field = cls.getDeclaredField("COMMAND_WORD"); + Object data = field.get(cls); + if (data instanceof String) { + value = (String) data; + } + + } catch (NoSuchFieldException | IllegalAccessException e) { + // Should not reach here... + + assert cls.equals(Command.class) + : "a public COMMAND_WORD static field should be present for Command subclasses, but missing in " + + cls.getName(); + } + + return Optional.ofNullable(value); + } + + /** + * Obtains a stream of {@code COMMAND_WORD}s which represent the command names for all given commands. + * + * @param clsStream The subclasses of {@link Command} to obtain the command word for. + * @return The command words as a list of optional strings. + */ + public static Stream> getCommandWords(Stream> clsStream) { + return clsStream.map(Command::getCommandWord); + } + + /** + * Obtains a list of {@code COMMAND_WORD}s which represent the command names for all given commands. + * + * @see #getCommandWords(Stream) + */ + public static List> getCommandWords(Collection> clsList) { + return getCommandWords(clsList.stream()).collect(Collectors.toList()); + } + + + } diff --git a/src/main/java/seedu/address/logic/commands/DeleteCommand.java b/src/main/java/seedu/address/logic/commands/DeleteCommand.java index 47fe842565a..5352fdb9097 100644 --- a/src/main/java/seedu/address/logic/commands/DeleteCommand.java +++ b/src/main/java/seedu/address/logic/commands/DeleteCommand.java @@ -10,6 +10,7 @@ import seedu.address.commons.core.index.Index; import seedu.address.commons.util.ToStringBuilder; import seedu.address.logic.Messages; +import seedu.address.logic.autocomplete.AutocompleteSupplier; import seedu.address.logic.commands.exceptions.CommandException; import seedu.address.model.Model; import seedu.address.model.contact.Contact; @@ -22,6 +23,17 @@ public class DeleteCommand extends Command { public static final String COMMAND_WORD = "delete"; + public static final AutocompleteSupplier AUTOCOMPLETE_SUPPLIER = AutocompleteSupplier.fromUniqueFlags( + FLAG_ID, FLAG_RECURSIVE + ).configureValueMap(m -> { + // Add value autocompletion data for: + m.put(FLAG_ID, model -> model.getAddressBook().getContactList().stream().map(c -> c.getId().value)); + + // Disable value autocompletion for: + m.put(FLAG_RECURSIVE, null); + }); + + public static final String MESSAGE_USAGE = COMMAND_WORD + ": Deletes the contact identified by the index number used in the displayed contact list.\n" + "Parameters: " diff --git a/src/main/java/seedu/address/logic/commands/EditCommand.java b/src/main/java/seedu/address/logic/commands/EditCommand.java index 7cf311c49bb..7fac96035b5 100644 --- a/src/main/java/seedu/address/logic/commands/EditCommand.java +++ b/src/main/java/seedu/address/logic/commands/EditCommand.java @@ -3,9 +3,14 @@ import static java.util.Objects.requireNonNull; import static seedu.address.logic.parser.CliSyntax.FLAG_ADDRESS; import static seedu.address.logic.parser.CliSyntax.FLAG_EMAIL; +import static seedu.address.logic.parser.CliSyntax.FLAG_ID; import static seedu.address.logic.parser.CliSyntax.FLAG_NAME; +import static seedu.address.logic.parser.CliSyntax.FLAG_ORGANIZATION_ID; import static seedu.address.logic.parser.CliSyntax.FLAG_PHONE; +import static seedu.address.logic.parser.CliSyntax.FLAG_POSITION; +import static seedu.address.logic.parser.CliSyntax.FLAG_STATUS; import static seedu.address.logic.parser.CliSyntax.FLAG_TAG; +import static seedu.address.logic.parser.CliSyntax.FLAG_URL; import static seedu.address.model.Model.PREDICATE_SHOW_ALL_CONTACTS; import java.util.Collections; @@ -19,6 +24,8 @@ import seedu.address.commons.util.CollectionUtil; import seedu.address.commons.util.ToStringBuilder; import seedu.address.logic.Messages; +import seedu.address.logic.autocomplete.AutocompleteSupplier; +import seedu.address.logic.autocomplete.data.AutocompleteDataSet; import seedu.address.logic.commands.exceptions.CommandException; import seedu.address.model.Model; import seedu.address.model.contact.Address; @@ -37,6 +44,16 @@ public class EditCommand extends Command { public static final String COMMAND_WORD = "edit"; + public static final AutocompleteSupplier AUTOCOMPLETE_SUPPLIER = AutocompleteSupplier.from( + AutocompleteDataSet.onceForEachOf( + FLAG_NAME, FLAG_ID, + FLAG_PHONE, FLAG_EMAIL, FLAG_ADDRESS, FLAG_URL, + FLAG_STATUS, FLAG_POSITION, + FLAG_ORGANIZATION_ID + ), + AutocompleteDataSet.anyNumberOf(FLAG_TAG) + ); + public static final String MESSAGE_USAGE = COMMAND_WORD + ": Edits the details of the contact identified " + "by the index number used in the displayed contact list. " + "Existing values will be overwritten by the input values.\n" diff --git a/src/main/java/seedu/address/logic/commands/ListCommand.java b/src/main/java/seedu/address/logic/commands/ListCommand.java index 3c341d10d07..9e8b0850dc5 100644 --- a/src/main/java/seedu/address/logic/commands/ListCommand.java +++ b/src/main/java/seedu/address/logic/commands/ListCommand.java @@ -1,11 +1,14 @@ package seedu.address.logic.commands; import static java.util.Objects.requireNonNull; +import static seedu.address.logic.parser.CliSyntax.FLAG_ORGANIZATION; +import static seedu.address.logic.parser.CliSyntax.FLAG_RECRUITER; import static seedu.address.model.Model.PREDICATE_SHOW_ONLY_ORGANIZATIONS; import static seedu.address.model.Model.PREDICATE_SHOW_ONLY_RECRUITERS; import java.util.function.Predicate; +import seedu.address.logic.autocomplete.AutocompleteSupplier; import seedu.address.model.Model; import seedu.address.model.contact.Contact; @@ -16,6 +19,10 @@ public class ListCommand extends Command { public static final String COMMAND_WORD = "list"; + public static final AutocompleteSupplier AUTOCOMPLETE_SUPPLIER = AutocompleteSupplier.fromUniqueFlags( + FLAG_ORGANIZATION, FLAG_RECRUITER + ); + public static final String MESSAGE_SUCCESS_ALL_CONTACTS = "Listed all contacts"; public static final String MESSAGE_SUCCESS_ORGANIZATIONS = "Listed all organizations"; public static final String MESSAGE_SUCCESS_RECRUITERS = "Listed all recruiters"; diff --git a/src/main/java/seedu/address/logic/parser/AddCommandParser.java b/src/main/java/seedu/address/logic/parser/AddCommandParser.java index 04edd35ddd3..05f97044e09 100644 --- a/src/main/java/seedu/address/logic/parser/AddCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/AddCommandParser.java @@ -45,11 +45,7 @@ public class AddCommandParser implements Parser { public AddCommand parse(String args) throws ParseException { ArgumentMultimap argMultimap = ArgumentTokenizer.tokenize(args, - FLAG_NAME, FLAG_PHONE, FLAG_EMAIL, - FLAG_ADDRESS, FLAG_TAG, FLAG_URL, - FLAG_ID, FLAG_STATUS, FLAG_POSITION, - FLAG_ORGANIZATION_ID, - FLAG_ORGANIZATION, FLAG_RECRUITER + AddCommand.AUTOCOMPLETE_SUPPLIER.getAllPossibleFlags().toArray(Flag[]::new) ); if (!argMultimap.hasAllOfFlags(FLAG_NAME) diff --git a/src/main/java/seedu/address/logic/parser/AddressBookParser.java b/src/main/java/seedu/address/logic/parser/AppParser.java similarity index 57% rename from src/main/java/seedu/address/logic/parser/AddressBookParser.java rename to src/main/java/seedu/address/logic/parser/AppParser.java index 88cbea43998..db8e4d48e6c 100644 --- a/src/main/java/seedu/address/logic/parser/AddressBookParser.java +++ b/src/main/java/seedu/address/logic/parser/AppParser.java @@ -3,11 +3,14 @@ import static seedu.address.logic.Messages.MESSAGE_INVALID_COMMAND_FORMAT; import static seedu.address.logic.Messages.MESSAGE_UNKNOWN_COMMAND; +import java.util.Optional; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; import seedu.address.commons.core.LogsCenter; +import seedu.address.logic.autocomplete.AutocompleteGenerator; import seedu.address.logic.commands.AddCommand; import seedu.address.logic.commands.ClearCommand; import seedu.address.logic.commands.Command; @@ -20,15 +23,16 @@ import seedu.address.logic.parser.exceptions.ParseException; /** - * Parses user input. + * Processes user input for the application. + * It is a utility class for parsing and performing actions on command strings app-wide. */ -public class AddressBookParser { +public class AppParser { /** * Used for initial separation of command word and args. */ private static final Pattern BASIC_COMMAND_FORMAT = Pattern.compile("(?\\S+)(?.*)"); - private static final Logger logger = LogsCenter.getLogger(AddressBookParser.class); + private static final Logger logger = LogsCenter.getLogger(AppParser.class); /** * Parses user input into command for execution. @@ -83,4 +87,50 @@ public Command parseCommand(String userInput) throws ParseException { } } + /** + * Parses user input into an evaluator that can be executed to obtain autocompletion results. + * + * @param userInput full user input string + * @return the command based on the user input + */ + public AutocompleteGenerator parseCompletionGenerator(String userInput) { + final Matcher matcher = BASIC_COMMAND_FORMAT.matcher(userInput.trim()); + if (!matcher.matches()) { + return AutocompleteGenerator.NO_RESULTS; + } + final String commandWord = matcher.group("commandWord"); + + logger.finest("Preparing autocomplete: " + userInput); + + switch (commandWord) { + case AddCommand.COMMAND_WORD: + return new AutocompleteGenerator(AddCommand.AUTOCOMPLETE_SUPPLIER); + + case EditCommand.COMMAND_WORD: + return new AutocompleteGenerator(EditCommand.AUTOCOMPLETE_SUPPLIER); + + case DeleteCommand.COMMAND_WORD: + return new AutocompleteGenerator(DeleteCommand.AUTOCOMPLETE_SUPPLIER); + + case ListCommand.COMMAND_WORD: + return new AutocompleteGenerator(ListCommand.AUTOCOMPLETE_SUPPLIER); + + case ClearCommand.COMMAND_WORD: + case FindCommand.COMMAND_WORD: + case ExitCommand.COMMAND_WORD: + case HelpCommand.COMMAND_WORD: + return AutocompleteGenerator.NO_RESULTS; + + default: + // Not a valid command. Return autocompletion results based on all the known command names. + return new AutocompleteGenerator( + Command.getCommandWords(Stream.of( + AddCommand.class, DeleteCommand.class, EditCommand.class, ListCommand.class, + FindCommand.class, HelpCommand.class, ClearCommand.class, ExitCommand.class + )).filter(Optional::isPresent).map(Optional::get) + ); + + } + } + } diff --git a/src/main/java/seedu/address/logic/parser/DeleteCommandParser.java b/src/main/java/seedu/address/logic/parser/DeleteCommandParser.java index 3cf4c54793d..5a3d4a90583 100644 --- a/src/main/java/seedu/address/logic/parser/DeleteCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/DeleteCommandParser.java @@ -22,7 +22,7 @@ public class DeleteCommandParser implements Parser { public DeleteCommand parse(String args) throws ParseException { ArgumentMultimap argumentMultimap = ArgumentTokenizer.tokenize(args, - FLAG_ID, FLAG_RECURSIVE); + DeleteCommand.AUTOCOMPLETE_SUPPLIER.getAllPossibleFlags().toArray(Flag[]::new)); boolean hasIndex = !argumentMultimap.getPreamble().isEmpty(); boolean hasId = argumentMultimap.getValue(FLAG_ID).isPresent(); diff --git a/src/main/java/seedu/address/logic/parser/EditCommandParser.java b/src/main/java/seedu/address/logic/parser/EditCommandParser.java index f22cbf49f7c..787509711cf 100644 --- a/src/main/java/seedu/address/logic/parser/EditCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/EditCommandParser.java @@ -32,7 +32,8 @@ public class EditCommandParser implements Parser { public EditCommand parse(String args) throws ParseException { requireNonNull(args); ArgumentMultimap argMultimap = - ArgumentTokenizer.tokenize(args, FLAG_NAME, FLAG_PHONE, FLAG_EMAIL, FLAG_ADDRESS, FLAG_TAG); + ArgumentTokenizer.tokenize(args, + EditCommand.AUTOCOMPLETE_SUPPLIER.getAllPossibleFlags().toArray(Flag[]::new)); Index index; diff --git a/src/main/java/seedu/address/logic/parser/ListCommandParser.java b/src/main/java/seedu/address/logic/parser/ListCommandParser.java index 5ab49878713..3dfd5506e9d 100644 --- a/src/main/java/seedu/address/logic/parser/ListCommandParser.java +++ b/src/main/java/seedu/address/logic/parser/ListCommandParser.java @@ -18,7 +18,9 @@ public class ListCommandParser implements Parser { * and returns a ListCommand object for execution. */ public ListCommand parse(String args) { - ArgumentMultimap argMultimap = ArgumentTokenizer.tokenize(args, FLAG_ORGANIZATION, FLAG_RECRUITER); + ArgumentMultimap argMultimap = + ArgumentTokenizer.tokenize(args, + ListCommand.AUTOCOMPLETE_SUPPLIER.getAllPossibleFlags().toArray(Flag[]::new)); if (argMultimap.hasAllOfFlags(FLAG_ORGANIZATION, FLAG_RECRUITER)) { return new ListCommand(PREDICATE_SHOW_ALL_CONTACTS); diff --git a/src/main/java/seedu/address/ui/AutocompleteTextField.java b/src/main/java/seedu/address/ui/AutocompleteTextField.java new file mode 100644 index 00000000000..9955aea854c --- /dev/null +++ b/src/main/java/seedu/address/ui/AutocompleteTextField.java @@ -0,0 +1,119 @@ +package seedu.address.ui; + +import java.util.LinkedList; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javafx.geometry.Side; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.CustomMenuItem; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; + +/** + * A text field capable of displaying autocomplete details. + * + *

+ * With reference to a proof-of-concept implementation from: + * floralvikings/AutoCompleteTextBox.java + *

+ */ +public class AutocompleteTextField extends TextField { + + /** + * A functional interface that performs auto-completions based on the given partial inputs. + */ + @FunctionalInterface + public interface CompletionGenerator extends Function> { } + + private final ContextMenu autocompletePopup; + + private CompletionGenerator completionGenerator = s -> Stream.empty(); + private int popupLimit = 10; + + /** + * Constructs a new text field with the ability to perform autocompletion. + */ + public AutocompleteTextField() { + super(); + autocompletePopup = new ContextMenu(); + textProperty().addListener(e -> updatePopupState()); + focusedProperty().addListener(e -> autocompletePopup.hide()); + } + + /** + * Sets the autocompletion generator for this text field. + */ + public void setCompletionGenerator(CompletionGenerator completionGenerator) { + this.completionGenerator = completionGenerator == null + ? s -> Stream.empty() + : completionGenerator; + } + + /** + * Sets the maximum number of entries that can be shown in the popup. + */ + public void setPopupLimit(int popupLimit) { + this.popupLimit = popupLimit; + } + + /** + * Triggers autocompletion immediately using the first suggested value, if any. + * + * @return true if an autocompleted result has been filled in, false otherwise. + */ + public boolean triggerImmediateAutocompletion() { + var results = completionGenerator.apply(getText()).limit(2).collect(Collectors.toList()); + if (results.size() < 1) { + return false; + } + + String result = results.get(0); + setText(result + " "); + requestFocus(); + end(); + updatePopupState(); + + return true; + } + + /** + * Updates the state of the popup indicating the autocompletion entries. + */ + protected void updatePopupState() { + String text = getText(); + if (text.isEmpty() || !isFocused()) { + autocompletePopup.hide(); + return; + } + + List menuItems = new LinkedList<>(); + + completionGenerator.apply(text) + .limit(popupLimit) + .forEachOrdered(autocompletedString -> { + Label entryLabel = new Label(autocompletedString); + CustomMenuItem item = new CustomMenuItem(entryLabel, false); + item.setOnAction(e -> { + setText(autocompletedString + " "); + requestFocus(); + end(); + updatePopupState(); + }); + menuItems.add(item); + }); + + autocompletePopup.getItems().clear(); + autocompletePopup.getItems().addAll(menuItems); + + if (menuItems.size() > 0) { + if (!autocompletePopup.isShowing()) { + autocompletePopup.show(AutocompleteTextField.this, Side.BOTTOM, 0, 0); + } + } else { + autocompletePopup.hide(); + } + } +} diff --git a/src/main/java/seedu/address/ui/CommandBox.java b/src/main/java/seedu/address/ui/CommandBox.java index 9e75478664b..6ad9488b6d1 100644 --- a/src/main/java/seedu/address/ui/CommandBox.java +++ b/src/main/java/seedu/address/ui/CommandBox.java @@ -1,9 +1,14 @@ package seedu.address.ui; +import java.util.Objects; +import java.util.logging.Logger; + import javafx.collections.ObservableList; import javafx.fxml.FXML; -import javafx.scene.control.TextField; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; import javafx.scene.layout.Region; +import seedu.address.commons.core.LogsCenter; import seedu.address.logic.commands.CommandResult; import seedu.address.logic.commands.exceptions.CommandException; import seedu.address.logic.parser.exceptions.ParseException; @@ -16,25 +21,82 @@ public class CommandBox extends UiPart { public static final String ERROR_STYLE_CLASS = "error"; private static final String FXML = "CommandBox.fxml"; + private static final Logger logger = LogsCenter.getLogger(CommandBox.class); + private final CommandExecutor commandExecutor; + private final AutocompleteTextField.CompletionGenerator completionGenerator; @FXML - private TextField commandTextField; + private AutocompleteTextField commandTextField; /** * Creates a {@code CommandBox} with the given {@code CommandExecutor}. */ - public CommandBox(CommandExecutor commandExecutor) { + public CommandBox( + CommandExecutor commandExecutor, AutocompleteTextField.CompletionGenerator completionGenerator + ) { super(FXML); this.commandExecutor = commandExecutor; + this.completionGenerator = completionGenerator; + + assert commandTextField != null; + commandTextField.setCompletionGenerator(completionGenerator); + commandTextField.setOnKeyPressed(this::handleKeyEvent); + commandTextField.addEventFilter(KeyEvent.KEY_PRESSED, this::handleKeyFilter); + commandTextField.addEventFilter(KeyEvent.KEY_TYPED, this::handleKeyFilter); + commandTextField.addEventFilter(KeyEvent.KEY_RELEASED, this::handleKeyFilter); + // calls #setStyleToDefault() whenever there is a change to the text of the command box. commandTextField.textProperty().addListener((unused1, unused2, unused3) -> setStyleToDefault()); } + + /** + * Handles the key event of a textbox. + */ + @FXML + private void handleKeyEvent(KeyEvent keyEvent) { + if (keyEvent.getEventType() != KeyEvent.KEY_PRESSED) { + return; + } + + if (keyEvent.getCode() == KeyCode.ENTER) { + logger.fine("Received key ENTER"); + this.handleCommandEntered(); + + } else if (keyEvent.getCode() == KeyCode.TAB) { + logger.fine("Received key TAB"); + + keyEvent.consume(); // consume by default + commandTextField.requestFocus(); // revert to this focus in case + + this.handleCommandAutocompleted(keyEvent); + } + } + /** - * Handles the Enter button pressed event. + * Handles the key filter of a textbox. */ @FXML + private void handleKeyFilter(KeyEvent keyEvent) { + if (keyEvent.getEventType() != KeyEvent.KEY_TYPED) { + return; + } + + if (this.commandTextField.getCaretPosition() < this.commandTextField.getText().length()) { + // Caret not at end. Autocomplete not applicable. + return; + } + + if (Objects.equals(keyEvent.getCharacter(), " ")) { + logger.fine("Intercepted SPACE typed at end"); + this.handleCommandAutocompleted(keyEvent); + } + } + + /** + * Handles the request for finalization of text input. + */ private void handleCommandEntered() { String commandText = commandTextField.getText(); if (commandText.equals("")) { @@ -44,11 +106,25 @@ private void handleCommandEntered() { try { commandExecutor.execute(commandText); commandTextField.setText(""); + commandTextField.requestFocus(); } catch (CommandException | ParseException e) { setStyleToIndicateCommandFailure(); } } + /** + * Handles the request for autocompletion of text input. + */ + @FXML + private void handleCommandAutocompleted(KeyEvent keyEvent) { + logger.fine("User invoked auto-completion!"); + + boolean hasAutocompletedResult = commandTextField.triggerImmediateAutocompletion(); + if (hasAutocompletedResult) { + keyEvent.consume(); + } + } + /** * Sets the command box style to use the default style. */ diff --git a/src/main/java/seedu/address/ui/MainWindow.java b/src/main/java/seedu/address/ui/MainWindow.java index 17e8054eb6e..d0e7090fb53 100644 --- a/src/main/java/seedu/address/ui/MainWindow.java +++ b/src/main/java/seedu/address/ui/MainWindow.java @@ -1,6 +1,7 @@ package seedu.address.ui; import java.util.logging.Logger; +import java.util.stream.Stream; import javafx.event.ActionEvent; import javafx.fxml.FXML; @@ -119,7 +120,7 @@ void fillInnerParts() { StatusBarFooter statusBarFooter = new StatusBarFooter(logic.getAddressBookFilePath()); statusbarPlaceholder.getChildren().add(statusBarFooter.getRoot()); - CommandBox commandBox = new CommandBox(this::executeCommand); + CommandBox commandBox = new CommandBox(this::executeCommand, this::generateCompletions); commandBoxPlaceholder.getChildren().add(commandBox.getRoot()); } @@ -193,4 +194,13 @@ private CommandResult executeCommand(String commandText) throws CommandException throw e; } } + + /** + * Obtains a list of auto-completed commands based on the current partial text. + * + * @see seedu.address.logic.Logic#generateCompletions(String) + */ + private Stream generateCompletions(String commandText) { + return logic.generateCompletions(commandText); + } } diff --git a/src/main/resources/view/CommandBox.fxml b/src/main/resources/view/CommandBox.fxml index 124283a392e..d0e9582ce8c 100644 --- a/src/main/resources/view/CommandBox.fxml +++ b/src/main/resources/view/CommandBox.fxml @@ -1,9 +1,9 @@ + - + - diff --git a/src/test/java/seedu/address/commons/util/StringUtilTest.java b/src/test/java/seedu/address/commons/util/StringUtilTest.java index c56d407bf3f..8675bfe1c88 100644 --- a/src/test/java/seedu/address/commons/util/StringUtilTest.java +++ b/src/test/java/seedu/address/commons/util/StringUtilTest.java @@ -1,5 +1,6 @@ package seedu.address.commons.util; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static seedu.address.testutil.Assert.assertThrows; @@ -140,4 +141,87 @@ public void getDetails_nullGiven_throwsNullPointerException() { assertThrows(NullPointerException.class, () -> StringUtil.getDetails(null)); } + + //---------------- Tests for isFuzzyMatch -------------------------------------- + + /* + * Possible cases: + * - Subsequence near start + * - Subsequence near middle + * - Subsequence near end + * - No part of subsequence exists + */ + + @Test + public void isFuzzyMatch_isSubsequence_returnsTrue() { + // Centers + assertTrue(StringUtil.isFuzzyMatch("1", "abc123")); + assertTrue(StringUtil.isFuzzyMatch("b12", "abc123")); + + // Boundaries + assertTrue(StringUtil.isFuzzyMatch("a", "abc123")); + assertTrue(StringUtil.isFuzzyMatch("3", "abc123")); + + assertTrue(StringUtil.isFuzzyMatch("ab3", "abc123")); + assertTrue(StringUtil.isFuzzyMatch("c23", "abc123")); + + // Null match + assertTrue(StringUtil.isFuzzyMatch(null, null)); + } + + @Test + public void isFuzzyMatch_notSubsequence_returnsFalse() { + // Doesn't exist + assertFalse(StringUtil.isFuzzyMatch("d", "abc123")); + + // Reordered + assertFalse(StringUtil.isFuzzyMatch("1c", "abc123")); + + // Boundaries + assertFalse(StringUtil.isFuzzyMatch("ab4", "abc123")); + assertFalse(StringUtil.isFuzzyMatch("zb2", "abc123")); + assertFalse(StringUtil.isFuzzyMatch("321", "abc123")); + assertFalse(StringUtil.isFuzzyMatch("cba", "abc123")); + + // Null mismatch + assertFalse(StringUtil.isFuzzyMatch(null, "abc123")); + assertFalse(StringUtil.isFuzzyMatch(null, "")); + assertFalse(StringUtil.isFuzzyMatch("", null)); + } + + //---------------- Tests for getFuzzyMatchScore -------------------------------------- + + /* + * The score must be 0 if mismatched, + * or the length of the match subtracting the number of gaps in the match range starting from the prefix, + * capped at 0. + * + * e.g., BD fuzzy matches ABCDE by ?B?D, so the score is 2 - 2 = 0. + */ + + @Test + public void getFuzzyMatchScore_allInputs_correctResult() { + // Matches prefix + assertEquals(1, StringUtil.getFuzzyMatchScore("a", "abcabc")); + assertEquals(3, StringUtil.getFuzzyMatchScore("aaa", "aaabbb")); + + // Matches, but has gaps + assertEquals(3 - 2, StringUtil.getFuzzyMatchScore("aaa", "ababab")); + assertEquals(3 - 2, StringUtil.getFuzzyMatchScore("abc", "ababcab")); + assertEquals(4 - 2, StringUtil.getFuzzyMatchScore("bcef", "abcdefg")); + assertEquals(3 - 4, StringUtil.getFuzzyMatchScore("aaa", "aa1234a")); + assertEquals(3 - 5, StringUtil.getFuzzyMatchScore("aba", "ab12345aa")); + + // Not a full match + assertEquals(-2, StringUtil.getFuzzyMatchScore("aa", "ab")); + assertEquals(0, StringUtil.getFuzzyMatchScore("a", "ba")); + assertEquals(-1, StringUtil.getFuzzyMatchScore("ab", "a")); + assertEquals(-1, StringUtil.getFuzzyMatchScore("cba", "a")); + + // Null values + assertEquals(0, StringUtil.getFuzzyMatchScore(null, "a")); + assertEquals(0, StringUtil.getFuzzyMatchScore("b", null)); + assertEquals(0, StringUtil.getFuzzyMatchScore(null, null)); + } + } diff --git a/src/test/java/seedu/address/logic/LogicManagerTest.java b/src/test/java/seedu/address/logic/LogicManagerTest.java index 94a27bcb7ed..ca074790eef 100644 --- a/src/test/java/seedu/address/logic/LogicManagerTest.java +++ b/src/test/java/seedu/address/logic/LogicManagerTest.java @@ -15,6 +15,8 @@ import java.io.IOException; import java.nio.file.AccessDeniedException; import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -84,6 +86,22 @@ public void execute_storageThrowsAdException_throwsCommandException() { LogicManager.FILE_OPS_PERMISSION_ERROR_FORMAT, DUMMY_AD_EXCEPTION.getMessage())); } + @Test + public void generateCompletions_knownSubsequence_successWithResults() throws Exception { + assertEquals( + List.of("edit abc --name"), + logic.generateCompletions("edit abc -nm").collect(Collectors.toList()) + ); + } + + @Test + public void generateCompletions_unknownSubsequence_failWithEmptyOutput() throws Exception { + assertEquals( + List.of(), + logic.generateCompletions("edit abc -xxx").collect(Collectors.toList()) + ); + } + @Test public void getFilteredContactList_modifyList_throwsUnsupportedOperationException() { assertThrows(UnsupportedOperationException.class, () -> logic.getFilteredContactList().remove(0)); diff --git a/src/test/java/seedu/address/logic/autocomplete/AutocompleteGeneratorTest.java b/src/test/java/seedu/address/logic/autocomplete/AutocompleteGeneratorTest.java new file mode 100644 index 00000000000..d8d5c929c80 --- /dev/null +++ b/src/test/java/seedu/address/logic/autocomplete/AutocompleteGeneratorTest.java @@ -0,0 +1,152 @@ +package seedu.address.logic.autocomplete; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import seedu.address.logic.autocomplete.data.AutocompleteConstraint; +import seedu.address.logic.autocomplete.data.AutocompleteDataSet; +import seedu.address.logic.parser.Flag; + +public class AutocompleteGeneratorTest { + + @Test + public void generateCompletions_usingGivenExpectedCommands_correctResult() { + List sourceList = List.of( + "abacus", + "ad free", + "add", + "add milk", + "add coffee", + "almond", + "ate cake", + "bake cake", + "cadence", + "cupcake" + ); + List resultList = List.of( + "ad free", + "add", + "add milk", + "add coffee", + "cadence", + "almond" + ); + + assertEquals( + resultList, + new AutocompleteGenerator(sourceList.stream()) + .generateCompletions("ad") + .collect(Collectors.toList()) + ); + } + + @Test + public void generateCompletions_usingAutocompleteSupplier_correctResult() { + // Assumption: Default format is --flag (full), -f (alias). + + Flag flagA1 = new Flag("aaa", "a"); + Flag flagA2 = new Flag("abc"); + Flag flagA3 = new Flag("adg"); + Flag flagB = new Flag("book", "b"); + Flag flagC1 = new Flag("cde", "c"); + Flag flagC2 = new Flag("code"); + + AutocompleteSupplier supplier = new AutocompleteSupplier( + AutocompleteDataSet.concat( + AutocompleteDataSet.onceForEachOf(flagA1, flagA2, flagA3), + AutocompleteDataSet.anyNumberOf(flagB, flagC1, flagC2) + ).addConstraint( + AutocompleteConstraint.oneAmongAllOf(flagA1, flagA2) + ), + Map.of( + flagA3, m -> Stream.of("apple", "banana", "car") + ) + ); + + // autocomplete: -a + assertEquals( + List.of( + "cmd --aaa", + "cmd --abc", + "cmd --adg" + ), + new AutocompleteGenerator(supplier) + .generateCompletions("cmd -a") + .collect(Collectors.toList()) + ); + + // autocomplete: -b + assertEquals( + List.of( + "cmd --book", + "cmd --abc" + ), + new AutocompleteGenerator(supplier) + .generateCompletions("cmd -b") + .collect(Collectors.toList()) + ); + assertEquals( + List.of( + "cmd --aaa --book" + // --abc no longer suggested when --aaa is present + ), + new AutocompleteGenerator(supplier) + .generateCompletions("cmd --aaa -b") + .collect(Collectors.toList()) + ); + assertEquals( + List.of(), // leading space yields no results since it's suggesting the part + new AutocompleteGenerator(supplier) + .generateCompletions("cmd --adg -b ") + .collect(Collectors.toList()) + ); + + // autocomplete: --adg + assertEquals( + List.of( + "cmd -b --adg apple", + "cmd -b --adg banana", + "cmd -b --adg car" + ), + new AutocompleteGenerator(supplier) + .generateCompletions("cmd -b --adg ") + .collect(Collectors.toList()) + ); + assertEquals( + List.of("cmd -b --adg banana"), + new AutocompleteGenerator(supplier) + .generateCompletions("cmd -b --adg anna") + .collect(Collectors.toList()) + ); + + // autocomplete: --cd + assertEquals( + List.of( + "cmd -a x y --cde", + "cmd -a x y --code" + ), + new AutocompleteGenerator(supplier) + .generateCompletions("cmd -a x y --cd") + .collect(Collectors.toList()) + ); + + // autocomplete: -o + assertEquals( + List.of( + "cmd -a x y --code z --book", + "cmd -a x y --code z --code" // --code can be repeated + // --abc not suggested when -a (alias for --aaa) is present + ), + new AutocompleteGenerator(supplier) + .generateCompletions("cmd -a x y --code z -o") + .collect(Collectors.toList()) + ); + + } +} diff --git a/src/test/java/seedu/address/logic/autocomplete/AutocompleteSupplierTest.java b/src/test/java/seedu/address/logic/autocomplete/AutocompleteSupplierTest.java new file mode 100644 index 00000000000..519afb75c01 --- /dev/null +++ b/src/test/java/seedu/address/logic/autocomplete/AutocompleteSupplierTest.java @@ -0,0 +1,140 @@ +package seedu.address.logic.autocomplete; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import seedu.address.logic.autocomplete.data.AutocompleteConstraint; +import seedu.address.logic.autocomplete.data.AutocompleteDataSet; +import seedu.address.logic.parser.Flag; + +public class AutocompleteSupplierTest { + + private static final Flag FLAG_A = new Flag("a"); + private static final Flag FLAG_B = new Flag("b"); + private static final Flag FLAG_C = new Flag("c"); + private static final Flag FLAG_D = new Flag("d"); + private static final Flag FLAG_E = new Flag("e"); + private static final Flag FLAG_F = new Flag("f"); + + private static final List LIST_A = List.of("a"); + private static final List LIST_B = List.of("b", "b"); + private static final List LIST_C = List.of("c", "c", "c"); + private static final List LIST_EMPTY = List.of(); + + @Test + public void getAllPossibleFlags() { + var supplier = AutocompleteSupplier.fromUniqueFlags(FLAG_A, FLAG_B); + assertEquals(Set.of(FLAG_A, FLAG_B), supplier.getAllPossibleFlags()); + + supplier = AutocompleteSupplier.fromRepeatableFlags(FLAG_A, FLAG_B); + assertEquals(Set.of(FLAG_A, FLAG_B), supplier.getAllPossibleFlags()); + + supplier = AutocompleteSupplier.from( + AutocompleteDataSet.onceForEachOf(FLAG_A, FLAG_B), + AutocompleteDataSet.anyNumberOf(FLAG_C, FLAG_D) + ); + assertEquals(Set.of(FLAG_A, FLAG_B, FLAG_C, FLAG_D), supplier.getAllPossibleFlags()); + } + + @Test + public void getOtherPossibleFlagsAsideFromFlagsPresent() { + // Unique flags only + var supplier = AutocompleteSupplier.fromUniqueFlags(FLAG_A, FLAG_B); + assertEquals(Set.of(FLAG_A, FLAG_B), supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of())); + assertEquals( + Set.of(FLAG_A), + supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of(FLAG_B)) + ); + + // Repeatable flags only + supplier = AutocompleteSupplier.fromRepeatableFlags(FLAG_A, FLAG_B); + assertEquals(Set.of(FLAG_A, FLAG_B), supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of())); + assertEquals( + Set.of(FLAG_A, FLAG_B), + supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of(FLAG_A, FLAG_B)) + ); + + // Mixed flags + supplier = AutocompleteSupplier.from( + AutocompleteDataSet.onceForEachOf(FLAG_A, FLAG_B), + AutocompleteDataSet.anyNumberOf(FLAG_C, FLAG_D) + ); + assertEquals( + Set.of(FLAG_A, FLAG_B, FLAG_C, FLAG_D), + supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of()) + ); + assertEquals( + Set.of(FLAG_A, FLAG_C, FLAG_D), + supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of(FLAG_B, FLAG_D)) + ); + + // Mixed advanced combination. + supplier = AutocompleteSupplier.from( + AutocompleteDataSet.concat( + AutocompleteDataSet.onceForEachOf(FLAG_A, FLAG_B), + AutocompleteDataSet.anyNumberOf(FLAG_C, FLAG_D) + ).addConstraints(List.of( + AutocompleteConstraint.oneAmongAllOf(FLAG_A, FLAG_B), // A & B cannot coexist + AutocompleteConstraint.oneAmongAllOf(FLAG_B, FLAG_C) // B & C cannot coexist + )) + ); + + assertEquals( + Set.of(FLAG_C, FLAG_D), // A is present -> A, B cannot be present again + supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of(FLAG_A)) + ); + assertEquals( + Set.of(FLAG_A, FLAG_D), // C is present -> B, C cannot be present again + supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of(FLAG_C)) + ); + assertEquals( + Set.of(FLAG_A, FLAG_B, FLAG_C, FLAG_D), // D is present -> no impact + supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of(FLAG_D)) + ); + assertEquals( + Set.of(FLAG_D), // B is present -> A, B, C cannot be present again; + supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of(FLAG_D, FLAG_B)) + ); + assertEquals( + Set.of(FLAG_D), // All present somehow -> only D can be repeated. + supplier.getOtherPossibleFlagsAsideFromFlagsPresent(Set.of(FLAG_A, FLAG_B, FLAG_C, FLAG_D)) + ); + } + + @Test + public void getValidValues() { + var supplier = new AutocompleteSupplier( + AutocompleteDataSet.concat( + AutocompleteDataSet.onceForEachOf(FLAG_A, FLAG_B, FLAG_C), + AutocompleteDataSet.anyNumberOf(FLAG_D) + ).addConstraint( + AutocompleteConstraint.oneAmongAllOf(FLAG_A, FLAG_B) // A & B cannot coexist + ), + Map.of( + FLAG_A, m -> LIST_A.stream(), + FLAG_B, m -> LIST_B.stream(), + FLAG_C, m -> LIST_C.stream(), + FLAG_D, m -> LIST_EMPTY.stream(), + FLAG_F, m -> Stream.of(m.toString()) + ) + ); + + // Should use the lambda's values + assertEquals(LIST_A, supplier.getValidValues(FLAG_A, null).get().collect(Collectors.toList())); + assertEquals(LIST_B, supplier.getValidValues(FLAG_B, null).get().collect(Collectors.toList())); + assertEquals(LIST_C, supplier.getValidValues(FLAG_C, null).get().collect(Collectors.toList())); + assertEquals(LIST_EMPTY, supplier.getValidValues(FLAG_D, null).get().collect(Collectors.toList())); + assertEquals(LIST_EMPTY, supplier.getValidValues(FLAG_E, null).get().collect(Collectors.toList())); + + // NPEs should be caught if the lambda does not handle it + assertEquals(LIST_EMPTY, supplier.getValidValues(FLAG_F, null).get().collect(Collectors.toList())); + } + +} diff --git a/src/test/java/seedu/address/logic/autocomplete/data/AutocompleteConstraintTest.java b/src/test/java/seedu/address/logic/autocomplete/data/AutocompleteConstraintTest.java new file mode 100644 index 00000000000..ba3cfef3e5a --- /dev/null +++ b/src/test/java/seedu/address/logic/autocomplete/data/AutocompleteConstraintTest.java @@ -0,0 +1,70 @@ +package seedu.address.logic.autocomplete.data; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +public class AutocompleteConstraintTest { + + @Test + public void onceForEachOf() { + var oneTwoThreeOnlyOnce = AutocompleteConstraint.onceForEachOf(1, 2, 3); + + // Allowed if the single arg itself is not yet added + assertTrue(oneTwoThreeOnlyOnce.isAllowed(0, Set.of(2, 3))); + assertTrue(oneTwoThreeOnlyOnce.isAllowed(1, Set.of(2, 3))); + + // Disallowed if the single arg itself is already present + assertFalse(oneTwoThreeOnlyOnce.isAllowed(3, Set.of(2, 3))); + + // Unspecified elements are not impacted and allowed + assertTrue(oneTwoThreeOnlyOnce.isAllowed(0, Set.of())); + assertTrue(oneTwoThreeOnlyOnce.isAllowed(0, Set.of(0))); + assertTrue(oneTwoThreeOnlyOnce.isAllowed(0, Set.of(1, 2))); + } + + @Test + public void oneAmongAllOf() { + var oneXorTwo = AutocompleteConstraint.oneAmongAllOf(1, 2); + + // Allowed if none of the arguments are added yet + assertTrue(oneXorTwo.isAllowed(1, Set.of(3))); + + // Disallowed if at least one of the arguments is already present + assertFalse(oneXorTwo.isAllowed(1, Set.of(2, 3))); + assertFalse(oneXorTwo.isAllowed(2, Set.of(2, 3))); + + // Unspecified elements are not impacted and allowed + assertTrue(oneXorTwo.isAllowed(0, Set.of())); + assertTrue(oneXorTwo.isAllowed(0, Set.of(0))); + assertTrue(oneXorTwo.isAllowed(0, Set.of(2, 3))); + } + + @Test + public void isPrerequisiteFor() { + var oneIsPrereqForTwoAndThree = AutocompleteConstraint.where(1).isPrerequisiteFor(2, 3); + + // Prerequisite is always allowed + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(1, Set.of())); + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(1, Set.of(0))); + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(1, Set.of(0, 1))); + + // Dependents are conditionally allowed + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(2, Set.of(1))); + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(2, Set.of(1, 2, 3))); + assertFalse(oneIsPrereqForTwoAndThree.isAllowed(2, Set.of(3))); + + // Existing dependents don't impact prerequisites + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(1, Set.of(2, 3))); + + // Unspecified elements are not impacted and allowed + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(0, Set.of())); + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(0, Set.of(0))); + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(0, Set.of(1))); + assertTrue(oneIsPrereqForTwoAndThree.isAllowed(0, Set.of(2, 3))); + } + +} diff --git a/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java b/src/test/java/seedu/address/logic/parser/AppParserTest.java similarity index 60% rename from src/test/java/seedu/address/logic/parser/AddressBookParserTest.java rename to src/test/java/seedu/address/logic/parser/AppParserTest.java index e3581572510..e24fc3b53b6 100644 --- a/src/test/java/seedu/address/logic/parser/AddressBookParserTest.java +++ b/src/test/java/seedu/address/logic/parser/AppParserTest.java @@ -29,9 +29,9 @@ import seedu.address.testutil.ContactUtil; import seedu.address.testutil.EditContactDescriptorBuilder; -public class AddressBookParserTest { +public class AppParserTest { - private final AddressBookParser parser = new AddressBookParser(); + private final AppParser parser = new AppParser(); @Test public void parseCommand_add() throws Exception { @@ -107,4 +107,93 @@ public void parseCommand_unrecognisedInput_throwsParseException() { public void parseCommand_unknownCommand_throwsParseException() { assertThrows(ParseException.class, MESSAGE_UNKNOWN_COMMAND, () -> parser.parseCommand("unknownCommand")); } + + @Test + public void parseCompletionGenerator_knownSubsequence_canGenerateCorrectSuggestions() { + // Add example + String userInput = "add --org -o"; + assertEquals( + List.of( + "add --org --pos", + "add --org --phone" + ), + parser.parseCompletionGenerator(userInput) + .generateCompletions(userInput) + .collect(Collectors.toList()) + ); + + // Edit example + userInput = "edit 1 --phone 12345678 --nm"; + assertEquals( + List.of( + "edit 1 --phone 12345678 --name" + ), + parser.parseCompletionGenerator(userInput) + .generateCompletions(userInput) + .collect(Collectors.toList()) + ); + + // List example + userInput = "list -o"; + assertEquals( + List.of("list --org"), + parser.parseCompletionGenerator(userInput) + .generateCompletions(userInput) + .collect(Collectors.toList()) + ); + + // Delete example + userInput = "delete 0 --re"; + assertEquals( + List.of("delete 0 --recursive"), + parser.parseCompletionGenerator(userInput) + .generateCompletions(userInput) + .collect(Collectors.toList()) + ); + + // Extra middle contents example + userInput = "edit 1 --phone 12345678 --nm"; + assertEquals( + List.of( + "edit 1 --phone 12345678 --name" + ), + parser.parseCompletionGenerator(userInput) + .generateCompletions(userInput) + .collect(Collectors.toList()) + ); + + // Command words example + userInput = "e"; + assertEquals( + List.of( + "edit", + "exit", + "delete", + "help", + "clear" + ), + parser.parseCompletionGenerator(userInput) + .generateCompletions(userInput) + .collect(Collectors.toList()) + ); + } + + @Test + public void parseCompletionGenerator_unknownSubsequence_willGenerateNoResults() { + String userInput = "add -asdf"; + assertEquals( + List.of(), + parser.parseCompletionGenerator(userInput) + .generateCompletions(userInput) + .collect(Collectors.toList()) + ); + + userInput = "edit 1 -xxx"; + assertEquals( + List.of(), + parser.parseCompletionGenerator(userInput) + .generateCompletions(userInput) + .collect(Collectors.toList()) + ); + } }