diff --git a/smithy-cli/src/it/java/software/amazon/smithy/cli/InitCommandTest.java b/smithy-cli/src/it/java/software/amazon/smithy/cli/InitCommandTest.java index fb7356e9330..b9a1d41a879 100644 --- a/smithy-cli/src/it/java/software/amazon/smithy/cli/InitCommandTest.java +++ b/smithy-cli/src/it/java/software/amazon/smithy/cli/InitCommandTest.java @@ -62,8 +62,20 @@ public void unexpectedTemplate() { IntegUtils.withTempDir("unexpectedTemplate", dir -> { RunResult result = IntegUtils.run( dir, ListUtils.of("init", "-t", "blabla", "-u", templatesDir.toString())); - assertThat(result.getOutput(), - containsString("Missing expected member `blabla` from `templates` object ([3, 18])")); + + String expectedOutput = new StringBuilder() + .append("Invalid template `blabla`. `Smithy-Examples` provides the following templates:") + .append(System.lineSeparator()) + .append(System.lineSeparator()) + .append("NAME DOCUMENTATION") + .append(System.lineSeparator()) + .append("-------------- ---------------------------------------------------------------") + .append(System.lineSeparator()) + .append("quickstart-cli Smithy Quickstart example weather service using the Smithy CLI.") + .append(System.lineSeparator()) + .toString(); + + assertThat(result.getOutput(), containsString(expectedOutput)); assertThat(result.getExitCode(), is(1)); }); }); @@ -82,6 +94,15 @@ public void withDirectoryArg() { assertThat(result.getExitCode(), is(0)); assertThat(Files.exists(Paths.get(dir.toString(), "hello-world")), is(true)); }); + + IntegUtils.withTempDir("withNestedDirectoryArg", dir -> { + RunResult result = IntegUtils.run(dir, ListUtils.of( + "init", "-t", "quickstart-cli", "-o", "./hello/world", "-u", templatesDir.toString())); + assertThat(result.getOutput(), + containsString("Smithy project created in directory: ./hello/world")); + assertThat(result.getExitCode(), is(0)); + assertThat(Files.exists(Paths.get(dir.toString(), "./hello/world")), is(true)); + }); }); } @@ -102,6 +123,30 @@ public void withLongHandArgs() { }); } + @Test + public void withListArg() { + IntegUtils.withProject(PROJECT_NAME, templatesDir -> { + setupTemplatesDirectory(templatesDir); + + IntegUtils.withTempDir("withListArg", dir -> { + RunResult result = IntegUtils.run(dir, ListUtils.of( + "init", "--list", "--url", templatesDir.toString())); + + String expectedOutput = new StringBuilder() + .append("NAME DOCUMENTATION") + .append(System.lineSeparator()) + .append("-------------- ---------------------------------------------------------------") + .append(System.lineSeparator()) + .append("quickstart-cli Smithy Quickstart example weather service using the Smithy CLI.") + .append(System.lineSeparator()) + .toString(); + + assertThat(result.getOutput(), containsString(expectedOutput)); + assertThat(result.getExitCode(), is(0)); + }); + }); + } + private static void run(List args, Path root) { StringBuilder output = new StringBuilder(); int result = IoUtils.runCommand(args, root, output, Collections.emptyMap()); diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/InitCommand.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/InitCommand.java index 3caf9b1610e..d7f80ee86d6 100644 --- a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/InitCommand.java +++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/InitCommand.java @@ -22,6 +22,9 @@ import java.nio.file.Paths; import java.util.Collections; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; import java.util.function.Consumer; import software.amazon.smithy.cli.ArgumentReceiver; import software.amazon.smithy.cli.Arguments; @@ -31,6 +34,8 @@ import software.amazon.smithy.cli.Command; import software.amazon.smithy.cli.HelpPrinter; import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; import software.amazon.smithy.utils.IoUtils; import software.amazon.smithy.utils.ListUtils; @@ -38,6 +43,10 @@ final class InitCommand implements Command { private static final String SMITHY_TEMPLATE_JSON = "smithy-templates.json"; private static final String DEFAULT_REPOSITORY_URL = "https://github.com/smithy-lang/smithy-examples.git"; + private static final String DOCUMENTATION = "documentation"; + + private static final String NAME = "name"; + private final String parentCommandName; InitCommand(String parentCommandName) { @@ -57,14 +66,31 @@ public String getSummary() { @Override public int execute(Arguments arguments, Env env) { arguments.addReceiver(new Options()); - CommandAction action = HelpActionWrapper.fromCommand(this, parentCommandName, this::run); + CommandAction action = HelpActionWrapper.fromCommand(this, parentCommandName, c -> { + ColorBuffer buffer = ColorBuffer.of(c, new StringBuilder()); + buffer.println("Examples:"); + buffer.println(" smithy init --list", ColorTheme.LITERAL); + buffer.println(" smithy init -o /tmp/quickstart-gradle -t quickstart-gradle", ColorTheme.LITERAL); + return buffer.toString(); + }, this::run); return action.apply(arguments, env); } private int run(Arguments arguments, Env env) { Options options = arguments.getReceiver(Options.class); try { - this.cloneTemplate(options.repositoryUrl, options.template, options.directory, env); + final Path root = Paths.get("."); + final Path temp = Files.createTempDirectory("temp"); + + loadSmithyTemplateJsonFile(options.repositoryUrl, root, temp); + + final ObjectNode smithyTemplatesNode = getSmithyTemplatesNode(temp); + + if (options.listTemplates) { + this.listTemplates(smithyTemplatesNode, env); + } else { + this.cloneTemplate(temp, smithyTemplatesNode, options.template, options.directory, env); + } } catch (IOException | InterruptedException | URISyntaxException e) { throw new RuntimeException(e); } @@ -72,37 +98,74 @@ private int run(Arguments arguments, Env env) { return 0; } - private void cloneTemplate(String repositoryUrl, String template, String directory, Env env) - throws IOException, InterruptedException, URISyntaxException { + private void listTemplates(ObjectNode smithyTemplatesNode, Env env) throws IOException { + try (ColorBuffer buffer = ColorBuffer.of(env.colors(), env.stderr())) { + buffer.println(getTemplateList(smithyTemplatesNode, env)); + } + } - if (template == null || template.isEmpty()) { - throw new IllegalArgumentException("Please specify a template using `--template` or `-t`"); + private String getTemplateList(ObjectNode smithyTemplatesNode, Env env) { + int maxTemplateLength = 0; + int maxDocumentationLength = 0; + Map templates = new TreeMap<>(); + + for (Map.Entry entry : getTemplatesNode(smithyTemplatesNode).getMembers().entrySet()) { + String template = entry.getKey().getValue(); + String documentation = entry.getValue() + .expectObjectNode() + .expectMember(DOCUMENTATION, String.format( + "Missing expected member `%s` from `%s` object", DOCUMENTATION, template)) + .expectStringNode() + .getValue(); + + templates.put(template, documentation); + + maxTemplateLength = Math.max(maxTemplateLength, template.length()); + maxDocumentationLength = Math.max(maxDocumentationLength, documentation.length()); } - final Path root = Paths.get("."); - final Path temp = Files.createTempDirectory("temp"); + final String space = " "; - // Use templateName if directory is not specified - if (directory == null) { - directory = template; + ColorBuffer builder = ColorBuffer.of(env.colors(), new StringBuilder()) + .print(pad(NAME.toUpperCase(Locale.US), maxTemplateLength), ColorTheme.LITERAL) + .print(space) + .print(DOCUMENTATION.toUpperCase(Locale.US), ColorTheme.LITERAL) + .println() + .print(pad("", maxTemplateLength).replace(' ', '-'), ColorTheme.LITERAL) + .print(space) + .print(pad("", maxDocumentationLength).replace(' ', '-'), ColorTheme.LITERAL) + .println(); + + for (Map.Entry entry : templates.entrySet()) { + String template = entry.getKey(); + String doc = entry.getValue(); + builder.print(pad(template, maxTemplateLength)) + .print(space) + .print(pad(doc, maxDocumentationLength)) + .println(); } - // Check Git is installed - exec(ListUtils.of("git", "clone", "--filter=blob:none", "--no-checkout", "--depth", "1", "--sparse", - repositoryUrl, temp.toString()), root); + return builder.toString(); + } - // Download template json file - exec(ListUtils.of("git", "sparse-checkout", "set", "--no-cone", SMITHY_TEMPLATE_JSON), temp); + private void cloneTemplate(Path temp, ObjectNode smithyTemplatesNode, String template, String directory, Env env) + throws IOException, InterruptedException, URISyntaxException { - exec(ListUtils.of("git", "checkout"), temp); + if (template == null || template.isEmpty()) { + throw new IllegalArgumentException("Please specify a template using `--template` or `-t`"); + } + + ObjectNode templatesNode = getTemplatesNode(smithyTemplatesNode); + + if (!templatesNode.containsMember(template)) { + throw new IllegalArgumentException(String.format( + "Invalid template `%s`. `%s` provides the following templates:%n%n%s", + template, getTemplatesName(smithyTemplatesNode), getTemplateList(smithyTemplatesNode, env))); + } // Retrieve template path from smithy-templates.json - String templatePath = readJsonFileAsNode(Paths.get(temp.toString(), SMITHY_TEMPLATE_JSON)) - .expectObjectNode() - .expectMember("templates", String.format( - "Missing expected member `templates` from %s", SMITHY_TEMPLATE_JSON)) - .expectObjectNode() - .expectMember(template, String.format("Missing expected member `%s` from `templates` object", template)) + final String templatePath = templatesNode + .expectObjectMember(template) .expectObjectNode() .expectMember("path", String.format("Missing expected member `path` from `%s` object", template)) .expectStringNode() @@ -113,6 +176,11 @@ private void cloneTemplate(String repositoryUrl, String template, String directo exec(ListUtils.of("git", "checkout"), temp); + // Use templateName if directory is not specified + if (directory == null) { + directory = template; + } + IoUtils.copyDir(Paths.get(temp.toString(), templatePath), Paths.get(directory)); try (ColorBuffer buffer = ColorBuffer.of(env.colors(), env.stderr())) { @@ -120,6 +188,33 @@ private void cloneTemplate(String repositoryUrl, String template, String directo } } + private static void loadSmithyTemplateJsonFile(String repositoryUrl, Path root, Path temp) { + exec(ListUtils.of("git", "clone", "--filter=blob:none", "--no-checkout", "--depth", "1", "--sparse", + repositoryUrl, temp.toString()), root); + + exec(ListUtils.of("git", "sparse-checkout", "set", "--no-cone", SMITHY_TEMPLATE_JSON), temp); + + exec(ListUtils.of("git", "checkout"), temp); + } + + private static ObjectNode getTemplatesNode(ObjectNode smithyTemplatesNode) { + return smithyTemplatesNode + .expectMember("templates", String.format( + "Missing expected member `templates` from %s", SMITHY_TEMPLATE_JSON)) + .expectObjectNode(); + } + + private static String getTemplatesName(ObjectNode smithyTemplatesNode) { + return smithyTemplatesNode + .expectMember(NAME, String.format("Missing expected member `%s` from %s", NAME, SMITHY_TEMPLATE_JSON)) + .expectStringNode() + .getValue(); + } + + private static ObjectNode getSmithyTemplatesNode(Path jsonFilePath) { + return readJsonFileAsNode(Paths.get(jsonFilePath.toString(), SMITHY_TEMPLATE_JSON)).expectObjectNode(); + } + private static String exec(List args, Path directory) { StringBuilder output = new StringBuilder(); int code = IoUtils.runCommand(args, directory, output, Collections.emptyMap()); @@ -130,15 +225,32 @@ private static String exec(List args, Path directory) { return output.toString(); } - private Node readJsonFileAsNode(Path jsonFilePath) { + private static Node readJsonFileAsNode(Path jsonFilePath) { return Node.parse(IoUtils.readUtf8File(jsonFilePath)); } + private static String pad(String s, int n) { + return String.format("%-" + n + "s", s); + } + private static final class Options implements ArgumentReceiver { private String template; private String directory; + private Boolean listTemplates = false; private String repositoryUrl = DEFAULT_REPOSITORY_URL; + @Override + public boolean testOption(String name) { + switch (name) { + case "--list": + case "-l": + listTemplates = true; + return true; + default: + return false; + } + } + @Override public Consumer testParameter(String name) { switch (name) { @@ -165,6 +277,8 @@ public void registerHelp(HelpPrinter printer) { "Smithy templates repository url"); printer.param("--output", "-o", "new-smithy-project", "Smithy project directory"); + printer.param("--list", "-l", null, + "List available templates"); } } } diff --git a/smithy-utils/src/main/java/software/amazon/smithy/utils/IoUtils.java b/smithy-utils/src/main/java/software/amazon/smithy/utils/IoUtils.java index 9d195efb4d3..0439e23f042 100644 --- a/smithy-utils/src/main/java/software/amazon/smithy/utils/IoUtils.java +++ b/smithy-utils/src/main/java/software/amazon/smithy/utils/IoUtils.java @@ -15,8 +15,6 @@ package software.amazon.smithy.utils; -import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; - import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -31,6 +29,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.Arrays; import java.util.Collections; @@ -40,7 +39,6 @@ import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; -import java.util.stream.Stream; /** * Utilities for IO operations. @@ -365,19 +363,43 @@ public FileVisitResult postVisitDirectory(Path dir, IOException e) throws IOExce } public static void copyDir(Path src, Path dest) { - try (Stream stream = Files.walk(src)) { - stream.forEach(source -> copyFile(source, dest.resolve(src.relativize(source)))); + try { + Files.walkFileTree(src, new CopyFileVisitor(src, dest)); } catch (IOException e) { throw new RuntimeException(String.format( "Error copying directory from \"%s\" to \"%s\": %s", src, dest, e.getMessage()), e); } } - public static void copyFile(Path source, Path dest) { - try { - Files.copy(source, dest, REPLACE_EXISTING); - } catch (Exception e) { - throw new RuntimeException(e.getMessage(), e); + private static final class CopyFileVisitor extends SimpleFileVisitor { + private final Path source; + private final Path target; + + CopyFileVisitor(Path source, Path target) { + this.source = source; + this.target = target; + } + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + Path resolve = target.resolve(source.relativize(dir)); + if (Files.notExists(resolve)) { + Files.createDirectories(resolve); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Path resolve = target.resolve(source.relativize(file)); + Files.copy(file, resolve, StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + return FileVisitResult.TERMINATE; } } }