Add help command

This commit is contained in:
Eric Bottard
2015-12-18 15:53:59 +01:00
parent 4c3af009cb
commit 2f1a42bde0
32 changed files with 1168 additions and 42 deletions

View File

@@ -59,6 +59,13 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
import org.springframework.boot.SpringApplication;

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
import java.util.HashMap;
@@ -15,15 +31,15 @@ public class DefaultMethodTargetResolver implements MethodTargetResolver {
@Override
public Map<String, MethodTarget> resolve(ApplicationContext applicationContext) {
Map<String,MethodTarget> methodTargets = new HashMap<>();
Map<String, MethodTarget> methodTargets = new HashMap<>();
Map<String, Object> commandBeans = applicationContext.getBeansWithAnnotation(ShellComponent.class);
for (Object bean : commandBeans.values()) {
Class<?> clazz = bean.getClass();
ReflectionUtils.doWithMethods(clazz, method -> {
ShellMethod shellMapping = method.getAnnotation(ShellMethod.class);
String[] keys = shellMapping.value();
if (keys.length == 1 && "".equals(keys[0])) {
keys[0] = method.getName();
if (keys.length == 0) {
keys = new String[]{method.getName()};
}
for (String key : keys) {
methodTargets.put(key, new MethodTarget(method, bean, shellMapping.help()));

View File

@@ -1,5 +1,23 @@
/*
* 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
*
* http://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.shell2;
import static org.springframework.shell2.Utils.unCamelify;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
@@ -23,25 +41,27 @@ import org.springframework.util.ConcurrentReferenceHashMap;
/**
* Default ParameterResolver implementation that supports the following features:<ul>
* <li>named parameters (recognized because they start with some {@link ShellMethod#prefix()}</li>
* <li>implicit named parameters (from the actual method parameter name)</li>
* <li>positional parameters (in order, for all parameter values that were not resolved via named parameters)</li>
* <li>default values (for all remaining parameters)</li>
* <li>named parameters (recognized because they start with some {@link ShellMethod#prefix()}</li>
* <li>implicit named parameters (from the actual method parameter name)</li>
* <li>positional parameters (in order, for all parameter values that were not resolved via named parameters)</li>
* <li>default values (for all remaining parameters)</li>
* </ul>
*
* <p>Method arguments can consume several words of input at once (driven by {@link ShellOption#arity()}, default 1).
* If several words are consumed, they will be joined together as a comma separated value and passed to the {@link ConversionService}
* If several words are consumed, they will be joined together as a comma separated value and passed to the {@link
* ConversionService}
* (which will typically return a List or array).</p>
*
* <p>Boolean parameters are by default expected to have an arity of 0, allowing invocations in the form {@code rm --force --dir /foo}:
* the presence of {@code --force} passes {@code true} as a parameter value, while its absence passes {@code false}. Both
* <p>Boolean parameters are by default expected to have an arity of 0, allowing invocations in the form {@code rm
* --force --dir /foo}:
* the presence of {@code --force} passes {@code true} as a parameter value, while its absence passes {@code false}.
* Both
* the default arity of 0 and the default value of {@code false} can be overridden <i>via</i> {@link ShellOption}
* if needed.</p>
*
* @author Eric Bottard
* @author Florent Biville
*/
class DefaultParameterResolver implements ParameterResolver {
public class DefaultParameterResolver implements ParameterResolver {
private final ConversionService conversionService;
@@ -63,7 +83,7 @@ class DefaultParameterResolver implements ParameterResolver {
@Override
public Object resolve(MethodParameter methodParameter, List<String> words) {
String prefix = methodParameter.getMethod().getAnnotation(ShellMethod.class).prefix();
String prefix = prefixForMethod(methodParameter);
CacheKey cacheKey = new CacheKey(methodParameter.getMethod(), words);
Map<Parameter, String> resolved = parameterCache.computeIfAbsent(cacheKey, (k) -> {
@@ -113,11 +133,7 @@ class DefaultParameterResolver implements ParameterResolver {
offset += arity;
} // No more input. Try defaultValues
else {
Optional<String> defaultValue = Optional.empty();
ShellOption option = parameter.getAnnotation(ShellOption.class);
if (option != null && !ShellOption.NULL.equals(option.defaultValue())) {
defaultValue = Optional.of(option.defaultValue());
}
Optional<String> defaultValue = defaultValueFor(parameter);
String value = defaultValue.orElseThrow(() -> new RuntimeException(String.format("Ran out of input for " + keys)));
result.put(parameter, value);
}
@@ -133,7 +149,72 @@ class DefaultParameterResolver implements ParameterResolver {
});
String s = resolved.get(methodParameter.getMethod().getParameters()[methodParameter.getParameterIndex()]);
return conversionService.convert(s, TypeDescriptor.valueOf(String.class), new TypeDescriptor(methodParameter));
if (ShellOption.NULL.equals(s)) {
return null;
}
else {
return conversionService.convert(s, TypeDescriptor.valueOf(String.class), new TypeDescriptor(methodParameter));
}
}
private String prefixForMethod(MethodParameter methodParameter) {
return methodParameter.getMethod().getAnnotation(ShellMethod.class).prefix();
}
private Optional<String> defaultValueFor(Parameter parameter) {
Optional<String> defaultValue = Optional.empty();
ShellOption option = parameter.getAnnotation(ShellOption.class);
if (option != null && !ShellOption.NONE.equals(option.defaultValue())) {
defaultValue = Optional.of(option.defaultValue());
}
return defaultValue;
}
@Override
public ParameterDescription describe(MethodParameter parameter) {
Parameter jlrParameter = parameter.getMethod().getParameters()[parameter.getParameterIndex()];
int arity = getArity(jlrParameter);
Class<?> type = parameter.getParameterType();
ShellOption option = jlrParameter.getAnnotation(ShellOption.class);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < arity; i++) {
if (i > 0) {
sb.append(" ");
}
sb.append(arity > 1 ? unCamelify(removeMultiplicityFromType(parameter).getSimpleName()) : unCamelify(type.getSimpleName()));
}
ParameterDescription result = ParameterDescription.ofType(type);
result.formal(sb.toString());
if (option != null) {
result.help(option.help());
Optional<String> defaultValue = defaultValueFor(jlrParameter);
if (defaultValue.isPresent()) {
result.defaultValue(defaultValue.map(dv -> dv.equals(ShellOption.NULL) ? "<none>" : dv).get());
}
}
List<String> rawKeys = getKeysForParameter(parameter.getMethod(), parameter.getParameterIndex());
String prefix = prefixForMethod(parameter);
result
.keys(rawKeys.stream().map(k -> prefix + k).collect(Collectors.toList()))
.mandatoryKey(false);
return result;
}
/**
* In case of {@code foo[] or Collection<Foo>} and arity > 1, return the element type.
*/
private Class<?> removeMultiplicityFromType(MethodParameter parameter) {
Class<?> parameterType = parameter.getParameterType();
if (parameterType.isArray()) {
return parameterType.getComponentType();
}
else if (parameterType.isAssignableFrom(Collection.class)) {
return parameter.getNestedParameterType();
}
else {
throw new RuntimeException("For " + parameter + " (with arity > 1) expected an array/collection type");
}
}
/**
@@ -167,7 +248,7 @@ class DefaultParameterResolver implements ParameterResolver {
* or from the actual parameter name.
* @throws IllegalArgumentException if parameter names could not be extracted
*/
private Collection<String> getKeysForParameter(Method method, int index) {
private List<String> getKeysForParameter(Method method, int index) {
Parameter parameter = method.getParameters()[index];
ShellOption option = parameter.getAnnotation(ShellOption.class);
if (option != null && option.value().length > 0) {
@@ -180,7 +261,7 @@ class DefaultParameterResolver implements ParameterResolver {
Assert.notNull(parameterName, String.format(
"Could not discover parameter name at index %d for %s, and option key(s) were not specified via %s annotation",
index, method, ShellOption.class.getSimpleName()));
return Collections.singleton(parameterName);
return Collections.singletonList(parameterName);
}
}

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
import java.lang.reflect.Parameter;
@@ -7,6 +23,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import javax.validation.ConstraintViolation;
@@ -22,7 +39,9 @@ import org.jline.reader.ParsedLine;
import org.jline.reader.impl.DefaultParser;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
import org.jline.utils.AttributedCharSequence;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.springframework.beans.factory.annotation.Autowired;
@@ -37,7 +56,7 @@ import org.springframework.util.ReflectionUtils;
* Created by ericbottard on 26/11/15.
*/
@Component
public class JLineShell {
public class JLineShell implements Shell {
@Autowired
private ApplicationContext applicationContext;
@@ -54,6 +73,11 @@ public class JLineShell {
@Autowired
private List<ParameterResolver> parameterResolvers = new ArrayList<>();
@Override
public Map<String, MethodTarget> listCommands() {
return methodTargets;
}
@PostConstruct
public void init() throws Exception {
for (MethodTargetResolver resolver : applicationContext.getBeansOfType(MethodTargetResolver.class).values()) {
@@ -76,11 +100,19 @@ public class JLineShell {
@Override
public AttributedString highlight(LineReader reader, String buffer) {
if (buffer.length() < 4) {
return new AttributedString(buffer, AttributedStyle.DEFAULT.blink().foreground(AttributedStyle.RED));
int l = 0;
String best = null;
for (String command : methodTargets.keySet()) {
if (buffer.startsWith(command) && command.length() > l) {
l = command.length();
best = command;
}
}
if (best != null) {
return new AttributedStringBuilder(buffer.length()).append(best, AttributedStyle.BOLD).append(buffer.substring(l)).toAttributedString();
}
else {
return new AttributedString(buffer);
return new AttributedString(buffer, AttributedStyle.DEFAULT.foreground(AttributedStyle.RED));
}
}
})
@@ -111,6 +143,8 @@ public class JLineShell {
}
List<String> words = lineReader.getParsedLine().words();
// TODO investigate trailing empty string in e.g. "help 'WTF 2'"
words = words.stream().filter(w -> w.length() > 0).collect(Collectors.toList());
if (methodTarget != null) {
Parameter[] parameters = methodTarget.getMethod().getParameters();
Object[] rawArgs = new Object[parameters.length];
@@ -135,7 +169,9 @@ public class JLineShell {
Set<ConstraintViolation<Object>> constraintViolations = executableValidator.validateParameters(methodTarget.getBean(),
methodTarget.getMethod(),
rawArgs);
System.out.println(constraintViolations);
if (constraintViolations.size() > 0) {
System.out.println(constraintViolations);
}
Object result = null;
try {
@@ -145,7 +181,12 @@ public class JLineShell {
result = e;
}
System.out.println(String.valueOf(result));
if (result instanceof AttributedCharSequence) {
System.out.println(((AttributedCharSequence) result).toAnsi(lineReader.getTerminal()));
}
else {
System.out.println(String.valueOf(result));
}
}
else {

View File

@@ -1,7 +1,25 @@
/*
* 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
*
* http://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.shell2;
import java.lang.reflect.Method;
import org.springframework.util.Assert;
/**
* Created by ericbottard on 27/11/15.
*/
@@ -14,6 +32,9 @@ public class MethodTarget {
private final String help;
public MethodTarget(Method method, Object bean, String help) {
Assert.notNull(method, "Method cannot be null");
Assert.notNull(bean, "Bean cannot be null");
Assert.hasText(help, "Help cannot be blank");
this.method = method;
this.bean = bean;
this.help = help;

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
import java.util.Map;

View File

@@ -0,0 +1,125 @@
/*
* 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
*
* http://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.shell2;
import java.util.List;
import java.util.Optional;
import org.springframework.core.MethodParameter;
/**
* 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 final 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 for the parameter, if any.
*/
private Optional<String> defaultValue = Optional.empty();
/**
* The list of 'keys' that can be used to specify this parameter, if any.
*/
private List<String> keys;
/**
* 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 = "";
public ParameterDescription(String type) {
this.type = type;
this.formal = type;
}
public static ParameterDescription ofType(String type) {
return new ParameterDescription(Utils.unCamelify(type));
}
public static ParameterDescription ofType(Class<?> type) {
return ofType(type.getSimpleName());
}
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.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;
}
public String type() {
return type;
}
public String formal() {
return formal;
}
public String help() {
return help;
}
public ParameterDescription formal(String formal) {
this.formal = formal;
return this;
}
}

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
import java.util.List;
@@ -11,6 +27,8 @@ public interface ParameterResolver {
boolean supports(MethodParameter parameter);
public Object resolve(MethodParameter methodParameter, List<String> words);
Object resolve(MethodParameter methodParameter, List<String> words);
ParameterDescription describe(MethodParameter parameter);
}

View File

@@ -0,0 +1,29 @@
/*
* 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
*
* http://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.shell2;
import java.util.Map;
/**
* Created by ericbottard on 17/12/15.
*/
public interface Shell {
public Map<String, MethodTarget> listCommands();
}

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
import java.lang.annotation.Documented;
@@ -9,7 +25,13 @@ import java.lang.annotation.Target;
import org.springframework.stereotype.Component;
/**
* Created by ericbottard on 27/11/15.
* Indicates that an annotated class may contain shell methods (themselves annotated with {@link @ShellMethod}) that
* is,
* methods that may be invoked reflectively by the shell.
*
* <p>This annotation is a specialization of {@link Component}.</p>
* @author Eric Bottard
* @see Component
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@@ -17,5 +39,8 @@ import org.springframework.stereotype.Component;
@Component
public @interface ShellComponent {
String value();
/**
* Used to indicate a suggestion for a logical name for the component.
*/
String value() default "";
}

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
import java.lang.annotation.Documented;
@@ -8,7 +24,6 @@ import java.lang.annotation.Target;
/**
* Used to mark a method as invokable via Spring Shell.
*
* @author Eric Bottard
* @author Florent Biville
*/

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
import java.lang.annotation.Documented;
@@ -16,6 +32,8 @@ public @interface ShellOption {
String NULL = "__NULL__";
String NONE = "__NONE__";
/**
* The key(s) (without the {@link ShellMethod#prefix()}) by which this parameter can be referenced
* when using named parameters. If none is specified, the actual method parameter name will be used.
@@ -30,5 +48,10 @@ public @interface ShellOption {
/**
* The textual (pre-conversion) value to assign to this parameter if no value is provided by the user.
*/
String defaultValue() default NULL;
String defaultValue() default NONE;
/**
* Return a short description of the parameter.
*/
String help() default "";
}

View File

@@ -0,0 +1,43 @@
/*
* 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
*
* http://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.shell2;
/**
* Some text utilities.
*
* @author Eric Bottard
*/
public class Utils {
/**
* Turn CamelCaseText into gnu-style-lowercase.
*/
public static String unCamelify(CharSequence original) {
StringBuilder result = new StringBuilder(original.length());
boolean wasLowercase = false;
for (int i = 0; i < original.length(); i++) {
char ch = original.charAt(i);
if (Character.isUpperCase(ch) && wasLowercase) {
result.append('-');
}
wasLowercase = Character.isLowerCase(ch);
result.append(Character.toLowerCase(ch));
}
return result.toString();
}
}

View File

@@ -0,0 +1,218 @@
/*
* 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
*
* http://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.shell2.commands;
import static java.util.stream.Collectors.mapping;
import static java.util.stream.Collectors.toCollection;
import java.io.IOException;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.shell2.MethodTarget;
import org.springframework.shell2.ParameterDescription;
import org.springframework.shell2.ParameterResolver;
import org.springframework.shell2.Shell;
import org.springframework.shell2.ShellComponent;
import org.springframework.shell2.ShellMethod;
import org.springframework.shell2.ShellOption;
/**
* A command to display help about all available commands.
*
* @author Eric Bottard
*/
@ShellComponent
public class Help {
private final List<ParameterResolver> parameterResolvers;
private Shell shell;
@Autowired
public Help(List<ParameterResolver> parameterResolvers) {
this.parameterResolvers = parameterResolvers;
}
@Autowired // ctor injection impossible b/c of circular dependency
public void setShell(Shell shell) {
this.shell = shell;
}
@ShellMethod(help = "Display help about available commands.", prefix = "-")
public CharSequence help(
@ShellOption(defaultValue = ShellOption.NULL,
value = {"C", "-command"},
help = "The command to obtain help for.") String command) throws IOException {
if (command == null) {
return listCommands();
}
else {
return documentCommand(command);
}
}
/**
* Return a description of a specific command. Uses a layout inspired by *nix man pages.
*/
private CharSequence documentCommand(String command) {
MethodTarget methodTarget = shell.listCommands().get(command);
if (methodTarget == null) {
throw new IllegalArgumentException("Unknown command '" + command + "'");
}
// NAME
AttributedStringBuilder result = new AttributedStringBuilder().append("\n\n");
result.append("NAME", AttributedStyle.BOLD).append("\n\t");
result.append(command).append(" - ").append(methodTarget.getHelp()).append("\n\n");
// SYNOPSYS
result.append("SYNOPSYS", AttributedStyle.BOLD).append("\n\t");
result.append(command, AttributedStyle.BOLD);
result.append(" ");
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(methodTarget);
for (ParameterDescription description : parameterDescriptions) {
if (description.defaultValue().isPresent()) {
result.append("[");
}
if (!description.mandatoryKey()) {
result.append("[");
}
result.append(description.keys().iterator().next(), AttributedStyle.BOLD);
if (!description.mandatoryKey()) {
result.append("]");
if (!description.formal().isEmpty()) {
result.append(" ");
}
}
appendUnderlinedFormal(result, description);
if (description.defaultValue().isPresent()) {
result.append("]");
}
result.append(" ");
}
result.append("\n\n");
// OPTIONS
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) {
result.append(" ");
appendUnderlinedFormal(result, description);
result.append("\n\t");
}
else if (description.keys().size() > 1) {
result.append("\n\t");
}
result.append("\t");
result.append(description.help());
if (description.defaultValue().isPresent()) {
result
.append(" [Optional, default = ", AttributedStyle.BOLD)
.append(description.defaultValue().get(), AttributedStyle.BOLD.italic())
.append("]", AttributedStyle.BOLD);
} else {
result.append(" [Mandatory]", AttributedStyle.BOLD);
}
result.append("\n\n");
}
// ALSO KNOWN AS
Set<String> aliases = shell.listCommands().entrySet().stream()
.filter(e -> e.getValue().equals(methodTarget))
.map(e -> e.getKey())
.filter(c -> !command.equals(c))
.collect(toCollection(TreeSet::new));
if (!aliases.isEmpty()) {
result.append("ALSO KNOWN AS", AttributedStyle.BOLD).append("\n");
for (String alias : aliases) {
result.append('\t').append(alias).append('\n');
}
}
result.append("\n");
return result;
}
private CharSequence listCommands() {
Map<String, Set<String>> groupedByMethodTarget = shell.listCommands().entrySet().stream()
.collect(Collectors.groupingBy(e -> e.getValue().getHelp(), // Use help() as the grouping key
mapping(Map.Entry::getKey, toCollection(TreeSet::new)))); // accumulate the command 'names' into a sorted set
// Then display commands, sorted alphabetically by their first alias
AttributedStringBuilder result = new AttributedStringBuilder();
result.append("AVAILABLE COMMANDS\n\n", AttributedStyle.BOLD);
groupedByMethodTarget.entrySet().stream()
.sorted(sortByFirstElement())
.forEach(e -> result.append("\t")
.append(e.getValue().stream().collect(Collectors.joining(", ")), AttributedStyle.BOLD)
.append(": ")
.append(e.getKey())
.append('\n')
);
return result.append("\n");
}
private Comparator<Map.Entry<String, Set<String>>> sortByFirstElement() {
return (e1, e2) -> e1.getValue().iterator().next().compareTo(e2.getValue().iterator().next());
}
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(MethodTarget methodTarget) {
Parameter[] parameters = methodTarget.getMethod().getParameters();
List<ParameterDescription> parameterDescriptions = new ArrayList<>();
for (int i = 0, parametersLength = parameters.length; i < parametersLength; i++) {
for (ParameterResolver parameterResolver : parameterResolvers) {
MethodParameter methodParameter = new MethodParameter(methodTarget.getMethod(), i);
if (parameterResolver.supports(methodParameter)) {
parameterDescriptions.add(parameterResolver.describe(methodParameter));
break;
}
}
}
return parameterDescriptions;
}
}

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.jcommander;
import java.lang.annotation.Annotation;
@@ -13,6 +29,7 @@ import com.beust.jcommander.ParametersDelegate;
import org.springframework.beans.BeanUtils;
import org.springframework.core.MethodParameter;
import org.springframework.shell2.ParameterDescription;
import org.springframework.shell2.ParameterResolver;
import org.springframework.util.ReflectionUtils;
@@ -60,4 +77,8 @@ public class JCommanderParameterResolver implements ParameterResolver {
return pojo;
}
@Override
public ParameterDescription describe(MethodParameter parameter) {
throw new UnsupportedOperationException();
}
}

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.legacy;
import java.util.HashMap;
@@ -12,7 +28,10 @@ import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;
/**
* Created by ericbottard on 09/12/15.
* A {@link MethodTargetResolver} that discovers methods annotated with {@link CliCommand} on beans
* implementing the {@link CommandMarker} marker interface.
* @author Eric Bottard
* @author Florent Biville
*/
@Component
public class LegacyMethodTargetResolver implements MethodTargetResolver {
@@ -20,13 +39,13 @@ public class LegacyMethodTargetResolver implements MethodTargetResolver {
@Override
public Map<String, MethodTarget> resolve(ApplicationContext context) {
Map<String, MethodTarget> methodTargets = new HashMap<>();
Map<String, CommandMarker> beansOfType = context.getBeansOfType(CommandMarker.class);
for (Object bean : beansOfType.values()) {
Map<String, CommandMarker> beans = context.getBeansOfType(CommandMarker.class);
for (Object bean : beans.values()) {
Class<?> clazz = bean.getClass();
ReflectionUtils.doWithMethods(clazz, method -> {
CliCommand shellMapping = method.getAnnotation(CliCommand.class);
for (String key : shellMapping.value()) {
methodTargets.put(key, new MethodTarget(method, bean, shellMapping.help()));
CliCommand cliCommand = method.getAnnotation(CliCommand.class);
for (String key : cliCommand.value()) {
methodTargets.put(key, new MethodTarget(method, bean, cliCommand.help()));
}
}, method -> method.getAnnotation(CliCommand.class) != null);
}

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.legacy;
import java.util.ArrayList;
@@ -14,6 +30,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.shell.core.Converter;
import org.springframework.shell.core.annotation.CliOption;
import org.springframework.shell2.ParameterDescription;
import org.springframework.shell2.ParameterResolver;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
@@ -24,8 +41,6 @@ import org.springframework.util.Assert;
@Component
public class LegacyParameterResolver implements ParameterResolver {
public static final Object UNSPECIFIED = new Object();
@Autowired(required = false)
private Collection<Converter<?>> converters = new ArrayList<>();
@@ -61,6 +76,11 @@ public class LegacyParameterResolver implements ParameterResolver {
}
}
@Override
public ParameterDescription describe(MethodParameter parameter) {
throw new UnsupportedOperationException();
}
private Map<String, String> parseOptions(List<String> words) {
Map<String, String> values = new HashMap<>();
for (int i = 0; i < words.size(); i++) {
@@ -68,7 +88,7 @@ public class LegacyParameterResolver implements ParameterResolver {
if (word.startsWith("--")) {
String key = word.substring("--".length());
// If next word doesn't exist or starts with '--', this is an unary option. Store null
String value = i < words.size() - 1 && !words.get(i+1).startsWith("--") ? words.get(++i) : null;
String value = i < words.size() - 1 && !words.get(i + 1).startsWith("--") ? words.get(++i) : null;
Assert.isTrue(!values.containsKey(key), String.format("Option --%s has already been set", key));
values.put(key, value);
} // Must be the 'anonymous' option

View File

@@ -0,0 +1,5 @@
<configuration>
<!-- Disable most stdout logging by default -->
<root level="ERROR">
</root>
</configuration>

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
import org.junit.Rule;

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2;
/**

View File

@@ -0,0 +1,37 @@
/*
* 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
*
* http://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.shell2;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.Test;
/**
* Tests for {@link Utils}.
*
* @author Eric Bottard
*/
public class UtilsTest {
@Test
public void testUnCamelify() throws Exception {
assertThat(Utils.unCamelify("HelloWorld")).isEqualTo("hello-world");
assertThat(Utils.unCamelify("helloWorld")).isEqualTo("hello-world");
assertThat(Utils.unCamelify("helloWorldHowAreYou")).isEqualTo("hello-world-how-are-you");
assertThat(Utils.unCamelify("URL")).isEqualTo("url");
}
}

View File

@@ -0,0 +1,157 @@
/*
* 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
*
* http://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.shell2.commands;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.assertj.core.api.Assertions;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestName;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.core.io.ClassPathResource;
import org.springframework.shell2.DefaultParameterResolver;
import org.springframework.shell2.MethodTarget;
import org.springframework.shell2.ParameterResolver;
import org.springframework.shell2.Shell;
import org.springframework.shell2.ShellComponent;
import org.springframework.shell2.ShellMethod;
import org.springframework.shell2.ShellOption;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.ReflectionUtils;
/**
* Tests for the {@link Help} command.
*
* @author Eric Bottard
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = HelpTest.Config.class)
public class HelpTest {
@Autowired
private Help help;
@Rule
public TestName testName = new TestName();
@Test
public void testCommandHelp() throws Exception {
CharSequence help = this.help.help("first-command").toString();
Assertions.assertThat(help).isEqualTo(sample());
}
@Test
public void testCommandList() throws Exception {
String list = this.help.help(null).toString();
Assertions.assertThat(list).isEqualTo(sample());
}
@Test(expected = IllegalArgumentException.class)
public void testUnknownCommand() throws Exception {
this.help.help("some unknown command");
}
private String sample() throws IOException {
InputStream is = new ClassPathResource(HelpTest.class.getSimpleName() + "-" + testName.getMethodName() + ".txt", HelpTest.class).getInputStream();
return FileCopyUtils.copyToString(new InputStreamReader(is, "UTF-8")).replace("&", "");
}
@Configuration
static class Config {
@Bean
public Help help() {
return new Help(Collections.singletonList(parameterResolver()));
}
@Bean
public Shell shell() {
return () -> {
Map<String, MethodTarget> result = new HashMap<>();
Method method = ReflectionUtils.findMethod(Commands.class, "firstCommand", boolean.class, boolean.class, int.class, float[].class);
MethodTarget methodTarget = new MethodTarget(method, commands(), "A rather extensive description of some command.");
result.put("first-command", methodTarget);
result.put("1st-command", methodTarget);
method = ReflectionUtils.findMethod(Commands.class, "secondCommand");
methodTarget = new MethodTarget(method, commands(), "The second command. This one is known under several aliases as well.");
result.put("second-command", methodTarget);
result.put("yet-another-command", methodTarget);
method = ReflectionUtils.findMethod(Commands.class, "thirdCommand");
methodTarget = new MethodTarget(method, commands(), "The last command.");
result.put("third-command", methodTarget);
return result;
};
}
@Bean
public ParameterResolver parameterResolver() {
return new DefaultParameterResolver(new DefaultConversionService());
}
@Bean
public Object commands() {
return new Commands();
}
}
@ShellComponent
static class Commands {
@ShellMethod(prefix = "-")
public void firstCommand(
// Single key and arity = 0. Help displayed on same line
@ShellOption(help = "Whether to delete recursively", arity = 0) boolean r,
// Multiple keys and arity 0. Help displayed on next line
@ShellOption(help = "Do not ask for confirmation. YOLO", arity = 0, value = {"f", "-force"}) boolean force,
// Single key, arity >= 1. Help displayed on next line. Optional
@ShellOption(help = "The answer to everything", defaultValue = "42") int n,
// Single key, arity > 1.
@ShellOption(help = "Some other parameters", arity = 3) float[] o
) {
}
@ShellMethod
public void secondCommand() {
}
@ShellMethod
public void thirdCommand() {
}
}
}

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.jcommander;
import java.util.ArrayList;

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.jcommander;
import org.junit.Test;

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.jcommander;
/**

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.legacy;
/**

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.legacy;
import java.lang.reflect.Method;
@@ -37,7 +53,7 @@ public class LegacyCommands implements CommandMarker {
return String.format(("Successfully registered module '%s:%s'"), type, name);
}
@CliCommand("sum")
@CliCommand(value = "sum", help = "adds two numbers")
public int sum(@CliOption(key = "v1", unspecifiedDefaultValue = "38") int a, @CliOption(key = "v2", specifiedDefaultValue = "42") int b) {
return a + b;
}

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.legacy;

View File

@@ -1,3 +1,19 @@
/*
* 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
*
* http://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.shell2.legacy;
import org.junit.Rule;

View File

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

View File

@@ -0,0 +1,6 @@
AVAILABLE COMMANDS&
&
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.&
&