Rework help command

- Change help command output to get templated using
  model classes.
- Remove things around ParameterDescription as those are
  replaced with template classes.
- Fixes for native configs.
- For now availability and aliases are removed from
  help to get back in better form.
- Aliases has been partly introduced to structure.
- Fixes #422
This commit is contained in:
Janne Valkealahti
2022-05-26 07:45:35 +01:00
committed by GitHub
parent eed1d84653
commit bd9ab62013
33 changed files with 1055 additions and 794 deletions

View File

@@ -188,6 +188,8 @@ public class SpringShellProperties {
public static class HelpCommand {
private boolean enabled = true;
private String commandTemplate = "classpath:template/help-command-default.stg";
private String commandsTemplate = "classpath:template/help-commands-default.stg";
private GroupingMode groupingMode = GroupingMode.GROUP;
public boolean isEnabled() {
@@ -198,6 +200,22 @@ public class SpringShellProperties {
this.enabled = enabled;
}
public String getCommandTemplate() {
return commandTemplate;
}
public void setCommandTemplate(String commandTemplate) {
this.commandTemplate = commandTemplate;
}
public String getCommandsTemplate() {
return commandsTemplate;
}
public void setCommandsTemplate(String commandsTemplate) {
this.commandsTemplate = commandsTemplate;
}
public GroupingMode getGroupingMode() {
return groupingMode;
}

View File

@@ -55,11 +55,13 @@ public class StandardCommandsAutoConfiguration {
@Bean
@ConditionalOnMissingBean(Help.Command.class)
@ConditionalOnProperty(prefix = "spring.shell.command.help", value = "enabled", havingValue = "true", matchIfMissing = true)
public Help help(SpringShellProperties properties) {
Help help = new Help();
public Help help(SpringShellProperties properties, ObjectProvider<TemplateExecutor> templateExecutor) {
Help help = new Help(templateExecutor.getIfAvailable());
if (properties.getCommand().getHelp().getGroupingMode() == GroupingMode.FLAT) {
help.setShowGroups(false);
}
help.setCommandTemplate(properties.getCommand().getHelp().getCommandTemplate());
help.setCommandsTemplate(properties.getCommand().getHelp().getCommandsTemplate());
return help;
}

View File

@@ -44,6 +44,8 @@ public class SpringShellPropertiesTests {
assertThat(properties.getCommand().getClear().isEnabled()).isTrue();
assertThat(properties.getCommand().getHelp().isEnabled()).isTrue();
assertThat(properties.getCommand().getHelp().getGroupingMode()).isEqualTo(GroupingMode.GROUP);
assertThat(properties.getCommand().getHelp().getCommandTemplate()).isNotNull();
assertThat(properties.getCommand().getHelp().getCommandsTemplate()).isNotNull();
assertThat(properties.getCommand().getHistory().isEnabled()).isTrue();
assertThat(properties.getCommand().getQuit().isEnabled()).isTrue();
assertThat(properties.getCommand().getScript().isEnabled()).isTrue();
@@ -78,6 +80,8 @@ public class SpringShellPropertiesTests {
.withPropertyValues("spring.shell.command.clear.enabled=false")
.withPropertyValues("spring.shell.command.help.enabled=false")
.withPropertyValues("spring.shell.command.help.grouping-mode=flat")
.withPropertyValues("spring.shell.command.help.command-template=fake1")
.withPropertyValues("spring.shell.command.help.commands-template=fake2")
.withPropertyValues("spring.shell.command.history.enabled=false")
.withPropertyValues("spring.shell.command.quit.enabled=false")
.withPropertyValues("spring.shell.command.script.enabled=false")
@@ -109,6 +113,8 @@ public class SpringShellPropertiesTests {
assertThat(properties.getCommand().getClear().isEnabled()).isFalse();
assertThat(properties.getCommand().getHelp().isEnabled()).isFalse();
assertThat(properties.getCommand().getHelp().getGroupingMode()).isEqualTo(GroupingMode.FLAT);
assertThat(properties.getCommand().getHelp().getCommandTemplate()).isEqualTo("fake1");
assertThat(properties.getCommand().getHelp().getCommandsTemplate()).isEqualTo("fake2");
assertThat(properties.getCommand().getHistory().isEnabled()).isFalse();
assertThat(properties.getCommand().getQuit().isEnabled()).isFalse();
assertThat(properties.getCommand().getScript().isEnabled()).isFalse();

View File

@@ -1,181 +0,0 @@
/*
* Copyright 2015 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;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.MethodParameter;
import javax.validation.metadata.ElementDescriptor;
/**
* Encapsulates information about a shell invokable method parameter, so that it can be documented.
*
* <p>Instances of this class are constructed by {@link ParameterResolver#describe(MethodParameter)}.</p>
*
* @author Eric Bottard
*/
public class ParameterDescription {
/**
* A string representation of the type of the parameter.
*/
private String type;
/**
* A string representation of the parameter, as it should appear in a parameter list.
* If not provided, this is derived from the parameter type.
*/
private String formal;
/**
* A string representation of the default value (if the option is left out entirely) for the parameter, if any.
*/
private Optional<String> defaultValue = Optional.empty();
/**
* A string representation of the default value for this parameter, if it can be used as a mere flag (<em>e.g.</em>
* {@literal --force} without a value, being an equivalent to {@literal --force true}).
* <p>{@literal Optional.empty()} (the default) means that this parameter cannot be used as a flag.</p>
*/
private Optional<String> defaultValueWhenFlag = Optional.empty();
/**
* The list of 'keys' that can be used to specify this parameter, if any.
*/
private List<String> keys = Collections.emptyList();
/**
* Depending on the {@link ParameterResolver}, whether keys are mandatory to identify this parameter.
*/
private boolean mandatoryKey = true;
/**
* A short description of this parameter.
*/
private String help = "";
/**
* Allows discovery of bean validation constraints for the command parameter.
* <p>Note that most often, constraints will directly come from parameter constraints,
* but sometimes (<em>e.g.</em> in case of one method argument mapping to multiple
* command options) may come from property constraints.</p>
*/
private ElementDescriptor elementDescriptor;
public void type(String type) {
this.type = type;
}
public ParameterDescription help(String help) {
this.help = help;
return this;
}
public boolean mandatoryKey() {
return mandatoryKey;
}
public List<String> keys() {
return keys;
}
public Optional<String> defaultValue() {
return defaultValue;
}
public ParameterDescription defaultValue(String defaultValue) {
this.defaultValue = Optional.ofNullable(defaultValue);
return this;
}
public ParameterDescription whenFlag(String defaultValue) {
this.defaultValueWhenFlag = Optional.of(defaultValue);
return this;
}
public ParameterDescription keys(List<String> keys) {
this.keys = keys;
return this;
}
public ParameterDescription mandatoryKey(boolean mandatoryKey) {
this.mandatoryKey = mandatoryKey;
return this;
}
/**
* @return an ElementDescriptor used to discover constraints. May be {@literal null}.
*/
public ElementDescriptor elementDescriptor() {
return this.elementDescriptor;
}
public ParameterDescription elementDescriptor(ElementDescriptor descriptor) {
this.elementDescriptor = descriptor;
return this;
}
public String type() {
return type;
}
public String formal() {
return formal;
}
public String help() {
return help;
}
public Optional<String> defaultValueWhenFlag() {
return defaultValueWhenFlag;
}
public ParameterDescription formal(String formal) {
this.formal = formal;
return this;
}
@Override
public String toString() {
return String.format("%s %s", keys.isEmpty() ? "" : keys().iterator().next(), formal());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ParameterDescription that = (ParameterDescription) o;
return mandatoryKey == that.mandatoryKey &&
// Objects.equals(parameter, that.parameter) &&
Objects.equals(type, that.type) &&
Objects.equals(formal, that.formal) &&
Objects.equals(defaultValue, that.defaultValue) &&
Objects.equals(defaultValueWhenFlag, that.defaultValueWhenFlag) &&
Objects.equals(keys, that.keys) &&
Objects.equals(help, that.help);
}
@Override
public int hashCode() {
return Objects.hash(type, formal, defaultValue, defaultValueWhenFlag, keys, mandatoryKey, help);
}
}

View File

@@ -1,40 +0,0 @@
/*
* Copyright 2015 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;
/**
* Thrown by a {@link ParameterResolver} when a parameter that should have been set has been left out altogether.
*
* @author Eric Bottard
*/
public class ParameterMissingResolutionException extends RuntimeException {
private final ParameterDescription parameterDescription;
public ParameterMissingResolutionException(ParameterDescription parameterDescription) {
this.parameterDescription = parameterDescription;
}
public ParameterDescription getParameterDescription() {
return parameterDescription;
}
@Override
public String getMessage() {
return String.format("Parameter '%s' should be specified", parameterDescription);
}
}

View File

@@ -35,6 +35,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.shell.command.CommandAlias;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.command.CommandExecution;
import org.springframework.shell.command.CommandExecution.CommandExecutionException;
@@ -155,7 +156,15 @@ public class Shell {
Optional<CommandRegistration> commandRegistration = commandRegistry.getRegistrations().values().stream()
.filter(r -> {
return r.getCommand().equals(command);
if (r.getCommand().equals(command)) {
return true;
}
for (CommandAlias a : r.getAliases()) {
if (a.getCommand().equals(command)) {
return true;
}
}
return false;
})
.findFirst();
@@ -255,14 +264,12 @@ public class Shell {
// Workaround for https://github.com/spring-projects/spring-shell/issues/150
// (sadly, this ties this class to JLine somehow)
int lastWordStart = prefix.lastIndexOf(' ') + 1;
return commandRegistry.getRegistrations().values().stream()
.filter(r -> {
return r.getCommand().startsWith(prefix);
})
.map(r -> {
String c = r.getCommand();
return commandRegistry.getRegistrations().entrySet().stream()
.filter(e -> e.getKey().startsWith(prefix))
.map(e -> {
String c = e.getKey();
c = c.substring(lastWordStart);
return toCommandProposal(c, r);
return toCommandProposal(c, e.getValue());
})
.collect(Collectors.toList());
}

View File

@@ -1,47 +0,0 @@
/*
* Copyright 2016 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;
/**
* Thrown during parameter resolution, when a parameter has been identified, but could not be correctly resolved.
*
* @author Eric Bottard
*/
public class UnfinishedParameterResolutionException extends RuntimeException {
private final ParameterDescription parameterDescription;
private final CharSequence input;
public UnfinishedParameterResolutionException(ParameterDescription parameterDescription, CharSequence input) {
this.parameterDescription = parameterDescription;
this.input = input;
}
public ParameterDescription getParameterDescription() {
return parameterDescription;
}
public CharSequence getInput() {
return input;
}
@Override
public String getMessage() {
return String.format("Error trying to resolve '%s' using [%s]", parameterDescription, input);
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2022 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.command;
/**
* Interface representing an alias in a command.
*
* @author Janne Valkealahti
*/
public interface CommandAlias {
/**
* Gets a command an alias.
*
* @return command
*/
String getCommand();
/**
* Get group for an alias.
*
* @return the group
*/
String getGroup();
/**
* Gets an instance of a default {@link CommandAlias}.
*
* @param command the command
* @param group the group
* @return default command alias
*/
public static CommandAlias of(String command, String group) {
return new DefaultCommandAlias(command, group);
}
/**
* Default implementation of {@link CommandAlias}.
*/
public static class DefaultCommandAlias implements CommandAlias {
private final String command;
private final String group;
public DefaultCommandAlias(String command, String group) {
this.command = command;
this.group = group;
}
@Override
public String getCommand() {
return command;
}
@Override
public String getGroup() {
return group;
}
}
}

View File

@@ -103,6 +103,9 @@ public interface CommandCatalog {
for (CommandRegistration r : registration) {
String commandName = r.getCommand();
commandRegistrations.put(commandName, r);
for (CommandAlias a : r.getAliases()) {
commandRegistrations.put(a.getCommand(), r);
}
}
}
@@ -111,6 +114,9 @@ public interface CommandCatalog {
for (CommandRegistration r : registration) {
String commandName = r.getCommand();
commandRegistrations.remove(commandName);
for (CommandAlias a : r.getAliases()) {
commandRegistrations.remove(a.getCommand());
}
}
}
@@ -156,13 +162,5 @@ public interface CommandCatalog {
return true;
};
}
// private static String commandName(String[] commands) {
// return Arrays.asList(commands).stream()
// .flatMap(c -> Stream.of(c.split(" ")))
// .filter(c -> StringUtils.hasText(c))
// .map(c -> c.trim())
// .collect(Collectors.joining(" "));
// }
}
}

View File

@@ -91,6 +91,13 @@ public interface CommandRegistration {
*/
List<CommandOption> getOptions();
/**
* Gets an aliases.
*
* @return the aliases
*/
List<CommandAlias> getAliases();
/**
* Gets a new instance of a {@link Buidler}.
*
@@ -350,6 +357,35 @@ public interface CommandRegistration {
Builder and();
}
/**
* Spec defining an alias.
*/
public interface AliasSpec {
/**
* Define commands for an alias.
*
* @param commands the commands
* @return a target spec for chaining
*/
AliasSpec command(String... commands);
/**
* Define group for an alias.
*
* @param group the group
* @return a target spec for chaining
*/
AliasSpec group(String group);
/**
* Return a builder for chaining.
*
* @return a builder for chaining
*/
Builder and();
}
/**
* Builder interface for {@link CommandRegistration}.
*/
@@ -413,6 +449,13 @@ public interface CommandRegistration {
*/
TargetSpec withTarget();
/**
* Define an alias what this command should execute
*
* @return alias spec for chaining
*/
AliasSpec withAlias();
/**
* Builds a {@link CommandRegistration}.
*
@@ -612,6 +655,40 @@ public interface CommandRegistration {
}
}
static class DefaultAliasSpec implements AliasSpec {
private BaseBuilder builder;
private String[] commands;
private String group;
DefaultAliasSpec(BaseBuilder builder) {
this.builder = builder;
}
@Override
public AliasSpec command(String... commands) {
Assert.notNull(commands, "commands must be set");
this.commands = Arrays.asList(commands).stream()
.flatMap(c -> Stream.of(c.split(" ")))
.filter(c -> StringUtils.hasText(c))
.map(c -> c.trim())
.collect(Collectors.toList())
.toArray(new String[0]);
return this;
}
@Override
public AliasSpec group(String group) {
this.group = group;
return this;
}
@Override
public Builder and() {
return builder;
}
}
static class DefaultCommandRegistration implements CommandRegistration {
private String command;
@@ -621,10 +698,11 @@ public interface CommandRegistration {
private Supplier<Availability> availability;
private List<DefaultOptionSpec> optionSpecs;
private DefaultTargetSpec targetSpec;
private List<DefaultAliasSpec> aliasSpecs;
public DefaultCommandRegistration(String[] commands, InteractionMode interactionMode, String group,
String description, Supplier<Availability> availability, List<DefaultOptionSpec> optionSpecs,
DefaultTargetSpec targetSpec) {
DefaultTargetSpec targetSpec, List<DefaultAliasSpec> aliasSpecs) {
this.command = commandArrayToName(commands);
this.interactionMode = interactionMode;
this.group = group;
@@ -632,6 +710,7 @@ public interface CommandRegistration {
this.availability = availability;
this.optionSpecs = optionSpecs;
this.targetSpec = targetSpec;
this.aliasSpecs = aliasSpecs;
}
@Override
@@ -681,6 +760,15 @@ public interface CommandRegistration {
throw new IllegalArgumentException("No bean, function or consumer defined");
}
@Override
public List<CommandAlias> getAliases() {
return this.aliasSpecs.stream()
.map(spec -> {
return CommandAlias.of(commandArrayToName(spec.commands), spec.group);
})
.collect(Collectors.toList());
}
private static String commandArrayToName(String[] commands) {
return Arrays.asList(commands).stream()
.flatMap(c -> Stream.of(c.split(" ")))
@@ -702,6 +790,7 @@ public interface CommandRegistration {
private String description;
private Supplier<Availability> availability;
private List<DefaultOptionSpec> optionSpecs = new ArrayList<>();
private List<DefaultAliasSpec> aliasSpecs = new ArrayList<>();
private DefaultTargetSpec targetSpec;
@Override
@@ -754,13 +843,20 @@ public interface CommandRegistration {
return spec;
}
@Override
public AliasSpec withAlias() {
DefaultAliasSpec spec = new DefaultAliasSpec(this);
this.aliasSpecs.add(spec);
return spec;
};
@Override
public CommandRegistration build() {
Assert.notNull(commands, "command cannot be empty");
Assert.notNull(targetSpec, "target cannot be empty");
Assert.state(!(targetSpec.bean != null && targetSpec.function != null), "only one target can exist");
return new DefaultCommandRegistration(commands, interactionMode, group, description, availability,
optionSpecs, targetSpec);
optionSpecs, targetSpec, aliasSpecs);
}
}
}

View File

@@ -40,6 +40,22 @@ public class CommandCatalogTests extends AbstractCommandTests {
assertThat(catalog.getRegistrations()).hasSize(0);
}
@Test
public void testCommandAliases () {
CommandRegistration r1 = CommandRegistration.builder()
.command("group1 sub1")
.withAlias()
.command("group1 sub2")
.and()
.withTarget()
.function(function1)
.and()
.build();
CommandCatalog catalog = CommandCatalog.of();
catalog.register(r1);
assertThat(catalog.getRegistrations()).hasSize(2);
}
@Test
public void testResolver() {
// catalog itself would not have any registered command but

View File

@@ -354,4 +354,31 @@ public class CommandRegistrationTests extends AbstractCommandTests {
assertThat(registration.getOptions().get(0).getArityMin()).isEqualTo(0);
assertThat(registration.getOptions().get(0).getArityMax()).isEqualTo(0);
}
@Test
public void testAliases() {
CommandRegistration registration = CommandRegistration.builder()
.command("command1")
.group("Test Group")
.withAlias()
.command("alias1")
.group("Alias Group")
.and()
.withAlias()
.command("alias2")
.group("Alias Group")
.and()
.withTarget()
.function(function1)
.and()
.build();
assertThat(registration.getCommand()).isEqualTo("command1");
assertThat(registration.getGroup()).isEqualTo("Test Group");
assertThat(registration.getAliases()).hasSize(2);
assertThat(registration.getAliases().stream().map(CommandAlias::getCommand)).containsExactlyInAnyOrder("alias1",
"alias2");
assertThat(registration.getAliases().get(0).getGroup()).isEqualTo("Alias Group");
assertThat(registration.getAliases().get(1).getGroup()).isEqualTo("Alias Group");
}
}

View File

@@ -9,56 +9,77 @@ However, if they are not overridden or disabled, this section describes their be
[[help-command]]
===== Help
Running a shell application often implies that the user is in a graphically limited
environment. Also, while we are nearly always connected in the era of mobile phones,
accessing a web browser or any other rich UI application (such as a PDF viewer) may not always
be possible. This is why it is important that the shell commands are correctly self documented, and this is where the `help`
command comes in.
Running a shell application often implies that the user is in a graphically limited environment. Also, while we are
nearly always connected in the era of mobile phones, accessing a web browser or any other rich UI application
(such as a PDF viewer) may not always be possible. This is why it is important that the shell commands are correctly
self documented, and this is where the `help` command comes in.
Typing `help` + `ENTER` lists all the commands known to the shell (including <<dynamic-command-availability,unavailable>> commands)
and a short description of what they do, similar to the following:
====
[source]
[source, bash]
----
shell:>help
my-shell:>help
AVAILABLE COMMANDS
add: Add numbers together.
* authenticate: Authenticate with the system.
* blow-up: Blow Everything up.
clear: Clear the shell screen.
connect: Connect to the system
disconnect: Disconnect from the system.
exit, quit: Exit the shell.
help: Display help about available commands.
register module: Register a new module.
script: Read and execute commands from a file.
stacktrace: Display the full stacktrace of the last error.
Commands marked with (*) are currently unavailable.
Type `help <command>` to learn more.
Built-In Commands
exit: Exit the shell.
help: Display help about available commands
stacktrace: Display the full stacktrace of the last error.
clear: Clear the shell screen.
quit: Exit the shell.
history: Display or save the history of previously run commands
completion bash: Generate bash completion script
version: Show version info
script: Read and execute commands from a file.
----
====
Typing `help <command>` shows more detailed information about a command, including the available parameters, their
type, whether they are mandatory or not, and other details.
The follwoing listing shows the `help` command applied to itself:
The following listing shows the `help` command applied to itself:
====
[source, bash]
----
shell:>help help
my-shell:>help help
NAME
help - Display help about available commands.
help - Display help about available commands
SYNOPSYS
help [[-C] string]
SYNOPSIS
help --command String
OPTIONS
-C or --command string
The command to obtain help for. [Optional, default = <none>]
--command or -C String
The command to obtain help for.
[Optional]
----
====
Help is templated and can be customized if needed. Settings are under `spring.shell.command.help` where you can use
`enabled` to disable command, `grouping-mode` taking `group` or `flat` if you want to hide groups by flattening
a structure, `command-template` to define your template for output of a command help, `commands-template` to define
output of a command list.
If `spring.shell.command.help.grouping-mode=flat` is set, then help would show:
====
[source, bash]
----
my-shell:>help help
AVAILABLE COMMANDS
exit: Exit the shell.
help: Display help about available commands
stacktrace: Display the full stacktrace of the last error.
clear: Clear the shell screen.
quit: Exit the shell.
history: Display or save the history of previously run commands
completion bash: Generate bash completion script
version: Show version info
script: Read and execute commands from a file.
----
====

View File

@@ -36,9 +36,6 @@ import org.springframework.nativex.type.NativeConfiguration;
resources = {
@ResourceHint(
patterns = {
"completion/.*",
"template/.*.st",
"org/springframework/shell/component/.*.stg",
"com/sun/jna/win32-x86-64/jnidispatch.dll"
}
),

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2022 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.samples.standard;
import org.springframework.context.annotation.Bean;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
@ShellComponent
public class AliasCommands {
@ShellMethod(key = { "alias anno main1", "alias anno main2" }, group = "Alias Commands")
public String annoMain1() {
return "Hello annoMain1";
}
@Bean
public CommandRegistration regMain1() {
return CommandRegistration.builder()
.command("alias", "reg", "main1")
.group("Alias Commands")
.withAlias()
.command("alias", "reg", "main2")
.group("Alias Commands")
.and()
.withTarget()
.function(ctx -> {
return "Hello regMain1";
})
.and()
.build();
}
}

View File

@@ -8,6 +8,8 @@ spring:
history:
name: spring-shell-samples-history.log
command:
help:
grouping-mode: group
completion:
root-command: spring-shell-samples
## disable console logging

View File

@@ -0,0 +1,81 @@
/*
* Copyright 2022 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.standard.commands;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.shell.command.CommandOption;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.util.ClassUtils;
/**
* Model encapsulating info about {@code command}.
*
* @author Janne Valkealahti
*/
class CommandInfoModel {
private String name;
private String description;
private List<CommandParameterInfoModel> parameters;
CommandInfoModel(String name, String description, List<CommandParameterInfoModel> parameters) {
this.name = name;
this.description = description;
this.parameters = parameters;
}
/**
* Builds {@link CommandInfoModel} from {@link CommandRegistration}.
*
* @param name the command name
* @param registration the command registration
* @return the command info model
*/
static CommandInfoModel of(String name, CommandRegistration registration) {
List<CommandOption> options = registration.getOptions();
List<CommandParameterInfoModel> parameters = options.stream()
.map(o -> {
String type = o.getType() == null ? "String" : ClassUtils.getShortName(o.getType().getRawClass());
List<String> arguments = Stream.concat(
Stream.of(o.getLongNames()).map(a -> "--" + a),
Stream.of(o.getShortNames()).map(s -> "-" + s))
.collect(Collectors.toList());
boolean required = o.isRequired();
String description = o.getDescription();
String defaultValue = o.getDefaultValue();
return CommandParameterInfoModel.of(type, arguments, required, description, defaultValue);
})
.collect(Collectors.toList());
String description = registration.getDescription();
return new CommandInfoModel(name, description, parameters);
}
public String getName() {
return name;
}
public String getDescription() {
return description;
}
public List<CommandParameterInfoModel> getParameters() {
return parameters;
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2022 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.standard.commands;
import java.util.List;
import org.springframework.util.StringUtils;
/**
* Model encapsulating info about {@code command parameter}.
*
* @author Janne Valkealahti
*/
class CommandParameterInfoModel {
private String type;
private List<String> arguments;
private boolean required;
private String description;
private String defaultValue;
CommandParameterInfoModel(String type, List<String> arguments, boolean required, String description,
String defaultValue) {
this.type = type;
this.arguments = arguments;
this.required = required;
this.description = description;
this.defaultValue = defaultValue;
}
/**
* Builds {@link CommandParameterInfoModel}.
*
* @param type the type
* @param arguments the arguments
* @param required the required flag
* @param description the description
* @param defaultValue the default value
* @return a command parameter info model
*/
static CommandParameterInfoModel of(String type, List<String> arguments, boolean required,
String description, String defaultValue) {
return new CommandParameterInfoModel(type, arguments, required, description, defaultValue);
}
public String getType() {
return type;
}
public List<String> getArguments() {
return arguments;
}
public boolean getRequired() {
return required;
}
public String getDescription() {
return description;
}
public String getDefaultValue() {
return defaultValue;
}
public boolean getHasDefaultValue() {
return StringUtils.hasText(this.defaultValue);
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2022 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.standard.commands;
import java.util.ArrayList;
import java.util.List;
/**
* Model encapsulating info about group and {@link CommandInfoModel}'s.
*
* @author Janne Valkealahti
*/
class GroupCommandInfoModel {
private String group = "";
private List<CommandInfoModel> commands = new ArrayList<>();
GroupCommandInfoModel(String group, List<CommandInfoModel> commands) {
this.group = group;
this.commands = commands;
}
/**
* Builds {@link GroupCommandInfoModel}.
*
* @param group the group
* @param commands the command info models
* @return a group command info model
*/
static GroupCommandInfoModel of(String group, List<CommandInfoModel> commands) {
return new GroupCommandInfoModel(group, commands);
}
public String getGroup() {
return group;
}
public List<CommandInfoModel> getCommands() {
return commands;
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2022 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.standard.commands;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.stream.Collectors;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.util.StringUtils;
/**
* Model encapsulating info about command structure which is more
* friendly to show and render via templating.
*
* @author Janne Valkealahti
*/
class GroupsInfoModel {
private boolean showGroups = true;
private final List<GroupCommandInfoModel> groups;
private final List<CommandInfoModel> commands;
GroupsInfoModel(boolean showGroups, List<GroupCommandInfoModel> groups, List<CommandInfoModel> commands) {
this.showGroups = showGroups;
this.groups = groups;
this.commands = commands;
}
/**
* Builds {@link GroupsInfoModel} from command registrations.
*
* @param showGroups the flag showing groups
* @param registrations the command registrations
* @return a groups info model
*/
static GroupsInfoModel of(boolean showGroups, Map<String, CommandRegistration> registrations) {
// collect commands into groups with sorting
SortedMap<String, Map<String, CommandRegistration>> commandsByGroupAndName = registrations.entrySet().stream()
.collect(Collectors.groupingBy(
e -> StringUtils.hasText(e.getValue().getGroup()) ? e.getValue().getGroup() : "Default",
TreeMap::new,
Collectors.toMap(Entry::getKey, Entry::getValue)
));
// build model
List<GroupCommandInfoModel> gcims = commandsByGroupAndName.entrySet().stream()
.map(e -> {
List<CommandInfoModel> cims = e.getValue().entrySet().stream()
.map(ee -> CommandInfoModel.of(ee.getKey(), ee.getValue()))
.collect(Collectors.toList());
return GroupCommandInfoModel.of(e.getKey(), cims);
})
.collect(Collectors.toList());
List<CommandInfoModel> commands = gcims.stream()
.flatMap(gcim -> gcim.getCommands().stream())
.collect(Collectors.toList());
return new GroupsInfoModel(showGroups, gcims, commands);
}
public boolean getShowGroups() {
return this.showGroups;
}
public List<GroupCommandInfoModel> getGroups() {
return this.groups;
}
public List<CommandInfoModel> getCommands() {
return commands;
}
}

View File

@@ -16,40 +16,24 @@
package org.springframework.shell.standard.commands;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Collectors;
import javax.validation.MessageInterpolator;
import javax.validation.ValidatorFactory;
import javax.validation.metadata.ConstraintDescriptor;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.shell.Availability;
import org.springframework.shell.ParameterDescription;
import org.springframework.shell.Utils;
import org.springframework.shell.command.CommandOption;
import org.springframework.core.io.Resource;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.standard.AbstractShellComponent;
import org.springframework.shell.standard.CommandValueProvider;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
import org.springframework.util.StringUtils;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toCollection;
import org.springframework.shell.style.TemplateExecutor;
import org.springframework.util.FileCopyUtils;
/**
* A command to display help about all available commands.
@@ -78,29 +62,45 @@ public class Help extends AbstractShellComponent {
public interface Command {
}
private MessageInterpolator messageInterpolator = Utils.defaultValidatorFactory().getMessageInterpolator();
private boolean showGroups = true;
private TemplateExecutor templateExecutor;
private String commandTemplate;
private String commandsTemplate;
public Help() {
public Help(TemplateExecutor templateExecutor) {
this.templateExecutor = templateExecutor;
}
@Autowired(required = false)
public void setValidatorFactory(ValidatorFactory validatorFactory) {
this.messageInterpolator = validatorFactory.getMessageInterpolator();
}
@ShellMethod(value = "Display help about available commands.", prefix = "-")
public CharSequence help(
@ShellMethod(value = "Display help about available commands")
public AttributedString help(
@ShellOption(defaultValue = ShellOption.NULL, valueProvider = CommandValueProvider.class, value = { "-C",
"--command" }, help = "The command to obtain help for.", arity = Integer.MAX_VALUE) String command)
throws IOException {
if (command == null) {
return listCommands();
return renderCommands();
}
else {
return documentCommand(command);
return renderCommand(command);
}
}
/**
* Sets a location for a template rendering command help.
*
* @param commandTemplate the command template location
*/
public void setCommandTemplate(String commandTemplate) {
this.commandTemplate = commandTemplate;
}
/**
* Sets a location for a template rendering commands help.
*
* @param commandsTemplate the commands template location
*/
public void setCommandsTemplate(String commandsTemplate) {
this.commandsTemplate = commandsTemplate;
}
/**
@@ -113,290 +113,41 @@ public class Help extends AbstractShellComponent {
this.showGroups = showGroups;
}
/**
* Return a description of a specific command. Uses a layout inspired by *nix man pages.
*/
private CharSequence documentCommand(String command) {
private AttributedString renderCommands() {
Map<String, CommandRegistration> registrations = getCommandCatalog().getRegistrations();
boolean isStg = this.commandTemplate.endsWith(".stg");
Map<String, Object> model = new HashMap<>();
model.put("model", GroupsInfoModel.of(this.showGroups, registrations));
String templateResource = resourceAsString(getResourceLoader().getResource(this.commandsTemplate));
return isStg ? this.templateExecutor.renderGroup(templateResource, model)
: this.templateExecutor.render(templateResource, model);
}
private AttributedString renderCommand(String command) {
Map<String, CommandRegistration> registrations = getCommandCatalog().getRegistrations();
CommandRegistration registration = registrations.get(command);
if (registration == null) {
throw new IllegalArgumentException("Unknown command '" + command + "'");
}
AttributedStringBuilder result = new AttributedStringBuilder().append("\n\n");
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(registration);
boolean isStg = this.commandTemplate.endsWith(".stg");
// NAME
documentCommandName(result, command, registration.getDescription());
Map<String, Object> model = new HashMap<>();
model.put("model", CommandInfoModel.of(command, registration));
// SYNOPSYS
documentSynopsys(result, command, parameterDescriptions);
// OPTIONS
documentOptions(result, parameterDescriptions);
// ALSO KNOWN AS
documentAliases(result, command, registrations, registration);
// AVAILABILITY
documentAvailability(result, registration);
result.append("\n");
return result;
String templateResource = resourceAsString(getResourceLoader().getResource(this.commandTemplate));
return isStg ? this.templateExecutor.renderGroup(templateResource, model)
: this.templateExecutor.render(templateResource, model);
}
private void documentCommandName(AttributedStringBuilder result, String command, String help) {
result.append("NAME", AttributedStyle.BOLD).append("\n\t");
result.append(command).append(" - ").append(help).append("\n\n");
}
private void documentSynopsys(AttributedStringBuilder result, String command,
List<ParameterDescription> parameterDescriptions) {
result.append("SYNOPSYS", AttributedStyle.BOLD).append("\n\t");
result.append(command, AttributedStyle.BOLD);
result.append(" ");
for (ParameterDescription description : parameterDescriptions) {
if (description.defaultValue().isPresent() && description.formal().length() > 0) {
result.append("["); // Whole parameter is optional, as there is a default value (1)
}
List<String> keys = description.keys();
if (!keys.isEmpty()) {
if (!description.mandatoryKey()) {
result.append("["); // Specifying a key is optional (ie positional params). (2)
}
result.append(first(keys), AttributedStyle.BOLD);
if (!description.mandatoryKey()) {
result.append("]"); // (close 2)
}
if (!description.formal().isEmpty()) {
result.append(" ");
}
}
if (description.defaultValueWhenFlag().isPresent()) {
result.append("["); // Parameter can be used as a toggle flag (3)
}
appendUnderlinedFormal(result, description);
if (description.defaultValueWhenFlag().isPresent()) {
result.append("]"); // (close 3)
}
if (description.defaultValue().isPresent() && description.formal().length() > 0) {
result.append("]"); // (close 1)
}
result.append(" "); // two spaces between each param for better legibility
}
result.append("\n\n");
}
private void documentOptions(AttributedStringBuilder result, List<ParameterDescription> parameterDescriptions) {
if (!parameterDescriptions.isEmpty()) {
result.append("OPTIONS", AttributedStyle.BOLD).append("\n");
}
for (ParameterDescription description : parameterDescriptions) {
result.append("\t").append(description.keys().stream().collect(Collectors.joining(" or ")),
AttributedStyle.BOLD);
if (description.formal().length() > 0) {
if (!description.keys().isEmpty()) {
result.append(" ");
}
description.defaultValueWhenFlag().ifPresent(f -> result.append('['));
appendUnderlinedFormal(result, description);
description.defaultValueWhenFlag().ifPresent(f -> result.append(']'));
result.append("\n\t");
}
else if (description.keys().size() > 1) {
result.append("\n\t");
}
result.append("\t");
result.append(description.help()).append('\n');
// Optional parameter
if (description.defaultValue().isPresent()) {
result
.append("\t\t[Optional, default = ", AttributedStyle.BOLD)
.append(description.defaultValue().get(), AttributedStyle.BOLD.italic());
description.defaultValueWhenFlag().ifPresent(
s -> result.append(", or ", AttributedStyle.BOLD)
.append(s, AttributedStyle.BOLD.italic())
.append(" if used as a flag", AttributedStyle.BOLD));
result.append("]", AttributedStyle.BOLD);
} // Mandatory parameter, but with a default when used as a flag
else if (description.defaultValueWhenFlag().isPresent()) {
result
.append("\t\t[Mandatory, default = ", AttributedStyle.BOLD)
.append(description.defaultValueWhenFlag().get(), AttributedStyle.BOLD.italic())
.append(" when used as a flag]", AttributedStyle.BOLD);
} // true mandatory parameter
else {
result.append("\t\t[Mandatory]", AttributedStyle.BOLD);
}
result.append('\n');
if (description.elementDescriptor() != null) {
for (ConstraintDescriptor<?> constraintDescriptor : description.elementDescriptor()
.getConstraintDescriptors()) {
String friendlyConstraint = messageInterpolator.interpolate(
constraintDescriptor.getMessageTemplate(), new DummyContext(constraintDescriptor));
result.append("\t\t[" + friendlyConstraint + "]\n", AttributedStyle.BOLD);
}
}
result.append('\n');
}
}
private void documentAliases(AttributedStringBuilder result, String command,
Map<String, CommandRegistration> registrations, CommandRegistration registration) {
List<String> aliases = registrations.entrySet().stream()
.filter(e -> e.getValue().equals(registration))
.map(Map.Entry::getKey)
.filter(c -> !command.equals(c))
.collect(Collectors.toList());
if (!aliases.isEmpty()) {
result.append("ALSO KNOWN AS", AttributedStyle.BOLD).append("\n");
for (String alias : aliases) {
result.append('\t').append(alias).append('\n');
}
}
}
private void documentAvailability(AttributedStringBuilder result, CommandRegistration registration) {
Availability availability = registration.getAvailability();
if (!availability.isAvailable()) {
result.append("CURRENTLY UNAVAILABLE", AttributedStyle.BOLD).append("\n");
result.append('\t').append("This command is currently not available because ")
.append(availability.getReason())
.append(".\n");
}
}
private String first(List<String> keys) {
return keys.iterator().next();
}
private CharSequence listCommands() {
AttributedStringBuilder result = new AttributedStringBuilder();
result.append("AVAILABLE COMMANDS\n\n", AttributedStyle.BOLD);
SortedMap<String, Map<String, CommandRegistration>> commandsByGroupAndName = getCommandCatalog().getRegistrations().entrySet().stream()
.collect(Collectors.groupingBy(
e -> StringUtils.hasText(e.getValue().getGroup()) ? e.getValue().getGroup() : "",
TreeMap::new,
Collectors.toMap(Entry::getKey, Entry::getValue)
));
commandsByGroupAndName.forEach((group, commandsInGroup) -> {
if (showGroups) {
result.append("".equals(group) ? "Default" : group, AttributedStyle.BOLD).append('\n');
}
Map<CommandRegistration, SortedSet<String>> commandNamesByMethod = commandsInGroup.entrySet().stream()
.collect(groupingBy(Entry::getValue, // group by command method
mapping(Entry::getKey, toCollection(TreeSet::new)))); // sort command names
// display commands, sorted alphabetically by their first alias
commandNamesByMethod.entrySet().stream().sorted(sortByFirstCommandName()).forEach(e -> {
String prefix = showGroups ? " " : "";
prefix = prefix + (isAvailable(e.getKey()) ? " " : " *");
result
.append(prefix)
.append(String.join(", ", e.getValue()), AttributedStyle.BOLD)
.append(": ")
.append(e.getKey().getDescription())
.append('\n');
});
if (showGroups) {
result.append('\n');
}
});
return result;
}
private Comparator<Entry<CommandRegistration, SortedSet<String>>> sortByFirstCommandName() {
return Comparator.comparing(e -> e.getValue().first());
}
private boolean isAvailable(CommandRegistration methodTarget) {
return true;
}
private void appendUnderlinedFormal(AttributedStringBuilder result, ParameterDescription description) {
for (char c : description.formal().toCharArray()) {
if (c != ' ') {
result.append("" + c, AttributedStyle.DEFAULT.underline());
}
else {
result.append(c);
}
}
}
private List<ParameterDescription> getParameterDescriptions(CommandRegistration registration) {
List<CommandOption> options = registration.getOptions();
List<ParameterDescription> descriptions = new ArrayList<>();
for (CommandOption option : options) {
ParameterDescription description = new ParameterDescription();
if (option.getType() != null) {
description.type(option.getType().toString());
description.formal(option.getType().toClass().getSimpleName());
}
else {
description.formal("");
}
description.help(option.getDescription());
description.mandatoryKey(option.isRequired());
if (option.getType() != null && option.getType().isAssignableFrom(boolean.class)) {
description.defaultValue("false");
}
else {
description.defaultValue(option.getDefaultValue());
}
List<String> keys = new ArrayList<>();
if (option.getLongNames() != null) {
for (String ln : option.getLongNames()) {
keys.add("--" + ln);
}
}
if (option.getShortNames() != null) {
for (Character sn : option.getShortNames()) {
keys.add("-" + String.valueOf(sn));
}
}
description.keys(keys);
descriptions.add(description);
}
// return Utils.createMethodParameters(registration.getTarget().getMethod())
// .flatMap(mp -> getParameterResolver().filter(pr -> pr.supports(mp)).limit(1L)
// .flatMap(pr -> pr.describe(mp)))
// .collect(Collectors.toList());
return descriptions;
}
private static class DummyContext implements MessageInterpolator.Context {
private final ConstraintDescriptor<?> descriptor;
private DummyContext(ConstraintDescriptor<?> descriptor) {
this.descriptor = descriptor;
}
@Override
public ConstraintDescriptor<?> getConstraintDescriptor() {
return descriptor;
}
@Override
public Object getValidatedValue() {
return null;
}
@Override
public <T> T unwrap(Class<T> type) {
return null;
private static String resourceAsString(Resource resource) {
try (Reader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) {
return FileCopyUtils.copyToString(reader);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}

View File

@@ -0,0 +1,18 @@
[
{
"name": "org.springframework.shell.standard.commands.CommandInfoModel",
"allDeclaredMethods": true
},
{
"name": "org.springframework.shell.standard.commands.CommandParameterInfoModel",
"allDeclaredMethods": true
},
{
"name": "org.springframework.shell.standard.commands.GroupCommandInfoModel",
"allDeclaredMethods": true
},
{
"name": "org.springframework.shell.standard.commands.GroupsInfoModel",
"allDeclaredMethods": true
}
]

View File

@@ -0,0 +1,12 @@
{
"resources": {
"includes": [
{
"pattern": "template/.*.st"
},
{
"pattern": "template/.*.stg"
}
]
}
}

View File

@@ -0,0 +1,67 @@
// NAME
name(commandName, commandShortDesc) ::= <<
<("NAME"); format="highlight">
<commandName> - <commandShortDesc>
>>
// SYNOPSIS
synopsisOption(option) ::= <%
<if(option.required)>
<("[")>
<endif>
<first(option.arguments)> <(option.type)>
<if(option.required)>
<("]")>
<endif>
%>
synopsis(commandName, options) ::= <<
<("SYNOPSIS"); format="highlight">
<(commandName); format="highlight"> <options: { o | <synopsisOption(o)>}; separator=" ">
>>
// OPTIONS
arguments(arguments) ::= <<
<arguments: { a | <a>}; separator=" or ">
>>
type(type) ::= <<
<type>
>>
required(option) ::= <%
[
<if(option.required)>
<("Mandatory")>
<else>
<("Optional")>
<endif>
<if(option.hasDefaultValue)>
<(", default = ")><(option.defaultValue)>
<endif>
]
%>
option(option) ::= <<
<arguments(option.arguments)> <type(option.type)>
<option.description>
<required(option)>
>>
options(options) ::= <<
<("OPTIONS"); format="highlight">
<options:{ o | <option(o)>}>
>>
// main
main(model) ::= <<
<name(model.name, model.description)>
<synopsis(model.name, model.parameters)>
<options(model.parameters)>
>>

View File

@@ -0,0 +1,32 @@
name() ::= <<
<("AVAILABLE COMMANDS"); format="highlight">
>>
command(command) ::= <<
<(command.name); format="highlight"><(":"); format="highlight"> <command.description>
>>
commandGroup(commandGroup) ::= <<
<(commandGroup.group); format="highlight">
<commandGroup.commands:{ c | <command(c)>}>
>>
groups(groups) ::= <<
<groups:{ g | <commandGroup(g)>}>
>>
flat(commands) ::= <<
<commands:{ c | <command(c)>}>
>>
main(model) ::= <<
<name()>
<if(model.showGroups)>
<groups(model.groups)>
<else>
<flat(model.commands)>
<endif>
>>

View File

@@ -19,9 +19,8 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import javax.validation.constraints.Max;
@@ -32,10 +31,8 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
@@ -44,6 +41,11 @@ import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
import org.springframework.shell.style.TemplateExecutor;
import org.springframework.shell.style.Theme;
import org.springframework.shell.style.ThemeRegistry;
import org.springframework.shell.style.ThemeResolver;
import org.springframework.shell.style.ThemeSettings;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.util.FileCopyUtils;
@@ -57,10 +59,9 @@ public class HelpTests {
private static Locale previousLocale;
private String testName;
private Map<String, CommandRegistration> registrations = new HashMap<>();
private CommandsPojo commandsPojo = new CommandsPojo();
@MockBean
@Autowired
private CommandCatalog commandCatalog;
@Autowired
@@ -79,12 +80,14 @@ public class HelpTests {
@BeforeEach
public void setup(TestInfo testInfo) {
registrations.clear();
Optional<Method> testMethod = testInfo.getTestMethod();
if (testMethod.isPresent()) {
this.testName = testMethod.get().getName();
}
Mockito.when(commandCatalog.getRegistrations()).thenReturn(registrations);
Collection<CommandRegistration> regs = this.commandCatalog.getRegistrations().values();
regs.stream().forEach(r -> {
this.commandCatalog.unregister(r);
});
}
@Test
@@ -117,66 +120,22 @@ public class HelpTests {
.type(float[].class)
.and()
.build();
registrations.put("first-command", registration);
registrations.put("1st-command", registration);
commandCatalog.register(registration);
CharSequence help = this.help.help("first-command").toString();
assertThat(help).isEqualTo(sample());
}
@Test
public void testCommandList() throws Exception {
CommandRegistration registration1 = CommandRegistration.builder()
.command("first-command")
.description("A rather extensive description of some command.")
.withTarget()
.method(commandsPojo, "firstCommand")
.and()
.withOption()
.shortNames('r')
.and()
.build();
registrations.put("first-command", registration1);
registrations.put("1st-command", registration1);
CommandRegistration registration2 = CommandRegistration.builder()
.command("second-command")
.description("The second command. This one is known under several aliases as well.")
.withTarget()
.method(commandsPojo, "secondCommand")
.and()
.build();
registrations.put("second-command", registration2);
registrations.put("yet-another-command", registration2);
CommandRegistration registration3 = CommandRegistration.builder()
.command("second-command")
.description("The last command.")
.withTarget()
.method(commandsPojo, "thirdCommand")
.and()
.build();
registrations.put("third-command", registration3);
CommandRegistration registration4 = CommandRegistration.builder()
.command("first-group-command")
.description("The first command in a separate group.")
.group("Example Group")
.withTarget()
.method(commandsPojo, "firstCommandInGroup")
.and()
.build();
registrations.put("first-group-command", registration4);
CommandRegistration registration5 = CommandRegistration.builder()
.command("second-group-command")
.description("The second command in a separate group.")
.group("Example Group")
.withTarget()
.method(commandsPojo, "secondCommandInGroup")
.and()
.build();
registrations.put("second-group-command", registration5);
public void testCommandListDefault() throws Exception {
registerCommandListCommands();
String list = this.help.help(null).toString();
assertThat(list).isEqualTo(sample());
}
@Test
public void testCommandListFlat() throws Exception {
registerCommandListCommands();
this.help.setShowGroups(false);
String list = this.help.help(null).toString();
assertThat(list).isEqualTo(sample());
}
@@ -193,18 +152,83 @@ public class HelpTests {
return FileCopyUtils.copyToString(new InputStreamReader(is, "UTF-8")).replace("&", "");
}
private void registerCommandListCommands() throws Exception {
CommandRegistration registration1 = CommandRegistration.builder()
.command("first-command")
.description("A rather extensive description of some command.")
.withAlias()
.command("1st-command")
.and()
.withTarget()
.method(commandsPojo, "firstCommand")
.and()
.withOption()
.shortNames('r')
.and()
.build();
commandCatalog.register(registration1);
CommandRegistration registration2 = CommandRegistration.builder()
.command("second-command")
.description("The second command. This one is known under several aliases as well.")
.withAlias()
.command("yet-another-command")
.and()
.withTarget()
.method(commandsPojo, "secondCommand")
.and()
.build();
commandCatalog.register(registration2);
CommandRegistration registration3 = CommandRegistration.builder()
.command("third-command")
.description("The last command.")
.withTarget()
.method(commandsPojo, "thirdCommand")
.and()
.build();
commandCatalog.register(registration3);
CommandRegistration registration4 = CommandRegistration.builder()
.command("first-group-command")
.description("The first command in a separate group.")
.group("Example Group")
.withTarget()
.method(commandsPojo, "firstCommandInGroup")
.and()
.build();
commandCatalog.register(registration4);
CommandRegistration registration5 = CommandRegistration.builder()
.command("second-group-command")
.description("The second command in a separate group.")
.group("Example Group")
.withTarget()
.method(commandsPojo, "secondCommandInGroup")
.and()
.build();
commandCatalog.register(registration5);
}
@Configuration
static class Config {
@Bean
public Help help() {
return new Help();
public CommandCatalog commandCatalog() {
return CommandCatalog.of();
}
// @Bean
// public ParameterResolver parameterResolver() {
// return new StandardParameterResolver(new DefaultConversionService(), Collections.emptySet());
// }
@Bean
public Help help() {
ThemeRegistry registry = new ThemeRegistry();
registry.register(Theme.of("default", ThemeSettings.themeSettings()));
ThemeResolver resolver = new ThemeResolver(registry, "default");
TemplateExecutor executor = new TemplateExecutor(resolver);
Help help = new Help(executor);
help.setCommandTemplate("classpath:template/help-command-default.stg");
help.setCommandsTemplate("classpath:template/help-commands-default.stg");
return help;
}
}
@ShellComponent

View File

@@ -1,28 +1,23 @@
NAME
first-command - A rather extensive description of some command.
first-command - A rather extensive description of some command.
SYNOPSYS
first-command [[-r] boolean] [[-f] boolean] [[-n] int] [-o] float[]
SYNOPSIS
first-command -r boolean -f boolean -n int -o float[]
OPTIONS
-r boolean
Whether to delete recursively
[Optional, default = false]
-r boolean
Whether to delete recursively
[Optional]
-f boolean
Do not ask for confirmation. YOLO
[Optional, default = false]
-f boolean
Do not ask for confirmation. YOLO
[Optional]
-n int
The answer to everything
[Optional, default = 42]
-n int
The answer to everything
[Optional, default = 42]
-o float[]
Some other parameters
[Mandatory]
ALSO KNOWN AS
1st-command
-o float[]
Some other parameters
[Optional]

View File

@@ -1,11 +0,0 @@
AVAILABLE COMMANDS&
&
Default&
1st-command, first-command: A rather extensive description of some command.&
second-command, yet-another-command: The second command. This one is known under several aliases as well.&
third-command: The last command.&
&
Example Group&
first-group-command: The first command in a separate group.&
second-group-command: The second command in a separate group.&
&

View File

@@ -0,0 +1,14 @@
AVAILABLE COMMANDS
Default
1st-command: A rather extensive description of some command.
yet-another-command: The second command. This one is known under several aliases as well.
third-command: The last command.
second-command: The second command. This one is known under several aliases as well.
first-command: A rather extensive description of some command.
Example Group
second-group-command: The second command in a separate group.
first-group-command: The first command in a separate group.

View File

@@ -0,0 +1,10 @@
AVAILABLE COMMANDS
1st-command: A rather extensive description of some command.
yet-another-command: The second command. This one is known under several aliases as well.
third-command: The last command.
second-command: The second command. This one is known under several aliases as well.
first-command: A rather extensive description of some command.
second-group-command: The second command in a separate group.
first-group-command: The first command in a separate group.

View File

@@ -79,84 +79,88 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar, App
keys = new String[] { Utils.unCamelify(method.getName()) };
}
String group = getOrInferGroup(method);
for (String key : keys) {
log.debug("Registering with keys='{}' key='{}'", keys, key);
Supplier<Availability> availabilityIndicator = findAvailabilityIndicator(keys, bean, method);
Builder builder = CommandRegistration.builder()
.command(key)
.group(group)
.description(shellMapping.value())
.interactionMode(shellMapping.interactionMode())
.availability(availabilityIndicator);
String key = keys[0];
log.debug("Registering with keys='{}' key='{}'", keys, key);
Supplier<Availability> availabilityIndicator = findAvailabilityIndicator(keys, bean, method);
InvocableHandlerMethod ihm = new InvocableHandlerMethod(bean, method);
for (MethodParameter mp : ihm.getMethodParameters()) {
Builder builder = CommandRegistration.builder()
.command(key)
.group(group)
.description(shellMapping.value())
.interactionMode(shellMapping.interactionMode())
.availability(availabilityIndicator);
ShellOption so = mp.getParameterAnnotation(ShellOption.class);
log.debug("Registering with mp='{}' so='{}'", mp, so);
if (so != null) {
List<String> longNames = new ArrayList<>();
List<Character> shortNames = new ArrayList<>();
if (!ObjectUtils.isEmpty(so.value())) {
Arrays.asList(so.value()).stream().forEach(o -> {
String stripped = StringUtils.trimLeadingCharacter(o, '-');
log.debug("Registering o='{}' stripped='{}'", o, stripped);
if (o.length() == stripped.length() + 2) {
longNames.add(stripped);
}
else if (o.length() == stripped.length() + 1 && stripped.length() == 1) {
shortNames.add(stripped.charAt(0));
}
});
}
else {
// ShellOption value not defined
mp.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
String longName = mp.getParameterName();
Class<?> parameterType = mp.getParameterType();
if (longName != null) {
log.debug("Using mp='{}' longName='{}' parameterType='{}'", mp, longName, parameterType);
longNames.add(longName);
for (int i = 1; i < keys.length; i++) {
builder.withAlias().command(keys[i]).group(group);
}
InvocableHandlerMethod ihm = new InvocableHandlerMethod(bean, method);
for (MethodParameter mp : ihm.getMethodParameters()) {
ShellOption so = mp.getParameterAnnotation(ShellOption.class);
log.debug("Registering with mp='{}' so='{}'", mp, so);
if (so != null) {
List<String> longNames = new ArrayList<>();
List<Character> shortNames = new ArrayList<>();
if (!ObjectUtils.isEmpty(so.value())) {
Arrays.asList(so.value()).stream().forEach(o -> {
String stripped = StringUtils.trimLeadingCharacter(o, '-');
log.debug("Registering o='{}' stripped='{}'", o, stripped);
if (o.length() == stripped.length() + 2) {
longNames.add(stripped);
}
}
if (!longNames.isEmpty() || !shortNames.isEmpty()) {
log.debug("Registering longNames='{}' shortNames='{}'", longNames, shortNames);
OptionSpec optionSpec = builder.withOption()
.type(mp.getParameterType())
.longNames(longNames.toArray(new String[0]))
.shortNames(shortNames.toArray(new Character[0]))
.position(mp.getParameterIndex())
.description(so.help());
if (so.arity() > -1) {
optionSpec.arity(0, so.arity());
else if (o.length() == stripped.length() + 1 && stripped.length() == 1) {
shortNames.add(stripped.charAt(0));
}
if (!ObjectUtils.nullSafeEquals(so.defaultValue(), ShellOption.NONE)
&& !ObjectUtils.nullSafeEquals(so.defaultValue(), ShellOption.NULL)) {
optionSpec.defaultValue(so.defaultValue());
}
}
});
}
else {
// ShellOption value not defined
mp.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
String longName = mp.getParameterName();
Class<?> parameterType = mp.getParameterType();
if (longName != null) {
log.debug("Using mp='{}' longName='{}' parameterType='{}'", mp, longName, parameterType);
builder.withOption()
.longNames(longName)
.type(parameterType)
.required()
.position(mp.getParameterIndex());
longNames.add(longName);
}
}
if (!longNames.isEmpty() || !shortNames.isEmpty()) {
log.debug("Registering longNames='{}' shortNames='{}'", longNames, shortNames);
OptionSpec optionSpec = builder.withOption()
.type(mp.getParameterType())
.longNames(longNames.toArray(new String[0]))
.shortNames(shortNames.toArray(new Character[0]))
.position(mp.getParameterIndex())
.description(so.help());
if (so.arity() > -1) {
optionSpec.arity(0, so.arity());
}
if (!ObjectUtils.nullSafeEquals(so.defaultValue(), ShellOption.NONE)
&& !ObjectUtils.nullSafeEquals(so.defaultValue(), ShellOption.NULL)) {
optionSpec.defaultValue(so.defaultValue());
}
}
}
builder.withTarget().method(bean, method);
CommandRegistration registration = builder.build();
registry.register(registration);
else {
mp.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
String longName = mp.getParameterName();
Class<?> parameterType = mp.getParameterType();
if (longName != null) {
log.debug("Using mp='{}' longName='{}' parameterType='{}'", mp, longName, parameterType);
builder.withOption()
.longNames(longName)
.type(parameterType)
.required()
.position(mp.getParameterIndex());
}
}
}
builder.withTarget().method(bean, method);
CommandRegistration registration = builder.build();
registry.register(registration);
}, method -> method.getAnnotation(ShellMethod.class) != null);
}
}

View File

@@ -3,9 +3,6 @@
"includes": [
{
"pattern": "completion/.*"
},
{
"pattern": "template/.*.st"
}
]
}