diff --git a/.github/workflows/athena.yml b/.github/workflows/athena.yml new file mode 100644 index 0000000..166dd6c --- /dev/null +++ b/.github/workflows/athena.yml @@ -0,0 +1,12 @@ +name: Athena + +on: + pull_request: + branches: + - 'master' + schedule: + - cron: '37 0 * * *' # Nightly at 00:37 + +jobs: + CI: + uses: athena-framework/actions/.github/workflows/ci.yml@master diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 2296d37..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CI - -on: - pull_request: - branches: - - 'master' - schedule: - - cron: '0 21 * * *' - -jobs: - check_format: - runs-on: ubuntu-latest - container: - image: crystallang/crystal:latest-alpine - steps: - - uses: actions/checkout@v2 - - name: Format - run: crystal tool format --check - coding_standards: - runs-on: ubuntu-latest - container: - image: crystallang/crystal:latest-alpine - steps: - - uses: actions/checkout@v2 - - name: Install Dependencies - run: shards install - - name: Ameba - run: ./bin/ameba - test_latest: - runs-on: ubuntu-latest - container: - image: crystallang/crystal:latest-alpine - steps: - - uses: actions/checkout@v2 - - name: Specs - run: crystal spec --order random --error-on-warnings - test_nightly: - runs-on: ubuntu-latest - container: - image: crystallang/crystal:nightly-alpine - steps: - - uses: actions/checkout@v2 - - name: Specs - run: crystal spec --order random --error-on-warnings diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml deleted file mode 100644 index f236334..0000000 --- a/.github/workflows/deployment.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Deployment - -on: - push: - branches: - - master - -jobs: - deploy_docs: - runs-on: ubuntu-latest - container: - image: crystallang/crystal:latest-alpine - steps: - - name: Install Build Dependencies - run: apk add --update rsync - - uses: actions/checkout@v2 - - name: Install Dependencies - run: shards install - - name: Build - run: make docs - - name: Deploy - uses: JamesIves/github-pages-deploy-action@3.6.1 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BRANCH: gh-pages - FOLDER: docs - SINGLE_COMMIT: true diff --git a/LICENSE b/LICENSE index 880c5e4..2583a4f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020 George Dietrich +Copyright (c) 2021 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index fb8b5a6..019d07f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ dependencies: ## Documentation -Everything is documented in the [API Docs](https://athena-framework.github.io/console/Athena/Console.html). +If using the component on its own, checkout the [API documentation](https://athenaframework.org/Console). +If using the component as part of Athena, also checkout the [external documentation](https://athenaframework.org/components/console). ## Contributing diff --git a/shard.yml b/shard.yml index e91a444..f06d62f 100644 --- a/shard.yml +++ b/shard.yml @@ -2,13 +2,13 @@ name: athena-console version: 0.1.0 -crystal: 0.35.0 +crystal: '>= 0.35.0' license: MIT repository: https://github.com/athena-framework/console -documentation: https://athena-framework.github.io/console/Athena/Console.html +documentation: https://athenaframework.org/Console Allows the creation of CLI based commands.: | Allows the creation of CLI based commands. @@ -19,4 +19,7 @@ authors: development_dependencies: ameba: github: crystal-ameba/ameba - version: ~> 0.13.0 \ No newline at end of file + version: ~> 0.14.0 + athena-spec: + github: athena-framework/spec + version: ~> 0.2.3 diff --git a/spec/application_spec.cr b/spec/application_spec.cr new file mode 100644 index 0000000..dcfe0f1 --- /dev/null +++ b/spec/application_spec.cr @@ -0,0 +1,1042 @@ +require "./spec_helper" + +struct ApplicationTest < ASPEC::TestCase + @col_size : Int32? + + def initialize + @col_size = ENV["COLUMNS"]?.try &.to_i + end + + def tear_down : Nil + if size = @col_size + ENV["COLUMNS"] = size.to_s + else + ENV.delete "COLUMNS" + end + + ENV.delete "SHELL_VERBOSITY" + end + + protected def assert_file_equals_string(filepath : String, string : String, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil + normalized_path = File.join __DIR__, "fixtures", filepath + string.should match(Regex.new(File.read(normalized_path))), file: file, line: line + end + + protected def ensure_static_command_help(application : ACON::Application) : Nil + application.each_command do |command| + command.help = command.help.gsub("%command.full_name%", "console %command.name%") + end + end + + def test_long_version : Nil + ACON::Application.new("foo", "1.2.3").long_version.should eq "foo 1.2.3" + end + + def test_help : Nil + ACON::Application.new("foo", "1.2.3").help.should eq "foo 1.2.3" + end + + def test_commands : Nil + app = ACON::Application.new "foo" + commands = app.commands + + commands["help"].should be_a ACON::Commands::Help + commands["list"].should be_a ACON::Commands::List + + app.add FooCommand.new + commands = app.commands "foo" + commands.size.should eq 1 + end + + def test_commands_with_loader : Nil + app = ACON::Application.new "foo" + commands = app.commands + + commands["help"].should be_a ACON::Commands::Help + commands["list"].should be_a ACON::Commands::List + + app.add FooCommand.new + commands = app.commands "foo" + commands.size.should eq 1 + + app.command_loader = ACON::Loader::Factory.new({ + "foo:bar1" => ->{ Foo1Command.new.as ACON::Command }, + }) + commands = app.commands "foo" + commands.size.should eq 2 + commands["foo:bar"].should be_a FooCommand + commands["foo:bar1"].should be_a Foo1Command + end + + def test_add : Nil + app = ACON::Application.new "foo" + app.add foo = FooCommand.new + commands = app.commands + + commands["foo:bar"].should be foo + + # TODO: Add a splat/enumerable overload of #add ? + end + + def test_has_get : Nil + app = ACON::Application.new "foo" + app.has?("list").should be_true + app.has?("afoobar").should be_false + + app.add foo = FooCommand.new + app.has?("afoobar").should be_true + app.get("afoobar").should be foo + app.get("foo:bar").should be foo + + app = ACON::Application.new "foo" + app.add FooCommand.new + + pointerof(app.@wants_help).value = true + + app.get("foo:bar").should be_a ACON::Commands::Help + end + + def test_has_get_with_loader : Nil + app = ACON::Application.new "foo" + app.has?("list").should be_true + app.has?("afoobar").should be_false + + app.add foo = FooCommand.new + app.has?("afoobar").should be_true + app.get("foo:bar").should be foo + app.get("afoobar").should be foo + + app.command_loader = ACON::Loader::Factory.new({ + "foo:bar1" => ->{ Foo1Command.new.as ACON::Command }, + }) + + app.has?("afoobar").should be_true + app.get("foo:bar").should be foo + app.get("afoobar").should be foo + app.has?("foo:bar1").should be_true + (foo1 = app.get("foo:bar1")).should be_a Foo1Command + app.has?("afoobar1").should be_true + app.get("afoobar1").should be foo1 + end + + def test_silent_help : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + tester = ACON::Spec::ApplicationTester.new app + + tester.run("-h": true, "-q": true, decorated: false) + tester.display.should be_empty + end + + def test_get_missing_command : Nil + app = ACON::Application.new "foo" + + expect_raises ACON::Exceptions::CommandNotFound, "The command 'foofoo' does not exist." do + app.get "foofoo" + end + end + + def test_namespaces : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add Foo1Command.new + app.namespaces.should eq ["foo"] + end + + def test_find_namespace : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.find_namespace("foo").should eq "foo" + app.find_namespace("f").should eq "foo" + app.add Foo1Command.new + app.find_namespace("foo").should eq "foo" + end + + def test_find_namespace_subnamespaces : Nil + app = ACON::Application.new "foo" + app.add FooSubnamespaced1Command.new + app.add FooSubnamespaced2Command.new + app.find_namespace("foo").should eq "foo" + end + + def test_find_namespace_ambigous : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add BarBucCommand.new + app.add Foo2Command.new + + expect_raises ACON::Exceptions::NamespaceNotFound, "The namespace 'f' is ambiguous." do + app.find_namespace "f" + end + end + + def test_find_namespace_invalid : Nil + app = ACON::Application.new "foo" + + expect_raises ACON::Exceptions::NamespaceNotFound, "There are no commands defined in the 'bar' namespace." do + app.find_namespace "bar" + end + end + + def test_find_namespace_does_not_fail_on_deep_similar_namespaces : Nil + app = ACON::Application.new "foo" + + app.register "foo:sublong:bar" { ACON::Command::Status::SUCCESS } + app.register "bar:sub:foo" { ACON::Command::Status::SUCCESS } + + app.find_namespace("f:sub").should eq "foo:sublong" + end + + def test_find : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + + app.find("foo:bar").should be_a FooCommand + app.find("h").should be_a ACON::Commands::Help + app.find("f:bar").should be_a FooCommand + app.find("f:b").should be_a FooCommand + app.find("a").should be_a FooCommand + end + + def test_find_non_ambiguous : Nil + app = ACON::Application.new "foo" + app.add TestAmbiguousCommandRegistering.new + app.add TestAmbiguousCommandRegistering2.new + + app.find("test").name.should eq "test-ambiguous" + end + + def test_find_unique_name_but_namespace_name : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add Foo1Command.new + app.add Foo2Command.new + + expect_raises ACON::Exceptions::CommandNotFound, "Command 'foo1' is not defined." do + app.find "foo1" + end + end + + def test_find_case_sensitive_first : Nil + app = ACON::Application.new "foo" + app.add FooSameCaseUppercaseCommand.new + app.add FooSameCaseLowercaseCommand.new + + app.find("f:B").should be_a FooSameCaseUppercaseCommand + app.find("f:BAR").should be_a FooSameCaseUppercaseCommand + app.find("f:b").should be_a FooSameCaseLowercaseCommand + app.find("f:bar").should be_a FooSameCaseLowercaseCommand + end + + def test_find_case_insensitive_fallback : Nil + app = ACON::Application.new "foo" + app.add FooSameCaseLowercaseCommand.new + + app.find("f:b").should be_a FooSameCaseLowercaseCommand + app.find("f:B").should be_a FooSameCaseLowercaseCommand + app.find("foO:BaR").should be_a FooSameCaseLowercaseCommand + end + + def test_find_case_insensitive_ambiguous : Nil + app = ACON::Application.new "foo" + app.add FooSameCaseUppercaseCommand.new + app.add FooSameCaseLowercaseCommand.new + + expect_raises ACON::Exceptions::CommandNotFound, "Command 'FoO:BaR' is ambiguous." do + app.find "FoO:BaR" + end + end + + def test_find_command_loader : Nil + app = ACON::Application.new "foo" + + app.command_loader = ACON::Loader::Factory.new({ + "foo:bar" => ->{ FooCommand.new.as ACON::Command }, + }) + + app.find("foo:bar").should be_a FooCommand + app.find("h").should be_a ACON::Commands::Help + app.find("f:bar").should be_a FooCommand + app.find("f:b").should be_a FooCommand + app.find("a").should be_a FooCommand + end + + @[DataProvider("ambiguous_abbreviations_provider")] + def test_find_ambiguous_abbreviations(abbreviation, expected_message) : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add Foo1Command.new + app.add Foo2Command.new + + expect_raises ACON::Exceptions::CommandNotFound, expected_message do + app.find abbreviation + end + end + + def ambiguous_abbreviations_provider : Tuple + { + {"f", "Command 'f' is not defined."}, + {"a", "Command 'a' is ambiguous."}, + {"foo:b", "Command 'foo:b' is ambiguous."}, + } + end + + def test_find_ambiguous_abbreviations_finds_command_if_alternatives_are_hidden : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add FooHiddenCommand.new + + app.find("foo:").should be_a FooCommand + end + + def test_find_command_equal_namespace + app = ACON::Application.new "foo" + app.add Foo3Command.new + app.add Foo4Command.new + + app.find("foo3:bar").should be_a Foo3Command + app.find("foo3:bar:toh").should be_a Foo4Command + end + + def test_find_ambiguous_namespace_but_unique_name + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add FooBarCommand.new + + app.find("f:f").should be_a FooBarCommand + end + + def test_find_missing_namespace + app = ACON::Application.new "foo" + app.add Foo4Command.new + + app.find("f::t").should be_a Foo4Command + end + + @[DataProvider("invalid_command_names_single_provider")] + def test_find_alternative_exception_message_single(name) : Nil + app = ACON::Application.new "foo" + app.add Foo3Command.new + + expect_raises ACON::Exceptions::CommandNotFound, "Did you mean this?" do + app.find name + end + end + + def invalid_command_names_single_provider : Tuple + { + {"foo3:barr"}, + {"fooo3:bar"}, + } + end + + def test_doesnt_run_alternative_namespace_name : Nil + app = ACON::Application.new "foo" + app.add Foo1Command.new + app.auto_exit = false + + tester = ACON::Spec::ApplicationTester.new app + tester.run command: "foos:bar1", decorated: false + self.assert_file_equals_string "text/application_alternative_namespace.txt", tester.display + end + + def test_run_alternate_command_name : Nil + app = ACON::Application.new "foo" + app.add FooWithoutAliasCommand.new + app.auto_exit = false + tester = ACON::Spec::ApplicationTester.new app + + tester.inputs = ["y"] + tester.run command: "foos", decorated: false + output = tester.display.strip + output.should contain "Command 'foos' is not defined" + output.should contain "Do you want to run 'foo' instead? (yes/no) [no]:" + output.should contain "execute called" + end + + def test_dont_run_alternate_command_name : Nil + app = ACON::Application.new "foo" + app.add FooWithoutAliasCommand.new + app.auto_exit = false + tester = ACON::Spec::ApplicationTester.new app + + tester.inputs = ["n"] + tester.run(command: "foos", decorated: false).should eq ACON::Command::Status::FAILURE + output = tester.display.strip + output.should contain "Command 'foos' is not defined" + output.should contain "Do you want to run 'foo' instead? (yes/no) [no]:" + end + + def test_find_alternative_exception_message_multiple : Nil + ENV["COLUMNS"] = "120" + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add Foo1Command.new + app.add Foo2Command.new + + # Command + plural + ex = expect_raises ACON::Exceptions::CommandNotFound do + app.find "foo:baR" + end + + message = ex.message.should_not be_nil + message.should contain "Did you mean one of these?" + message.should contain "foo1:bar" + message.should contain "foo:bar" + + # Namespace + plural + ex = expect_raises ACON::Exceptions::CommandNotFound do + app.find "foo2:bar" + end + + message = ex.message.should_not be_nil + message.should contain "Did you mean one of these?" + message.should contain "foo1" + + app.add Foo3Command.new + app.add Foo4Command.new + + # Subnamespace + plural + ex = expect_raises ACON::Exceptions::CommandNotFound do + app.find "foo3:" + end + + message = ex.message.should_not be_nil + message.should contain "foo3:bar" + message.should contain "foo3:bar:toh" + end + + def test_find_alternative_commands : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add Foo1Command.new + app.add Foo2Command.new + + ex = expect_raises ACON::Exceptions::CommandNotFound do + app.find "Unknown command" + end + + ex.alternatives.should be_empty + ex.message.should eq "Command 'Unknown command' is not defined." + + # Test if "bar1" command throw a "CommandNotFoundException" and does not contain + # "foo:bar" as alternative because "bar1" is too far from "foo:bar" + ex = expect_raises ACON::Exceptions::CommandNotFound do + app.find "bar1" + end + + ex.alternatives.should eq ["afoobar1", "foo:bar1"] + + message = ex.message.should_not be_nil + message.should contain "Command 'bar1' is not defined" + message.should contain "afoobar1" + message.should contain "foo:bar1" + message.should_not match /foo:bar(?!1)/ + end + + def test_find_alternative_commands_with_alias : Nil + foo_command = FooCommand.new + foo_command.aliases = ["foo2"] + + app = ACON::Application.new "foo" + app.command_loader = ACON::Loader::Factory.new({ + "foo3" => ->{ foo_command.as ACON::Command }, + }) + app.add foo_command + + app.find("foo").should be foo_command + end + + def test_find_alternate_namespace : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add Foo1Command.new + app.add Foo2Command.new + app.add Foo3Command.new + + ex = expect_raises ACON::Exceptions::CommandNotFound, "There are no commands defined in the 'Unknown-namespace' namespace." do + app.find "Unknown-namespace:Unknown-command" + end + ex.alternatives.should be_empty + + ex = expect_raises ACON::Exceptions::CommandNotFound do + app.find "foo2:command" + end + ex.alternatives.should eq ["foo", "foo1", "foo3"] + + message = ex.message.should_not be_nil + message.should contain "There are no commands defined in the 'foo2' namespace." + message.should contain "foo" + message.should contain "foo1" + message.should contain "foo3" + end + + def test_find_alternates_output : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add Foo1Command.new + app.add Foo2Command.new + app.add Foo3Command.new + app.add FooHiddenCommand.new + + expect_raises ACON::Exceptions::CommandNotFound, "There are no commands defined in the 'Unknown-namespace' namespace." do + app.find "Unknown-namespace:Unknown-command" + end.alternatives.should be_empty + + expect_raises ACON::Exceptions::CommandNotFound, /Command 'foo' is not defined\..*Did you mean one of these\?.*/m do + app.find "foo" + end.alternatives.should eq ["afoobar", "afoobar1", "afoobar2", "foo1:bar", "foo3:bar", "foo:bar", "foo:bar1"] + end + + def test_find_double_colon_doesnt_find_command : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add Foo4Command.new + + expect_raises ACON::Exceptions::CommandNotFound, "Command 'foo::bar' is not defined." do + app.find "foo::bar" + end + end + + def test_find_hidden_command_exact_name : Nil + app = ACON::Application.new "foo" + app.add FooHiddenCommand.new + + app.find("foo:hidden").should be_a FooHiddenCommand + app.find("afoohidden").should be_a FooHiddenCommand + end + + def test_find_ambiguous_commands_if_all_alternatives_are_hidden : Nil + app = ACON::Application.new "foo" + app.add FooCommand.new + app.add FooHiddenCommand.new + + app.find("foo:").should be_a FooCommand + end + + def test_set_catch_exceptions : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + ENV["COLUMNS"] = "120" + tester = ACON::Spec::ApplicationTester.new app + + app.catch_exceptions = true + tester.run command: "foo", decorated: false + self.assert_file_equals_string "text/application_renderexception1.txt", tester.display + + tester.run command: "foo", decorated: false, capture_stderr_separately: true + self.assert_file_equals_string "text/application_renderexception1.txt", tester.error_output + tester.display.should be_empty + + app.catch_exceptions = false + + expect_raises Exception, "Command 'foo' is not defined." do + tester.run command: "foo", decorated: false + end + end + + def test_render_exception : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + ENV["COLUMNS"] = "120" + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "foo", decorated: false, capture_stderr_separately: true + self.assert_file_equals_string "text/application_renderexception1.txt", tester.error_output + + tester.run command: "foo", decorated: false, capture_stderr_separately: true, verbosity: :verbose + tester.error_output.should contain "Exception trace" + + tester.run command: "list", "--foo": true, decorated: false, capture_stderr_separately: true + self.assert_file_equals_string "text/application_renderexception2.txt", tester.error_output + + app.add Foo3Command.new + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "foo3:bar", decorated: false, capture_stderr_separately: true + self.assert_file_equals_string "text/application_renderexception3.txt", tester.error_output + + tester.run({"command" => "foo3:bar"}, decorated: false, verbosity: :verbose) + tester.display.should match /\[Exception\]\s*First exception/ + tester.display.should match /\[Exception\]\s*Second exception/ + tester.display.should match /\[Exception\]\s*Third exception/ + + tester.run command: "foo3:bar", decorated: true + self.assert_file_equals_string "text/application_renderexception3_decorated.txt", tester.display + + tester.run command: "foo3:bar", decorated: true, capture_stderr_separately: true + self.assert_file_equals_string "text/application_renderexception3_decorated.txt", tester.error_output + + app = ACON::Application.new "foo" + app.auto_exit = false + ENV["COLUMNS"] = "32" + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "foo", decorated: false, capture_stderr_separately: true + self.assert_file_equals_string "text/application_renderexception4.txt", tester.error_output + + ENV["COLUMNS"] = "120" + end + + def ptest_render_exception_double_width_characters : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + ENV["COLUMNS"] = "120" + tester = ACON::Spec::ApplicationTester.new app + + app.register "foo" do + raise "エラーメッセージ" + end + + tester.run command: "foo", decorated: false, capture_stderr_separately: true + tester.error_output.should eq RENDER_EXCEPTION_DOUBLE_WIDTH + end + + def test_render_exception_escapes_lines : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + ENV["COLUMNS"] = "22" + app.register "foo" do + raise "dont break here !" + end + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "foo", decorated: false + self.assert_file_equals_string "text/application_renderexception_escapeslines.txt", tester.display + + ENV["COLUMNS"] = "120" + end + + def test_render_exception_line_breaks : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + ENV["COLUMNS"] = "120" + app.register "foo" do + raise "\n\nline 1 with extra spaces \nline 2\n\nline 4\n" + end + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "foo", decorated: false + self.assert_file_equals_string "text/application_renderexception_linebreaks.txt", tester.display + end + + def test_render_exception_escapes_lines_of_synopsis : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + + app.register "foo" do + raise "some exception" + end.argument "info" + + tester = ACON::Spec::ApplicationTester.new app + tester.run command: "foo", decorated: false + self.assert_file_equals_string "text/application_renderexception_synopsis_escapeslines.txt", tester.display + end + + def test_run_passes_io_thru : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + app.add command = Foo1Command.new + + input = ACON::Input::Hash.new({"command" => "foo:bar1"}) + output = ACON::Output::IO.new IO::Memory.new + + app.run input, output + + command.input.should eq input + command.output.should eq output + end + + def test_run_default_command : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + self.ensure_static_command_help app + tester = ACON::Spec::ApplicationTester.new app + + tester.run decorated: false + self.assert_file_equals_string "text/application_run1.txt", tester.display + end + + def test_run_help_command : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + self.ensure_static_command_help app + tester = ACON::Spec::ApplicationTester.new app + + tester.run "--help": true, decorated: false + self.assert_file_equals_string "text/application_run2.txt", tester.display + + tester.run "-h": true, decorated: false + self.assert_file_equals_string "text/application_run2.txt", tester.display + end + + def test_run_help_list_command : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + self.ensure_static_command_help app + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "list", "--help": true, decorated: false + self.assert_file_equals_string "text/application_run3.txt", tester.display + + tester.run command: "list", "-h": true, decorated: false + self.assert_file_equals_string "text/application_run3.txt", tester.display + end + + def test_run_ansi : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + tester = ACON::Spec::ApplicationTester.new app + + tester.run "--ansi": true + tester.output.decorated?.should be_true + + tester.run "--no-ansi": true + tester.output.decorated?.should be_false + end + + def test_run_version : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + tester = ACON::Spec::ApplicationTester.new app + + tester.run "--version": true, decorated: false + self.assert_file_equals_string "text/application_run4.txt", tester.display + + tester.run "-V": true, decorated: false + self.assert_file_equals_string "text/application_run4.txt", tester.display + end + + def test_run_quest : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "list", "--quiet": true, decorated: false + tester.display.should be_empty + tester.input.interactive?.should be_false + + tester.run command: "list", "-q": true, decorated: false + tester.display.should be_empty + tester.input.interactive?.should be_false + end + + def test_run_verbosity : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + self.ensure_static_command_help app + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "list", "--verbose": true, decorated: false + tester.output.verbosity.should eq ACON::Output::Verbosity::VERBOSE + + tester.run command: "list", "--verbose": 1, decorated: false + tester.output.verbosity.should eq ACON::Output::Verbosity::VERBOSE + + tester.run command: "list", "--verbose": 2, decorated: false + tester.output.verbosity.should eq ACON::Output::Verbosity::VERY_VERBOSE + + tester.run command: "list", "--verbose": 3, decorated: false + tester.output.verbosity.should eq ACON::Output::Verbosity::DEBUG + + tester.run command: "list", "--verbose": 4, decorated: false + tester.output.verbosity.should eq ACON::Output::Verbosity::VERBOSE + + tester.run command: "list", "-v": true, decorated: false + tester.output.verbosity.should eq ACON::Output::Verbosity::VERBOSE + + tester.run command: "list", "-vv": true, decorated: false + tester.output.verbosity.should eq ACON::Output::Verbosity::VERY_VERBOSE + + tester.run command: "list", "-vvv": true, decorated: false + tester.output.verbosity.should eq ACON::Output::Verbosity::DEBUG + end + + def test_run_help_help_command : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + self.ensure_static_command_help app + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "help", "--help": true, decorated: false + self.assert_file_equals_string "text/application_run5.txt", tester.display + + tester.run command: "help", "-h": true, decorated: false + self.assert_file_equals_string "text/application_run5.txt", tester.display + end + + def test_run_no_interaction : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + app.add FooCommand.new + + tester = ACON::Spec::ApplicationTester.new app + + tester.run command: "foo:bar", "--no-interaction": true, decorated: false + tester.display.should eq "execute called\n" + + tester.run command: "foo:bar", "-n": true, decorated: false + tester.display.should eq "execute called\n" + end + + def test_run_global_option_and_no_command : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + app.definition << ACON::Input::Option.new "foo", "f", :optional + + input = ACON::Input::ARGV.new ["--foo", "bar"] + + app.run(input, ACON::Output::Null.new).should eq ACON::Command::Status::SUCCESS + end + + def test_run_verbose_value_doesnt_break_arguments : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + app.add FooCommand.new + + output = ACON::Output::IO.new IO::Memory.new + input = ACON::Input::ARGV.new ["-v", "foo:bar"] + + app.run(input, output).should eq ACON::Command::Status::SUCCESS + + input = ACON::Input::ARGV.new ["--verbose", "foo:bar"] + + app.run(input, output).should eq ACON::Command::Status::SUCCESS + end + + def test_run_returns_status_with_custom_code_on_exception : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.register "foo" do + raise ACON::Exceptions::Logic.new "", code: 5 + end + + input = ACON::Input::Hash.new({"command" => "foo"}) + + app.run(input, ACON::Output::Null.new).value.should eq 5 + end + + def test_run_returns_failure_status_on_exception : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.register "foo" do + raise "" + end + + input = ACON::Input::Hash.new({"command" => "foo"}) + + app.run(input, ACON::Output::Null.new).value.should eq 1 + end + + def test_add_option_duplicate_shortcut : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + app.definition << ACON::Input::Option.new "--env", "-e", :required, "Environment" + + app.register "foo" do + ACON::Command::Status::SUCCESS + end + .aliases("f") + .definition( + ACON::Input::Option.new("survey", "e", :required, "Option with shortcut") + ) + + input = ACON::Input::Hash.new({"command" => "foo"}) + + expect_raises ACON::Exceptions::Logic, "An option with shortcut 'e' already exists." do + app.run input, ACON::Output::Null.new + end + end + + @[DataProvider("already_set_definition_element_provider")] + def test_adding_already_set_definition_element(element) : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + app.register "foo" do + ACON::Command::Status::SUCCESS + end + .definition(element) + + input = ACON::Input::Hash.new({"command" => "foo"}) + + expect_raises ACON::Exceptions::Logic do + app.run input, ACON::Output::Null.new + end + end + + def already_set_definition_element_provider : Tuple + { + {ACON::Input::Argument.new("command", :required)}, + {ACON::Input::Option.new("quiet", value_mode: :none)}, + {ACON::Input::Option.new("query", "q", :none)}, + } + end + + def test_helper_set_contains_default_helpers : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + helper_set = app.helper_set + + helper_set.has?(ACON::Helper::Question).should be_true + helper_set.has?(ACON::Helper::Formatter).should be_true + end + + def test_adding_single_helper_overwrites_default : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + app.helper_set = ACON::Helper::HelperSet.new(ACON::Helper::Formatter.new) + + helper_set = app.helper_set + helper_set.has?(ACON::Helper::Question).should be_false + helper_set.has?(ACON::Helper::Formatter).should be_true + end + + def test_default_input_definition_returns_default_values : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + definition = app.definition + + definition.has_argument?("command").should be_true + + definition.has_option?("help").should be_true + definition.has_option?("quiet").should be_true + definition.has_option?("verbose").should be_true + definition.has_option?("version").should be_true + definition.has_option?("ansi").should be_true + definition.has_option?("no-interaction").should be_true + definition.has_negation?("no-ansi").should be_true + definition.has_option?("no-ansi").should be_false + end + + # TODO: Test custom application type's helper set. + + def test_setting_custom_input_definition_overrides_default_values : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.catch_exceptions = false + + app.definition = ACON::Input::Definition.new( + ACON::Input::Option.new "--custom", "-c", :none, "Set the custom input definition" + ) + + definition = app.definition + + definition.has_argument?("command").should be_false + + definition.has_option?("help").should be_false + definition.has_option?("quiet").should be_false + definition.has_option?("verbose").should be_false + definition.has_option?("version").should be_false + definition.has_option?("ansi").should be_false + definition.has_option?("no-interaction").should be_false + definition.has_negation?("no-ansi").should be_false + + definition.has_option?("custom").should be_true + end + + # TODO: Add dispatcher related specs + + def test_run_custom_default_command : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.add command = FooCommand.new + app.default_command command.name + + tester = ACON::Spec::ApplicationTester.new app + tester.run interactive: false + tester.display.should eq "execute called\n" + + # TODO: Test custom application default. + end + + def test_run_custom_default_command_with_option : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.add command = FooOptCommand.new + app.default_command command.name + + tester = ACON::Spec::ApplicationTester.new app + tester.run "--fooopt": "opt", interactive: false + tester.display.should eq "execute called\nopt\n" + end + + def test_run_custom_single_default_command : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.add command = FooOptCommand.new + app.default_command command.name, true + + tester = ACON::Spec::ApplicationTester.new app + + tester.run + tester.display.should contain "execute called" + + tester.run "--help": true + tester.display.should contain "The foo:bar command" + end + + def test_find_alternative_does_not_load_same_namespace_commands_on_exact_match : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + + loaded = Hash(String, Bool).new + + app.command_loader = ACON::Loader::Factory.new({ + "foo:bar" => ->do + loaded["foo:bar"] = true + + ACON::Commands::Generic.new("foo:bar") { ACON::Command::Status::SUCCESS }.as ACON::Command + end, + "foo" => ->do + loaded["foo"] = true + + ACON::Commands::Generic.new("foo") { ACON::Command::Status::SUCCESS }.as ACON::Command + end, + }) + + app.run ACON::Input::Hash.new({"command" => "foo"}), ACON::Output::Null.new + + loaded.should eq({"foo" => true}) + end + + def test_command_name_mismatch_with_command_loader_raises : Nil + app = ACON::Application.new "foo" + + app.command_loader = ACON::Loader::Factory.new({ + "foo" => ->{ ACON::Commands::Generic.new("bar") { ACON::Command::Status::SUCCESS }.as ACON::Command }, + }) + + expect_raises ACON::Exceptions::CommandNotFound, "The 'foo' command cannot be found because it is registered under multiple names. Make sure you don't set a different name via constructor or 'name='." do + app.get "foo" + end + end +end diff --git a/spec/application_tester_spec.cr b/spec/application_tester_spec.cr new file mode 100644 index 0000000..5776d6a --- /dev/null +++ b/spec/application_tester_spec.cr @@ -0,0 +1,78 @@ +require "./spec_helper" + +struct ApplicationTesterTest < ASPEC::TestCase + @app : ACON::Application + @tester : ACON::Spec::ApplicationTester + + def initialize + @app = ACON::Application.new "foo" + @app.auto_exit = false + + @app.register "foo" do |_, output| + output.puts "foo" + + ACON::Command::Status::SUCCESS + end.argument "foo" + + @tester = ACON::Spec::ApplicationTester.new @app + @tester.run command: "foo", foo: "bar", interactive: false, decorated: false, verbosity: :verbose + end + + def test_run : Nil + @tester.input.interactive?.should be_false + @tester.output.decorated?.should be_false + @tester.output.verbosity.verbose?.should be_true + end + + def test_input : Nil + @tester.input.argument("foo").should eq "bar" + end + + def test_output : Nil + @tester.output.to_s.should eq "foo\n" + end + + def test_display : Nil + @tester.display.to_s.should eq "foo\n" + end + + def test_status : Nil + @tester.status.should eq ACON::Command::Status::SUCCESS + end + + def test_inputs : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.register "foo" do |input, output| + helper = ACON::Helper::Question.new + + helper.ask input, output, ACON::Question(String?).new "Q1", nil + helper.ask input, output, ACON::Question(String?).new "Q2", nil + helper.ask input, output, ACON::Question(String?).new "Q3", nil + + ACON::Command::Status::SUCCESS + end + + tester = ACON::Spec::ApplicationTester.new app + tester.inputs = ["A1", "A2", "A3"] + tester.run command: "foo" + + tester.status.should eq ACON::Command::Status::SUCCESS + tester.display.should eq "Q1Q2Q3" + end + + def test_error_output : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + app.register "foo" do |_, output| + output.as(ACON::Output::ConsoleOutput).error_output.print "foo" + + ACON::Command::Status::SUCCESS + end.argument "foo" + + tester = ACON::Spec::ApplicationTester.new app + tester.run command: "foo", foo: "bar", capture_stderr_separately: true + + tester.error_output.should eq "foo" + end +end diff --git a/spec/athena-console.cr b/spec/athena-console.cr deleted file mode 100644 index 8085ea0..0000000 --- a/spec/athena-console.cr +++ /dev/null @@ -1,7 +0,0 @@ -require "./spec_helper" - -describe Athena::Console do - it "works" do - false.should eq(true) - end -end diff --git a/spec/command_spec.cr b/spec/command_spec.cr new file mode 100644 index 0000000..e826b3a --- /dev/null +++ b/spec/command_spec.cr @@ -0,0 +1,135 @@ +require "./spec_helper" + +abstract class ACON::Command + def merge_application_definition(merge_args : Bool = true) : Nil + previous_def + end +end + +describe ACON::Command do + describe ".new" do + it "falls back on class vars" do + command = ClassVarConfiguredCommand.new + command.name.should eq "class:var:configured" + command.description.should eq "Command configured via class vars" + end + + it "prioritizes constructor args" do + command = ClassVarConfiguredCommand.new "cv" + command.name.should eq "cv" + command.description.should eq "Command configured via class vars" + end + + it "raises on invalid name" do + expect_raises ACON::Exceptions::InvalidArgument, "Command name '' is invalid." do + ClassVarConfiguredCommand.new "" + end + + expect_raises ACON::Exceptions::InvalidArgument, "Command name ' ' is invalid." do + ClassVarConfiguredCommand.new " " + end + + expect_raises ACON::Exceptions::InvalidArgument, "Command name 'foo:' is invalid." do + ClassVarConfiguredCommand.new "foo:" + end + end + end + + describe "#application=" do + it "sets the helper_set and application" do + app = ACON::Application.new "foo" + command = TestCommand.new + command.application = app + + command.application.should be app + command.helper_set.should be app.helper_set + end + + it "clears out the command's helper_set when clearing out the application" do + command = TestCommand.new + command.application = nil + command.helper_set.should be_nil + end + end + + describe "get/set definition" do + command = TestCommand.new + command.definition definition = ACON::Input::Definition.new + command.definition.should be definition + + command.definition ACON::Input::Argument.new("foo"), ACON::Input::Option.new("bar") + command.definition.has_argument?("foo").should be_true + command.definition.has_option?("bar").should be_true + end + + it "#argument" do + command = TestCommand.new + command.argument "foo" + command.definition.has_argument?("foo").should be_true + end + + it "#option" do + command = TestCommand.new + command.option "bar" + command.definition.has_option?("bar").should be_true + end + + describe "#processed_help" do + it "replaces placeholders correctly" do + command = TestCommand.new + command.help = "The %command.name% command does... Example: %command.full_name%." + command.processed_help.should start_with "The namespace:name command does" + command.processed_help.should_not contain "%command.full_name%" + end + + it "falls back on the description" do + command = TestCommand.new + command.help = "" + command.processed_help.should eq "description" + end + end + + describe "#synopsis" do + it "long" do + TestCommand.new.option("foo").argument("bar").argument("info").synopsis.should eq "namespace:name [--foo] [--] [ []]" + end + + it "short" do + TestCommand.new.option("foo").argument("bar").synopsis(true).should eq "namespace:name [options] [--] []" + end + end + + describe "#usages" do + it "that starts with the command's name" do + TestCommand.new.usage("namespace:name foo").usages.should contain "namespace:name foo" + end + + it "that doesn't include the command's name" do + TestCommand.new.usage("bar").usages.should contain "namespace:name bar" + end + end + + # TODO: Does `#merge_application_definition` need explicit tests? + + describe "#run" do + it "interactive" do + tester = ACON::Spec::CommandTester.new TestCommand.new + tester.execute interactive: true + tester.display.should eq "interact called\nexecute called\n" + end + + it "non-interactive" do + tester = ACON::Spec::CommandTester.new TestCommand.new + tester.execute interactive: false + tester.display.should eq "execute called\n" + end + + it "invalid option" do + tester = ACON::Spec::CommandTester.new TestCommand.new + + expect_raises ACON::Exceptions::InvalidOption, "The '--bar' option does not exist." do + tester.execute "--bar": true + end + end + end +end diff --git a/spec/command_tester_spec.cr b/spec/command_tester_spec.cr new file mode 100644 index 0000000..ac0ba68 --- /dev/null +++ b/spec/command_tester_spec.cr @@ -0,0 +1,202 @@ +require "./spec_helper" + +struct CommandTesterTest < ASPEC::TestCase + @command : ACON::Command + @tester : ACON::Spec::CommandTester + + def initialize + @command = ACON::Commands::Generic.new "foo" do |_, output| + output.puts "foo" + + ACON::Command::Status::SUCCESS + end + @command.argument "command" + @command.argument "foo" + + @tester = ACON::Spec::CommandTester.new @command + @tester.execute foo: "bar", interactive: false, decorated: false, verbosity: :verbose + end + + def test_execute : Nil + @tester.input.interactive?.should be_false + @tester.output.decorated?.should be_false + @tester.output.verbosity.verbose?.should be_true + end + + def test_input : Nil + @tester.input.argument("foo").should eq "bar" + end + + def test_output : Nil + @tester.output.to_s.should eq "foo\n" + end + + def test_display : Nil + @tester.display.to_s.should eq "foo\n" + end + + def test_display_before_calling_execute : Nil + tester = ACON::Spec::CommandTester.new ACON::Commands::Generic.new "foo" { ACON::Command::Status::SUCCESS } + + expect_raises ACON::Exceptions::Logic, "Output not initialized. Did you execute the command before requesting the display?" do + tester.display + end + end + + def test_status_code : Nil + @tester.status.should eq ACON::Command::Status::SUCCESS + end + + def test_command_from_application : Nil + app = ACON::Application.new "foo" + app.auto_exit = false + + app.register "foo" { |_, output| output.puts "foo"; ACON::Command::Status::SUCCESS } + + tester = ACON::Spec::CommandTester.new app.find "foo" + + tester.execute.should eq ACON::Command::Status::SUCCESS + end + + def test_command_with_inputs : Nil + questions = { + "What is your name?", + "How are you?", + "Where do you come from?", + } + + command = ACON::Commands::Generic.new "foo" do |input, output, c| + helper = c.helper ACON::Helper::Question + + helper.ask input, output, ACON::Question(String?).new questions[0], nil + helper.ask input, output, ACON::Question(String?).new questions[1], nil + helper.ask input, output, ACON::Question(String?).new questions[2], nil + + ACON::Command::Status::SUCCESS + end + command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new + + tester = ACON::Spec::CommandTester.new command + tester.inputs = ["Bobby", "Fine", "Germany"] + tester.execute + + tester.status.should eq ACON::Command::Status::SUCCESS + tester.display.should eq questions.join + end + + def test_command_with_inputs_with_defaults : Nil + questions = { + "What is your name?", + "How are you?", + "Where do you come from?", + } + + command = ACON::Commands::Generic.new "foo" do |input, output, c| + helper = c.helper ACON::Helper::Question + + helper.ask input, output, ACON::Question(String).new questions[0], "Bobby" + helper.ask input, output, ACON::Question(String).new questions[1], "Fine" + helper.ask input, output, ACON::Question(String).new questions[2], "Estonia" + + ACON::Command::Status::SUCCESS + end + command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new + + tester = ACON::Spec::CommandTester.new command + tester.inputs = ["", "", ""] + tester.execute + + tester.status.should eq ACON::Command::Status::SUCCESS + tester.display.should eq questions.join + end + + def test_command_with_inputs_wrong_input_amount : Nil + questions = { + "What is your name?", + "How are you?", + "Where do you come from?", + } + + command = ACON::Commands::Generic.new "foo" do |input, output, c| + helper = c.helper ACON::Helper::Question + + helper.ask input, output, ACON::Question::Choice.new "choice", {"a", "b"} + helper.ask input, output, ACON::Question(String?).new questions[0], nil + helper.ask input, output, ACON::Question(String?).new questions[1], nil + helper.ask input, output, ACON::Question(String?).new questions[2], nil + + ACON::Command::Status::SUCCESS + end + command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new + + tester = ACON::Spec::CommandTester.new command + tester.inputs = ["a", "Bobby", "Fine"] + + expect_raises ACON::Exceptions::MissingInput, "Aborted." do + tester.execute + end + end + + def ptest_command_with_questions_but_no_input : Nil + questions = { + "What is your name?", + "How are you?", + "Where do you come from?", + } + + command = ACON::Commands::Generic.new "foo" do |input, output, c| + helper = c.helper ACON::Helper::Question + + helper.ask input, output, ACON::Question::Choice.new "choice", {"a", "b"} + helper.ask input, output, ACON::Question(String?).new questions[0], nil + helper.ask input, output, ACON::Question(String?).new questions[1], nil + helper.ask input, output, ACON::Question(String?).new questions[2], nil + + ACON::Command::Status::SUCCESS + end + command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new + + tester = ACON::Spec::CommandTester.new command + + expect_raises ACON::Exceptions::MissingInput, "Aborted." do + tester.execute + end + end + + def test_athena_style_command_with_inputs : Nil + questions = { + "What is your name?", + "How are you?", + "Where do you come from?", + } + + command = ACON::Commands::Generic.new "foo" do |input, output| + style = ACON::Style::Athena.new input, output + + style.ask ACON::Question(String?).new questions[0], nil + style.ask ACON::Question(String?).new questions[1], nil + style.ask ACON::Question(String?).new questions[2], nil + + ACON::Command::Status::SUCCESS + end + + tester = ACON::Spec::CommandTester.new command + tester.inputs = ["Bobby", "Fine", "France"] + tester.execute.should eq ACON::Command::Status::SUCCESS + end + + def test_error_output : Nil + command = ACON::Commands::Generic.new "foo" do |_, output| + output.as(ACON::Output::ConsoleOutput).error_output.print "foo" + + ACON::Command::Status::SUCCESS + end + command.argument "command" + command.argument "foo" + + tester = ACON::Spec::CommandTester.new command + tester.execute foo: "bar", capture_stderr_separately: true + + tester.error_output.should eq "foo" + end +end diff --git a/spec/commands/help_spec.cr b/spec/commands/help_spec.cr new file mode 100644 index 0000000..8357db9 --- /dev/null +++ b/spec/commands/help_spec.cr @@ -0,0 +1,40 @@ +require "../spec_helper" + +describe ACON::Commands::Help do + describe "#execute" do + it "with command alias" do + command = ACON::Commands::Help.new + command.application = ACON::Application.new "foo" + + tester = ACON::Spec::CommandTester.new command + tester.execute command_name: "li", decorated: false + + tester.display.should contain "list [options] [--] []" + tester.display.should contain "format=FORMAT" + tester.display.should contain "raw" + end + + it "executes" do + command = ACON::Commands::Help.new + + tester = ACON::Spec::CommandTester.new command + command.command = ACON::Commands::List.new + + tester.execute decorated: false + + tester.display.should contain "list [options] [--] []" + tester.display.should contain "format=FORMAT" + tester.display.should contain "raw" + end + + it "with application command" do + app = ACON::Application.new "foo" + tester = ACON::Spec::CommandTester.new app.get "help" + tester.execute command_name: "list" + + tester.display.should contain "list [options] [--] []" + tester.display.should contain "format=FORMAT" + tester.display.should contain "raw" + end + end +end diff --git a/spec/commands/list_spec.cr b/spec/commands/list_spec.cr new file mode 100644 index 0000000..4c6e587 --- /dev/null +++ b/spec/commands/list_spec.cr @@ -0,0 +1,70 @@ +require "../spec_helper" + +describe ACON::Commands::List do + describe "#execute" do + it "executes" do + app = ACON::Application.new "foo" + tester = ACON::Spec::CommandTester.new app.get("list") + tester.execute command: "list", decorated: false + + tester.display.should match /help\s{2,}Display help for a command/ + end + + it "with raw option" do + app = ACON::Application.new "foo" + tester = ACON::Spec::CommandTester.new app.get("list") + tester.execute command: "list", "--raw": true + + tester.display.should eq "help Display help for a command\nlist List commands\n" + end + + it "with namespace argument" do + app = ACON::Application.new "foo" + app.add FooCommand.new + + tester = ACON::Spec::CommandTester.new app.get("list") + tester.execute command: "list", namespace: "foo", "--raw": true + + tester.display.should eq "foo:bar The foo:bar command\n" + end + + it "lists commands order" do + app = ACON::Application.new "foo" + app.add Foo6Command.new + + tester = ACON::Spec::CommandTester.new app.get("list") + tester.execute command: "list", decorated: false + + tester.display.should eq <<-OUTPUT + foo 0.1.0 + + Usage: + command [options] [arguments] + + Options: + -h, --help Display help for the given command. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output + -n, --no-interaction Do not ask any interactive question + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + + Available commands: + help Display help for a command + list List commands + 0foo + 0foo:bar 0foo:bar command\n + OUTPUT + end + + it "lists commands order with raw option" do + app = ACON::Application.new "foo" + app.add Foo6Command.new + + tester = ACON::Spec::CommandTester.new app.get("list") + tester.execute command: "list", "--raw": true + + tester.display.should eq "help Display help for a command\nlist List commands\n0foo:bar 0foo:bar command\n" + end + end +end diff --git a/spec/cursor_spec.cr b/spec/cursor_spec.cr new file mode 100644 index 0000000..443e328 --- /dev/null +++ b/spec/cursor_spec.cr @@ -0,0 +1,114 @@ +require "./spec_helper" + +struct CursorTest < ASPEC::TestCase + @cursor : ACON::Cursor + @output : ACON::Output::IO + + def initialize + @output = ACON::Output::IO.new IO::Memory.new + @cursor = ACON::Cursor.new @output + end + + def test_move_up_one_line : Nil + @cursor.move_up + @output.to_s.should eq "\x1b[1A" + end + + def test_move_up_multiple_lines : Nil + @cursor.move_up 12 + @output.to_s.should eq "\x1b[12A" + end + + def test_move_down_one_line : Nil + @cursor.move_down + @output.to_s.should eq "\x1b[1B" + end + + def test_move_down_multiple_lines : Nil + @cursor.move_down 12 + @output.to_s.should eq "\x1b[12B" + end + + def test_move_right_one_line : Nil + @cursor.move_right + @output.to_s.should eq "\x1b[1C" + end + + def test_move_right_multiple_lines : Nil + @cursor.move_right 12 + @output.to_s.should eq "\x1b[12C" + end + + def test_move_left_one_line : Nil + @cursor.move_left + @output.to_s.should eq "\x1b[1D" + end + + def test_move_left_multiple_lines : Nil + @cursor.move_left 12 + @output.to_s.should eq "\x1b[12D" + end + + def test_move_to_column : Nil + @cursor.move_to_column 5 + @output.to_s.should eq "\x1b[5G" + end + + def test_move_to_position : Nil + @cursor.move_to_position 18, 16 + @output.to_s.should eq "\x1b[17;18H" + end + + def test_clear_line : Nil + @cursor.clear_line + @output.to_s.should eq "\x1b[2K" + end + + def test_save_position : Nil + @cursor.save_position + @output.to_s.should eq "\x1b7" + end + + def test_restore_position : Nil + @cursor.restore_position + @output.to_s.should eq "\x1b8" + end + + def test_hide : Nil + @cursor.hide + @output.to_s.should eq "\x1b[?25l" + end + + def test_show : Nil + @cursor.show + @output.to_s.should eq "\x1b[?25h\x1b[?0c" + end + + def test_clear_output : Nil + @cursor.clear_output + @output.to_s.should eq "\x1b[0J" + end + + def test_current_position : Nil + @cursor = ACON::Cursor.new @output, IO::Memory.new + + @cursor.move_to_position 10, 10 + position = @cursor.current_position + + @output.to_s.should eq "\x1b[11;10H" + + position.should eq({1, 1}) + end + + # TODO: Figure out a less brittle way of testing this. + def ptest_current_position_tty : Nil + @cursor = ACON::Cursor.new @output + + @cursor.move_to_position 10, 10 + position = @cursor.current_position + + @output.to_s.should eq "\x1b[11;10H" + + position.should_not eq({1, 1}) + end +end diff --git a/spec/descriptor/abstract_descriptor_test_case.cr b/spec/descriptor/abstract_descriptor_test_case.cr new file mode 100644 index 0000000..0e81d47 --- /dev/null +++ b/spec/descriptor/abstract_descriptor_test_case.cr @@ -0,0 +1,65 @@ +require "../spec_helper" +require "./object_provider" + +abstract struct AbstractDescriptorTestCase < ASPEC::TestCase + @[DataProvider("input_argument_test_data")] + def test_describe_input_argument(object : ACON::Input::Argument, expected : String) : Nil + self.assert_description expected, object + end + + @[DataProvider("input_option_test_data")] + def test_describe_input_option(object : ACON::Input::Option, expected : String) : Nil + self.assert_description expected, object + end + + @[DataProvider("input_definition_test_data")] + def test_describe_input_definition(object : ACON::Input::Definition, expected : String) : Nil + self.assert_description expected, object + end + + @[DataProvider("command_test_data")] + def test_describe_command(object : ACON::Command, expected : String) : Nil + self.assert_description expected, object + end + + @[DataProvider("application_test_data")] + def test_describe_application(object : ACON::Application, expected : String) : Nil + self.assert_description expected, object + end + + def input_argument_test_data : Array + self.description_test_data ObjectProvider.input_arguments + end + + def input_option_test_data : Array + self.description_test_data ObjectProvider.input_options + end + + def input_definition_test_data : Array + self.description_test_data ObjectProvider.input_definitions + end + + def command_test_data : Array + self.description_test_data ObjectProvider.commands + end + + def application_test_data : Array + self.description_test_data ObjectProvider.applications + end + + protected abstract def descriptor : ACON::Descriptor::Interface + protected abstract def format : String + + protected def description_test_data(data : Hash(String, _)) : Array + data.map do |k, v| + normalized_path = File.join __DIR__, "..", "fixtures", "text" + {v, File.read "#{normalized_path}/#{k}.#{self.format}"} + end + end + + protected def assert_description(expected : String, object, context : ACON::Descriptor::Context = ACON::Descriptor::Context.new) : Nil + output = ACON::Output::IO.new IO::Memory.new + self.descriptor.describe output, object, context.copy_with(raw_output: true) + output.to_s.strip.should eq expected.strip + end +end diff --git a/spec/descriptor/application_spec.cr b/spec/descriptor/application_spec.cr new file mode 100644 index 0000000..2753a29 --- /dev/null +++ b/spec/descriptor/application_spec.cr @@ -0,0 +1,30 @@ +require "../spec_helper" + +private class TestApplication < ACON::Application + protected def default_commands : Array(ACON::Command) + [] of ACON::Command + end +end + +struct ApplicationDescriptorTest < ASPEC::TestCase + @[DataProvider("namespace_provider")] + def test_namespaces(expected : Array(String), names : Array(String)) : Nil + app = TestApplication.new "foo" + + names.each do |name| + app.register name do + ACON::Command::Status::SUCCESS + end + end + + ACON::Descriptor::Application.new(app).namespaces.keys.should eq expected + end + + def namespace_provider : Tuple + { + {["_global"], ["foobar"]}, + {["a", "b"], ["b:foo", "a:foo", "b:bar"]}, + {["_global", "22", "33", "b", "z"], ["z:foo", "1", "33:foo", "b:foo", "22:foo:bar"]}, + } + end +end diff --git a/spec/descriptor/object_provider.cr b/spec/descriptor/object_provider.cr new file mode 100644 index 0000000..b1a2c7d --- /dev/null +++ b/spec/descriptor/object_provider.cr @@ -0,0 +1,50 @@ +module ObjectProvider + def self.input_arguments : Hash(String, ACON::Input::Argument) + { + "input_argument_1" => ACON::Input::Argument.new("argument_name", :required), + "input_argument_2" => ACON::Input::Argument.new("argument_name", :is_array, "argument description"), + "input_argument_3" => ACON::Input::Argument.new("argument_name", :optional, "argument description", "default_value"), + "input_argument_4" => ACON::Input::Argument.new("argument_name", :required, "multiline\nargument description"), + "input_argument_with_style" => ACON::Input::Argument.new("argument_name", :optional, "argument description", "style"), + } + end + + def self.input_options : Hash(String, ACON::Input::Option) + { + "input_option_1" => ACON::Input::Option.new("option_name", "o", :none), + "input_option_2" => ACON::Input::Option.new("option_name", "o", :optional, "option description", "default_value"), + "input_option_3" => ACON::Input::Option.new("option_name", "o", :required, "option description"), + "input_option_4" => ACON::Input::Option.new("option_name", "o", ACON::Input::Option::Value.flags(IS_ARRAY, OPTIONAL), "option description", Array(String).new), + "input_option_5" => ACON::Input::Option.new("option_name", "o", :required, "multiline\noption description"), + "input_option_6" => ACON::Input::Option.new("option_name", {"o", "O"}, :required, "option with multiple shortcuts"), + "input_option_with_style" => ACON::Input::Option.new("option_name", "o", :required, "option description", "style"), + "input_option_with_style_array" => ACON::Input::Option.new("option_name", "o", ACON::Input::Option::Value.flags(IS_ARRAY, REQUIRED), "option description", ["Hello", "world"]), + } + end + + def self.input_definitions : Hash(String, ACON::Input::Definition) + { + "input_definition_1" => ACON::Input::Definition.new, + "input_definition_2" => ACON::Input::Definition.new(ACON::Input::Argument.new("argument_name", :required)), + "input_definition_3" => ACON::Input::Definition.new(ACON::Input::Option.new("option_name", "o", :none)), + "input_definition_4" => ACON::Input::Definition.new( + ACON::Input::Argument.new("argument_name", :required), + ACON::Input::Option.new("option_name", "o", :none), + ), + } + end + + def self.commands : Hash(String, ACON::Command) + { + "command_1" => DescriptorCommand1.new, + "command_2" => DescriptorCommand2.new, + } + end + + def self.applications : Hash(String, ACON::Application) + { + "application_1" => DescriptorApplication1.new("foo"), + "application_2" => DescriptorApplication2.new, + } + end +end diff --git a/spec/descriptor/text_spec.cr b/spec/descriptor/text_spec.cr new file mode 100644 index 0000000..18f0e31 --- /dev/null +++ b/spec/descriptor/text_spec.cr @@ -0,0 +1,23 @@ +require "../spec_helper" +require "./abstract_descriptor_test_case" + +struct TextDescriptorTest < AbstractDescriptorTestCase + # TODO: Include test data for double width chars + # For both Application and Command contexts + + def test_describe_application_filtered_namespace : Nil + self.assert_description( + File.read("#{__DIR__}/../fixtures/text/application_filtered_namespace.txt"), + DescriptorApplication2.new, + ACON::Descriptor::Context.new(namespace: "command4"), + ) + end + + protected def descriptor : ACON::Descriptor::Interface + ACON::Descriptor::Text.new + end + + protected def format : String + "txt" + end +end diff --git a/spec/fixtures/applications/descriptor1.cr b/spec/fixtures/applications/descriptor1.cr new file mode 100644 index 0000000..08f05bc --- /dev/null +++ b/spec/fixtures/applications/descriptor1.cr @@ -0,0 +1,2 @@ +class DescriptorApplication1 < ACON::Application +end diff --git a/spec/fixtures/applications/descriptor2.cr b/spec/fixtures/applications/descriptor2.cr new file mode 100644 index 0000000..d2e6d4e --- /dev/null +++ b/spec/fixtures/applications/descriptor2.cr @@ -0,0 +1,10 @@ +class DescriptorApplication2 < ACON::Application + def initialize + super "My Athena application", SemanticVersion.new 1, 0, 0 + + self.add DescriptorCommand1.new + self.add DescriptorCommand2.new + self.add DescriptorCommand3.new + self.add DescriptorCommand4.new + end +end diff --git a/spec/fixtures/commands/bar_buc.cr b/spec/fixtures/commands/bar_buc.cr new file mode 100644 index 0000000..d95e40c --- /dev/null +++ b/spec/fixtures/commands/bar_buc.cr @@ -0,0 +1,10 @@ +class BarBucCommand < ACON::Command + protected def configure : Nil + self + .name("bar:buc") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/class_var_configured.cr b/spec/fixtures/commands/class_var_configured.cr new file mode 100644 index 0000000..ce927ae --- /dev/null +++ b/spec/fixtures/commands/class_var_configured.cr @@ -0,0 +1,8 @@ +class ClassVarConfiguredCommand < ACON::Command + @@default_name = "class:var:configured" + @@default_description = "Command configured via class vars" + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/descriptor1.cr b/spec/fixtures/commands/descriptor1.cr new file mode 100644 index 0000000..78de090 --- /dev/null +++ b/spec/fixtures/commands/descriptor1.cr @@ -0,0 +1,13 @@ +class DescriptorCommand1 < ACON::Command + protected def configure : Nil + self + .name("descriptor:command1") + .aliases("alias1", "alias2") + .description("command 1 description") + .help("command 1 help") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/descriptor2.cr b/spec/fixtures/commands/descriptor2.cr new file mode 100644 index 0000000..0ee2a0e --- /dev/null +++ b/spec/fixtures/commands/descriptor2.cr @@ -0,0 +1,16 @@ +class DescriptorCommand2 < ACON::Command + protected def configure : Nil + self + .name("descriptor:command2") + .description("command 2 description") + .help("command 2 help") + .usage("-o|--option_name ") + .usage("") + .argument("argument_name", :required) + .option("option_name", "o", :none) + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/descriptor3.cr b/spec/fixtures/commands/descriptor3.cr new file mode 100644 index 0000000..6b16123 --- /dev/null +++ b/spec/fixtures/commands/descriptor3.cr @@ -0,0 +1,13 @@ +class DescriptorCommand3 < ACON::Command + protected def configure : Nil + self + .name("descriptor:command3") + .description("command 3 description") + .help("command 3 help") + .hidden + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/descriptor4.cr b/spec/fixtures/commands/descriptor4.cr new file mode 100644 index 0000000..6d49abf --- /dev/null +++ b/spec/fixtures/commands/descriptor4.cr @@ -0,0 +1,11 @@ +class DescriptorCommand4 < ACON::Command + protected def configure : Nil + self + .name("descriptor:command4") + .aliases("descriptor:alias_command4", "command4:descriptor") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/foo.cr b/spec/fixtures/commands/foo.cr new file mode 100644 index 0000000..7a31cbf --- /dev/null +++ b/spec/fixtures/commands/foo.cr @@ -0,0 +1,18 @@ +class FooCommand < IOCommand + protected def configure : Nil + self + .name("foo:bar") + .description("The foo:bar command") + .aliases("afoobar") + end + + protected def interact(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil + output.puts "interact called" + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + output.puts "execute called" + + super + end +end diff --git a/spec/fixtures/commands/foo1.cr b/spec/fixtures/commands/foo1.cr new file mode 100644 index 0000000..8821bbc --- /dev/null +++ b/spec/fixtures/commands/foo1.cr @@ -0,0 +1,8 @@ +class Foo1Command < IOCommand + protected def configure : Nil + self + .name("foo:bar1") + .description("The foo:bar1 command") + .aliases("afoobar1") + end +end diff --git a/spec/fixtures/commands/foo2.cr b/spec/fixtures/commands/foo2.cr new file mode 100644 index 0000000..27da2e4 --- /dev/null +++ b/spec/fixtures/commands/foo2.cr @@ -0,0 +1,12 @@ +class Foo2Command < IOCommand + protected def configure : Nil + self + .name("foo1:bar") + .description("The foo1:bar command") + .aliases("afoobar2") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/foo3.cr b/spec/fixtures/commands/foo3.cr new file mode 100644 index 0000000..119679e --- /dev/null +++ b/spec/fixtures/commands/foo3.cr @@ -0,0 +1,19 @@ +class Foo3Command < ACON::Command + protected def configure : Nil + self + .name("foo3:bar") + .description("The foo3:bar command") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + begin + begin + raise Exception.new "First exception

this is html

" + rescue ex + raise Exception.new "Second exception comment", ex + end + rescue ex + raise Exception.new "Third exception comment", ex + end + end +end diff --git a/spec/fixtures/commands/foo4.cr b/spec/fixtures/commands/foo4.cr new file mode 100644 index 0000000..51d80e8 --- /dev/null +++ b/spec/fixtures/commands/foo4.cr @@ -0,0 +1,10 @@ +class Foo4Command < ACON::Command + protected def configure : Nil + self + .name("foo3:bar:toh") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/foo6.cr b/spec/fixtures/commands/foo6.cr new file mode 100644 index 0000000..a004562 --- /dev/null +++ b/spec/fixtures/commands/foo6.cr @@ -0,0 +1,11 @@ +class Foo6Command < ACON::Command + protected def configure : Nil + self + .name("0foo:bar") + .description("0foo:bar command") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/foo_bar.cr b/spec/fixtures/commands/foo_bar.cr new file mode 100644 index 0000000..ec35476 --- /dev/null +++ b/spec/fixtures/commands/foo_bar.cr @@ -0,0 +1,7 @@ +class FooBarCommand < IOCommand + protected def configure : Nil + self + .name("foobar:foo") + .description("The foobar:foo command") + end +end diff --git a/spec/fixtures/commands/foo_hidden.cr b/spec/fixtures/commands/foo_hidden.cr new file mode 100644 index 0000000..c304547 --- /dev/null +++ b/spec/fixtures/commands/foo_hidden.cr @@ -0,0 +1,12 @@ +class FooHiddenCommand < ACON::Command + protected def configure : Nil + self + .name("foo:hidden") + .aliases("afoohidden") + .hidden(true) + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/foo_opt.cr b/spec/fixtures/commands/foo_opt.cr new file mode 100644 index 0000000..dbc2ef4 --- /dev/null +++ b/spec/fixtures/commands/foo_opt.cr @@ -0,0 +1,18 @@ +class FooOptCommand < IOCommand + protected def configure : Nil + self + .name("foo:bar") + .description("The foo:bar command") + .aliases("afoobar") + .option("fooopt", "fo", :optional, "fooopt description") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + super + + self.output.puts "execute called" + self.output.puts input.option("fooopt") + + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/foo_same_case_lowercase.cr b/spec/fixtures/commands/foo_same_case_lowercase.cr new file mode 100644 index 0000000..638c911 --- /dev/null +++ b/spec/fixtures/commands/foo_same_case_lowercase.cr @@ -0,0 +1,11 @@ +class FooSameCaseLowercaseCommand < ACON::Command + protected def configure : Nil + self + .name("foo:bar") + .description("foo:bar command") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/foo_same_case_uppercase.cr b/spec/fixtures/commands/foo_same_case_uppercase.cr new file mode 100644 index 0000000..e05737a --- /dev/null +++ b/spec/fixtures/commands/foo_same_case_uppercase.cr @@ -0,0 +1,11 @@ +class FooSameCaseUppercaseCommand < ACON::Command + protected def configure : Nil + self + .name("foo:BAR") + .description("foo:BAR command") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/foo_subnamespaced1.cr b/spec/fixtures/commands/foo_subnamespaced1.cr new file mode 100644 index 0000000..346a721 --- /dev/null +++ b/spec/fixtures/commands/foo_subnamespaced1.cr @@ -0,0 +1,8 @@ +class FooSubnamespaced1Command < IOCommand + protected def configure : Nil + self + .name("foo:bar:baz") + .description("The foo:bar:baz command") + .aliases("foobarbaz") + end +end diff --git a/spec/fixtures/commands/foo_subnamespaced2.cr b/spec/fixtures/commands/foo_subnamespaced2.cr new file mode 100644 index 0000000..c1761ca --- /dev/null +++ b/spec/fixtures/commands/foo_subnamespaced2.cr @@ -0,0 +1,8 @@ +class FooSubnamespaced2Command < IOCommand + protected def configure : Nil + self + .name("foo:bar:go") + .description("The foo:bar:go command") + .aliases("foobargo") + end +end diff --git a/spec/fixtures/commands/foo_without_alias.cr b/spec/fixtures/commands/foo_without_alias.cr new file mode 100644 index 0000000..27e5246 --- /dev/null +++ b/spec/fixtures/commands/foo_without_alias.cr @@ -0,0 +1,13 @@ +class FooWithoutAliasCommand < IOCommand + protected def configure : Nil + self + .name("foo") + .description("The foo command") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + output.puts "execute called" + + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/io.cr b/spec/fixtures/commands/io.cr new file mode 100644 index 0000000..b008166 --- /dev/null +++ b/spec/fixtures/commands/io.cr @@ -0,0 +1,8 @@ +abstract class IOCommand < ACON::Command + getter! input : ACON::Input::Interface + getter! output : ACON::Output::Interface + + protected def execute(@input : ACON::Input::Interface, @output : ACON::Output::Interface) : ACON::Command::Status + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/test.cr b/spec/fixtures/commands/test.cr new file mode 100644 index 0000000..a576f00 --- /dev/null +++ b/spec/fixtures/commands/test.cr @@ -0,0 +1,19 @@ +class TestCommand < ACON::Command + protected def configure : Nil + self + .name("namespace:name") + .description("description") + .aliases("name") + .help("help") + end + + protected def interact(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil + output.puts "interact called" + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + output.puts "execute called" + + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/test_ambiguous_command_registrering1.cr b/spec/fixtures/commands/test_ambiguous_command_registrering1.cr new file mode 100644 index 0000000..c113976 --- /dev/null +++ b/spec/fixtures/commands/test_ambiguous_command_registrering1.cr @@ -0,0 +1,14 @@ +class TestAmbiguousCommandRegistering < ACON::Command + protected def configure : Nil + self + .name("test-ambiguous") + .description("The test-ambiguous command") + .aliases("test") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + output.puts "test-ambiguous" + + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/commands/test_ambiguous_command_registrering2.cr b/spec/fixtures/commands/test_ambiguous_command_registrering2.cr new file mode 100644 index 0000000..79fc74c --- /dev/null +++ b/spec/fixtures/commands/test_ambiguous_command_registrering2.cr @@ -0,0 +1,13 @@ +class TestAmbiguousCommandRegistering2 < ACON::Command + protected def configure : Nil + self + .name("test-ambiguous2") + .description("The test-ambiguous2 command") + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + output.puts "test-ambiguous2" + + ACON::Command::Status::SUCCESS + end +end diff --git a/spec/fixtures/style/backslashes.txt b/spec/fixtures/style/backslashes.txt new file mode 100644 index 0000000..7a18157 --- /dev/null +++ b/spec/fixtures/style/backslashes.txt @@ -0,0 +1,7 @@ + +Title ending with \\ +=================== + +Section ending with \\ +--------------------- + diff --git a/spec/fixtures/style/block.txt b/spec/fixtures/style/block.txt new file mode 100644 index 0000000..07215cb --- /dev/null +++ b/spec/fixtures/style/block.txt @@ -0,0 +1,3 @@ + + ! \[CAUTION\] Lorem ipsum dolor sit amet + diff --git a/spec/fixtures/style/block_line_endings.txt b/spec/fixtures/style/block_line_endings.txt new file mode 100644 index 0000000..97cda9b --- /dev/null +++ b/spec/fixtures/style/block_line_endings.txt @@ -0,0 +1,18 @@ +Lorem ipsum dolor sit amet + \* Lorem ipsum dolor sit amet + \* consectetur adipiscing elit + +Lorem ipsum dolor sit amet + \* Lorem ipsum dolor sit amet + \* consectetur adipiscing elit + +Lorem ipsum dolor sit amet + Lorem ipsum dolor sit amet + consectetur adipiscing elit + +Lorem ipsum dolor sit amet + + \/\/ Lorem ipsum dolor sit amet + \/\/ + \/\/ consectetur adipiscing elit + diff --git a/spec/fixtures/style/block_no_prefix_type.txt b/spec/fixtures/style/block_no_prefix_type.txt new file mode 100644 index 0000000..86d55f5 --- /dev/null +++ b/spec/fixtures/style/block_no_prefix_type.txt @@ -0,0 +1,7 @@ + + \[TEST\] Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua\. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat\. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur\. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est + laborum + diff --git a/spec/fixtures/style/block_padding.txt b/spec/fixtures/style/block_padding.txt new file mode 100644 index 0000000..a05d75e --- /dev/null +++ b/spec/fixtures/style/block_padding.txt @@ -0,0 +1,8 @@ + +\e\[30;42m \e\[0m +\e\[30;42m \[OK\] Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore \e\[0m +\e\[30;42m magna aliqua\. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo \e\[0m +\e\[30;42m consequat\. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur\. \e\[0m +\e\[30;42m Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum \e\[0m +\e\[30;42m \e\[0m + diff --git a/spec/fixtures/style/block_prefix_no_type.txt b/spec/fixtures/style/block_prefix_no_type.txt new file mode 100644 index 0000000..2a9309e --- /dev/null +++ b/spec/fixtures/style/block_prefix_no_type.txt @@ -0,0 +1,6 @@ + +\$ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna +\$ aliqua\. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat\. +\$ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur\. Excepteur sint +\$ occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum + diff --git a/spec/fixtures/style/blocks.txt b/spec/fixtures/style/blocks.txt new file mode 100644 index 0000000..9c3fc57 --- /dev/null +++ b/spec/fixtures/style/blocks.txt @@ -0,0 +1,15 @@ + + \[WARNING\] Warning + + ! \[CAUTION\] Caution + + \[ERROR\] Error + + \[OK\] Success + + ! \[NOTE\] Note + + \[INFO\] Info + +X \[CUSTOM\] Custom block + diff --git a/spec/fixtures/style/closing_tag.txt b/spec/fixtures/style/closing_tag.txt new file mode 100644 index 0000000..916e2ad --- /dev/null +++ b/spec/fixtures/style/closing_tag.txt @@ -0,0 +1 @@ +\e\[30;46mdo you want \e\[0m\e\[33msomething\e\[0m\e\[30;46m\?\e\[0m diff --git a/spec/fixtures/style/emojis.txt b/spec/fixtures/style/emojis.txt new file mode 100644 index 0000000..ea8ac5e --- /dev/null +++ b/spec/fixtures/style/emojis.txt @@ -0,0 +1,7 @@ + + \[OK\] Lorem ipsum dolor sit amet + + \[OK\] Lorem ipsum dolor sit amet with one emoji 🎉 + + \[OK\] Lorem ipsum dolor sit amet with so many of them 👩‍🌾👩‍🌾👩‍🌾👩‍🌾👩‍🌾 + diff --git a/spec/fixtures/style/long_line_block.txt b/spec/fixtures/style/long_line_block.txt new file mode 100644 index 0000000..f2dd805 --- /dev/null +++ b/spec/fixtures/style/long_line_block.txt @@ -0,0 +1,7 @@ + +X \[CUSTOM\] Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et +X dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea +X commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat +X nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit +X anim id est laborum + diff --git a/spec/fixtures/style/long_line_block_wrapping.txt b/spec/fixtures/style/long_line_block_wrapping.txt new file mode 100644 index 0000000..dc1fa98 --- /dev/null +++ b/spec/fixtures/style/long_line_block_wrapping.txt @@ -0,0 +1,4 @@ + + § \[CUSTOM\] Lopadotemachoselachogaleokranioleipsanodrimhypotrimmatosilphioparaomelitokatakechymenokichlepikossyphophatto + § peristeralektryonoptekephalliokigklopeleiolagoiosiraiobaphetraganopterygon + diff --git a/spec/fixtures/style/long_line_comment.txt b/spec/fixtures/style/long_line_comment.txt new file mode 100644 index 0000000..9983af8 --- /dev/null +++ b/spec/fixtures/style/long_line_comment.txt @@ -0,0 +1,6 @@ + + // Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna + // aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + // Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur + // sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum + diff --git a/spec/fixtures/style/long_line_comment_decorated.txt b/spec/fixtures/style/long_line_comment_decorated.txt new file mode 100644 index 0000000..369ae0b --- /dev/null +++ b/spec/fixtures/style/long_line_comment_decorated.txt @@ -0,0 +1,6 @@ + + \/\/ Lorem ipsum dolor sit \e\[33mamet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore \e\[0m + \/\/ \e\[33mmagna aliqua\. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo \e\[0m + \/\/ \e\[33mconsequat\. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla \e\[0m + \/\/ \e\[33mpariatur\.\e\[0m Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + \/\/ est laborum \ No newline at end of file diff --git a/spec/fixtures/style/multi_line_block.txt b/spec/fixtures/style/multi_line_block.txt new file mode 100644 index 0000000..ec05182 --- /dev/null +++ b/spec/fixtures/style/multi_line_block.txt @@ -0,0 +1,5 @@ + +X \[CUSTOM\] Custom block +X +X Second custom block line + diff --git a/spec/fixtures/style/non_interactive_question.txt b/spec/fixtures/style/non_interactive_question.txt new file mode 100644 index 0000000..ecea977 --- /dev/null +++ b/spec/fixtures/style/non_interactive_question.txt @@ -0,0 +1,5 @@ + +Title +===== + + Duis aute irure dolor in reprehenderit in voluptate velit esse diff --git a/spec/fixtures/style/text_block_blank_line.txt b/spec/fixtures/style/text_block_blank_line.txt new file mode 100644 index 0000000..e3f2e95 --- /dev/null +++ b/spec/fixtures/style/text_block_blank_line.txt @@ -0,0 +1,6 @@ + + \* Lorem ipsum dolor sit amet + \* consectetur adipiscing elit + + \[OK\] Lorem ipsum dolor sit amet + diff --git a/spec/fixtures/style/title_block.txt b/spec/fixtures/style/title_block.txt new file mode 100644 index 0000000..118dffa --- /dev/null +++ b/spec/fixtures/style/title_block.txt @@ -0,0 +1,9 @@ + +Title +===== + + \[WARNING\] Lorem ipsum dolor sit amet + +Title +===== + diff --git a/spec/fixtures/style/titles.txt b/spec/fixtures/style/titles.txt new file mode 100644 index 0000000..f4b6d58 --- /dev/null +++ b/spec/fixtures/style/titles.txt @@ -0,0 +1,7 @@ + +First title +=========== + +Second title +============ + diff --git a/spec/fixtures/style/titles_text.txt b/spec/fixtures/style/titles_text.txt new file mode 100644 index 0000000..dce33c4 --- /dev/null +++ b/spec/fixtures/style/titles_text.txt @@ -0,0 +1,32 @@ +Lorem ipsum dolor sit amet + +First title +=========== + +Lorem ipsum dolor sit amet + +Second title +============ + +Lorem ipsum dolor sit amet + +Third title +=========== + +Lorem ipsum dolor sit amet + +Fourth title +============ + +Lorem ipsum dolor sit amet + + +Fifth title +=========== + +Lorem ipsum dolor sit amet + + +Sixth title +=========== + diff --git a/spec/fixtures/text/application_1.txt b/spec/fixtures/text/application_1.txt new file mode 100644 index 0000000..b06e539 --- /dev/null +++ b/spec/fixtures/text/application_1.txt @@ -0,0 +1,16 @@ +foo 0.1.0 + +Usage: + command [options] [arguments] + +Options: + -h, --help Display help for the given command. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output + -n, --no-interaction Do not ask any interactive question + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Available commands: + help Display help for a command + list List commands diff --git a/spec/fixtures/text/application_2.txt b/spec/fixtures/text/application_2.txt new file mode 100644 index 0000000..ef103b3 --- /dev/null +++ b/spec/fixtures/text/application_2.txt @@ -0,0 +1,20 @@ +My Athena application 1.0.0 + +Usage: + command [options] [arguments] + +Options: + -h, --help Display help for the given command. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output + -n, --no-interaction Do not ask any interactive question + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Available commands: + help Display help for a command + list List commands + descriptor + descriptor:command1 [alias1|alias2] command 1 description + descriptor:command2 command 2 description + descriptor:command4 [descriptor:alias_command4|command4:descriptor] diff --git a/spec/fixtures/text/application_alternative_namespace.txt b/spec/fixtures/text/application_alternative_namespace.txt new file mode 100644 index 0000000..af76958 --- /dev/null +++ b/spec/fixtures/text/application_alternative_namespace.txt @@ -0,0 +1,7 @@ + + + There are no commands defined in the 'foos' namespace\. + + Did you mean this\? + foo + diff --git a/spec/fixtures/text/application_filtered_namespace.txt b/spec/fixtures/text/application_filtered_namespace.txt new file mode 100644 index 0000000..1c358ce --- /dev/null +++ b/spec/fixtures/text/application_filtered_namespace.txt @@ -0,0 +1,15 @@ +My Athena application 1.0.0 + +Usage: + command [options] [arguments] + +Options: + -h, --help Display help for the given command. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi|--no-ansi Force (or disable --no-ansi) ANSI output + -n, --no-interaction Do not ask any interactive question + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Available commands for the "command4" namespace: + command4:descriptor diff --git a/spec/fixtures/text/application_renderexception1.txt b/spec/fixtures/text/application_renderexception1.txt new file mode 100644 index 0000000..424bc46 --- /dev/null +++ b/spec/fixtures/text/application_renderexception1.txt @@ -0,0 +1,5 @@ + + + Command 'foo' is not defined. + + diff --git a/spec/fixtures/text/application_renderexception2.txt b/spec/fixtures/text/application_renderexception2.txt new file mode 100644 index 0000000..6e96918 --- /dev/null +++ b/spec/fixtures/text/application_renderexception2.txt @@ -0,0 +1,7 @@ + + + The '--foo' option does not exist\. + + +list \[--raw\] \[--format FORMAT\] \[--short\] \[--\] \[\] + diff --git a/spec/fixtures/text/application_renderexception3.txt b/spec/fixtures/text/application_renderexception3.txt new file mode 100644 index 0000000..ddacde4 --- /dev/null +++ b/spec/fixtures/text/application_renderexception3.txt @@ -0,0 +1,18 @@ + +In foo3.cr line \d+: + + Third exception comment + + +In foo3.cr line \d+: + + Second exception comment + + +In foo3.cr line \d+: + + First exception

this is html

+ + +foo3:bar + diff --git a/spec/fixtures/text/application_renderexception3_decorated.txt b/spec/fixtures/text/application_renderexception3_decorated.txt new file mode 100644 index 0000000..6fc5d66 --- /dev/null +++ b/spec/fixtures/text/application_renderexception3_decorated.txt @@ -0,0 +1,18 @@ + +\e\[33mIn foo3\.cr line \d+:\e\[0m +\e\[97;41m \e\[0m +\e\[97;41m Third exception comment \e\[0m +\e\[97;41m \e\[0m + +\e\[33mIn foo3\.cr line \d+:\e\[0m +\e\[97;41m \e\[0m +\e\[97;41m Second exception comment \e\[0m +\e\[97;41m \e\[0m + +\e\[33mIn foo3\.cr line \d+:\e\[0m +\e\[97;41m \e\[0m +\e\[97;41m First exception

this is html

\e\[0m +\e\[97;41m \e\[0m + +\e\[32mfoo3:bar\e\[0m + diff --git a/spec/fixtures/text/application_renderexception4.txt b/spec/fixtures/text/application_renderexception4.txt new file mode 100644 index 0000000..e05e215 --- /dev/null +++ b/spec/fixtures/text/application_renderexception4.txt @@ -0,0 +1,6 @@ + + + Command 'foo' is not define + d. + + diff --git a/spec/fixtures/text/application_renderexception_doublewidth1.txt b/spec/fixtures/text/application_renderexception_doublewidth1.txt new file mode 100644 index 0000000..b1b9e42 --- /dev/null +++ b/spec/fixtures/text/application_renderexception_doublewidth1.txt @@ -0,0 +1,8 @@ + +At spec/application_spec.cr:\d+:\d+ in '->' + + エラーメッセージ + + +foo + diff --git a/spec/fixtures/text/application_renderexception_escapeslines.txt b/spec/fixtures/text/application_renderexception_escapeslines.txt new file mode 100644 index 0000000..6433af9 --- /dev/null +++ b/spec/fixtures/text/application_renderexception_escapeslines.txt @@ -0,0 +1,9 @@ + +In application_spec\.cr line \d+: + + dont break here < + info>!<\/info> + + +foo + diff --git a/spec/fixtures/text/application_renderexception_linebreaks.txt b/spec/fixtures/text/application_renderexception_linebreaks.txt new file mode 100644 index 0000000..a508423 --- /dev/null +++ b/spec/fixtures/text/application_renderexception_linebreaks.txt @@ -0,0 +1,11 @@ + +In application_spec\.cr line \d+: + + line 1 with extra spaces + line 2 + + line 4 + + +foo + diff --git a/spec/fixtures/text/application_renderexception_synopsis_escapeslines.txt b/spec/fixtures/text/application_renderexception_synopsis_escapeslines.txt new file mode 100644 index 0000000..054935c --- /dev/null +++ b/spec/fixtures/text/application_renderexception_synopsis_escapeslines.txt @@ -0,0 +1,8 @@ + +In application_spec\.cr line \d+: + + some exception + + +foo \[\] + diff --git a/spec/fixtures/text/application_run1.txt b/spec/fixtures/text/application_run1.txt new file mode 100644 index 0000000..7a9ba5b --- /dev/null +++ b/spec/fixtures/text/application_run1.txt @@ -0,0 +1,16 @@ +foo 0.1.0 + +Usage: + command \[options\] \[arguments\] + +Options: + -h, --help Display help for the given command\. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi\|--no-ansi Force \(or disable --no-ansi\) ANSI output + -n, --no-interaction Do not ask any interactive question + -v\|vv\|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Available commands: + help Display help for a command + list List commands diff --git a/spec/fixtures/text/application_run2.txt b/spec/fixtures/text/application_run2.txt new file mode 100644 index 0000000..4dc5605 --- /dev/null +++ b/spec/fixtures/text/application_run2.txt @@ -0,0 +1,32 @@ +Description: + List commands + +Usage: + list \[options\] \[--\] \[\] + +Arguments: + namespace Only list commands in this namespace + +Options: + --raw To output raw command list + --format=FORMAT The output format \(txt\) \[default: "txt"\] + --short To skip describing command's arguments + -h, --help Display help for the given command. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi\|--no-ansi Force \(or disable --no-ansi\) ANSI output + -n, --no-interaction Do not ask any interactive question + -v\|vv\|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + The list command lists all commands: + + console list + + You can also display the commands for a specific namespace: + + console list test + + It's also possible to get raw list of commands \(useful for embedding command runner\): + + console list --raw diff --git a/spec/fixtures/text/application_run3.txt b/spec/fixtures/text/application_run3.txt new file mode 100644 index 0000000..c3ff4c0 --- /dev/null +++ b/spec/fixtures/text/application_run3.txt @@ -0,0 +1,32 @@ +Description: + List commands + +Usage: + list \[options\] \[--\] \[\] + +Arguments: + namespace Only list commands in this namespace + +Options: + --raw To output raw command list + --format=FORMAT The output format \(txt\) \[default: "txt"\] + --short To skip describing command's arguments + -h, --help Display help for the given command\. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi\|--no-ansi Force \(or disable --no-ansi\) ANSI output + -n, --no-interaction Do not ask any interactive question + -v\|vv\|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + The list command lists all commands: + + console list + + You can also display the commands for a specific namespace: + + console list test + + It's also possible to get raw list of commands \(useful for embedding command runner\): + + console list --raw diff --git a/spec/fixtures/text/application_run4.txt b/spec/fixtures/text/application_run4.txt new file mode 100644 index 0000000..c83611e --- /dev/null +++ b/spec/fixtures/text/application_run4.txt @@ -0,0 +1 @@ +foo 0.1.0 diff --git a/spec/fixtures/text/application_run5.txt b/spec/fixtures/text/application_run5.txt new file mode 100644 index 0000000..5dc6ab6 --- /dev/null +++ b/spec/fixtures/text/application_run5.txt @@ -0,0 +1,25 @@ +Description: + Display help for a command + +Usage: + help \[options\] \[--\] \[\] + +Arguments: + command_name The command name \[default: "help"\] + +Options: + --format=FORMAT The output format \(txt\) \[default: "txt"\] + --raw To output raw command help + -h, --help Display help for the given command\. When no command is given display help for the list command + -q, --quiet Do not output any message + -V, --version Display this application version + --ansi\|--no-ansi Force \(or disable --no-ansi\) ANSI output + -n, --no-interaction Do not ask any interactive question + -v\|vv\|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + +Help: + The help command displays help for a given command: + + console help list + + To display the list of available commands, please use the list command\. diff --git a/spec/fixtures/text/command_1.txt b/spec/fixtures/text/command_1.txt new file mode 100644 index 0000000..bf5fa3c --- /dev/null +++ b/spec/fixtures/text/command_1.txt @@ -0,0 +1,10 @@ +Description: + command 1 description + +Usage: + descriptor:command1 + alias1 + alias2 + +Help: + command 1 help diff --git a/spec/fixtures/text/command_2.txt b/spec/fixtures/text/command_2.txt new file mode 100644 index 0000000..45e7bec --- /dev/null +++ b/spec/fixtures/text/command_2.txt @@ -0,0 +1,16 @@ +Description: + command 2 description + +Usage: + descriptor:command2 [options] [--] \ + descriptor:command2 -o|--option_name \ + descriptor:command2 \ + +Arguments: + argument_name + +Options: + -o, --option_name + +Help: + command 2 help diff --git a/spec/fixtures/text/input_argument_1.txt b/spec/fixtures/text/input_argument_1.txt new file mode 100644 index 0000000..5503518 --- /dev/null +++ b/spec/fixtures/text/input_argument_1.txt @@ -0,0 +1 @@ + argument_name diff --git a/spec/fixtures/text/input_argument_2.txt b/spec/fixtures/text/input_argument_2.txt new file mode 100644 index 0000000..e713660 --- /dev/null +++ b/spec/fixtures/text/input_argument_2.txt @@ -0,0 +1 @@ + argument_name argument description diff --git a/spec/fixtures/text/input_argument_3.txt b/spec/fixtures/text/input_argument_3.txt new file mode 100644 index 0000000..6b76639 --- /dev/null +++ b/spec/fixtures/text/input_argument_3.txt @@ -0,0 +1 @@ + argument_name argument description [default: "default_value"] diff --git a/spec/fixtures/text/input_argument_4.txt b/spec/fixtures/text/input_argument_4.txt new file mode 100644 index 0000000..fc7d669 --- /dev/null +++ b/spec/fixtures/text/input_argument_4.txt @@ -0,0 +1,2 @@ + argument_name multiline + argument description diff --git a/spec/fixtures/text/input_argument_with_style.txt b/spec/fixtures/text/input_argument_with_style.txt new file mode 100644 index 0000000..35384a6 --- /dev/null +++ b/spec/fixtures/text/input_argument_with_style.txt @@ -0,0 +1 @@ + argument_name argument description [default: "\style\"] diff --git a/spec/fixtures/text/input_definition_1.txt b/spec/fixtures/text/input_definition_1.txt new file mode 100644 index 0000000..e69de29 diff --git a/spec/fixtures/text/input_definition_2.txt b/spec/fixtures/text/input_definition_2.txt new file mode 100644 index 0000000..73b0f30 --- /dev/null +++ b/spec/fixtures/text/input_definition_2.txt @@ -0,0 +1,2 @@ +Arguments: + argument_name diff --git a/spec/fixtures/text/input_definition_3.txt b/spec/fixtures/text/input_definition_3.txt new file mode 100644 index 0000000..c02766f --- /dev/null +++ b/spec/fixtures/text/input_definition_3.txt @@ -0,0 +1,2 @@ +Options: + -o, --option_name diff --git a/spec/fixtures/text/input_definition_4.txt b/spec/fixtures/text/input_definition_4.txt new file mode 100644 index 0000000..63aa81d --- /dev/null +++ b/spec/fixtures/text/input_definition_4.txt @@ -0,0 +1,5 @@ +Arguments: + argument_name + +Options: + -o, --option_name diff --git a/spec/fixtures/text/input_option_1.txt b/spec/fixtures/text/input_option_1.txt new file mode 100644 index 0000000..3a5e4ee --- /dev/null +++ b/spec/fixtures/text/input_option_1.txt @@ -0,0 +1 @@ + -o, --option_name diff --git a/spec/fixtures/text/input_option_2.txt b/spec/fixtures/text/input_option_2.txt new file mode 100644 index 0000000..1009eff --- /dev/null +++ b/spec/fixtures/text/input_option_2.txt @@ -0,0 +1 @@ + -o, --option_name[=OPTION_NAME] option description [default: "default_value"] diff --git a/spec/fixtures/text/input_option_3.txt b/spec/fixtures/text/input_option_3.txt new file mode 100644 index 0000000..947bb65 --- /dev/null +++ b/spec/fixtures/text/input_option_3.txt @@ -0,0 +1 @@ + -o, --option_name=OPTION_NAME option description diff --git a/spec/fixtures/text/input_option_4.txt b/spec/fixtures/text/input_option_4.txt new file mode 100644 index 0000000..27edf77 --- /dev/null +++ b/spec/fixtures/text/input_option_4.txt @@ -0,0 +1 @@ + -o, --option_name[=OPTION_NAME] option description (multiple values allowed) diff --git a/spec/fixtures/text/input_option_5.txt b/spec/fixtures/text/input_option_5.txt new file mode 100644 index 0000000..9563b4c --- /dev/null +++ b/spec/fixtures/text/input_option_5.txt @@ -0,0 +1,2 @@ + -o, --option_name=OPTION_NAME multiline + option description diff --git a/spec/fixtures/text/input_option_6.txt b/spec/fixtures/text/input_option_6.txt new file mode 100644 index 0000000..0e6c975 --- /dev/null +++ b/spec/fixtures/text/input_option_6.txt @@ -0,0 +1 @@ + -o|O, --option_name=OPTION_NAME option with multiple shortcuts diff --git a/spec/fixtures/text/input_option_with_style.txt b/spec/fixtures/text/input_option_with_style.txt new file mode 100644 index 0000000..880a535 --- /dev/null +++ b/spec/fixtures/text/input_option_with_style.txt @@ -0,0 +1 @@ + -o, --option_name=OPTION_NAME option description [default: "\style\"] diff --git a/spec/fixtures/text/input_option_with_style_array.txt b/spec/fixtures/text/input_option_with_style_array.txt new file mode 100644 index 0000000..265c18c --- /dev/null +++ b/spec/fixtures/text/input_option_with_style_array.txt @@ -0,0 +1 @@ + -o, --option_name=OPTION_NAME option description [default: ["\Hello\","\world\"]] (multiple values allowed) diff --git a/spec/formatter/output_formatter_spec.cr b/spec/formatter/output_formatter_spec.cr new file mode 100644 index 0000000..ff0574a --- /dev/null +++ b/spec/formatter/output_formatter_spec.cr @@ -0,0 +1,198 @@ +require "../spec_helper" + +struct OutputFormatterTest < ASPEC::TestCase + @formatter : ACON::Formatter::Output + + def initialize + @formatter = ACON::Formatter::Output.new true + end + + def test_format_empty_tag : Nil + @formatter.format("foo<>bar").should eq "foo<>bar" + end + + def test_format_lg_char_escaping : Nil + @formatter.format("foo\\bar \\ baz \\").should eq "foo << \e[32mbar \\ baz\e[0m \\" + @formatter.format("\\some info\\").should eq "some info" + ACON::Formatter::Output.escape("some info").should eq "\\some info\\" + + @formatter.format("Some\\Path\\ToFile does work very well!").should eq "\e[33mSome\\Path\\ToFile does work very well!\e[0m" + end + + def test_format_built_in_styles : Nil + @formatter.has_style?("error").should be_true + @formatter.has_style?("info").should be_true + @formatter.has_style?("comment").should be_true + @formatter.has_style?("question").should be_true + + @formatter.format("some error").should eq "\e[97;41msome error\e[0m" + @formatter.format("some info").should eq "\e[32msome info\e[0m" + @formatter.format("some comment").should eq "\e[33msome comment\e[0m" + @formatter.format("some question").should eq "\e[30;46msome question\e[0m" + end + + # TODO: Dependent on https://github.com/crystal-lang/crystal/issues/10652. + def ptest_format_nested_styles : Nil + @formatter.format("some some info error").should eq "\e[97;41msome \e[0m\e[32msome info\e[39m\e[97;41m error\e[0m" + end + + # TODO: Dependent on https://github.com/crystal-lang/crystal/issues/10652. + def ptest_format_deeply_nested_styles : Nil + @formatter.format("errorinfocommenterror").should eq "\e[97;41merror\e[0m\e[32minfo\e[39m\e[33mcomment\e[39m\e[97;41merror\e[0m" + end + + def test_format_adjacent_styles : Nil + @formatter.format("some errorsome info").should eq "\e[97;41msome error\e[0m\e[32msome info\e[0m" + end + + def test_format_adjacent_styles_not_greedy : Nil + @formatter.format("(>=2.0,<2.3)").should eq "(\e[32m>=2.0,<2.3\e[0m)" + end + + def test_format_style_escaping : Nil + @formatter.format(%((#{@formatter.class.escape "z>=2.0,<\\<))).should eq "(\e[32mz>=2.0,<<#{@formatter.class.escape "some error"})).should eq "\e[32msome error\e[0m" + end + + def test_format_custom_style : Nil + style = ACON::Formatter::OutputStyle.new :blue, :white + @formatter.set_style "test", style + + @formatter.style("test").should eq style + @formatter.style("info").should_not eq style + + style = ACON::Formatter::OutputStyle.new :blue, :white + @formatter.set_style "b", style + + @formatter.format("some messagecustom").should eq "\e[34;107msome message\e[0m\e[34;107mcustom\e[0m" + # TODO: Also assert it works when nested. + end + + def test_format_redefine_style : Nil + style = ACON::Formatter::OutputStyle.new :blue, :white + @formatter.set_style "info", style + + @formatter.format("some custom message").should eq "\e[34;107msome custom message\e[0m" + end + + def test_format_inline_style : Nil + @formatter.format("some text").should eq "\e[34;41msome text\e[0m" + @formatter.format("some text").should eq "\e[34;41msome text\e[0m" + end + + @[DataProvider("inline_style_options_provider")] + def test_format_inline_style_options(tag : String, expected : String?, input : String?, truecolor : Bool) : Nil + if truecolor && "truecolor" != ENV["COLORTERM"]? + pending! "The terminal does not support true colors." + end + + style_string = tag.strip "<>" + + style = @formatter.create_style_from_string style_string + + if expected.nil? + style.should be_nil + expected = "#{tag}#{input}" + @formatter.format(expected).should eq expected + else + style.should be_a ACON::Formatter::OutputStyle + @formatter.format("#{tag}#{input}").should eq expected + @formatter.format("#{tag}#{input}").should eq expected + end + end + + def inline_style_options_provider : Tuple + { + {"", nil, nil, false}, + {"", nil, nil, false}, + {"", "\e[32m[test]\e[0m", "[test]", false}, + {"", "\e[32;44ma\e[0m", "a", false}, + {"", "\e[32;1mb\e[0m", "b", false}, + {"", "\e[32;7m\e[0m", "", false}, + {"", "\e[32;1;4mz\e[0m", "z", false}, + {"", "\e[32;1;4;7md\e[0m", "d", false}, + {"", "\e[38;2;0;255;0;48;2;0;0;255m[test]\e[0m", "[test]", true}, + } + end + + def test_format_non_style_tag : Nil + @formatter + .format("some styled

single-char tag

") + .should eq "\e[32msome \e[0m\e[32m\e[0m\e[32m \e[0m\e[32m\e[0m\e[32m styled \e[0m\e[32m

\e[0m\e[32msingle-char tag\e[0m\e[32m

\e[0m" + end + + def test_format_long_string : Nil + long = "\\" * 14_000 + @formatter.format("some error#{long}").should eq "\e[97;41msome error\e[0m#{long}" + end + + def test_has_style : Nil + @formatter = ACON::Formatter::Output.new + + @formatter.has_style?("error").should be_true + @formatter.has_style?("info").should be_true + @formatter.has_style?("comment").should be_true + @formatter.has_style?("question").should be_true + end + + @[DataProvider("decorated_and_non_decorated_output")] + def test_format_not_decorated(input : String, expected_non_decorated_output : String, expected_decorated_output : String, term_emulator : String) : Nil + previous_term_emulator = ENV["TERMINAL_EMULATOR"]? + ENV["TERMINAL_EMULATOR"] = term_emulator + + begin + ACON::Formatter::Output.new(true).format(input).should eq expected_decorated_output + ACON::Formatter::Output.new(false).format(input).should eq expected_non_decorated_output + ensure + if previous_term_emulator + ENV["TERMINAL_EMULATOR"] = previous_term_emulator + else + ENV.delete "TERMINAL_EMULATOR" + end + end + end + + def decorated_and_non_decorated_output : Tuple + { + {"some error", "some error", "\e[97;41msome error\e[0m", "foo"}, + {"some info", "some info", "\e[32msome info\e[0m", "foo"}, + {"some comment", "some comment", "\e[33msome comment\e[0m", "foo"}, + {"some question", "some question", "\e[30;46msome question\e[0m", "foo"}, + {"some text with inline style", "some text with inline style", "\e[31msome text with inline style\e[0m", "foo"}, + {"some URL", "some URL", "\e]8;;idea://open/?file=/path/SomeFile.php&line=12\e\\some URL\e]8;;\e\\", "foo"}, + {"some URL", "some URL", "some URL", "JetBrains-JediTerm"}, + } + end + + def test_format_with_line_breaks : Nil + @formatter.format("\nsome text").should eq "\e[32m\nsome text\e[0m" + @formatter.format("some text\n").should eq "\e[32msome text\n\e[0m" + @formatter.format("\nsome text\n").should eq "\e[32m\nsome text\n\e[0m" + @formatter.format("\nsome text\nmore text\n").should eq "\e[32m\nsome text\nmore text\n\e[0m" + end + + def test_format_and_wrap : Nil + @formatter.format_and_wrap("foobar baz", 2).should eq "fo\no\e[97;41mb\e[0m\n\e[97;41mar\e[0m\nba\nz" + @formatter.format_and_wrap("pre foo bar baz post", 2).should eq "pr\ne \e[97;41m\e[0m\n\e[97;41mfo\e[0m\n\e[97;41mo \e[0m\n\e[97;41mba\e[0m\n\e[97;41mr \e[0m\n\e[97;41mba\e[0m\n\e[97;41mz\e[0m \npo\nst" + @formatter.format_and_wrap("pre foo bar baz post", 3).should eq "pre\e[97;41m\e[0m\n\e[97;41mfoo\e[0m\n\e[97;41mbar\e[0m\n\e[97;41mbaz\e[0m\npos\nt" + @formatter.format_and_wrap("pre foo bar baz post", 4).should eq "pre \e[97;41m\e[0m\n\e[97;41mfoo \e[0m\n\e[97;41mbar \e[0m\n\e[97;41mbaz\e[0m \npost" + @formatter.format_and_wrap("pre foo bar baz post", 5).should eq "pre \e[97;41mf\e[0m\n\e[97;41moo ba\e[0m\n\e[97;41mr baz\e[0m\npost" + + @formatter.format_and_wrap("Lorem ipsum dolor sit amet", 4).should eq "Lore\nm \e[97;41mip\e[0m\n\e[97;41msum\e[0m \ndolo\nr \e[32msi\e[0m\n\e[32mt\e[0m am\net" + @formatter.format_and_wrap("Lorem ipsum dolor sit amet", 8).should eq "Lorem \e[97;41mip\e[0m\n\e[97;41msum\e[0m dolo\nr \e[32msit\e[0m am\net" + @formatter.format_and_wrap("Lorem ipsum dolor sit, amet et laudantium architecto", 18).should eq "Lorem \e[97;41mipsum\e[0m dolor \e[32m\e[0m\n\e[32msit\e[0m, \e[97;41mamet\e[0m et \e[32mlauda\e[0m\n\e[32mntium\e[0m architecto" + end + + def test_format_and_wrap_non_decorated : Nil + @formatter = ACON::Formatter::Output.new + + @formatter.format_and_wrap("foobar baz", 2).should eq "fo\nob\nar\nba\nz" + @formatter.format_and_wrap("pre foo bar baz post", 2).should eq "pr\ne \nfo\no \nba\nr \nba\nz \npo\nst" + @formatter.format_and_wrap("pre foo bar baz post", 3).should eq "pre\nfoo\nbar\nbaz\npos\nt" + @formatter.format_and_wrap("pre foo bar baz post", 4).should eq "pre \nfoo \nbar \nbaz \npost" + @formatter.format_and_wrap("pre foo bar baz post", 5).should eq "pre f\noo ba\nr baz\npost" + end +end diff --git a/spec/formatter/output_formatter_style_spec.cr b/spec/formatter/output_formatter_style_spec.cr new file mode 100644 index 0000000..61ca093 --- /dev/null +++ b/spec/formatter/output_formatter_style_spec.cr @@ -0,0 +1,111 @@ +require "../spec_helper" + +describe ACON::Formatter::OutputStyle do + it ".new" do + ACON::Formatter::OutputStyle.new(:green, :black, ACON::Formatter::Mode.flags Bold, Underline) + .apply("foo").should eq "\e[32;40;1;4mfoo\e[0m" + + ACON::Formatter::OutputStyle.new(:red, options: ACON::Formatter::Mode::Blink) + .apply("foo").should eq "\e[31;5mfoo\e[0m" + + ACON::Formatter::OutputStyle.new(background: :white) + .apply("foo").should eq "\e[107mfoo\e[0m" + + ACON::Formatter::OutputStyle.new("red", "#000000", ACON::Formatter::Mode.flags Bold, Underline) + .apply("foo").should eq "\e[31;48;2;0;0;0;1;4mfoo\e[0m" + end + + describe "foreground=" do + it "with ANSI color" do + style = ACON::Formatter::OutputStyle.new + style.foreground = :black + style.apply("foo").should eq "\e[30mfoo\e[0m" + end + + it "with default value" do + style = ACON::Formatter::OutputStyle.new + style.foreground = :default + style.apply("foo").should eq "foo" + end + + it "with HEX RGB value" do + style = ACON::Formatter::OutputStyle.new + style.foreground = "#aedfff" + style.apply("foo").should eq "\e[38;2;174;223;255mfoo\e[0m" + end + + it "with invalid color" do + style = ACON::Formatter::OutputStyle.new + + expect_raises ArgumentError do + style.foreground = "invalid" + end + end + end + + describe "background=" do + it "with ANSI color" do + style = ACON::Formatter::OutputStyle.new + style.background = :black + style.apply("foo").should eq "\e[40mfoo\e[0m" + end + + it "with default value" do + style = ACON::Formatter::OutputStyle.new + style.background = :default + style.apply("foo").should eq "foo" + end + + it "with HEX RGB value" do + style = ACON::Formatter::OutputStyle.new + style.background = "#aedfff" + style.apply("foo").should eq "\e[48;2;174;223;255mfoo\e[0m" + end + + it "with invalid color" do + style = ACON::Formatter::OutputStyle.new + + expect_raises ArgumentError do + style.background = "invalid" + end + end + end + + it "add/remove_option" do + style = ACON::Formatter::OutputStyle.new + + style.add_option "reverse" + style.add_option "hidden" + style.apply("foo").should eq "\e[7;8mfoo\e[0m" + + style.add_option "bold" + style.apply("foo").should eq "\e[1;7;8mfoo\e[0m" + + style.remove_option "reverse" + style.apply("foo").should eq "\e[1;8mfoo\e[0m" + + style.add_option "bold" + style.apply("foo").should eq "\e[1;8mfoo\e[0m" + + style.options = ACON::Formatter::Mode::Bold + style.apply("foo").should eq "\e[1mfoo\e[0m" + end + + it "href" do + previous_term_emulator = ENV["TERMINAL_EMULATOR"]? + ENV.delete "TERMINAL_EMULATOR" + + style = ACON::Formatter::OutputStyle.new + + begin + style.href = "idea://open/?file=/path/SomeFile.php&line=12" + style.apply("some URL").should eq "\e]8;;idea://open/?file=/path/SomeFile.php&line=12\e\\some URL\e]8;;\e\\" + ensure + if previous_term_emulator + ENV["TERMINAL_EMULATOR"] = previous_term_emulator + else + ENV.delete "TERMINAL_EMULATOR" + end + end + end +end diff --git a/spec/formatter/output_formatter_style_stack_spec.cr b/spec/formatter/output_formatter_style_stack_spec.cr new file mode 100644 index 0000000..80605ac --- /dev/null +++ b/spec/formatter/output_formatter_style_stack_spec.cr @@ -0,0 +1,52 @@ +require "../spec_helper" + +describe ACON::Formatter::OutputStyleStack do + it "#<<" do + stack = ACON::Formatter::OutputStyleStack.new + stack << ACON::Formatter::OutputStyle.new :white, :black + stack << (s2 = ACON::Formatter::OutputStyle.new :yellow, :blue) + + stack.current.should eq s2 + + stack << (s3 = ACON::Formatter::OutputStyle.new :green, :red) + + stack.current.should eq s3 + end + + describe "#pop" do + it "returns the oldest style" do + stack = ACON::Formatter::OutputStyleStack.new + stack << (s1 = ACON::Formatter::OutputStyle.new :white, :black) + stack << (s2 = ACON::Formatter::OutputStyle.new :yellow, :blue) + + stack.pop.should eq s2 + stack.pop.should eq s1 + end + + it "returns the default style if empty" do + stack = ACON::Formatter::OutputStyleStack.new + style = ACON::Formatter::OutputStyle.new + + stack.pop.should eq style + end + + it "allows popping a specific style" do + stack = ACON::Formatter::OutputStyleStack.new + stack << (s1 = ACON::Formatter::OutputStyle.new :white, :black) + stack << (s2 = ACON::Formatter::OutputStyle.new :yellow, :blue) + stack << ACON::Formatter::OutputStyle.new :green, :red + + stack.pop(s2).should eq s2 + stack.pop.should eq s1 + end + + it "invalid pop" do + stack = ACON::Formatter::OutputStyleStack.new + stack << ACON::Formatter::OutputStyle.new :white, :black + + expect_raises ACON::Exceptions::InvalidArgument, "Provided style is not present in the stack." do + stack.pop ACON::Formatter::OutputStyle.new :yellow, :blue + end + end + end +end diff --git a/spec/helper/abstract_question_helper_test_case.cr b/spec/helper/abstract_question_helper_test_case.cr new file mode 100644 index 0000000..d23d0a3 --- /dev/null +++ b/spec/helper/abstract_question_helper_test_case.cr @@ -0,0 +1,25 @@ +require "../spec_helper" + +abstract struct AbstractQuestionHelperTest < ASPEC::TestCase + def initialize + @helper_set = ACON::Helper::HelperSet.new ACON::Helper::Formatter.new + + @output = ACON::Output::IO.new IO::Memory.new + end + + protected def with_input(data : String, interactive : Bool = true, & : ACON::Input::Interface -> Nil) : Nil + input_stream = IO::Memory.new data + input = ACON::Input::Hash.new + input.stream = input_stream + input.interactive = interactive + + yield input + end + + protected def assert_output_contains(string : String) : Nil + stream = @output.io + stream.rewind + + stream.to_s.should contain string + end +end diff --git a/spec/helper/athena_question_spec.cr b/spec/helper/athena_question_spec.cr new file mode 100644 index 0000000..bcd02c3 --- /dev/null +++ b/spec/helper/athena_question_spec.cr @@ -0,0 +1,177 @@ +require "../spec_helper" +require "./abstract_question_helper_test_case" + +struct AthenaQuestionTest < AbstractQuestionHelperTest + @helper : ACON::Helper::Question + + def initialize + @helper = ACON::Helper::AthenaQuestion.new + + super + end + + def test_ask_choice_question : Nil + heros = ["Superman", "Batman", "Spiderman"] + self.with_input "\n1\n 1 \nGeorge\n1\nGeorge" do |input| + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros, 2 + question.max_attempts = 1 + + # First answer is empty, so should use default + @helper.ask(input, @output, question).should eq "Spiderman" + self.assert_output_contains "Who is your favorite superhero? [Spiderman]" + + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros + question.max_attempts = 1 + + @helper.ask(input, @output, question).should eq "Batman" + @helper.ask(input, @output, question).should eq "Batman" + + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros + question.error_message = "Input '%s' is not a superhero!" + question.max_attempts = 2 + + @helper.ask(input, @output, question).should eq "Batman" + self.assert_output_contains "Input 'George' is not a superhero!" + + begin + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros, 1 + question.max_attempts = 1 + @helper.ask input, @output, question + rescue ex : ACON::Exceptions::InvalidArgument + ex.message.should eq "Value 'George' is invalid." + end + end + end + + def test_ask_multiple_choice : Nil + heros = ["Superman", "Batman", "Spiderman"] + + self.with_input "1\n0,2\n 0 , 2 \n\n\n" do |input| + question = ACON::Question::MultipleChoice.new "Who is your favorite superhero?", heros + question.max_attempts = 1 + + @helper.ask(input, @output, question).should eq ["Batman"] + @helper.ask(input, @output, question).should eq ["Superman", "Spiderman"] + @helper.ask(input, @output, question).should eq ["Superman", "Spiderman"] + + question = ACON::Question::MultipleChoice.new "Who is your favorite superhero?", heros, "0,1" + question.max_attempts = 1 + + @helper.ask(input, @output, question).should eq ["Superman", "Batman"] + self.assert_output_contains "Who is your favorite superhero? [Superman, Batman]" + + question = ACON::Question::MultipleChoice.new "Who is your favorite superhero?", heros, " 0 , 1 " + question.max_attempts = 1 + + @helper.ask(input, @output, question).should eq ["Superman", "Batman"] + self.assert_output_contains "Who is your favorite superhero? [Superman, Batman]" + end + end + + def test_ask_choice_with_choice_value_as_default : Nil + question = ACON::Question::Choice.new "Who is your favorite superhero?", ["Superman", "Batman", "Spiderman"], "Batman" + question.max_attempts = 1 + + self.with_input "Batman\n" do |input| + @helper.ask(input, @output, question).should eq "Batman" + end + + self.assert_output_contains "Who is your favorite superhero? [Batman]" + end + + def test_ask_returns_nil_if_validator_allows_it : Nil + question = ACON::Question(String?).new "Who is your favorite superhero?", nil + question.validator do |value| + value + end + + self.with_input "\n" do |input| + @helper.ask(input, @output, question).should be_nil + end + end + + def test_ask_escapes_default_value : Nil + self.with_input "\\" do |input| + question = ACON::Question.new "Can I have a backslash?", "\\" + + @helper.ask input, @output, question + self.assert_output_contains %q(Can I have a backslash? [\]) + end + end + + def test_ask_format_and_escape_label : Nil + question = ACON::Question.new %q(Do you want to use Foo\Bar or Foo\Baz\?), "Foo\\Baz" + + self.with_input "Foo\\Bar" do |input| + @helper.ask input, @output, question + end + + self.assert_output_contains %q( Do you want to use Foo\Bar or Foo\Baz\? [Foo\Baz]:) + end + + def test_ask_label_trailing_backslash : Nil + question = ACON::Question(String?).new "Question with a trailing \\", nil + + self.with_input "sure" do |input| + @helper.ask input, @output, question + end + + self.assert_output_contains "Question with a trailing \\" + end + + def test_ask_raises_on_missing_input : Nil + self.with_input "" do |input| + question = ACON::Question(String?).new "What's your name?", nil + + expect_raises ACON::Exceptions::MissingInput, "Aborted." do + @helper.ask input, @output, question + end + end + end + + def test_ask_choice_question_padding : Nil + question = ACON::Question::Choice.new "qqq", {"foo" => "foo", "żółw" => "bar", "łabądź" => "baz"} + self.with_input "foo\n" do |input| + @helper.ask input, @output, question + end + + self.assert_output_contains <<-OUT + qqq: + [foo ] foo + [żółw ] bar + [łabądź] baz + > + OUT + end + + def test_ask_choice_question_custom_prompt : Nil + question = ACON::Question::Choice.new "qqq", {"foo"} + question.prompt = " >ccc> " + + self.with_input "foo\n" do |input| + @helper.ask input, @output, question + end + + self.assert_output_contains <<-OUT + qqq: + [0] foo + >ccc> + OUT + end + + def test_ask_multiline_question_includes_help_text : Nil + expected = "Write an essay (press Ctrl+D to continue)" + + # TODO: Update expected message on windows + # expected = "Write an essay (press Ctrl+Z then Enter to continue)" + + question = ACON::Question(String?).new "Write an essay", nil + question.multi_line = true + + self.with_input "\\" do |input| + @helper.ask input, @output, question + end + + self.assert_output_contains expected + end +end diff --git a/spec/helper/formatter_spec.cr b/spec/helper/formatter_spec.cr new file mode 100644 index 0000000..0ade90e --- /dev/null +++ b/spec/helper/formatter_spec.cr @@ -0,0 +1,70 @@ +require "../spec_helper" + +describe ACON::Helper::Formatter do + it "#format_section" do + ACON::Helper::Formatter.new.format_section("cli", "some text to display").should eq "[cli] some text to display" + end + + describe "#format_block" do + it "formats" do + formatter = ACON::Helper::Formatter.new + + formatter.format_block("Some text to display", "error").should eq " Some text to display " + formatter.format_block({"Some text to display", "foo bar"}, "error").should eq " Some text to display \n foo bar " + formatter.format_block("Some text to display", "error", true).should eq <<-BLOCK + + Some text to display + + BLOCK + end + + it "formats with diacritic letters" do + formatter = ACON::Helper::Formatter.new + + formatter.format_block("Du texte à afficher", "error", true).should eq <<-BLOCK + + Du texte à afficher + + BLOCK + end + + pending "formats with double with characters" do + end + + it "escapes < within the block" do + ACON::Helper::Formatter.new.format_block("some info", "error", true).should eq <<-BLOCK + + \\some info\\ + + BLOCK + end + end + + describe "#truncate" do + it "with shorter length than message with suffix" do + formatter = ACON::Helper::Formatter.new + message = "testing truncate" + + formatter.truncate(message, 4).should eq "test..." + formatter.truncate(message, 15).should eq "testing truncat..." + formatter.truncate(message, 16).should eq "testing truncate..." + formatter.truncate("zażółć gęślą jaźń", 12).should eq "zażółć gęślą..." + end + + it "with custom suffix" do + ACON::Helper::Formatter.new.truncate("testing truncate", 4, "!").should eq "test!" + end + + it "with longer length than message with suffix" do + ACON::Helper::Formatter.new.truncate("test", 10).should eq "test" + end + + it "with negative length" do + formatter = ACON::Helper::Formatter.new + message = "testing truncate" + + formatter.truncate(message, -5).should eq "testing tru..." + formatter.truncate(message, -100).should eq "..." + end + end +end diff --git a/spec/helper/question_spec.cr b/spec/helper/question_spec.cr new file mode 100644 index 0000000..5e65d6a --- /dev/null +++ b/spec/helper/question_spec.cr @@ -0,0 +1,409 @@ +require "../spec_helper" +require "./abstract_question_helper_test_case" + +struct QuestionHelperTest < AbstractQuestionHelperTest + @helper : ACON::Helper::Question + + def initialize + @helper = ACON::Helper::Question.new + + super + end + + def test_ask_choice_question : Nil + heros = ["Superman", "Batman", "Spiderman"] + self.with_input "\n1\n 1 \nGeorge\n1\nGeorge\n\n\n" do |input| + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros, 2 + question.max_attempts = 1 + + # First answer is empty, so should use default + @helper.ask(input, @output, question).should eq "Spiderman" + + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros + question.max_attempts = 1 + + @helper.ask(input, @output, question).should eq "Batman" + @helper.ask(input, @output, question).should eq "Batman" + + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros + question.error_message = "Input '%s' is not a superhero!" + question.max_attempts = 2 + + @helper.ask(input, @output, question).should eq "Batman" + + begin + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros, 1 + question.max_attempts = 1 + @helper.ask input, @output, question + rescue ex : ACON::Exceptions::InvalidArgument + ex.message.should eq "Value 'George' is invalid." + end + + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros, "0" + question.max_attempts = 1 + + @helper.ask(input, @output, question).should eq "Superman" + end + end + + def test_ask_choice_question_non_interactive : Nil + heros = ["Superman", "Batman", "Spiderman"] + self.with_input "\n1\n 1 \nGeorge\n1\nGeorge\n1\n", false do |input| + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros, 0 + @helper.ask(input, @output, question).should eq "Superman" + + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros, "Batman" + @helper.ask(input, @output, question).should eq "Batman" + + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros + @helper.ask(input, @output, question).should be_nil + + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros, 0 + question.validator = nil + @helper.ask(input, @output, question).should eq "Superman" + + begin + question = ACON::Question::Choice.new "Who is your favorite superhero?", heros + @helper.ask input, @output, question + rescue ex : ACON::Exceptions::InvalidArgument + ex.message.should eq "Value '' is invalid." + end + end + end + + def test_ask_multiple_choice : Nil + heros = ["Superman", "Batman", "Spiderman"] + + self.with_input "1\n0,2\n 0 , 2 " do |input| + question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heros + question.max_attempts = 1 + + @helper.ask(input, @output, question).should eq ["Batman"] + @helper.ask(input, @output, question).should eq ["Superman", "Spiderman"] + @helper.ask(input, @output, question).should eq ["Superman", "Spiderman"] + + question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heros, "0,1" + question.max_attempts = 1 + + @helper.ask(input, @output, question).should eq ["Superman", "Batman"] + + question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heros, " 0 , 1 " + question.max_attempts = 1 + + @helper.ask(input, @output, question).should eq ["Superman", "Batman"] + end + end + + def test_ask_multiple_choice_non_interactive : Nil + heros = ["Superman", "Batman", "Spiderman"] + + self.with_input "1\n0,2\n 0 , 2 ", false do |input| + question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heros, "0,1" + @helper.ask(input, @output, question).should eq ["Superman", "Batman"] + + question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heros, " 0 , 1 " + question.validator = nil + @helper.ask(input, @output, question).should eq ["Superman", "Batman"] + + question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heros, "0,Batman" + @helper.ask(input, @output, question).should eq ["Superman", "Batman"] + + question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heros + @helper.ask(input, @output, question).should be_nil + + question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", {"a" => "Batman", "b" => "Superman"}, "a" + @helper.ask(input, @output, question).should eq ["Batman"] + + begin + question = ACON::Question::MultipleChoice.new "Who are your favorite superheros?", heros, "" + @helper.ask input, @output, question + rescue ex : ACON::Exceptions::InvalidArgument + ex.message.should eq "Value '' is invalid." + end + end + end + + def test_ask : Nil + self.with_input "\n8AM\n" do |input| + question = ACON::Question.new "What time is it?", "2PM" + @helper.ask(input, @output, question).should eq "2PM" + + question = ACON::Question.new "What time is it?", "2PM" + @helper.ask(input, @output, question).should eq "8AM" + + self.assert_output_contains "What time is it?" + end + end + + def test_ask_non_trimmed : Nil + question = ACON::Question.new "What time is it?", "2PM" + question.trimmable = false + + self.with_input " 8AM " do |input| + @helper.ask(input, @output, question).should eq " 8AM " + end + + self.assert_output_contains "What time is it?" + end + + # TODO: Add autocompleter tests + + def test_ask_hidden : Nil + question = ACON::Question.new "What time is it?", "2PM" + question.hidden = true + + self.with_input "8AM\n" do |input| + @helper.ask(input, @output, question).should eq "8AM" + end + + self.assert_output_contains "What time is it?" + end + + def test_ask_hidden_non_trimmed : Nil + question = ACON::Question.new "What time is it?", "2PM" + question.hidden = true + question.trimmable = false + + self.with_input " 8AM" do |input| + @helper.ask(input, @output, question).should eq " 8AM" + end + + self.assert_output_contains "What time is it?" + end + + def test_ask_multi_line : Nil + essay = <<-ESSAY + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque pretium lectus quis suscipit porttitor. Sed pretium bibendum vestibulum. + + Etiam accumsan, justo vitae imperdiet aliquet, neque est sagittis mauris, sed interdum massa leo id leo. + + Aliquam rhoncus, libero ac blandit convallis, est sapien hendrerit nulla, vitae aliquet tellus orci a odio. Aliquam gravida ante sit amet massa lacinia, ut condimentum purus venenatis. + + Vivamus et erat dictum, euismod neque in, laoreet odio. Aenean vitae tellus at leo vestibulum auctor id eget urna. + ESSAY + + question = ACON::Question(String?).new "Write an essay", nil + question.multi_line = true + + self.with_input essay do |input| + @helper.ask(input, @output, question).should eq essay + end + end + + def test_ask_multi_line_response_with_single_newline : Nil + question = ACON::Question(String?).new "Write an essay", nil + question.multi_line = true + + self.with_input "\n" do |input| + @helper.ask(input, @output, question).should be_nil + end + end + + def test_ask_multi_line_response_with_data_after_newline : Nil + question = ACON::Question(String?).new "Write an essay", nil + question.multi_line = true + + self.with_input "\nSome Text" do |input| + @helper.ask(input, @output, question).should be_nil + end + end + + def test_ask_multi_line_response_multiple_newlines_at_end : Nil + question = ACON::Question(String?).new "Write an essay", nil + question.multi_line = true + + self.with_input "Some Text\n\n" do |input| + @helper.ask(input, @output, question).should eq "Some Text" + end + end + + @[DataProvider("confirmation_provider")] + def test_ask_confirmation(answer : String, expected : Bool, default : Bool) : Nil + question = ACON::Question::Confirmation.new "Some question", default + + self.with_input "#{answer}\n" do |input| + @helper.ask(input, @output, question).should eq expected + end + end + + def confirmation_provider : Tuple + { + {"", true, true}, + {"", false, false}, + {"y", true, false}, + {"yes", true, false}, + {"n", false, true}, + {"no", false, true}, + } + end + + def test_ask_confirmation_custom_true_answer : Nil + question = ACON::Question::Confirmation.new "Some question", false, /^(j|y)/i + + self.with_input "j\ny\n" do |input| + @helper.ask(input, @output, question).should be_true + @helper.ask(input, @output, question).should be_true + end + end + + def test_ask_and_validate : Nil + error = "This is not a color!" + + question = ACON::Question.new " What is your favorite color?", "white" + question.max_attempts = 2 + question.validator do |answer| + raise ACON::Exceptions::ValidationFailed.new error unless answer.in? "white", "black" + + answer + end + + self.with_input "\nblack\n" do |input| + @helper.ask(input, @output, question).should eq "white" + @helper.ask(input, @output, question).should eq "black" + end + + self.with_input "green\nyellow\norange\n" do |input| + expect_raises ACON::Exceptions::ValidationFailed, error do + @helper.ask input, @output, question + end + end + end + + @[DataProvider("simple_answer_provider")] + def test_ask_choice_simple_answers(answer, expected : String) : Nil + choices = [ + "My environment 1", + "My environment 2", + "My environment 3", + ] + + question = ACON::Question::Choice.new "Please select the environment to load", choices + question.max_attempts = 1 + + self.with_input "#{answer}\n" do |input| + @helper.ask(input, @output, question).should eq expected + end + end + + def simple_answer_provider : Tuple + { + {0, "My environment 1"}, + {1, "My environment 2"}, + {2, "My environment 3"}, + {"My environment 1", "My environment 1"}, + {"My environment 2", "My environment 2"}, + {"My environment 3", "My environment 3"}, + } + end + + @[DataProvider("special_character_provider")] + def test_ask_special_characters_multiple_choice(answer : String, expected : Array(String)) : Nil + choices = [ + ".", + "src", + ] + + question = ACON::Question::MultipleChoice.new "Please select the environment to load", choices + question.max_attempts = 1 + + self.with_input "#{answer}\n" do |input| + @helper.ask(input, @output, question).should eq expected + end + end + + def special_character_provider : Tuple + { + {".", ["."]}, + {".,src", [".", "src"]}, + } + end + + @[DataProvider("answer_provider")] + def test_ask_choice_hash_choices(answer : String, expected : String) : Nil + choices = { + "env_1" => "My environment 1", + "env_2" => "My environment", + "env_3" => "My environment", + } + + question = ACON::Question::Choice.new "Please select the environment to load", choices + question.max_attempts = 1 + + self.with_input "#{answer}\n" do |input| + @helper.ask(input, @output, question).should eq expected + end + end + + def answer_provider : Tuple + { + {"env_1", "My environment 1"}, + {"env_2", "My environment"}, + {"env_3", "My environment"}, + {"My environment 1", "My environment 1"}, + } + end + + def test_ask_ambiguous_choice : Nil + choices = { + "env_1" => "My first environment", + "env_2" => "My environment", + "env_3" => "My environment", + } + + question = ACON::Question::Choice.new "Please select the environment to load", choices + question.max_attempts = 1 + + self.with_input "My environment\n" do |input| + expect_raises ACON::Exceptions::InvalidArgument, "The provided answer is ambiguous. Value should be one of 'env_2' or 'env_3'." do + @helper.ask input, @output, question + end + end + end + + def test_ask_non_interactive : Nil + question = ACON::Question.new "Some question", "some answer" + + self.with_input "yes", false do |input| + @helper.ask(input, @output, question).should eq "some answer" + end + end + + def test_ask_raises_on_missing_input : Nil + question = ACON::Question.new "Some question", "some answer" + + self.with_input "" do |input| + expect_raises ACON::Exceptions::MissingInput, "Aborted." do + @helper.ask input, @output, question + end + end + end + + # TODO: What to do if the input is ""? + + def test_question_validator_repeats_the_prompt : Nil + tries = 0 + + app = ACON::Application.new "foo" + app.auto_exit = false + app.register "question" do |input, output| + question = ACON::Question(String?).new "This is a promptable question", nil + question.validator do |answer| + tries += 1 + + raise "" unless answer.presence + + answer + end + + ACON::Helper::Question.new.ask input, output, question + + ACON::Command::Status::SUCCESS + end + + tester = ACON::Spec::ApplicationTester.new app + tester.inputs = ["", "not-empty"] + + tester.run(command: "question", interactive: true).should eq ACON::Command::Status::SUCCESS + tries.should eq 2 + end +end diff --git a/spec/input/argument_spec.cr b/spec/input/argument_spec.cr new file mode 100644 index 0000000..924c361 --- /dev/null +++ b/spec/input/argument_spec.cr @@ -0,0 +1,47 @@ +require "../spec_helper" + +describe ACON::Input::Argument do + describe ".new" do + it "disallows blank names" do + expect_raises ACON::Exceptions::InvalidArgument, "An argument name cannot be blank." do + ACON::Input::Argument.new "" + end + + expect_raises ACON::Exceptions::InvalidArgument, "An argument name cannot be blank." do + ACON::Input::Argument.new " " + end + end + end + + describe "#default=" do + describe "when the argument is required" do + it "raises if not nil" do + argument = ACON::Input::Argument.new "foo", :required + + expect_raises ACON::Exceptions::Logic, "Cannot set a default value when the argument is required." do + argument.default = "bar" + end + end + + it "allows nil" do + ACON::Input::Argument.new("foo", :required).default = nil + end + end + + describe "array" do + it "nil value" do + argument = ACON::Input::Argument.new "foo", ACON::Input::Argument::Mode.flags OPTIONAL, IS_ARRAY + argument.default = nil + argument.default.should eq [] of String + end + + it "non array" do + argument = ACON::Input::Argument.new "foo", ACON::Input::Argument::Mode.flags OPTIONAL, IS_ARRAY + + expect_raises ACON::Exceptions::Logic, "Default value for an array argument must be an array." do + argument.default = "bar" + end + end + end + end +end diff --git a/spec/input/argv_spec.cr b/spec/input/argv_spec.cr new file mode 100644 index 0000000..75d4f13 --- /dev/null +++ b/spec/input/argv_spec.cr @@ -0,0 +1,228 @@ +require "../spec_helper" + +struct ARGVTest < ASPEC::TestCase + def test_parse : Nil + input = ACON::Input::ARGV.new ["foo"] + + input.bind ACON::Input::Definition.new ACON::Input::Argument.new "name" + input.arguments.should eq({"name" => "foo"}) + + input.bind ACON::Input::Definition.new ACON::Input::Argument.new "name" + input.arguments.should eq({"name" => "foo"}) + end + + def test_array_argument : Nil + input = ACON::Input::ARGV.new ["foo", "bar", "baz", "bat"] + input.bind ACON::Input::Definition.new ACON::Input::Argument.new "name", :is_array + + input.arguments.should eq({"name" => ["foo", "bar", "baz", "bat"]}) + end + + def test_array_option : Nil + input = ACON::Input::ARGV.new ["--name=foo", "--name=bar", "--name=baz"] + input.bind ACON::Input::Definition.new ACON::Input::Option.new "name", value_mode: ACON::Input::Option::Value.flags OPTIONAL, IS_ARRAY + input.options.should eq({"name" => ["foo", "bar", "baz"]}) + + input = ACON::Input::ARGV.new ["--name", "foo", "--name", "bar", "--name", "baz"] + input.bind ACON::Input::Definition.new ACON::Input::Option.new "name", value_mode: ACON::Input::Option::Value.flags OPTIONAL, IS_ARRAY + input.options.should eq({"name" => ["foo", "bar", "baz"]}) + + input = ACON::Input::ARGV.new ["--name=foo", "--name=bar", "--name="] + input.bind ACON::Input::Definition.new ACON::Input::Option.new "name", value_mode: ACON::Input::Option::Value.flags OPTIONAL, IS_ARRAY + input.options.should eq({"name" => ["foo", "bar", ""]}) + + input = ACON::Input::ARGV.new ["--name=foo", "--name=bar", "--name", "--anotherOption"] + input.bind ACON::Input::Definition.new( + ACON::Input::Option.new("name", value_mode: ACON::Input::Option::Value.flags OPTIONAL, IS_ARRAY), + ACON::Input::Option.new("anotherOption", value_mode: :none), + ) + input.options.should eq({"name" => ["foo", "bar", nil], "anotherOption" => true}) + end + + def test_parse_negative_number_after_double_dash : Nil + input = ACON::Input::ARGV.new ["--", "-1"] + input.bind ACON::Input::Definition.new ACON::Input::Argument.new "number" + input.arguments.should eq({"number" => "-1"}) + + input = ACON::Input::ARGV.new ["-f", "bar", "--", "-1"] + input.bind ACON::Input::Definition.new( + ACON::Input::Argument.new("number"), + ACON::Input::Option.new("foo", "f", :optional), + ) + + input.options.should eq({"foo" => "bar"}) + input.arguments.should eq({"number" => "-1"}) + end + + def test_parse_empty_string_argument : Nil + input = ACON::Input::ARGV.new ["-f", "bar", ""] + input.bind ACON::Input::Definition.new( + ACON::Input::Argument.new("empty"), + ACON::Input::Option.new("foo", "f", :optional), + ) + + input.options.should eq({"foo" => "bar"}) + input.arguments.should eq({"empty" => ""}) + end + + @[DataProvider("parse_options_provider")] + def test_parse_options(input_args : Array(String), options : Array(ACON::Input::Option | ACON::Input::Argument), expected : Hash) : Nil + input = ACON::Input::ARGV.new input_args + input.bind ACON::Input::Definition.new options + + input.options.should eq expected + end + + def parse_options_provider : Hash + { + "long options without a value" => { + ["--foo"], + [ACON::Input::Option.new("foo")], + {"foo" => true}, + }, + "long options with a required value (with a = separator)" => { + ["--foo=bar"], + [ACON::Input::Option.new("foo", "f", :required)], + {"foo" => "bar"}, + }, + "long options with a required value (with a space separator)" => { + ["--foo", "bar"], + [ACON::Input::Option.new("foo", "f", :required)], + {"foo" => "bar"}, + }, + "long options with optional value which is empty (with a = separator) as empty string" => { + ["--foo="], + [ACON::Input::Option.new("foo", "f", :optional)], + {"foo" => ""}, + }, + "long options with optional value without value specified or an empty string (with a = separator) followed by an argument as empty string" => { + ["--foo=", "bar"], + [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Argument.new("name", :required)], + {"foo" => ""}, + }, + "long options with optional value which is empty (with a = separator) preceded by an argument" => { + ["bar", "--foo"], + [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Argument.new("name", :required)], + {"foo" => nil}, + }, + "long options with optional value which is empty as empty string even followed by an argument" => { + ["--foo", "", "bar"], + [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Argument.new("name", :required)], + {"foo" => ""}, + }, + "long options with optional value specified with no separator and no value as nil" => { + ["--foo"], + [ACON::Input::Option.new("foo", "f", :optional)], + {"foo" => nil}, + }, + "short options without a value" => { + ["-f"], + [ACON::Input::Option.new("foo", "f")], + {"foo" => true}, + }, + "short options with a required value (with no separator)" => { + ["-fbar"], + [ACON::Input::Option.new("foo", "f", :required)], + {"foo" => "bar"}, + }, + "short options with a required value (with a space separator)" => { + ["-f", "bar"], + [ACON::Input::Option.new("foo", "f", :required)], + {"foo" => "bar"}, + }, + "short options with an optional empty value" => { + ["-f", ""], + [ACON::Input::Option.new("foo", "f", :optional)], + {"foo" => ""}, + }, + "short options with an optional empty value followed by an argument" => { + ["-f", "", "foo"], + [ACON::Input::Argument.new("name"), ACON::Input::Option.new("foo", "f", :optional)], + {"foo" => ""}, + }, + "short options with an optional empty value followed by an option" => { + ["-f", "", "-b"], + [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Option.new("bar", "b")], + {"foo" => "", "bar" => true}, + }, + "short options with an optional value which is not present" => { + ["-f", "-b", "foo"], + [ACON::Input::Argument.new("name"), ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Option.new("bar", "b")], + {"foo" => nil, "bar" => true}, + }, + "short options when they are aggregated as a single one" => { + ["-fb"], + [ACON::Input::Option.new("foo", "f"), ACON::Input::Option.new("bar", "b")], + {"foo" => true, "bar" => true}, + }, + "short options when they are aggregated as a single one and the last one has a required value" => { + ["-fb", "bar"], + [ACON::Input::Option.new("foo", "f"), ACON::Input::Option.new("bar", "b", :required)], + {"foo" => true, "bar" => "bar"}, + }, + "short options when they are aggregated as a single one and the last one has an optional value" => { + ["-fb", "bar"], + [ACON::Input::Option.new("foo", "f"), ACON::Input::Option.new("bar", "b", :optional)], + {"foo" => true, "bar" => "bar"}, + }, + "short options when they are aggregated as a single one and the last one has an optional value with no separator" => { + ["-fbbar"], + [ACON::Input::Option.new("foo", "f"), ACON::Input::Option.new("bar", "b", :optional)], + {"foo" => true, "bar" => "bar"}, + }, + "short options when they are aggregated as a single one and one of them takes a value" => { + ["-fbbar"], + [ACON::Input::Option.new("foo", "f", :optional), ACON::Input::Option.new("bar", "b", :optional)], + {"foo" => "bbar", "bar" => nil}, + }, + } + end + + @[DataProvider("parse_options_negatable_provider")] + def test_parse_options_negatble(input_args : Array(String), options : Array(ACON::Input::Option | ACON::Input::Argument), expected : Hash) : Nil + input = ACON::Input::ARGV.new input_args + input.bind ACON::Input::Definition.new options + + input.options.should eq expected + end + + def parse_options_negatable_provider : Hash + { + "long options without a value - negatable" => { + ["--foo"], + [ACON::Input::Option.new("foo", value_mode: :negatable)], + {"foo" => true}, + }, + "long options without a value - no value negatable" => { + ["--foo"], + [ACON::Input::Option.new("foo", value_mode: ACON::Input::Option::Value.flags NONE, NEGATABLE)], + {"foo" => true}, + }, + "negated long options without a value - negatable" => { + ["--no-foo"], + [ACON::Input::Option.new("foo", value_mode: :negatable)], + {"foo" => false}, + }, + "negated long options without a value - no value negatable" => { + ["--no-foo"], + [ACON::Input::Option.new("foo", value_mode: ACON::Input::Option::Value.flags NONE, NEGATABLE)], + {"foo" => false}, + }, + "missing negated option uses default - negatable" => { + [] of String, + [ACON::Input::Option.new("foo", value_mode: :negatable)], + {"foo" => nil}, + }, + "missing negated option uses default - no value negatable" => { + [] of String, + [ACON::Input::Option.new("foo", value_mode: ACON::Input::Option::Value.flags NONE, NEGATABLE)], + {"foo" => nil}, + }, + "missing negated option uses default - bool default" => { + [] of String, + [ACON::Input::Option.new("foo", value_mode: :negatable, default: false)], + {"foo" => false}, + }, + } + end +end diff --git a/spec/input/definition_spec.cr b/spec/input/definition_spec.cr new file mode 100644 index 0000000..c4ae9f3 --- /dev/null +++ b/spec/input/definition_spec.cr @@ -0,0 +1,355 @@ +require "../spec_helper" + +struct InputDefinitionTest < ASPEC::TestCase + getter arg_foo : ACON::Input::Argument { ACON::Input::Argument.new "foo" } + getter arg_foo1 : ACON::Input::Argument { ACON::Input::Argument.new "foo" } + getter arg_foo2 : ACON::Input::Argument { ACON::Input::Argument.new "foo2", :required } + getter arg_bar : ACON::Input::Argument { ACON::Input::Argument.new "bar" } + + getter opt_foo : ACON::Input::Option { ACON::Input::Option.new "foo", "f" } + getter opt_foo1 : ACON::Input::Option { ACON::Input::Option.new "foobar", "f" } + getter opt_foo2 : ACON::Input::Option { ACON::Input::Option.new "foo", "p" } + getter opt_bar : ACON::Input::Option { ACON::Input::Option.new "bar", "b" } + getter opt_multi : ACON::Input::Option { ACON::Input::Option.new "multi", "m|mm|mmm" } + + def test_new_arguments : Nil + definition = ACON::Input::Definition.new + definition.arguments.should be_empty + + # Splat + definition = ACON::Input::Definition.new self.arg_foo, self.arg_bar + definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) + + # Array + definition = ACON::Input::Definition.new [self.arg_foo, self.arg_bar] + definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) + + # Hash + definition = ACON::Input::Definition.new({"foo" => self.arg_foo, "bar" => self.arg_bar}) + definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) + end + + def test_new_options : Nil + definition = ACON::Input::Definition.new + definition.options.should be_empty + + # Splat + definition = ACON::Input::Definition.new self.opt_foo, self.opt_bar + definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) + + # Array + definition = ACON::Input::Definition.new [self.opt_foo, self.opt_bar] + definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) + + # Hash + definition = ACON::Input::Definition.new({"foo" => self.opt_foo, "bar" => self.opt_bar}) + definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) + end + + def test_set_arguments : Nil + definition = ACON::Input::Definition.new + + definition.arguments = [self.arg_foo] + definition.arguments.should eq({"foo" => self.arg_foo}) + + definition.arguments = [self.arg_bar] + definition.arguments.should eq({"bar" => self.arg_bar}) + end + + def test_add_arguments : Nil + definition = ACON::Input::Definition.new + + definition << [self.arg_foo] + definition.arguments.should eq({"foo" => self.arg_foo}) + + definition << [self.arg_bar] + definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) + end + + def test_add_argument : Nil + definition = ACON::Input::Definition.new + + definition << self.arg_foo + definition.arguments.should eq({"foo" => self.arg_foo}) + + definition << self.arg_bar + definition.arguments.should eq({"foo" => self.arg_foo, "bar" => self.arg_bar}) + end + + def test_add_argument_must_have_unique_names : Nil + definition = ACON::Input::Definition.new self.arg_foo + + expect_raises ACON::Exceptions::Logic, "An argument with the name 'foo' already exists." do + definition << self.arg_foo + end + end + + def test_add_argument_array_argument_must_be_last : Nil + definition = ACON::Input::Definition.new ACON::Input::Argument.new "foo_array", :is_array + + expect_raises ACON::Exceptions::Logic, "Cannot add a required argument 'foo' after Array argument 'foo_array'." do + definition << ACON::Input::Argument.new "foo" + end + end + + def test_add_argument_required_argument_cannot_follow_optional : Nil + definition = ACON::Input::Definition.new self.arg_foo + + expect_raises ACON::Exceptions::Logic, "Cannot add required argument 'foo2' after the optional argument 'foo'." do + definition << self.arg_foo2 + end + end + + def test_argument : Nil + definition = ACON::Input::Definition.new self.arg_foo + + definition.argument("foo").should be self.arg_foo + definition.argument(0).should be self.arg_foo + end + + def test_argument_missing : Nil + definition = ACON::Input::Definition.new self.arg_foo + + expect_raises ACON::Exceptions::InvalidArgument, "The argument 'bar' does not exist." do + definition.argument "bar" + end + end + + def test_has_argument : Nil + definition = ACON::Input::Definition.new self.arg_foo + + definition.has_argument?("foo").should be_true + definition.has_argument?(0).should be_true + definition.has_argument?("bar").should be_false + definition.has_argument?(1).should be_false + end + + def test_required_argument_count : Nil + definition = ACON::Input::Definition.new + + definition << self.arg_foo2 + definition.required_argument_count.should eq 1 + + definition << self.arg_foo + definition.required_argument_count.should eq 1 + end + + def test_argument_count : Nil + definition = ACON::Input::Definition.new + + definition << self.arg_foo2 + definition.argument_count.should eq 1 + + definition << self.arg_foo + definition.argument_count.should eq 2 + + definition << ACON::Input::Argument.new "foo_array", :is_array + definition.argument_count.should eq Int32::MAX + end + + def test_argument_defaults : Nil + definition = ACON::Input::Definition.new( + ACON::Input::Argument.new("foo1", :optional), + ACON::Input::Argument.new("foo2", :optional, "", "default"), + ACON::Input::Argument.new("foo3", ACON::Input::Argument::Mode.flags OPTIONAL, IS_ARRAY), + ) + + definition.argument_defaults.should eq({"foo1" => nil, "foo2" => "default", "foo3" => [] of String}) + + definition = ACON::Input::Definition.new( + ACON::Input::Argument.new("foo4", ACON::Input::Argument::Mode.flags(OPTIONAL, IS_ARRAY), default: ["1", "2"]), + ) + + definition.argument_defaults.should eq({"foo4" => ["1", "2"]}) + end + + def test_set_options : Nil + definition = ACON::Input::Definition.new + + definition.options = [self.opt_foo] + definition.options.should eq({"foo" => self.opt_foo}) + + definition.options = [self.opt_bar] + definition.options.should eq({"bar" => self.opt_bar}) + end + + def test_set_options_clears_options : Nil + definition = ACON::Input::Definition.new [self.opt_foo] + definition.options = [self.opt_bar] + + expect_raises ACON::Exceptions::InvalidArgument, "The '-f' option does not exist." do + definition.option_for_shortcut "f" + end + end + + def test_add_options : Nil + definition = ACON::Input::Definition.new + + definition << [self.opt_foo] + definition.options.should eq({"foo" => self.opt_foo}) + + definition << [self.opt_bar] + definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) + end + + def test_add_option : Nil + definition = ACON::Input::Definition.new + + definition << self.opt_foo + definition.options.should eq({"foo" => self.opt_foo}) + + definition << self.opt_bar + definition.options.should eq({"foo" => self.opt_foo, "bar" => self.opt_bar}) + end + + def test_add_option_must_have_unique_names : Nil + definition = ACON::Input::Definition.new self.opt_foo + + expect_raises ACON::Exceptions::Logic, "An option named 'foo' already exists." do + definition << self.opt_foo2 + end + end + + def test_add_option_duplicate_negated : Nil + definition = ACON::Input::Definition.new ACON::Input::Option.new "no-foo" + + expect_raises ACON::Exceptions::Logic, "An option named 'no-foo' already exists." do + definition << ACON::Input::Option.new "foo", value_mode: :negatable + end + end + + def test_add_option_duplicate_negated_reverse_option : Nil + definition = ACON::Input::Definition.new ACON::Input::Option.new "foo", value_mode: :negatable + + expect_raises ACON::Exceptions::Logic, "An option named 'no-foo' already exists." do + definition << ACON::Input::Option.new "no-foo" + end + end + + def test_add_option_duplicate_shortcut : Nil + definition = ACON::Input::Definition.new self.opt_foo + + expect_raises ACON::Exceptions::Logic, "An option with shortcut 'f' already exists." do + definition << self.opt_foo1 + end + end + + def test_option : Nil + definition = ACON::Input::Definition.new self.opt_foo + + definition.option("foo").should be self.opt_foo + definition.option(0).should be self.opt_foo + end + + def test_option_missing : Nil + definition = ACON::Input::Definition.new self.opt_foo + + expect_raises ACON::Exceptions::InvalidArgument, "The '--bar' option does not exist." do + definition.option "bar" + end + end + + def test_has_option : Nil + definition = ACON::Input::Definition.new self.opt_foo + + definition.has_option?("foo").should be_true + definition.has_option?(0).should be_true + definition.has_option?("bar").should be_false + definition.has_option?(1).should be_false + end + + def test_has_shortcut : Nil + definition = ACON::Input::Definition.new self.opt_foo + + definition.has_shortcut?("f").should be_true + definition.has_shortcut?("p").should be_false + end + + def test_option_for_shortcut : Nil + definition = ACON::Input::Definition.new self.opt_foo + + definition.option_for_shortcut("f").should be self.opt_foo + end + + def test_option_for_shortcut_multi : Nil + definition = ACON::Input::Definition.new self.opt_multi + + definition.option_for_shortcut("m").should be self.opt_multi + definition.option_for_shortcut("mmm").should be self.opt_multi + end + + def test_option_for_shortcut_invalid : Nil + definition = ACON::Input::Definition.new self.opt_foo + + expect_raises ACON::Exceptions::InvalidArgument, "The '-l' option does not exist." do + definition.option_for_shortcut "l" + end + end + + def test_option_defaults : Nil + definition = ACON::Input::Definition.new( + ACON::Input::Option.new("foo1", value_mode: :none), + ACON::Input::Option.new("foo2", value_mode: :required), + ACON::Input::Option.new("foo3", value_mode: :required, default: "default"), + ACON::Input::Option.new("foo4", value_mode: :optional), + ACON::Input::Option.new("foo5", value_mode: :optional, default: "default"), + ACON::Input::Option.new("foo6", value_mode: ACON::Input::Option::Value.flags OPTIONAL, IS_ARRAY), + ACON::Input::Option.new("foo7", value_mode: ACON::Input::Option::Value.flags(OPTIONAL, IS_ARRAY), default: ["1", "2"]), + ) + + definition.option_defaults.should eq({ + "foo1" => false, + "foo2" => nil, + "foo3" => "default", + "foo4" => nil, + "foo5" => "default", + "foo6" => [] of String, + "foo7" => ["1", "2"], + }) + end + + def test_negation_to_name : Nil + definition = ACON::Input::Definition.new ACON::Input::Option.new "foo", value_mode: :negatable + definition.negation_to_name("no-foo").should eq "foo" + end + + def test_negation_to_name_invalid : Nil + definition = ACON::Input::Definition.new ACON::Input::Option.new "foo", value_mode: :negatable + + expect_raises ACON::Exceptions::InvalidArgument, "The '--no-bar' option does not exist." do + definition.negation_to_name "no-bar" + end + end + + @[DataProvider("synopsis_provider")] + def test_synopsis(definition : ACON::Input::Definition, expected : String) : Nil + definition.synopsis.should eq expected + end + + def synopsis_provider : Hash + { + "puts optional options in square brackets" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo")), "[--foo]"}, + "separates shortcuts with a pipe" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo", "f")), "[-f|--foo]"}, + "uses shortcut as value placeholder" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo", "f", :required)), "[-f|--foo FOO]"}, + "puts optional values in square brackets" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo", "f", :optional)), "[-f|--foo [FOO]]"}, + + "puts arguments in angle brackets" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo", :required)), ""}, + "puts optional arguments square brackets" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo", :optional)), "[]"}, + "chains optional arguments inside brackets" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo"), ACON::Input::Argument.new("bar")), "[ []]"}, + "uses an ellipsis for array arguments" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo", :is_array)), "[...]"}, + "uses an ellipsis for required array arguments" => {ACON::Input::Definition.new(ACON::Input::Argument.new("foo", ACON::Input::Argument::Mode.flags(REQUIRED, IS_ARRAY))), "..."}, + + "puts [--] between options and arguments" => {ACON::Input::Definition.new(ACON::Input::Option.new("foo"), ACON::Input::Argument.new("foo", :required)), "[--foo] [--] "}, + } + end + + def test_synopsis_short : Nil + definition = ACON::Input::Definition.new( + ACON::Input::Option.new("foo"), + ACON::Input::Option.new("bar"), + ACON::Input::Argument.new("baz"), + ) + + definition.synopsis(true).should eq "[options] [--] []" + end +end diff --git a/spec/input/hash_spec.cr b/spec/input/hash_spec.cr new file mode 100644 index 0000000..9185a92 --- /dev/null +++ b/spec/input/hash_spec.cr @@ -0,0 +1,129 @@ +require "../spec_helper" + +struct HashTest < ASPEC::TestCase + def test_first_argument : Nil + ACON::Input::Hash.new.first_argument.should be_nil + ACON::Input::Hash.new(name: "George").first_argument.should eq "George" + ACON::Input::Hash.new("--foo": "bar", name: "George").first_argument.should eq "George" + end + + def test_has_parameter : Nil + input = ACON::Input::Hash.new(name: "George", "--foo": "bar") + input.has_parameter?("--foo").should be_true + input.has_parameter?("--bar").should be_false + + ACON::Input::Hash.new("--foo").has_parameter?("--foo").should be_true + + input = ACON::Input::Hash.new "--foo", "--", "--bar" + input.has_parameter?("--bar").should be_true + input.has_parameter?("--bar", only_params: true).should be_false + end + + def test_get_parameter : Nil + input = ACON::Input::Hash.new(name: "George", "--foo": "bar") + input.parameter("--foo").should eq "bar" + input.parameter("--bar", "default").should eq "default" + + ACON::Input::Hash.new("George": nil, "--foo": "bar").parameter("--foo").should eq "bar" + + input = ACON::Input::Hash.new("--foo": nil, "--": nil, "--bar": "baz") + input.parameter("--bar").should eq "baz" + input.parameter("--bar", "default", true).should eq "default" + end + + def test_parse_arguments : Nil + input = ACON::Input::Hash.new( + {"name" => "foo"}, + ACON::Input::Definition.new ACON::Input::Argument.new "name" + ) + + input.arguments.should eq({"name" => "foo"}) + end + + @[DataProvider("option_provider")] + def test_parse_options(args : Hash(String, _), options : Array(ACON::Input::Option), expected_options : ::Hash) : Nil + input = ACON::Input::Hash.new args, ACON::Input::Definition.new options + + input.options.should eq expected_options + end + + def option_provider : Hash + { + "long option" => { + { + "--foo" => "bar", + }, + [ACON::Input::Option.new("foo")], + {"foo" => "bar"}, + }, + "long option with default" => { + { + "--foo" => "bar", + }, + [ACON::Input::Option.new("foo", "f", :optional, "", "default")], + {"foo" => "bar"}, + }, + "uses default value if not passed" => { + Hash(String, String).new, + [ACON::Input::Option.new("foo", "f", :optional, "", "default")], + {"foo" => "default"}, + }, + "uses passed value even with default" => { + {"--foo" => nil}, + [ACON::Input::Option.new("foo", "f", :optional, "", "default")], + {"foo" => nil}, + }, + "short option" => { + {"-f" => "bar"}, + [ACON::Input::Option.new("foo", "f", :optional, "", "default")], + {"foo" => "bar"}, + }, + "does not parse args after --" => { + {"--" => nil, "-f" => "bar"}, + [ACON::Input::Option.new("foo", "f", :optional, "", "default")], + {"foo" => "default"}, + }, + "handles only --" => { + {"--" => nil}, + Array(ACON::Input::Option).new, + Hash(String, String).new, + }, + } + end + + @[DataProvider("invalid_input_provider")] + def test_parse_invalid_input(args : Hash(String, _), definition : ACON::Input::Definition, error_class : Exception.class, error_message : String) : Nil + expect_raises error_class, error_message do + ACON::Input::Hash.new args, definition + end + end + + def invalid_input_provider : Tuple + { + { + {"foo" => "foo"}, + ACON::Input::Definition.new(ACON::Input::Argument.new("name")), + ACON::Exceptions::InvalidArgument, + "The 'foo' argument does not exist.", + }, + { + {"--foo" => nil}, + ACON::Input::Definition.new(ACON::Input::Option.new("foo", "f", :required)), + ACON::Exceptions::InvalidOption, + "The '--foo' option requires a value.", + }, + { + {"--foo" => "foo"}, + ACON::Input::Definition.new, + ACON::Exceptions::InvalidOption, + "The '--foo' option does not exist.", + }, + { + {"-o" => "foo"}, + ACON::Input::Definition.new, + ACON::Exceptions::InvalidOption, + "The '-o' option does not exist.", + }, + } + end +end diff --git a/spec/input/input_spec.cr b/spec/input/input_spec.cr new file mode 100644 index 0000000..662c9d0 --- /dev/null +++ b/spec/input/input_spec.cr @@ -0,0 +1,359 @@ +require "../spec_helper" + +describe ACON::Input do + describe "options" do + it "parses long option" do + input = ACON::Input::Hash.new( + {"--name" => "foo"}, + ACON::Input::Definition.new(ACON::Input::Option.new("name")) + ) + + input.option("name").should eq "foo" + + input.set_option "name", "bar" + input.option("name").should eq "bar" + input.options.should eq({"name" => "bar"}) + end + + it "parses short option" do + input = ACON::Input::Hash.new( + {"-n" => "foo"}, + ACON::Input::Definition.new(ACON::Input::Option.new("name", shortcut: "n")) + ) + + input.option("name").should eq "foo" + + input.set_option "name", "bar" + input.option("name").should eq "bar" + input.options.should eq({"name" => "bar"}) + end + + it "uses default when not provided" do + input = ACON::Input::Hash.new( + {"--name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name"), + ACON::Input::Option.new("bar", nil, :optional, "", "default") + ) + ) + + input.option("bar").should eq "default" + input.options.should eq({"name" => "foo", "bar" => "default"}) + end + + it "should parse explicit empty string value" do + input = ACON::Input::Hash.new( + {"--name" => "foo", "--bar" => ""}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name"), + ACON::Input::Option.new("bar", nil, :optional, "", "default") + ) + ) + + input.option("bar").should eq "" + input.options.should eq({"name" => "foo", "bar" => ""}) + end + + it "should parse explicit nil value" do + input = ACON::Input::Hash.new( + {"--name" => "foo", "--bar" => nil}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name"), + ACON::Input::Option.new("bar", nil, :optional, "", "default") + ) + ) + + input.option("bar").should be_nil + input.options.should eq({"name" => "foo", "bar" => nil}) + end + + describe "negatable option" do + it "non negated" do + input = ACON::Input::Hash.new( + {"--name" => nil}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name", value_mode: :negatable) + ) + ) + + input.has_option?("name").should be_true + input.has_option?("no-name").should be_true + input.option("name").should eq "true" + input.option("no-name").should eq "false" + end + + it "negated" do + input = ACON::Input::Hash.new( + {"--no-name" => nil}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name", value_mode: :negatable) + ) + ) + + input.option("name").should eq "false" + input.option("no-name").should eq "true" + end + + it "with default" do + input = ACON::Input::Hash.new( + Hash(String, String).new, + ACON::Input::Definition.new( + ACON::Input::Option.new("name", value_mode: :negatable, default: nil) + ) + ) + + input.option("name").should be_nil + input.option("no-name").should be_nil + end + end + + it "set invalid option" do + input = ACON::Input::Hash.new( + {"--name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name"), + ACON::Input::Option.new("bar", nil, :optional, "", "default") + ) + ) + + expect_raises ACON::Exceptions::InvalidArgument, "The 'foo' option does not exist." do + input.set_option "foo", "foo" + end + end + + it "get invalid option" do + input = ACON::Input::Hash.new( + {"--name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name"), + ACON::Input::Option.new("bar", nil, :optional, "", "default") + ) + ) + + expect_raises ACON::Exceptions::InvalidArgument, "The 'foo' option does not exist." do + input.option "foo" + end + end + + describe "#option(T)" do + it "optional option with default accessed via non nilable type" do + input = ACON::Input::Hash.new( + Hash(String, String).new, + ACON::Input::Definition.new( + ACON::Input::Option.new("name", nil, :optional, default: "bar"), + ) + ) + + option = input.option "name", String + typeof(option).should eq String + option.should eq "bar" + end + + it "optional option without default accessed via nilable type" do + input = ACON::Input::Hash.new( + {"--name2" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name"), + ACON::Input::Option.new("name2"), + ) + ) + + option = input.option "name2", String? + typeof(option).should eq String? + option.should eq "foo" + end + + it "required option with default accessed via non nilable type" do + input = ACON::Input::Hash.new( + {"--name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name", nil, :required), + ) + ) + + option = input.option "name", String + typeof(option).should eq String + option.should eq "foo" + end + + it "negatable option accessed via non bool type" do + input = ACON::Input::Hash.new( + {"--name" => "true"}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name", nil, :negatable), + ) + ) + + expect_raises ACON::Exceptions::Logic, "Cannot cast negatable option 'name' to non 'Bool?' type." do + input.option "name", Int32 + end + end + + it "negatable option with default accessed via non nilable type" do + input = ACON::Input::Hash.new( + {"--name" => "true"}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name", nil, :negatable), + ) + ) + + option = input.option "name", Bool + typeof(option).should eq Bool + option.should be_true + + option = input.option "no-name", Bool + typeof(option).should eq Bool + option.should be_false + end + + it "option that doesnt exist" do + input = ACON::Input::Hash.new( + {"--name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Option.new("name"), + ) + ) + + expect_raises ACON::Exceptions::InvalidArgument, "The 'foo' option does not exist." do + input.option "foo" + end + end + end + end + + describe "arguments" do + it do + input = ACON::Input::Hash.new( + {"name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Argument.new("name"), + ) + ) + + input.argument("name").should eq "foo" + input.set_argument "name", "bar" + input.argument("name").should eq "bar" + input.arguments.should eq({"name" => "bar"}) + end + + it do + input = ACON::Input::Hash.new( + {"name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Argument.new("name"), + ACON::Input::Argument.new("bar", :optional, "", "default") + ) + ) + + input.argument("bar").should eq "default" + typeof(input.argument("bar")).should eq String? + input.arguments.should eq({"name" => "foo", "bar" => "default"}) + end + + it "set invalid option" do + input = ACON::Input::Hash.new( + {"name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Argument.new("name"), + ACON::Input::Argument.new("bar", :optional, "", "default") + ) + ) + + expect_raises ACON::Exceptions::InvalidArgument, "The 'foo' argument does not exist." do + input.set_argument "foo", "foo" + end + end + + it "get invalid option" do + input = ACON::Input::Hash.new( + {"name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Argument.new("name"), + ACON::Input::Argument.new("bar", :optional, "", "default") + ) + ) + + expect_raises ACON::Exceptions::InvalidArgument, "The 'foo' argument does not exist." do + input.argument "foo" + end + end + + describe "#argument(T)" do + it "optional arg without default raises when accessed via non nilable type" do + input = ACON::Input::Hash.new( + {"name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Argument.new("name"), + ) + ) + + expect_raises ACON::Exceptions::Logic, "Cannot cast optional argument 'name' to non-nilable type 'String' without a default." do + input.argument "name", String + end + end + + it "optional arg with default accessed via non nilable type" do + input = ACON::Input::Hash.new( + Hash(String, String).new, + ACON::Input::Definition.new( + ACON::Input::Argument.new("name", default: "bar"), + ) + ) + + arg = input.argument "name", String + typeof(arg).should eq String + arg.should eq "bar" + end + + it "optional arg without default accessed via nilable type" do + input = ACON::Input::Hash.new( + {"name2" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Argument.new("name"), + ACON::Input::Argument.new("name2"), + ) + ) + + arg = input.argument "name2", String? + typeof(arg).should eq String? + arg.should eq "foo" + end + + it "arg that doesnt exist" do + input = ACON::Input::Hash.new( + {"name" => "foo"}, + ACON::Input::Definition.new( + ACON::Input::Argument.new("name"), + ) + ) + + expect_raises ACON::Exceptions::InvalidArgument, "The 'foo' argument does not exist." do + input.argument "foo" + end + end + end + end + + describe "#validate" do + it "missing arguments" do + input = ACON::Input::Hash.new + input.bind ACON::Input::Definition.new ACON::Input::Argument.new("name", :required) + + expect_raises ACON::Exceptions::ValidationFailed, "Not enough arguments (missing: 'name')." do + input.validate + end + end + + it "missing required argument" do + input = ACON::Input::Hash.new bar: "baz" + input.bind ACON::Input::Definition.new( + ACON::Input::Argument.new("name", :required), + ACON::Input::Argument.new("bar", :optional) + ) + + expect_raises ACON::Exceptions::ValidationFailed, "Not enough arguments (missing: 'name')." do + input.validate + end + end + end +end diff --git a/spec/input/option_spec.cr b/spec/input/option_spec.cr new file mode 100644 index 0000000..9f2b5b9 --- /dev/null +++ b/spec/input/option_spec.cr @@ -0,0 +1,85 @@ +require "../spec_helper" + +describe ACON::Input::Option do + describe ".new" do + it "normalizes the name" do + ACON::Input::Option.new("--foo").name.should eq "foo" + end + + it "disallows blank names" do + expect_raises ACON::Exceptions::InvalidArgument, "An option name cannot be blank." do + ACON::Input::Option.new "" + end + + expect_raises ACON::Exceptions::InvalidArgument, "An option name cannot be blank." do + ACON::Input::Option.new " " + end + end + + describe "shortcut" do + it "array" do + ACON::Input::Option.new("foo", ["a", "b"]).shortcut.should eq "a|b" + end + + it "string" do + ACON::Input::Option.new("foo", "-a|b").shortcut.should eq "a|b" + end + + it "string with whitespace" do + ACON::Input::Option.new("foo", "a| -b").shortcut.should eq "a|b" + end + + it "blank" do + expect_raises ACON::Exceptions::InvalidArgument, "An option shortcut cannot be blank." do + ACON::Input::Option.new "foo", [] of String + end + + expect_raises ACON::Exceptions::InvalidArgument, "An option shortcut cannot be blank." do + ACON::Input::Option.new "foo", "" + end + + expect_raises ACON::Exceptions::InvalidArgument, "An option shortcut cannot be blank." do + ACON::Input::Option.new "foo", " " + end + end + end + + describe "value_mode" do + it "NONE | IS_ARRAY" do + expect_raises ACON::Exceptions::InvalidArgument, "Cannot have VALUE::IS_ARRAY option mode when the option does not accept a value." do + ACON::Input::Option.new "foo", value_mode: ACON::Input::Option::Value::NONE | ACON::Input::Option::Value::IS_ARRAY + end + end + + it "NEGATABLE with value" do + expect_raises ACON::Exceptions::InvalidArgument, "Cannot have VALUE::NEGATABLE option mode if the option also accepts a value." do + ACON::Input::Option.new "foo", value_mode: ACON::Input::Option::Value::REQUIRED | ACON::Input::Option::Value::NEGATABLE + end + end + end + end + + describe "#default=" do + it "does not allow a default if using Value::NONE" do + expect_raises ACON::Exceptions::Logic, "Cannot set a default value when using Value::NONE mode." do + ACON::Input::Option.new "foo", default: "bar" + end + end + + describe "array" do + it "nil value" do + option = ACON::Input::Option.new "foo", value_mode: ACON::Input::Option::Value::OPTIONAL | ACON::Input::Option::Value::IS_ARRAY + option.default = nil + option.default.should eq [] of String + end + + it "non array" do + option = ACON::Input::Option.new "foo", value_mode: ACON::Input::Option::Value::OPTIONAL | ACON::Input::Option::Value::IS_ARRAY + + expect_raises ACON::Exceptions::Logic, "Default value for an array option must be an array." do + option.default = "bar" + end + end + end + end +end diff --git a/spec/input/value/array_spec.cr b/spec/input/value/array_spec.cr new file mode 100644 index 0000000..a074e66 --- /dev/null +++ b/spec/input/value/array_spec.cr @@ -0,0 +1,139 @@ +require "../../spec_helper" + +describe ACON::Input::Value::Number do + describe "#get" do + describe Bool do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Bool'." do + ACON::Input::Value::Number.new(123).get Bool + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Bool | Nil)'." do + ACON::Input::Value::Number.new(123).get Bool? + end + end + end + + describe String do + it "non-nilable" do + val = ACON::Input::Value::Number.new(123).get String + typeof(val).should eq String + val.should eq "123" + end + + it "nilable" do + val = ACON::Input::Value::Number.new(123).get String? + typeof(val).should eq String? + val.should eq "123" + end + end + + describe Int do + it "non-nilable" do + val = ACON::Input::Value::Number.new(123).get Int32 + typeof(val).should eq Int32 + val.should eq 123 + + val = ACON::Input::Value::Number.new(123_u8).get UInt8 + typeof(val).should eq UInt8 + val.should eq 123_u8 + end + + it "non-nilable" do + val = ACON::Input::Value::Number.new(123).get Int32? + typeof(val).should eq Int32? + val.should eq 123 + + val = ACON::Input::Value::Number.new(123_u8).get UInt8? + typeof(val).should eq UInt8? + val.should eq 123_u8 + end + end + + describe Float do + it "non-nilable" do + val = ACON::Input::Value::Number.new(4.69).get Float32 + typeof(val).should eq Float32 + val.should eq 4.69_f32 + + val = ACON::Input::Value::Number.new(4.69).get Float64 + typeof(val).should eq Float64 + val.should eq 4.69 + end + + it "non-nilable" do + val = ACON::Input::Value::Number.new(4.69).get Float32? + typeof(val).should eq Float32? + val.should eq 4.69_f32 + + val = ACON::Input::Value::Number.new(4.69).get Float64? + typeof(val).should eq Float64? + val.should eq 4.69 + end + end + + describe Array do + describe String do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Array(String)'." do + ACON::Input::Value::Number.new(123).get Array(String) + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(String) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(String)? + end + end + + it "nilable generic value" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(String | Nil) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(String?)? + end + end + end + + describe Int32 do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Array(Int32)'." do + ACON::Input::Value::Number.new(123).get Array(Int32) + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(Int32) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(Int32)? + end + end + + it "nilable generic value" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(Int32 | Nil) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(Int32?)? + end + end + end + + describe Bool do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Array(Bool)'." do + ACON::Input::Value::Number.new(123).get Array(Bool) + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(Bool) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(Bool)? + end + end + + it "nilable generic value" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(Bool | Nil) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(Bool?)? + end + end + end + end + end +end diff --git a/spec/input/value/bool_spec.cr b/spec/input/value/bool_spec.cr new file mode 100644 index 0000000..bd45d2e --- /dev/null +++ b/spec/input/value/bool_spec.cr @@ -0,0 +1,139 @@ +require "../../spec_helper" + +describe ACON::Input::Value::Bool do + describe "#get" do + describe Bool do + it "non-nilable" do + val = ACON::Input::Value::Bool.new(false).get Bool + typeof(val).should eq Bool + val.should be_false + end + + it "nilable" do + val = ACON::Input::Value::Bool.new(true).get Bool? + typeof(val).should eq Bool? + val.should be_true + end + end + + describe String do + it "non-nilable" do + val = ACON::Input::Value::Bool.new(false).get String + typeof(val).should eq String + val.should eq "false" + end + + it "nilable" do + val = ACON::Input::Value::Bool.new(true).get String? + typeof(val).should eq String? + val.should eq "true" + end + end + + describe Int do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid 'Int32'." do + ACON::Input::Value::Bool.new(false).get Int32 + end + + expect_raises ACON::Exceptions::Logic, "'true' is not a valid 'UInt8'." do + ACON::Input::Value::Bool.new(true).get UInt8 + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid '(Int32 | Nil)'." do + ACON::Input::Value::Bool.new(false).get Int32? + end + + expect_raises ACON::Exceptions::Logic, "'true' is not a valid '(UInt8 | Nil)'." do + ACON::Input::Value::Bool.new(true).get UInt8? + end + end + end + + describe Float do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid 'Float32'." do + ACON::Input::Value::Bool.new(false).get Float32 + end + + expect_raises ACON::Exceptions::Logic, "'true' is not a valid 'Float64'." do + ACON::Input::Value::Bool.new(true).get Float64 + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid '(Float32 | Nil)'." do + ACON::Input::Value::Bool.new(false).get Float32? + end + + expect_raises ACON::Exceptions::Logic, "'true' is not a valid '(Float64 | Nil)'." do + ACON::Input::Value::Bool.new(true).get Float64? + end + end + end + + describe Array do + describe String do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid 'Array(String)'." do + ACON::Input::Value::Bool.new(false).get Array(String) + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid '(Array(String) | Nil)'." do + ACON::Input::Value::Bool.new(false).get Array(String)? + end + end + + it "nilable generic value" do + expect_raises ACON::Exceptions::Logic, "'true' is not a valid '(Array(String | Nil) | Nil)'." do + ACON::Input::Value::Bool.new(true).get Array(String?)? + end + end + end + + describe Int32 do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid 'Array(Int32)'." do + ACON::Input::Value::Bool.new(false).get Array(Int32) + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid '(Array(Int32) | Nil)'." do + ACON::Input::Value::Bool.new(false).get Array(Int32)? + end + end + + it "nilable generic value" do + expect_raises ACON::Exceptions::Logic, "'true' is not a valid '(Array(Int32 | Nil) | Nil)'." do + ACON::Input::Value::Bool.new(true).get Array(Int32?)? + end + end + end + + describe Bool do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid 'Array(Bool)'." do + ACON::Input::Value::Bool.new(false).get Array(Bool) + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'false' is not a valid '(Array(Bool) | Nil)'." do + ACON::Input::Value::Bool.new(false).get Array(Bool)? + end + end + + it "nilable generic value" do + expect_raises ACON::Exceptions::Logic, "'true' is not a valid '(Array(Bool | Nil) | Nil)'." do + ACON::Input::Value::Bool.new(true).get Array(Bool?)? + end + end + end + end + end +end diff --git a/spec/input/value/nil_spec.cr b/spec/input/value/nil_spec.cr new file mode 100644 index 0000000..1c5f62d --- /dev/null +++ b/spec/input/value/nil_spec.cr @@ -0,0 +1,57 @@ +require "../../spec_helper" + +describe ACON::Input::Value::Number do + describe "#get" do + it Bool do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Bool'." do + ACON::Input::Value::Number.new(123).get Bool + end + end + + it String do + val = ACON::Input::Value::Number.new(123).get String + typeof(val).should eq String + val.should eq "123" + end + + it Int do + val = ACON::Input::Value::Number.new(123).get Int32 + typeof(val).should eq Int32 + val.should eq 123 + + val = ACON::Input::Value::Number.new(123_u8).get UInt8 + typeof(val).should eq UInt8 + val.should eq 123_u8 + end + + it Float do + val = ACON::Input::Value::Number.new(4.69).get Float32 + typeof(val).should eq Float32 + val.should eq 4.69_f32 + + val = ACON::Input::Value::Number.new(4.69).get Float64 + typeof(val).should eq Float64 + val.should eq 4.69 + end + + describe Array do + it String do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Array(String)'." do + ACON::Input::Value::Number.new(123).get Array(String) + end + end + + it Int32 do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(Int32) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(Int32)? + end + end + + it Bool do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Array(Bool)'." do + ACON::Input::Value::Number.new(123).get Array(Bool) + end + end + end + end +end diff --git a/spec/input/value/number_spec.cr b/spec/input/value/number_spec.cr new file mode 100644 index 0000000..a074e66 --- /dev/null +++ b/spec/input/value/number_spec.cr @@ -0,0 +1,139 @@ +require "../../spec_helper" + +describe ACON::Input::Value::Number do + describe "#get" do + describe Bool do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Bool'." do + ACON::Input::Value::Number.new(123).get Bool + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Bool | Nil)'." do + ACON::Input::Value::Number.new(123).get Bool? + end + end + end + + describe String do + it "non-nilable" do + val = ACON::Input::Value::Number.new(123).get String + typeof(val).should eq String + val.should eq "123" + end + + it "nilable" do + val = ACON::Input::Value::Number.new(123).get String? + typeof(val).should eq String? + val.should eq "123" + end + end + + describe Int do + it "non-nilable" do + val = ACON::Input::Value::Number.new(123).get Int32 + typeof(val).should eq Int32 + val.should eq 123 + + val = ACON::Input::Value::Number.new(123_u8).get UInt8 + typeof(val).should eq UInt8 + val.should eq 123_u8 + end + + it "non-nilable" do + val = ACON::Input::Value::Number.new(123).get Int32? + typeof(val).should eq Int32? + val.should eq 123 + + val = ACON::Input::Value::Number.new(123_u8).get UInt8? + typeof(val).should eq UInt8? + val.should eq 123_u8 + end + end + + describe Float do + it "non-nilable" do + val = ACON::Input::Value::Number.new(4.69).get Float32 + typeof(val).should eq Float32 + val.should eq 4.69_f32 + + val = ACON::Input::Value::Number.new(4.69).get Float64 + typeof(val).should eq Float64 + val.should eq 4.69 + end + + it "non-nilable" do + val = ACON::Input::Value::Number.new(4.69).get Float32? + typeof(val).should eq Float32? + val.should eq 4.69_f32 + + val = ACON::Input::Value::Number.new(4.69).get Float64? + typeof(val).should eq Float64? + val.should eq 4.69 + end + end + + describe Array do + describe String do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Array(String)'." do + ACON::Input::Value::Number.new(123).get Array(String) + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(String) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(String)? + end + end + + it "nilable generic value" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(String | Nil) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(String?)? + end + end + end + + describe Int32 do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Array(Int32)'." do + ACON::Input::Value::Number.new(123).get Array(Int32) + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(Int32) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(Int32)? + end + end + + it "nilable generic value" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(Int32 | Nil) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(Int32?)? + end + end + end + + describe Bool do + it "non-nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Array(Bool)'." do + ACON::Input::Value::Number.new(123).get Array(Bool) + end + end + + it "nilable" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(Bool) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(Bool)? + end + end + + it "nilable generic value" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid '(Array(Bool | Nil) | Nil)'." do + ACON::Input::Value::Number.new(123).get Array(Bool?)? + end + end + end + end + end +end diff --git a/spec/input/value/string_spec.cr b/spec/input/value/string_spec.cr new file mode 100644 index 0000000..ca04266 --- /dev/null +++ b/spec/input/value/string_spec.cr @@ -0,0 +1,189 @@ +require "../../spec_helper" + +describe ACON::Input::Value::String do + describe "#get" do + describe Bool do + describe "non-nilable" do + it "true" do + val = ACON::Input::Value::String.new("true").get Bool + typeof(val).should eq Bool + val.should be_true + end + + it "false" do + val = ACON::Input::Value::String.new("false").get Bool + typeof(val).should eq Bool + val.should be_false + end + + it "invalid" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Bool'." do + ACON::Input::Value::String.new("123").get Bool + end + end + end + + describe "nilable" do + it "valid" do + val = ACON::Input::Value::String.new("true").get Bool? + typeof(val).should eq Bool? + val.should be_true + end + + it "invalid" do + expect_raises ACON::Exceptions::Logic, "'123' is not a valid 'Bool?'." do + ACON::Input::Value::String.new("123").get Bool? + end + end + end + end + + describe String do + it "non-nilable" do + val = ACON::Input::Value::String.new("foo").get String + typeof(val).should eq String + val.should eq "foo" + end + + it "nilable" do + val = ACON::Input::Value::String.new("foo").get String? + typeof(val).should eq String? + val.should eq "foo" + end + end + + describe Int do + it "non-nilable" do + string = ACON::Input::Value::String.new "123" + + val = string.get Int32 + typeof(val).should eq Int32 + val.should eq 123 + + val = string.get(UInt8) + typeof(val).should eq UInt8 + val.should eq 123_u8 + end + + it "nilable" do + string = ACON::Input::Value::String.new "123" + + val = string.get Int32? + typeof(val).should eq Int32? + val.should eq 123 + + val = string.get UInt8? + typeof(val).should eq UInt8? + val.should eq 123_u8 + end + + it "non number" do + expect_raises ACON::Exceptions::Logic, "'foo' is not a valid 'Int32'." do + ACON::Input::Value::String.new("foo").get Int32 + end + + expect_raises ACON::Exceptions::Logic, "'foo' is not a valid 'Int32'." do + ACON::Input::Value::String.new("foo").get Int32? + end + end + end + + describe Float do + it "non-nilable" do + string = ACON::Input::Value::String.new "4.57" + + val = string.get Float64 + typeof(val).should eq Float64 + val.should eq 4.57 + + val = string.get Float32 + typeof(val).should eq Float32 + val.should eq 4.57_f32 + end + + it "nilable" do + string = ACON::Input::Value::String.new "4.57" + + val = string.get Float64? + typeof(val).should eq Float64? + val.should eq 4.57 + + val = string.get Float32? + typeof(val).should eq Float32? + val.should eq 4.57_f32 + end + + it "non number" do + expect_raises ACON::Exceptions::Logic, "'foo' is not a valid 'Float64'." do + ACON::Input::Value::String.new("foo").get Float64 + end + + expect_raises ACON::Exceptions::Logic, "'foo' is not a valid 'Float64'." do + ACON::Input::Value::String.new("foo").get Float64? + end + end + end + + describe Array do + describe String do + it "non-nilable" do + val = ACON::Input::Value::String.new("foo,bar,baz").get Array(String) + typeof(val).should eq Array(String) + val.should eq ["foo", "bar", "baz"] + end + + it "nilable" do + val = ACON::Input::Value::String.new("foo,bar,baz").get Array(String)? + typeof(val).should eq Array(String)? + val.should eq ["foo", "bar", "baz"] + end + + it "nilable generic value" do + val = ACON::Input::Value::String.new("foo,bar,baz").get Array(String?)? + typeof(val).should eq Array(String?)? + val.should eq ["foo", "bar", "baz"] + end + end + + describe Int32 do + it "non-nilable" do + val = ACON::Input::Value::String.new("1,2,3").get Array(Int32) + typeof(val).should eq Array(Int32) + val.should eq [1, 2, 3] + end + + it "nilable" do + val = ACON::Input::Value::String.new("1,2,3").get Array(Int32)? + typeof(val).should eq Array(Int32)? + val.should eq [1, 2, 3] + end + + it "nilable generic value" do + val = ACON::Input::Value::String.new("1,2,3").get Array(Int32?)? + typeof(val).should eq Array(Int32?)? + val.should eq [1, 2, 3] + end + end + + describe Bool do + it "non-nilable" do + val = ACON::Input::Value::String.new("false,true,true").get Array(Bool) + typeof(val).should eq Array(Bool) + val.should eq [false, true, true] + end + + it "nilable" do + val = ACON::Input::Value::String.new("false,true,true").get Array(Bool)? + typeof(val).should eq Array(Bool)? + val.should eq [false, true, true] + end + + it "nilable generic value" do + val = ACON::Input::Value::String.new("false,true,true").get Array(Bool?)? + typeof(val).should eq Array(Bool?)? + val.should eq [false, true, true] + end + end + end + end +end diff --git a/spec/output/console_section_output_spec.cr b/spec/output/console_section_output_spec.cr new file mode 100644 index 0000000..b6fc942 --- /dev/null +++ b/spec/output/console_section_output_spec.cr @@ -0,0 +1,118 @@ +require "../spec_helper" + +struct ConsoleSectionOutputTest < ASPEC::TestCase + @io : IO::Memory + + def initialize + @io = IO::Memory.new + end + + def test_clear_all : Nil + sections = Array(ACON::Output::Section).new + output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new + + output.puts "Foo\nBar" + output.clear + + output.io.to_s.should eq "Foo\nBar\n\e[2A\e[0J" + end + + def test_clear_number_of_lines : Nil + sections = Array(ACON::Output::Section).new + output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new + + output.puts "Foo\nBar\nBaz\nFooBar" + output.clear 2 + + output.io.to_s.should eq "Foo\nBar\nBaz\nFooBar\n\e[2A\e[0J" + end + + def test_clear_number_more_than_current_size : Nil + sections = Array(ACON::Output::Section).new + output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new + + output.puts "Foo" + output.clear 2 + + output.io.to_s.should eq "Foo\n\e[1A\e[0J" + end + + def test_clear_number_of_lines_multiple_sections : Nil + output = ACON::Output::IO.new @io + + sections = Array(ACON::Output::Section).new + output1 = ACON::Output::Section.new output.io, sections, :normal, true, ACON::Formatter::Output.new + output2 = ACON::Output::Section.new output.io, sections, :normal, true, ACON::Formatter::Output.new + + output2.puts "Foo" + output2.puts "Bar" + output2.clear 1 + output1.puts "Baz" + + output.io.to_s.should eq "Foo\nBar\n\e[1A\e[0J\e[1A\e[0JBaz\nFoo\n" + end + + def test_clear_number_of_lines_multiple_sections_preserves_empty_lines : Nil + output = ACON::Output::IO.new @io + + sections = Array(ACON::Output::Section).new + output1 = ACON::Output::Section.new output.io, sections, :normal, true, ACON::Formatter::Output.new + output2 = ACON::Output::Section.new output.io, sections, :normal, true, ACON::Formatter::Output.new + + output2.puts "\nfoo" + output2.clear 1 + output1.puts "bar" + + output.io.to_s.should eq "\nfoo\n\e[1A\e[0J\e[1A\e[0Jbar\n\n" + end + + def test_overwrite : Nil + sections = Array(ACON::Output::Section).new + output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new + + output.puts "Foo" + output.overwrite "Bar" + + output.io.to_s.should eq "Foo\n\e[1A\e[0JBar\n" + end + + def test_overwrite_multiple_lines : Nil + sections = Array(ACON::Output::Section).new + output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new + + output.puts "Foo\nBar\nBaz" + output.overwrite "Bar" + + output.io.to_s.should eq "Foo\nBar\nBaz\n\e[3A\e[0JBar\n" + end + + def test_multiple_section_output : Nil + output = ACON::Output::IO.new @io + + sections = Array(ACON::Output::Section).new + output1 = ACON::Output::Section.new output.io, sections, :normal, true, ACON::Formatter::Output.new + output2 = ACON::Output::Section.new output.io, sections, :normal, true, ACON::Formatter::Output.new + + output1.puts "Foo" + output2.puts "Bar" + + output1.overwrite "Baz" + output2.overwrite "Foobar" + + output.io.to_s.should eq "Foo\nBar\n\e[2A\e[0JBar\n\e[1A\e[0JBaz\nBar\n\e[1A\e[0JFoobar\n" + end + + def test_clear_with_question : Nil + input = ACON::Input::Hash.new + input.stream = IO::Memory.new "Batman & Robin\n" + input.interactive = true + + sections = Array(ACON::Output::Section).new + output = ACON::Output::Section.new @io, sections, :normal, true, ACON::Formatter::Output.new + + ACON::Helper::Question.new.ask input, output, ACON::Question(String?).new("What's your favorite superhero?", nil) + output.clear + + output.io.to_s.should eq "What's your favorite superhero?\n\e[2A\e[0J" + end +end diff --git a/spec/output/io_spec.cr b/spec/output/io_spec.cr new file mode 100644 index 0000000..f998606 --- /dev/null +++ b/spec/output/io_spec.cr @@ -0,0 +1,19 @@ +require "../spec_helper" + +struct IOTest < ASPEC::TestCase + @io : IO::Memory + + def initialize + @io = IO::Memory.new + end + + def tear_down : Nil + @io.clear + end + + def test_do_write : Nil + output = ACON::Output::IO.new @io + output.puts "foo" + output.to_s.should eq "foo\n" + end +end diff --git a/spec/output/output_spec.cr b/spec/output/output_spec.cr new file mode 100644 index 0000000..95a4c39 --- /dev/null +++ b/spec/output/output_spec.cr @@ -0,0 +1,89 @@ +require "../spec_helper" + +private class MockOutput < ACON::Output + getter output : String = "" + + def clear : Nil + @output = "" + end + + protected def do_write(message : String, new_line : Bool) : Nil + @output += message + @output += "\n" if new_line + end +end + +struct OutputTest < ASPEC::TestCase + def test_write_verbosity_quiet : Nil + output = MockOutput.new :quiet + output.puts "foo" + output.output.should be_empty + end + + def test_write_array_messages : Nil + output = MockOutput.new + output.puts ["foo", "bar"] + output.output.should eq "foo\nbar\n" + end + + @[DataProvider("message_provider")] + def test_write_raw_message(message : String, output_type : ACON::Output::Type, expected : String) : Nil + output = MockOutput.new + output.puts message, output_type: output_type + output.output.should eq expected + end + + def message_provider : Tuple + { + {"foo", ACON::Output::Type::RAW, "foo\n"}, + {"foo", ACON::Output::Type::PLAIN, "foo\n"}, + } + end + + def test_write_non_decorated : Nil + output = MockOutput.new + output.decorated = false + output.puts "foo" + output.output.should eq "foo\n" + end + + def test_write_decorated : Nil + foo_style = ACON::Formatter::OutputStyle.new :yellow, :red, :blink + output = MockOutput.new + output.formatter.set_style "FOO", foo_style + output.decorated = true + output.puts "foo" + output.output.should eq "\e[33;41;5mfoo\e[0m\n" + end + + def test_write_decorated_invalid_style : Nil + output = MockOutput.new + output.puts "foo" + output.output.should eq "foo\n" + end + + @[DataProvider("verbosity_provider")] + def test_write_with_verbosity(verbosity : ACON::Output::Verbosity, expected : String) : Nil + output = MockOutput.new + + output.verbosity = verbosity + output.print "1" + output.print "2", :quiet + output.print "3", :normal + output.print "4", :verbose + output.print "5", :very_verbose + output.print "6", :debug + + output.output.should eq expected + end + + def verbosity_provider : Tuple + { + {ACON::Output::Verbosity::QUIET, "2"}, + {ACON::Output::Verbosity::NORMAL, "123"}, + {ACON::Output::Verbosity::VERBOSE, "1234"}, + {ACON::Output::Verbosity::VERY_VERBOSE, "12345"}, + {ACON::Output::Verbosity::DEBUG, "123456"}, + } + end +end diff --git a/spec/question/choice_spec.cr b/spec/question/choice_spec.cr new file mode 100644 index 0000000..a3ff1da --- /dev/null +++ b/spec/question/choice_spec.cr @@ -0,0 +1,87 @@ +require "../spec_helper" + +struct ChoiceQuestionTest < ASPEC::TestCase + def test_new_empty_choices : Nil + expect_raises ACON::Exceptions::Logic, "Choice questions must have at least 1 choice available." do + ACON::Question::Choice.new "A question", Array(String).new + end + end + + def test_validator_exact_match : Nil + question = ACON::Question::Choice.new( + "A question", + [ + "First response", + "Second response", + "Third response", + "Fourth response", + ] + ) + + {"First response", "First response ", " First response", " First response "}.each do |answer| + validator = question.validator.not_nil! + actual = validator.call answer + + actual.should eq "First response" + end + end + + def test_validator_index_match : Nil + question = ACON::Question::Choice.new( + "A question", + [ + "First response", + "Second response", + "Third response", + "Fourth response", + ] + ) + + {"0"}.each do |answer| + validator = question.validator.not_nil! + actual = validator.call answer + + actual.should eq "First response" + end + end + + def test_non_trimmable : Nil + question = ACON::Question::Choice.new( + "A question", + [ + "First response ", + " Second response", + " Third response ", + ] + ) + + question.trimmable = false + + question.validator.not_nil!.call(" Third response ").should eq " Third response " + end + + @[DataProvider("hash_choice_provider")] + def test_validator_hash_choices(answer : String, expected : String) : Nil + question = ACON::Question::Choice.new( + "A question", + { + "0" => "First choice", + "foo" => "Foo", + "99" => "N°99", + } + ) + + question.validator.not_nil!.call(answer).should eq expected + end + + def hash_choice_provider : Hash + { + "'0' choice by key" => {"0", "First choice"}, + "'0' choice by value" => {"First choice", "First choice"}, + "select by key" => {"foo", "Foo"}, + "select by value" => {"Foo", "Foo"}, + "select by key, numeric key" => {"99", "N°99"}, + "select by value, numeric key" => {"N°99", "N°99"}, + } + end +end diff --git a/spec/question/confirmation_spec.cr b/spec/question/confirmation_spec.cr new file mode 100644 index 0000000..4804bb2 --- /dev/null +++ b/spec/question/confirmation_spec.cr @@ -0,0 +1,39 @@ +require "../spec_helper" + +struct ConfirmationQuestionTest < ASPEC::TestCase + @[DataProvider("normalizer_provider")] + def test_default_regex(default : Bool, answers : Array, expected : Bool) : Nil + question = ACON::Question::Confirmation.new "A question", default + + answers.each do |answer| + normalizer = question.normalizer.not_nil! + actual = normalizer.call answer + actual.should eq expected + end + end + + def normalizer_provider : Tuple + { + { + true, + ["y", "Y", "yes", "YES", "yEs", ""], + true, + }, + { + true, + ["n", "N", "no", "NO", "nO", "foo", "1", "0"], + false, + }, + { + false, + ["y", "Y", "yes", "YES", "yEs"], + true, + }, + { + false, + ["n", "N", "no", "NO", "nO", "foo", "1", "0", ""], + false, + }, + } + end +end diff --git a/spec/question/multiple_choice_spec.cr b/spec/question/multiple_choice_spec.cr new file mode 100644 index 0000000..0ef7017 --- /dev/null +++ b/spec/question/multiple_choice_spec.cr @@ -0,0 +1,46 @@ +require "../spec_helper" + +struct MultipleChoiceQuestionTest < ASPEC::TestCase + def test_new_empty_choices : Nil + expect_raises ACON::Exceptions::Logic, "Choice questions must have at least 1 choice available." do + ACON::Question::MultipleChoice.new "A question", Array(String).new + end + end + + def test_non_trimmable : Nil + question = ACON::Question::MultipleChoice(String).new( + "A question", + [ + "First response ", + " Second response", + " Third response ", + ] + ) + + question.trimmable = false + + question.validator.not_nil!.call("First response , Second response").should eq ["First response ", " Second response"] + end + + @[DataProvider("hash_choice_provider")] + def test_validator_hash_choices(answer : String, expected : Array) : Nil + question = ACON::Question::MultipleChoice.new( + "A question", + { + "0" => "First choice", + "foo" => "Foo", + "99" => "N°99", + } + ) + + question.validator.not_nil!.call(answer).should eq expected + end + + def hash_choice_provider : Hash + { + "'0' choice by key - multiple" => {"0,Foo", ["First choice", "Foo"]}, + "'0' choice by key- single" => {"foo", ["Foo"]}, + "select by value, numeric key" => {"N°99,foo,First choice", ["N°99", "Foo", "First choice"]}, + } + end +end diff --git a/spec/question/question_spec.cr b/spec/question/question_spec.cr new file mode 100644 index 0000000..964d528 --- /dev/null +++ b/spec/question/question_spec.cr @@ -0,0 +1,54 @@ +require "../spec_helper" + +struct QuestionTest < ASPEC::TestCase + @question : ACON::Question(String?) + + def initialize + @question = ACON::Question(String?).new "Test Question", nil + end + + def test_default : Nil + @question.default.should be_nil + default = ACON::Question(String).new("Test Question", "FOO").default + default.should eq "FOO" + typeof(default).should eq String + end + + def test_hidden_autocompleter_callback : Nil + @question.autocompleter_callback do + [] of String + end + + expect_raises ACON::Exceptions::Logic, "A hidden question cannot use the autocompleter" do + @question.hidden = true + end + end + + @[DataProvider("autocompleter_values_provider")] + def test_get_set_autocompleter_values(values : Indexable | Hash, expected : Array(String)) : Nil + @question.autocompleter_values = values + + @question.autocompleter_values.should eq expected + end + + def autocompleter_values_provider : Hash + { + "tuple" => { + {"a", "b", "c"}, + ["a", "b", "c"], + }, + "array" => { + ["a", "b", "c"], + ["a", "b", "c"], + }, + "string key hash" => { + {"a" => "b", "c" => "d"}, + ["a", "c", "b", "d"], + }, + "int key hash" => { + {0 => "b", 1 => "d"}, + ["b", "d"], + }, + } + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 824a001..0eabdcb 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,2 +1,14 @@ require "spec" + +require "athena-spec" require "../src/athena-console" +require "../src/spec" + +require "./fixtures/commands/io" +require "./fixtures/**" + +# Spec by default disables colorize with `TERM=dumb`. +# Override that given there are specs based on ansi output. +Colorize.enabled = true + +ASPEC.run_all diff --git a/spec/style/athena_style_spec.cr b/spec/style/athena_style_spec.cr new file mode 100644 index 0000000..0aad5a2 --- /dev/null +++ b/spec/style/athena_style_spec.cr @@ -0,0 +1,283 @@ +require "../spec_helper" + +struct AthenaStyleTest < ASPEC::TestCase + @col_size : String? + + def initialize + @col_size = ENV["COLUMNS"]? + ENV["COLUMNS"] = "121" + end + + def tear_down : Nil + if size = @col_size + ENV["COLUMNS"] = size + else + ENV.delete "COLUMNS" + end + end + + private def assert_file_equals_string(filepath : String, string : String, *, file : String = __FILE__, line : Int32 = __LINE__) : Nil + normalized_path = File.join __DIR__, "..", "fixtures", filepath + string.should match(Regex.new(File.read(normalized_path))), file: file, line: line + end + + @[DataProvider("output_provider")] + def test_outputs(command_proc : ACON::Commands::Generic::Proc, file_path : String) : Nil + command = ACON::Commands::Generic.new "foo", &command_proc + + tester = ACON::Spec::CommandTester.new command + + tester.execute interactive: false, decorated: false + self.assert_file_equals_string file_path, tester.display + end + + def output_provider : Hash + { + "Single blank line at start with block element" => { + (ACON::Commands::Generic::Proc.new do |input, output| + ACON::Style::Athena.new(input, output).caution "Lorem ipsum dolor sit amet" + + ACON::Command::Status::SUCCESS + end), + "style/block.txt", + }, + "Single blank line between titles and blocks" => { + (ACON::Commands::Generic::Proc.new do |input, output| + style = ACON::Style::Athena.new input, output + style.title "Title" + style.warning "Lorem ipsum dolor sit amet" + style.title "Title" + + ACON::Command::Status::SUCCESS + end), + "style/title_block.txt", + }, + "Single blank line between blocks" => { + (ACON::Commands::Generic::Proc.new do |input, output| + style = ACON::Style::Athena.new input, output + style.warning "Warning" + style.caution "Caution" + style.error "Error" + style.success "Success" + style.note "Note" + style.info "Info" + style.block "Custom block", "CUSTOM", style: "fg=white;bg=green", prefix: "X ", padding: true + + ACON::Command::Status::SUCCESS + end), + "style/blocks.txt", + }, + "Single blank line between titles" => { + (ACON::Commands::Generic::Proc.new do |input, output| + style = ACON::Style::Athena.new input, output + style.title "First title" + style.title "Second title" + + ACON::Command::Status::SUCCESS + end), + "style/titles.txt", + }, + "Single blank line after any text and a title" => { + (ACON::Commands::Generic::Proc.new do |input, output| + style = ACON::Style::Athena.new input, output + style.print "Lorem ipsum dolor sit amet" + style.title "First title" + + style.puts "Lorem ipsum dolor sit amet" + style.title "Second title" + + style.print "Lorem ipsum dolor sit amet" + style.print "" + style.title "Third title" + + # Handle edge case by appending empty strings to history + style.print "Lorem ipsum dolor sit amet" + style.print({"", "", ""}) + style.title "Fourth title" + + # Ensure manual control over number of blank lines + style.puts "Lorem ipsum dolor sit amet" + style.puts({"", ""}) # Should print 1 extra newline + style.title "Fifth title" + + style.puts "Lorem ipsum dolor sit amet" + style.new_line 2 # Should print 1 extra newline + style.title "Sixth title" + + ACON::Command::Status::SUCCESS + end), + "style/titles_text.txt", + }, + "Proper line endings before outputting a text block" => { + (ACON::Commands::Generic::Proc.new do |input, output| + style = ACON::Style::Athena.new input, output + style.puts "Lorem ipsum dolor sit amet" + style.listing "Lorem ipsum dolor sit amet", "consectetur adipiscing elit" + + # When using print + style.print "Lorem ipsum dolor sit amet" + style.listing "Lorem ipsum dolor sit amet", "consectetur adipiscing elit" + + style.print "Lorem ipsum dolor sit amet" + style.text({"Lorem ipsum dolor sit amet", "consectetur adipiscing elit"}) + + style.new_line + + style.print "Lorem ipsum dolor sit amet" + style.comment({"Lorem ipsum dolor sit amet", "consectetur adipiscing elit"}) + + ACON::Command::Status::SUCCESS + end), + "style/block_line_endings.txt", + }, + "Proper blank line after text block with block" => { + (ACON::Commands::Generic::Proc.new do |input, output| + style = ACON::Style::Athena.new input, output + style.listing "Lorem ipsum dolor sit amet", "consectetur adipiscing elit" + style.success "Lorem ipsum dolor sit amet" + + ACON::Command::Status::SUCCESS + end), + "style/text_block_blank_line.txt", + }, + "Questions do not output anything when input is non-interactive" => { + (ACON::Commands::Generic::Proc.new do |input, output| + style = ACON::Style::Athena.new input, output + style.title "Title" + style.ask_hidden "Hidden question" + style.choice "Choice question with default", {"choice1", "choice2"}, "choice1" + style.confirm "Confirmation with yes default", true + style.text "Duis aute irure dolor in reprehenderit in voluptate velit esse" + + ACON::Command::Status::SUCCESS + end), + "style/non_interactive_question.txt", + }, + # TODO: Test table formatting with multiple headers + TableCell + "Lines are aligned to the beginning of the first line in a multi-line block" => { + (ACON::Commands::Generic::Proc.new do |input, output| + ACON::Style::Athena.new(input, output).block({"Custom block", "Second custom block line"}, "CUSTOM", style: "fg=white;bg=green", prefix: "X ", padding: true) + + ACON::Command::Status::SUCCESS + end), + "style/multi_line_block.txt", + }, + "Lines are aligned to the beginning of the first line in a very long line block" => { + (ACON::Commands::Generic::Proc.new do |input, output| + ACON::Style::Athena.new(input, output).block( + "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", + "CUSTOM", + style: "fg=white;bg=green", + prefix: "X ", + padding: true + ) + + ACON::Command::Status::SUCCESS + end), + "style/long_line_block.txt", + }, + "Long lines are wrapped within a block" => { + (ACON::Commands::Generic::Proc.new do |input, output| + ACON::Style::Athena.new(input, output).block( + "Lopadotemachoselachogaleokranioleipsanodrimhypotrimmatosilphioparaomelitokatakechymenokichlepikossyphophattoperisteralektryonoptekephalliokigklopeleiolagoiosiraiobaphetraganopterygon", + "CUSTOM", + style: "fg=white;bg=green", + prefix: " § ", + ) + + ACON::Command::Status::SUCCESS + end), + "style/long_line_block_wrapping.txt", + }, + "Lines are aligned to the first line and start with '//' in a very long line comment" => { + (ACON::Commands::Generic::Proc.new do |input, output| + ACON::Style::Athena.new(input, output).comment( + "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum" + ) + + ACON::Command::Status::SUCCESS + end), + "style/long_line_comment.txt", + }, + "Nested tags have no effect on the color of the '//' prefix" => { + (ACON::Commands::Generic::Proc.new do |input, output| + output.decorated = true + ACON::Style::Athena.new(input, output).comment( + "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum" + ) + + ACON::Command::Status::SUCCESS + end), + "style/long_line_comment_decorated.txt", + }, + "Block behaves properly with a prefix and without type" => { + (ACON::Commands::Generic::Proc.new do |input, output| + ACON::Style::Athena.new(input, output).block( + "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", + prefix: "$ " + ) + + ACON::Command::Status::SUCCESS + end), + "style/block_prefix_no_type.txt", + }, + "Block behaves properly with a type and without prefix" => { + (ACON::Commands::Generic::Proc.new do |input, output| + ACON::Style::Athena.new(input, output).block( + "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", + type: "TEST" + ) + + ACON::Command::Status::SUCCESS + end), + "style/block_no_prefix_type.txt", + }, + "Block output is properly formatted with even padding lines" => { + (ACON::Commands::Generic::Proc.new do |input, output| + output.decorated = true + ACON::Style::Athena.new(input, output).success( + "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum", + ) + + ACON::Command::Status::SUCCESS + end), + "style/block_padding.txt", + }, + "Handles trailing backslashes" => { + (ACON::Commands::Generic::Proc.new do |input, output| + style = ACON::Style::Athena.new input, output + style.title "Title ending with \\" + style.section "Section ending with \\" + + ACON::Command::Status::SUCCESS + end), + "style/backslashes.txt", + }, + # TODO: Test horizontal table (definition list) + # TODO: Test horizontal table + "Closing tag is only applied once" => { + (ACON::Commands::Generic::Proc.new do |input, output| + output.decorated = true + style = ACON::Style::Athena.new input, output + style.print "do you want something" + style.puts "?" + + ACON::Command::Status::SUCCESS + end), + "style/closing_tag.txt", + }, + # TODO: Enable this test case when multi width char support is added. + # "Emojis don't make the line longer than expected" => { + # (ACON::Commands::Generic::Proc.new do |input, output| + # style = ACON::Style::Athena.new input, output + # style.success "Lorem ipsum dolor sit amet" + # style.success "Lorem ipsum dolor sit amet with one emoji 🎉" + # style.success "Lorem ipsum dolor sit amet with so many of them 👩‍🌾👩‍🌾👩‍🌾👩‍🌾👩‍🌾" + + # ACON::Command::Status::SUCCESS + # end), + # "style/emojis.txt", + # }, + } + end +end diff --git a/spec/terminal_spec.cr b/spec/terminal_spec.cr new file mode 100644 index 0000000..8af6442 --- /dev/null +++ b/spec/terminal_spec.cr @@ -0,0 +1,41 @@ +require "./spec_helper" + +struct TerminalTest < ASPEC::TestCase + @col_size : Int32? + @line_size : Int32? + + def initialize + @col_size = ENV["COLUMNS"]?.try &.to_i? + @line_size = ENV["LINES"]?.try &.to_i? + end + + def tear_down : Nil + ENV.delete "COLUMNS" + ENV.delete "LINES" + end + + def test_height_width : Nil + ENV["COLUMNS"] = "100" + ENV["LINES"] = "50" + + terminal = ACON::Terminal.new + terminal.width.should eq 100 + terminal.height.should eq 50 + + ENV["COLUMNS"] = "120" + ENV["LINES"] = "60" + + terminal = ACON::Terminal.new + terminal.width.should eq 120 + terminal.height.should eq 60 + end + + def test_zero_values : Nil + ENV["COLUMNS"] = "0" + ENV["LINES"] = "0" + + terminal = ACON::Terminal.new + terminal.width.should eq 0 + terminal.height.should eq 0 + end +end diff --git a/src/application.cr b/src/application.cr new file mode 100644 index 0000000..3349a87 --- /dev/null +++ b/src/application.cr @@ -0,0 +1,871 @@ +require "semantic_version" +require "levenshtein" + +# An `ACON::Application` is a container for a collection of multiple `ACON::Command`, and serves as the entry point of a CLI application. +# +# This class is optimized for a standard CLI environment; but it may be subclassed to provide a more specialized/customized entry point. +# +# ## Basic Usage +# +# The console component best works in conjunction with a dedicated Crystal file that'll be used as the entry point. +# Ideally this file is compiled into a dedicated binary for use in production, but is invoked directly while developing. +# Otherwise, any changes made to the files it requires would not be represented. +# The most basic example would be: +# +# ``` +# #!/usr/bin/env crystal +# +# # Require the component and anything extra needed based on your business logic. +# require "athena-console" +# +# # Create an ACON::Application, passing it the name of your CLI. +# # Optionally accepts a second argument representing the version of the CLI. +# application = ACON::Application.new "My CLI" +# +# # Add any commands defined externally, +# # or configure/customize the application as needed. +# +# # Run the application. +# # By default this uses STDIN and STDOUT for its input and output. +# application.run +# ``` +# +# The [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) allows executing the file as a command without needing the `crystal` prefix. +# For example `./console list` would list all commands. +# +# External commands can be registered via `#add`: +# +# ``` +# application.add MyCommand.new +# ``` +# +# The `#register` method may also be used to define simpler/generic commands: +# +# ``` +# application.register "foo" do |input, output| +# # Do stuff here. +# +# # Denote that this command has finished successfully. +# ACON::Command::Status::SUCCESS +# end +# ``` +# +# ## Default Command +# +# The default command represents which command should be executed when no command name is provided; by default this is `ACON::Commands::List`. +# For example, running `./console` would result in all the available commands being listed. +# The default command can be customized via `#default_command`. +# +# ## Single Command Applications +# +# In some cases a CLI may only have one supported command in which passing the command's name each time is tedious. +# In such a case an application may be declared as a single command application via the optional second argument to `#default_command`. +# Passing `true` makes it so that any supplied arguments or options are passed to the default command. +# +# WARNING: Arguments and options passed to the default command are ignored when `#single_command?` is `false`. +# +# ## Custom Applications +# +# `ACON::Application` may also be extended in order to better fit a given application. +# For example, it could define some [global custom styles][Athena::Console::Formatter::OutputStyleInterface--global-custom-styles], +# override the array of default commands, or customize the default input options, etc. +class Athena::Console::Application + # Returns the version of this CLI application. + getter version : SemanticVersion + + # Returns the name of this CLI application. + getter name : String + + # By default, the application will auto [exit](https://crystal-lang.org/api/toplevel.html#exit(status=0):NoReturn-class-method) after executing a command. + # This method can be used to disable that functionality. + # + # If set to `false`, the `ACON::Command::Status` of the executed command is returned from `#run`. + # Otherwise the `#run` method never returns. + # + # ``` + # application = ACON::Application.new "My CLI" + # application.auto_exit = false + # exit_status = application.run + # exit_status # => ACON::Command::Status::SUCCESS + # + # application.auto_exit = true + # exit_status = application.run + # + # # This line is never reached. + # exit_status + # ``` + setter auto_exit : Bool = true + + # By default, the application will gracefully handle exceptions raised as part of the execution of a command + # by formatting and outputting it; including varying levels of information depending on the `ACON::Output::Verbosity` level used. + # + # If set to `false`, that logic is bypassed and the exception is bubbled up to where `#run` was invoked from. + # + # ``` + # application = ACON::Application.new "My CLI" + # + # application.register "foo" do |input, output, command| + # output.puts %(Hello #{input.argument "name"}!) + # + # # Denote that this command has finished successfully. + # ACON::Command::Status::SUCCESS + # end.argument("name", :required) + # + # application.default_command "foo", true + # application.catch_exceptions = false + # + # application.run # => Not enough arguments (missing: 'name'). (Athena::Console::Exceptions::ValidationFailed) + # ``` + setter catch_exceptions : Bool = true + + # Allows setting the `ACON::Loader::Interface` that should be used by `self`. + # See the related interface for more information. + setter command_loader : ACON::Loader::Interface? = nil + + # Returns `true` if `self` only supports a single command. + # See [Single Command Applications][Athena::Console::Application#default_command(name,single_command)--single-command-applications] for more information. + getter? single_command : Bool = false + + # Returns/sets the `ACON::Helper::HelperSet` associated with `self`. + # + # The default helper set includes: + # + # * `ACON::Helper::Formatter` + # * `ACON::Helper::Question` + property helper_set : ACON::Helper::HelperSet { self.default_helper_set } + + @commands = Hash(String, ACON::Command).new + @default_command : String = "list" + @definition : ACON::Input::Definition? = nil + @initialized : Bool = false + @running_command : ACON::Command? = nil + @terminal : ACON::Terminal + @wants_help : Bool = false + + def self.new(name : String, version : String = "0.1.0") : self + new name, SemanticVersion.parse version + end + + def initialize(@name : String, @version : SemanticVersion = SemanticVersion.new(0, 1, 0)) + @terminal = ACON::Terminal.new + + # TODO: Emit events when certain signals are triggered. + # This will require the ability to optional set an event dispatcher on this type. + end + + # Adds the provided *command* instance to `self`, allowing it be executed. + def add(command : ACON::Command) : ACON::Command? + self.init + + command.application = self + + unless command.enabled? + command.application = nil + + return nil + end + + # TODO: Do something about LazyCommands? + + @commands[command.name] = command + + command.aliases.each do |a| + @commands[a] = command + end + + command + end + + # Returns if application should exit automatically after executing a command. + # See `#auto_exit=`. + def auto_exit? : Bool + @auto_exit + end + + # Returns if the application should handle exceptions raised within the execution of a command. + # See `#catch_exceptions=`. + def catch_exceptions? : Bool + @catch_exceptions + end + + # Returns all commands within `self`, optionally only including the ones within the provided *namespace*. + # The keys of the returned hash represent the full command names, while the values are the command instances. + def commands(namespace : String? = nil) : Hash(String, ACON::Command) + self.init + + if namespace.nil? + unless command_loader = @command_loader + return @commands + end + + commands = @commands.dup + command_loader.names.each do |name| + if !commands.has_key?(name) && self.has?(name) + commands[name] = self.get name + end + end + + return commands + end + + commands = Hash(String, ACON::Command).new + @commands.each do |name, command| + if namespace == self.extract_namespace(name, namespace.count(':') + 1) + commands[name] = command + end + end + + if command_loader = @command_loader + command_loader.names.each do |name| + if !commands.has_key?(name) && namespace == self.extract_namespace(name, namespace.count(':') + 1) && self.has?(name) + commands[name] = self.get name + end + end + end + + commands + end + + # Sets the [default command][Athena::Console::Application--default-command] to the command with the provided *name*. + # + # For example, executing the following console script via `./console` + # would result in `Hello world!` being printed instead of the default list output. + # + # ``` + # application = ACON::Application.new "My CLI" + # + # application.register "foo" do |_, output| + # output.puts "Hello world!" + # ACON::Command::Status::SUCCESS + # end + # + # application.default_command "foo" + # + # application.run + # + # ./console # => Hello world! + # ``` + # + # For example, executing the following console script via `./console George` + # would result in `Hello George!` being printed. If we tried this again without setting *single_command* + # to `true`, it would error saying `Command 'George' is not defined. + # + # ``` + # application = ACON::Application.new "My CLI" + # + # application.register "foo" do |input, output, command| + # output.puts %(Hello #{input.argument "name"}!) + # ACON::Command::Status::SUCCESS + # end.argument("name", :required) + # + # application.default_command "foo", true + # + # application.run + # ``` + def default_command(name : String, single_command : Bool = false) : self + @default_command = name + + if single_command + self.find name + + @single_command = true + end + + self + end + + # Returns the `ACON::Input::Definition` associated with `self`. + # See the related type for more information. + def definition : ACON::Input::Definition + @definition ||= self.default_input_definition + + if self.single_command? + input_definition = @definition.not_nil! + input_definition.arguments = Array(ACON::Input::Argument).new + + return input_definition + end + + @definition.not_nil! + end + + # Sets the *definition* that should be used by `self`. + # See the related type for more information. + def definition=(@definition : ACON::Input::Definition) + end + + # Yields each command within `self`, optionally only yields those within the provided *namespace*. + def each_command(namespace : String? = nil, & : ACON::Command -> Nil) : Nil + self.commands(namespace).each_value { |c| yield c } + end + + # Returns the `ACON::Command` with the provided *name*, which can either be the full name, an abbreviation, or an alias. + # This method will attempt to find the best match given an abbreviation of a name or alias. + # + # Raises an `ACON::Exceptions::CommandNotFound` exception when the provided *name* is incorrect or ambiguous. + # + # ameba:disable Metrics/CyclomaticComplexity + def find(name : String) : ACON::Command + self.init + + aliases = Hash(String, String).new + + @commands.each_value do |command| + command.aliases.each do |a| + @commands[a] = command unless self.has? a + end + end + + return self.get name if self.has? name + + all_command_names = if command_loader = @command_loader + command_loader.names + @commands.keys + else + @commands.keys + end + + expression = "#{name.split(':').join("[^:]*:", &->Regex.escape(String))}[^:]*" + commands = all_command_names.select(/^#{expression}/) + + if commands.empty? + commands = all_command_names.select(/^#{expression}/i) + end + + if commands.empty? || commands.select(/^#{expression}$/i).size < 1 + if pos = name.index ':' + # Check if a namespace exists and contains commands + self.find_namespace name[0...pos] + end + + message = "Command '#{name}' is not defined." + + if (alternatives = self.find_alternatives name, all_command_names) && (!alternatives.empty?) + alternatives.select! do |n| + !self.get(n).hidden? + end + + case alternatives.size + when 1 then message += "\n\nDid you mean this?\n " + else message += "\n\nDid you mean one of these?\n " + end + + message += alternatives.join("\n ") + end + + raise ACON::Exceptions::CommandNotFound.new message, alternatives + end + + # Filter out aliases for commands which are already on the list. + if commands.size > 1 + command_list = @commands.dup + + commands.select! do |name_or_alias| + command = if !command_list.has_key?(name_or_alias) + command_list[name_or_alias] = @command_loader.not_nil!.get name_or_alias + else + command_list[name_or_alias] + end + + command_name = command.name + + aliases[name_or_alias] = command_name + + command_name == name_or_alias || !commands.includes? command_name + end.uniq! + + usable_width = @terminal.width - 10 + max_len = commands.max_of &->ACON::Helper.width(String) + abbreviations = commands.map do |n| + if command_list[n].hidden? + commands.delete n + + next nil + end + + abbreviation = "#{n.rjust max_len, ' '} #{command_list[n].description}" + + ACON::Helper.width(abbreviation) > usable_width ? "#{abbreviation[0, usable_width - 3]}..." : abbreviation + end + + if commands.size > 1 + suggestions = self.abbreviation_suggestions abbreviations.compact + + raise ACON::Exceptions::CommandNotFound.new "Command '#{name}' is ambiguous.\nDid you mean one of these?\n#{suggestions}", commands + end + end + + command = self.get commands.first + + raise ACON::Exceptions::CommandNotFound.new "The command '#{name}' does not exist." if command.hidden? + + command + end + + # Returns the full name of a registered namespace with the provided *name*, which can either be the full name or an abbreviation. + # + # Raises an `ACON::Exceptions::NamespaceNotFound` exception when the provided *name* is incorrect or ambiguous. + def find_namespace(name : String) : String + all_namespace_names = self.namespaces + + expression = "#{name.split(':').join("[^:]*:", &->Regex.escape(String))}[^:]*" + namespaces = all_namespace_names.select(/^#{expression}/) + + if namespaces.empty? + message = "There are no commands defined in the '#{name}' namespace." + + if (alternatives = self.find_alternatives name, all_namespace_names) && (!alternatives.empty?) + case alternatives.size + when 1 then message += "\n\nDid you mean this?\n " + else message += "\n\nDid you mean one of these?\n " + end + + message += alternatives.join("\n ") + end + + raise ACON::Exceptions::NamespaceNotFound.new message, alternatives + end + + exact = namespaces.includes? name + + if namespaces.size > 1 && !exact + raise ACON::Exceptions::NamespaceNotFound.new "The namespace '#{name}' is ambiguous.\nDid you mean one of these?\n#{self.abbreviation_suggestions namespaces}", namespaces + end + + exact ? name : namespaces.first + end + + # Returns the `ACON::Command` with the provided *name*. + # + # Raises an `ACON::Exceptions::CommandNotFound` exception when a command with the provided *name* does not exist. + def get(name : String) : ACON::Command + self.init + + raise ACON::Exceptions::CommandNotFound.new "The command '#{name}' does not exist." unless self.has? name + + if !@commands.has_key? name + raise ACON::Exceptions::CommandNotFound.new "The '#{name}' command cannot be found because it is registered under multiple names. Make sure you don't set a different name via constructor or 'name='." + end + + command = @commands[name] + + if @wants_help + @wants_help = false + + help_command = self.get "help" + help_command.as(ACON::Commands::Help).command = command + + return help_command + end + + command + end + + # Returns `true` if a command with the provided *name* exists, otherwise `false`. + def has?(name : String) : Bool + self.init + + return true if @commands.has_key? name + + if (command_loader = @command_loader) && command_loader.has? name + self.add command_loader.get name + + true + else + false + end + end + + # By default this is the same as `#long_version`, but can be overridden + # to provide more in-depth help/usage instructions for `self`. + def help : String + self.long_version + end + + # Returns all unique namespaces used by currently registered commands, + # excluding the global namespace. + def namespaces : Array(String) + namespaces = [] of String + + self.commands.each_value do |command| + next if command.hidden? + + namespaces.concat self.extract_all_namespaces command.name.not_nil! + + command.aliases.each do |a| + namespaces.concat self.extract_all_namespaces a + end + end + + namespaces.reject!(&.blank?).uniq! + end + + # Runs the current application, optionally with the provided *input* and *output*. + # + # Returns the `ACON::Command::Status` of the related command execution if `#auto_exit?` is `false`. + # Will gracefully handle exceptions raised within the command execution unless `#catch_exceptions?` is `false`. + def run(input : ACON::Input::Interface = ACON::Input::ARGV.new, output : ACON::Output::Interface = ACON::Output::ConsoleOutput.new) : ACON::Command::Status | NoReturn + ENV["LINES"] = @terminal.height.to_s + ENV["COLUMNS"] = @terminal.width.to_s + + self.configure_io input, output + + begin + exit_status = self.do_run input, output + rescue ex : ::Exception + raise ex unless @catch_exceptions + + self.render_exception ex, output + + exit_status = if ex.is_a? ACON::Exceptions::ConsoleException + ACON::Command::Status.new ex.code + else + ACON::Command::Status::FAILURE + end + end + + if @auto_exit + exit exit_status.value + end + + exit_status + end + + # Creates and `#add`s an `ACON::Command` with the provided *name*; executing the block when the command is invoked. + def register(name : String, &block : ACON::Input::Interface, ACON::Output::Interface, ACON::Command -> ACON::Command::Status) : ACON::Command + self.add(ACON::Commands::Generic.new(name, &block)).not_nil! + end + + # Returns the `#name` and `#version` of the application. + # Used when the `-V` or `--version` option is passed. + def long_version : String + "#{@name} #{@version}" + end + + protected def command_name(input : ACON::Input::Interface) : String? + @single_command ? @default_command : input.first_argument + end + + # ameba:disable Metrics/CyclomaticComplexity + protected def configure_io(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil + if input.has_parameter? "--ansi", only_params: true + output.decorated = true + elsif input.has_parameter? "--no-ansi", only_params: true + output.decorated = false + end + + if input.has_parameter? "--no-interaction", "-n", only_params: true + input.interactive = false + end + + case shell_verbosity = ENV["SHELL_VERBOSITY"]?.try &.to_i + when -1 then output.verbosity = :quiet + when 1 then output.verbosity = :verbose + when 2 then output.verbosity = :very_verbose + when 3 then output.verbosity = :debug + else + shell_verbosity = 0 + end + + if input.has_parameter? "--quiet", "-q", only_params: true + output.verbosity = :quiet + shell_verbosity = -1 + else + if input.has_parameter?("-vvv", "--verbose=3", only_params: true) || 3 == input.parameter("--verbose", false, true) + output.verbosity = :debug + shell_verbosity = 3 + elsif input.has_parameter?("-vv", "--verbose=2", only_params: true) || 2 == input.parameter("--verbose", false, true) + output.verbosity = :very_verbose + shell_verbosity = 2 + elsif input.has_parameter?("-v", "--verbose=1", only_params: true) || input.has_parameter?("--verbose") || input.parameter("--verbose", false, true) + output.verbosity = :verbose + shell_verbosity = 1 + end + end + + if -1 == shell_verbosity + input.interactive = false + end + + ENV["SHELL_VERBOSITY"] = shell_verbosity.to_s + end + + # ameba:disable Metrics/CyclomaticComplexity + protected def do_run(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + if input.has_parameter? "--version", "-V", only_params: true + output.puts self.long_version + + return ACON::Command::Status::SUCCESS + end + + input.bind self.definition rescue nil + + command_name = self.command_name input + + if input.has_parameter? "--help", "-h", only_params: true + if command_name.nil? + command_name = "help" + input = ACON::Input::Hash.new(command_name: @default_command) + else + @wants_help = true + end + end + + if command_name.nil? + command_name = @default_command + definition = self.definition + definition.arguments.merge!({ + "command" => ACON::Input::Argument.new("command", :optional, definition.argument("command").description, command_name), + }) + end + + begin + @running_command = nil + + command = self.find command_name + rescue ex : Exception + if !(ex.is_a?(ACON::Exceptions::CommandNotFound) && !ex.is_a?(ACON::Exceptions::NamespaceNotFound)) || + 1 != (alternatives = ex.alternatives).size || + !input.interactive? + # TODO: Handle dispatching + + raise ex + end + + alternative = alternatives.not_nil!.first + + style = ACON::Style::Athena.new input, output + + style.block "\nCommand '#{command_name}' is not defined.\n", style: "error" + + unless style.confirm "Do you want to run '#{alternative}' instead?", false + # TODO: Handle dispatching + + return ACON::Command::Status::FAILURE + end + + command = self.find alternative + end + + @running_command = command + exit_status = self.do_run_command command, input, output + @running_command = nil + + exit_status + end + + protected def do_run_command(command : ACON::Command, input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + # TODO: Support input aware helpers. + # TODO: Handle registering signable command listeners. + + command.run input, output + + # TODO: Handle eventing. + end + + protected def default_input_definition : ACON::Input::Definition + ACON::Input::Definition.new( + ACON::Input::Argument.new("command", :required, "The command to execute"), + ACON::Input::Option.new("help", "h", description: "Display help for the given command. When no command is given display help for the #{@default_command} command"), + ACON::Input::Option.new("quiet", "q", description: "Do not output any message"), + ACON::Input::Option.new("verbose", "v|vv|vvv", description: "Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug"), + ACON::Input::Option.new("version", "V", description: "Display this application version"), + ACON::Input::Option.new("ansi", value_mode: :negatable, description: "Force (or disable --no-ansi) ANSI output", default: false), + ACON::Input::Option.new("no-interaction", "n", description: "Do not ask any interactive question"), + ) + end + + protected def default_commands : Array(ACON::Command) + [ + Athena::Console::Commands::List.new, + Athena::Console::Commands::Help.new, + ] + end + + protected def default_helper_set : ACON::Helper::HelperSet + ACON::Helper::HelperSet.new( + ACON::Helper::Formatter.new, + ACON::Helper::Question.new + ) + end + + # ameba:disable Metrics/CyclomaticComplexity + protected def do_render_exception(ex : Exception, output : ACON::Output::Interface) : Nil + loop do + message = (ex.message || "").strip + + if message.empty? || ACON::Output::Verbosity::VERBOSE <= output.verbosity + title = " [#{ex.class}] " + len = ACON::Helper.width title + else + len = 0 + title = "" + end + + width = @terminal.width ? @terminal.width - 1 : Int32::MAX + lines = [] of Tuple(String, Int32) + + message.split(/(?:\r?\n)/) do |line| + self.split_string_by_width(line, width - 4) do |l| + line_length = ACON::Helper.width(l) + 4 + lines << {l, line_length} + + len = Math.max line_length, len + end + end + + messages = [] of String + + if !ex.is_a?(ACON::Exceptions::ConsoleException) || ACON::Output::Verbosity::VERBOSE <= output.verbosity + if trace = ex.backtrace?.try &.first + filename = nil + line = nil + + if match = trace.match(/(\w+\.cr):(\d+)/) + filename = if f = match[1]? + File.basename f + end + line = match[2]? + end + + messages << %(#{ACON::Formatter::Output.escape "In #{filename || "n/a"} line #{line || "n/a"}:"}) + end + end + + messages << (empty_line = "#{" "*len}") + + if messages.empty? || ACON::Output::Verbosity::VERBOSE <= output.verbosity + messages << "#{title}#{" "*(Math.max(0, len - ACON::Helper.width(title)))}" + end + + lines.each do |l| + messages << " #{ACON::Formatter::Output.escape l[0]} #{" "*(len - l[1])}" + end + + messages << empty_line + messages << "" + + messages.each do |m| + output.puts m, :quiet + end + + if (ACON::Output::Verbosity::VERBOSE <= output.verbosity) && (t = ex.backtrace?) + output.puts "Exception trace:", :quiet + + # TODO: Improve backtrace rendering. + t.each do |l| + output.puts " #{l}" + end + + output.puts "", :quiet + end + + break unless (ex = ex.cause) + end + end + + protected def extract_namespace(name : String, limit : Int32? = nil) : String + # Pop off the shortcut name of the command. + parts = name.split(':').tap &.pop + + (limit.nil? ? parts : parts[0...limit]).join ':' + end + + protected def render_exception(ex : Exception, output : ACON::Output::ConsoleOutputInterface) : Nil + self.render_exception ex, output.error_output + end + + protected def render_exception(ex : Exception, output : ACON::Output::Interface) : Nil + output.puts "", :quiet + + self.do_render_exception ex, output + + if running_command = @running_command + output.puts "#{ACON::Formatter::Output.escape running_command.synopsis}", :quiet + output.puts "", :quiet + end + end + + private def abbreviation_suggestions(abbreviations : Array(String)) : String + %( #{abbreviations.join("\n ")}) + end + + private def extract_all_namespaces(name : String) : Array(String) + # Pop off the shortcut name of the command. + parts = name.split(':').tap &.pop + + namespaces = [] of String + + parts.each do |p| + namespaces << if namespaces.empty? + p + else + "#{namespaces.last}:#{p}" + end + end + + namespaces + end + + # ameba:disable Metrics/CyclomaticComplexity + private def find_alternatives(name : String, collection : Enumerable(String)) : Array(String) + alternatives = Hash(String, Int32).new + threshold = 1_000 + + collection_parts = Hash(String, Array(String)).new + collection.each do |item| + collection_parts[item] = item.split ':' + end + + name.split(':').each_with_index do |sub_name, idx| + collection_parts.each do |collection_name, parts| + exists = alternatives.has_key? collection_name + + if exists && parts[idx]?.nil? + alternatives[collection_name] += threshold + next + elsif parts[idx]?.nil? + next + end + + lev = Levenshtein.distance sub_name, parts[idx] + + if lev <= sub_name.size / 3 || !sub_name.empty? && parts[idx].includes? sub_name + alternatives[collection_name] = exists ? alternatives[collection_name] + lev : lev + elsif exists + alternatives[collection_name] += threshold + end + end + end + + collection.each do |item| + lev = Levenshtein.distance name, item + if lev <= name.size / 3 || item.includes? name + alternatives[item] = (current = alternatives[item]?) ? current - lev : lev + end + end + + alternatives.select! { |_, lev| lev < 2 * threshold } + + alternatives.keys.sort! + end + + private def init : Nil + return if @initialized + + @initialized = true + + self.default_commands.each do |command| + self.add command + end + end + + private def split_string_by_width(line : String, width : Int32, & : String -> Nil) : Nil + if line.empty? + return yield line + end + + line.each_char.each_slice(width).map(&.join).each do |set| + yield set + end + end +end diff --git a/src/athena-console.cr b/src/athena-console.cr index c3d1a4b..c5296ed 100644 --- a/src/athena-console.cr +++ b/src/athena-console.cr @@ -1,6 +1,84 @@ +require "./application" +require "./command" +require "./cursor" +require "./terminal" + +require "./commands/*" +require "./descriptor/*" +require "./exceptions/*" +require "./formatter/*" +require "./helper/*" +require "./input/*" +require "./loader/*" +require "./output/*" +require "./question/*" +require "./style/*" + # Convenience alias to make referencing `Athena::Console` types easier. alias ACON = Athena::Console +# Athena's Console component, `ACON` for short, allows for the creation of command-line based `ACON::Command`s. +# These commands could be used for any reoccurring task such as cron jobs, imports, etc. +# All commands belong to an `ACON::Application`, that can be extended to better fit a specific project's needs. +# +# `Athena::Console` also provides various utility/helper features, including: +# +# * Asking `ACON::Question`s +# * Reusable output [styles][Athena::Console::Formatter::OutputStyleInterface] +# * High level reusable formatting [styles][Athena::Console::Style::Interface] +# * [Testing abstractions][Athena::Console::Spec] +# +# The console component best works in conjunction with a dedicated Crystal file that'll be used as the entry point. +# Ideally this file is compiled into a dedicated binary for use in production, but is invoked directly while developing. +# Otherwise, any changes made to the files it requires would not be represented. +# The most basic example would be: +# +# ``` +# #!/usr/bin/env crystal +# +# # Require the component and anything extra needed based on your business logic. +# require "athena-console" +# +# # Create an ACON::Application, passing it the name of your CLI. +# # Optionally accepts a second argument representing the version of the CLI. +# application = ACON::Application.new "My CLI" +# +# # Add any commands defined externally, +# # or configure/customize the application as needed. +# +# # Run the application. +# # By default this uses STDIN and STDOUT for its input and output. +# application.run +# ``` +# +# The [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) allows executing the file as a command without needing the `crystal` prefix. +# For example `./console list` would list all commands. +# +# External commands can be registered via `ACON::Application#add`: +# +# ``` +# application.add MyCommand.new +# ``` +# +# The `ACON::Application#register` method may also be used to define simpler/generic commands: +# +# ``` +# application.register "foo" do |input, output| +# # Do stuff here. +# +# # Denote that this command has finished successfully. +# ACON::Command::Status::SUCCESS +# end +# ``` module Athena::Console VERSION = "0.1.0" + + # Includes the commands that come bundled with `Athena::Console`. + module Commands; end + + # Contains all custom exceptions defined within `Athena::Console`. + module Exceptions; end + + # Contains types realted to lazily loading commands. + module Loader; end end diff --git a/src/command.cr b/src/command.cr new file mode 100644 index 0000000..30c69d7 --- /dev/null +++ b/src/command.cr @@ -0,0 +1,459 @@ +# An `ACON::Command` represents a concrete command that can be invoked via the CLI. +# All commands should inherit from this base type, but additional abstract subclasses can be used +# to share common logic for related command classes. +# +# ## Creating a Command +# +# A command is defined by extending `ACON::Command` and implementing the `#execute` method. +# For example: +# +# ``` +# class CreateUserCommand < ACON::Command +# @@default_name = "app:create-user" +# +# protected def configure : Nil +# # ... +# end +# +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# # Implement all the business logic here. +# +# # Indicates the command executed successfully. +# ACON::Command::Status::SUCCESS +# end +# end +# ``` +# +# ### Command Lifecycle +# +# Commands have three lifecycle methods that are invoked when running the command: +# +# 1. `setup` (optional) - Executed before `#interact` and `#execute`. Can be used to setup state based on input data. +# 1. `interact` (optional) - Executed after `#setup` but before `#execute`. Can be used to check if any arguments/options are missing +# and interactively ask the user for those values. After this method, missing arguments/options will result in an error. +# 1. `execute` (required) - Contains the business logic for the command, returning the status of the invocation via `ACON::Command::Status`. +# +# ``` +# class CreateUserCommand < ACON::Command +# @@default_name = "app:create-user" +# +# protected def configure : Nil +# # ... +# end +# +# protected def setup(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil +# # ... +# end +# +# protected def interact(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil +# # ... +# end +# +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# # Indicates the command executed successfully. +# ACON::Command::Status::SUCCESS +# end +# end +# ``` +# +# ## Configuring the Command +# +# In most cases, a command is going to need to be configured to better fit its purpose. +# The `#configure` method can be used configure various aspects of the command, +# such as its name, description, `ACON::Input`s, help message, aliases, etc. +# +# ``` +# protected def configure : Nil +# self +# .help("Creates a user...") # Shown when running the command with the `--help` option +# .aliases("new-user") # Alternate names for the command +# .hidden # Hide the command from the list +# # ... +# end +# ``` +# +# INFO: The name and description can also be set via `@@default_name` and `@@default_description` class variables, +# which is the preferred way of setting them. +# +# The `#configure` command is called automatically at the end of the constructor method. +# If your command defines its own, be sure to call `super()` to also run the parent constructor. +# `super` may also be called _after_ setting the properties if they should be used to determine how to configure the command. +# +# ``` +# class CreateUserCommand < ACON::Command +# def initialize(@require_password : Bool = false) +# super() +# end +# +# protected def configure : Nil +# self +# .argument("password", @require_password ? ACON::Input::Argument::Mode::REQUIRED : ACON::Input::Argument::Mode::OPTIONAL) +# end +# end +# ``` +# +# ### Output +# +# The `#execute` method has access to an `ACON::Output::Interface` instance that can be used to write messages to display. +# The `output` parameter should be used instead of `#puts` or `#print` to decouple the command from `STDOUT`. +# +# ``` +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# # outputs multiple lines to the console (adding "\n" at the end of each line) +# output.puts([ +# "User Creator", +# "============", +# "", +# ]) +# +# # outputs a message followed by a "\n" +# output.puts "Whoa!" +# +# # outputs a message without adding a "\n" at the end of the line +# output.print "You are about to " +# output.print "create a user." +# +# ACON::Command::Status::SUCCESS +# end +# ``` +# +# See `ACON::Output::Interface` for more information. +# +# ### Input +# +# In most cases, a command is going to have some sort of input arguments/options. +# These inputs can be setup in the `#configure` method, and accessed via the *input* parameter within `#execute`. +# +# ``` +# protected def configure : Nil +# self +# .argument("username", :required, "The username of the user") +# end +# +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# # Retrieve the username as a String? +# output.puts %(Hello #{input.argument "username"}!) +# +# ACON::Command::Status::SUCCESS +# end +# ``` +# +# See `ACON::Input::Interface` for more information. +# +# ## Testing the Command +# +# `Athena::Console` also includes a way to test your console commands without needing to build and run a binary. +# A single command can be tested via an `ACON::Spec::CommandTester` and a whole application can be tested via an `ACON::Spec::ApplicationTester`. +# +# See `ACON::Spec` for more information. +abstract class Athena::Console::Command + # Represents the execution status of an `ACON::Command`. + # + # The value of each member is used as the exit code of the invocation. + enum Status + # Represents a successful invocation with no errors. + SUCCESS = 0 + + # Represents that some error happened during invocation. + FAILURE = 1 + + # Represents the command was not used correctly, such as invalid options or missing arguments. + INVALID = 2 + end + + private enum Synopsis + SHORT + LONG + end + + # Returns the default name of `self`, or `nil` if it was not set. + class_getter default_name : String? = nil + + # Returns the default description of `self`, or `nil` if it was not set. + class_getter default_description : String? = nil + + # Returns the name of `self`. + getter! name : String + + # Returns the `description of `self`. + getter description : String = "" + + # Returns/sets the help template for `self`. + # + # See `#processed_help`. + property help : String = "" + + # Returns the `ACON::Application` associated with `self`, otherwise `nil`. + getter! application : ACON::Application + + # Returns/sets the list of aliases that may also be used to execute `self` in addition to its `#name`. + property aliases : Array(String) = [] of String + + # Sets the process title of `self`. + # + # TODO: Implement this. + setter process_title : String? = nil + + # Returns/sets an `ACON::Helper::HelperSet` on `self`. + property helper_set : ACON::Helper::HelperSet? = nil + + # Returns `true` if `self` is hidden from the command list, otherwise `false`. + getter? hidden : Bool = false + + # Returns if `self` is enabled in the current environment. + # + # Can be overridden to return `false` if it cannot run under the current conditions. + getter? enabled : Bool = true + + # Returns the list of usages for `self`. + # + # See `#usage`. + getter usages : Array(String) = [] of String + + @definition : ACON::Input::Definition = ACON::Input::Definition.new + @full_definition : ACON::Input::Definition? = nil + @ignore_validation_errors : Bool = false + @synopsis = Hash(Synopsis, String).new + + def initialize(name : String? = nil) + if n = (name || self.class.default_name) + self.name n + end + + if (@description.empty?) && (description = self.class.default_description) + self.description description + end + + self.configure + end + + # Sets the aliases of `self`. + def aliases(*aliases : String) : self + self.aliases aliases.to_a + end + + # :ditto: + def aliases(aliases : Enumerable(String)) : self + aliases.each &->validate_name(String) + + @aliases = aliases + + self + end + + def application=(@application : ACON::Application? = nil) : Nil + if application = @application + @helper_set = application.helper_set + else + @helper_set = nil + end + + @full_definition = nil + end + + # Adds an `ACON::Input::Argument` to `self` with the provided *name*. + # Optionally supports setting its *mode*, *description*, and *default* value. + def argument(name : String, mode : ACON::Input::Argument::Mode = :optional, description : String = "", default = nil) : self + @definition << ACON::Input::Argument.new name, mode, description, default + + if full_definition = @full_definition + full_definition << ACON::Input::Argument.new name, mode, description, default + end + + self + end + + def definition : ACON::Input::Definition + @full_definition || self.native_definition + end + + # Sets the `ACON::Input::Definition` on self. + def definition(@definition : ACON::Input::Definition) : self + @full_definition = nil + + self + end + + # :ditto: + def definition(*definitions : ACON::Input::Argument | ACON::Input::Option) : self + self.definition definitions.to_a + end + + # :ditto: + def definition(definition : Array(ACON::Input::Argument | ACON::Input::Option)) : self + @definition.definition = definition + + @full_definition = nil + + self + end + + # Sets the `#description` of `self`. + def description(@description : String) : self + self + end + + def name(name : String) : self + self.validate_name name + + @name = name + + self + end + + # Sets the `#help` of `self`. + def help(@help : String) : self + self + end + + # Returns an `ACON:Helper::Interface` of the provided *helper_class*. + # + # ``` + # formatter = self.helper ACON::Helper::Formatter + # # ... + # ``` + def helper(helper_class : T.class) : T forall T + unless helper_set = @helper_set + raise ACON::Exceptions::Logic.new "Cannot retrieve helper '#{helper_class}' because there is no `ACON::Helper::HelperSet` defined. Did you forget to add your command to the application or to set the application on the command using '#application='? You can also set the HelperSet directly using '#helper_set='." + end + + helper_set[helper_class].as T + end + + # Hides `self` from the command list. + def hidden(@hidden : Bool = true) : self + self + end + + # Adds an `ACON::Input::Option` to `self` with the provided *name*. + # Optionally supports setting its *shortcut*, *value_mode*, *description*, and *default* value. + def option(name : String, shotcut : String? = nil, value_mode : ACON::Input::Option::Value = :none, description : String = "", default = nil) : self + @definition << ACON::Input::Option.new name, shotcut, value_mode, description, default + + if full_definition = @full_definition + full_definition << ACON::Input::Option.new name, shotcut, value_mode, description, default + end + + self + end + + # The `#help` message can include some template variables for the command: + # + # * `%command.name%` - Returns the `#name` of `self`. E.g. `app:create-user` + # + # This method returns the `#help` message with these variables replaced. + def processed_help : String + is_single_command = (application = @application) && application.single_command? + prog_name = Path.new(PROGRAM_NAME).basename + full_name = is_single_command ? prog_name : "#{prog_name} #{@name}" + + processed_help = self.help.presence || self.description + + { {"%command.name%", @name}, {"%command.full_name%", full_name} }.each do |(placeholder, replacement)| + processed_help = processed_help.gsub placeholder, replacement + end + + processed_help + end + + # Returns a short synopsis of `self`, including its `#name` and expected arguments/options. + # For example `app:user-create [--dry-run] [--] `. + def synopsis(short : Bool = false) : String + key = short ? Synopsis::SHORT : Synopsis::LONG + + unless @synopsis.has_key? key + @synopsis[key] = "#{@name} #{@definition.synopsis short}".strip + end + + @synopsis[key] + end + + # Adds a usage string that will displayed within the `Usage` section after the auto generated entry. + def usage(usage : String) : self + unless (name = @name) && usage.starts_with? name + usage = "#{name} #{usage}" + end + + @usages << usage + + self + end + + # Makes the command ignore any input validation errors. + def ignore_validation_errors : Nil + @ignore_validation_errors = true + end + + # Runs the command with the provided *input* and *output*, returning the status of the invocation as an `ACON::Command::Status`. + def run(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + self.merge_application_definition + + begin + input.bind self.definition + rescue ex : ACON::Exceptions::ConsoleException + raise ex unless @ignore_validation_errors + end + + self.setup input, output + + # TODO: Allow setting process title + + if input.interactive? + self.interact input, output + end + + if input.has_argument?("command") && input.argument("command").nil? + input.set_argument "command", self.name + end + + input.validate + + self.execute input, output + end + + protected def merge_application_definition(merge_args : Bool = true) : Nil + return unless (application = @application) + + # TODO: Figure out if there is a better way to structure/store + # the data to remove the .values call. + full_definition = ACON::Input::Definition.new + full_definition.options = @definition.options.values + full_definition << application.definition.options.values + + if merge_args + full_definition.arguments = application.definition.arguments.values + full_definition << @definition.arguments.values + else + full_definition.arguments = @definition.arguments.values + end + + @full_definition = full_definition + end + + protected def native_definition + @definition + end + + # Executes the command with the provided *input* and *output*, returning the status of the invocation via `ACON::Command::Status`. + # + # This method _MUST_ be defined and implement the business logic for the command. + protected abstract def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + + # Can be overridden to configure the current command, such as setting the name, adding arguments/options, setting help information etc. + protected def configure : Nil + end + + # The related `ACON::Input::Definition` is validated _after_ this method is executed. + # This method can be used to interactively ask the user for missing required arguments. + protected def interact(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil + end + + # Called after the input has been bound, but before it has been validated. + # Can be used to setup state of the command based on the provided input data. + protected def setup(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil + end + + private def validate_name(name : String) : Nil + raise ACON::Exceptions::InvalidArgument.new "Command name '#{name}' is invalid." if name.blank? || !name.matches? /^[^:]++(:[^:]++)*$/ + end +end diff --git a/src/commands/generic.cr b/src/commands/generic.cr new file mode 100644 index 0000000..a362a52 --- /dev/null +++ b/src/commands/generic.cr @@ -0,0 +1,14 @@ +# A generic implementation of `ACON::Command` that is instantiated with a block that will be executed as part of the `#execute` method. +# +# This is the command class used as part of `ACON::Application#register`. +class Athena::Console::Commands::Generic < Athena::Console::Command + alias Proc = ::Proc(ACON::Input::Interface, ACON::Output::Interface, ACON::Command, ACON::Command::Status) + + def initialize(name : String, &@callback : ACON::Commands::Generic::Proc) + super name + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + @callback.call input, output, self + end +end diff --git a/src/commands/help.cr b/src/commands/help.cr new file mode 100644 index 0000000..4fb2d5d --- /dev/null +++ b/src/commands/help.cr @@ -0,0 +1,46 @@ +# Displays information for a given command. +class Athena::Console::Commands::Help < Athena::Console::Command + # :nodoc: + setter command : ACON::Command? = nil + + protected def configure : Nil + self.ignore_validation_errors + + self + .name("help") + .definition( + ACON::Input::Argument.new("command_name", :optional, "The command name", "help"), + ACON::Input::Option.new("format", nil, :required, "The output format (txt)", "txt"), + ACON::Input::Option.new("raw", nil, :none, "To output raw command help"), + ) + .description("Display help for a command") + .help( + <<-HELP + The %command.name% command displays help for a given command: + + %command.full_name% list + + To display the list of available commands, please use the list command. + HELP + ) + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + if @command.nil? + @command = self.application.find input.argument("command_name", String) + end + + ACON::Helper::Descriptor.new.describe( + output, + @command.not_nil!, + ACON::Descriptor::Context.new( + format: input.option("format", String), + raw_text: input.option("raw", Bool), + ) + ) + + @command = nil + + ACON::Command::Status::SUCCESS + end +end diff --git a/src/commands/list.cr b/src/commands/list.cr new file mode 100644 index 0000000..97b51d0 --- /dev/null +++ b/src/commands/list.cr @@ -0,0 +1,44 @@ +# Lists the available commands, optionally only including those in a specific namespace. +class Athena::Console::Commands::List < Athena::Console::Command + protected def configure : Nil + self + .name("list") + .description("List commands") + .definition( + ACON::Input::Argument.new("namespace", :optional, "Only list commands in this namespace"), + ACON::Input::Option.new("raw", nil, :none, "To output raw command list"), + ACON::Input::Option.new("format", nil, :required, "The output format (txt)", "txt"), + ACON::Input::Option.new("short", nil, :none, "To skip describing command's arguments"), + ) + .help( + <<-HELP + The %command.name% command lists all commands: + + %command.full_name% + + You can also display the commands for a specific namespace: + + %command.full_name% test + + It's also possible to get raw list of commands (useful for embedding command runner): + + %command.full_name% --raw + HELP + ) + end + + protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + ACON::Helper::Descriptor.new.describe( + output, + self.application, + ACON::Descriptor::Context.new( + format: input.option("format", String), + raw_text: input.option("raw", Bool), + namespace: input.argument("namespace", String?), + short: input.option("short", Bool) + ) + ) + + ACON::Command::Status::SUCCESS + end +end diff --git a/src/cursor.cr b/src/cursor.cr new file mode 100644 index 0000000..89bfe30 --- /dev/null +++ b/src/cursor.cr @@ -0,0 +1,147 @@ +# Provides an OO way to interact with the console window, +# allows writing on any position of the output. +# +# ``` +# class CursorCommand < ACON::Command +# @@default_name = "cursor" +# +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# cursor = ACON::Cursor.new output +# +# # Move the cursor to a specific column, row position. +# cursor.move_to_position 50, 3 +# +# # Write text at that location. +# output.puts "Hello!" +# +# # Clear the current line. +# cursor.clear_line +# +# ACON::Command::Status::SUCCESS +# end +# end +# ``` +struct Athena::Console::Cursor + @output : ACON::Output::Interface + @input : IO + + def initialize(@output : ACON::Output::Interface, input : IO? = nil) + @input = input || STDIN + end + + # Moves the cursor up *lines* lines. + def move_up(lines : Int32 = 1) : self + @output.print "\x1b[#{lines}A" + + self + end + + # Moves the cursor down *lines* lines. + def move_down(lines : Int32 = 1) : self + @output.print "\x1b[#{lines}B" + + self + end + + # Moves the cursor right *lines* lines. + def move_right(lines : Int32 = 1) : self + @output.print "\x1b[#{lines}C" + + self + end + + # Moves the cursor left *lines* lines. + def move_left(lines : Int32 = 1) : self + @output.print "\x1b[#{lines}D" + + self + end + + # Moves the cursor to the provided *column*. + def move_to_column(column : Int32) : self + @output.print "\x1b[#{column}G" + + self + end + + # Moves the cursor to the provided *column*, *row* position. + def move_to_position(column : Int32, row : Int32) : self + @output.print "\x1b[#{row + 1};#{column}H" + + self + end + + # Saves the current position such that it could be restored via `#restore_position`. + def save_position : self + @output.print "\x1b7" + + self + end + + # Restores the position set via `#save_position`. + def restore_position : self + @output.print "\x1b8" + + self + end + + # Hides the cursor. + def hide : self + @output.print "\x1b[?25l" + + self + end + + # Shows the cursor. + def show : self + @output.print "\x1b[?25h\x1b[?0c" + + self + end + + # Clears the current line. + def clear_line : self + @output.print "\x1b[2K" + + self + end + + # Clears the current line after the cursor's current position. + def clear_line_after : self + @output.print "\x1b[K" + + self + end + + # Clears the output from the cursors' current position to the end of the screen. + def clear_output : self + @output.print "\x1b[0J" + + self + end + + # Clears the entire screen. + def clear_screen : self + @output.print "\x1b[2J" + + self + end + + # Returns the current column, row position of the cursor. + def current_position : {Int32, Int32} + return {1, 1} unless @input.tty? + + stty_mode = `stty -g` + system "stty -icanon -echo" + + @input.print "\033[6n" + + bytes = @input.peek + + system "stty #{stty_mode}" + + String.new(bytes.not_nil!).match /\e\[(\d+);(\d+)R/ + + {$2.to_i, $1.to_i} + end +end diff --git a/src/descriptor/application.cr b/src/descriptor/application.cr new file mode 100644 index 0000000..9a41586 --- /dev/null +++ b/src/descriptor/application.cr @@ -0,0 +1,112 @@ +abstract class Athena::Console::Descriptor; end + +# :nodoc: +record Athena::Console::Descriptor::Application, application : ACON::Application, namespace : String? = nil, show_hidden : Bool = false do + GLOBAL_NAMESPACE = "_global" + + @commands : Hash(String, ACON::Command)? = nil + @namespaces : Hash(String, NamedTuple(id: String, commands: Array(String)))? = nil + @aliases : Hash(String, ACON::Command)? = nil + + def commands : Hash(String, ACON::Command) + if @commands.nil? + self.inspect_application + end + + @commands.not_nil! + end + + def command(name : String) : ACON::Command + if !@commands.not_nil!.has_key?(name) && !@aliases.not_nil!.has_key?(name) + raise ACON::Exceptions::CommandNotFound.new "Command '#{name}' does not exist." + end + + @commands.not_nil![name]? || @aliases.not_nil![name] + end + + def namespaces : Hash(String, NamedTuple(id: String, commands: Array(String))) + if @namespaces.nil? + self.inspect_application + end + + @namespaces.not_nil! + end + + private def inspect_application : Nil + commands = Hash(String, ACON::Command).new + namespaces = Hash(String, NamedTuple(id: String, commands: Array(String))).new + aliases = Hash(String, ACON::Command).new + + all_commands = @application.commands ((namespace = @namespace) ? @application.find_namespace(namespace) : nil) + + self.sort_commands(all_commands).each do |namespace, command_hash| + names = Array(String).new + + command_hash.each do |name, command| + next if command.name.nil? || (!@show_hidden && command.hidden?) + + if name == command.name + commands[name] = command + else + aliases[name] = command + end + + names << name + end + + namespaces[namespace] = {id: namespace, commands: names} + end + + @commands = commands + @namespaces = namespaces + @aliases = aliases + end + + private def sort_commands(commands : Hash(String, ACON::Command)) : Hash(String, Hash(String, ACON::Command)) + namespaced_commands = Hash(String, Hash(String, ACON::Command)).new + global_commands = Hash(String, ACON::Command).new + sorted_commands = Hash(String, Hash(String, ACON::Command)).new + + commands.each do |name, command| + key = @application.extract_namespace name, 1 + if key.in? "", GLOBAL_NAMESPACE + global_commands[name] = command + else + (namespaced_commands[key] ||= Hash(String, ACON::Command).new)[name] = command + end + end + + unless global_commands.empty? + sorted_commands[GLOBAL_NAMESPACE] = self.sort_hash global_commands + end + + unless namespaced_commands.empty? + namespaced_commands = self.sort_hash namespaced_commands + namespaced_commands.keys.sort!.each do |key| + sorted_commands[key] = self.sort_hash namespaced_commands[key] + end + end + + sorted_commands + end + + private def sort_hash(hash : Hash(String, Hash(String, Athena::Console::Command))) : Hash(String, Hash(String, Athena::Console::Command)) + sorted_hash = Hash(String, Hash(String, Athena::Console::Command)).new + + hash.keys.sort!.each do |k| + sorted_hash[k] = self.sort_hash hash[k] + end + + sorted_hash + end + + private def sort_hash(hash : Hash(String, ACON::Command)) : Hash(String, ACON::Command) + sorted_hash = Hash(String, ACON::Command).new + + hash.keys.sort!.each do |k| + sorted_hash[k] = hash[k] + end + + sorted_hash + end +end diff --git a/src/descriptor/context.cr b/src/descriptor/context.cr new file mode 100644 index 0000000..1a7585d --- /dev/null +++ b/src/descriptor/context.cr @@ -0,0 +1,19 @@ +record Athena::Console::Descriptor::Context, + format : String = "txt", + raw_text : Bool = false, + raw_output : Bool? = nil, + namespace : String? = nil, + total_width : Int32? = nil, + short : Bool = false do + def raw_text? : Bool + @raw_text + end + + def raw_output? : Bool? + @raw_output + end + + def short? : Bool + @short + end +end diff --git a/src/descriptor/descriptor.cr b/src/descriptor/descriptor.cr new file mode 100644 index 0000000..eab67e2 --- /dev/null +++ b/src/descriptor/descriptor.cr @@ -0,0 +1,24 @@ +require "./interface" + +# :nodoc: +abstract class Athena::Console::Descriptor + include Athena::Console::Descriptor::Interface + + getter! output : ACON::Output::Interface + + def describe(output : ACON::Output::Interface, object : _, context : ACON::Descriptor::Context) : Nil + @output = output + + self.describe object, context + end + + protected abstract def describe(application : ACON::Application, context : ACON::Descriptor::Context) : Nil + protected abstract def describe(command : ACON::Command, context : ACON::Descriptor::Context) : Nil + protected abstract def describe(definition : ACON::Input::Definition, context : ACON::Descriptor::Context) : Nil + protected abstract def describe(argument : ACON::Input::Argument, context : ACON::Descriptor::Context) : Nil + protected abstract def describe(option : ACON::Input::Option, context : ACON::Descriptor::Context) : Nil + + protected def write(content : String, decorated : Bool = false) : Nil + self.output.print content, output_type: decorated ? Athena::Console::Output::Type::NORMAL : Athena::Console::Output::Type::RAW + end +end diff --git a/src/descriptor/interface.cr b/src/descriptor/interface.cr new file mode 100644 index 0000000..29eb71a --- /dev/null +++ b/src/descriptor/interface.cr @@ -0,0 +1,3 @@ +module Athena::Console::Descriptor::Interface + abstract def describe(output : ACON::Output::Interface, object : _, context : ACON::Descriptor::Context) : Nil +end diff --git a/src/descriptor/text.cr b/src/descriptor/text.cr new file mode 100644 index 0000000..3a6ee98 --- /dev/null +++ b/src/descriptor/text.cr @@ -0,0 +1,296 @@ +# :nodoc: +# +# TODO: Should/can this be implemented via `to_s(io)` on each type? +class Athena::Console::Descriptor::Text < Athena::Console::Descriptor + protected def describe(application : ACON::Application, context : ACON::Descriptor::Context) : Nil + described_namespace = context.namespace + description = ACON::Descriptor::Application.new application, context.namespace + + commands = description.commands.values + + if context.raw_text? + width = self.width commands + + commands.each do |command| + self.write_text sprintf("%-#{width}s %s", command.name, command.description), context + self.write_text "\n" + end + + return + end + + self.write_text "#{application.help}\n\n", context + + self.write_text "Usage:\n", context + self.write_text " command [options] [arguments]\n\n", context + + self.describe ACON::Input::Definition.new(application.definition.options), context + + self.write_text "\n" + self.write_text "\n" + + commands = description.commands + namespaces = description.namespaces + + if described_namespace && !namespaces.empty? + namespaces.values.first[:commands].each do |n| + commands[n] = description.command n + end + end + + width = self.width( + namespaces.values.flat_map do |n| + commands.keys & n[:commands] + end.uniq! + ) + + if described_namespace + self.write_text %(Available commands for the "#{described_namespace}" namespace:), context + else + self.write_text "Available commands:", context + end + + namespaces.each_value do |namespace| + namespace[:commands].select! { |c| commands.has_key? c } + + next if namespace[:commands].empty? + + if !described_namespace && namespace[:id] != ACON::Descriptor::Application::GLOBAL_NAMESPACE + self.write_text "\n" + self.write_text " #{namespace[:id]}", context + end + + namespace[:commands].each do |name| + self.write_text "\n" + spacing_width = width - ACON::Helper.width name + command = commands[name] + command_aliases = name === command.name ? self.command_aliases_text command : "" + + self.write_text " #{name}#{" " * spacing_width}#{command_aliases}#{command.description}", context + end + end + + self.write_text "\n" + end + + protected def describe(argument : ACON::Input::Argument, context : ACON::Descriptor::Context) : Nil + default = if !argument.default.nil? && !argument.default.is_a?(Array) + %( [default: #{self.format_default_value argument.default}]) + else + "" + end + + total_width = context.total_width || ACON::Helper.width argument.name + spacing_width = total_width - argument.name.size + + self.write_text( + sprintf( + " %s %s%s%s", + argument.name, + " " * spacing_width, + argument.description.gsub(/\s*[\r\n]\s*/, "\n#{" " * (total_width + 4)}"), + default + ), + context + ) + end + + protected def describe(command : ACON::Command, context : ACON::Descriptor::Context) : Nil + command.merge_application_definition false + + if description = command.description.presence + self.write_text "Description:", context + self.write_text "\n" + self.write_text " #{description}" + self.write_text "\n\n" + end + + self.write_text "Usage:", context + + ([command.synopsis(true)] + command.aliases + command.usages).each do |usage| + self.write_text "\n" + self.write_text " #{ACON::Formatter::Output.escape usage}", context + end + + self.write_text "\n" + + definition = command.definition + + if !definition.options.empty? || !definition.arguments.empty? + self.write_text "\n" + self.describe definition, context + self.write_text "\n" + end + + if (help = command.processed_help).presence && help != description + self.write_text "\n" + self.write_text "Help:", context + self.write_text "\n" + self.write_text " #{help.gsub("\n", "\n ")}", context + self.write_text "\n" + end + end + + protected def describe(definition : ACON::Input::Definition, context : ACON::Descriptor::Context) : Nil + total_width = self.calculate_total_width_for_options definition.options + + definition.arguments.each_value do |arg| + total_width = Math.max total_width, ACON::Helper.width(arg.name) + end + + unless definition.arguments.empty? + self.write_text "Arguments:", context + self.write_text "\n" + + definition.arguments.each_value do |arg| + self.describe arg, context.copy_with total_width: total_width + self.write_text "\n" + end + end + + if !definition.arguments.empty? && !definition.options.empty? + self.write_text "\n" + end + + unless definition.options.empty? + later_options = [] of ACON::Input::Option + + self.write_text "Options:", context + + definition.options.each_value do |option| + if (option.shortcut || "").size > 1 + later_options << option + next + end + + self.write_text "\n" + self.describe option, context.copy_with total_width: total_width + end + + later_options.each do |option| + self.write_text "\n" + self.describe option, context.copy_with total_width: total_width + end + end + end + + # ameba:disable Metrics/CyclomaticComplexity + protected def describe(option : ACON::Input::Option, context : ACON::Descriptor::Context) : Nil + if option.accepts_value? && !option.default.nil? && (!option.default.is_a?(Array) || !option.default.as(Array).empty?) + default = %( [default: #{self.format_default_value option.default}]) + else + default = "" + end + + value = "" + if option.accepts_value? + value = "=#{option.name.upcase}" + + if option.value_optional? + value = "[#{value}]" + end + end + + total_width = context.total_width || self.calculate_total_width_for_options [option] + synopsis = sprintf( + "%s%s", + (s = option.shortcut) ? sprintf("-%s, ", s) : " ", + (option.negatable? ? "--%s|--no-%s" : "--%s%s") % {name: option.name, value: value} + ) + + spacing_width = total_width - ACON::Helper.width synopsis + + self.write_text( + sprintf( + " %s %s%s%s%s", + synopsis, + " " * spacing_width, + option.description.gsub(/\s*[\r\n]\s*/, "\n#{" " * (total_width + 4)}"), + default, + option.is_array? ? " (multiple values allowed)" : "" + ), + context + ) + end + + private def calculate_total_width_for_options(options : Hash(String, ACON::Input::Option)) : Int32 + self.calculate_total_width_for_options options.values + end + + private def calculate_total_width_for_options(options : Array(ACON::Input::Option)) : Int32 + return 0 if options.empty? + + options.max_of do |o| + name_length = 1 + Math.max(ACON::Helper.width(o.shortcut || ""), 1) + 4 + ACON::Helper.width(o.name) + + if o.negatable? + name_length += 6 + ACON::Helper.width(o.name) + elsif o.accepts_value? + name_length += 1 + ACON::Helper.width(o.name) + (o.value_optional? ? 2 : 0) + end + + name_length + end + end + + private def command_aliases_text(command : ACON::Command) : String + String.build do |io| + unless (aliases = command.aliases).empty? + io << '[' + aliases.join io, '|' + io << ']' << ' ' + end + end + end + + private def format_default_value(default) + case default + when String + %("#{ACON::Formatter::Output.escape default}") + when Enumerable + %([#{default.map { |item| %|"#{ACON::Formatter::Output.escape item.to_s}"| }.join ","}]) + else + default + end + end + + private def width(commands : Array(ACON::Command) | Array(String)) : Int32 + widths = Array(Int32).new + + commands.each do |command| + case command + in ACON::Command + widths << ACON::Helper.width command.name.not_nil! + + command.aliases.each do |a| + widths << ACON::Helper.width a + end + in String + widths << ACON::Helper.width command + end + end + + widths.empty? ? 0 : widths.max + 2 + end + + private def write_text(content : String, context : ACON::Descriptor::Context? = nil) : Nil + unless ctx = context + return self.write content, true + end + + raw_output = true + + ctx.raw_output?.try do |ro| + raw_output = !ro + end + + if ctx.raw_text? + content = content.gsub(/(?:<\/?[^>]*>)|(?:[\n]?)/, "") # TODO: Use a more robust strip_tags implementation. + end + + self.write( + content, + raw_output + ) + end +end diff --git a/src/exceptions/command_not_found.cr b/src/exceptions/command_not_found.cr new file mode 100644 index 0000000..c0748f6 --- /dev/null +++ b/src/exceptions/command_not_found.cr @@ -0,0 +1,9 @@ +require "./console_exception" + +class Athena::Console::Exceptions::CommandNotFound < Athena::Console::Exceptions::ConsoleException + getter alternatives : Array(String) + + def initialize(message : String, @alternatives : Array(String) = [] of String, code : Int32 = 0, cause : Exception? = nil) + super message, code, cause + end +end diff --git a/src/exceptions/console_exception.cr b/src/exceptions/console_exception.cr new file mode 100644 index 0000000..b065647 --- /dev/null +++ b/src/exceptions/console_exception.cr @@ -0,0 +1,11 @@ +# Base class of all `ACON::Exceptions`. +# +# Exposes a `#code` method that represents the exit code of a command invocation. +abstract class Athena::Console::Exceptions::ConsoleException < ::Exception + # Returns the code to use as the exit status of a command invocation. + getter code : Int32 + + def initialize(message : String, @code : Int32 = 1, cause : Exception? = nil) + super message, cause + end +end diff --git a/src/exceptions/invalid_argument.cr b/src/exceptions/invalid_argument.cr new file mode 100644 index 0000000..10521dc --- /dev/null +++ b/src/exceptions/invalid_argument.cr @@ -0,0 +1,4 @@ +require "./console_exception" + +class Athena::Console::Exceptions::InvalidArgument < Athena::Console::Exceptions::ConsoleException +end diff --git a/src/exceptions/invalid_option.cr b/src/exceptions/invalid_option.cr new file mode 100644 index 0000000..624db39 --- /dev/null +++ b/src/exceptions/invalid_option.cr @@ -0,0 +1,2 @@ +class Athena::Console::Exceptions::InvalidOption < Athena::Console::Exceptions::InvalidArgument +end diff --git a/src/exceptions/logic_exception.cr b/src/exceptions/logic_exception.cr new file mode 100644 index 0000000..9a1f70b --- /dev/null +++ b/src/exceptions/logic_exception.cr @@ -0,0 +1,5 @@ +require "./console_exception" + +# Represents a code logic error that should lead directly to a fix in your code. +class Athena::Console::Exceptions::Logic < Athena::Console::Exceptions::ConsoleException +end diff --git a/src/exceptions/missing_input.cr b/src/exceptions/missing_input.cr new file mode 100644 index 0000000..c5cc90a --- /dev/null +++ b/src/exceptions/missing_input.cr @@ -0,0 +1,4 @@ +require "./console_exception" + +class Athena::Console::Exceptions::MissingInput < Athena::Console::Exceptions::ConsoleException +end diff --git a/src/exceptions/namespace_not_found.cr b/src/exceptions/namespace_not_found.cr new file mode 100644 index 0000000..54b9874 --- /dev/null +++ b/src/exceptions/namespace_not_found.cr @@ -0,0 +1,4 @@ +require "./console_exception" + +class Athena::Console::Exceptions::NamespaceNotFound < Athena::Console::Exceptions::CommandNotFound +end diff --git a/src/exceptions/validation_failed.cr b/src/exceptions/validation_failed.cr new file mode 100644 index 0000000..5a6c263 --- /dev/null +++ b/src/exceptions/validation_failed.cr @@ -0,0 +1,2 @@ +class Athena::Console::Exceptions::ValidationFailed < Athena::Console::Exceptions::ConsoleException +end diff --git a/src/formatter/interface.cr b/src/formatter/interface.cr new file mode 100644 index 0000000..811794f --- /dev/null +++ b/src/formatter/interface.cr @@ -0,0 +1,23 @@ +require "./output_style_interface" + +# A container that stores and applies `ACON::Formatter::OutputStyleInterface`. +# Is responsible for formatting outputted messages as per their styles. +module Athena::Console::Formatter::Interface + # Sets if output messages should be decorated. + abstract def decorated=(@decorated : Bool) + + # Returns `true` if output messages will be decorated, otherwise `false`. + abstract def decorated? : Bool + + # Assigns the provided *style* to the provided *name*. + abstract def set_style(name : String, style : ACON::Formatter::OutputStyleInterface) : Nil + + # Returns `true` if `self` has a style with the provided *name*, otherwise `false`. + abstract def has_style?(name : String) : Bool + + # Returns an `ACON::Formatter::OutputStyleInterface` with the provided *name*. + abstract def style(name : String) : ACON::Formatter::OutputStyleInterface + + # Formats the provided *message* according to the stored styles. + abstract def format(message : String?) : String +end diff --git a/src/formatter/mode.cr b/src/formatter/mode.cr new file mode 100644 index 0000000..6259735 --- /dev/null +++ b/src/formatter/mode.cr @@ -0,0 +1,34 @@ +# TODO: Remove this type in favor in the stdlib's version when/if https://github.com/crystal-lang/crystal/pull/7690 is merged. +@[Flags] +enum Athena::Console::Formatter::Mode + # Makes the text bold. + Bold = 1 + + # Dims the text color. + Dim + + # Underlines the text. + Underline + + # Makes the text blink slowly. + Blink + + # Swaps the foreground and background colors of the text. + Reverse + + # Makes the text invisible. + Hidden + + protected def to_sym : Symbol + case self + when .bold? then :bold + when .dim? then :dim + when .underline? then :underline + when .blink? then :blink + when .reverse? then :reverse + when .hidden? then :hidden + else + raise "" + end + end +end diff --git a/src/formatter/null.cr b/src/formatter/null.cr new file mode 100644 index 0000000..42981bd --- /dev/null +++ b/src/formatter/null.cr @@ -0,0 +1,30 @@ +require "./interface" + +# :nodoc: +class Athena::Console::Formatter::Null + include Athena::Console::Formatter::Interface + + @style : ACON::Formatter::OutputStyle? = nil + + def decorated=(@decorated : Bool) + end + + def decorated? : Bool + false + end + + def set_style(name : String, style : ACON::Formatter::OutputStyleInterface) : Nil + end + + def has_style?(name : String) : Bool + false + end + + def style(name : String) : ACON::Formatter::OutputStyleInterface + @style ||= ACON::Formatter::NullStyle.new + end + + def format(message : String?) : String + message + end +end diff --git a/src/formatter/null_style.cr b/src/formatter/null_style.cr new file mode 100644 index 0000000..78478dc --- /dev/null +++ b/src/formatter/null_style.cr @@ -0,0 +1,25 @@ +# :nodoc: +class Athena::Console::Formatter::NullStyle + include Athena::Console::Formatter::OutputStyleInterface + + # :inherit: + def foreground=(forground : Colorize::Color) + end + + # :inherit: + def background=(background : Colorize::Color) + end + + # :inherit: + def add_option(option : ACON::Formatter::Mode) : Nil + end + + # :inherit: + def remove_option(option : ACON::Formatter::Mode) : Nil + end + + # :inherit: + def apply(text : String) : String + text + end +end diff --git a/src/formatter/output.cr b/src/formatter/output.cr new file mode 100644 index 0000000..ab5f40c --- /dev/null +++ b/src/formatter/output.cr @@ -0,0 +1,179 @@ +require "./wrappable_interface" + +# Default implementation of `ACON::Formatter::WrappableInterface`. +class Athena::Console::Formatter::Output + include Athena::Console::Formatter::WrappableInterface + + # Returns a new string where the special `<` characters in the provided *text* are escaped. + def self.escape(text : String) : String + text = text.gsub /([^\\\\]?)]*+) | \/([a-z][^<>]*+)?)>/ix) do |match| + pos = match.begin.not_nil! + text = match[0] + + next if pos != 0 && '\\' == message[pos - 1] + + # Add text up to next tag. + output += self.apply_current_style message[offset, pos - offset], output, width + offset = pos + text.size + + tag = if open = '/' != text.char_at(1) + match[2] + else + match[3]? || "" + end + + if !open && !tag.presence + # + @style_stack.pop + elsif (style = self.create_style_from_string(tag)).nil? + output += self.apply_current_style text, output, width + elsif open + @style_stack << style + else + @style_stack.pop style + end + end + + output += self.apply_current_style message[offset...], output, width + + if output.includes? '\0' + return output + .gsub("\0", '\\') + .gsub("\\<", '<') + end + + output.gsub /\\ 201100) + end + + def initialize(foreground : Colorize::Color | String = :default, background : Colorize::Color | String = :default, @options : ACON::Formatter::Mode = :none) + self.foreground = foreground + self.background = background + end + + # :inherit: + def add_option(option : ACON::Formatter::Mode) : Nil + @options |= option + end + + # :ditto: + def add_option(option : String) : Nil + self.add_option ACON::Formatter::Mode.parse option + end + + # :inherit: + def background=(color : String) + if hex_value = color.lchop? '#' + r, g, b = hex_value.hexbytes + return @background = Colorize::ColorRGB.new r, g, b + end + + @background = Colorize::ColorANSI.parse color + end + + # :inherit: + def foreground=(color : String) + if hex_value = color.lchop? '#' + r, g, b = hex_value.hexbytes + return @foreground = Colorize::ColorRGB.new r, g, b + end + + @foreground = Colorize::ColorANSI.parse color + end + + # :inherit: + def remove_option(option : ACON::Formatter::Mode) : Nil + @options ^= option + end + + # :ditto: + def remove_option(option : String) : Nil + self.remove_option ACON::Formatter::Mode.parse option + end + + # :inherit: + def apply(text : String) : String + if (href = @href) && self.handles_href_gracefully? + text = "\e]8;;#{href}\e\\#{text}\e]8;;\e\\" + end + + color = Colorize::Object(String) + .new(text) + .fore(@foreground) + .back(@background) + + if options = @options + options.each do |mode| + color.mode mode.to_sym + end + end + + color.to_s + end +end diff --git a/src/formatter/output_style_interface.cr b/src/formatter/output_style_interface.cr new file mode 100644 index 0000000..b0aee09 --- /dev/null +++ b/src/formatter/output_style_interface.cr @@ -0,0 +1,100 @@ +require "colorize" +require "./mode" + +# Output styles represent reusable formatting information that can be used when formatting output messages. +# `Athena::Console` comes bundled with a few common styles including: +# +# * error +# * info +# * comment +# * question +# +# Whenever you output text via an `ACON::Output::Interface`, you can surround the text with tags to color its output. For example: +# +# ``` +# # Green text +# output.puts "foo" +# +# # Yellow text +# output.puts "foo" +# +# # Black text on a cyan background +# output.puts "foo" +# +# # White text on a red background +# output.puts "foo" +# ``` +# +# ## Custom Styles +# +# Custom styles can also be defined/used: +# +# ``` +# my_style = ACON::Formatter::OutputStyle.new :red, "#f87b05", ACON::Formatter::Mode.flags Bold, Underline +# output.formatter.set_style "fire", my_style +# +# output.puts "foo" +# ``` +# +# ### Global Custom Styles +# +# You can also make your style global by extending `ACON::Application` and adding it within the `#configure_io` method: +# +# ``` +# class MyCustomApplication < ACON::Application +# protected def configure_io(input : ACON::Input::Interface, output : ACON::Output::Interface) : Nil +# super +# +# my_style = ACON::Formatter::OutputStyle.new :red, "#f87b05", ACON::Formatter::Mode.flags Bold, Underline +# output.formatter.set_style "fire", my_style +# end +# end +# ``` +# +# ## Inline Styles +# +# Styles can also be defined inline when printing a message: +# +# ``` +# # Using named colors +# output.puts "foo" +# +# # Using hexadecimal colors +# output.puts "foo" +# +# # Black text on a cyan background +# output.puts "foo" +# +# # Bold text on a yellow background +# output.puts "foo" +# +# # Bold text with underline. +# output.puts "foo" +# ``` +# +# ## Clickable Links +# +# Commands can use the special `href` tag to display links within the console. +# +# ``` +# output.puts "Athena" +# ``` +# +# If your terminal [supports](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) it, you would be able to click +# the text and have it open in your default browser. Otherwise, you will see it as regular text. +module Athena::Console::Formatter::OutputStyleInterface + # Sets the foreground color of `self`. + abstract def foreground=(forground : Colorize::Color) + + # Sets the background color of `self`. + abstract def background=(background : Colorize::Color) + + # Adds a text mode to `self`. + abstract def add_option(option : ACON::Formatter::Mode) : Nil + + # Removes a text mode to `self`. + abstract def remove_option(option : ACON::Formatter::Mode) : Nil + + # Applies `self` to the provided *text*. + abstract def apply(text : String) : String +end diff --git a/src/formatter/wrappable_interface.cr b/src/formatter/wrappable_interface.cr new file mode 100644 index 0000000..ebbc78d --- /dev/null +++ b/src/formatter/wrappable_interface.cr @@ -0,0 +1,10 @@ +require "./interface" + +# Extension of `ACON::Formatter::Interface` that supports word wrapping. +module Athena::Console::Formatter::WrappableInterface + include Athena::Console::Formatter::Interface + + # Formats the provided *message* according to the defined styles, wrapping it at the provided *width*. + # A width of `0` means no wrapping. + abstract def format_and_wrap(message : String?, width : Int32) : String +end diff --git a/src/helper/athena_question.cr b/src/helper/athena_question.cr new file mode 100644 index 0000000..c77c58e --- /dev/null +++ b/src/helper/athena_question.cr @@ -0,0 +1,74 @@ +abstract class Athena::Console::Helper; end + +require "./question" + +# Extension of `ACON::Helper::Question` that provides more structured output. +# +# See `ACON::Style::Athena`. +class Athena::Console::Helper::AthenaQuestion < Athena::Console::Helper::Question + protected def write_error(output : ACON::Output::Interface, error : Exception) : Nil + if output.is_a? ACON::Style::Athena + output.new_line + output.error error.message || "" + + return + end + + super + end + + # ameba:disable Metrics/CyclomaticComplexity + protected def write_prompt(output : ACON::Output::Interface, question : ACON::Question::Base) : Nil + text = ACON::Formatter::Output.escape_trailing_backslash question.question + default = question.default + + if question.multi_line? + text = "#{text} (press #{self.eof_shortcut} to continue)" + end + + text = if default.nil? + " #{text}:" + elsif question.is_a? ACON::Question::Confirmation + %( #{text} (yes/no) [#{default ? "yes" : "no"}]:) + elsif question.is_a? ACON::Question::MultipleChoice + choices = question.choices + default = case default + when String then default.split(',').map! do |item| + if idx = item.to_i? + item = idx + end + + choices[item]? || item.to_s + end + else + [default] + end + + %( #{text} [#{ACON::Formatter::Output.escape default.join(", ")}]:) + elsif question.is_a? ACON::Question::Choice + choices = question.choices + + " #{text} [#{ACON::Formatter::Output.escape default.to_s}]:" + else + " #{text} [#{ACON::Formatter::Output.escape default.to_s}]:" + end + + output.puts text + + prompt = " > " + + if question.is_a? ACON::Question::AbstractChoice + output.puts self.format_choice_question_choices question, "comment" + + prompt = question.prompt + end + + output.print prompt + end + + private def eof_shortcut : String + # TODO: Windows uses Ctrl+Z + Enter + + "Ctrl+D" + end +end diff --git a/src/helper/descriptor_helper.cr b/src/helper/descriptor_helper.cr new file mode 100644 index 0000000..ffed277 --- /dev/null +++ b/src/helper/descriptor_helper.cr @@ -0,0 +1,20 @@ +# :nodoc: +class Athena::Console::Helper::Descriptor < Athena::Console::Helper + @descriptors = Hash(String, ACON::Descriptor::Interface).new + + def initialize + self.register "txt", ACON::Descriptor::Text.new + end + + def describe(output : ACON::Output::Interface, object : _, context : ACON::Descriptor::Context) : Nil + raise "Unsupported format #{context.format}." unless (descriptor = @descriptors[context.format]?) + + descriptor.describe output, object, context + end + + def register(format : String, descriptor : ACON::Descriptor::Interface) : self + @descriptors[format] = descriptor + + self + end +end diff --git a/src/helper/formatter.cr b/src/helper/formatter.cr new file mode 100644 index 0000000..4560b19 --- /dev/null +++ b/src/helper/formatter.cr @@ -0,0 +1,84 @@ +# Provides additional ways to format output messages than `ACON::Formatter::OutputStyle` can do alone, such as: +# +# * Printing messages in a section +# * Printing messages in a block +# * Print truncated messages. +# +# The provided methods return a `String` which could then be passed to `ACON::Output::Interface#print` or `ACON::Output::Interface#puts`. +class Athena::Console::Helper::Formatter < Athena::Console::Helper + # Prints the provided *message* in the provided *section*. + # Optionally allows setting the *style* of the section. + # + # ```text + # [SomeSection] Here is some message related to that section + # ``` + # + # ``` + # output.puts formatter.format_section "SomeSection", "Here is some message related to that section" + # ``` + def format_section(section : String, message : String, style : String = "info") : String + "<#{style}>[#{section}] #{message}" + end + + # Prints the provided *messages* in a block formatted according to the provided *style*, with a total width a bit more than the longest line. + # + # The *large* options adds additional padding, one blank line above and below the messages, and 2 more spaces on the left and right. + # + # ``` + # output.puts formatter.format_block({"Error!", "Something went wrong"}, "error", true) + # ``` + def format_block(messages : String | Enumerable(String), style : String, large : Bool = false) + messages = messages.is_a?(String) ? {messages} : messages + + len = 0 + lines = [] of String + + messages.each do |message| + message = ACON::Formatter::Output.escape message + lines << (large ? " #{message} " : " #{message} ") + len = Math.max (message.size + (large ? 4 : 2)), len + end + + messages = large ? [" " * len] : [] of String + + lines.each do |line| + messages << %(#{line}#{" " * (len - line.size)}) + end + + if large + messages << " " * len + end + + messages.each_with_index do |line, idx| + messages[idx] = "<#{style}>#{line}" + end + + messages.join '\n' + end + + # Truncates the provided *message* to be at most *length* characters long, + # with the optional *suffix* appended to the end. + # + # ``` + # message = "This is a very long message, which should be truncated" + # truncated_message = formatter.truncate message, 7 + # output.puts truncated_message # => This is... + # ``` + # + # If *length* is negative, it will start truncating from the end. + # + # ``` + # message = "This is a very long message, which should be truncated" + # truncated_message = formatter.truncate message, -5 + # output.puts truncated_message # => This is a very long message, which should be trun... + # ``` + def truncate(message : String, length : Int, suffix : String = "...") : String + computed_length = length - self.class.width suffix + + if computed_length > self.class.width message + return message + end + + "#{message[0...length]}#{suffix}" + end +end diff --git a/src/helper/helper.cr b/src/helper/helper.cr new file mode 100644 index 0000000..ec55b14 --- /dev/null +++ b/src/helper/helper.cr @@ -0,0 +1,29 @@ +require "./interface" + +# Contains `ACON::Helper::Interface` implementations that can be used to help with various tasks. +# Such as asking questions, or customizing the output format. +# +# This class also acts as a base type that implements common functionality between each helper. +abstract class Athena::Console::Helper + include Athena::Console::Helper::Interface + + # Returns a new string with all of its ANSI formatting removed. + def self.remove_decoration(formatter : ACON::Formatter::Interface, string : String) : String + is_decorated = formatter.decorated? + formatter.decorated = false + string = formatter.format string + string = string.gsub /\033\[[^m]*m/, "" + formatter.decorated = is_decorated + + string + end + + # Returns the width of a string; where the width is how many character positions the string will use. + # + # TODO: Support double width chars. + def self.width(string : String) : Int32 + string.size + end + + property helper_set : ACON::Helper::HelperSet? = nil +end diff --git a/src/helper/helper_set.cr b/src/helper/helper_set.cr new file mode 100644 index 0000000..28535d7 --- /dev/null +++ b/src/helper/helper_set.cr @@ -0,0 +1,43 @@ +# The container that stores various `ACON::Helper::Interface` implementations, keyed by their class. +# +# Each application includes a default helper set, but additional ones may be added. +# See `ACON::Application#helper_set`. +# +# These helpers can be accessed from within a command via the `ACON::Command#helper` method. +class Athena::Console::Helper::HelperSet + @helpers = Hash(ACON::Helper.class, ACON::Helper::Interface).new + + def self.new(*helpers : ACON::Helper::Interface) : self + helper_set = new + helpers.each do |helper| + helper_set << helper + end + helper_set + end + + def initialize(@helpers : Hash(ACON::Helper.class, ACON::Helper::Interface) = Hash(ACON::Helper.class, ACON::Helper::Interface).new); end + + # Adds the provided *helper* to `self`. + def <<(helper : ACON::Helper::Interface) : Nil + @helpers[helper.class] = helper + + helper.helper_set = self + end + + # Returns `true` if `self` has a helper for the provided *helper_class*, otherwise `false`. + def has?(helper_class : ACON::Helper.class) : Bool + @helpers.has_key? helper_class + end + + # Returns the helper of the provided *helper_class*, or `nil` if it is not defined. + def []?(helper_class : T.class) : T? forall T + {% T.raise "Helper class type '#{T}' is not an 'ACON::Helper::Interface'." unless T <= ACON::Helper::Interface %} + + @helpers[helper_class]?.as? T + end + + # Returns the helper of the provided *helper_class*, or raises if it is not defined. + def [](helper_class : T.class) : T forall T + self.[helper_class]? || raise ACON::Exceptions::InvalidArgument.new "The helper '#{helper_class}' is not defined." + end +end diff --git a/src/helper/interface.cr b/src/helper/interface.cr new file mode 100644 index 0000000..82e7039 --- /dev/null +++ b/src/helper/interface.cr @@ -0,0 +1,7 @@ +module Athena::Console::Helper::Interface + # Sets the `ACON::Helper::HelperSet` related to `self`. + abstract def helper_set=(helper_set : ACON::Helper::HelperSet?) + + # Returns the `ACON::Helper::HelperSet` related to `self`, if any. + abstract def helper_set : ACON::Helper::HelperSet? +end diff --git a/src/helper/question.cr b/src/helper/question.cr new file mode 100644 index 0000000..aad4464 --- /dev/null +++ b/src/helper/question.cr @@ -0,0 +1,208 @@ +# Provides a method to ask the user for more information; +# such as to confirm an action, or to provide additional values. +# +# See `ACON::Question` namespace for more information. +class Athena::Console::Helper::Question < Athena::Console::Helper + @@stty : Bool = true + + def self.disable_stty : Nil + @@stty = false + end + + @stream : IO? = nil + + def ask(input : ACON::Input::Interface, output : ACON::Output::Interface, question : ACON::Question::Base) + if output.is_a? ACON::Output::ConsoleOutputInterface + output = output.error_output + end + + return self.default_answer question unless input.interactive? + + if input.is_a?(ACON::Input::Streamable) && (stream = input.stream) + @stream = stream + end + + begin + if question.validator.nil? + return self.do_ask output, question + end + + self.validate_attempts(output, question) do + self.do_ask output, question + end + rescue ex : ACON::Exceptions::MissingInput + input.interactive = false + + raise ex + end + end + + protected def format_choice_question_choices(question : ACON::Question::AbstractChoice, tag : String) : Array(String) + messages = Array(String).new + + choices = question.choices + + max_width = choices.keys.max_of { |k| k.is_a?(String) ? self.class.width(k) : k.digits.size } + + choices.each do |k, v| + padding = " " * (max_width - (k.is_a?(String) ? k.size : k.digits.size)) + + messages << " [<#{tag}>#{k}#{padding}] #{v}" + end + + messages + end + + protected def write_error(output : ACON::Output::Interface, error : Exception) : Nil + message = if (helper_set = self.helper_set) && (formatter_helper = helper_set[ACON::Helper::Formatter]?) + formatter_helper.format_block error.message || "", "error" + else + "#{error.message}" + end + + output.puts message + end + + protected def write_prompt(output : ACON::Output::Interface, question : ACON::Question::Base) : Nil + message = question.question + + if question.is_a? ACON::Question::AbstractChoice + output.puts question.question + output.puts self.format_choice_question_choices question, "info" + + message = question.prompt + end + + output.print message + end + + private def default_answer(question : ACON::Question::Base) + default = question.default + + return default if default.nil? + + if validator = question.validator + return validator.call default + elsif question.is_a? ACON::Question::AbstractChoice + choices = question.choices + + unless question.is_a? ACON::Question::MultipleChoice + return choices[default]? || default + end + + default = case default + when String then default.split(',').map! do |item| + if idx = item.to_i? + item = idx + end + + choices[item]? || item.to_s + end + else + default + end + end + + default + end + + # ameba:disable Metrics/CyclomaticComplexity + private def do_ask(output : ACON::Output::Interface, question : ACON::Question::Base) + self.write_prompt output, question + + input_stream = @stream || STDIN + autocompleter = question.autocompleter_callback + + # TODO: Handle invalid input IO + + if autocompleter.nil? || !@@stty || !ACON::Terminal.has_stty_available? + response = nil + + if question.hidden? + begin + hidden_response = self.hidden_response output, input_stream + response = question.trimmable? ? hidden_response.strip : hidden_response + rescue ex : ACON::Exceptions::ConsoleException + raise ex unless question.hidden_fallback? + end + end + + if response.nil? + raise ACON::Exceptions::MissingInput.new "Aborted." unless (response = self.read_input input_stream, question) + response = response.strip if question.trimmable? + end + else + autocomplete = self.autocomplete output, question, input_stream, autocompleter + response = question.trimmable? ? autocomplete.strip : autocomplete + end + + if output.is_a? ACON::Output::Section + output.add_content response + end + + question.process_response response + end + + private def autocomplete(output : ACON::Output::Interface, question : ACON::Question::Base, input_stream : IO, autocompleter) : String + # TODO: Support autocompletion. + self.read_input(input_stream, question) || "" + end + + private def hidden_response(output : ACON::Output::Interface, input_stream : IO) : String + # TODO: Support Windows + {% raise "Athena::Console component does not support Windows yet." if flag?(:win32) %} + + response = if input_stream.tty? && input_stream.responds_to? :noecho + input_stream.noecho &.gets 4096 + elsif @@stty && ACON::Terminal.has_stty_available? + stty_mode = `stty -g` + system "stty -echo" + + input_stream.gets(4096).tap { system "stty #{stty_mode}" } + elsif input_stream.tty? + raise ACON::Exceptions::MissingInput.new "Unable to hide the response." + end + + raise ACON::Exceptions::MissingInput.new "Aborted." if response.nil? + + output.puts "" + + response + end + + private def read_input(input_stream : IO, question : ACON::Question::Base) : String? + unless question.multi_line? + return input_stream.gets 4096 + end + + # Can't just do `.gets_to_end` because we need to be able + # to return early if the only input provided is a newline. + String.build do |io| + input_stream.each_char do |char| + break if '\n' == char && io.empty? + io << char + end + end + end + + private def validate_attempts(output : ACON::Output::Interface, question : ACON::Question::Base) + error = nil + attempts = question.max_attempts + + while attempts.nil? || attempts > 0 + self.write_error output, error if error + + begin + return question.validator.not_nil!.call yield + rescue ex : ACON::Exceptions::ValidationFailed + raise ex + rescue ex : Exception + error = ex + ensure + attempts -= 1 if attempts + end + end + + raise error.not_nil! + end +end diff --git a/src/input/argument.cr b/src/input/argument.cr new file mode 100644 index 0000000..9f1a703 --- /dev/null +++ b/src/input/argument.cr @@ -0,0 +1,101 @@ +abstract class Athena::Console::Input; end + +# Represents a value (or array of values) provided to a command as a ordered positional argument, +# that can either be required or optional, optionally with a default value and/or description. +# +# Arguments are strings separated by spaces that come _after_ the command name. +# For example, `./console test arg1 "Arg2 with spaces"`. +# +# Arguments can be added via the `ACON::Command#argument` method, +# or by instantiating one manually as part of an `ACON::Input::Definition`. +# The value of the argument could then be accessed via one of the `ACON::Input::Interface#argument` overloads. +# +# See `ACON::Input::Interface` for more examples on how arguments/options are parsed, and how they can be accessed. +class Athena::Console::Input::Argument + @[Flags] + # Represents the possible modes of an `ACON::Input::Argument`, + # that describe the "type" of the argument. + # + # Modes can also be combined using the [Enum.flags](https://crystal-lang.org/api/master/Enum.html#flags%28%2Avalues%29-macro) macro. + # For example, `ACON::Input::Argument::Mode.flags REQUIRED, IS_ARRAY` which defines a required array argument. + enum Mode + # Represents a required argument that _MUST_ be provided. + # Otherwise the command will not run. + REQUIRED + + # Represents an optional argument that could be omitted. + OPTIONAL + + # Represents an argument that accepts a variable amount of values. + # Arguments of this type must be last. + IS_ARRAY + end + + # Returns the name of the `self`. + getter name : String + + # Returns the `ACON::Input::Argument::Mode` of `self`. + getter mode : ACON::Input::Argument::Mode + + # Returns the description of `self`. + getter description : String + + @default : ACON::Input::Value? = nil + + def initialize( + @name : String, + @mode : ACON::Input::Argument::Mode = :optional, + @description : String = "", + default = nil + ) + raise ACON::Exceptions::InvalidArgument.new "An argument name cannot be blank." if name.blank? + + self.default = default + end + + # Returns the default value of `self`, if any. + def default + @default.try do |value| + case value + when ACON::Input::Value::Array + value.value.map &.value + else + value.value + end + end + end + + # Returns the default value of `self`, if any, converted to the provided *type*. + def default(type : T.class) : T forall T + {% if T.nilable? %} + self.default.as T + {% else %} + @default.not_nil!.get T + {% end %} + end + + # Sets the default value of `self`. + def default=(default = nil) + raise ACON::Exceptions::Logic.new "Cannot set a default value when the argument is required." if @mode.required? && !default.nil? + + if @mode.is_array? + if default.nil? + return @default = ACON::Input::Value::Array.new + elsif !default.is_a? Array + raise ACON::Exceptions::Logic.new "Default value for an array argument must be an array." + end + end + + @default = ACON::Input::Value.from_value default + end + + # Returns `true` if `self` is a required argument, otherwise `false`. + def required? : Bool + @mode.required? + end + + # Returns `true` if `self` expects an array of values, otherwise `false`. + def is_array? : Bool + @mode.is_array? + end +end diff --git a/src/input/argv.cr b/src/input/argv.cr new file mode 100644 index 0000000..f384a73 --- /dev/null +++ b/src/input/argv.cr @@ -0,0 +1,200 @@ +# An `ACON::Input::Interface` based on [ARGV](https://crystal-lang.org/api/toplevel.html#ARGV). +class Athena::Console::Input::ARGV < Athena::Console::Input + @tokens : Array(String) + @parsed : Array(String) = [] of String + + def initialize(@tokens : Array(String) = ::ARGV, definition : ACON::Input::Definition? = nil) + super definition + end + + # :inherit: + # ameba:disable Metrics/CyclomaticComplexity + def first_argument : String? + is_option = false + + @tokens.each_with_index do |token, idx| + if !token.empty? && token.starts_with? '-' + next if token.includes?('=') || @tokens[idx + 1]?.nil? + + name = '-' == token.char_at(1) ? token[2..] : token[-1..] + + if !@options.has_key?(name) && !@definition.has_shortcut?(name) + # noop + elsif (@options.has_key?(name) || @options.has_key?(name = @definition.shortcut_to_name(name))) && @tokens[idx + 1]? == @options[name].value + is_option = true + end + + next + end + + if is_option + is_option = false + next + end + + return token + end + + nil + end + + # :inherit: + def has_parameter?(*values : String, only_params : Bool = false) : Bool + @tokens.each do |token| + return false if only_params && "--" == token + + values.each do |value| + leading = value.starts_with?("--") ? "#{value}=" : value + return true if token == value || (!leading.empty? && token.starts_with? leading) + end + end + + false + end + + # :inherit: + def parameter(value : String, default : _ = false, only_params : Bool = false) + tokens = @tokens.dup + + while token = tokens.shift? + return default if only_params && "--" == token + return tokens.shift? if token == value + + leading = value.starts_with?("--") ? "#{value}=" : value + return token[leading.size..] if !leading.empty? && token.starts_with? leading + end + + default + end + + # ameba:disable Metrics/CyclomaticComplexity + protected def parse : Nil + parse_options = true + @parsed = @tokens.dup + + while token = @parsed.shift? + if parse_options && token.empty? + self.parse_argument token + elsif parse_options && "--" == token + parse_options = false + elsif parse_options && token.starts_with? "--" + self.parse_long_option token + elsif parse_options && token.starts_with?('-') && "-" != token + self.parse_short_option token + else + self.parse_argument token + end + end + end + + private def parse_argument(token : String) : Nil + count = @arguments.size + + # If expecting another argument, add it. + if @definition.has_argument? count + argument = @definition.argument count + @arguments[argument.name] = argument.is_array? ? ACON::Input::Value::Array.new(token) : ACON::Input::Value.from_value token + + # If the last argument IS_ARRAY, append token to last argument. + elsif @definition.has_argument?(count - 1) && @definition.argument(count - 1).is_array? + argument = @definition.argument(count - 1) + @arguments[argument.name].as(ACON::Input::Value::Array) << token + + # TODO: Handle unexpected argument. + else + end + end + + private def parse_long_option(token : String) : Nil + name = token.lchop "--" + + if pos = name.index '=' + if (value = name[(pos + 1)..]).empty? + @parsed.unshift value + end + + self.add_long_option name[0, pos], value + else + self.add_long_option name, nil + end + end + + # ameba:disable Metrics/CyclomaticComplexity + private def add_long_option(name : String, value : String?) : Nil + unless @definition.has_option?(name) + raise ACON::Exceptions::InvalidOption.new "The '--#{name}' option does not exist." unless @definition.has_negation? name + + option_name = @definition.negation_to_name name + raise ACON::Exceptions::InvalidOption.new "The '--#{name}' option does not accept a value." unless value.nil? + + return @options[option_name] = ACON::Input::Value.from_value false + end + + option = @definition.option name + + if !value.nil? && !option.accepts_value? + raise ACON::Exceptions::InvalidOption.new "The --#{option.name} option does not accept a value." + end + + if value.in?("", nil) && option.accepts_value? && !@parsed.empty? + next_value = @parsed.shift? + + if ((v = next_value.presence) && '-' != v.char_at(0)) || next_value.in?("", nil) + value = next_value + else + @parsed.unshift next_value || "" + end + end + + if value.nil? + raise ACON::Exceptions::InvalidOption.new "The --#{option.name} option requires a value." if option.value_required? + value = true if !option.is_array? && !option.value_optional? + end + + if option.is_array? + (@options[name] ||= ACON::Input::Value::Array.new).as(ACON::Input::Value::Array) << value + else + @options[name] = ACON::Input::Value.from_value value + end + end + + private def parse_short_option(token : String) : Nil + name = token.lchop '-' + + if name.size > 1 + if @definition.has_shortcut?(name[0]) && @definition.option_for_shortcut(name[0]).accepts_value? + # Option with a value & no space + self.add_short_option name[0], name[1..] + else + self.parse_short_option_set name + end + else + self.add_short_option name, nil + end + end + + private def parse_short_option_set(name : String) : Nil + length = name.size + name.each_char_with_index do |char, idx| + raise ACON::Exceptions::InvalidOption.new "The -#{char} option does not exist." unless @definition.has_shortcut? char + + option = @definition.option_for_shortcut char + + if option.accepts_value? + self.add_long_option option.name, idx == length - 1 ? nil : name[(idx + 1)..] + + break + else + self.add_long_option option.name, nil + end + end + end + + private def add_short_option(name : String | Char, value : String?) : Nil + name = name.to_s + + raise ACON::Exceptions::InvalidOption.new "The -#{name} option does not exist." if !@definition.has_shortcut? name + + self.add_long_option @definition.option_for_shortcut(name).name, value + end +end diff --git a/src/input/definition.cr b/src/input/definition.cr new file mode 100644 index 0000000..85f6d1f --- /dev/null +++ b/src/input/definition.cr @@ -0,0 +1,266 @@ +# Represents a collection of `ACON::Input::Argument`s and `ACON::Input::Option`s that are to be parsed from an `ACON::Input::Interface`. +# +# Can be used to set the inputs of an `ACON::Command` via the `ACON::Command#definition=` method if so desired, +# instead of using the dedicated methods. +class Athena::Console::Input::Definition + getter options : ::Hash(String, ACON::Input::Option) = ::Hash(String, ACON::Input::Option).new + getter arguments : ::Hash(String, ACON::Input::Argument) = ::Hash(String, ACON::Input::Argument).new + + @last_array_argument : ACON::Input::Argument? = nil + @last_optional_argument : ACON::Input::Argument? = nil + + @shortcuts = ::Hash(String, String).new + @negations = ::Hash(String, String).new + + getter required_argument_count : Int32 = 0 + + def self.new(definition : ::Hash(String, ACON::Input::Option) | ::Hash(String, ACON::Input::Argument)) : self + new definition.values + end + + def self.new(*definitions : ACON::Input::Argument | ACON::Input::Option) : self + new definitions.to_a + end + + def initialize(definition : Array(ACON::Input::Argument | ACON::Input::Option) = Array(ACON::Input::Argument | ACON::Input::Option).new) + self.definition = definition + end + + # Adds the provided *argument* to `self`. + def <<(argument : ACON::Input::Argument) : Nil + raise ACON::Exceptions::Logic.new "An argument with the name '#{argument.name}' already exists." if @arguments.has_key?(argument.name) + + if (last_array_argument = @last_array_argument) + raise ACON::Exceptions::Logic.new "Cannot add a required argument '#{argument.name}' after Array argument '#{last_array_argument.name}'." + end + + if argument.required? && (last_optional_argument = @last_optional_argument) + raise ACON::Exceptions::Logic.new "Cannot add required argument '#{argument.name}' after the optional argument '#{last_optional_argument.name}'." + end + + if argument.is_array? + @last_array_argument = argument + end + + if argument.required? + @required_argument_count += 1 + else + @last_optional_argument = argument + end + + @arguments[argument.name] = argument + end + + # Adds the provided *options* to `self`. + def <<(option : ACON::Input::Option) : Nil + if self.has_option?(option.name) && option != self.option(option.name) + raise ACON::Exceptions::Logic.new "An option named '#{option.name}' already exists." + end + + if self.has_negation?(option.name) + raise ACON::Exceptions::Logic.new "An option named '#{option.name}' already exists." + end + + if shortcut = option.shortcut + shortcut.split('|', remove_empty: true) do |s| + if self.has_shortcut?(s) && option != self.option_for_shortcut(s) + raise ACON::Exceptions::Logic.new "An option with shortcut '#{s}' already exists." + end + end + end + + @options[option.name] = option + + if shortcut + shortcut.split('|', remove_empty: true) do |s| + @shortcuts[s] = option.name + end + end + + if option.negatable? + negated_name = "no-#{option.name}" + + raise ACON::Exceptions::Logic.new "An option named '#{negated_name}' already exists." if self.has_option? negated_name + + @negations[negated_name] = option.name + end + end + + # Adds the provided *arguments* to `self`. + def <<(arguments : Array(ACON::Input::Argument | ACON::Input::Option)) : Nil + arguments.each do |arg| + self.<< arg + end + end + + # Overrides the arguments and options of `self` to those in the provided *definition*. + def definition=(definition : Array(ACON::Input::Argument | ACON::Input::Option)) : Nil + arguments = Array(ACON::Input::Argument).new + options = Array(ACON::Input::Option).new + + definition.each do |d| + case d + in ACON::Input::Argument then arguments << d + in ACON::Input::Option then options << d + end + end + + self.arguments = arguments + self.options = options + end + + # Overrides the arguments of `self` to those in the provided *arguments* array. + def arguments=(arguments : Array(ACON::Input::Argument)) : Nil + @arguments.clear + @required_argument_count = 0 + @last_array_argument = nil + @last_optional_argument = nil + + self.<< arguments + end + + # Returns the `ACON::Input::Argument` with the provided *name_or_index*, + # otherwise raises `ACON::Exceptions::InvalidArgument` if that argument is not defined. + def argument(name_or_index : String | Int32) : ACON::Input::Argument + raise ACON::Exceptions::InvalidArgument.new "The argument '#{name_or_index}' does not exist." unless self.has_argument? name_or_index + + case name_or_index + in String then @arguments[name_or_index] + in Int32 then @arguments.values[name_or_index] + end + end + + # Returns `true` if `self` has an argument with the provided *name_or_index*. + def has_argument?(name_or_index : String | Int32) : Bool + case name_or_index + in String then @arguments.has_key? name_or_index + in Int32 then !@arguments.values.[name_or_index]?.nil? + end + end + + # Returns the number of `ACON::Input::Argument`s defined within `self`. + def argument_count : Int32 + !@last_array_argument.nil? ? Int32::MAX : @arguments.size + end + + # Returns a `::Hash` whose keys/values represent the names and default values of the `ACON::Input::Argument`s defined within `self`. + def argument_defaults : ::Hash + @arguments.to_h do |(name, arg)| + {name, arg.default} + end + end + + # Overrides the options of `self` to those in the provided *options* array. + def options=(options : Array(ACON::Input::Option)) : Nil + @options.clear + @shortcuts.clear + @negations.clear + + self.<< options + end + + # Returns the `ACON::Input::Option` with the provided *name_or_index*, + # otherwise raises `ACON::Exceptions::InvalidArgument` if that option is not defined. + def option(name_or_index : String | Int32) : ACON::Input::Option + raise ACON::Exceptions::InvalidArgument.new "The '--#{name_or_index}' option does not exist." unless self.has_option? name_or_index + + case name_or_index + in String then @options[name_or_index] + in Int32 then @options.values[name_or_index] + end + end + + # Returns a `::Hash` whose keys/values represent the names and default values of the `ACON::Input::Option`s defined within `self`. + def option_defaults : ::Hash + @options.to_h do |(name, opt)| + {name, opt.default} + end + end + + # Returns `true` if `self` has an option with the provided *name_or_index*. + def has_option?(name_or_index : String | Int32) : Bool + case name_or_index + in String then @options.has_key? name_or_index + in Int32 then !@options.values.[name_or_index]?.nil? + end + end + + # Returns `true` if `self` has a shortcut with the provided *name*, otherwise `false`. + def has_shortcut?(name : String | Char) : Bool + @shortcuts.has_key? name.to_s + end + + # Returns `true` if `self` has a negation with the provided *name*, otherwise `false`. + def has_negation?(name : String | Char) : Bool + @negations.has_key? name.to_s + end + + # Returns the name of the `ACON::Input::Option` that maps to the provided *negation*. + def negation_to_name(negation : String) : String + raise ACON::Exceptions::InvalidArgument.new "The '--#{negation}' option does not exist." unless self.has_negation? negation + + @negations[negation] + end + + # Returns the name of the `ACON::Input::Option` with the provided *shortcut*. + def option_for_shortcut(shortcut : String | Char) : ACON::Input::Option + self.option self.shortcut_to_name shortcut.to_s + end + + # Returns an optionally *short* synopsis based on the `ACON::Input::Argument`s and `ACON::Input::Option`s defined within `self`. + # + # The synopsis being the [docopt](http://docopt.org) string representing the expected options/arguments. + # E.g. ` move [--speed=]`. + # ameba:disable Metrics/CyclomaticComplexity + def synopsis(short : Bool = false) : String + elements = [] of String + + if short && !@options.empty? + elements << "[options]" + elsif !short + @options.each_value do |opt| + value = "" + + if opt.accepts_value? + value = sprintf( + " %s%s%s", + opt.value_optional? ? "[" : "", + opt.name.upcase, + opt.value_optional? ? "]" : "", + ) + end + + shortcut = (s = opt.shortcut) ? sprintf("-%s|", s) : "" + negation = opt.negatable? ? sprintf("|--no-%s", opt.name) : "" + + elements << "[#{shortcut}--#{opt.name}#{value}#{negation}]" + end + end + + if !elements.empty? && !@arguments.empty? + elements << "[--]" + end + + tail = "" + + @arguments.each_value do |arg| + element = "<#{arg.name}>" + element += "..." if arg.is_array? + + unless arg.required? + element = "[#{element}" + tail += "]" + end + + elements << element + end + + %(#{elements.join " "}#{tail}) + end + + protected def shortcut_to_name(shortcut : String) : String + raise ACON::Exceptions::InvalidArgument.new "The '-#{shortcut}' option does not exist." unless self.has_shortcut? shortcut + + @shortcuts[shortcut] + end +end diff --git a/src/input/hash.cr b/src/input/hash.cr new file mode 100644 index 0000000..d42f9a5 --- /dev/null +++ b/src/input/hash.cr @@ -0,0 +1,124 @@ +# An `ACON::Input::Interface` based on a [Hash](https://crystal-lang.org/api/Hash.html). +# +# Primarily useful for manually invoking commands, or as part of tests. +# +# ``` +# ACON::Input::Hash.new(name: "George", "--foo": "bar") +# ``` +# +# The keys of the input should be the name of the argument. +# Options should have `--` prefixed to their name. +class Athena::Console::Input::Hash < Athena::Console::Input + @parameters : ::Hash(String, ACON::Input::Value) + + def self.new(*args : _) : self + new args + end + + def self.new(**args : _) : self + new args.to_h + end + + def initialize(args : ::Hash = ::Hash(NoReturn, NoReturn).new, definition : ACON::Input::Definition? = nil) + hash = ::Hash(String, ACON::Input::Value).new + + args.each do |key, value| + hash[key.to_s] = ACON::Input::Value.from_value value + end + + @parameters = hash + + super definition + end + + def initialize(args : Enumerable, definition : ACON::Input::Definition? = nil) + hash = ::Hash(String, ACON::Input::Value).new + + args.each do |arg| + hash[arg.to_s] = ACON::Input::Value::Nil.new + end + + @parameters = hash + + super definition + end + + # :inherit: + def first_argument : String? + @parameters.each do |name, value| + next if name.starts_with? '-' + + return value.value.as(String) + end + + nil + end + + # :inherit: + def has_parameter?(*values : String, only_params : Bool = false) : Bool + @parameters.each do |name, value| + value = value.value + value = name unless value.is_a? Number + return false if only_params && "--" == value + return true if values.includes? value + end + + false + end + + # :inherit: + def parameter(value : String, default : _ = false, only_params : Bool = false) + @parameters.each do |name, v| + return default if only_params && ("--" == name || "--" == value) + return v.value if value == name + end + + default + end + + protected def parse : Nil + @parameters.each do |name, value| + return if "--" == name + + if name.starts_with? "--" + self.add_long_option name.lchop("--"), value + elsif name.starts_with? '-' + self.add_short_option name.lchop('-'), value + else + self.add_argument name, value + end + end + end + + private def add_argument(name : String, value : ACON::Input::Value) : Nil + raise ACON::Exceptions::InvalidArgument.new "The '#{name}' argument does not exist." if !@definition.has_argument? name + + @arguments[name] = value + end + + private def add_long_option(name : String, value : ACON::Input::Value) : Nil + unless @definition.has_option?(name) + raise ACON::Exceptions::InvalidOption.new "The '--#{name}' option does not exist." unless @definition.has_negation? name + + option_name = @definition.negation_to_name name + return @options[option_name] = ACON::Input::Value::Bool.new false + end + + option = @definition.option name + + if value.is_a? ACON::Input::Value::Nil + raise ACON::Exceptions::InvalidOption.new "The '--#{option.name}' option requires a value." if option.value_required? + value = ACON::Input::Value::Bool.new(true) if !option.is_array? && !option.value_optional? + end + + @options[name] = value + end + + private def add_short_option(name : String, value : ACON::Input::Value) : Nil + name = name.to_s + + raise ACON::Exceptions::InvalidOption.new "The '-#{name}' option does not exist." if !@definition.has_shortcut? name + + self.add_long_option @definition.option_for_shortcut(name).name, value + end +end diff --git a/src/input/input.cr b/src/input/input.cr new file mode 100644 index 0000000..4fbcf61 --- /dev/null +++ b/src/input/input.cr @@ -0,0 +1,190 @@ +require "./interface" +require "./streamable" + +require "./value/*" + +# Common base implementation of `ACON::Input::Interface`. +abstract class Athena::Console::Input + include Athena::Console::Input::Streamable + + # :inherit: + property stream : IO? = nil + + # :inherit: + property? interactive : Bool = true + + @arguments = ::Hash(String, ACON::Input::Value).new + @definition : ACON::Input::Definition + @options = ::Hash(String, ACON::Input::Value).new + + def initialize(definition : ACON::Input::Definition? = nil) + if definition.nil? + @definition = ACON::Input::Definition.new + else + @definition = definition + self.bind definition + self.validate + end + end + + # :inherit: + def argument(name : String) : String? + raise ACON::Exceptions::InvalidArgument.new "The '#{name}' argument does not exist." unless @definition.has_argument? name + + value = if @arguments.has_key? name + @arguments[name] + else + @definition.argument(name).default + end + + case value + when Nil, ACON::Input::Value::Nil then nil + else + value.to_s + end + end + + # :inherit: + def argument(name : String, type : T.class) : T forall T + raise ACON::Exceptions::InvalidArgument.new "The '#{name}' argument does not exist." unless @definition.has_argument? name + + {% unless T.nilable? %} + if !@definition.argument(name).required? && @definition.argument(name).default.nil? + raise ACON::Exceptions::Logic.new "Cannot cast optional argument '#{name}' to non-nilable type '#{T}' without a default." + end + {% end %} + + if @arguments.has_key? name + return @arguments[name].get T + end + + @definition.argument(name).default T + end + + # :inherit: + def set_argument(name : String, value : _) : Nil + raise ACON::Exceptions::InvalidArgument.new "The '#{name}' argument does not exist." unless @definition.has_argument? name + + @arguments[name] = ACON::Input::Value.from_value value + end + + # :inherit: + def arguments : ::Hash + @definition.argument_defaults.merge(self.resolve @arguments) + end + + # :inherit: + def has_argument?(name : String) : Bool + @definition.has_argument? name + end + + # :inherit: + def option(name : String) : String? + if @definition.has_negation?(name) + self.option(@definition.negation_to_name(name), Bool?).try do |v| + return (!v).to_s + end + + return + end + + raise ACON::Exceptions::InvalidArgument.new "The '#{name}' option does not exist." unless @definition.has_option? name + + value = if @options.has_key? name + @options[name] + else + @definition.option(name).default + end + + case value + when Nil, ACON::Input::Value::Nil then nil + else + value.to_s + end + end + + # :inherit: + def option(name : String, type : T.class) : T forall T + {% if T <= Bool? %} + if @definition.has_negation?(name) + negated_name = @definition.negation_to_name(name) + + if @options.has_key? negated_name + return !@options[negated_name].get T + end + + raise "BUG: Didn't return negated value." + end + {% end %} + + raise ACON::Exceptions::InvalidArgument.new "The '#{name}' option does not exist." unless @definition.has_option? name + + {% unless T <= Bool? %} + raise ACON::Exceptions::Logic.new "Cannot cast negatable option '#{name}' to non 'Bool?' type." if @definition.option(name).negatable? + {% end %} + + {% unless T.nilable? %} + if !@definition.option(name).value_required? && !@definition.option(name).negatable? && @definition.option(name).default.nil? + raise ACON::Exceptions::Logic.new "Cannot cast optional option '#{name}' to non-nilable type '#{T}' without a default." + end + {% end %} + + if @options.has_key? name + return @options[name].get T + end + + @definition.option(name).default T + end + + # :inherit: + def set_option(name : String, value : _) : Nil + if @definition.has_negation?(name) + return @options[@definition.negation_to_name(name)] = ACON::Input::Value.from_value !value + end + + raise ACON::Exceptions::InvalidArgument.new "The '#{name}' option does not exist." unless @definition.has_option? name + + @options[name] = ACON::Input::Value.from_value value + end + + # :inherit: + def options : ::Hash + @definition.option_defaults.merge(self.resolve @options) + end + + # :inherit: + def has_option?(name : String) : Bool + @definition.has_option?(name) || @definition.has_negation?(name) + end + + # :inherit: + def bind(definition : ACON::Input::Definition) : Nil + @arguments.clear + @options.clear + @definition = definition + + self.parse + end + + protected abstract def parse : Nil + + # :inherit: + def validate : Nil + missing_args = @definition.arguments.keys.select do |arg| + !@arguments.has_key?(arg) && @definition.argument(arg).required? + end + + raise ACON::Exceptions::ValidationFailed.new %(Not enough arguments (missing: '#{missing_args.join(", ")}').) unless missing_args.empty? + end + + private def resolve(hash : ::Hash(String, ACON::Input::Value)) : ::Hash + hash.transform_values do |value| + case value + when ACON::Input::Value::Array + value.value.map &.value + else + value.value + end + end + end +end diff --git a/src/input/interface.cr b/src/input/interface.cr new file mode 100644 index 0000000..da8cc9b --- /dev/null +++ b/src/input/interface.cr @@ -0,0 +1,175 @@ +require "./definition" + +# `Athena::Console` uses a dedicated interface for representing an input source. +# This allows it to have multiple more specialized implementations as opposed to +# being tightly coupled to `STDIN` or a raw [IO](https://crystal-lang.org/api/IO.html). +# This interface represents the methods that _must_ be implemented, however implementations can add additional functionality. +# +# All input sources follow the [docopt](http://docopt.org) standard, used by many CLI utility tools. +# Documentation on this type covers functionality/logic common to all inputs. +# See each type for more specific information. +# +# Option and argument values can be accessed via `ACON::Input::Interface#option` and `ACON::Input::Interface#argument` respectively. +# There are two overloads, the first accepting just the name of the option/argument as a `String`, returning the raw value as a `String?`, +# with arrays being represented as a comma separated list. +# The other two overloads accept a `T.class` representing the desired type the value should be parsed as. +# For example, given a command with two required and one array arguments: +# +# ``` +# protected def configure : Nil +# self +# .argument("bool", :required) +# .argument("int", :required) +# .argument("floats", :is_array) +# end +# ``` +# +# Assuming the invocation is `./console test false 10 3.14 172.0 123.7777`, the values could then be accessed like: +# +# ``` +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# input.argument "bool" # => "false" : String +# input.argument "bool", Bool # => false : Bool +# input.argument "int", Int8 # => 10 : Int8 +# +# input.argument "floats" # => "3.14,172.0,123.7777" : String +# input.argument "floats", Array(Float64) # => [3.14, 172.0, 123.7777] : Array(Float64) +# +# ACON::Command::Status::SUCCESS +# end +# ``` +# +# The latter syntax is preferred since it correctly types the value. +# If a provided value cannot be converted to the expected type, +# an `ACON::Exceptions::Logic` exception will be raised. +# E.g. `'123' is not a valid 'Bool'.`. +# +# TIP: Argument/option modes can be combined. +# E.g.`ACON::Input::Argument::Mode.flags(IS_ARRAY, REQUIRED)` for a required array argument. +# See also: https://github.com/crystal-lang/crystal/issues/10680. +# +# There are a lot of possible combinations in regards to what options are defined versus those are provided. +# To better illustrate how these cases are handled, let's look at an example of a command with three `ACON::Input::Option`s: +# +# ``` +# protected def configure : Nil +# self +# .option("foo", "f") +# .option("bar", "b", :required) +# .option("baz", "z", :optional) +# end +# ``` +# +# The value of `foo` will either be `true` if provided, otherwise `false`; this is the default behavior of `ACON::Input::Option`s. +# The `bar` (`b`) option is required to have a value. +# A value can be separated from the option's long name by either a space or `=` or by its short name by an optional space. +# Finally, the `baz` (`z`) option's value is optional. +# +# This table shows how the value of each option based on the provided input: +# +# | Input | foo | bar | baz | +# | :-----------------: | :-----: | :--------: | :--------: | +# | `--bar=Hello` | `false` | `"Hello"` | `nil` | +# | `--bar Hello` | `false` | `"Hello"` | `nil` | +# | `-b=Hello` | `false` | `"=Hello"` | `nil` | +# | `-b Hello` | `false` | `"Hello"` | `nil` | +# | `-bHello` | `false` | `"Hello"` | `nil` | +# | `-fzWorld -b Hello` | `true` | `"Hello"` | `"World"` | +# | `-zfWorld -b Hello` | `false` | `"Hello"` | `"fWorld"` | +# | `-zbWorld` | `false` | `nil` | `"bWorld"` | +# +# Things get a bit trickier when an optional `ACON::Input::Argument`: +# +# ``` +# protected def configure : Nil +# self +# .option("foo", "f") +# .option("bar", "b", :required) +# .option("baz", "z", :optional) +# .argument("arg", :optional) +# end +# ``` +# +# In some cases you may need to use the special `--` option in order to denote later values should be parsed as arguments, not as a value to an option: +# +# | Input | bar | baz | arg | +# | :--------------------------: | :-------------: | :-------: | :-------: | +# | `--bar Hello` | `"Hello"` | `nil` | `nil` | +# | `--bar Hello World` | `"Hello"` | `nil` | `"World"` | +# | `--bar "Hello World"` | `"Hello World"` | `nil` | `nil` | +# | `--bar Hello --baz World` | `"Hello"` | `"World"` | `nil` | +# | `--bar Hello --baz -- World` | `"Hello"` | `nil` | `"World"` | +# | `-b Hello -z World` | `"Hello"` | `"World"` | `nil` | +module Athena::Console::Input::Interface + # Returns the first argument from the raw un-parsed input. + # Mainly used to get the command that should be executed. + abstract def first_argument : String? + + # Returns `true` if the raw un-parsed input contains one of the provided *values*. + # + # This method is to be used to introspect the input parameters before they have been validated. + # It must be used carefully. + # It does not necessarily return the correct result for short options when multiple flags are combined in the same option. + # + # If *only_params* is `true`, only real parameters are checked. I.e. skipping those that come after the `--` option. + abstract def has_parameter?(*values : String, only_params : Bool = false) : Bool + + # Returns the value of a raw un-parsed parameter for the provided *value*.. + # + # This method is to be used to introspect the input parameters before they have been validated. + # It must be used carefully. + # It does not necessarily return the correct result for short options when multiple flags are combined in the same option. + # + # If *only_params* is `true`, only real parameters are checked. I.e. skipping those that come after the `--` option. + abstract def parameter(value : String, default : _ = false, only_params : Bool = false) + + # Binds the provided *definition* to `self`. + # Essentially provides what should be parsed from `self`. + abstract def bind(definition : ACON::Input::Definition) : Nil + + # Validates the input, asserting all of the required parameters are provided. + # Raises `ACON::Exceptions::ValidationFailed` if not valid. + abstract def validate : Nil + + # Returns a `::Hash` representing the keys and values of the parsed arguments of `self`. + abstract def arguments : ::Hash + + # Returns the raw string value of the argument with the provided *name*, or `nil` if is optional and was not provided. + abstract def argument(name : String) : String? + + # Returns the value of the argument with the provided *name* converted to the desired *type*. + # This method is preferred over `#argument` since it provides better typing. + # + # Raises an `ACON::Exceptions::Logic` if the actual argument value could not be converted to a *type*. + abstract def argument(name : String, type : T.class) forall T + + # Sets the *value* of the argument with the provided *name*. + abstract def set_argument(name : String, value : _) : Nil + + # Returns `true` if `self` has an argument with the provided *name*, otherwise `false`. + abstract def has_argument?(name : String) : Bool + + # Returns a `::Hash` representing the keys and values of the parsed options of `self`. + abstract def options : ::Hash + + # Returns the raw string value of the option with the provided *name*, or `nil` if is optional and was not provided. + abstract def option(name : String) : String? + + # Returns the value of the option with the provided *name* converted to the desired *type*. + # This method is preferred over `#option` since it provides better typing. + # + # Raises an `ACON::Exceptions::Logic` if the actual option value could not be converted to a *type*. + abstract def option(name : String, type : T.class) forall T + + # Sets the *value* of the option with the provided *name*. + abstract def set_option(name : String, value : _) : Nil + + # Returns `true` if `self` has an option with the provided *name*, otherwise `false`. + abstract def has_option?(name : String) : Bool + + # Returns `true` if `self` represents an interactive input, such as a TTY. + abstract def interactive? : Bool + + # Sets if `self` is `#interactive?`. + abstract def interactive=(interactive : Bool) +end diff --git a/src/input/option.cr b/src/input/option.cr new file mode 100644 index 0000000..774a6fa --- /dev/null +++ b/src/input/option.cr @@ -0,0 +1,163 @@ +# Represents a value (or array of ) provided to a command as optional un-ordered flags +# that be setup to accept a value, or represent a boolean flag. +# Options can also have an optional shortcut, default value, and/or description. +# +# Options are specified with two dashes, or one dash when using the shortcut. +# For example, `./console test --yell --dir=src -v`. +# We have one option representing a boolean value, providing a value to another, and using the shortcut of another. +# +# Options can be added via the `ACON::Command#option` method, +# or by instantiating one manually as part of an `ACON::Input::Definition`. +# The value of the option could then be accessed via one of the `ACON::Input::Interface#option` overloads. +# +# See `ACON::Input::Interface` for more examples on how arguments/options are parsed, and how they can be accessed. +class Athena::Console::Input::Option + @[Flags] + # Represents the possible vale types of an `ACON::Input::Option`. + # + # Value modes can also be combined using the [Enum.flags](https://crystal-lang.org/api/master/Enum.html#flags%28%2Avalues%29-macro) macro. + # For example, `ACON::Input::Option::Value.flags REQUIRED, IS_ARRAY` which defines a required array option. + enum Value + # Represents a boolean flag option that will be `true` if provided, otherwise `false`. + # E.g. `--yell`. + NONE = 0 + + # Represents an option that _MUST_ have a value if provided. + # The option itself is still optional. + # E.g. `--dir=src`. + REQUIRED + + # Represents an option that _MAY_ have a value, but it is not a requirement. + # E.g. `--yell` or `--yell=loud`. + # + # When using the option value mode, it can be hard to distinguish between passing an option without a value and not passing it at all. + # In this case you should set the default of the option to `false`, instead of the default of `nil`. + # Then you would be able to tell it wasn't passed by the value being `false`, passed without a value as `nil`, and passed with a value. + # + # NOTE: In this context you will need to work with the raw `String?` representation of the value due to the union of types the value could be. + OPTIONAL + + # Represents an option that can be provided multiple times to produce an array of values. + # E.g. `--dir=/foo --dir=/bar`. + IS_ARRAY + + # Similar to `NONE`, but also accepts its negation. + # E.g. `--yell` or `--no-yell`. + NEGATABLE + + def accepts_value? : Bool + self.required? || self.optional? + end + end + + # Returns the name of `self`. + getter name : String + + # Returns the shortcut of `self`, if any. + getter shortcut : String? + + # Returns the `ACON::Input::Option::Value` of `self`. + getter value_mode : ACON::Input::Option::Value + + # Returns the description of `self`. + getter description : String + + @default : ACON::Input::Value? = nil + + def initialize( + name : String, + shortcut : String | Enumerable(String) | Nil = nil, + @value_mode : ACON::Input::Option::Value = :none, + @description : String = "", + default = nil + ) + @name = name.lchop "--" + + raise ACON::Exceptions::InvalidArgument.new "An option name cannot be blank." if name.blank? + + unless shortcut.nil? + if shortcut.is_a? Enumerable + shortcut = shortcut.join '|' + end + + shortcut = shortcut.lchop('-').split(/(?:\|)-?/, remove_empty: true).map(&.strip.lchop('-')).join '|' + + raise ACON::Exceptions::InvalidArgument.new "An option shortcut cannot be blank." if shortcut.blank? + end + + @shortcut = shortcut + + if @value_mode.is_array? && !self.accepts_value? + raise ACON::Exceptions::InvalidArgument.new " Cannot have VALUE::IS_ARRAY option mode when the option does not accept a value." + end + + if @value_mode.negatable? && self.accepts_value? + raise ACON::Exceptions::InvalidArgument.new " Cannot have VALUE::NEGATABLE option mode if the option also accepts a value." + end + + self.default = default + end + + def_equals @name, @shortcut, @default, @value_mode + + # Returns the default value of `self`, if any. + def default + @default.try do |value| + case value + when ACON::Input::Value::Array + value.value.map &.value + else + value.value + end + end + end + + # Returns the default value of `self`, if any, converted to the provided *type*. + def default(type : T.class) : T forall T + {% if T.nilable? %} + self.default.as T + {% else %} + @default.not_nil!.get T + {% end %} + end + + # Sets the default value of `self`. + def default=(default = nil) : Nil + raise ACON::Exceptions::Logic.new "Cannot set a default value when using Value::NONE mode." if @value_mode.none? && !default.nil? + + if @value_mode.is_array? + if default.nil? + return @default = ACON::Input::Value::Array.new + else + raise ACON::Exceptions::Logic.new "Default value for an array option must be an array." unless default.is_a? Array + end + end + + @default = ACON::Input::Value.from_value (@value_mode.accepts_value? || @value_mode.negatable?) ? default : false + end + + # Returns `true` if `self` is able to accept a value, otherwise `false`. + def accepts_value? : Bool + @value_mode.accepts_value? + end + + # Returns `true` if `self` is a required argument, otherwise `false`. + def is_array? : Bool + @value_mode.is_array? + end + + # Returns `true` if `self` is negatable, otherwise `false`. + def negatable? : Bool + @value_mode.negatable? + end + + # Returns `true` if `self` accepts a value and it is required, otherwise `false`. + def value_required? : Bool + @value_mode.required? + end + + # Returns `true` if `self` accepts a value but is optional, otherwise `false`. + def value_optional? : Bool + @value_mode.optional? + end +end diff --git a/src/input/streamable.cr b/src/input/streamable.cr new file mode 100644 index 0000000..5c256b9 --- /dev/null +++ b/src/input/streamable.cr @@ -0,0 +1,13 @@ +# An extension of `ACON::Input::Interface` that supports input stream [IOs](https://crystal-lang.org/api/IO.html). +# +# Allows customizing where the input data is read from. +# Defaults to `STDIN`. +module Athena::Console::Input::Streamable + include Athena::Console::Input::Interface + + # Returns the input stream. + abstract def stream : IO? + + # Sets the input stream. + abstract def stream=(@stream : IO?) +end diff --git a/src/input/value/array.cr b/src/input/value/array.cr new file mode 100644 index 0000000..fe7d09f --- /dev/null +++ b/src/input/value/array.cr @@ -0,0 +1,40 @@ +abstract struct Athena::Console::Input::Value; end + +# :nodoc: +struct Athena::Console::Input::Value::Array < Athena::Console::Input::Value + getter value : ::Array(Athena::Console::Input::Value) + + def self.from_array(array : ::Array) : self + new(array.map { |item| ACON::Input::Value.from_value item }) + end + + def self.new(value) + new [ACON::Input::Value.from_value value] + end + + def self.new + new [] of ACON::Input::Value + end + + def initialize(@value : ::Array(Athena::Console::Input::Value)); end + + def <<(value) + @value << ACON::Input::Value.from_value value + end + + def get(type : ::Array(T).class) : ::Array(T) forall T + @value.map &.get(T) + end + + def get(type : ::Array(T)?.class) : ::Array(T)? forall T + @value.map(&.get(T)) || nil + end + + def resolve + self.value.map &.resolve + end + + def to_s(io : IO) : ::Nil + @value.join io, ',' + end +end diff --git a/src/input/value/bool.cr b/src/input/value/bool.cr new file mode 100644 index 0000000..d151a4b --- /dev/null +++ b/src/input/value/bool.cr @@ -0,0 +1,18 @@ +# :nodoc: +struct Athena::Console::Input::Value::Bool < Athena::Console::Input::Value + getter value : ::Bool + + def initialize(@value : ::Bool); end + + def get(type : ::Bool.class) : ::Bool + @value + end + + def get(type : ::Bool?.class) : ::Bool? + @value.try do |v| + return v + end + + nil + end +end diff --git a/src/input/value/nil.cr b/src/input/value/nil.cr new file mode 100644 index 0000000..0fadbed --- /dev/null +++ b/src/input/value/nil.cr @@ -0,0 +1,4 @@ +# :nodoc: +struct Athena::Console::Input::Value::Nil < Athena::Console::Input::Value + def value : ::Nil; end +end diff --git a/src/input/value/number.cr b/src/input/value/number.cr new file mode 100644 index 0000000..fe2153a --- /dev/null +++ b/src/input/value/number.cr @@ -0,0 +1,16 @@ +# :nodoc: +struct Athena::Console::Input::Value::Number < Athena::Console::Input::Value + getter value : ::Number::Primitive + + def initialize(@value : ::Number::Primitive); end + + {% for type in ::Number::Primitive.union_types %} + def get(type : {{type.id}}.class) : {{type.id}} + {{type.id}}.new @value + end + + def get(type : {{type.id}}?.class) : {{type.id}}? + {{type.id}}.new(@value) || nil + end + {% end %} +end diff --git a/src/input/value/string.cr b/src/input/value/string.cr new file mode 100644 index 0000000..72fab67 --- /dev/null +++ b/src/input/value/string.cr @@ -0,0 +1,43 @@ +# :nodoc: +struct Athena::Console::Input::Value::String < Athena::Console::Input::Value + getter value : ::String + + def initialize(@value : ::String); end + + def get(type : ::Bool.class) : ::Bool + raise ACON::Exceptions::Logic.new "'#{@value}' is not a valid 'Bool'." unless @value.in? "true", "false" + + @value == "true" + end + + def get(type : ::Bool?.class) : ::Bool? + (@value == "true").try do |v| + raise ACON::Exceptions::Logic.new "'#{@value}' is not a valid 'Bool?'." unless @value.in? "true", "false" + return v + end + + nil + end + + def get(type : ::Array(T).class) : ::Array(T) forall T + Array.from_array(@value.split(',')).get ::Array(T) + end + + def get(type : ::Array(T)?.class) : ::Array(T)? forall T + Array.from_array(@value.split(',')).get ::Array(T)? + end + + {% for type in ::Number::Primitive.union_types %} + def get(type : {{type.id}}.class) : {{type.id}} + {{type.id}}.new @value + rescue ArgumentError + raise ACON::Exceptions::Logic.new "'#{@value}' is not a valid '#{{{type.id}}}'." + end + + def get(type : {{type.id}}?.class) : {{type.id}}? + {{type.id}}.new(@value) || nil + rescue ArgumentError + raise ACON::Exceptions::Logic.new "'#{@value}' is not a valid '#{{{type.id}}}'." + end + {% end %} +end diff --git a/src/input/value/value.cr b/src/input/value/value.cr new file mode 100644 index 0000000..6f47e38 --- /dev/null +++ b/src/input/value/value.cr @@ -0,0 +1,33 @@ +# :nodoc: +abstract struct Athena::Console::Input::Value + def self.from_value(value : T) : self forall T + case value + when ACON::Input::Value then value + when ::Nil then ACON::Input::Value::Nil.new + when ::String then ACON::Input::Value::String.new value + when ::Number then ACON::Input::Value::Number.new value + when ::Bool then ACON::Input::Value::Bool.new value + when ::Array then ACON::Input::Value::Array.from_array value + else + raise "Unsupported type: #{T}." + end + end + + def get(type : ::String.class) : ::String + self.to_s + end + + def get(type : ::String?.class) : ::String? + self.to_s.presence + end + + def get(type : T.class) : NoReturn forall T + raise ACON::Exceptions::Logic.new "'#{self.value}' is not a valid '#{T}'." + end + + def to_s(io : IO) : ::Nil + self.value.to_s io + end + + abstract def value +end diff --git a/src/loader/factory.cr b/src/loader/factory.cr new file mode 100644 index 0000000..ef9d144 --- /dev/null +++ b/src/loader/factory.cr @@ -0,0 +1,42 @@ +require "./interface" + +# A default implementation of `ACON::Loader::Interface` that accepts a `Hash(String, Proc(ACON::Command))`. +# +# A factory could then be set on the `ACON::Application`: +# +# ``` +# application = MyCustomApplication.new "My CLI" +# +# application.command_loader = Athena::Console::Loader::Factory.new({ +# "command1" => Proc(ACON::Command).new { Command1.new }, +# "app:create-user" => Proc(ACON::Command).new { CreateUserCommand.new }, +# }) +# +# application.run +# ``` +struct Athena::Console::Loader::Factory + include Athena::Console::Loader::Interface + + @factories : Hash(String, Proc(ACON::Command)) + + def initialize(@factories : Hash(String, Proc(ACON::Command))); end + + # :inherit: + def get(name : String) : ACON::Command + if factory = @factories[name]? + factory.call + else + raise ACON::Exceptions::CommandNotFound.new "Command '#{name}' does not exist." + end + end + + # :inherit: + def has?(name : String) : Bool + @factories.has_key? name + end + + # :inherit: + def names : Array(String) + @factories.keys + end +end diff --git a/src/loader/interface.cr b/src/loader/interface.cr new file mode 100644 index 0000000..d5cfc0e --- /dev/null +++ b/src/loader/interface.cr @@ -0,0 +1,14 @@ +# Normally the `ACON::Application#add` method requires instances of each command to be provided. +# `ACON::Loader::Interface` provides a way to lazily instantiate only the command(s) being called, +# which can be more performant since not every command needs instantiated. +module Athena::Console::Loader::Interface + # Returns an `ACON::Command` with the provided *name*. + # Raises `ACON::Exceptions::CommandNotFound` if it is not defined. + abstract def get(name : String) : ACON::Command + + # Returns `true` if `self` has a command with the provided *name*, otherwise `false`. + abstract def has?(name : String) : Bool + + # Returns all of the command names defined within `self`. + abstract def names : Array(String) +end diff --git a/src/output/console_output.cr b/src/output/console_output.cr new file mode 100644 index 0000000..c1cdaf9 --- /dev/null +++ b/src/output/console_output.cr @@ -0,0 +1,66 @@ +abstract class Athena::Console::Output; end + +require "./console_output_interface" +require "./io" + +# An `ACON::Output::ConsoleOutputInterface` that wraps `STDOUT` and `STDERR`. +class Athena::Console::Output::ConsoleOutput < Athena::Console::Output::IO + include Athena::Console::Output::ConsoleOutputInterface + + # Sets the `ACON::Output::Interface` that represents `STDERR`. + setter stderr : ACON::Output::Interface + @console_section_outputs = Array(ACON::Output::Section).new + + def initialize( + verbosity : ACON::Output::Verbosity = :normal, + decorated : Bool? = nil, + formatter : ACON::Formatter::Interface? = nil + ) + super STDOUT, verbosity, decorated, formatter + + @stderr = ACON::Output::IO.new STDERR, verbosity, decorated, @formatter + actual_decorated = self.decorated? + + if decorated.nil? + self.decorated = actual_decorated && @stderr.decorated? + end + end + + # :inherit: + def section : ACON::Output::Section + ACON::Output::Section.new( + self.io, + @console_section_outputs, + self.verbosity, + self.decorated?, + self.formatter + ) + end + + # :inherit: + def error_output : ACON::Output::Interface + @stderr + end + + # :inherit: + def error_output=(@stderr : ACON::Output::Interface) : Nil + end + + # :inherit: + def decorated=(decorated : Bool) : Nil + super + @stderr.decorated = decorated + end + + # :inherit: + def formatter=(formatter : Bool) : Nil + super + @stderr.formatter = formatter + end + + # :inherit: + def verbosity=(verbosity : ACON::Output::Verbosity) : Nil + super + @stderr.verbosity = verbosity + end +end diff --git a/src/output/console_output_interface.cr b/src/output/console_output_interface.cr new file mode 100644 index 0000000..7c4a14f --- /dev/null +++ b/src/output/console_output_interface.cr @@ -0,0 +1,14 @@ +require "./interface" + +# Extension of `ACON::Output::Interface` that adds additional functionality for terminal based outputs. +module Athena::Console::Output::ConsoleOutputInterface + # include Athena::Console::Output::Interface + + # Returns an `ACON::Output::Interface` that represents `STDERR`. + abstract def error_output : ACON::Output::Interface + + # Sets the `ACON::Output::Interface` that represents `STDERR`. + abstract def error_output=(error_output : ACON::Output::Interface) : Nil + + abstract def section : ACON::Output::Section +end diff --git a/src/output/interface.cr b/src/output/interface.cr new file mode 100644 index 0000000..9425174 --- /dev/null +++ b/src/output/interface.cr @@ -0,0 +1,37 @@ +# `Athena::Console` uses a dedicated interface for representing an output destination. +# This allows it to have multiple more specialized implementations as opposed to +# being tightly coupled to `STDOUT` or a raw [IO](https://crystal-lang.org/api/IO.html). +# This interface represents the methods that _must_ be implemented, however implementations can add additional functionality. +# +# The most common implementations include `ACON::Output::ConsoleOutput` which is based on `STDOUT` and `STDERR`, +# and `ACON::Output::Null` which can be used when you want to silent all output, such as for tests. +# +# Each output's `ACON::Output::Verbosity` and output `ACON::Output::Type` can also be configured on a per message basis. +module Athena::Console::Output::Interface + # Outputs the provided *message* followed by a new line. + # The *verbosity* and/or *output_type* parameters can be used to control when and how the *message* is printed. + abstract def puts(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + + # Outputs the provided *message*. + # The *verbosity* and/or *output_type* parameters can be used to control when and how the *message* is printed. + abstract def print(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + + # Returns the minimum `ACON::Output::Verbosity` required for a message to be printed. + abstract def verbosity : ACON::Output::Verbosity + + # Set the minimum `ACON::Output::Verbosity` required for a message to be printed. + abstract def verbosity=(verbosity : ACON::Output::Verbosity) : Nil + + # Returns `true` if printed messages should have their decorations applied. + # I.e. `ACON::Formatter::OutputStyleInterface`. + abstract def decorated? : Bool + + # Sets if printed messages should be *decorated*. + abstract def decorated=(decorated : Bool) : Nil + + # Returns the `ACON::Formatter::Interface` used by `self`. + abstract def formatter : ACON::Formatter::Interface + + # Sets the `ACON::Formatter::Interface` used by `self`. + abstract def formatter=(formatter : ACON::Formatter::Interface) : Nil +end diff --git a/src/output/io.cr b/src/output/io.cr new file mode 100644 index 0000000..1c44006 --- /dev/null +++ b/src/output/io.cr @@ -0,0 +1,29 @@ +# An `ACON::Output::Interface` implementation that wraps an [IO](https://crystal-lang.org/api/IO.html). +class Athena::Console::Output::IO < Athena::Console::Output + property io : ::IO + + delegate :to_s, to: @io + + def initialize( + @io : ::IO, + verbosity : ACON::Output::Verbosity? = :normal, + decorated : Bool? = nil, + formatter : ACON::Formatter::Interface? = nil + ) + decorated = self.has_color_support? if decorated.nil? + + super verbosity, decorated, formatter + end + + protected def do_write(message : String, new_line : Bool) : Nil + new_line ? @io.puts(message) : @io.print(message) + end + + private def has_color_support? : Bool + # Respect https://no-color.org. + return false if "false" == ENV["NO_COLOR"]? + return true if "Hyper" == ENV["TERM_PROGRAM"]? + + @io.tty? + end +end diff --git a/src/output/null.cr b/src/output/null.cr new file mode 100644 index 0000000..d7aecc9 --- /dev/null +++ b/src/output/null.cr @@ -0,0 +1,49 @@ +require "./interface" + +# An `ACON::Output::Interface` that does not output anything, such as for tests. +class Athena::Console::Output::Null + include Athena::Console::Output::Interface + + @formatter : ACON::Formatter::Interface? = nil + + # :inherit: + def puts(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + end + + # :inherit: + def print(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + end + + def puts(message, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + end + + def print(message, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + end + + # :inherit: + def verbosity : ACON::Output::Verbosity + ACON::Output::Verbosity::QUIET + end + + # :inherit: + def verbosity=(verbosity : ACON::Output::Verbosity) : Nil + end + + # :inherit: + def decorated=(decorated : Bool) : Nil + end + + # :inherit: + def decorated? : Bool + false + end + + # :inherit: + def formatter : ACON::Formatter::Interface + @formatter ||= ACON::Formatter::Null.new + end + + # :inherit: + def formatter=(formatter : ACON::Formatter::Interface) : Nil + end +end diff --git a/src/output/output.cr b/src/output/output.cr new file mode 100644 index 0000000..9102b99 --- /dev/null +++ b/src/output/output.cr @@ -0,0 +1,101 @@ +require "./interface" + +# Common base implementation of `ACON::Output::Interface`. +abstract class Athena::Console::Output + include Athena::Console::Output::Interface + + @formatter : ACON::Formatter::Interface + @verbosity : ACON::Output::Verbosity + + def initialize( + verbosity : ACON::Output::Verbosity? = :normal, + decorated : Bool = false, + formatter : ACON::Formatter::Interface? = nil + ) + @verbosity = verbosity || ACON::Output::Verbosity::NORMAL + @formatter = formatter || ACON::Formatter::Output.new + @formatter.decorated = decorated + end + + # :inherit: + def verbosity : ACON::Output::Verbosity + @verbosity + end + + # :inherit: + def verbosity=(@verbosity : ACON::Output::Verbosity) : Nil + end + + # :inherit: + def formatter : ACON::Formatter::Interface + @formatter + end + + # :inherit: + def formatter=(@formatter : ACON::Formatter::Interface) : Nil + end + + # :inherit: + def decorated? : Bool + @formatter.decorated? + end + + # :inherit: + def decorated=(decorated : Bool) : Nil + @formatter.decorated = decorated + end + + # :inherit: + def puts(*messages : String) : Nil + self.puts messages + end + + # :inherit: + def puts(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + self.write message, true, verbosity, output_type + end + + # :inherit: + def puts(message : _, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + self.puts message.to_s, verbosity, output_type + end + + # :inherit: + def print(*messages : String) : Nil + self.print messages + end + + # :inherit: + def print(message : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + self.write message, false, verbosity, output_type + end + + # :inherit: + def print(message : _, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + self.print message.to_s, verbosity, output_type + end + + protected def write( + message : String | Enumerable(String), + new_line : Bool, + verbosity : ACON::Output::Verbosity, + output_type : ACON::Output::Type + ) + messages = message.is_a?(String) ? {message} : message + + return if verbosity > self.verbosity + + messages.each do |m| + self.do_write( + case output_type + in .normal? then @formatter.format m + in .plain? then @formatter.format(m).gsub(/(?:<\/?[^>]*>)|(?:[\n]?)/, "") # TODO: Use a more robust strip_tags implementation. + in .raw? then m + end, + new_line + ) + end + end + + protected abstract def do_write(message : String, new_line : Bool) : Nil +end diff --git a/src/output/section.cr b/src/output/section.cr new file mode 100644 index 0000000..f0e957b --- /dev/null +++ b/src/output/section.cr @@ -0,0 +1,134 @@ +require "./io" + +# A `ACON::Output::ConsoleOutput` can be divided into multiple sections that can be written to and cleared independently of one another. +# +# Output sections can be used for advanced console outputs, such as displaying multiple progress bars which are updated independently, +# or appending additional rows to tables. +# +# TODO: Implement progress bars and tables. +# +# ``` +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# raise ArgumentError.new "This command may only be used with `ACON::Output::ConsoleOutputInterface`." unless output.is_a? ACON::Output::ConsoleOutputInterface +# +# section1 = output.section +# section2 = output.section +# +# section1.puts "Hello" +# section2.puts "World!" +# # Output contains "Hello\nWorld!\n" +# +# sleep 1 +# +# # Replace "Hello" with "Goodbye!" +# section1.overwrite "Goodbye!" +# # Output now contains "Goodbye\nWorld!\n" +# +# sleep 1 +# +# # Clear "World!" +# section2.clear +# # Output now contains "Goodbye!\n" +# +# sleep 1 +# +# # Delete the last 2 lines of the first section +# section1.clear 2 +# # Output is now empty +# +# ACON::Command::Status::SUCCESS +# end +# ``` +class Athena::Console::Output::Section < Athena::Console::Output::IO + protected getter lines = 0 + + @content = [] of String + @sections : Array(self) + @terminal : ACON::Terminal + + def initialize( + io : ::IO, + @sections : Array(self), + verbosity : ACON::Output::Verbosity, + decorated : Bool, + formatter : ACON::Formatter::Interface + ) + super io, verbosity, decorated, formatter + + @terminal = ACON::Terminal.new + @sections.unshift self + end + + # Returns the full content string contained within `self`. + def content : String + @content.join + end + + # Clears at most *lines* from `self`. + # If *lines* is `nil`, all of `self` is cleared. + def clear(lines : Int32? = nil) : Nil + return if @content.empty? || !self.decorated? + + if lines && @lines >= lines + # Double the lines to account for each new line added between content + @content.delete_at (-lines * 2)..-1 + else + lines = @lines + @content.clear + end + + @lines -= lines + + @io.print self.pop_stream_content_until_current_section(lines) + end + + # Overrides the current content of `self` with the provided *message*. + def overwrite(message : String) : Nil + self.clear + self.puts message + end + + protected def add_content(input : String) : Nil + input.each_line do |line| + lines = (self.get_display_width(line) // @terminal.width).ceil + @lines += lines.zero? ? 1 : lines + @content.push line, "\n" + end + end + + protected def do_write(message : String, new_line : Bool) : Nil + return super unless self.decorated? + + erased_content = self.pop_stream_content_until_current_section + + self.add_content message + super message, true + super erased_content, false + end + + private def get_display_width(input : String) : Int32 + ACON::Helper.width ACON::Helper.remove_decoration(self.formatter, input.gsub("\t", " ")) + end + + private def pop_stream_content_until_current_section(lines_to_clear_from_current_section : Int32 = 0) : String + number_of_lines_to_clear = lines_to_clear_from_current_section + erased_content = Array(String).new + + @sections.each do |section| + break if self == section + + number_of_lines_to_clear += section.lines + erased_content << section.content + end + + if number_of_lines_to_clear > 0 + # Move cursor up n lines + @io.print "\e[#{number_of_lines_to_clear}A" + + # Erase to end of screen + @io.print "\e[0J" + end + + erased_content.reverse.join + end +end diff --git a/src/output/sized_buffer.cr b/src/output/sized_buffer.cr new file mode 100644 index 0000000..619b44c --- /dev/null +++ b/src/output/sized_buffer.cr @@ -0,0 +1,30 @@ +# :nodoc: +class Athena::Console::Output::SizedBuffer < Athena::Console::Output + @buffer : String = "" + @max_length : Int32 + + def initialize( + @max_length : Int32, + verbosity : ACON::Output::Verbosity? = :normal, + decorated : Bool = false, + formatter : ACON::Formatter::Interface? = nil + ) + super verbosity, decorated, formatter + end + + def fetch : String + content = @buffer + + @buffer = "" + + content + end + + protected def do_write(message : String, new_line : Bool) : Nil + @buffer += message + + @buffer += "\n" if new_line + + @buffer = @buffer.chars.last(@max_length).join + end +end diff --git a/src/output/type.cr b/src/output/type.cr new file mode 100644 index 0000000..f55a5c3 --- /dev/null +++ b/src/output/type.cr @@ -0,0 +1,21 @@ +# Determines how a message should be printed. +# +# When you output a message via `ACON::Output::Interface#puts` or `ACON::Output::Interface#print`, they also provide a way to set the output type it should be printed: +# +# ``` +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# output.puts "Some Message", output_type: :raw +# +# ACON::Command::Status::SUCCESS +# end +# ``` +enum Athena::Console::Output::Type + # Normal output, with any styles applied to format the text. + NORMAL + + # Output style tags as is without formatting the string. + RAW + + # Strip any style tags and only output the actual text. + PLAIN +end diff --git a/src/output/verbosity.cr b/src/output/verbosity.cr new file mode 100644 index 0000000..2a53cb2 --- /dev/null +++ b/src/output/verbosity.cr @@ -0,0 +1,63 @@ +# Verbosity levels determine which messages will be displayed, essentially the same idea as [Log::Severity](https://crystal-lang.org/api/Log/Severity.html) but for console output. +# +# For example: +# +# ```sh +# # Output nothing +# ./console my-command -q +# ./console my-command --quiet +# +# # Display only useful output +# ./console my-command +# +# # Increase the verbosity of messages +# ./console my-command -v +# +# # Also display non-essential information +# ./console my-command -vv +# +# # Display all messages, such as for debugging +# ./console my-command -vvv +# ``` +# +# As used in the previous example, the verbosity can be controlled on a command invocation basis using a CLI option, +# but may also be globally set via the `SHELL_VERBOSITY` environmental variable. +# +# When you output a message via `ACON::Output::Interface#puts` or `ACON::Output::Interface#print`, they also provide a way to set the verbosity at which that message should print: +# +# ``` +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# # Via conditional logic +# if output.verbosity.verbose? +# output.puts "Obj class: #{obj.class}" +# end +# +# # Inline within the method +# output.puts "Only print this in verbose mode or higher", verbosity: :verbose +# +# ACON::Command::Status::SUCCESS +# end +# ``` +# +# TIP: The full stack trace of an exception is printed in `ACON::Output::Verbosity::VERBOSE` mode or higher. +enum Athena::Console::Output::Verbosity + # Silences all output. + # Equivalent to `-q`, `--quiet` CLI options or `SHELL_VERBOSITY=-1`. + QUIET = -1 + + # Normal behavior, display only useful messages. + # Equivalent not providing any CLI options or `SHELL_VERBOSITY=0`. + NORMAL = 0 + + # Increase the verbosity of messages. + # Equivalent to `-v`, `--verbose=1` CLI options or `SHELL_VERBOSITY=1`. + VERBOSE = 1 + + # Display all the informative non-essential messages. + # Equivalent to `-vv`, `--verbose=2` CLI options or `SHELL_VERBOSITY=2`. + VERY_VERBOSE = 2 + + # Display all messages, such as for debugging. + # Equivalent to `-vvv`, `--verbose=3` CLI options or `SHELL_VERBOSITY=3`. + DEBUG = 3 +end diff --git a/src/question/abstract_choice.cr b/src/question/abstract_choice.cr new file mode 100644 index 0000000..7321afe --- /dev/null +++ b/src/question/abstract_choice.cr @@ -0,0 +1,95 @@ +class Athena::Console::Question(T); end + +require "./base" + +# Base type of choice based questions. +# See each subclass for more information. +abstract class Athena::Console::Question::AbstractChoice(T, ChoiceType) + include Athena::Console::Question::Base(T?) + + # Returns the possible choices. + getter choices : Hash(String | Int32, T) + + # Returns the message to display if the provided answer is not a valid choice. + getter error_message : String = "Value '%s' is invalid." + + # Returns/sets the prompt to use for the question. + # The prompt being the character(s) before the user input. + property prompt : String = " > " + + # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. + property validator : Proc(T?, ChoiceType)? = nil + + def self.new(question : String, choices : Indexable(T), default : Int | T | Nil = nil) + choices_hash = Hash(String | Int32, T).new + + choices.each_with_index do |choice, idx| + choices_hash[idx] = choice + end + + new question, choices_hash, (default.is_a?(Int) ? choices[default]? : default) + end + + def initialize(question : String, choices : Hash(String | Int32, T), default : T? = nil) + super question, default + + raise ACON::Exceptions::Logic.new "Choice questions must have at least 1 choice available." if choices.empty? + + @choices = choices.transform_keys &.as String | Int32 + + self.validator = ->default_validator(T?) + self.autocompleter_values = choices + end + + def error_message=(@error_message : String) : self + self.validator = ->default_validator(T?) + + self + end + + # Sets the validator callback to the provided block. + # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. + def validator(&@validator : T? -> ChoiceType) : Nil + end + + private def selected_choices(answer : String?) : Array(T) + selected_choices = self.parse_answers answer + + if @trimmable + selected_choices.map! &.strip + end + + valid_choices = [] of String + selected_choices.each do |value| + results = [] of String + + @choices.each do |key, choice| + results << key.to_s if choice == value + end + + raise ACON::Exceptions::InvalidArgument.new %(The provided answer is ambiguous. Value should be one of #{results.join(" or ") { |i| "'#{i}'" }}.) if results.size > 1 + + result = @choices.find { |(k, v)| v == value || k.to_s == value }.try &.first.to_s + + # If none of the keys are a string, assume the original choice values were an Indexable. + if @choices.keys.none?(String) && result + result = @choices[result.to_i] + elsif @choices.has_key? value + result = @choices[value] + elsif @choices.has_key? result + result = @choices[result] + end + + if result.nil? + raise ACON::Exceptions::InvalidArgument.new sprintf(@error_message, value) + end + + valid_choices << result + end + + valid_choices + end + + protected abstract def default_validator(answer : T?) : ChoiceType + protected abstract def parse_answers(answer : T?) : Array(String) +end diff --git a/src/question/base.cr b/src/question/base.cr new file mode 100644 index 0000000..2462cf2 --- /dev/null +++ b/src/question/base.cr @@ -0,0 +1,118 @@ +# Common logic shared between all question types. +# See each type for more information. +module Athena::Console::Question::Base(T) + # Returns the question that should be asked. + getter question : String + + # Returns the default value if no valid input is provided. + getter default : T + + # Returns the answer should be hidden. + # See [Hiding User Input][Athena::Console::Question--hiding-user-input]. + getter? hidden : Bool = false + + # If hidden questions should fallback on making the response visible if it was unable to be hidden. + # See [Hiding User Input][Athena::Console::Question--hiding-user-input]. + property? hidden_fallback : Bool = true + + # Returns how many attempts the user has to enter a valid value when a `#validator` is set. + # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. + getter max_attempts : Int32? = nil + + # :nodoc: + getter autocompleter_callback : Proc(String, Array(String))? = nil + + # See [Normalizing the Answer][Athena::Console::Question--normalizing-the-answer]. + property normalizer : Proc(T | String, T)? = nil + + # If multi line text should be allowed in the response. + # See [Multiline Input][Athena::Console::Question--multiline-input]. + property? multi_line : Bool = false + + # Returns/sets if the answer value should be automatically [trimmed](https://crystal-lang.org/api/String.html#strip%3AString-instance-method). + # See [Trimming the Answer][Athena::Console::Question--trimming-the-answer]. + property? trimmable : Bool = true + + def initialize(@question : String, @default : T) + {% T.raise "An ACON::Question generic argument cannot be 'Nil'. Use 'String?' instead." if T == Nil %} + end + + # :nodoc: + def autocompleter_values : Array(String)? + if callback = @autocompleter_callback + return callback.call "" + end + + nil + end + + # :nodoc: + def autocompleter_values=(values : Hash(String, _)?) : self + self.autocompleter_values = values.keys + values.values + end + + # :nodoc: + def autocompleter_values=(values : Hash?) : self + self.autocompleter_values = values.values + end + + # :nodoc: + def autocompleter_values=(values : Indexable?) : self + if values.nil? + @autocompleter_callback = nil + return self + end + + callback = Proc(String, Array(String)).new do + values.to_a + end + + self.autocompleter_callback &callback + + self + end + + # :nodoc: + def autocompleter_callback(&block : String -> Array(String)) : Nil + raise ACON::Exceptions::Logic.new "A hidden question cannot use the autocompleter." if @hidden + + @autocompleter_callback = block + end + + # Sets if the answer should be *hidden*. + # See [Hiding User Input][Athena::Console::Question--hiding-user-input]. + def hidden=(hidden : Bool) : self + raise ACON::Exceptions::Logic.new "A hidden question cannot use the autocompleter." if @autocompleter_callback + + @hidden = hidden + + self + end + + # Allow at most *attempts* for the user to enter a valid value when a `#validator` is set. + # If *attempts* is `nil`, they have an unlimited amount. + # + # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. + def max_attempts=(attempts : Int32?) : self + raise ACON::Exceptions::InvalidArgument.new "Maximum number of attempts must be a positive value." if attempts && attempts < 0 + + @max_attempts = attempts + self + end + + # Sets the normalizer callback to this block. + # See [Normalizing the Answer][Athena::Console::Question--normalizing-the-answer]. + def normalizer(&@normalizer : T | String -> T) : Nil + end + + protected def process_response(response : String) + response = response.presence || @default + + # Only call the normalizer with the actual response or a non nil default. + if (normalizer = @normalizer) && !response.nil? + return normalizer.call response + end + + response.as T + end +end diff --git a/src/question/choice.cr b/src/question/choice.cr new file mode 100644 index 0000000..9bdaf98 --- /dev/null +++ b/src/question/choice.cr @@ -0,0 +1,49 @@ +require "./abstract_choice" + +# A question whose answer _MUCH_ be within a set of predefined answers. +# If the user enters an invalid answer, an error is displayed and they are prompted again. +# +# ``` +# question = ACON::Question::Choice.new "What is your favorite color?", {"red", "blue", "green"} +# color = helper.ask input, output, question +# ``` +# +# This would display something like the following: +# +# ```sh +# What is your favorite color? +# [0] red +# [1] blue +# [2] green +# > +# ``` +# +# The user would be able to enter the name of the color, or the index associated with it. E.g. `blue` or `2` for `green`. +# If a `Hash` is used as the choices, the key of each choice is used instead of its index. +# +# Similar to `ACON::Question`, the third argument can be set to set the default choice. +# This value can also either be the actual value, or the index/key of the related choice. +# +# ``` +# question = ACON::Question::Choice.new "What is your favorite color?", {"c1" => "red", "c2" => "blue", "c3" => "green"}, "c2" +# color = helper.ask input, output, question +# ``` +# +# Which would display something like : +# +# ```sh +# What is your favorite color? +# [c1] red +# [c2] blue +# [c3] green +# > +# ``` +class Athena::Console::Question::Choice(T) < Athena::Console::Question::AbstractChoice(T, T?) + protected def default_validator(answer : T?) : T? + self.selected_choices(answer).first? + end + + protected def parse_answers(answer : T?) : Array(String) + [answer || ""] + end +end diff --git a/src/question/confirmation.cr b/src/question/confirmation.cr new file mode 100644 index 0000000..bfe1505 --- /dev/null +++ b/src/question/confirmation.cr @@ -0,0 +1,40 @@ +# Allows prompting the user to confirm an action. +# +# ``` +# question = ACON::Question::Confirmation.new "Continue with this action?", false +# +# if !helper.ask input, output, question +# return ACON::Command::Status::SUCCESS +# end +# +# # ... +# ``` +# +# In this example the user will be asked if they wish to `Continue with this action`. +# The `#ask` method will return `true` if the user enters anything starting with `y`, otherwise `false`. +class Athena::Console::Question::Confirmation < Athena::Console::Question(Bool) + @true_answer_regex : Regex + + # Creates a new instance of self with the provided *question* string. + # The *default* parameter represents the value to return if no valid input was entered. + # The *true_answer_regex* can be used to customize the pattern used to determine if the user's input evaluates to `true`. + def initialize(question : String, default : Bool = true, @true_answer_regex : Regex = /^y/i) + super question, default + + self.normalizer = ->default_normalizer(String | Bool) + end + + private def default_normalizer(answer : String | Bool) : Bool + if answer.is_a? Bool + return answer + end + + answer_is_true = answer.matches? @true_answer_regex + + if false == @default + return !answer.blank? && answer_is_true + end + + answer.empty? || answer_is_true + end +end diff --git a/src/question/multiple_choice.cr b/src/question/multiple_choice.cr new file mode 100644 index 0000000..f7d6f61 --- /dev/null +++ b/src/question/multiple_choice.cr @@ -0,0 +1,21 @@ +require "./abstract_choice" + +# Similar to `ACON::Question::Choice`, but allows for more than one answer to be selected. +# This question accepts a comma separated list of answers. +# +# ``` +# question = ACON::Question::MultipleChoice.new "What is your favorite color?", {"red", "blue", "green"} +# answer = helper.ask input, output, question +# ``` +# +# This question is also similar to `ACON::Question::Choice` in that you can provide either the index, key, or value of the choice. +# For example submitting `green,0` would result in `["green", "red"]` as the value of `answer`. +class Athena::Console::Question::MultipleChoice(T) < Athena::Console::Question::AbstractChoice(T, Array(T)) + protected def default_validator(answer : T?) : Array(T) + self.selected_choices answer + end + + protected def parse_answers(answer : T?) : Array(String) + answer.try(&.split(',')) || [""] + end +end diff --git a/src/question/question.cr b/src/question/question.cr new file mode 100644 index 0000000..0e27539 --- /dev/null +++ b/src/question/question.cr @@ -0,0 +1,104 @@ +require "./base" + +# This namespaces contains various questions that can be asked via the `ACON::Helper::Question` helper or `ART::Style::Athena` style. +# +# This class can also be used to ask the user for more information in the most basic form, a simple question and answer. +# +# ## Usage +# +# ``` +# question = ACON::Question(String?).new "What is your name?", nil +# name = helper.ask input, output, question +# ``` +# +# This will prompt to user to enter their name. If they do not enter valid input, the default value of `nil` will be used. +# The default can be customized, ideally with sane defaults to make the UX better. +# +# ### Trimming the Answer +# +# By default the answer is [trimmed](https://crystal-lang.org/api/String.html#strip%3AString-instance-method) in order to remove leading and trailing white space. +# The `ACON::Question::Base#trimmable=` method can be used to disable this if you need the input as is. +# +# ``` +# question = ACON::Question(String?).new "What is your name?", nil +# question.trimmable = false +# name_with_whitespace_and_newline = helper.ask input, output, question +# ``` +# +# ### Multiline Input +# +# The question helper will stop reading input when it receives a newline character. I.e. the user presses the `ENTER` key. +# However in some cases you may want to allow for an answer that spans multiple lines. +# The `ACON::Question::Base#multi_line=` method can be used to enable multi line mode. +# +# ``` +# question = ACON::Question(String?).new "Tell me a story.", nil +# question.multi_line = true +# ``` +# +# Multiline questions stop reading user input after receiving an end-of-transmission control character. (`Ctrl+D` on Unix systems). +# +# ### Hiding User Input +# +# If your question is asking for sensitive information, such as a password, you can set a question to hidden. +# This will make it so the input string is not displayed on the terminal, which is equivalent to how password are handled on Unix systems. +# +# ``` +# question = ACON::Question(String?).new "What is your password?.", nil +# question.hidden = true +# ``` +# +# WARNING: If no method to hide the response is available on the underlying system/input, it will fallback and allow the response to be seen. +# If having the hidden response hidden is vital, you _MUST_ set `ACON::Question::Base#hidden_fallback=` to `false`; which will +# raise an exception instead of allowing the input to be visible. +# +# ### Normalizing the Answer +# +# The answer can be "normalized" before being validated to fix any small errors or tweak it as needed. +# For example, you could normalize the casing of the input: +# +# ``` +# question = ACON::Question(String?).new "Enter your name.", nil +# question.normalizer do |input| +# input.try &.downcase +# end +# ``` +# +# It is possible for *input* to be `nil` in this case, so that need to also be handled in the block. +# The block should return a value of the same type of the generic, in this case `String?`. +# +# NOTE: The normalizer is called first and its return value is used as the input of the validator. +# If the answer is invalid do not raise an exception in the normalizer and let the validator handle it. +# +# ### Validating the Answer +# +# If the answer to a question needs to match some specific requirements, you can register a question validator to check the validity of the answer. +# This callback should raise an exception if the input is not valid, such as `ArgumentError`. Otherwise, it must return the input value. +# +# ``` +# question = ACON::Question(String?).new "Enter your name.", nil +# question.validator do |input| +# next input if input.nil? || !input.starts_with? /^\d+/ +# +# raise ArgumentError.new "Invalid name. Cannot start with numeric digits." +# end +# ``` +# +# In this example, we are asserting that the user's name does not start with numeric digits. +# If the user entered `123Jim`, they would be told it is an invalid answer and prompted to answer the question again. +# By default the user would have an unlimited amount of retries to get it right, but this can be customized via `ACON::Question::Base#max_attempts=`. +# +# ### Autocompletion +# +# TODO: Implement this. +class Athena::Console::Question(T) + include Athena::Console::Question::Base(T) + + # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. + property validator : Proc(T, T)? = nil + + # Sets the validator callback to this block. + # See [Validating the Answer][Athena::Console::Question--validating-the-answer]. + def validator(&@validator : T -> T) : Nil + end +end diff --git a/src/spec.cr b/src/spec.cr new file mode 100644 index 0000000..7c1a5e3 --- /dev/null +++ b/src/spec.cr @@ -0,0 +1,292 @@ +# Provides helper types for testing `ACON::Command` and `ACON::Application`s. +module Athena::Console::Spec + # Contains common logic shared by both `ACON::Spec::CommandTester` and `ACON::Spec::ApplicationTester`. + module Tester + @capture_stderr_separately : Bool = false + + # Returns the `ACON::Output::Interface` being used by the tester. + getter! output : ACON::Output::Interface + + # Sets an array of values that will be used as the input to the command. + # `RETURN` is automatically assumed after each input. + setter inputs : Array(String) = [] of String + + # Returns the output resulting from running the command. + # Raises if called before executing the command. + def display : String + raise ACON::Exceptions::Logic.new "Output not initialized. Did you execute the command before requesting the display?" unless (output = @output) + output.to_s + end + + # Returns the error output resulting from running the command. + # Raises if `capture_stderr_separately` was not set to `true`. + def error_output : String + raise ACON::Exceptions::Logic.new "The error output is not available when the test is ran without 'capture_stderr_separately' set." unless @capture_stderr_separately + + self.output.as(ACON::Output::ConsoleOutput).error_output.to_s + end + + # Helper method to setting the `#inputs=` property. + def inputs(*args : String) : Nil + @inputs = args.to_a + end + + protected def init_output( + decorated : Bool? = nil, + interactive : Bool? = nil, + verbosity : ACON::Output::Verbosity? = nil, + @capture_stderr_separately : Bool = false + ) : Nil + if !@capture_stderr_separately + @output = ACON::Output::IO.new IO::Memory.new + + decorated.try do |d| + self.output.decorated = d + end + + verbosity.try do |v| + self.output.verbosity = v + end + else + @output = ACON::Output::ConsoleOutput.new( + verbosity || ACON::Output::Verbosity::NORMAL, + decorated + ) + + error_output = ACON::Output::IO.new IO::Memory.new + error_output.formatter = self.output.formatter + error_output.verbosity = self.output.verbosity + error_output.decorated = self.output.decorated? + + self.output.as(ACON::Output::ConsoleOutput).stderr = error_output + self.output.as(ACON::Output::IO).io = IO::Memory.new + end + end + + private def create_input_stream(inputs : Array(String)) : IO + input_stream = IO::Memory.new + + inputs.each do |input| + input_stream << "#{input}\n" + end + + input_stream.rewind + + input_stream + end + end + + # Functionally similar to `ACON::Spec::CommandTester`, but used for testing entire `ACON::Application`s. + # + # Can be useful if your project extends the base application in order to customize it in some way. + # + # NOTE: Be sure to set `ACON::Application#auto_exit=` to `false`, when testing an entire application. + struct ApplicationTester + include Tester + + # Returns the `ACON::Application` instance being tested. + getter application : ACON::Application + + # Returns the `ACON::Input::Interface` being used by the tester. + getter! input : ACON::Input::Interface + + # Returns the `ACON::Command::Status` of the command execution, or `nil` if it has not yet been executed. + getter status : ACON::Command::Status? = nil + + def initialize(@application : ACON::Application); end + + # Runs the application, with the provided *input* being used as the input of `ACON::Application#run`. + # + # Custom values for *decorated*, *interactive*, and *verbosity* can also be provided and will be forwarded to their respective types. + # *capture_stderr_separately* makes it so output to `STDERR` is captured separately, in case you wanted to test error output. + # Otherwise both error and normal output are captured via `ACON::Spec::Tester#display`. + def run( + decorated : Bool = false, + interactive : Bool? = nil, + capture_stderr_separately : Bool = false, + verbosity : ACON::Output::Verbosity? = nil, + **input : _ + ) + self.run input.to_h.transform_keys(&.to_s), decorated: decorated, interactive: interactive, capture_stderr_separately: capture_stderr_separately, verbosity: verbosity + end + + # :ditto: + def run( + input : Hash(String, _) = Hash(String, String).new, + *, + decorated : Bool? = nil, + interactive : Bool? = nil, + capture_stderr_separately : Bool = false, + verbosity : ACON::Output::Verbosity? = nil + ) : ACON::Command::Status + @input = ACON::Input::Hash.new input + + interactive.try do |i| + self.input.interactive = i + end + + unless (inputs = @inputs).empty? + self.input.stream = self.create_input_stream inputs + end + + self.init_output( + decorated: decorated, + interactive: interactive, + capture_stderr_separately: capture_stderr_separately, + verbosity: verbosity + ) + + @status = @application.run self.input, self.output + end + end + + # Allows testing the logic of an `ACON::Command`, without needing to create and run a binary. + # + # Say we have the following command: + # + # ``` + # class AddCommand < ACON::Command + # @@default_name = "add" + # @@default_description = "Sums two numbers, optionally making making the sum negative" + # + # protected def configure : Nil + # self + # .argument("value1", :required, "The first value") + # .argument("value2", :required, "The second value") + # .option("negative", description: "If the sum should be made negative") + # end + # + # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + # sum = input.argument("value1", Int32) + input.argument("value2", Int32) + # + # sum = -sum if input.option "negative", Bool + # + # output.puts "The sum of values is: #{sum}" + # + # ACON::Command::Status::SUCCESS + # end + # end + # ``` + # + # We can use `ACON::Spec::CommandTester` to assert it is working as expected. + # + # ``` + # require "spec" + # require "athena-spec" + # + # describe AddCommand do + # describe "#execute" do + # it "without negative option" do + # tester = ACON::Spec::CommandTester.new AddCommand.new + # tester.execute value1: 10, value2: 7 + # tester.display.should eq "The sum of the values is: 17\n" + # end + # + # it "with negative option" do + # tester = ACON::Spec::CommandTester.new AddCommand.new + # tester.execute value1: -10, value2: 5, "--negative": nil + # tester.display.should eq "The sum of the values is: 5\n" + # end + # end + # end + # ``` + # + # ### Commands with User Input + # + # A command that are asking `ACON::Question`s can also be tested: + # + # ``` + # class QuestionCommand < ACON::Command + # @@default_name = "question" + # + # protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + # helper = self.helper ACON::Helper::Question + # + # question = ACON::Question(String).new "What is your name?", "None" + # output.puts "Your name is: #{helper.ask input, output, question}" + # + # ACON::Command::Status::SUCCESS + # end + # end + # ``` + # + # ``` + # require "spec" + # require "./src/spec" + # + # describe QuestionCommand do + # describe "#execute" do + # it do + # command = QuestionCommand.new + # command.helper_set = ACON::Helper::HelperSet.new ACON::Helper::Question.new + # tester = ACON::Spec::CommandTester.new command + # tester.inputs "Jim" + # tester.execute + # tester.display.should eq "What is your name?Your name is: Jim\n" + # end + # end + # end + # ``` + # + # Because we are not in the context of an `ACON::Application`, we need to manually set the `ACON::Helper::HelperSet` + # in order to make the command aware of `ACON::Helper::Question`. After that we can use the `ACON::Spec::Tester#inputs` method + # to set the inputs our test should use when prompted. + # + # Multiple inputs can be provided if there are multiple questions being asked. + struct CommandTester + include Tester + + # Returns the `ACON::Input::Interface` being used by the tester. + getter! input : ACON::Input::Interface + + # Returns the `ACON::Command::Status` of the command execution, or `nil` if it has not yet been executed. + getter status : ACON::Command::Status? = nil + + def initialize(@command : ACON::Command); end + + # Executes the command, with the provided *input* being passed to the command. + # + # Custom values for *decorated*, *interactive*, and *verbosity* can also be provided and will be forwarded to their respective types. + # *capture_stderr_separately* makes it so output to `STDERR` is captured separately, in case you wanted to test error output. + # Otherwise both error and normal output are captured via `ACON::Spec::Tester#display`. + def execute( + decorated : Bool = false, + interactive : Bool? = nil, + capture_stderr_separately : Bool = false, + verbosity : ACON::Output::Verbosity? = nil, + **input : _ + ) + self.execute input.to_h.transform_keys(&.to_s), decorated: decorated, interactive: interactive, capture_stderr_separately: capture_stderr_separately, verbosity: verbosity + end + + # :ditto: + def execute( + input : Hash(String, _) = Hash(String, String).new, + *, + decorated : Bool = false, + interactive : Bool? = nil, + capture_stderr_separately : Bool = false, + verbosity : ACON::Output::Verbosity? = nil + ) : ACON::Command::Status + if !input.has_key?("command") && (application = @command.application?) && application.definition.has_argument?("command") + input = input.merge({"command" => @command.name}) + end + + @input = ACON::Input::Hash.new input + self.input.stream = self.create_input_stream @inputs + + interactive.try do |i| + self.input.interactive = i + end + + self.init_output( + decorated: decorated, + interactive: interactive, + capture_stderr_separately: capture_stderr_separately, + verbosity: verbosity + ) + + @status = @command.run self.input, self.output + end + end +end diff --git a/src/style/athena.cr b/src/style/athena.cr new file mode 100644 index 0000000..6fd2adb --- /dev/null +++ b/src/style/athena.cr @@ -0,0 +1,349 @@ +require "./output" + +# Default implementation of `ACON::Style::Interface` that provides a slew of helpful methods for formatting output. +# +# Uses `ACON::Helper::AthenaQuestion` to improve the appearance of questions. +# +# ``` +# protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status +# style = ACON::Style::Athena.new input, output +# +# style.title "Some Fancy Title" +# +# # ... +# +# ACON::Command::Status::SUCCESS +# end +# ``` +struct Athena::Console::Style::Athena < Athena::Console::Style::Output + private MAX_LINE_LENGTH = 120 + + protected getter question_helper : ACON::Helper::Question { ACON::Helper::AthenaQuestion.new } + + @input : ACON::Input::Interface + @buffered_output : ACON::Output::SizedBuffer + + @line_length : Int32 + + def initialize(@input : ACON::Input::Interface, output : ACON::Output::Interface) + width = ACON::Terminal.new.width || MAX_LINE_LENGTH + + @buffered_output = ACON::Output::SizedBuffer.new {{flag?(:windows) ? 4 : 2}}, output.verbosity, false, output.formatter.dup + @line_length = Math.min(width - {{flag?(:windows) ? 1 : 0}}, MAX_LINE_LENGTH) + + super output + end + + # :inherit: + def ask(question : String, default : _) + self.ask ACON::Question.new question, default + end + + # :ditto: + def ask(question : ACON::Question::Base) + if @input.interactive? + self.auto_prepend_block + end + + answer = self.question_helper.ask @input, self, question + + if @input.interactive? + self.new_line + @buffered_output.print "\n" + end + + answer + end + + # :inherit: + def ask_hidden(question : String) + question = ACON::Question(String?).new question, nil + + question.hidden = true + + self.ask question + end + + # Helper method for outputting blocks of *messages* that powers the `#caution`, `#success`, `#note`, etc. methods. + # It includes various optional parameters that can be used to print customized blocks. + # + # If *type* is provided, its value will be printed within `[]`. E.g. `[TYPE]`. + # + # If *style* is provided, each of the *messages* will be printed in that style. + # + # *prefix* represents what each of the *messages* should be prefixed with. + # + # If *padding* is `true`, empty lines will be added before/after the block. + # + # If *escape* is `true`, each of the *messages* will be escaped via `ACON::Formatter::Output.escape`. + def block(messages : String | Enumerable(String), type : String? = nil, style : String? = nil, prefix : String = " ", padding : Bool = false, escape : Bool = true) : Nil + messages = messages.is_a?(Enumerable(String)) ? messages : {messages} + + self.auto_prepend_block + self.puts self.create_block(messages, type, style, prefix, padding, escape) + self.new_line + end + + # :inherit: + # + # ``` + # ! + # ! [CAUTION] Some Message + # ! + # ``` + # + # White text on a 3 line red background block with an empty line above/below the block. + def caution(messages : String | Enumerable(String)) : Nil + self.block messages, "CAUTION", "fg=white;bg=red", " ! ", true + end + + # :inherit: + def choice(question : String, choices : Indexable | Hash, default = nil) + self.ask ACON::Question::Choice.new question, choices, default + end + + # :inherit: + # + # ``` + # // Some Message + # ``` + # + # White text with one empty line above/below the message(s). + def comment(messages : String | Enumerable(String)) : Nil + self.block messages, prefix: " // ", escape: false + end + + # :inherit: + def confirm(question : String, default : Bool = true) : Bool + self.ask ACON::Question::Confirmation.new question, default + end + + # :inherit: + # + # ``` + # [ERROR] Some Message + # ``` + # + # White text on a 3 line red background block with an empty line above/below the block. + def error(messages : String | Enumerable(String)) : Nil + self.block messages, "ERROR", "fg=white;bg=red", padding: true + end + + # Returns a new instance of `self` that outputs to the error output. + def error_style : self + self.class.new @input, self.error_output + end + + # :inherit: + # + # ``` + # [INFO] Some Message + # ``` + # + # Green text with two empty lines above/below the message(s). + def info(messages : String | Enumerable(String)) : Nil + self.block messages, "INFO", "fg=green", padding: true + end + + # :inherit: + # + # ``` + # * Item 1 + # * Item 2 + # * Item 3 + # ``` + # + # White text with one empty line above/below the list. + def listing(elements : Enumerable) : Nil + self.auto_prepend_text + elements.each do |element| + self.puts " * #{element}" + end + self.new_line + end + + # :ditto: + def listing(*elements : String) : Nil + self.listing elements + end + + # :inherit: + def new_line(count : Int32 = 1) : Nil + super + @buffered_output.print "\n" * count + end + + # :inherit: + # + # ``` + # ! [NOTE] Some Message + # ``` + # + # Green text with one empty line above/below the message(s). + def note(messages : String | Enumerable(String)) : Nil + self.block messages, "NOTE", "fg=yellow", " ! " + end + + # :inherit: + def puts(messages : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + messages = messages.is_a?(String) ? {messages} : messages + + messages.each do |message| + super message, verbosity, output_type + self.write_buffer message, true, verbosity, output_type + end + end + + # :inherit: + def print(messages : String | Enumerable(String), verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + messages = messages.is_a?(String) ? {messages} : messages + + messages.each do |message| + super message, verbosity, output_type + self.write_buffer message, false, verbosity, output_type + end + end + + # :inherit: + # + # ``` + # Some Message + # ------------ + # ``` + # + # Orange text with one empty line above/below the section. + def section(message : String) : Nil + self.auto_prepend_block + self.puts "#{ACON::Formatter::Output.escape_trailing_backslash message}" + self.puts %(#{"-" * ACON::Helper.width(ACON::Helper.remove_decoration(self.formatter, message))}) + self.new_line + end + + # :inherit: + # + # ``` + # [OK] Some Message + # ``` + # + # Black text on a 3 line green background block with an empty line above/below the block. + def success(messages : String | Enumerable(String)) : Nil + self.block messages, "OK", "fg=black;bg=green", padding: true + end + + # def table(headers : Enumerable, rows : Enumerable(Enumerable)) : Nil + # end + + # :inherit: + # + # Same as `#puts` but indented one space and an empty line above the message(s). + def text(messages : String | Enumerable(String)) : Nil + self.auto_prepend_text + + messages = messages.is_a?(Enumerable(String)) ? messages : {messages} + + messages.each do |message| + self.puts " #{message}" + end + end + + # :inherit: + # + # ``` + # Some Message + # ============ + # ``` + # + # Orange text with one empty line above/below the title. + def title(message : String) : Nil + self.auto_prepend_block + self.puts "#{ACON::Formatter::Output.escape_trailing_backslash message}" + self.puts %(#{"=" * ACON::Helper.width(ACON::Helper.remove_decoration(self.formatter, message))}) + self.new_line + end + + # :inherit: + # + # ``` + # [WARNING] Some Message + # ``` + # + # Black text on a 3 line orange background block with an empty line above/below the block. + def warning(messages : String | Enumerable(String)) : Nil + self.block messages, "WARNING", "fg=black;bg=yellow", padding: true + end + + # def progress_start(max : Int32 = 0) : Nil + # end + + # def progress_advance(step : Int32 = 1) : Nil + # end + + # def progress_finish : Nil + # end + + private def auto_prepend_block : Nil + chars = @buffered_output.fetch + + if chars.empty? + return self.new_line + end + + self.new_line 2 - chars.count '\n' + end + + private def auto_prepend_text : Nil + fetched = @buffered_output.fetch + self.new_line unless fetched.ends_with? "\n" + end + + private def create_block(messages : Enumerable(String), type : String? = nil, style : String? = nil, prefix : String = " ", padding : Bool = false, escape : Bool = true) : Array(String) + indent_length = 0 + prefix_length = ACON::Helper.width ACON::Helper.remove_decoration self.formatter, prefix + lines = [] of String + + unless type.nil? + type = "[#{type}] " + indent_length = type.size + line_indentation = " " * indent_length + end + + messages.each_with_index do |message, idx| + message = ACON::Formatter::Output.escape message if escape + + decoration_length = ACON::Helper.width(message) - ACON::Helper.width(ACON::Helper.remove_decoration(self.formatter, message)) + message_line_length = Math.min(@line_length - prefix_length - indent_length + decoration_length, @line_length) + + message.gsub(/(.{1,#{message_line_length}})( +|$\n?)|(.{1,#{message_line_length}})/, "\\0\n").split "\n", remove_empty: true do |match| + lines << match.strip + end + + lines << "" if messages.size > 1 && idx < (messages.size - 1) + end + + first_line_index = 0 + if padding && self.decorated? + first_line_index = 1 + lines.unshift "" + lines << "" + end + + lines.map_with_index do |line, idx| + unless type.nil? + line = first_line_index == idx ? "#{type}#{line}" : "#{line_indentation}#{line}" + end + + line = "#{prefix}#{line}" + line += " " * Math.max @line_length - ACON::Helper.width(ACON::Helper.remove_decoration(self.formatter, line)), 0 + + if style + line = "<#{style}>#{line}" + end + + line + end + end + + private def write_buffer(message : String | Enumerable(String), new_line : Bool, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + @buffered_output.write message, new_line, verbosity, output_type + end +end diff --git a/src/style/interface.cr b/src/style/interface.cr new file mode 100644 index 0000000..6b7702c --- /dev/null +++ b/src/style/interface.cr @@ -0,0 +1,64 @@ +# Represents a "style" that provides a way to abstract _how_ to format console input/output data +# such that you can reduce the amount of code needed, and to standardize the appearance. +# +# See `ACON::Style::Athena`. +# +# ## Custom Styles +# +# Custom styles can also be created by implementing this interface, and optionally extending from `ACON::Style::Output` +# which makes the style an `ACON::Output::Interface` as well as defining part of this interface. +# Then you could simply instantiate your custom style within a command as you would `ACON::Style::Athena`. +module Athena::Console::Style::Interface + # Helper method for asking `ACON::Question` questions. + abstract def ask(question : String, default : _) + + # Helper method for asking hidden `ACON::Question` questions. + abstract def ask_hidden(question : String) + + # Formats and prints the provided *messages* within a caution block. + abstract def caution(messages : String | Enumerable(String)) : Nil + + # Formats and prints the provided *messages* within a comment block. + abstract def comment(messages : String | Enumerable(String)) : Nil + + # Helper method for asking `ACON::Question::Confirmation` questions. + abstract def confirm(question : String, default : Bool = true) : Bool + + # Formats and prints the provided *messages* within a error block. + abstract def error(messages : String | Enumerable(String)) : Nil + + # Helper method for asking `ACON::Question::Choice` questions. + abstract def choice(question : String, choices : Indexable | Hash, default = nil) + + # Formats and prints the provided *messages* within a info block. + abstract def info(messages : String | Enumerable(String)) : Nil + + # Formats and prints a bulleted list containing the provided *elements*. + abstract def listing(elements : Enumerable) : Nil + + # Prints *count* empty new lines. + abstract def new_line(count : Int32 = 1) : Nil + + # Formats and prints the provided *messages* within a note block. + abstract def note(messages : String | Enumerable(String)) : Nil + + # Creates a section header with the provided *message*. + abstract def section(message : String) : Nil + + # Formats and prints the provided *messages* within a success block. + abstract def success(messages : String | Enumerable(String)) : Nil + + # Formats and prints the provided *messages* as text. + abstract def text(messages : String | Enumerable(String)) : Nil + + # Formats and prints *message* as a title. + abstract def title(message : String) : Nil + + # abstract def table(headers : Enumerable, rows : Enumerable(Enumerable)) : Nil + # abstract def progress_start(max : Int32 = 0) : Nil + # abstract def progress_advance(step : Int32 = 1) : Nil + # abstract def progress_finish : Nil + + # Formats and prints the provided *messages* within a warning block. + abstract def warning(messages : String | Enumerable(String)) : Nil +end diff --git a/src/style/output.cr b/src/style/output.cr new file mode 100644 index 0000000..3fd7295 --- /dev/null +++ b/src/style/output.cr @@ -0,0 +1,56 @@ +require "./interface" + +# Base implementation of `ACON::Style::Interface` and `ACON::Output::Interface` that provides logic common to all styles. +abstract struct Athena::Console::Style::Output + include Athena::Console::Style::Interface + include Athena::Console::Output::Interface + + @output : ACON::Output::Interface + + def initialize(@output : ACON::Output::Interface); end + + # See `ACON::Output::Interface#decorated?`. + def decorated? : Bool + @output.decorated? + end + + # See `ACON::Output::Interface#decorated=`. + def decorated=(decorated : Bool) : Nil + @output.decorated = decorated + end + + # See `ACON::Output::Interface#formatter`. + def formatter : ACON::Formatter::Interface + @output.formatter + end + + # See `ACON::Output::Interface#formatter=`. + def formatter=(formatter : ACON::Formatter::Interface) : Nil + @output.formatter = formatter + end + + # :inherit: + def new_line(count : Int32 = 1) : Nil + @output.print "\n" * count + end + + # See `ACON::Output::Interface#puts`. + def puts(message, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + @output.puts message, verbosity, output_type + end + + # See `ACON::Output::Interface#print`. + def print(message, verbosity : ACON::Output::Verbosity = :normal, output_type : ACON::Output::Type = :normal) : Nil + @output.print message, verbosity, output_type + end + + # See `ACON::Output::Interface#verbosity`. + def verbosity : ACON::Output::Verbosity + @output.verbosity + end + + # See `ACON::Output::Interface#verbosity=`. + def verbosity=(verbosity : ACON::Output::Verbosity) : Nil + @output.verbosity = verbosity + end +end diff --git a/src/terminal.cr b/src/terminal.cr new file mode 100644 index 0000000..c9441e0 --- /dev/null +++ b/src/terminal.cr @@ -0,0 +1,65 @@ +# :nodoc: +struct Athena::Console::Terminal + @@width : Int32? = nil + @@height : Int32? = nil + @@stty : Bool = false + + def self.has_stty_available? : Bool + if stty = @@stty + return stty + end + + @@stty = !Process.find_executable("stty").nil? + end + + def width : Int32 + if env_width = ENV["COLUMNS"]? + return env_width.to_i + end + + if @@width.nil? + self.class.init_dimensions + end + + @@width || 80 + end + + def height : Int32 + if env_height = ENV["LINES"]? + return env_height.to_i + end + + if @@height.nil? + self.class.init_dimensions + end + + @@height || 50 + end + + protected def self.init_dimensions : Nil + # TODO: Support Windows + {% raise "Athena::Console component does not support Windows yet." if flag?(:win32) %} + + self.init_dimensions_via_stty + end + + private def self.init_dimensions_via_stty : Nil + return unless stty_info = self.stty_columns + + if match = stty_info.match /rows.(\d+);.columns.(\d+);/i + @@height = match[1].to_i + @@width = match[2].to_i + elsif match = stty_info.match /;.(\d+).rows;.(\d+).columns/i + @@height = match[1].to_i + @@width = match[2].to_i + end + end + + private def self.stty_columns : String? + stty_info = `stty -a | grep columns` + + return nil unless $?.success? + + stty_info + end +end