Make command not found configurable

- New CommandNotFoundResultHandler which handles
  CommandNotFound to be able to customize error shown.
- New CommandNotFoundMessageProvider which is a plain
  function given a "context" and returns a string.
  Context contains common info to provide better
  error messages.
- Default provider gives same message but
  removes long stacktrace(which previously originated
  from a common ThrowableResultHandler.
- Relates #778
This commit is contained in:
Janne Valkealahti
2023-06-20 11:31:19 +01:00
parent 38f9126aeb
commit 9ef509a4da
6 changed files with 281 additions and 5 deletions

View File

@@ -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<String> words;
private final Map<String, CommandRegistration> registrations;
private final String text;
public CommandNotFound(List<String> words) {
this(words, null, null);
}
public CommandNotFound(List<String> words, Map<String, CommandRegistration> registrations, String text) {
this.words = words;
this.registrations = registrations;
this.text = text;
}
@Override
@@ -44,4 +54,22 @@ public class CommandNotFound extends RuntimeException {
public List<String> getWords(){
return new ArrayList<>(words);
}
/**
* Gets command registrations known when this error was created.
*
* @return known command registrations
*/
public Map<String, CommandRegistration> getRegistrations() {
return registrations;
}
/**
* Gets a raw text input.
*
* @return raw text input
*/
public String getText() {
return text;
}
}

View File

@@ -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<String, CommandRegistration> 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> commandRegistration = commandRegistry.getRegistrations().values().stream()
Optional<CommandRegistration> 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) {

View File

@@ -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<ProviderContext, String> {
static ProviderContext contextOf(Throwable error, List<String> commands, Map<String, CommandRegistration> registrations, String text) {
return new ProviderContext() {
@Override
public Throwable error() {
return error;
}
@Override
public List<String> commands() {
return commands;
}
@Override
public Map<String, CommandRegistration> 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<String> commands();
/**
* Gets a command registrations.
*
* @return a command registrations
*/
Map<String, CommandRegistration> registrations();
/**
* Gets a raw input text.
*
* @return a raw input text
*/
String text();
}
}

View File

@@ -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<CommandNotFound> {
private CommandNotFoundMessageProvider provider;
public CommandNotFoundResultHandler(Terminal terminal, ObjectProvider<CommandNotFoundMessageProvider> 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;
}
}
}

View File

@@ -70,4 +70,10 @@ public class ResultHandlerConfig {
ShellContext shellContext, ObjectProvider<InteractiveShellRunner> interactiveApplicationRunner) {
return new ThrowableResultHandler(terminal, commandCatalog, shellContext, interactiveApplicationRunner);
}
@Bean
public CommandNotFoundResultHandler commandNotFoundResultHandler(Terminal terminal,
ObjectProvider<CommandNotFoundMessageProvider> provider) {
return new CommandNotFoundResultHandler(terminal, provider);
}
}

View File

@@ -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<CommandNotFoundMessageProvider> 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<String> commands = Arrays.asList("one", "two");
Map<String, CommandRegistration> 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");
}
}