From 669baf0ef801c87d2a23c5ce9942ed117e8d614f Mon Sep 17 00:00:00 2001 From: Matheus Oliveira Date: Sun, 25 Oct 2026 10:37:20 -0300 Subject: [PATCH] feat: Add fuzzy/camel-case completion fallback Tab completion only matches exact prefixes, so typos like comit and camelCase abbreviations like gCI find nothing. When prefix matching returns no candidates, the typed word is fuzzy-matched against command, subcommand, and long option names with the fzf-based scorer shipped in aesh-readline, ranked by score. Single matches replace the typed word using the new replacingCurrentWord completion flag, so this requires aesh-readline 3.18.8. Enabled by default via SettingsBuilder.fuzzyCompletion; runtimes built without settings, including dynamic shell completion, are unaffected. * Closes #510 Signed-off-by: Matheus Oliveira --- .../command/AeshCommandRuntimeBuilder.java | 3 +- .../completer/FuzzyCompletionMatcher.java | 120 +++++++++ .../invocation/AeshInvocationProviders.java | 18 ++ .../AeshCommandLineCompletionParser.java | 75 +++++- .../registry/MutableCommandRegistryImpl.java | 24 ++ .../invocation/InvocationProviders.java | 11 + .../org/aesh/command/settings/Settings.java | 22 ++ .../command/settings/SettingsBuilder.java | 13 + .../aesh/command/settings/SettingsImpl.java | 12 + .../completer/FuzzyCompletionTest.java | 255 ++++++++++++++++++ pom.xml | 2 +- 11 files changed, 541 insertions(+), 14 deletions(-) create mode 100644 aesh/src/main/java/org/aesh/command/impl/completer/FuzzyCompletionMatcher.java create mode 100644 aesh/src/test/java/org/aesh/command/completer/FuzzyCompletionTest.java diff --git a/aesh/src/main/java/org/aesh/command/AeshCommandRuntimeBuilder.java b/aesh/src/main/java/org/aesh/command/AeshCommandRuntimeBuilder.java index 3db92ea26..db9de637e 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 000000000..13e0b2e8d --- /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 579b9af5a..ca04146c6 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 75da77c46..9a2974d02 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 d25c863ba..58708e0b8 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 b95ce2ed5..dfaebdf9f 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 da773a76d..484becaa2 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 179e82d44..de04c1841 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 156f07638..e3a882fc4 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 000000000..671b7df72 --- /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 3c7d8bec9..b050cfe29 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,7 @@ 8 - 3.18.7 + 3.18.8 0.6.0 UTF-8