diff --git a/spring-shell-core/src/main/java/org/springframework/shell/CommandNotFound.java b/spring-shell-core/src/main/java/org/springframework/shell/CommandNotFound.java index f0ec7dad..61ba13ce 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/CommandNotFound.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/CommandNotFound.java @@ -1,6 +1,5 @@ /* - * Copyright 2017 the original author or authors. - * + * Copyright 2017-2023 * 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 @@ -18,17 +17,28 @@ package org.springframework.shell; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; +import org.springframework.shell.command.CommandRegistration; + /** * A result to be handled by the {@link ResultHandler} when no command could be mapped to user input */ public class CommandNotFound extends RuntimeException { private final List words; + private final Map registrations; + private final String text; public CommandNotFound(List words) { + this(words, null, null); + } + + public CommandNotFound(List words, Map registrations, String text) { this.words = words; + this.registrations = registrations; + this.text = text; } @Override @@ -44,4 +54,22 @@ public class CommandNotFound extends RuntimeException { public List getWords(){ return new ArrayList<>(words); } + + /** + * Gets command registrations known when this error was created. + * + * @return known command registrations + */ + public Map getRegistrations() { + return registrations; + } + + /** + * Gets a raw text input. + * + * @return raw text input + */ + public String getText() { + return text; + } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java index 400f539b..d5c8ca63 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java @@ -18,6 +18,7 @@ package org.springframework.shell; import java.lang.reflect.UndeclaredThrowableException; import java.nio.channels.ClosedByInterruptException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -201,13 +202,14 @@ public class Shell { String line = words.stream().collect(Collectors.joining(" ")).trim(); String command = findLongestCommand(line, false); + Map registrations = commandRegistry.getRegistrations(); if (command == null) { - return new CommandNotFound(words); + return new CommandNotFound(words, new HashMap<>(registrations), input.rawText()); } log.debug("Evaluate input with line=[{}], command=[{}]", line, command); - Optional commandRegistration = commandRegistry.getRegistrations().values().stream() + Optional commandRegistration = registrations.values().stream() .filter(r -> { if (r.getCommand().equals(command)) { return true; @@ -222,7 +224,7 @@ public class Shell { .findFirst(); if (commandRegistration.isEmpty()) { - return new CommandNotFound(words); + return new CommandNotFound(words, new HashMap<>(registrations), input.rawText()); } if (this.exitCodeMappings != null) { diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/CommandNotFoundMessageProvider.java b/spring-shell-core/src/main/java/org/springframework/shell/result/CommandNotFoundMessageProvider.java new file mode 100644 index 00000000..66c9e1ef --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/result/CommandNotFoundMessageProvider.java @@ -0,0 +1,92 @@ +/* + * Copyright 2023 the original author or authors. + * + * 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 + * + * https://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.springframework.shell.result; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.result.CommandNotFoundMessageProvider.ProviderContext; + +/** + * Provider for a message used within {@link CommandNotFoundResultHandler}. + * + * @author Janne Valkealahti + */ +@FunctionalInterface +public interface CommandNotFoundMessageProvider extends Function { + + static ProviderContext contextOf(Throwable error, List commands, Map registrations, String text) { + return new ProviderContext() { + + @Override + public Throwable error() { + return error; + } + + @Override + public List commands() { + return commands; + } + + @Override + public Map registrations() { + return registrations; + } + + @Override + public String text() { + return text; + } + }; + } + + /** + * Context for {@link CommandNotFoundResultHandler}. + */ + interface ProviderContext { + + /** + * Gets an actual error. + * + * @return actual error + */ + Throwable error(); + + /** + * Gets a list of commands parsed. + * + * @return list of commands parsed + */ + List commands(); + + /** + * Gets a command registrations. + * + * @return a command registrations + */ + Map registrations(); + + /** + * Gets a raw input text. + * + * @return a raw input text + */ + String text(); + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/CommandNotFoundResultHandler.java b/spring-shell-core/src/main/java/org/springframework/shell/result/CommandNotFoundResultHandler.java new file mode 100644 index 00000000..bdf6a0bd --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/result/CommandNotFoundResultHandler.java @@ -0,0 +1,52 @@ +package org.springframework.shell.result; + +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStyle; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.shell.CommandNotFound; +import org.springframework.shell.ResultHandler; +import org.springframework.shell.result.CommandNotFoundMessageProvider.ProviderContext; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * {@link ResultHandler} for {@link CommandNotFound} using + * {@link CommandNotFoundMessageProvider} to provide an error message. + * Default internal provider simply provides message from a {@link CommandNotFound} + * with a red color. Provider can be defined by providing a custom + * {@link CommandNotFoundMessageProvider} bean. + * + * @author Janne Valkealahti + */ +public final class CommandNotFoundResultHandler extends TerminalAwareResultHandler { + + private CommandNotFoundMessageProvider provider; + + public CommandNotFoundResultHandler(Terminal terminal, ObjectProvider provider) { + super(terminal); + Assert.notNull(provider, "provider cannot be null"); + this.provider = provider.getIfAvailable(() -> new DefaultProvider()); + } + + @Override + protected void doHandleResult(CommandNotFound result) { + ProviderContext context = CommandNotFoundMessageProvider.contextOf(result, result.getWords(), + result.getRegistrations(), result.getText()); + String message = provider.apply(context); + if (StringUtils.hasText(message)) { + terminal.writer().println(message); + } + } + + private static class DefaultProvider implements CommandNotFoundMessageProvider { + + @Override + public String apply(ProviderContext context) { + String message = new AttributedString(context.error().getMessage(), + AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi(); + return message; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java b/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java index 12f8e536..24fde7e4 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java @@ -70,4 +70,10 @@ public class ResultHandlerConfig { ShellContext shellContext, ObjectProvider interactiveApplicationRunner) { return new ThrowableResultHandler(terminal, commandCatalog, shellContext, interactiveApplicationRunner); } + + @Bean + public CommandNotFoundResultHandler commandNotFoundResultHandler(Terminal terminal, + ObjectProvider provider) { + return new CommandNotFoundResultHandler(terminal, provider); + } } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/result/CommandNotFoundResultHandlerTests.java b/spring-shell-core/src/test/java/org/springframework/shell/result/CommandNotFoundResultHandlerTests.java new file mode 100644 index 00000000..7302eea2 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/result/CommandNotFoundResultHandlerTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2023 the original author or authors. + * + * 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 + * + * https://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.springframework.shell.result; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.jline.terminal.Terminal; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.shell.CommandNotFound; +import org.springframework.shell.command.CommandRegistration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class CommandNotFoundResultHandlerTests { + + @Mock + ObjectProvider provider; + + @Mock + Terminal terminal; + + @Test + void defaultProviderPrintsBasicMessage() { + StringWriter out = new StringWriter(); + PrintWriter writer = new PrintWriter(out); + given(provider.getIfAvailable()).willReturn(null); + given(provider.getIfAvailable(any())).willCallRealMethod(); + given(terminal.writer()).willReturn(writer); + CommandNotFoundResultHandler handler = new CommandNotFoundResultHandler(terminal, provider); + CommandNotFound e = new CommandNotFound(Arrays.asList("one", "two"), Collections.emptyMap(), "one two --xxx"); + handler.handleResult(e); + String string = out.toString(); + assertThat(string).contains("No command found for 'one two'"); + } + + @Test + void customProviderPrintsBasicMessage() { + StringWriter out = new StringWriter(); + PrintWriter writer = new PrintWriter(out); + given(provider.getIfAvailable()).willReturn(ctx -> "hi"); + given(provider.getIfAvailable(any())).willCallRealMethod(); + given(terminal.writer()).willReturn(writer); + CommandNotFoundResultHandler handler = new CommandNotFoundResultHandler(terminal, provider); + CommandNotFound e = new CommandNotFound(Arrays.asList("one", "two"), Collections.emptyMap(), "one two --xxx"); + handler.handleResult(e); + String string = out.toString(); + assertThat(string).contains("hi"); + } + + @Test + void customProviderGetsContext() { + StringWriter out = new StringWriter(); + PrintWriter writer = new PrintWriter(out); + List commands = Arrays.asList("one", "two"); + Map registrations = Collections.emptyMap(); + CommandNotFound e = new CommandNotFound(commands, registrations, "text"); + given(provider.getIfAvailable()).willReturn(ctx -> { + return String.format("%s%s%s%s%s", "hi", ctx.error() == e ? "true" : "false", + ctx.commands().stream().collect(Collectors.joining()), + ctx.registrations() == registrations ? "true" : "false", ctx.text()); + }); + given(provider.getIfAvailable(any())).willCallRealMethod(); + given(terminal.writer()).willReturn(writer); + CommandNotFoundResultHandler handler = new CommandNotFoundResultHandler(terminal, provider); + handler.handleResult(e); + String string = out.toString(); + assertThat(string).contains("hitrueonetwotruetext"); + } +}