\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 /([^\\\\]?), "\\1\\<"
+
+ self.escape_trailing_backslash text
+ end
+
+ # Returns a new string where trailing `\` in the provided *text* is escaped.
+ def self.escape_trailing_backslash(text : String) : String
+ if text.ends_with? '\\'
+ len = text.size
+ text = text.rstrip '\\'
+ text = text.gsub "\0", ""
+ text += "\0" * (len - text.size)
+ end
+
+ text
+ end
+
+ # :nodoc:
+ getter style_stack : ACON::Formatter::OutputStyleStack = ACON::Formatter::OutputStyleStack.new
+
+ # :inherit:
+ property? decorated : Bool
+
+ @styles = Hash(String, ACON::Formatter::OutputStyleInterface).new
+ @current_line_length = 0
+
+ def initialize(@decorated : Bool = false, styles : ACON::Formatter::Mode? = nil)
+ self.set_style "error", ACON::Formatter::OutputStyle.new(:white, :red)
+ self.set_style "info", ACON::Formatter::OutputStyle.new(:green)
+ self.set_style "comment", ACON::Formatter::OutputStyle.new(:yellow)
+ self.set_style "question", ACON::Formatter::OutputStyle.new(:black, :cyan)
+ end
+
+ # :inherit:
+ def set_style(name : String, style : ACON::Formatter::OutputStyleInterface) : Nil
+ @styles[name.downcase] = style
+ end
+
+ # :inherit:
+ def has_style?(name : String) : Bool
+ @styles.has_key? name.downcase
+ end
+
+ # :inherit:
+ def style(name : String) : ACON::Formatter::OutputStyleInterface
+ @styles[name.downcase]
+ end
+
+ # :inherit:
+ def format(message : String?) : String
+ self.format_and_wrap message, 0
+ end
+
+ # :inherit:
+ def format_and_wrap(message : String?, width : Int32) : String
+ offset = 0
+ output = ""
+
+ @current_line_length = 0
+
+ message.scan(/<(([a-z][^<>]*+) | \/([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 /\\, "<"
+ end
+
+ protected def create_style_from_string(string : String) : ACON::Formatter::OutputStyleInterface?
+ if style = @styles[string]?
+ return style
+ end
+
+ matches = string.scan /([^=]+)=([^;]+)(;|$)/
+
+ return nil if matches.empty?
+
+ style = ACON::Formatter::OutputStyle.new
+ matches.each do |match|
+ case match[1].downcase
+ when "fg" then style.foreground = match[2]
+ when "bg" then style.background = match[2]
+ when "href" then style.href = match[2]
+ when "options"
+ match[2].downcase.scan /([^,;]+)/ do |option|
+ style.add_option option[1]
+ end
+ else
+ return nil
+ end
+ end
+
+ style
+ end
+
+ # ameba:disable Metrics/CyclomaticComplexity
+ private def apply_current_style(text : String, current : String, width : Int32)
+ return "" if text.empty?
+
+ if width.zero?
+ return self.decorated? ? @style_stack.current.apply(text) : text
+ end
+
+ if @current_line_length.zero? && !current.empty?
+ text = text.lstrip
+ end
+
+ if !@current_line_length.zero?
+ i = width - @current_line_length
+ prefix = "#{text[0, i]}\n"
+ text = text[i...]? || ""
+ else
+ prefix = ""
+ end
+
+ # TODO: Something about matching `~(\\n)$~`.
+ text = "#{prefix}#{text.gsub(/([^\\n]{#{width}})\ */, "\\1\n")}"
+ text = text.chomp
+
+ if @current_line_length.zero? && !current.empty? && !current.ends_with? "\n"
+ text = "\n#{text}"
+ end
+
+ lines = text.split "\n"
+
+ lines.each do |line|
+ @current_line_length += line.size
+
+ @current_line_length = 0 if width <= @current_line_length
+ end
+
+ if self.decorated?
+ lines.map! do |line|
+ @style_stack.current.apply line
+ end
+ end
+
+ lines.join "\n"
+ end
+end
diff --git a/src/formatter/output_formatter_style_stack.cr b/src/formatter/output_formatter_style_stack.cr
new file mode 100644
index 0000000..d391df6
--- /dev/null
+++ b/src/formatter/output_formatter_style_stack.cr
@@ -0,0 +1,38 @@
+# :nodoc:
+struct Athena::Console::Formatter::OutputStyleStack
+ property empty_style : ACON::Formatter::OutputStyleInterface
+
+ @styles = Array(ACON::Formatter::OutputStyleInterface).new
+
+ def initialize(@empty_style : ACON::Formatter::OutputStyleInterface = ACON::Formatter::OutputStyle.new)
+ self.reset
+ end
+
+ def reset : Nil
+ @styles.clear
+ end
+
+ def <<(style : ACON::Formatter::OutputStyleInterface) : Nil
+ @styles << style
+ end
+
+ def pop(style : ACON::Formatter::OutputStyleInterface? = nil) : ACON::Formatter::OutputStyleInterface
+ return @empty_style if @styles.empty?
+
+ return @styles.pop if style.nil?
+
+ @styles.reverse_each.each_with_index do |stacked_style, idx|
+ if style.apply("") == stacked_style.apply("")
+ @styles = @styles[0...idx]
+
+ return stacked_style
+ end
+ end
+
+ raise ACON::Exceptions::InvalidArgument.new "Provided style is not present in the stack."
+ end
+
+ def current : ACON::Formatter::OutputStyleInterface
+ @styles.last? || @empty_style
+ end
+end
diff --git a/src/formatter/output_style.cr b/src/formatter/output_style.cr
new file mode 100644
index 0000000..2a9bda6
--- /dev/null
+++ b/src/formatter/output_style.cr
@@ -0,0 +1,89 @@
+require "colorize"
+require "./output_style_interface"
+
+# Default implementation of `ACON::Formatter::OutputStyleInterface`.
+struct Athena::Console::Formatter::OutputStyle
+ include Athena::Console::Formatter::OutputStyleInterface
+
+ # :inherit:
+ setter foreground : Colorize::Color = :default
+
+ # :inherit:
+ setter background : Colorize::Color = :default
+
+ # :inherit:
+ setter options : ACON::Formatter::Mode = :none
+
+ # Sets the `href` that `self` should link to.
+ setter href : String? = nil
+
+ # :nodoc:
+ getter? handles_href_gracefully : Bool do
+ "JetBrains-JediTerm" != ENV["TERMINAL_EMULATOR"]? && (!ENV.has_key?("KONSOLE_VERSION") || ENV["KONSOLE_VERSION"].to_i > 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}]#{style}> #{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}#{style}>"
+ 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}#{tag}>] #{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