Skip to content

Commit d159245

Browse files
committed
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. - Backport #778 - Relates #793
1 parent ee94ba3 commit d159245

File tree

6 files changed

+281
-5
lines changed

6 files changed

+281
-5
lines changed

spring-shell-core/src/main/java/org/springframework/shell/CommandNotFound.java

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/*
2-
* Copyright 2017 the original author or authors.
3-
*
2+
* Copyright 2017-2023
43
* Licensed under the Apache License, Version 2.0 (the "License");
54
* you may not use this file except in compliance with the License.
65
* You may obtain a copy of the License at
@@ -18,17 +17,28 @@
1817

1918
import java.util.ArrayList;
2019
import java.util.List;
20+
import java.util.Map;
2121
import java.util.stream.Collectors;
2222

23+
import org.springframework.shell.command.CommandRegistration;
24+
2325
/**
2426
* A result to be handled by the {@link ResultHandler} when no command could be mapped to user input
2527
*/
2628
public class CommandNotFound extends RuntimeException {
2729

2830
private final List<String> words;
31+
private final Map<String, CommandRegistration> registrations;
32+
private final String text;
2933

3034
public CommandNotFound(List<String> words) {
35+
this(words, null, null);
36+
}
37+
38+
public CommandNotFound(List<String> words, Map<String, CommandRegistration> registrations, String text) {
3139
this.words = words;
40+
this.registrations = registrations;
41+
this.text = text;
3242
}
3343

3444
@Override
@@ -44,4 +54,22 @@ public String getMessage() {
4454
public List<String> getWords(){
4555
return new ArrayList<>(words);
4656
}
57+
58+
/**
59+
* Gets command registrations known when this error was created.
60+
*
61+
* @return known command registrations
62+
*/
63+
public Map<String, CommandRegistration> getRegistrations() {
64+
return registrations;
65+
}
66+
67+
/**
68+
* Gets a raw text input.
69+
*
70+
* @return raw text input
71+
*/
72+
public String getText() {
73+
return text;
74+
}
4775
}

spring-shell-core/src/main/java/org/springframework/shell/Shell.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.lang.reflect.UndeclaredThrowableException;
1919
import java.nio.channels.ClosedByInterruptException;
2020
import java.util.ArrayList;
21+
import java.util.HashMap;
2122
import java.util.List;
2223
import java.util.Map;
2324
import java.util.Optional;
@@ -201,13 +202,14 @@ protected Object evaluate(Input input) {
201202
String line = words.stream().collect(Collectors.joining(" ")).trim();
202203
String command = findLongestCommand(line, false);
203204

205+
Map<String, CommandRegistration> registrations = commandRegistry.getRegistrations();
204206
if (command == null) {
205-
return new CommandNotFound(words);
207+
return new CommandNotFound(words, new HashMap<>(registrations), input.rawText());
206208
}
207209

208210
log.debug("Evaluate input with line=[{}], command=[{}]", line, command);
209211

210-
Optional<CommandRegistration> commandRegistration = commandRegistry.getRegistrations().values().stream()
212+
Optional<CommandRegistration> commandRegistration = registrations.values().stream()
211213
.filter(r -> {
212214
if (r.getCommand().equals(command)) {
213215
return true;
@@ -222,7 +224,7 @@ protected Object evaluate(Input input) {
222224
.findFirst();
223225

224226
if (commandRegistration.isEmpty()) {
225-
return new CommandNotFound(words);
227+
return new CommandNotFound(words, new HashMap<>(registrations), input.rawText());
226228
}
227229

228230
if (this.exitCodeMappings != null) {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.shell.result;
17+
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.function.Function;
21+
22+
import org.springframework.shell.command.CommandRegistration;
23+
import org.springframework.shell.result.CommandNotFoundMessageProvider.ProviderContext;
24+
25+
/**
26+
* Provider for a message used within {@link CommandNotFoundResultHandler}.
27+
*
28+
* @author Janne Valkealahti
29+
*/
30+
@FunctionalInterface
31+
public interface CommandNotFoundMessageProvider extends Function<ProviderContext, String> {
32+
33+
static ProviderContext contextOf(Throwable error, List<String> commands, Map<String, CommandRegistration> registrations, String text) {
34+
return new ProviderContext() {
35+
36+
@Override
37+
public Throwable error() {
38+
return error;
39+
}
40+
41+
@Override
42+
public List<String> commands() {
43+
return commands;
44+
}
45+
46+
@Override
47+
public Map<String, CommandRegistration> registrations() {
48+
return registrations;
49+
}
50+
51+
@Override
52+
public String text() {
53+
return text;
54+
}
55+
};
56+
}
57+
58+
/**
59+
* Context for {@link CommandNotFoundResultHandler}.
60+
*/
61+
interface ProviderContext {
62+
63+
/**
64+
* Gets an actual error.
65+
*
66+
* @return actual error
67+
*/
68+
Throwable error();
69+
70+
/**
71+
* Gets a list of commands parsed.
72+
*
73+
* @return list of commands parsed
74+
*/
75+
List<String> commands();
76+
77+
/**
78+
* Gets a command registrations.
79+
*
80+
* @return a command registrations
81+
*/
82+
Map<String, CommandRegistration> registrations();
83+
84+
/**
85+
* Gets a raw input text.
86+
*
87+
* @return a raw input text
88+
*/
89+
String text();
90+
}
91+
92+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package org.springframework.shell.result;
2+
3+
import org.jline.terminal.Terminal;
4+
import org.jline.utils.AttributedString;
5+
import org.jline.utils.AttributedStyle;
6+
7+
import org.springframework.beans.factory.ObjectProvider;
8+
import org.springframework.shell.CommandNotFound;
9+
import org.springframework.shell.ResultHandler;
10+
import org.springframework.shell.result.CommandNotFoundMessageProvider.ProviderContext;
11+
import org.springframework.util.Assert;
12+
import org.springframework.util.StringUtils;
13+
14+
/**
15+
* {@link ResultHandler} for {@link CommandNotFound} using
16+
* {@link CommandNotFoundMessageProvider} to provide an error message.
17+
* Default internal provider simply provides message from a {@link CommandNotFound}
18+
* with a red color. Provider can be defined by providing a custom
19+
* {@link CommandNotFoundMessageProvider} bean.
20+
*
21+
* @author Janne Valkealahti
22+
*/
23+
public final class CommandNotFoundResultHandler extends TerminalAwareResultHandler<CommandNotFound> {
24+
25+
private CommandNotFoundMessageProvider provider;
26+
27+
public CommandNotFoundResultHandler(Terminal terminal, ObjectProvider<CommandNotFoundMessageProvider> provider) {
28+
super(terminal);
29+
Assert.notNull(provider, "provider cannot be null");
30+
this.provider = provider.getIfAvailable(() -> new DefaultProvider());
31+
}
32+
33+
@Override
34+
protected void doHandleResult(CommandNotFound result) {
35+
ProviderContext context = CommandNotFoundMessageProvider.contextOf(result, result.getWords(),
36+
result.getRegistrations(), result.getText());
37+
String message = provider.apply(context);
38+
if (StringUtils.hasText(message)) {
39+
terminal.writer().println(message);
40+
}
41+
}
42+
43+
private static class DefaultProvider implements CommandNotFoundMessageProvider {
44+
45+
@Override
46+
public String apply(ProviderContext context) {
47+
String message = new AttributedString(context.error().getMessage(),
48+
AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi();
49+
return message;
50+
}
51+
}
52+
}

spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,10 @@ public ThrowableResultHandler throwableResultHandler(Terminal terminal, CommandC
7070
ShellContext shellContext, ObjectProvider<InteractiveShellRunner> interactiveApplicationRunner) {
7171
return new ThrowableResultHandler(terminal, commandCatalog, shellContext, interactiveApplicationRunner);
7272
}
73+
74+
@Bean
75+
public CommandNotFoundResultHandler commandNotFoundResultHandler(Terminal terminal,
76+
ObjectProvider<CommandNotFoundMessageProvider> provider) {
77+
return new CommandNotFoundResultHandler(terminal, provider);
78+
}
7379
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright 2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.shell.result;
17+
18+
import java.io.PrintWriter;
19+
import java.io.StringWriter;
20+
import java.util.Arrays;
21+
import java.util.Collections;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.stream.Collectors;
25+
26+
import org.jline.terminal.Terminal;
27+
import org.junit.jupiter.api.Test;
28+
import org.junit.jupiter.api.extension.ExtendWith;
29+
import org.mockito.Mock;
30+
import org.mockito.junit.jupiter.MockitoExtension;
31+
32+
import org.springframework.beans.factory.ObjectProvider;
33+
import org.springframework.shell.CommandNotFound;
34+
import org.springframework.shell.command.CommandRegistration;
35+
36+
import static org.assertj.core.api.Assertions.assertThat;
37+
import static org.mockito.ArgumentMatchers.any;
38+
import static org.mockito.BDDMockito.given;
39+
40+
@ExtendWith(MockitoExtension.class)
41+
class CommandNotFoundResultHandlerTests {
42+
43+
@Mock
44+
ObjectProvider<CommandNotFoundMessageProvider> provider;
45+
46+
@Mock
47+
Terminal terminal;
48+
49+
@Test
50+
void defaultProviderPrintsBasicMessage() {
51+
StringWriter out = new StringWriter();
52+
PrintWriter writer = new PrintWriter(out);
53+
given(provider.getIfAvailable()).willReturn(null);
54+
given(provider.getIfAvailable(any())).willCallRealMethod();
55+
given(terminal.writer()).willReturn(writer);
56+
CommandNotFoundResultHandler handler = new CommandNotFoundResultHandler(terminal, provider);
57+
CommandNotFound e = new CommandNotFound(Arrays.asList("one", "two"), Collections.emptyMap(), "one two --xxx");
58+
handler.handleResult(e);
59+
String string = out.toString();
60+
assertThat(string).contains("No command found for 'one two'");
61+
}
62+
63+
@Test
64+
void customProviderPrintsBasicMessage() {
65+
StringWriter out = new StringWriter();
66+
PrintWriter writer = new PrintWriter(out);
67+
given(provider.getIfAvailable()).willReturn(ctx -> "hi");
68+
given(provider.getIfAvailable(any())).willCallRealMethod();
69+
given(terminal.writer()).willReturn(writer);
70+
CommandNotFoundResultHandler handler = new CommandNotFoundResultHandler(terminal, provider);
71+
CommandNotFound e = new CommandNotFound(Arrays.asList("one", "two"), Collections.emptyMap(), "one two --xxx");
72+
handler.handleResult(e);
73+
String string = out.toString();
74+
assertThat(string).contains("hi");
75+
}
76+
77+
@Test
78+
void customProviderGetsContext() {
79+
StringWriter out = new StringWriter();
80+
PrintWriter writer = new PrintWriter(out);
81+
List<String> commands = Arrays.asList("one", "two");
82+
Map<String, CommandRegistration> registrations = Collections.emptyMap();
83+
CommandNotFound e = new CommandNotFound(commands, registrations, "text");
84+
given(provider.getIfAvailable()).willReturn(ctx -> {
85+
return String.format("%s%s%s%s%s", "hi", ctx.error() == e ? "true" : "false",
86+
ctx.commands().stream().collect(Collectors.joining()),
87+
ctx.registrations() == registrations ? "true" : "false", ctx.text());
88+
});
89+
given(provider.getIfAvailable(any())).willCallRealMethod();
90+
given(terminal.writer()).willReturn(writer);
91+
CommandNotFoundResultHandler handler = new CommandNotFoundResultHandler(terminal, provider);
92+
handler.handleResult(e);
93+
String string = out.toString();
94+
assertThat(string).contains("hitrueonetwotruetext");
95+
}
96+
}

0 commit comments

Comments
 (0)