Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@ public CommandRuntime<CI> 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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}.
* <p>
* 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<TerminalString> fuzzyMatches(String input, Collection<String> candidates) {
List<TerminalString> matches = new ArrayList<>();
if (!isEligible(input) || candidates == null || candidates.isEmpty())
return matches;

int[] pattern = Parser.toCodePoints(input);
List<ScoredCandidate> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -60,18 +61,30 @@ 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,
CompleterInvocationProvider completerInvocationProvider,
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> T defaultIfNull(T value, T defaultValue) {
Expand Down Expand Up @@ -102,4 +115,9 @@ public ValidatorInvocationProvider getValidatorProvider() {
public OptionActivatorProvider getOptionActivatorProvider() {
return optionActivatorProvider;
}

@Override
public boolean fuzzyCompletion() {
return fuzzyCompletion;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -98,7 +99,7 @@ public void injectValuesAndComplete(AeshCompleteOperation completeOperation, Inv
doProcessArgument(completeOperation, invocationProviders, line);
} else {
//list options
doListOptions(completeOperation, "");
doListOptions(completeOperation, "", invocationProviders);
}
}
}
Expand All @@ -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
Expand All @@ -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)) {
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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)) {
Expand All @@ -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())))
Expand All @@ -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<String> childNames = new java.util.ArrayList<>();
for (CommandLineParser child : parser.getAllChildParsers()) {
if (child.getProcessedCommand().isActivated(new ParsedCommand(child.getProcessedCommand())))
childNames.add(child.getProcessedCommand().name());
}
List<TerminalString> 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);
Expand All @@ -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 &&
Expand Down Expand Up @@ -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<TerminalString> optionNamesWithDash;
if (value.length() > 0 && !value.startsWith("-")) {
// Bare option prefix (e.g. "st" → "staging") — filter to matching bare options
Expand Down Expand Up @@ -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<TerminalString> universe = parser.getProcessedCommand().getOptionLongNamesWithDash();
addInheritedOptions(universe, "--");
List<TerminalString> fuzzyMatches = FuzzyCompletionMatcher.getInstance()
.fuzzyMatches(name, terminalStringsAsStrings(universe));
if (!fuzzyMatches.isEmpty()) {
completeOperation.addCompletionCandidatesTerminalString(fuzzyMatches);
completeOperation.setOffset(completeOperation.getCursor());
completeOperation.setReplacingCurrentWord(true);
}
}

private static List<String> terminalStringsAsStrings(List<TerminalString> candidates) {
List<String> 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.
Expand Down
Loading