Repository: smithy-lang/smithy
Pull request #1888 (2023-07-28 15:32:33)

Add progress tracker and message for CLI while cloning a template
*Description of changes:*
Adds a progress tracker class that can be used to execute a progress tracker for the CLI while waiting for a set of commands to execute. This PR adds the progress tracker to cloning of templates in the init command as that action can take multiple seconds to complete.

Additional loading bars and progress tracker styles could be added in the future.

Example Execution gif: 
![cli](https://github.com/smithy-lang/smithy/assets/124718352/54fb976b-9f62-4294-b394-866d583e80ed)


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.



Patch:
diff --git a/config/spotbugs/filter.xml b/config/spotbugs/filter.xml
index 3e4ed00a8..655956182 100644
--- a/config/spotbugs/filter.xml
+++ b/config/spotbugs/filter.xml
@@ -188,4 +188,10 @@
         <Bug pattern="SF_SWITCH_FALLTHROUGH"/>
     </Match>
 
+    <!-- It is not worth catching if the closing task of a progress bar fails to execute -->
+    <Match>
+        <Class name="software.amazon.smithy.cli.commands.ProgressTracker"/>
+        <Bug pattern="DE_MIGHT_IGNORE"/>
+    </Match>
+
 </FindBugsFilter>
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 8deb92fd0..5b814dba4 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
@@ -188,13 +188,18 @@ final class InitCommand implements Command {
         final String templatePath = getTemplatePath(templateNode, template);
         List<String> includedFiles = getIncludedFiles(templateNode);
 
-        // Specify the subdirectory to download
-        exec(ListUtils.of("git", "sparse-checkout", "set", "--no-cone", templatePath), temp);
-        // add any additional files that should be included
-        for (String includedFile : includedFiles) {
-            exec(ListUtils.of("git", "sparse-checkout", "add", "--no-cone", includedFile), temp);
+        try (ProgressTracker t = new ProgressTracker(env,
+                ProgressStyle.dots("cloning template", "template cloned"),
+                standardOptions.quiet()
+        )) {
+            // Specify the subdirectory to download
+            exec(ListUtils.of("git", "sparse-checkout", "set", "--no-cone", templatePath), temp);
+            // add any additional files that should be included
+            for (String includedFile : includedFiles) {
+                exec(ListUtils.of("git", "sparse-checkout", "add", "--no-cone", includedFile), temp);
+            }
+            exec(ListUtils.of("git", "checkout"), temp);
         }
-        exec(ListUtils.of("git", "checkout"), temp);
 
 
 
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ProgressStyle.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ProgressStyle.java
new file mode 100644
index 000000000..176da8f8f
--- /dev/null
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ProgressStyle.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.cli.commands;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import software.amazon.smithy.cli.ColorBuffer;
+import software.amazon.smithy.cli.ColorTheme;
+import software.amazon.smithy.cli.Command;
+import software.amazon.smithy.utils.StringUtils;
+
+
+interface ProgressStyle {
+
+    void updateAction(Command.Env env, AtomicInteger tracker);
+
+    void closeAction(Command.Env env);
+
+    static ColorBuffer getBuffer(Command.Env env) {
+        return ColorBuffer.of(env.colors(), env.stdout());
+    }
+
+    static ProgressStyle dots(String progressMessage, String closeMessage) {
+        return new ProgressStyle() {
+            private static final String PROGRESS_CHAR = ".";
+            private static final int TICKER_LENGTH = 3;
+            private final long startTimeMillis = System.currentTimeMillis();
+
+            @Override
+            public void updateAction(Command.Env env, AtomicInteger tracker) {
+                int tickCount = tracker.getAndIncrement();
+                int tickNumber =  tickCount % (TICKER_LENGTH + 1);
+                String loadStr = StringUtils.repeat(PROGRESS_CHAR, tickNumber)
+                        + StringUtils.repeat(" ", TICKER_LENGTH - tickNumber);
+                try (ColorBuffer buffer = getBuffer(env)) {
+                    buffer.print("\r")
+                            .print(progressMessage, ColorTheme.NOTE)
+                            .print(loadStr, ColorTheme.NOTE);
+                }
+            }
+
+            @Override
+            public void closeAction(Command.Env env) {
+                try (ColorBuffer buffer = getBuffer(env)) {
+                    buffer.print("\r")
+                            .print(closeMessage, ColorTheme.SUCCESS)
+                            .print(" [", ColorTheme.MUTED)
+                            .print((System.currentTimeMillis() - startTimeMillis) / 1000.0 + "s",
+                                    ColorTheme.NOTE)
+                            .print("]", ColorTheme.MUTED)
+                            .println();
+                }
+            }
+        };
+    }
+}
diff --git a/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ProgressTracker.java b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ProgressTracker.java
new file mode 100644
index 000000000..d6bcb8f1f
--- /dev/null
+++ b/smithy-cli/src/main/java/software/amazon/smithy/cli/commands/ProgressTracker.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.cli.commands;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import software.amazon.smithy.cli.Command;
+
+final class ProgressTracker implements AutoCloseable {
+    private static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(1, runnable -> {
+        Thread thread = Executors.defaultThreadFactory().newThread(runnable);
+        thread.setDaemon(true);
+        return thread;
+    });
+    private static final long INTERVAL_MILLIS = 400L;
+    private final ScheduledFuture<?> task;
+    private final ProgressStyle style;
+    private final Command.Env env;
+    private final AtomicInteger tracker;
+    private final boolean quiet;
+
+    ProgressTracker(Command.Env env, ProgressStyle style, boolean quiet) {
+        this(env, style, quiet, new AtomicInteger());
+    }
+
+    ProgressTracker(Command.Env env, ProgressStyle style, boolean quiet, AtomicInteger tracker) {
+        this.env = env;
+        this.style = style;
+        this.quiet = quiet;
+        this.tracker = tracker;
+
+        // Do not print a progress bar if the quiet setting is enabled
+        if (quiet) {
+            task = null;
+        } else {
+            task = EXECUTOR.scheduleAtFixedRate(this::write, 0, INTERVAL_MILLIS, TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Override
+    public void close() {
+        if (!quiet) {
+            task.cancel(false);
+            try {
+                EXECUTOR.schedule(this::executeClose, 0, TimeUnit.NANOSECONDS).get();
+            } catch (ExecutionException | InterruptedException e) { /* ignored */ }
+        }
+    }
+
+    private void write() {
+        style.updateAction(env, tracker);
+        // Flush so the output is written immediately
+        env.stdout().flush();
+    }
+
+    private void executeClose() {
+        style.closeAction(env);
+        // Flush so the output is written immediately
+        env.stdout().flush();
+    }
+}
diff --git a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/CapturingTokenizer.java b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/CapturingTokenizer.java
index 1a35c5f16..1f41d34a9 100644
--- a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/CapturingTokenizer.java
+++ b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/CapturingTokenizer.java
@@ -132,10 +132,18 @@ final class CapturingTokenizer implements IdlTokenizer {
         if (getToken().getIdlToken() == IdlToken.EOF) {
             throw new NoSuchElementException();
         }
-        trees.getFirst().appendChild(TokenTree.of(CapturedToken.from(this, this.stringTable)));
+        appendCurrentTokenToFirstTree();
         return tokens.get(++cursor).getIdlToken();
     }
 
+    void eof() {
+        appendCurrentTokenToFirstTree();
+    }
+
+    private void appendCurrentTokenToFirstTree() {
+        trees.getFirst().appendChild(TokenTree.of(CapturedToken.from(this, this.stringTable)));
+    }
+
     CapturedToken peekPastSpaces() {
         return peekWhile(0, token -> token == IdlToken.SPACE);
     }
diff --git a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/TreeCursor.java b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/TreeCursor.java
index b5a103e59..1e2536133 100644
--- a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/TreeCursor.java
+++ b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/TreeCursor.java
@@ -304,7 +304,7 @@ public final class TreeCursor implements FromSourceLocation {
                     isMatch = column >= startColumn && column < endColumn;
                 } else if (line == startLine && column >= startColumn) {
                     isMatch = true;
-                } else if (line == endLine && column <= endColumn) {
+                } else if (line == endLine && column < endColumn) {
                     isMatch = true;
                 } else if (line > startLine && line < endLine) {
                     isMatch = true;
diff --git a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/TreeType.java b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/TreeType.java
index c63b4d693..5c7cfb8e2 100644
--- a/smithy-syntax/src/main/java/software/amazon/smithy/syntax/TreeType.java
+++ b/smithy-syntax/src/main/java/software/amazon/smithy/syntax/TreeType.java
@@ -1012,6 +1012,8 @@ public enum TreeType {
                         optionalWs(tokenizer);
                         break;
                     case EOF:
+                        tokenizer.eof();
+                        break;
                     default:
                         break;
                 }


Test patch:
diff --git a/smithy-syntax/src/test/java/software/amazon/smithy/syntax/TreeCursorTest.java b/smithy-syntax/src/test/java/software/amazon/smithy/syntax/TreeCursorTest.java
index 42ad9a4ba..297157408 100644
--- a/smithy-syntax/src/test/java/software/amazon/smithy/syntax/TreeCursorTest.java
+++ b/smithy-syntax/src/test/java/software/amazon/smithy/syntax/TreeCursorTest.java
@@ -17,7 +17,7 @@ import software.amazon.smithy.utils.IoUtils;
 public class TreeCursorTest {
     @Test
     public void hasChildren() {
-        TokenTree tree = createTree();
+        TokenTree tree = createTree("simple-model.smithy");
         TreeCursor cursor = tree.zipper();
         List<TreeCursor> children = cursor.getChildren();
 
@@ -36,7 +36,7 @@ public class TreeCursorTest {
 
     @Test
     public void hasParentAndSiblings() {
-        TokenTree tree = createTree();
+        TokenTree tree = createTree("simple-model.smithy");
         TreeCursor cursor = tree.zipper();
         List<TreeCursor> children = cursor.getChildren();
 
@@ -55,7 +55,7 @@ public class TreeCursorTest {
 
     @Test
     public void findsNodeAtPosition() {
-        TokenTree tree = createTree();
+        TokenTree tree = createTree("simple-model.smithy");
         TreeCursor cursor = tree.zipper();
         TreeCursor click = cursor.findAt(3, 17);
 
@@ -65,8 +65,32 @@ public class TreeCursorTest {
         assertThat(click.getRoot(), equalTo(cursor));
     }
 
-    private TokenTree createTree() {
-        String model = IoUtils.readUtf8Url(getClass().getResource("formatter/simple-model.smithy"));
+    @Test
+    public void findsNodeAtPositionBetweenTokens() {
+        TokenTree tree = createTree("incorrect-indentation.smithy");
+        TreeCursor cursor = tree.zipper();
+        TreeCursor click = cursor.findAt(10, 5);
+
+        assertThat(click, notNullValue());
+        assertThat(click.getTree().getType(), is(TreeType.TOKEN));
+        assertThat(click.getTree().tokens().iterator().next().getLexeme().toString(), equalTo("@"));
+        assertThat(click.getRoot(), equalTo(cursor));
+    }
+
+    @Test
+    public void findsNodeAtLastLineOfFile() {
+        TokenTree tree = createTree("missing-trailing-newline.smithy");
+        TreeCursor cursor = tree.zipper();
+        TreeCursor click = cursor.findAt(8, 4);
+
+        assertThat(click, notNullValue());
+        assertThat(click.getTree().getType(), is(TreeType.TOKEN));
+        assertThat(click.getTree().tokens().iterator().next().getLexeme().toString(), equalTo("foo"));
+        assertThat(click.getRoot(), equalTo(cursor));
+    }
+
+    private TokenTree createTree(String filename) {
+        String model = IoUtils.readUtf8Url(getClass().getResource("formatter/" + filename));
         IdlTokenizer tokenizer = IdlTokenizer.create(model);
         return TokenTree.of(tokenizer);
     }
diff --git a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/incorrect-indentation.formatted.smithy b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/incorrect-indentation.formatted.smithy
new file mode 100644
index 000000000..09c6d9a5e
--- /dev/null
+++ b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/incorrect-indentation.formatted.smithy
@@ -0,0 +1,11 @@
+$version: "2.0"
+
+namespace smithy.example
+
+structure Foo {}
+
+structure Bar {}
+
+// Comment
+@input
+structure Baz {}
diff --git a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/incorrect-indentation.smithy b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/incorrect-indentation.smithy
new file mode 100644
index 000000000..1f967d207
--- /dev/null
+++ b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/incorrect-indentation.smithy
@@ -0,0 +1,11 @@
+$version: "2.0"
+
+namespace smithy.example
+
+structure Foo {}
+
+    structure Bar {}
+
+// Comment
+    @input
+structure Baz {}
diff --git a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/missing-trailing-newline.formatted.smithy b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/missing-trailing-newline.formatted.smithy
new file mode 100644
index 000000000..95b66d5ab
--- /dev/null
+++ b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/missing-trailing-newline.formatted.smithy
@@ -0,0 +1,9 @@
+$version: "2.0"
+
+namespace smithy.example
+
+@trait
+structure foo {}
+
+@foo
+structure Bar {}
diff --git a/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/missing-trailing-newline.smithy b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/missing-trailing-newline.smithy
new file mode 100644
index 000000000..dd3568693
--- /dev/null
+++ b/smithy-syntax/src/test/resources/software/amazon/smithy/syntax/formatter/missing-trailing-newline.smithy
@@ -0,0 +1,9 @@
+$version: "2.0"
+
+namespace smithy.example
+
+@trait
+structure foo {}
+
+@foo
+structure Bar {}
\ No newline at end of file