diff --git a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java index 737e8d9b1..5d52e7beb 100644 --- a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java +++ b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 the original author or authors. + * Copyright 2022-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import org.springframework.shell.standard.ShellComponent; import org.springframework.shell.standard.ShellMethod; import org.springframework.shell.standard.completion.BashCompletions; +import org.springframework.shell.standard.completion.ZshCompletions; /** * Command to create a shell completion files, i.e. for {@code bash}. @@ -52,4 +53,10 @@ public String bash() { BashCompletions bashCompletions = new BashCompletions(resourceLoader, getCommandCatalog()); return bashCompletions.generate(rootCommand); } + + @ShellMethod(key = "completion zsh", value = "Generate zsh completion script") + public String zsh() { + ZshCompletions zshCompletions = new ZshCompletions(resourceLoader, getCommandCatalog()); + return zshCompletions.generate(rootCommand); + } } diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java index 8a1d862e0..a3e03ad78 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 the original author or authors. + * Copyright 2022-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -88,8 +88,9 @@ protected CommandModel generateCommandModel() { else { commandKey = splitKeys[i]; } + String desc = i + 1 < splitKeys.length ? null : registration.getDescription(); DefaultCommandModelCommand command = commands.computeIfAbsent(commandKey, - (fullCommand) -> new DefaultCommandModelCommand(fullCommand, main)); + (fullCommand) -> new DefaultCommandModelCommand(fullCommand, main, desc)); // TODO long vs short List options = registration.getOptions().stream() @@ -147,8 +148,13 @@ interface CommandModel { interface CommandModelCommand { /** - * Gets sub-commands known to this command. + * Gets a description of a command. + * @return command description + */ + String getDescription(); + /** + * Gets sub-commands known to this command. * @return known sub-commands */ List getCommands(); @@ -240,12 +246,19 @@ class DefaultCommandModelCommand implements CommandModelCommand { private String fullCommand; private String mainCommand; + private String description; private List commands = new ArrayList<>(); private List options = new ArrayList<>(); - DefaultCommandModelCommand(String fullCommand, String mainCommand) { + DefaultCommandModelCommand(String fullCommand, String mainCommand, String description) { this.fullCommand = fullCommand; this.mainCommand = mainCommand; + this.description = description; + } + + @Override + public String getDescription() { + return description; } @Override @@ -293,6 +306,9 @@ void addOptions(List options) { } void addCommand(DefaultCommandModelCommand command) { + if (commands.contains(command)) { + return; + } commands.add(command); } diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/ZshCompletions.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/ZshCompletions.java new file mode 100644 index 000000000..0e37399a6 --- /dev/null +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/ZshCompletions.java @@ -0,0 +1,41 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.standard.completion; + +import org.springframework.core.io.ResourceLoader; +import org.springframework.shell.command.CommandCatalog; + +/** + * Completion script generator for a {@code zsh}. + * + * @author Janne Valkealahti + */ +public class ZshCompletions extends AbstractCompletions { + + public ZshCompletions(ResourceLoader resourceLoader, CommandCatalog commandCatalog) { + super(resourceLoader, commandCatalog); + } + + public String generate(String rootCommand) { + CommandModel model = generateCommandModel(); + return builder() + .attribute("name", rootCommand) + .attribute("model", model) + .group("classpath:completion/zsh.stg") + .appendGroup("main") + .build(); + } +} diff --git a/spring-shell-standard/src/main/resources/completion/zsh.stg b/spring-shell-standard/src/main/resources/completion/zsh.stg new file mode 100644 index 000000000..1cbc689b3 --- /dev/null +++ b/spring-shell-standard/src/main/resources/completion/zsh.stg @@ -0,0 +1,89 @@ +// +// pre content template before commands +// needs to escape some > characters +// +pre(name) ::= << +#compdef _ +>> + +// +// commands section with command and description +// +cmd_and_desc(command) ::= << +":" +>> + +// +// case for command to call function +// +cmd_func(name,command) ::= << +) + __}; separator="_"> + ;; +>> + +// +// recursive sub commands +// +sub_command(name,command,commands) ::= << +function __}; separator="_"> { + local -a commands + + _arguments -C \ + " \\}; separator="\n"> + "1: :->cmnds" \ + "*::arg:->args" + + case $state in + cmnds) + commands=( + }; separator="\n"> + ) + _describe "command" commands + ;; + esac + + case "$words[1]" in + }; separator="\n"> + esac +} + +}; separator="\n\n"> +>> + +// +// top level commands +// +top_commands(name,commands) ::= << +function _ { + local -a commands + + _arguments -C \ + "1: :->cmnds" \ + "*::arg:->args" + + case $state in + cmnds) + commands=( + }; separator="\n"> + ) + _describe "command" commands + ;; + esac + + case "$words[1]" in + }; separator="\n"> + esac +} + +}; separator="\n\n"> +>> + +// +// main template to call from render +// +main(name, model) ::= << + + + +>> diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java index cfcd083ed..77286a8f3 100644 --- a/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 the original author or authors. + * Copyright 2022-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,73 +29,224 @@ public class AbstractCompletionsTests { + private final TestCommands commands = new TestCommands(); + + private final CommandRegistration r1 = CommandRegistration.builder() + .command("test1") + .withTarget() + .method(commands, "test1") + .and() + .withOption() + .longNames("param1") + .and() + .build(); + + private final CommandRegistration r2 = CommandRegistration.builder() + .command("test2") + .withTarget() + .method(commands, "test2") + .and() + .build(); + + private final CommandRegistration r3 = CommandRegistration.builder() + .command("test3") + .withTarget() + .method(commands, "test3") + .and() + .build(); + + private final CommandRegistration r3_4 = CommandRegistration.builder() + .command("test3", "test4") + .withTarget() + .method(commands, "test4") + .and() + .withOption() + .longNames("param4") + .and() + .build(); + + private final CommandRegistration r3_5 = CommandRegistration.builder() + .command("test3", "test5") + .withTarget() + .method(commands, "test4") + .and() + .withOption() + .longNames("param4") + .and() + .build(); + + private final CommandRegistration r3_4_5 = CommandRegistration.builder() + .command("test3", "test4", "test5") + .withTarget() + .method(commands, "test4") + .and() + .withOption() + .longNames("param4") + .and() + .build(); + + private final CommandRegistration r3_4_6 = CommandRegistration.builder() + .command("test3", "test4", "test6") + .withTarget() + .method(commands, "test4") + .and() + .withOption() + .longNames("param4") + .and() + .build(); + + private final CommandRegistration r3_5_5 = CommandRegistration.builder() + .command("test3", "test5", "test5") + .withTarget() + .method(commands, "test4") + .and() + .withOption() + .longNames("param4") + .and() + .build(); + + private final CommandRegistration r3_5_6 = CommandRegistration.builder() + .command("test3", "test5", "test6") + .withTarget() + .method(commands, "test4") + .and() + .withOption() + .longNames("param4") + .and() + .build(); + + @Test + public void deepL3Commands() { + DefaultResourceLoader resourceLoader = new DefaultResourceLoader(); + CommandCatalog commandCatalog = CommandCatalog.of(); + + commandCatalog.register(r3_4_5); + commandCatalog.register(r3_4_6); + commandCatalog.register(r3_5_5); + commandCatalog.register(r3_5_6); + TestCompletions completions = new TestCompletions(resourceLoader, commandCatalog); + CommandModel commandModel = completions.testCommandModel(); + + assertThat(commandModel.getCommands()).satisfiesExactlyInAnyOrder( + c3 -> { + assertThat(c3.getMainCommand()).isEqualTo("test3"); + assertThat(c3.getOptions()).hasSize(0); + assertThat(c3.getSubCommands()).hasSize(2); + assertThat(c3.getCommands()).hasSize(2); + assertThat(c3.getCommands()).satisfiesExactlyInAnyOrder( + c34 -> { + assertThat(c34.getMainCommand()).isEqualTo("test4"); + assertThat(c34.getCommands()).satisfiesExactlyInAnyOrder( + c345 -> { + assertThat(c345.getMainCommand()).isEqualTo("test5"); + }, + c346 -> { + assertThat(c346.getMainCommand()).isEqualTo("test6"); + } + ); + }, + c35 -> { + assertThat(c35.getMainCommand()).isEqualTo("test5"); + assertThat(c35.getCommands()).satisfiesExactlyInAnyOrder( + c355 -> { + assertThat(c355.getMainCommand()).isEqualTo("test5"); + }, + c356 -> { + assertThat(c356.getMainCommand()).isEqualTo("test6"); + } + ); + } + ); + } + ); + } + + @Test + public void deepL2Commands() { + DefaultResourceLoader resourceLoader = new DefaultResourceLoader(); + CommandCatalog commandCatalog = CommandCatalog.of(); + + commandCatalog.register(r3_4); + commandCatalog.register(r3_5); + TestCompletions completions = new TestCompletions(resourceLoader, commandCatalog); + CommandModel commandModel = completions.testCommandModel(); + + assertThat(commandModel.getCommands()).satisfiesExactlyInAnyOrder( + c3 -> { + assertThat(c3.getMainCommand()).isEqualTo("test3"); + assertThat(c3.getOptions()).hasSize(0); + assertThat(c3.getSubCommands()).hasSize(2); + assertThat(c3.getCommands()).hasSize(2); + assertThat(c3.getCommands()).satisfiesExactlyInAnyOrder( + c34 -> { + assertThat(c34.getMainCommand()).isEqualTo("test4"); + assertThat(c34.getOptions()).hasSize(1); + assertThat(c34.getOptions()).satisfiesExactly( + o -> { + assertThat(o.option()).isEqualTo("--param4"); + } + ); + }, + c35 -> { + assertThat(c35.getMainCommand()).isEqualTo("test5"); + assertThat(c35.getOptions()).hasSize(1); + assertThat(c35.getOptions()).satisfiesExactly( + o -> { + assertThat(o.option()).isEqualTo("--param4"); + } + ); + } + ); + } + ); + } + @Test public void testBasicModelGeneration() { DefaultResourceLoader resourceLoader = new DefaultResourceLoader(); CommandCatalog commandCatalog = CommandCatalog.of(); - TestCommands commands = new TestCommands(); - - CommandRegistration registration1 = CommandRegistration.builder() - .command("test1") - .withTarget() - .method(commands, "test1") - .and() - .withOption() - .longNames("param1") - .and() - .build(); - - CommandRegistration registration2 = CommandRegistration.builder() - .command("test2") - .withTarget() - .method(commands, "test2") - .and() - .build(); - - CommandRegistration registration3 = CommandRegistration.builder() - .command("test3") - .withTarget() - .method(commands, "test3") - .and() - .build(); - - CommandRegistration registration4 = CommandRegistration.builder() - .command("test3", "test4") - .withTarget() - .method(commands, "test4") - .and() - .withOption() - .longNames("param4") - .and() - .build(); - - commandCatalog.register(registration1); - commandCatalog.register(registration2); - commandCatalog.register(registration3); - commandCatalog.register(registration4); + commandCatalog.register(r1); + commandCatalog.register(r2); + commandCatalog.register(r3); + commandCatalog.register(r3_4); TestCompletions completions = new TestCompletions(resourceLoader, commandCatalog); CommandModel commandModel = completions.testCommandModel(); - assertThat(commandModel.getCommands()).hasSize(3); - assertThat(commandModel.getCommands().stream().map(c -> c.getMainCommand())).containsExactlyInAnyOrder("test1", "test2", - "test3"); - assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test1")).findFirst().get() - .getOptions()).hasSize(1); - assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test1")).findFirst().get() - .getOptions().get(0).option()).isEqualTo("--param1"); - assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test2")).findFirst().get() - .getOptions()).hasSize(0); - assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get() - .getOptions()).hasSize(0); - assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get() - .getCommands()).hasSize(1); - assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get() - .getCommands().get(0).getMainCommand()).isEqualTo("test4"); - assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get() - .getCommands().get(0).getOptions()).hasSize(1); - assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get() - .getCommands().get(0).getOptions().get(0).option()).isEqualTo("--param4"); + assertThat(commandModel.getCommands()).satisfiesExactlyInAnyOrder( + c1 -> { + assertThat(c1.getMainCommand()).isEqualTo("test1"); + assertThat(c1.getSubCommands()).hasSize(0); + assertThat(c1.getOptions()).hasSize(1); + assertThat(c1.getOptions()).satisfiesExactly( + o -> { + assertThat(o.option()).isEqualTo("--param1"); + } + ); + }, + c2 -> { + assertThat(c2.getMainCommand()).isEqualTo("test2"); + assertThat(c2.getSubCommands()).hasSize(0); + assertThat(c2.getOptions()).hasSize(0); + }, + c3 -> { + assertThat(c3.getMainCommand()).isEqualTo("test3"); + assertThat(c3.getOptions()).hasSize(0); + assertThat(c3.getSubCommands()).hasSize(1); + assertThat(c3.getCommands()).hasSize(1); + assertThat(c3.getCommands()).satisfiesExactly( + c34 -> { + assertThat(c34.getMainCommand()).isEqualTo("test4"); + assertThat(c34.getOptions()).hasSize(1); + assertThat(c34.getOptions()).satisfiesExactly( + o -> { + assertThat(o.option()).isEqualTo("--param4"); + } + ); + } + ); + } + ); } @Test diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/ZshCompletionsTests.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/ZshCompletionsTests.java new file mode 100644 index 000000000..21091f067 --- /dev/null +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/ZshCompletionsTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.standard.completion; + +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandContext; +import org.springframework.shell.command.CommandRegistration; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ZshCompletionsTests { + + AnnotationConfigApplicationContext context; + + @BeforeEach + public void setup() { + context = new AnnotationConfigApplicationContext(); + context.refresh(); + } + + @AfterEach + public void clean() { + if (context != null) { + context.close(); + } + context = null; + } + + @Test + public void testNoCommands() { + CommandCatalog commandCatalog = CommandCatalog.of(); + ZshCompletions completions = new ZshCompletions(context, commandCatalog); + String zsh = completions.generate("root-command"); + assertThat(zsh).contains("root-command"); + } + + @Test + public void testCommandFromMethod() { + CommandCatalog commandCatalog = CommandCatalog.of(); + registerFromMethod(commandCatalog); + ZshCompletions completions = new ZshCompletions(context, commandCatalog); + String zsh = completions.generate("root-command"); + assertThat(zsh).contains("root-command"); + assertThat(zsh).contains("testmethod1)"); + assertThat(zsh).contains("_root-command_testmethod1"); + assertThat(zsh).contains("--arg1"); + } + + @Test + public void testCommandFromFunction() { + CommandCatalog commandCatalog = CommandCatalog.of(); + registerFromFunction(commandCatalog, "testmethod1"); + ZshCompletions completions = new ZshCompletions(context, commandCatalog); + String zsh = completions.generate("root-command"); + assertThat(zsh).contains("root-command"); + assertThat(zsh).contains("testmethod1)"); + assertThat(zsh).contains("_root-command_testmethod1"); + assertThat(zsh).contains("--arg1"); + } + + private void registerFromMethod(CommandCatalog commandCatalog) { + Pojo1 pojo1 = new Pojo1(); + CommandRegistration registration = CommandRegistration.builder() + .command("testmethod1") + .description("desc") + .withTarget() + .method(pojo1, "method1") + .and() + .withOption() + .longNames("arg1") + .and() + .build(); + commandCatalog.register(registration); + } + + private void registerFromFunction(CommandCatalog commandCatalog, String command) { + Function function = ctx -> { + String arg1 = ctx.getOptionValue("arg1"); + return String.format("hi, arg1 value is '%s'", arg1); + }; + CommandRegistration registration = CommandRegistration.builder() + .command(command) + .withTarget() + .function(function) + .and() + .withOption() + .longNames("arg1") + .and() + .build(); + commandCatalog.register(registration); + } + + protected static class Pojo1 { + + void method1() {} + } +}