diff --git a/CHANGELOG.md b/CHANGELOG.md index a61fa6500..b53fb7a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ ## Unreleased ### Added - Added `CliktCommand.terminal` extension for accessing the terminal from a command. +- Added `includeSystemEnvvars`, `ansiLevel`, `width`, and `height` parameters to all `CliktCommand.test` overloads. ### Deprecated -- Deprecated `CliktCommand.prompt`, use `CliktCommand.terminal.prompt` instead. +- Deprecated `CliktCommand.prompt`, use `CliktCommand.terminal.prompt` or `Prompt` instead. +- Deprecated `CliktCommand.confirm`, use `YesNoPrompt` instead. ### Fixed - Fixed incorrect error message when a `defaultLazy` option referenced a `required` option. ([#430](https://github.com/ajalt/clikt/issues/430)) diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/CliktCommand.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/CliktCommand.kt index 61bd1fe5d..26d9335a4 100644 --- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/CliktCommand.kt +++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/CliktCommand.kt @@ -427,21 +427,14 @@ abstract class CliktCommand( convert ) - /** - * Prompt for user confirmation. - * - * @param text The message asking for input to show the user - * @param default The value to return if the user enters an empty line, or `null` to require a value - * @param uppercaseDefault If true and [default] is not `null`, the default choice will be shown in uppercase. - * @param showChoices If true, the choices will be added to the [prompt] - * @param choiceStrings The strings to accept for `true` and `false` inputs - * @param promptSuffix A string to append after [prompt] when showing the user the prompt - * @param invalidChoiceMessage The message to show the user if they enter a value that isn't one of the [choiceStrings]. - * - * @return The converted user input, or `null` if EOF was reached before this function was called. - * - * @see Terminal.prompt - */ + @Deprecated( + "Use YesNoPrompt instead", + ReplaceWith( + "YesNoPrompt(text, terminal, default, uppercaseDefault, showChoices, choiceStrings, promptSuffix, invalidChoiceMessage).ask()", + "com.github.ajalt.clikt.core.terminal", + "com.github.ajalt.mordant.terminal.YesNoPrompt" + ) + ) fun confirm( text: String, default: Boolean? = null, @@ -451,16 +444,7 @@ abstract class CliktCommand( promptSuffix: String = ": ", invalidChoiceMessage: String = "Invalid value, choose from ", ): Boolean? { - return YesNoPrompt( - text, - currentContext.terminal, - default, - uppercaseDefault, - showChoices, - choiceStrings, - promptSuffix, - invalidChoiceMessage, - ).ask() + return YesNoPrompt(text, terminal, default, uppercaseDefault, showChoices, choiceStrings, promptSuffix, invalidChoiceMessage).ask() } /** diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/TransformAll.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/TransformAll.kt index e5155b3b4..240e0cec0 100644 --- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/TransformAll.kt +++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parameters/options/TransformAll.kt @@ -8,7 +8,9 @@ import com.github.ajalt.clikt.core.MissingOption import com.github.ajalt.clikt.core.UsageError import com.github.ajalt.clikt.output.HelpFormatter import com.github.ajalt.clikt.output.ParameterFormatter +import com.github.ajalt.mordant.terminal.ConfirmationPrompt import com.github.ajalt.mordant.terminal.ConversionResult +import com.github.ajalt.mordant.terminal.Prompt import kotlin.jvm.JvmMultifileClass import kotlin.jvm.JvmName @@ -191,35 +193,37 @@ fun NullableOption.prompt( hideInput: Boolean = false, promptSuffix: String = ": ", showDefault: Boolean = true, + requireConfirmation: Boolean = false, + confirmationPrompt: String = "Repeat for confirmation: ", + confirmationMismatchMessage: String = "Values do not match, try again", ): OptionWithValues = transformAll { invocations -> val promptText = text ?: longestName()?.let { splitOptionPrefix(it).second } ?.replace(Regex("\\W"), " ")?.capitalize2() ?: "Value" - - when (val provided = invocations.lastOrNull()) { - null -> { - if (context.errorEncountered) throw Abort() - context.terminal.prompt( - prompt = promptText, - default = default, - showDefault = showDefault, - hideInput = hideInput, - promptSuffix = promptSuffix, - ) { input -> + val provided = invocations.lastOrNull() + if (provided != null) return@transformAll provided + if (context.errorEncountered) throw Abort() + + val builder: (String) -> Prompt = { + object : Prompt( + prompt = it, + terminal = terminal, + default = default, + showDefault = showDefault, + hideInput = hideInput, + promptSuffix = promptSuffix, + ) { + override fun convert(input: String): ConversionResult { val ctx = OptionCallTransformContext("", this@transformAll, context) try { - val v = - transformAll(listOf(transformEach(ctx, listOf(transformValue(ctx, input))))) - if (v != null) { - @Suppress("UNCHECKED_CAST") - (option as? OptionWithValues)?.transformValidator?.invoke( - this@transformAll, - v - ) - } - ConversionResult.Valid(v) + val v = transformEach(ctx, listOf(transformValue(ctx, input))) + + @Suppress("UNCHECKED_CAST") + val validator = (option as? OptionWithValues)?.transformValidator + validator?.invoke(this@transformAll, v) + return ConversionResult.Valid(v) } catch (e: UsageError) { e.context = e.context ?: context - ConversionResult.Invalid( + return ConversionResult.Invalid( e.formatMessage( context.localization, ParameterFormatter.Plain @@ -228,9 +232,18 @@ fun NullableOption.prompt( } } } - - else -> provided - } ?: throw Abort() + } + val result = if (requireConfirmation) { + ConfirmationPrompt.create( + promptText, + confirmationPrompt, + confirmationMismatchMessage, + builder + ).ask() + } else { + builder(promptText).ask() + } + return@transformAll result ?: throw Abort() } // the stdlib capitalize was deprecated without a replacement diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt index 94c63555a..ba16c0321 100644 --- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt +++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt @@ -299,7 +299,7 @@ internal object Parser { command.currentContext.invokedSubcommand = subcommand if (command.currentContext.printExtraMessages) { for (warning in command.messages) { - command.currentContext.terminal.warning(warning, stderr = true) + command.terminal.warning(warning, stderr = true) } } diff --git a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/testing/CliktTesting.kt b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/testing/CliktTesting.kt index 65a45d923..a2cabac91 100644 --- a/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/testing/CliktTesting.kt +++ b/clikt/src/commonMain/kotlin/com/github/ajalt/clikt/testing/CliktTesting.kt @@ -24,20 +24,60 @@ data class CliktCommandTestResult( val statusCode: Int, ) +/** + * Test this command, returning a result that captures the output and result status code. + * + * Note that only output printed with [echo][CliktCommand.echo] will be captured. Anything printed with [print] or + * [println] is not. + * + * @param argv The command line to send to the command + * @param stdin Content of stdin that will be read by prompt options. Multiple inputs should be separated by `\n`. + * @param envvars A map of environment variable name to value for envvars that can be read by the command + * @param includeSystemEnvvars Set to true to include the environment variables from the system in addition to those + * defined in [envvars] + * @param ansiLevel Defaults to no colored output; set to [AnsiLevel.TRUECOLOR] to include ANSI codes in the output. + * @param width The width of the terminal, used to wrap text + * @param height The height of the terminal + */ fun CliktCommand.test( argv: String, stdin: String = "", envvars: Map = emptyMap(), + includeSystemEnvvars: Boolean = false, + ansiLevel: AnsiLevel = AnsiLevel.NONE, + width: Int = 79, + height: Int = 24, ): CliktCommandTestResult { - return test(shlex("test", argv, null), stdin, envvars) + val argvArray = shlex("test", argv, null) + return test(argvArray, stdin, envvars, includeSystemEnvvars, ansiLevel, width, height) } +/** + * Test this command, returning a result that captures the output and result status code. + * + * Note that only output printed with [echo][CliktCommand.echo] will be captured. Anything printed with [print] or + * [println] is not. + * + * @param argv The command line to send to the command + * @param stdin Content of stdin that will be read by prompt options. Multiple inputs should be separated by `\n`. + * @param envvars A map of environment variable name to value for envvars that can be read by the command + * @param includeSystemEnvvars Set to true to include the environment variables from the system in addition to those + * defined in [envvars] + * @param ansiLevel Defaults to no colored output; set to [AnsiLevel.TRUECOLOR] to include ANSI codes in the output. + * @param width The width of the terminal, used to wrap text + * @param height The height of the terminal + */ fun CliktCommand.test( argv: List, stdin: String = "", envvars: Map = emptyMap(), -): CliktCommandTestResult = test(argv.toTypedArray(), stdin, envvars) + includeSystemEnvvars: Boolean = false, + ansiLevel: AnsiLevel = AnsiLevel.NONE, + width: Int = 79, + height: Int = 24, +): CliktCommandTestResult = + test(argv.toTypedArray(), stdin, envvars, includeSystemEnvvars, ansiLevel, width, height) /** * Test this command, returning a result that captures the output and result status code. @@ -64,11 +104,11 @@ fun CliktCommand.test( height: Int = 24, ): CliktCommandTestResult { var exitCode = 0 - val iface = TerminalRecorder(ansiLevel, width, height) - iface.inputLines = stdin.split("\n").toMutableList() + val recorder = TerminalRecorder(ansiLevel, width, height) + recorder.inputLines = stdin.split("\n").toMutableList() context { envvarReader = { envvars[it] ?: (if (includeSystemEnvvars) readEnvvar(it) else null) } - terminal = Terminal(terminal.theme, terminal.tabWidth, iface) + terminal = Terminal(terminal.theme, terminal.tabWidth, recorder) } try { @@ -77,5 +117,5 @@ fun CliktCommand.test( echoFormattedHelp(e) exitCode = e.statusCode } - return CliktCommandTestResult(iface.stdout(), iface.stderr(), iface.output(), exitCode) + return CliktCommandTestResult(recorder.stdout(), recorder.stderr(), recorder.output(), exitCode) } diff --git a/clikt/src/jvmTest/kotlin/com/github/ajalt/clikt/parameters/PromptOptionsTest.kt b/clikt/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/PromptOptionsTest.kt similarity index 58% rename from clikt/src/jvmTest/kotlin/com/github/ajalt/clikt/parameters/PromptOptionsTest.kt rename to clikt/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/PromptOptionsTest.kt index 260b1f56c..32ff4fd38 100644 --- a/clikt/src/jvmTest/kotlin/com/github/ajalt/clikt/parameters/PromptOptionsTest.kt +++ b/clikt/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/PromptOptionsTest.kt @@ -1,65 +1,48 @@ package com.github.ajalt.clikt.parameters -import com.github.ajalt.clikt.core.BadParameterValue import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.core.terminal import com.github.ajalt.clikt.parameters.options.check -import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.options.nullableFlag import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.transform.theme +import com.github.ajalt.clikt.parameters.options.prompt import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.testing.TestCommand -import com.github.ajalt.clikt.testing.formattedMessage -import com.github.ajalt.clikt.testing.parse import com.github.ajalt.clikt.testing.test import com.github.ajalt.mordant.terminal.ConversionResult -import io.kotest.assertions.throwables.shouldThrow +import com.github.ajalt.mordant.terminal.YesNoPrompt import io.kotest.matchers.shouldBe -import org.junit.Rule -import org.junit.contrib.java.lang.system.SystemOutRule -import org.junit.contrib.java.lang.system.TextFromStandardInputStream +import io.kotest.matchers.string.shouldContain +import kotlin.js.JsName import kotlin.test.Test class PromptOptionsTest { - @Rule - @JvmField - val stdout: SystemOutRule = SystemOutRule().enableLog().muteForSuccessfulTests() - - @Rule - @JvmField - val stdin: TextFromStandardInputStream = TextFromStandardInputStream.emptyStandardInputStream() - @Test + @JsName("command_prompt") fun `command prompt`() { - stdin.provideLines("bar", "1") - class C : TestCommand() { override fun run_() { terminal.prompt("Foo") shouldBe "bar" terminal.prompt("Baz") { ConversionResult.Valid(it.toInt()) } shouldBe 1 } } - C().parse("") - stdout.logWithNormalizedLineSeparator shouldBe "Foo: Baz: " + C().test("", stdin="bar\n1").output shouldBe "Foo: Baz: " } @Test + @JsName("command_confirm") fun `command confirm`() { - stdin.provideLines("y") - class C : TestCommand() { override fun run_() { - confirm("Foo", default = false) shouldBe true + YesNoPrompt("Foo", terminal, default = false).ask() shouldBe true } } - C().parse("") - stdout.logWithNormalizedLineSeparator shouldBe "Foo [y/N]: " + C().test("", stdin="y").output shouldBe "Foo [y/N]: " } @Test + @JsName("prompt_option") fun `prompt option`() { - stdin.provideLines("foo", "bar") - class C : TestCommand() { val foo by option().prompt() val bar by option().prompt() @@ -68,27 +51,37 @@ class PromptOptionsTest { bar shouldBe "bar" } } - C().parse("") - stdout.logWithNormalizedLineSeparator shouldBe "Foo: Bar: " + C().test("", stdin="foo\nbar").output shouldBe "Foo: Bar: " } @Test + @JsName("prompt_option_after_error") fun `prompt option after error`() { class C : TestCommand(false) { val foo by option().int() val bar by option().prompt() } - shouldThrow { - C().parse("--foo=x") - }.formattedMessage shouldBe "invalid value for --foo: x is not a valid integer" - stdout.logWithNormalizedLineSeparator shouldBe "" + + val result = C().test("--foo=x") + result.stdout shouldBe "" + result.stderr shouldContain "invalid value for --foo: x is not a valid integer" } + @Test + @JsName("prompt_option_requireConfirmation") + fun `prompt option requireConfirmation`() { + class C : TestCommand() { + val foo by option().prompt(requireConfirmation = true) + override fun run_() { + foo shouldBe "foo" + } + } + C().test("", stdin="foo\nfoo").output shouldBe "Foo: Repeat for confirmation: " + } @Test + @JsName("prompt_flag") fun `prompt flag`() { - stdin.provideLines("yes", "f") - class C : TestCommand() { val foo by option().nullableFlag().prompt() val bar by option().nullableFlag().prompt() @@ -99,71 +92,50 @@ class PromptOptionsTest { baz shouldBe null } } - C().parse("") - stdout.logWithNormalizedLineSeparator shouldBe "Foo: Bar: " + C().test("", stdin="yes\nf").output shouldBe "Foo: Bar: " } @Test + @JsName("prompt_option_validate") fun `prompt option validate`() { - stdin.provideLines("f", "foo") - class C : TestCommand() { val foo by option().prompt().check { it.length > 1 } override fun run_() { foo shouldBe "foo" } } - C().parse("") - stdout.logWithNormalizedLineSeparator shouldBe "Foo: invalid value for --foo: f\nFoo: " - } - - - @Test - fun `custom console`() { - class C : TestCommand() { - val foo by option().prompt() - override fun run_() { - foo shouldBe "bar" - } - } - - val r = C().test("", stdin = "bar") - r.output shouldBe "Foo: " + C().test("", stdin="f\nfoo").output shouldBe "Foo: invalid value for --foo: f\nFoo: " } @Test + @JsName("custom_console_inherited_by_subcommand") fun `custom console inherited by subcommand`() { - class C : TestCommand() - - class S : TestCommand() { + class C : TestCommand() { val foo by option().prompt() override fun run_() { foo shouldBe "bar" } } - val r = C().subcommands(S()).test("s", stdin = "bar") + val r = TestCommand().subcommands(C()).test("c", stdin = "bar") r.output shouldBe "Foo: " } @Test + @JsName("custom_name") fun `custom name`() { - stdin.provideLines("foo") - class C : TestCommand() { val foo by option().prompt("INPUT") override fun run_() { foo shouldBe "foo" } } - C().parse("") - stdout.logWithNormalizedLineSeparator shouldBe "INPUT: " + C().test("", stdin="foo").output shouldBe "INPUT: " } @Test + @JsName("inferred_names") fun `inferred names`() { - stdin.provideLines("foo", "bar", "baz") - class C : TestCommand() { val foo by option().prompt() val bar by option("/bar").prompt() @@ -174,14 +146,12 @@ class PromptOptionsTest { baz shouldBe "baz" } } - C().parse("") - stdout.logWithNormalizedLineSeparator shouldBe "Foo: Bar: Some thing: " + C().test("", stdin="foo\nbar\nbaz").output shouldBe "Foo: Bar: Some thing: " } @Test - fun default() { - stdin.provideLines("bar") - + @JsName("prompt_default") + fun `prompt default`() { class C : TestCommand() { val foo by option().prompt(default = "baz") override fun run_() { @@ -189,14 +159,12 @@ class PromptOptionsTest { } } - C().parse("") - stdout.logWithNormalizedLineSeparator shouldBe "Foo (baz): " + C().test("", stdin="bar").output shouldBe "Foo (baz): " } @Test - fun `default no stdin`() { - stdin.provideLines("") - + @JsName("prompt_default_no_stdin") + fun `prompt default no stdin`() { class C : TestCommand() { val foo by option().prompt(default = "baz") override fun run_() { @@ -204,6 +172,6 @@ class PromptOptionsTest { } } - C().parse("") + C().test("").output shouldBe "Foo (baz): " } } diff --git a/docs/options.md b/docs/options.md index 6b8038126..2b5c2ed43 100644 --- a/docs/options.md +++ b/docs/options.md @@ -804,7 +804,6 @@ passwords. ```text $ ./login Password: - Repeat for confirmation: Your hidden password: hunter2 ``` diff --git a/docs/utilities.md b/docs/utilities.md index fb2ec7bbf..b4b985570 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -25,15 +25,14 @@ are defined. The functions return the edited text if the user saved their change ## Input Prompts -Options can [prompt for values automatically][prompting-for-input], but you can also do -so manually with [`prompt`][prompt]. By -default, it accepts any input string, but you can also pass in a conversion function. If the -conversion raises a [`UsageError`][UsageError], -the prompt will ask the user to enter a different value. +Options can [prompt for values automatically][prompting-for-input], but you can also do so manually +by using Mordant's prompt functionality directly. By default, it accepts any input string, but you +can also pass in a conversion function. If the conversion returns a `ConversionResult.Invalid`, the +prompt will ask the user to enter a different value. === "Example" ```kotlin - val input = prompt("Enter a number") { + val input = terminal.prompt("Enter a number") { it.toIntOrNull() ?.let { ConversionResult.Valid(it) } ?: ConversionResult.Invalid("$it is not a valid integer") @@ -51,12 +50,11 @@ the prompt will ask the user to enter a different value. ## Confirmation Prompts -You can also ask the user for a yes or no response with -[`confirm`][confirm]: +You can also ask the user for a yes or no response with Mordant's [`YesNoPrompt`][YesNoPrompt]: ```kotlin -if (confirm("Continue?") == true) { - echo("OK!") +if (YesNoPrompt("Continue?", terminal).ask() == true) { + echo("Ok!") } ``` @@ -67,3 +65,4 @@ if (confirm("Continue?") == true) { [prompt]: api/clikt/com.github.ajalt.clikt.core/-clikt-command/prompt.html [prompting-for-input]: options.md#prompting-for-input [UsageError]: api/clikt/com.github.ajalt.clikt.core/-usage-error/index.html +[YesNoPrompt]: https://ajalt.github.io/mordant/api/mordant/com.github.ajalt.mordant.terminal/-yes-no-prompt/index.html