Repository: ajalt/clikt (kotlin)

Issue:
Suspending Version of `CompletionCommand`
Hey,
I'm a bit unsure how to integrate a `SuspendingCliktCommand` with `CompletionCommand`. When having something like the following:

```kotlin
object EtaDeploy : SuspendingCliktCommand() {
    init {
        subcommands(
            CompletionCommand(name = "completion")
        )
    }

    override suspend fun run() {
        ...
    }
}
```

subcommand only accepts suspending commands. Is there a way I can still integrate it?

Pull request description:
Fixes #554 
Fixes #553

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a82b3a9f..084c78c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,9 @@
 # Changelog
 
 ## Unreleased
+### Added
+- Added completion commands for suspending and chained commands.  ([#553](https://github.com/ajalt/clikt/pull/553))
+- Added no-op suspending commands. ([#554](https://github.com/ajalt/clikt/pull/554))
 
 ## 5.0.0
 ### Added
diff --git a/clikt-mordant/api/clikt-mordant.api b/clikt-mordant/api/clikt-mordant.api
old mode 100755
new mode 100644
index c5396ea3..7fe8a7a0
--- a/clikt-mordant/api/clikt-mordant.api
+++ b/clikt-mordant/api/clikt-mordant.api
@@ -28,6 +28,13 @@ public final class com/github/ajalt/clikt/command/SuspendingCliktCommandKt {
 	public static synthetic fun test$default (Lcom/github/ajalt/clikt/command/SuspendingCliktCommand;[Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ZLcom/github/ajalt/mordant/rendering/AnsiLevel;IIZZZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
 }
 
+public abstract class com/github/ajalt/clikt/command/SuspendingNoOpCliktCommand : com/github/ajalt/clikt/command/SuspendingCliktCommand {
+	public fun <init> ()V
+	public fun <init> (Ljava/lang/String;)V
+	public synthetic fun <init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
 public abstract class com/github/ajalt/clikt/core/CliktCommand : com/github/ajalt/clikt/core/CoreCliktCommand {
 	public fun <init> ()V
 	public fun <init> (Ljava/lang/String;)V
diff --git a/clikt-mordant/src/commonMain/kotlin/com/github/ajalt/clikt/command/SuspendingNoOpCliktCommand.kt b/clikt-mordant/src/commonMain/kotlin/com/github/ajalt/clikt/command/SuspendingNoOpCliktCommand.kt
new file mode 100644
index 00000000..e492f44d
--- /dev/null
+++ b/clikt-mordant/src/commonMain/kotlin/com/github/ajalt/clikt/command/SuspendingNoOpCliktCommand.kt
@@ -0,0 +1,15 @@
+package com.github.ajalt.clikt.command
+
+/**
+ * A [SuspendingCliktCommand] that has a default implementation of
+ * [run][SuspendingCliktCommand.run] that is a no-op.
+ */
+abstract class SuspendingNoOpCliktCommand(
+    /**
+     * The name of the program to use in the help output. If not given, it is inferred from the
+     * class name.
+     */
+    name: String? = null,
+) : SuspendingCliktCommand(name) {
+    override suspend fun run() = Unit
+}
diff --git a/clikt-mordant/src/commonMain/kotlin/com/github/ajalt/clikt/core/NoOpCliktCommand.kt b/clikt-mordant/src/commonMain/kotlin/com/github/ajalt/clikt/core/NoOpCliktCommand.kt
index 972536b2..26bb6510 100644
--- a/clikt-mordant/src/commonMain/kotlin/com/github/ajalt/clikt/core/NoOpCliktCommand.kt
+++ b/clikt-mordant/src/commonMain/kotlin/com/github/ajalt/clikt/core/NoOpCliktCommand.kt
@@ -1,7 +1,7 @@
 package com.github.ajalt.clikt.core
 
 /**
- * A [CoreCliktCommand] that has a default implementation of [CliktCommand.run] that is a no-op.
+ * A [CliktCommand] that has a default implementation of [CliktCommand.run] that is a no-op.
  */
 open class NoOpCliktCommand(
     /**
diff --git a/clikt/api/clikt.api b/clikt/api/clikt.api
index a7206d0b..052a8895 100644
--- a/clikt/api/clikt.api
+++ b/clikt/api/clikt.api
@@ -26,6 +26,22 @@ public final class com/github/ajalt/clikt/command/CoreSuspendingCliktCommandKt {
 	public static final fun parse (Lcom/github/ajalt/clikt/command/CoreSuspendingCliktCommand;[Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
+public abstract class com/github/ajalt/clikt/command/CoreSuspendingNoOpCliktCommand : com/github/ajalt/clikt/command/CoreSuspendingCliktCommand {
+	public fun <init> ()V
+	public fun <init> (Ljava/lang/String;)V
+	public synthetic fun <init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
+public final class com/github/ajalt/clikt/completion/ChainedCompletionCommand : com/github/ajalt/clikt/command/CoreChainedCliktCommand {
+	public fun <init> ()V
+	public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
+	public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public fun help (Lcom/github/ajalt/clikt/core/Context;)Ljava/lang/String;
+	public fun helpEpilog (Lcom/github/ajalt/clikt/core/Context;)Ljava/lang/String;
+	public fun run (Ljava/lang/Object;)Ljava/lang/Object;
+}
+
 public final class com/github/ajalt/clikt/completion/CompletionBuiltinsKt {
 	public static final fun completionOption (Lcom/github/ajalt/clikt/core/BaseCliktCommand;[Ljava/lang/String;Ljava/lang/String;Z)Lcom/github/ajalt/clikt/core/BaseCliktCommand;
 	public static synthetic fun completionOption$default (Lcom/github/ajalt/clikt/core/BaseCliktCommand;[Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lcom/github/ajalt/clikt/core/BaseCliktCommand;
@@ -112,6 +128,15 @@ public final class com/github/ajalt/clikt/completion/CompletionGenerator {
 	public final fun generateCompletionForCommand (Lcom/github/ajalt/clikt/core/BaseCliktCommand;Ljava/lang/String;)Ljava/lang/String;
 }
 
+public final class com/github/ajalt/clikt/completion/SuspendingCompletionCommand : com/github/ajalt/clikt/command/CoreSuspendingCliktCommand {
+	public fun <init> ()V
+	public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
+	public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public fun help (Lcom/github/ajalt/clikt/core/Context;)Ljava/lang/String;
+	public fun helpEpilog (Lcom/github/ajalt/clikt/core/Context;)Ljava/lang/String;
+	public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+}
+
 public final class com/github/ajalt/clikt/core/Abort : com/github/ajalt/clikt/core/ProgramResult {
 	public fun <init> ()V
 }
diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/command/CoreChainedCliktCommand.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/command/CoreChainedCliktCommand.kt
index a2a14380..0327ed60 100644
--- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/command/CoreChainedCliktCommand.kt
+++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/command/CoreChainedCliktCommand.kt
@@ -31,6 +31,7 @@ abstract class CoreChainedCliktCommand<T>(
 }
 
 
+
 /**
  * Parse the command line and print helpful output if any errors occur.
  *
diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/command/CoreSuspendingNoOpCliktCommand.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/command/CoreSuspendingNoOpCliktCommand.kt
new file mode 100644
index 00000000..e4dbf6af
--- /dev/null
+++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/command/CoreSuspendingNoOpCliktCommand.kt
@@ -0,0 +1,15 @@
+package com.github.ajalt.clikt.command
+
+/**
+ * A [CoreSuspendingCliktCommand] that has a default implementation of
+ * [run][CoreSuspendingCliktCommand.run] that is a no-op.
+ */
+abstract class CoreSuspendingNoOpCliktCommand(
+    /**
+     * The name of the program to use in the help output. If not given, it is inferred from the
+     * class name.
+     */
+    name: String? = null,
+) : CoreSuspendingCliktCommand(name) {
+    override suspend fun run() = Unit
+}
diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/completion/CompletionBuiltins.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/completion/CompletionBuiltins.kt
index 917cbd96..20dbd36b 100644
--- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/completion/CompletionBuiltins.kt
+++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/completion/CompletionBuiltins.kt
@@ -1,5 +1,7 @@
 package com.github.ajalt.clikt.completion
 
+import com.github.ajalt.clikt.command.CoreChainedCliktCommand
+import com.github.ajalt.clikt.command.CoreSuspendingCliktCommand
 import com.github.ajalt.clikt.completion.CompletionGenerator.generateCompletionForCommand
 import com.github.ajalt.clikt.core.BaseCliktCommand
 import com.github.ajalt.clikt.core.Context
@@ -44,3 +46,40 @@ class CompletionCommand(
         throw PrintCompletionMessage(generateCompletionForCommand(cmd, shell))
     }
 }
+
+/**
+ * A [CoreSuspendingCliktCommand] subcommand that will print a completion script for the given shell
+ * when invoked.
+ */
+class SuspendingCompletionCommand(
+    private val help: String = "Generate a tab-complete script for the given shell",
+    private val epilog: String = "",
+    name: String = "generate-completion",
+) : CoreSuspendingCliktCommand(name) {
+    override fun help(context: Context): String = help
+    override fun helpEpilog(context: Context): String = epilog
+    private val shell by argument("shell").choice(*choices)
+    override suspend fun run() {
+        val cmd = currentContext.parent?.command ?: this
+        throw PrintCompletionMessage(generateCompletionForCommand(cmd, shell))
+    }
+}
+
+/**
+ * A [CoreChainedCliktCommand] subcommand that will print a completion script for the given shell
+ * when invoked.
+ */
+class ChainedCompletionCommand<T>(
+    private val help: String = "Generate a tab-complete script for the given shell",
+    private val epilog: String = "",
+    name: String = "generate-completion",
+) : CoreChainedCliktCommand<T>(name) {
+    override fun help(context: Context): String = help
+    override fun helpEpilog(context: Context): String = epilog
+    private val shell by argument("shell").choice(*choices)
+    override fun run(value: T): T {
+        val cmd = currentContext.parent?.command ?: this
+        throw PrintCompletionMessage(generateCompletionForCommand(cmd, shell))
+    }
+}
+


Test patch:
diff --git a/test/src/commonTest/kotlin/com/github/ajalt/clikt/command/SuspendingCliktCommandTest.kt b/test/src/commonTest/kotlin/com/github/ajalt/clikt/command/SuspendingCliktCommandTest.kt
index f2401aeb..8e3b8e9a 100644
--- a/test/src/commonTest/kotlin/com/github/ajalt/clikt/command/SuspendingCliktCommandTest.kt
+++ b/test/src/commonTest/kotlin/com/github/ajalt/clikt/command/SuspendingCliktCommandTest.kt
@@ -52,4 +52,13 @@ class SuspendingCliktCommandTest {
 
         C().test("baz").output shouldBe "baz\n"
     }
+
+    @Test
+    @JsName("suspending_noop_command_test")
+    fun `suspending no-op command test`() = runTest {
+        class C : SuspendingNoOpCliktCommand()
+        class Sub : CoreSuspendingNoOpCliktCommand()
+
+        C().subcommands(Sub()).test("sub").output shouldBe ""
+    }
 }
diff --git a/test/src/commonTest/kotlin/com/github/ajalt/clikt/completion/CompletionTestBase.kt b/test/src/commonTest/kotlin/com/github/ajalt/clikt/completion/CompletionTestBase.kt
index b5899999..3a7cbb30 100644
--- a/test/src/commonTest/kotlin/com/github/ajalt/clikt/completion/CompletionTestBase.kt
+++ b/test/src/commonTest/kotlin/com/github/ajalt/clikt/completion/CompletionTestBase.kt
@@ -1,5 +1,8 @@
 package com.github.ajalt.clikt.completion
 
+import com.github.ajalt.clikt.command.ChainedCliktCommand
+import com.github.ajalt.clikt.command.SuspendingNoOpCliktCommand
+import com.github.ajalt.clikt.command.parse
 import com.github.ajalt.clikt.core.PrintCompletionMessage
 import com.github.ajalt.clikt.core.context
 import com.github.ajalt.clikt.core.subcommands
@@ -11,6 +14,7 @@ import com.github.ajalt.clikt.testing.TestCommand
 import com.github.ajalt.clikt.testing.parse
 import io.kotest.assertions.throwables.shouldThrow
 import io.kotest.matchers.string.shouldContain
+import kotlinx.coroutines.test.runTest
 import kotlin.js.JsName
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -130,4 +134,34 @@ abstract class CompletionTestBase(private val shell: String) {
         message shouldContain shell
         message shouldContain "foo"
     }
+
+    @Test
+    @JsName("suspending_completion_command")
+    fun `suspending completion command`() = runTest {
+        class Foo : SuspendingNoOpCliktCommand()
+
+        val message = shouldThrow<PrintCompletionMessage> {
+            Foo()
+                .subcommands(SuspendingCompletionCommand(), Foo())
+                .parse(listOf("generate-completion", shell))
+        }.message
+        message shouldContain shell
+        message shouldContain "foo"
+    }
+
+    @Test
+    @JsName("chained_completion_command")
+    fun `chained completion command`() = runTest {
+        class Foo : ChainedCliktCommand<Unit>() {
+            override fun run(value: Unit) = Unit
+        }
+
+        val message = shouldThrow<PrintCompletionMessage> {
+            Foo()
+                .subcommands(ChainedCompletionCommand(), Foo())
+                .parse(listOf("generate-completion", shell), Unit)
+        }.message
+        message shouldContain shell
+        message shouldContain "foo"
+    }
 }