diff --git a/aesh/src/main/java/org/aesh/command/AeshCommandRuntimeBuilder.java b/aesh/src/main/java/org/aesh/command/AeshCommandRuntimeBuilder.java index 3db92ea2..db9de637 100644 --- a/aesh/src/main/java/org/aesh/command/AeshCommandRuntimeBuilder.java +++ b/aesh/src/main/java/org/aesh/command/AeshCommandRuntimeBuilder.java @@ -285,7 +285,8 @@ public CommandRuntime build() { optionActivatorProvider != null ? optionActivatorProvider : resolvedProviders.getOptionActivatorProvider(), commandActivatorProvider != null ? commandActivatorProvider - : resolvedProviders.getCommandActivatorProvider()); + : resolvedProviders.getCommandActivatorProvider(), + resolvedProviders.fuzzyCompletion()); } else if (resolvedProviders == null) { // Historic path: individuals only (nulls become defaults). resolvedProviders = new AeshInvocationProviders(converterInvocationProvider, diff --git a/aesh/src/main/java/org/aesh/command/impl/completer/FuzzyCompletionMatcher.java b/aesh/src/main/java/org/aesh/command/impl/completer/FuzzyCompletionMatcher.java new file mode 100644 index 00000000..13e0b2e8 --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/impl/completer/FuzzyCompletionMatcher.java @@ -0,0 +1,120 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.command.impl.completer; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.aesh.readline.fuzzy.FuzzyResult; +import org.aesh.readline.fuzzy.FuzzyScheme; +import org.aesh.readline.fuzzy.FuzzyScorer; +import org.aesh.terminal.formatting.TerminalString; +import org.aesh.terminal.utils.Parser; + +/** + * Fuzzy fallback matcher for tab completion. + *

+ * When prefix matching finds no candidates, this matcher scores the full + * candidate set against the typed word using the fzf-derived + * {@link FuzzyScorer} with the {@link FuzzyScheme#DEFAULT} scheme. Matching + * is case-insensitive and camelCase transitions are rewarded, so an + * abbreviation like {@code gCI} matches {@code getCursorIndex} and a typo + * like {@code comit} matches {@code commit}. + *

+ * Results are sorted by score, best match first, with ties kept in + * candidate order. Inputs shorter than {@link #MIN_INPUT_LENGTH} are not + * fuzzy-matched to avoid low-quality matches for single characters. + * + * @see FuzzyScorer + */ +public final class FuzzyCompletionMatcher { + + /** + * Minimum length of the typed word for fuzzy matching to kick in. + */ + public static final int MIN_INPUT_LENGTH = 2; + + private static final FuzzyCompletionMatcher INSTANCE = new FuzzyCompletionMatcher(); + + private final FuzzyScorer scorer; + + private FuzzyCompletionMatcher() { + scorer = new FuzzyScorer(FuzzyScheme.DEFAULT, false); + } + + /** + * Get the shared matcher instance. + * + * @return the shared matcher + */ + public static FuzzyCompletionMatcher getInstance() { + return INSTANCE; + } + + /** + * Check whether the typed word is long enough for fuzzy matching. + * + * @param input the typed word + * @return true if fuzzy matching may run for this input + */ + public static boolean isEligible(String input) { + return input != null && input.length() >= MIN_INPUT_LENGTH; + } + + /** + * Fuzzy-match the typed word against all candidates. + * + * @param input the typed word + * @param candidates the full candidate set + * @return matching candidates sorted by score (best first), never null + */ + public List fuzzyMatches(String input, Collection candidates) { + List matches = new ArrayList<>(); + if (!isEligible(input) || candidates == null || candidates.isEmpty()) + return matches; + + int[] pattern = Parser.toCodePoints(input); + List scored = new ArrayList<>(candidates.size()); + for (String candidate : candidates) { + if (candidate == null || candidate.isEmpty()) + continue; + FuzzyResult result = scorer.score(Parser.toCodePoints(candidate), pattern, false); + if (result.isMatch()) + scored.add(new ScoredCandidate(candidate, result.score)); + } + + scored.sort((a, b) -> Integer.compare(b.score, a.score)); + + for (ScoredCandidate candidate : scored) + matches.add(new TerminalString(candidate.name, true)); + return matches; + } + + private static final class ScoredCandidate { + private final String name; + private final int score; + + private ScoredCandidate(String name, int score) { + this.name = name; + this.score = score; + } + } +} diff --git a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshInvocationProviders.java b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshInvocationProviders.java index 579b9af5..ca04146c 100644 --- a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshInvocationProviders.java +++ b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshInvocationProviders.java @@ -49,6 +49,7 @@ public class AeshInvocationProviders implements InvocationProviders { private final ValidatorInvocationProvider validatorInvocationProvider; private final OptionActivatorProvider optionActivatorProvider; private final CommandActivatorProvider commandActivatorProvider; + private final boolean fuzzyCompletion; public AeshInvocationProviders() { this(null, null, null, null, null); @@ -60,6 +61,7 @@ public AeshInvocationProviders(Settings settings) { this.validatorInvocationProvider = defaultIfNull(settings.validatorInvocationProvider(), DEFAULT_VALIDATOR); this.optionActivatorProvider = defaultIfNull(settings.optionActivatorProvider(), DEFAULT_OPTION_ACTIVATOR); this.commandActivatorProvider = defaultIfNull(settings.commandActivatorProvider(), DEFAULT_COMMAND_ACTIVATOR); + this.fuzzyCompletion = settings.fuzzyCompletion(); } public AeshInvocationProviders(ConverterInvocationProvider converterInvocationProvider, @@ -67,11 +69,22 @@ public AeshInvocationProviders(ConverterInvocationProvider converterInvocationPr ValidatorInvocationProvider validatorInvocationProvider, OptionActivatorProvider optionActivatorProvider, CommandActivatorProvider commandActivatorProvider) { + this(converterInvocationProvider, completerInvocationProvider, validatorInvocationProvider, + optionActivatorProvider, commandActivatorProvider, false); + } + + public AeshInvocationProviders(ConverterInvocationProvider converterInvocationProvider, + CompleterInvocationProvider completerInvocationProvider, + ValidatorInvocationProvider validatorInvocationProvider, + OptionActivatorProvider optionActivatorProvider, + CommandActivatorProvider commandActivatorProvider, + boolean fuzzyCompletion) { this.converterInvocationProvider = defaultIfNull(converterInvocationProvider, DEFAULT_CONVERTER); this.completerInvocationProvider = defaultIfNull(completerInvocationProvider, DEFAULT_COMPLETER); this.validatorInvocationProvider = defaultIfNull(validatorInvocationProvider, DEFAULT_VALIDATOR); this.optionActivatorProvider = defaultIfNull(optionActivatorProvider, DEFAULT_OPTION_ACTIVATOR); this.commandActivatorProvider = defaultIfNull(commandActivatorProvider, DEFAULT_COMMAND_ACTIVATOR); + this.fuzzyCompletion = fuzzyCompletion; } private static T defaultIfNull(T value, T defaultValue) { @@ -102,4 +115,9 @@ public ValidatorInvocationProvider getValidatorProvider() { public OptionActivatorProvider getOptionActivatorProvider() { return optionActivatorProvider; } + + @Override + public boolean fuzzyCompletion() { + return fuzzyCompletion; + } } diff --git a/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineCompletionParser.java b/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineCompletionParser.java index 75da77c4..9a2974d0 100644 --- a/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineCompletionParser.java +++ b/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineCompletionParser.java @@ -24,6 +24,7 @@ import org.aesh.command.completer.CompleterInvocation; import org.aesh.command.impl.completer.CompleterData; import org.aesh.command.impl.completer.DefaultValueOptionCompleter; +import org.aesh.command.impl.completer.FuzzyCompletionMatcher; import org.aesh.command.impl.internal.OptionType; import org.aesh.command.impl.internal.ParsedCommand; import org.aesh.command.impl.internal.ProcessedOption; @@ -62,13 +63,13 @@ public void injectValuesAndComplete(AeshCompleteOperation completeOperation, Inv String currentWord = line.selectedWord().word(); if (currentWord.length() > 1 && !currentWord.startsWith("-") && !parser.getProcessedCommand().findPossibleBareLongNamesWithDash(currentWord).isEmpty()) { - doListOptions(completeOperation, currentWord); + doListOptions(completeOperation, currentWord, invocationProviders); return; } } if (parser.getProcessedCommand().completeStatus() == null) { - doListOptions(completeOperation, ""); + doListOptions(completeOperation, "", invocationProviders); return; } @@ -98,7 +99,7 @@ public void injectValuesAndComplete(AeshCompleteOperation completeOperation, Inv doProcessArgument(completeOperation, invocationProviders, line); } else { //list options - doListOptions(completeOperation, ""); + doListOptions(completeOperation, "", invocationProviders); } } } @@ -111,7 +112,7 @@ public void injectValuesAndComplete(AeshCompleteOperation completeOperation, Inv // parser.getProcessedCommand().getArguments().addValue(lastWord.word()); doProcessArgument(completeOperation, invocationProviders, line); } else - doListOptions(completeOperation, ""); + doListOptions(completeOperation, "", invocationProviders); } } //no space means we should try to complete the value of the last parsed option @@ -126,7 +127,7 @@ public void injectValuesAndComplete(AeshCompleteOperation completeOperation, Inv //partial long option name, contains atleast -- else if (parser.getProcessedCommand().completeStatus().status().equals(CompleteStatus.Status.LONG_OPTION)) { //not option value so we'll complete all options not already listed - doListOptions(completeOperation, "--" + parser.getProcessedCommand().completeStatus().value()); + doListOptions(completeOperation, "--" + parser.getProcessedCommand().completeStatus().value(), invocationProviders); } //partial short option, contains atleast - else if (parser.getProcessedCommand().completeStatus().status().equals(CompleteStatus.Status.SHORT_OPTION)) { @@ -142,6 +143,9 @@ else if (parser.getProcessedCommand().completeStatus().status().equals(CompleteS if (!matches.isEmpty()) { completeOperation.addCompletionCandidatesTerminalString(matches); completeOperation.setOffset(completeOperation.getCursor() - prefix.length() - 1); + } else if (invocationProviders != null && invocationProviders.fuzzyCompletion() + && FuzzyCompletionMatcher.isEligible(prefix)) { + addFuzzyOptionMatches(completeOperation, prefix, invocationProviders); } } } @@ -185,7 +189,8 @@ else if (parser.getProcessedCommand().completeStatus().status().equals(CompleteS } //group command else if (parser.getProcessedCommand().completeStatus().status().equals(CompleteStatus.Status.GROUP_COMMAND)) { - doProcessGroupCommand(completeOperation, parser.getProcessedCommand().completeStatus().value(), line); + doProcessGroupCommand(completeOperation, parser.getProcessedCommand().completeStatus().value(), line, + invocationProviders); } //append space after group command else if (parser.getProcessedCommand().completeStatus().status().equals(CompleteStatus.Status.APPEND_SPACE)) { @@ -195,7 +200,8 @@ else if (parser.getProcessedCommand().completeStatus().status().equals(CompleteS } } - private void doProcessGroupCommand(AeshCompleteOperation completeOperation, String name, ParsedLine line) { + private void doProcessGroupCommand(AeshCompleteOperation completeOperation, String name, ParsedLine line, + InvocationProviders invocationProviders) { if (name.isEmpty()) { for (CommandLineParser clp : parser.getAllChildParsers()) { if (clp.getProcessedCommand().isActivated(new ParsedCommand(clp.getProcessedCommand()))) @@ -211,6 +217,21 @@ private void doProcessGroupCommand(AeshCompleteOperation completeOperation, Stri completeOperation.setOffset(completeOperation.getCursor() - name.length()); } } + if (completeOperation.getCompletionCandidates().isEmpty() + && invocationProviders != null && invocationProviders.fuzzyCompletion() + && FuzzyCompletionMatcher.isEligible(name)) { + List childNames = new java.util.ArrayList<>(); + for (CommandLineParser child : parser.getAllChildParsers()) { + if (child.getProcessedCommand().isActivated(new ParsedCommand(child.getProcessedCommand()))) + childNames.add(child.getProcessedCommand().name()); + } + List fuzzyMatches = FuzzyCompletionMatcher.getInstance().fuzzyMatches(name, childNames); + if (!fuzzyMatches.isEmpty()) { + completeOperation.addCompletionCandidatesTerminalString(fuzzyMatches); + completeOperation.setOffset(completeOperation.getCursor()); + completeOperation.setReplacingCurrentWord(true); + } + } } if (completeOperation.getCompletionCandidates().size() == 1 && !line.cursorAtEnd()) completeOperation.setAppendSeparator(false); @@ -221,20 +242,20 @@ private void doProcessArgument(AeshCompleteOperation completeOperation, Invocati ProcessedOption arg = parser.getProcessedCommand().getPositionalForNextValue(); if (arg == null) { if (parser.getProcessedCommand().hasOptions()) - doListOptions(completeOperation, ""); + doListOptions(completeOperation, "", invocationProviders); return; } //first check if arg is argument, if so check if it already have a value, if so to an option complete if (arg.getOptionType() == OptionType.ARGUMENT && (arg.getValue() != null || !arg.isActivated(new ParsedCommand(parser.getProcessedCommand())))) { //list options - doListOptions(completeOperation, ""); + doListOptions(completeOperation, "", invocationProviders); } //if arguments, but not activated else if (arg.getOptionType() == OptionType.ARGUMENTS && !arg.isActivated(new ParsedCommand(parser.getProcessedCommand()))) //list options - doListOptions(completeOperation, ""); + doListOptions(completeOperation, "", invocationProviders); //argument(s) else { if (parser.getProcessedCommand().completeStatus().value() != null && @@ -274,13 +295,13 @@ else if (arg.getOptionType() == OptionType.ARGUMENTS if ((arg.completer() != null || !arg.isRequired()) && (parser.getProcessedCommand().completeStatus().value() == null || parser.getProcessedCommand().completeStatus().value().isEmpty())) { - doListOptions(completeOperation, ""); + doListOptions(completeOperation, "", invocationProviders); } } } } - private void doListOptions(AeshCompleteOperation completeOperation, String value) { + private void doListOptions(AeshCompleteOperation completeOperation, String value, InvocationProviders invocationProviders) { List optionNamesWithDash; if (value.length() > 0 && !value.startsWith("-")) { // Bare option prefix (e.g. "st" → "staging") — filter to matching bare options @@ -315,10 +336,40 @@ else if (value.startsWith("--")) else completeOperation.addCompletionCandidate(optionNamesWithDash.get(0)); completeOperation.setOffset(completeOperation.getCursor() - value.length()); + } else if (value.startsWith("--") && value.length() > 2) { + addFuzzyOptionMatches(completeOperation, value.substring(2), invocationProviders); } } + /** + * Fuzzy fallback for a partial long option name that matched no option by + * prefix. Candidates replace the typed word, so the operation is marked + * with replacingCurrentWord. + */ + private void addFuzzyOptionMatches(AeshCompleteOperation completeOperation, String name, + InvocationProviders invocationProviders) { + if (invocationProviders == null || !invocationProviders.fuzzyCompletion() + || !FuzzyCompletionMatcher.isEligible(name)) + return; + List universe = parser.getProcessedCommand().getOptionLongNamesWithDash(); + addInheritedOptions(universe, "--"); + List fuzzyMatches = FuzzyCompletionMatcher.getInstance() + .fuzzyMatches(name, terminalStringsAsStrings(universe)); + if (!fuzzyMatches.isEmpty()) { + completeOperation.addCompletionCandidatesTerminalString(fuzzyMatches); + completeOperation.setOffset(completeOperation.getCursor()); + completeOperation.setReplacingCurrentWord(true); + } + } + + private static List terminalStringsAsStrings(List candidates) { + List names = new java.util.ArrayList<>(candidates.size()); + for (TerminalString candidate : candidates) + names.add(candidate.getCharacters()); + return names; + } + /** * Add inherited options from parent parsers to the completion candidates. * Only includes options marked with inherited=true that haven't already been set. diff --git a/aesh/src/main/java/org/aesh/command/impl/registry/MutableCommandRegistryImpl.java b/aesh/src/main/java/org/aesh/command/impl/registry/MutableCommandRegistryImpl.java index d25c863b..58708e0b 100644 --- a/aesh/src/main/java/org/aesh/command/impl/registry/MutableCommandRegistryImpl.java +++ b/aesh/src/main/java/org/aesh/command/impl/registry/MutableCommandRegistryImpl.java @@ -34,6 +34,7 @@ import org.aesh.command.DefaultValueProvider; import org.aesh.command.container.CommandContainer; import org.aesh.command.container.CommandContainerBuilder; +import org.aesh.command.impl.completer.FuzzyCompletionMatcher; import org.aesh.command.impl.container.AeshCommandContainerBuilder; import org.aesh.command.impl.internal.ParsedCommand; import org.aesh.command.impl.internal.ProcessedCommand; @@ -48,6 +49,7 @@ import org.aesh.command.registry.MutableCommandRegistry; import org.aesh.parser.ParsedLine; import org.aesh.readline.completion.CompleteOperation; +import org.aesh.terminal.formatting.TerminalString; /** * @author Aesh team @@ -173,9 +175,31 @@ public void completeCommandName(CompleteOperation co, ParsedLine parsedLine) { co.setAppendSeparator(false); } } + if (co.getCompletionCandidates().isEmpty() && isFuzzyCompletionEnabled() && + FuzzyCompletionMatcher.isEligible(parsedLine.selectedWord().word())) { + List activatedNames = new ArrayList<>(); + for (CommandContainer command : registry.values()) { + ProcessedCommand, CI> com = command.getParser().getProcessedCommand(); + if (com.isActivated(new ParsedCommand(com))) + activatedNames.add(com.name()); + } + List fuzzyMatches = FuzzyCompletionMatcher.getInstance() + .fuzzyMatches(parsedLine.selectedWord().word(), activatedNames); + if (!fuzzyMatches.isEmpty()) { + co.addCompletionCandidatesTerminalString(fuzzyMatches); + co.setOffset(co.getCursor()); + co.setReplacingCurrentWord(true); + if (parsedLine.selectedIndex() < parsedLine.size() - 1) + co.setAppendSeparator(false); + } + } } } + private boolean isFuzzyCompletionEnabled() { + return invocationProviders != null && invocationProviders.fuzzyCompletion(); + } + @Override public Set getAllCommandNames() { Set names = new java.util.LinkedHashSet<>(registry.keySet()); diff --git a/aesh/src/main/java/org/aesh/command/invocation/InvocationProviders.java b/aesh/src/main/java/org/aesh/command/invocation/InvocationProviders.java index b95ce2ed..dfaebdf9 100644 --- a/aesh/src/main/java/org/aesh/command/invocation/InvocationProviders.java +++ b/aesh/src/main/java/org/aesh/command/invocation/InvocationProviders.java @@ -40,4 +40,15 @@ public interface InvocationProviders { OptionActivatorProvider getOptionActivatorProvider(); CommandActivatorProvider getCommandActivatorProvider(); + + /** + * Whether fuzzy matching is used as a completion fallback when prefix + * matching finds no candidates. Defaults to false; interactive consoles + * enable it through their settings. + * + * @return true if fuzzy completion is enabled + */ + default boolean fuzzyCompletion() { + return false; + } } diff --git a/aesh/src/main/java/org/aesh/command/settings/Settings.java b/aesh/src/main/java/org/aesh/command/settings/Settings.java index da773a76..484becaa 100644 --- a/aesh/src/main/java/org/aesh/command/settings/Settings.java +++ b/aesh/src/main/java/org/aesh/command/settings/Settings.java @@ -455,6 +455,28 @@ default boolean hasExplicitAeshContext() { */ void setEnableShellEscape(boolean enable); + /** + * Whether fuzzy matching is used as a completion fallback in the + * interactive terminal. When enabled, a typed word that matches no + * command, subcommand, or option name by prefix is fuzzy-matched + * against the available candidates (tolerating typos and supporting + * camelCase abbreviations) and the matches are ranked by score. + *

+ * Enabled by default. Only applies to interactive completion; shell + * completion scripts ({@code --aesh-complete}) are unaffected since + * the shell re-filters candidates by prefix. + * + * @return true if fuzzy completion is enabled + */ + boolean fuzzyCompletion(); + + /** + * Set whether fuzzy matching is used as a completion fallback. + * + * @param enable true to enable fuzzy completion + */ + void setFuzzyCompletion(boolean enable); + /** * Handler that receives command output (what commands write via * {@code invocation.println()} / {@code invocation.print()}) separately diff --git a/aesh/src/main/java/org/aesh/command/settings/SettingsBuilder.java b/aesh/src/main/java/org/aesh/command/settings/SettingsBuilder.java index 179e82d4..de04c184 100644 --- a/aesh/src/main/java/org/aesh/command/settings/SettingsBuilder.java +++ b/aesh/src/main/java/org/aesh/command/settings/SettingsBuilder.java @@ -413,6 +413,19 @@ public SettingsBuilder enableShellEscape(boolean enable) { return this; } + /** + * Enable fuzzy matching as a completion fallback in the interactive + * terminal. When prefix matching finds no candidates, the typed word is + * fuzzy-matched against command, subcommand, and option names. + * Enabled by default. + * + * @param enable true to enable fuzzy completion + */ + public SettingsBuilder fuzzyCompletion(boolean enable) { + settings.setFuzzyCompletion(enable); + return this; + } + /** * Set a handler that receives command output separately from readline * chrome. Output is teed to both the terminal and this handler. diff --git a/aesh/src/main/java/org/aesh/command/settings/SettingsImpl.java b/aesh/src/main/java/org/aesh/command/settings/SettingsImpl.java index 156f0763..e3a882fc 100644 --- a/aesh/src/main/java/org/aesh/command/settings/SettingsImpl.java +++ b/aesh/src/main/java/org/aesh/command/settings/SettingsImpl.java @@ -121,6 +121,7 @@ public class SettingsImpl private Consumer connectionClosedHandler; private SubCommandModeSettings subCommandModeSettings; private boolean enableShellEscape; + private boolean fuzzyCompletion = true; private Consumer commandOutputHandler; private java.util.Queue inputLineResponses; @@ -186,6 +187,7 @@ protected SettingsImpl(Settings baseSettings) { setConnectionClosedHandler(baseSettings.connectionClosedHandler()); setSubCommandModeSettings(baseSettings.subCommandModeSettings()); setEnableShellEscape(baseSettings.enableShellEscape()); + setFuzzyCompletion(baseSettings.fuzzyCompletion()); setCommandOutputHandler(baseSettings.commandOutputHandler()); setInputLineResponses(baseSettings.inputLineResponses()); } @@ -964,6 +966,16 @@ public void setEnableShellEscape(boolean enable) { this.enableShellEscape = enable; } + @Override + public boolean fuzzyCompletion() { + return fuzzyCompletion; + } + + @Override + public void setFuzzyCompletion(boolean enable) { + this.fuzzyCompletion = enable; + } + @Override public Consumer commandOutputHandler() { return commandOutputHandler; diff --git a/aesh/src/test/java/org/aesh/command/completer/FuzzyCompletionTest.java b/aesh/src/test/java/org/aesh/command/completer/FuzzyCompletionTest.java new file mode 100644 index 00000000..671b7df7 --- /dev/null +++ b/aesh/src/test/java/org/aesh/command/completer/FuzzyCompletionTest.java @@ -0,0 +1,255 @@ +package org.aesh.command.completer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.impl.invocation.AeshInvocationProviders; +import org.aesh.command.impl.registry.AeshCommandRegistryBuilder; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.invocation.InvocationProviders; +import org.aesh.command.option.Option; +import org.aesh.command.registry.CommandRegistry; +import org.aesh.command.settings.Settings; +import org.aesh.command.settings.SettingsBuilder; +import org.aesh.console.ReadlineConsole; +import org.aesh.readline.prompt.Prompt; +import org.aesh.terminal.Key; +import org.aesh.tty.TestConnection; +import org.junit.Test; + +/** + * Tests for fuzzy/camel-case completion fallback (#510). + *

+ * When prefix matching finds no candidates, the typed word is fuzzy-matched + * against command, subcommand, and option names. A single match replaces the + * typed word; multiple matches are listed ranked by score. Exact prefix + * behavior is unchanged. + *

+ * Replacement assertions press enter and read the executed command line from + * the execution listener, because the word replacement redraws the line and + * raw terminal output still contains the original typed word. + */ +public class FuzzyCompletionTest { + + private final Key completeChar = Key.CTRL_I; + private final Key enter = Key.ENTER; + + @Test + public void testFuzzyCommandNameSingleMatchReplacesWord() throws Exception { + TestConnection connection = new TestConnection(); + ReadlineConsole console = setupConsole(connection, true); + + assertEquals("commit", completeAndExecute(connection, "comit")); + + console.stop(); + } + + @Test + public void testFuzzyDisabledViaSettings() throws Exception { + TestConnection connection = new TestConnection(); + ReadlineConsole console = setupConsole(connection, false); + + connection.read("comit"); + connection.read(completeChar.getFirstValue()); + connection.assertBuffer("comit"); + + console.stop(); + } + + @Test + public void testFuzzyCommandNameMultipleMatchesAreListed() throws Exception { + TestConnection connection = new TestConnection(); + ReadlineConsole console = setupConsole(connection, true); + + connection.read("foa"); + connection.read(completeChar.getFirstValue()); + String output = connection.waitForOutputContaining("foobar", 5000); + assertTrue("Both fuzzy matches should be listed, got: " + output, + output.contains("foobar")); + assertTrue("Both fuzzy matches should be listed, got: " + output, + output.contains("foobaz")); + connection.assertBufferEndsWith("foa"); + + console.stop(); + } + + @Test + public void testPrefixCompletionUnchanged() throws Exception { + TestConnection connection = new TestConnection(); + ReadlineConsole console = setupConsole(connection, true); + + connection.read("com"); + connection.read(completeChar.getFirstValue()); + connection.assertBuffer("commit "); + + console.stop(); + } + + @Test + public void testSingleCharacterDoesNotTriggerFuzzy() throws Exception { + TestConnection connection = new TestConnection(); + ReadlineConsole console = setupConsole(connection, true); + + connection.read("x"); + connection.read(completeChar.getFirstValue()); + connection.assertBuffer("x"); + + console.stop(); + } + + @Test + public void testFuzzyOptionNameTypo() throws Exception { + TestConnection connection = new TestConnection(); + ReadlineConsole console = setupConsole(connection, true); + + assertEquals("commit --amend", completeAndExecute(connection, "commit --amnd")); + + console.stop(); + } + + @Test + public void testCamelCaseOptionAbbreviation() throws Exception { + TestConnection connection = new TestConnection(); + ReadlineConsole console = setupConsole(connection, true); + + assertEquals("camel --getCursorIndex", completeAndExecute(connection, "camel --gCI")); + + console.stop(); + } + + @Test + public void testFuzzySubcommandName() throws Exception { + TestConnection connection = new TestConnection(); + ReadlineConsole console = setupConsole(connection, true); + + assertEquals("git commit", completeAndExecute(connection, "git comit")); + + console.stop(); + } + + @Test + public void testInvocationProvidersFuzzyFlagDefaults() { + InvocationProviders plain = new AeshInvocationProviders(); + assertFalse("Providers built without settings must not enable fuzzy completion", + plain.fuzzyCompletion()); + + Settings enabled = SettingsBuilder.builder().build(); + assertTrue("Settings default should enable fuzzy completion", + enabled.fuzzyCompletion()); + assertTrue("Providers built from default settings should enable fuzzy completion", + new AeshInvocationProviders(enabled).fuzzyCompletion()); + + Settings disabled = SettingsBuilder.builder() + .fuzzyCompletion(false) + .build(); + assertFalse(disabled.fuzzyCompletion()); + assertFalse(new AeshInvocationProviders(disabled).fuzzyCompletion()); + } + + // ========== Helpers ========== + + /** + * Type the given text, press tab, press enter, and return the command + * line reported by the execution listener (trimmed). + */ + private String completeAndExecute(TestConnection connection, String typed) throws InterruptedException { + connection.read(typed); + connection.read(completeChar.getFirstValue()); + connection.read(enter.getFirstValue()); + assertTrue("Command should have been executed", executedLatch.await(5, TimeUnit.SECONDS)); + return executedLine.get().trim(); + } + + private final CountDownLatch executedLatch = new CountDownLatch(1); + private final AtomicReference executedLine = new AtomicReference<>(); + + // ========== Setup ========== + + private ReadlineConsole setupConsole(TestConnection connection, boolean fuzzy) throws Exception { + CommandRegistry registry = AeshCommandRegistryBuilder.builder() + .command(GitGroupCmd.class) + .command(GitCommitCmd.class) + .command(FooBarCmd.class) + .command(FooBazCmd.class) + .command(CamelCmd.class) + .create(); + + Settings settings = SettingsBuilder.builder() + .connection(connection) + .commandRegistry(registry) + .fuzzyCompletion(fuzzy) + .commandExecutionListener((line, result, durationMs) -> { + executedLine.set(line); + executedLatch.countDown(); + }) + .logging(true) + .build(); + + ReadlineConsole console = new ReadlineConsole(settings); + console.setPrompt(new Prompt("")); + console.start(); + return console; + } + + // ========== Test commands ========== + + @CommandDefinition(name = "git", description = "Git", generateHelp = true, groupCommands = { GitCommitCmd.class }) + public static class GitGroupCmd implements Command { + @Override + public CommandResult execute(CommandInvocation ci) { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "commit", description = "Commit changes", generateHelp = true) + public static class GitCommitCmd implements Command { + @Option(name = "all", shortName = 'a', hasValue = false, description = "Stage all modified files") + boolean all; + + @Option(name = "message", shortName = 'm', description = "Commit message") + String message; + + @Option(name = "amend", hasValue = false, description = "Amend previous commit") + boolean amend; + + @Override + public CommandResult execute(CommandInvocation ci) { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "foobar", description = "Foo bar") + public static class FooBarCmd implements Command { + @Override + public CommandResult execute(CommandInvocation ci) { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "foobaz", description = "Foo baz") + public static class FooBazCmd implements Command { + @Override + public CommandResult execute(CommandInvocation ci) { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "camel", description = "Camel case options") + public static class CamelCmd implements Command { + @Option(name = "getCursorIndex", hasValue = false, description = "Get the cursor index") + boolean getCursorIndex; + + @Override + public CommandResult execute(CommandInvocation ci) { + return CommandResult.SUCCESS; + } + } +} diff --git a/pom.xml b/pom.xml index 3c7d8bec..b050cfe2 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,7 @@ 8 - 3.18.7 + 3.18.8 0.6.0 UTF-8