From 1a8dfee75ceb132147c860107c04bf8bc977347e Mon Sep 17 00:00:00 2001 From: Tristan Tarrant Date: Wed, 25 Feb 2026 18:02:44 +0100 Subject: [PATCH] [#368] Add CommandSuggestionProvider and expose registry from ReadlineConsole --- .../completer/CommandSuggestionProvider.java | 177 ++++++++++++++ .../org/aesh/console/ReadlineConsole.java | 33 +++ .../CommandSuggestionProviderTest.java | 229 ++++++++++++++++++ pom.xml | 2 +- 4 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 aesh/src/main/java/org/aesh/command/impl/completer/CommandSuggestionProvider.java create mode 100644 aesh/src/test/java/org/aesh/command/completer/CommandSuggestionProviderTest.java diff --git a/aesh/src/main/java/org/aesh/command/impl/completer/CommandSuggestionProvider.java b/aesh/src/main/java/org/aesh/command/impl/completer/CommandSuggestionProvider.java new file mode 100644 index 00000000..102127b9 --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/impl/completer/CommandSuggestionProvider.java @@ -0,0 +1,177 @@ +package org.aesh.command.impl.completer; + +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.aesh.command.CommandNotFoundException; +import org.aesh.command.container.CommandContainer; +import org.aesh.command.impl.internal.ProcessedCommand; +import org.aesh.command.impl.internal.ProcessedOption; +import org.aesh.command.impl.parser.CommandLineParser; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.registry.CommandRegistry; +import org.aesh.readline.SuggestionProvider; +import org.aesh.terminal.utils.LoggerUtil; + +/** + * Suggests command names, subcommand names, and option names from a {@link CommandRegistry}. + *

+ * Only suggests when there is exactly one unambiguous match. + */ +public class CommandSuggestionProvider implements SuggestionProvider { + + private static final Logger LOGGER = LoggerUtil.getLogger(CommandSuggestionProvider.class.getName()); + + private final CommandRegistry registry; + + public CommandSuggestionProvider(CommandRegistry registry) { + this.registry = registry; + } + + @Override + public String suggest(String buffer) { + if (buffer == null || buffer.isEmpty()) { + return null; + } + + String trimmed = buffer.trim(); + if (trimmed.isEmpty()) { + return null; + } + + // Check if the user is typing an option (--something) + int lastSpace = buffer.lastIndexOf(' '); + if (lastSpace >= 0) { + String lastWord = buffer.substring(lastSpace + 1); + if (lastWord.startsWith("--")) { + return suggestOption(trimmed, lastWord); + } + } + + // Check if buffer contains spaces -> could be subcommand + if (trimmed.contains(" ")) { + return suggestSubcommand(trimmed); + } + + // Single word -> suggest command name + return suggestCommand(trimmed, buffer); + } + + private String suggestCommand(String prefix, String originalBuffer) { + Set allNames = registry.getAllCommandNames(); + String match = null; + for (String name : allNames) { + if (name.startsWith(prefix) && !name.equals(prefix)) { + if (!isCommandActivated(name)) { + continue; + } + if (match != null) { + // Ambiguous - more than one match + return null; + } + match = name; + } + } + if (match != null) { + return match.substring(prefix.length()); + } + return null; + } + + private String suggestSubcommand(String trimmed) { + // Split into command name and the rest + int firstSpace = trimmed.indexOf(' '); + String commandName = trimmed.substring(0, firstSpace); + String rest = trimmed.substring(firstSpace + 1).trim(); + + if (rest.isEmpty()) { + return null; + } + + try { + CommandContainer container = registry.getCommand(commandName, trimmed); + CommandLineParser parser = container.getParser(); + + if (parser.isGroupCommand()) { + // rest is a partial subcommand name + List> childParsers = parser.getAllChildParsers(); + String match = null; + for (CommandLineParser child : childParsers) { + String childName = child.getProcessedCommand().name(); + if (childName.startsWith(rest) && !childName.equals(rest)) { + if (match != null) { + return null; // ambiguous + } + match = childName; + } + } + if (match != null) { + return match.substring(rest.length()); + } + } + } catch (CommandNotFoundException e) { + // Command not found, no suggestion + } catch (Exception e) { + LOGGER.log(Level.FINE, "Error suggesting subcommand", e); + } + return null; + } + + private String suggestOption(String trimmed, String lastWord) { + // Extract command name from the full buffer + String[] parts = trimmed.split("\\s+"); + if (parts.length < 1) { + return null; + } + + String commandName = parts[0]; + String prefix = lastWord.substring(2); // strip -- + + try { + CommandContainer container = registry.getCommand(commandName, trimmed); + CommandLineParser parser = container.getParser(); + + ProcessedCommand processedCommand; + + // For group commands, check if we have a subcommand + if (parser.isGroupCommand() && parts.length >= 2 && !parts[1].startsWith("-")) { + CommandLineParser childParser = parser.getChildParser(parts[1]); + if (childParser != null) { + processedCommand = childParser.getProcessedCommand(); + } else { + processedCommand = parser.getProcessedCommand(); + } + } else { + processedCommand = parser.getProcessedCommand(); + } + + List possibleNames = processedCommand.findPossibleLongNames(prefix); + if (possibleNames.size() == 1) { + String optionName = possibleNames.get(0); + String suffix = optionName.substring(prefix.length()); + // Append = if the option takes a value + ProcessedOption option = processedCommand.findLongOptionNoActivatorCheck(optionName); + if (option != null && option.hasValue()) { + suffix += "="; + } + return suffix; + } + } catch (CommandNotFoundException e) { + // Command not found, no suggestion + } catch (Exception e) { + LOGGER.log(Level.FINE, "Error suggesting option", e); + } + return null; + } + + private boolean isCommandActivated(String name) { + try { + CommandContainer container = registry.getCommand(name, name); + return container.getParser().getProcessedCommand().getActivator().isActivated(null); + } catch (Exception e) { + return true; // default to activated if we can't check + } + } +} diff --git a/aesh/src/main/java/org/aesh/console/ReadlineConsole.java b/aesh/src/main/java/org/aesh/console/ReadlineConsole.java index b383c4c1..be20b838 100644 --- a/aesh/src/main/java/org/aesh/console/ReadlineConsole.java +++ b/aesh/src/main/java/org/aesh/console/ReadlineConsole.java @@ -56,6 +56,7 @@ import org.aesh.command.invocation.CommandInvocation; import org.aesh.command.operator.OperatorType; import org.aesh.command.parser.CommandLineParserException; +import org.aesh.command.registry.CommandRegistry; import org.aesh.command.registry.CommandRegistryException; import org.aesh.command.registry.MutableCommandRegistry; import org.aesh.command.settings.Settings; @@ -69,6 +70,7 @@ import org.aesh.readline.Prompt; import org.aesh.readline.Readline; import org.aesh.readline.ReadlineFlag; +import org.aesh.readline.SuggestionProvider; import org.aesh.readline.alias.AliasCompletion; import org.aesh.readline.alias.AliasManager; import org.aesh.readline.alias.AliasPreProcessor; @@ -108,6 +110,7 @@ public class ReadlineConsole implements Console, Consumer { private volatile boolean running = false; private History history; + private SuggestionProvider suggestionProvider; private ShellImpl shell; private CommandContext commandContext; @@ -282,6 +285,9 @@ private void init() { } readline = new Readline(EditModeBuilder.builder(settings.mode()).create(), history, completionHandler); + if (suggestionProvider != null) { + readline.setSuggestionProvider(suggestionProvider); + } running = true; } @@ -560,4 +566,31 @@ public boolean isInSubCommandMode() { return commandContext != null && commandContext.isInSubCommandMode(); } + /** + * Sets the suggestion provider for inline ghost text suggestions. + * + * @param provider the suggestion provider + */ + public void setSuggestionProvider(SuggestionProvider provider) { + this.suggestionProvider = provider; + } + + /** + * Gets the command history. + * + * @return the history instance + */ + public History getHistory() { + return history; + } + + /** + * Gets the command registry. + * + * @return the command registry + */ + public CommandRegistry getCommandRegistry() { + return commandResolver.getRegistry(); + } + } diff --git a/aesh/src/test/java/org/aesh/command/completer/CommandSuggestionProviderTest.java b/aesh/src/test/java/org/aesh/command/completer/CommandSuggestionProviderTest.java new file mode 100644 index 00000000..ee9f2c76 --- /dev/null +++ b/aesh/src/test/java/org/aesh/command/completer/CommandSuggestionProviderTest.java @@ -0,0 +1,229 @@ +package org.aesh.command.completer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandException; +import org.aesh.command.CommandResult; +import org.aesh.command.GroupCommandDefinition; +import org.aesh.command.activator.CommandActivator; +import org.aesh.command.activator.OptionActivator; +import org.aesh.command.converter.ConverterInvocation; +import org.aesh.command.impl.completer.CommandSuggestionProvider; +import org.aesh.command.impl.registry.AeshCommandRegistryBuilder; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.option.Option; +import org.aesh.command.registry.CommandRegistry; +import org.aesh.command.registry.CommandRegistryException; +import org.aesh.command.settings.Settings; +import org.aesh.command.settings.SettingsBuilder; +import org.aesh.command.validator.ValidatorInvocation; +import org.aesh.console.ReadlineConsole; +import org.aesh.readline.CompositeSuggestionProvider; +import org.aesh.readline.Prompt; +import org.aesh.readline.SuggestionProvider; +import org.aesh.tty.TestConnection; +import org.junit.Test; + +public class CommandSuggestionProviderTest { + + @Test + public void testCommandNameSuggestion() throws CommandRegistryException { + CommandRegistry registry = AeshCommandRegistryBuilder. builder() + .command(CacheCommand.class) + .command(ConnectCommand.class) + .create(); + + CommandSuggestionProvider provider = new CommandSuggestionProvider<>(registry); + + // "ca" should suggest "che" (from "cache") + assertEquals("che", provider.suggest("ca")); + + // "con" should suggest "nect" (from "connect") + assertEquals("nect", provider.suggest("con")); + + // "c" is ambiguous (cache and connect) -> null + assertNull(provider.suggest("c")); + + // Full command name -> null + assertNull(provider.suggest("cache")); + + // Empty -> null + assertNull(provider.suggest("")); + assertNull(provider.suggest(null)); + } + + @Test + public void testSubcommandSuggestion() throws CommandRegistryException { + CommandRegistry registry = AeshCommandRegistryBuilder. builder() + .command(GitCommand.class) + .create(); + + CommandSuggestionProvider provider = new CommandSuggestionProvider<>(registry); + + // "git co" should suggest "mmit" (from "commit") + assertEquals("mmit", provider.suggest("git co")); + + // "git r" should suggest "ebase" (from "rebase", only one match) + assertEquals("ebase", provider.suggest("git r")); + } + + @Test + public void testOptionSuggestion() throws CommandRegistryException { + CommandRegistry registry = AeshCommandRegistryBuilder. builder() + .command(ConnectCommand.class) + .create(); + + CommandSuggestionProvider provider = new CommandSuggestionProvider<>(registry); + + // "connect --ho" should suggest "st=" (from "--host") + assertEquals("st=", provider.suggest("connect --ho")); + + // "connect --p" is ambiguous (port and password) -> null + assertNull(provider.suggest("connect --p")); + } + + @Test + public void testGroupCommandOptionSuggestion() throws CommandRegistryException { + CommandRegistry registry = AeshCommandRegistryBuilder. builder() + .command(GitCommand.class) + .create(); + + CommandSuggestionProvider provider = new CommandSuggestionProvider<>(registry); + + // "git commit --al" should suggest "l" (from "--all", boolean so no =) + assertEquals("l", provider.suggest("git commit --al")); + } + + @Test + public void testCompositeSuggestionProvider() throws CommandRegistryException { + CommandRegistry registry = AeshCommandRegistryBuilder. builder() + .command(CacheCommand.class) + .create(); + + // History provider that returns null for everything + SuggestionProvider historyProvider = buffer -> null; + CommandSuggestionProvider commandProvider = new CommandSuggestionProvider<>(registry); + + CompositeSuggestionProvider composite = new CompositeSuggestionProvider(historyProvider, commandProvider); + + // With no history match, falls through to command provider + assertEquals("che", composite.suggest("ca")); + + // History provider that returns a match + SuggestionProvider historyWithMatch = buffer -> { + if (buffer.equals("ca")) + return "che --name=test"; + return null; + }; + + CompositeSuggestionProvider compositeWithHistory = new CompositeSuggestionProvider(historyWithMatch, commandProvider); + + // History match takes priority + assertEquals("che --name=test", compositeWithHistory.suggest("ca")); + } + + @Test + public void testIntegrationWithConsole() throws Exception { + TestConnection connection = new TestConnection(false); + + CommandRegistry registry = AeshCommandRegistryBuilder.builder() + .command(CacheCommand.class) + .command(ConnectCommand.class) + .create(); + + Settings settings = SettingsBuilder + .builder() + .logging(true) + .enableAlias(false) + .connection(connection) + .commandRegistry(registry) + .build(); + + ReadlineConsole console = new ReadlineConsole(settings); + console.setPrompt(new Prompt("")); + + // Set up command suggestion provider using the registry from console + console.setSuggestionProvider(new CommandSuggestionProvider<>(console.getCommandRegistry())); + + console.start(); + + connection.clearOutputBuffer(); + connection.read("ca"); + + // Ghost text is rendered with ANSI DIM codes; in non-stripped mode we should see "che" in the output + Thread.sleep(50); + String output = connection.getOutputBuffer(); + // The output should contain both the typed text and the ghost suggestion + // "ca" is typed, then "che" is shown as ghost text (with ANSI codes around it) + assert output.contains("ca") : "Output should contain typed text 'ca', got: " + output; + assert output.contains("che") : "Output should contain ghost suggestion 'che', got: " + output; + + console.stop(); + } + + // --- Test command classes --- + + @CommandDefinition(name = "cache", description = "Cache operations") + public static class CacheCommand implements Command { + @Option + private String name; + + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "connect", description = "Connect to a server") + public static class ConnectCommand implements Command { + @Option + private String host; + + @Option + private int port; + + @Option + private String password; + + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @GroupCommandDefinition(name = "git", description = "Git operations", groupCommands = { GitCommit.class, GitRebase.class }) + public static class GitCommand implements Command { + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "commit", description = "Commit changes") + public static class GitCommit implements Command { + @Option(hasValue = false) + private boolean all; + + @Option + private String message; + + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "rebase", description = "Rebase branch") + public static class GitRebase implements Command { + @Option(hasValue = false) + private boolean force; + + @Override + public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } +} diff --git a/pom.xml b/pom.xml index 21287a8b..969d2551 100644 --- a/pom.xml +++ b/pom.xml @@ -53,7 +53,7 @@ 1.8 1.8 - 3.1 + 3.2 0.1.0 UTF-8