├── .github ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE.md └── workflows │ └── ci.yml ├── .rspec ├── lib ├── tty-runner.rb └── tty │ ├── runner │ ├── version.rb │ ├── inflection.rb │ ├── interceptor.rb │ ├── loader.rb │ ├── parser.rb │ ├── context.rb │ └── router.rb │ └── runner.rb ├── bin ├── setup └── console ├── CHANGELOG.md ├── .gitignore ├── .editorconfig ├── Rakefile ├── Gemfile ├── tasks ├── coverage.rake ├── console.rake └── spec.rake ├── spec ├── unit │ ├── inflection_spec.rb │ ├── parser_spec.rb │ ├── context_spec.rb │ ├── namespace_spec.rb │ ├── desc_spec.rb │ ├── intercept_spec.rb │ ├── commands_dir_spec.rb │ ├── loader_spec.rb │ └── run_spec.rb ├── support │ └── files.rb └── spec_helper.rb ├── examples ├── mount.rb ├── tty_option.rb ├── commands.rb └── optparse.rb ├── appveyor.yml ├── .rubocop.yml ├── LICENSE.txt ├── tty-runner.gemspec ├── CODE_OF_CONDUCT.md └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: piotrmurach 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | --warnings 5 | -------------------------------------------------------------------------------- /lib/tty-runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "tty/runner" 4 | -------------------------------------------------------------------------------- /lib/tty/runner/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TTY 4 | class Runner 5 | VERSION = "0.1.0" 6 | end # Runner 7 | end # TTY 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## [v0.1.0] - unreleased 4 | 5 | * Initial implementation and release 6 | 7 | [v0.1.0]: https://github.com/piotrmurach/tty-runner/compare/v0.1.0 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /Gemfile.lock 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.rb] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | FileList["tasks/**/*.rake"].each(&method(:import)) 6 | 7 | desc "Run all specs" 8 | task ci: %w[ spec ] 9 | 10 | task default: :spec 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "tty-option", git: "https://github.com/piotrmurach/tty-option" 6 | 7 | group :test do 8 | gem "coveralls", "~> 0.8.22" 9 | gem "simplecov", "~> 0.16.1" 10 | end 11 | -------------------------------------------------------------------------------- /tasks/coverage.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "Measure code coverage" 4 | task :coverage do 5 | begin 6 | original, ENV["COVERAGE"] = ENV["COVERAGE"], "true" 7 | Rake::Task["spec"].invoke 8 | ensure 9 | ENV["COVERAGE"] = original 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /tasks/console.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "Load gem inside irb console" 4 | task :console do 5 | require "irb" 6 | require "irb/completion" 7 | require File.join(__FILE__, "../../lib/tty-runner") 8 | ARGV.clear 9 | IRB.start 10 | end 11 | task c: %w[ console ] 12 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "tty/runner" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /lib/tty/runner/inflection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TTY 4 | class Runner 5 | module Inflection 6 | # Convert snakecase string into camelcase 7 | # 8 | # @param [String] string 9 | # 10 | # @api public 11 | def camelcase(string) 12 | string.split("_").each(&:capitalize!).join 13 | end 14 | module_function :camelcase 15 | end 16 | end # Runner 17 | end # TTY 18 | -------------------------------------------------------------------------------- /spec/unit/inflection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TTY::Runner::Inflection, "#camelcase" do 4 | { 5 | "some_class_name" => "SomeClassName", 6 | "html_class" => "HtmlClass", 7 | "some_html_class" => "SomeHtmlClass", 8 | "ipv6_class" => "Ipv6Class" 9 | }.each do |underscored, class_name| 10 | it "converts #{underscored.inspect} to #{class_name.inspect}" do 11 | expect(described_class.camelcase(underscored)).to eq(class_name) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Describe the change 2 | What does this Pull Request do? 3 | 4 | ### Why are we doing this? 5 | Any related context as to why is this is a desirable change. 6 | 7 | ### Benefits 8 | How will the library improve? 9 | 10 | ### Drawbacks 11 | Possible drawbacks applying this change. 12 | 13 | ### Requirements 14 | 15 | - [ ] Tests written & passing locally? 16 | - [ ] Code style checked? 17 | - [ ] Rebased with `master` branch? 18 | - [ ] Documentation updated? 19 | - [ ] Changelog updated? 20 | -------------------------------------------------------------------------------- /spec/support/files.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tmpdir" 4 | 5 | module Support 6 | module Files 7 | def within_tmpdir(&block) 8 | ::Dir.mktmpdir do |dir| 9 | ::Dir.chdir(dir, &block) 10 | end 11 | end 12 | 13 | def with_files(files) 14 | within_tmpdir do 15 | files.each do |fname, contents| 16 | ::FileUtils.mkdir_p(::File.dirname(fname)) 17 | ::File.write(fname, contents) 18 | end 19 | yield 20 | end 21 | end 22 | end # Files 23 | end # Support 24 | 25 | RSpec.configure do |config| 26 | config.include(Support::Files) 27 | end 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Are you in the right place? 2 | * For issues or feature requests file a GitHub issue in this repository 3 | * For general questions or discussion post in [Gitter](https://gitter.im/piotrmurach/tty) 4 | 5 | ### Describe the problem 6 | A brief description of the issue/feature. 7 | 8 | ### Steps to reproduce the problem 9 | ``` 10 | Your code here to reproduce the issue 11 | ``` 12 | 13 | ### Actual behaviour 14 | What happened? This could be a description, log output, error raised etc... 15 | 16 | ### Expected behaviour 17 | What did you expect to happen? 18 | 19 | ### Describe your environment 20 | 21 | * OS version: 22 | * Ruby version: 23 | * TTY::Runner version: 24 | -------------------------------------------------------------------------------- /examples/mount.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/tty-runner" 4 | 5 | class Config < TTY::Runner 6 | commands do 7 | on "add", run: -> { puts "config adding..." } 8 | 9 | on :get, run: -> { puts "config getting..." } 10 | 11 | on "rm", run: -> { puts "config removing..."} 12 | 13 | on "edit", run: -> { puts "config editing..." } 14 | end 15 | end 16 | 17 | class Tag < TTY::Runner 18 | commands do 19 | on "create", run: -> { puts "tag creating..." } 20 | 21 | on "delete", run: -> { puts "tag deleting..." } 22 | end 23 | end 24 | 25 | class App < TTY::Runner 26 | commands do 27 | on "config" do 28 | mount Config 29 | end 30 | on "tag" do 31 | mount Tag 32 | end 33 | end 34 | end 35 | 36 | App.run 37 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | --- 2 | skip_commits: 3 | files: 4 | - "bin/**" 5 | - "examples/**" 6 | - "*.md" 7 | install: 8 | - SET PATH=C:\Ruby%ruby_version%\bin;%PATH% 9 | - gem install bundler -v '< 2.0' 10 | - bundle install --jobs 4 --retry 3 11 | before_test: 12 | - ruby -v 13 | - gem -v 14 | - bundle -v 15 | build: off 16 | test_script: 17 | - bundle exec rake ci 18 | environment: 19 | matrix: 20 | - ruby_version: "200" 21 | - ruby_version: "200-x64" 22 | - ruby_version: "21" 23 | - ruby_version: "21-x64" 24 | - ruby_version: "22" 25 | - ruby_version: "22-x64" 26 | - ruby_version: "23" 27 | - ruby_version: "23-x64" 28 | - ruby_version: "24" 29 | - ruby_version: "24-x64" 30 | - ruby_version: "25" 31 | - ruby_version: "25-x64" 32 | - ruby_version: "26" 33 | - ruby_version: "26-x64" 34 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | 4 | Lint/AssignmentInCondition: 5 | Enabled: false 6 | 7 | Metrics/AbcSize: 8 | Max: 30 9 | 10 | Metrics/BlockLength: 11 | CountComments: true 12 | Max: 25 13 | ExcludedMethods: [] 14 | Exclude: 15 | - "spec/**/*" 16 | 17 | Metrics/ClassLength: 18 | Max: 1500 19 | 20 | Metrics/CyclomaticComplexity: 21 | Enabled: false 22 | 23 | Layout/LineLength: 24 | Max: 80 25 | 26 | Metrics/MethodLength: 27 | Max: 20 28 | 29 | Naming/BinaryOperatorParameterName: 30 | Enabled: false 31 | 32 | Style/AsciiComments: 33 | Enabled: false 34 | 35 | Style/LambdaCall: 36 | SupportedStyles: 37 | - call 38 | - braces 39 | 40 | Style/StringLiterals: 41 | EnforcedStyle: double_quotes 42 | 43 | Style/TrivialAccessors: 44 | Enabled: false 45 | 46 | # { ... } for multi-line blocks is okay 47 | Style/BlockDelimiters: 48 | Enabled: false 49 | 50 | Style/CommentedKeyword: 51 | Enabled: false 52 | -------------------------------------------------------------------------------- /tasks/spec.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require "rspec/core/rake_task" 5 | 6 | desc "Run all specs" 7 | RSpec::Core::RakeTask.new(:spec) do |task| 8 | task.pattern = "spec/{unit,integration}{,/*/**}/*_spec.rb" 9 | end 10 | 11 | namespace :spec do 12 | desc "Run unit specs" 13 | RSpec::Core::RakeTask.new(:unit) do |task| 14 | task.pattern = "spec/unit{,/*/**}/*_spec.rb" 15 | end 16 | 17 | desc "Run integration specs" 18 | RSpec::Core::RakeTask.new(:integration) do |task| 19 | task.pattern = "spec/integration{,/*/**}/*_spec.rb" 20 | end 21 | 22 | desc "Run performance specs" 23 | RSpec::Core::RakeTask.new(:perf) do |task| 24 | task.pattern = "spec/perf{,/*/**}/*_spec.rb" 25 | end 26 | end 27 | 28 | rescue LoadError 29 | %w[spec spec:unit spec:integration spec:perf].each do |name| 30 | task name do 31 | $stderr.puts "In order to run #{name}, do `gem install rspec`" 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Piotr Murach (https://piotrmurach.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/tty_option.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/tty-runner" 4 | 5 | class AddCommand 6 | include TTY::Option 7 | 8 | usage do 9 | program "app" 10 | 11 | command "add" 12 | 13 | desc "Add config entry" 14 | end 15 | 16 | argument :name do 17 | required 18 | desc "The name for the configuration option" 19 | end 20 | 21 | argument :value do 22 | required 23 | desc "The value for the configuration option" 24 | end 25 | 26 | def run(argv) 27 | puts "config adding #{params["name"]}:#{params["value"]}" 28 | end 29 | end 30 | 31 | class App < TTY::Runner 32 | commands do 33 | on "add", "Add config entry", run: "add_command#run" 34 | 35 | on "get", "Get config entry" do 36 | run do 37 | program :app 38 | 39 | command :get 40 | 41 | desc "Get an entry by name" 42 | 43 | argument :name do 44 | required 45 | desc "The name of the configured option" 46 | end 47 | 48 | def call(argv) 49 | puts "config getting #{params["name"]}" 50 | end 51 | end 52 | end 53 | end 54 | end 55 | 56 | App.run 57 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV["COVERAGE"] == "true" 4 | require "simplecov" 5 | require "coveralls" 6 | 7 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ 8 | SimpleCov::Formatter::HTMLFormatter, 9 | Coveralls::SimpleCov::Formatter 10 | ]) 11 | 12 | SimpleCov.start do 13 | command_name "spec" 14 | add_filter "spec" 15 | end 16 | end 17 | 18 | require "bundler/setup" 19 | require "tty/runner" 20 | 21 | module TestHelpers 22 | def unindent(s) 23 | s.gsub(/^#{s.scan(/^[ \t]+(?=\S)/).min}/, "") 24 | end 25 | 26 | def remove_const(const_name, parent: Object) 27 | parent.__send__(:remove_const, const_name) 28 | end 29 | end 30 | 31 | Dir[::File.join(__dir__, "support/**/*.rb")].each(&method(:require)) 32 | 33 | RSpec.configure do |config| 34 | config.include(TestHelpers) 35 | 36 | config.expect_with :rspec do |expectations| 37 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 38 | expectations.max_formatted_output_length = nil 39 | end 40 | 41 | # Disable RSpec exposing methods globally on `Module` and `main` 42 | config.disable_monkey_patching! 43 | 44 | config.expect_with :rspec do |c| 45 | c.syntax = :expect 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /examples/commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/tty-runner" 4 | 5 | module Config 6 | class AddCommand 7 | def call 8 | puts "config adding..." 9 | end 10 | end 11 | 12 | class GetCommand 13 | def call 14 | puts "config getting..." 15 | end 16 | end 17 | 18 | class RemoveCommand 19 | def execute 20 | puts "config removing..." 21 | end 22 | end 23 | end 24 | 25 | class App < TTY::Runner 26 | commands do 27 | on "config", "Manage config file" do 28 | on "add", "Add a new entry", run: Config::AddCommand 29 | 30 | on :get, "Get value for a key", run: "get_command" 31 | 32 | on "remove", aliases: %w[rm] do 33 | desc "Remove an entry" 34 | 35 | run "remove_command#execute" 36 | end 37 | 38 | on "edit", "Open an editor" do 39 | run do 40 | def call(argv) 41 | puts "config editing with #{argv}" 42 | end 43 | end 44 | end 45 | end 46 | 47 | on "tag", "Manage tags" do 48 | on "create", "Add a new tag object", run: -> { puts "tag creating..." } 49 | 50 | on "delete", "Delete a tag object", run: -> { puts "tag deleting..." } 51 | end 52 | end 53 | end 54 | 55 | App.run 56 | -------------------------------------------------------------------------------- /examples/optparse.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "optparse" 4 | 5 | require_relative "../lib/tty-runner" 6 | 7 | class AddCommand 8 | def parser 9 | @parser ||= create_option_parser 10 | end 11 | 12 | def create_option_parser 13 | OptionParser.new do |opts| 14 | opts.banner = "Usage: app add [OPTIONS] NAME VALUE" 15 | opts.separator "\nRun an add command" 16 | opts.separator "\nArguments:" 17 | opts.separator " NAME The name for the configuration option" 18 | opts.separator " VALUE The value for the configuration option" 19 | opts.separator "\nOptions" 20 | 21 | opts.on("-h", "--help", "Print usage") do 22 | puts opts 23 | exit 24 | end 25 | end 26 | end 27 | 28 | def parse(argv) 29 | parser.parse(argv) 30 | end 31 | 32 | def run(argv) 33 | parse(argv) 34 | 35 | params = {} 36 | params["name"] = argv.shift 37 | params["value"] = argv.shift 38 | 39 | if params["name"].nil? 40 | puts parser 41 | exit 42 | end 43 | 44 | puts "config adding #{params["name"]}:#{params["value"]}" 45 | end 46 | end 47 | 48 | class App < TTY::Runner 49 | commands do 50 | on "add", run: "add_command#run" 51 | end 52 | end 53 | 54 | App.run 55 | -------------------------------------------------------------------------------- /tty-runner.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/tty/runner/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "tty-runner" 7 | spec.version = TTY::Runner::VERSION 8 | spec.authors = ["Piotr Murach"] 9 | spec.email = ["piot@piotrmurach.com"] 10 | spec.summary = %q{A command routing tree for terminal applications.} 11 | spec.description = %q{A command routing tree for terminal applications.} 12 | spec.homepage = "https://ttytoolkit.org" 13 | spec.license = "MIT" 14 | 15 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 16 | spec.metadata["bug_tracker_uri"] = "https://github.com/piotrmurach/tty-runner/issues" 17 | spec.metadata["changelog_uri"] = "https://github.com/piotrmurach/tty-runner/blob/master/CHANGELOG.md" 18 | spec.metadata["documentation_uri"] = "https://www.rubydoc.info/gems/tty-runner" 19 | spec.metadata["homepage_uri"] = spec.homepage 20 | spec.metadata["source_code_uri"] = "https://github.com/piotrmurach/tty-runner" 21 | 22 | spec.files = Dir["lib/**/*"] 23 | spec.extra_rdoc_files = ["README.md", "CHANGELOG.md", "LICENSE.txt"] 24 | spec.require_paths = ["lib"] 25 | spec.required_ruby_version = Gem::Requirement.new(">= 2.0.0") 26 | 27 | spec.add_development_dependency "rake" 28 | spec.add_development_dependency "rspec", ">= 3.0" 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - "bin/**" 9 | - "*.md" 10 | - "examples/**" 11 | pull_request: 12 | branches: 13 | - master 14 | paths-ignore: 15 | - "bin/**" 16 | - "*.md" 17 | - "examples/**" 18 | jobs: 19 | tests: 20 | name: Ruby ${{ matrix.ruby }} 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | os: 26 | - ubuntu-latest 27 | ruby: 28 | - 2.1 29 | - 2.2 30 | - 2.3 31 | - 2.4 32 | - 2.5 33 | - 2.6 34 | - ruby-head 35 | - jruby-9.2.13.0 36 | - jruby-head 37 | - truffleruby-head 38 | include: 39 | - ruby: 2.7 40 | os: ubuntu-latest 41 | coverage: true 42 | env: 43 | COVERAGE: ${{ matrix.coverage }} 44 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 45 | continue-on-error: ${{ endsWith(matrix.ruby, 'head') }} 46 | steps: 47 | - uses: actions/checkout@v2 48 | - name: Set up Ruby 49 | uses: ruby/setup-ruby@v1 50 | with: 51 | ruby-version: ${{ matrix.ruby }} 52 | - name: Install bundler 53 | run: gem install bundler -v '< 2.0' 54 | - name: Install dependencies 55 | run: bundle install --jobs 4 --retry 3 56 | - name: Run tests 57 | run: bundle exec rake ci 58 | -------------------------------------------------------------------------------- /lib/tty/runner/interceptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tty-option" 4 | 5 | module TTY 6 | class Runner 7 | module Interceptor 8 | def self.intercept(runnable, action: :call) 9 | unless runnable.included_modules.include?(TTY::Option) 10 | return 11 | end 12 | 13 | runnable.extend(ClassMethods) 14 | runnable.add_help_flag if runnable.respond_to?(:parameters) 15 | runnable.redefine_action(action) if runnable.method_defined?(action) 16 | end 17 | 18 | module ClassMethods 19 | def add_help_flag 20 | module_eval do 21 | unless parameters.map(&:key).include?(:help) 22 | flag :help, short: "-h", long: "--help", desc: "Print usage" 23 | end 24 | end 25 | end 26 | 27 | def redefine_action(action) 28 | module_eval do 29 | new_action = :"#{action}" 30 | old_action = :"tty_runner_original_#{action}" 31 | 32 | alias_method old_action, new_action 33 | 34 | define_method(new_action) do |argv| 35 | parse(argv) 36 | 37 | if params["help"] || argv.first == "help" 38 | $stderr.puts help 39 | exit 40 | end 41 | 42 | met_params = method(old_action).parameters 43 | req_args = met_params.select { |mp| [:req].include?(mp[0]) } 44 | arity = req_args.size 45 | args = arity < 1 ? [] : [argv] 46 | 47 | public_send(old_action, *args) 48 | end 49 | end 50 | end 51 | end 52 | end # Interceptor 53 | end # Runner 54 | end # TTY 55 | -------------------------------------------------------------------------------- /spec/unit/parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TTY::Runner::Parser do 4 | it "finds no matching command" do 5 | root_context = TTY::Runner::Context.new("") 6 | parser = described_class.new(root_context) 7 | 8 | res = parser.parse(%w[foo], {}) 9 | 10 | expect(res).to eq([nil, "", false]) 11 | expect(parser.matched_argv).to eq([]) 12 | expect(parser.remaining_argv).to eq(%w[foo]) 13 | end 14 | 15 | it "finds a top level 'foo' command" do 16 | root_context = TTY::Runner::Context.new("") 17 | foo_context = root_context.add("foo") 18 | parser = described_class.new(root_context) 19 | 20 | res = parser.parse(%w[foo], {}) 21 | 22 | expect(res).to eq([foo_context, "foo", true]) 23 | expect(parser.matched_argv).to eq(%w[foo]) 24 | expect(parser.remaining_argv).to eq([]) 25 | end 26 | 27 | it "finds an exact match 'foo bar' subcommand " do 28 | root_context = TTY::Runner::Context.new("") 29 | foo_context = root_context.add("foo") 30 | bar_context = foo_context.add("bar") 31 | parser = described_class.new(root_context) 32 | 33 | res = parser.parse(%w[foo bar], {}) 34 | 35 | expect(res).to eq([bar_context, "foo bar", true]) 36 | expect(parser.matched_argv).to eq(%w[foo bar]) 37 | expect(parser.remaining_argv).to eq([]) 38 | end 39 | 40 | it "returns command when 'foo bar' matched and runnable" do 41 | root_context = TTY::Runner::Context.new("") 42 | foo_context = root_context.add("foo") 43 | runnable = -> { "baz" } 44 | bar_context = foo_context.add("bar", runnable) 45 | parser = described_class.new(root_context) 46 | 47 | res = parser.parse(%w[foo bar extra], {}) 48 | 49 | expect(res).to eq([bar_context, "foo bar", true]) 50 | expect(parser.matched_argv).to eq(%w[foo bar]) 51 | expect(parser.remaining_argv).to eq(%w[extra]) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/unit/context_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TTY::Runner::Context do 4 | describe "#add" do 5 | it "add child context" do 6 | root_context = described_class.new("foo") 7 | 8 | expect(root_context.root?).to eq(true) 9 | expect(root_context.children?).to eq(false) 10 | 11 | bar_context = root_context.add("bar") 12 | baz_context = root_context.add("baz") 13 | 14 | expect(root_context.children?).to eq(true) 15 | 16 | expect do |block| 17 | root_context.each(&block) 18 | end.to yield_successive_args(["bar", bar_context], ["baz", baz_context]) 19 | end 20 | end 21 | 22 | describe "#runnable?" do 23 | it "doesn't set runnable by default" do 24 | foo_context = described_class.new("foo") 25 | expect(foo_context.runnable?).to eq(false) 26 | end 27 | 28 | it "sets runnable" do 29 | foo_context = described_class.new("foo", runnable: -> { "bar" }) 30 | expect(foo_context.runnable?).to eq(true) 31 | end 32 | end 33 | 34 | describe "#<=>" do 35 | it "compares by command context name in alphabetical order" do 36 | foo_context = described_class.new("foo") 37 | bar_context = described_class.new("bar") 38 | 39 | expect(bar_context).to be < foo_context 40 | end 41 | end 42 | 43 | describe "#to_s" do 44 | it "returns name" do 45 | cmd_context = described_class.new("foo") 46 | expect(cmd_context.to_s).to eq("foo") 47 | end 48 | end 49 | 50 | describe "#dump" do 51 | it "dumps root context" do 52 | cmd_context = described_class.new("foo") 53 | expect(cmd_context.dump).to eq("foo") 54 | end 55 | 56 | it "dumps nested contexts" do 57 | cmd_context = described_class.new("foo") 58 | cmd_context.add("bar") 59 | get_cmd_context = cmd_context.add("baz") 60 | get_cmd_context.add("qux") 61 | 62 | expect(cmd_context.dump).to eq("foo => [bar, baz => [qux]]") 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/unit/namespace_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TTY::Runner, "namespace" do 4 | context "nested commands" do 5 | before do 6 | stub_const("MyCLI::Commands::FooCommand", Class.new do 7 | def call 8 | print "running FooCommand" 9 | end 10 | end) 11 | 12 | stub_const("MyCLI::Commands::Foo::BarCommand", Class.new do 13 | def call 14 | print "running Foo::BarCommand" 15 | end 16 | end) 17 | 18 | stub_const("MyCLI::Commands::Foo::Bar::BazCommand", Class.new do 19 | def call 20 | print "running Foo::Bar::BazCommand" 21 | end 22 | end) 23 | 24 | stub_const("A", Class.new(TTY::Runner) do 25 | commands namespace: MyCLI::Commands do 26 | on "foo", run: "foo_command" do 27 | on "bar", run: "bar_command" do 28 | on "baz" do 29 | run "baz_command" 30 | end 31 | end 32 | end 33 | end 34 | end) 35 | end 36 | 37 | it "prepends top level 'foo' command with MyCLI::Commands" do 38 | expect { A.run(%w[foo]) }.to output("running FooCommand").to_stdout 39 | end 40 | 41 | it "prepends first level 'bar' command with MyCLI::Commands" do 42 | expect { 43 | A.run(%w[foo bar]) 44 | }.to output("running Foo::BarCommand").to_stdout 45 | end 46 | 47 | it "prepends second level 'baz' command with MyCLI::Commands" do 48 | expect { 49 | A.run(%w[foo bar baz]) 50 | }.to output("running Foo::Bar::BazCommand").to_stdout 51 | end 52 | end 53 | 54 | context "error" do 55 | it "fails when namespace is not a class or a module" do 56 | expect { 57 | TTY::Runner.commands namespace: :invalid do 58 | end 59 | }.to raise_error(described_class::Error, 60 | "invalid namespace: :invalid, needs to be " \ 61 | "a class or module.") 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/unit/desc_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TTY::Runner, "#desc" do 4 | let(:stdout) { StringIO.new } 5 | 6 | context "displays command summaries" do 7 | before do 8 | stub_const("A", Class.new(TTY::Runner) do 9 | commands do 10 | on "foo", "Foo cmd desc" do 11 | on "a", "A cmd desc" 12 | on "b" do 13 | desc "B cmd desc" 14 | end 15 | on "c", "C cmd desc" 16 | end 17 | 18 | on "bar" do 19 | desc "Bar cmd desc" 20 | 21 | on "d", "D cmd desc" 22 | on "f", "F cmd desc" 23 | 24 | on "baz", "Baz cmd desc" do 25 | on "e", "E cmd desc" 26 | end 27 | end 28 | end 29 | end) 30 | 31 | end 32 | 33 | it "displays top level commands descriptions" do 34 | A.run(%w[], output: stdout) 35 | stdout.rewind 36 | expect(stdout.string).to eq([ 37 | "Commands:", 38 | " bar Bar cmd desc", 39 | " foo Foo cmd desc\n" 40 | ].join("\n")) 41 | end 42 | 43 | it "displays 'foo' subcommands descriptions" do 44 | A.run(%w[foo], output: stdout) 45 | stdout.rewind 46 | expect(stdout.string).to eq([ 47 | "Commands:", 48 | " foo a A cmd desc", 49 | " foo b B cmd desc", 50 | " foo c C cmd desc\n" 51 | ].join("\n")) 52 | end 53 | 54 | it "displays 'bar' subcommands descriptions" do 55 | A.run(%w[bar], output: stdout) 56 | stdout.rewind 57 | expect(stdout.string).to eq([ 58 | "Commands:", 59 | " bar baz Baz cmd desc", 60 | " bar d D cmd desc", 61 | " bar f F cmd desc\n" 62 | ].join("\n")) 63 | end 64 | 65 | it "displays 'bar baz' subcommands descriptions" do 66 | A.run(%w[bar baz], output: stdout) 67 | stdout.rewind 68 | expect(stdout.string).to eq([ 69 | "Commands:", 70 | " bar baz e E cmd desc\n" 71 | ].join("\n")) 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/tty/runner/loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "inflection" 4 | 5 | module TTY 6 | class Runner 7 | class Loader 8 | # Mappings of an absolute command path to a namespace 9 | # 10 | # @example 11 | # "/Users/tty/my_cli/commands" => MyCLI::Commands 12 | # 13 | # @api private 14 | attr_reader :dir_mappings 15 | 16 | def initialize 17 | @dir_mappings = {} 18 | end 19 | 20 | # A collection of all the command directories 21 | # 22 | # @return [Array] 23 | # 24 | # @api public 25 | def command_dirs 26 | dir_mappings.keys 27 | end 28 | 29 | # Add directory to load commands from 30 | # 31 | # @example 32 | # add_dir "cli/commands" 33 | # 34 | # @param [String] dir 35 | # the absolute directory path 36 | # @param [Object] namespace 37 | # the namespace for all commands inside the directory 38 | # 39 | # @raise [TTY::Runner::Error] 40 | # 41 | # @api public 42 | def add_dir(dir, namespace: Object) 43 | unless namespace.is_a?(Module) 44 | raise Error, "invalid namespace: #{namespace.inspect}, " \ 45 | "needs to be a class or module." 46 | end 47 | 48 | abs_path = ::File.expand_path(dir) 49 | if ::File.directory?(abs_path) 50 | @dir_mappings[abs_path] = namespace 51 | else 52 | raise Error, "directory #{abs_path} does not exist" 53 | end 54 | end 55 | 56 | # Load a command from a file matching commands path 57 | # 58 | # @param [Array[String]] cmds 59 | # @param [Object] namespace 60 | # 61 | # @api public 62 | def load_command(*cmds, namespace: Object) 63 | dir_mappings.each do |dir, object| 64 | cmd_path = ::File.join(dir, *cmds.map(&:to_s)) 65 | if ::File.file?("#{cmd_path}.rb") 66 | Kernel.require(cmd_path) 67 | namespace = object unless object == Object 68 | break 69 | end 70 | end 71 | 72 | const_name = cmds.map(&Inflection.method(:camelcase)).join("::") 73 | namespace.const_get(const_name) 74 | end 75 | end # Loader 76 | end # Runner 77 | end # TTY 78 | -------------------------------------------------------------------------------- /lib/tty/runner/parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TTY 4 | class Runner 5 | # Responsible for parsing command line arguments 6 | class Parser 7 | attr_reader :matched_argv 8 | 9 | attr_reader :remaining_argv 10 | 11 | def initialize(context) 12 | @context = context 13 | @argv = [] 14 | @matched_argv = [] 15 | @remaining_argv = [] 16 | end 17 | 18 | # Parse command line arguments and find a matching command context 19 | # 20 | # @param [Array] argv 21 | # the command line arguments 22 | # 23 | # @param [Hash] env 24 | # the command environment variables 25 | # 26 | # @return [Context, String, Boolean] 27 | # 28 | # @api private 29 | def parse(argv, env) 30 | @argv = argv.dup 31 | @env = env 32 | 33 | tuple = find_command(@context, prefix: "") 34 | 35 | while (val = @argv.shift) 36 | @remaining_argv << val 37 | end 38 | 39 | tuple 40 | end 41 | 42 | # Find a command that matches argument 43 | # 44 | # @return [Context, String, Boolean] 45 | # 46 | # @api private 47 | def find_command(context, prefix: "") 48 | any_match = false 49 | name = peek.to_s 50 | cmd_context = context[name] 51 | 52 | if cmd_context 53 | any_match = true 54 | @matched_argv << consume 55 | prefix = "#{prefix}#{' ' if !prefix.empty? && name}#{name}" 56 | 57 | if !last? && cmd_context.children? && command? 58 | return find_command(cmd_context, prefix: prefix) 59 | end 60 | end 61 | 62 | [cmd_context, prefix, any_match] 63 | end 64 | 65 | def last? 66 | @argv.empty? 67 | end 68 | 69 | def command? 70 | peek && !peek.to_s.match(/^-{1,2}\S+/) 71 | end 72 | 73 | def peek 74 | @argv.first 75 | end 76 | 77 | def consume 78 | @argv.shift 79 | end 80 | 81 | def unknown_command 82 | out = @matched_argv.join(" ") 83 | out += " " unless @matched_argv.empty? 84 | out += @remaining_argv.first unless @remaining_argv.empty? 85 | out 86 | end 87 | end # Parser 88 | end # Runner 89 | end # TTY 90 | -------------------------------------------------------------------------------- /lib/tty/runner/context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TTY 4 | class Runner 5 | # Command context 6 | class Context 7 | include Comparable 8 | include Enumerable 9 | 10 | # The parent that owns this context 11 | attr_accessor :parent 12 | 13 | # The command name 14 | attr_reader :name 15 | 16 | # The object to call when running command 17 | attr_accessor :runnable 18 | 19 | # The method to execute on the runnable command 20 | attr_accessor :action 21 | 22 | # The nested commands 23 | attr_reader :children 24 | 25 | # The aliases for this context command 26 | attr_reader :aliases 27 | 28 | # The command description 29 | attr_accessor :desc 30 | 31 | def initialize(name, parent = EMPTY, runnable: nil, action: default_action, 32 | desc: nil) 33 | @name = name 34 | @parent = parent 35 | @runnable = runnable 36 | @action = action 37 | @children = {} 38 | @aliases = {} 39 | @desc = desc 40 | end 41 | 42 | def default_action 43 | :call 44 | end 45 | 46 | # Add a child context 47 | # 48 | # @param [String] name 49 | # @param [Object|nil] runnable 50 | # @param [Array] aliases 51 | # 52 | # @api public 53 | def add(name, runnable = nil, aliases: [], action: default_action) 54 | context = self.class.new(name, self, runnable: runnable, action: action) 55 | @children[name] = context 56 | aliases.each { |aliaz| @aliases[aliaz] = name } 57 | context 58 | end 59 | 60 | # Check if this context is empty 61 | def empty? 62 | @name.nil? && @parent.nil? 63 | end 64 | 65 | # Check if this context is top level 66 | def root? 67 | @parent.empty? 68 | end 69 | 70 | # Check if context has runnable command 71 | def runnable? 72 | !@runnable.nil? 73 | end 74 | 75 | def children? 76 | !@children.empty? 77 | end 78 | 79 | # Compare two different contexts 80 | def <=>(other) 81 | @name <=> other.name 82 | end 83 | 84 | # Lookup context based on the command name 85 | # 86 | # @param [String] name 87 | # 88 | # @api public 89 | def [](name) 90 | return self if @name == name && root? 91 | 92 | @children[@aliases.fetch(name, name)] 93 | end 94 | 95 | # Iterate over all the child contexts 96 | def each(&block) 97 | @children.each(&block) 98 | end 99 | 100 | # This context name 101 | def to_s 102 | name 103 | end 104 | 105 | # Dump the subcommands structure 106 | # 107 | # Useful for debugging 108 | # 109 | # @api private 110 | def dump 111 | res = name 112 | res += " => [#{each.map { |_, v| v.dump }.join(', ')}]" if children? 113 | res 114 | end 115 | 116 | EMPTY = Context.new(nil, nil) 117 | end # Context 118 | end # Runner 119 | end # TTY 120 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at piotr@piotrmurach.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/tty/runner/router.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "context" 4 | 5 | module TTY 6 | class Runner 7 | # Map command names to commands 8 | class Router 9 | attr_reader :context 10 | 11 | def initialize(mod_extension: nil) 12 | @context = Context.new("") 13 | @mod_extension = mod_extension 14 | end 15 | 16 | # Evaluate all commands 17 | # 18 | # @api private 19 | def evaluate(&block) 20 | instance_exec(&block) 21 | end 22 | 23 | # Map command name with runnable commands 24 | # 25 | # @example 26 | # on "foo" do 27 | # on "bar" do 28 | # # matches 29 | # end 30 | # end 31 | # 32 | # @param [String, Proc] name 33 | # the command name to match 34 | # @param [Object] run 35 | # the object to run 36 | # @param [Array] aliases 37 | # the command aliases 38 | # 39 | # @api public 40 | def on(name, desc = nil, run: nil, aliases: [], action: :call, &block) 41 | name = convert(name) 42 | 43 | with_context(name, aliases: aliases) do 44 | desc(desc) if desc 45 | 46 | run(run, action: action) 47 | 48 | block.call if block 49 | end 50 | end 51 | 52 | # Provide summary for the command 53 | # 54 | # @param [String] description 55 | # 56 | # @api public 57 | def desc(description) 58 | @context.desc = description 59 | end 60 | 61 | # Specify code to run when command is matched 62 | # 63 | # @param [Class] command 64 | # 65 | # @api public 66 | def run(command = nil, action: nil, &block) 67 | if block && !command.nil? 68 | raise Error, "cannot provide both command object and block" 69 | end 70 | 71 | if block 72 | runnable = Class.new 73 | runnable.__send__(:include, @mod_extension) if @mod_extension 74 | runnable.module_eval(&block) 75 | else 76 | runnable = command 77 | end 78 | 79 | @context.runnable = runnable 80 | @context.action = action if action 81 | end 82 | 83 | # Mount other commands runner 84 | # 85 | # @example 86 | # on :foo do 87 | # mount TagCommands 88 | # end 89 | # 90 | # @api public 91 | def mount(object) 92 | unless runner_class?(object) 93 | raise Error, "A TTY::Runner type must be given" 94 | end 95 | 96 | instance_exec(&object.commands_block) 97 | end 98 | 99 | private 100 | 101 | # Evaluate block with a new context 102 | # 103 | # @api private 104 | def with_context(name, aliases: []) 105 | @context = @context.add(name, aliases: aliases) 106 | yield 107 | ensure 108 | @context = @context.parent 109 | end 110 | 111 | # Check if runner is of TTY::Runner type 112 | # 113 | # @api private 114 | def runner_class?(object) 115 | object.is_a?(Class) && object < TTY::Runner 116 | end 117 | 118 | # Convert matcher to string value 119 | # 120 | # @api private 121 | def convert(matcher) 122 | case matcher 123 | when String, Symbol 124 | matcher.to_s 125 | when Proc 126 | matcher.call.to_s 127 | else 128 | unsupported_matcher(matcher) 129 | end 130 | end 131 | 132 | # Matcher not supported 133 | # 134 | # @api private 135 | def unsupported_matcher(matcher) 136 | raise Error, "unsupported matcher: #{matcher.inspect}" 137 | end 138 | end # Router 139 | end # Runner 140 | end # TTY 141 | -------------------------------------------------------------------------------- /spec/unit/intercept_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TTY::Runner, "intercepting action" do 4 | context "defined command" do 5 | before do 6 | stub_const("FooCommand", Class.new do 7 | include TTY::Option 8 | 9 | command :foo 10 | 11 | argument :a, desc: "A desc" 12 | argument :b, desc: "B desc" 13 | 14 | def call 15 | puts "foo a=#{params[:a]} b=#{params[:b]}" 16 | end 17 | end) 18 | 19 | stub_const("BarCommand", Class.new do 20 | include TTY::Option 21 | 22 | command :bar 23 | 24 | option :a, short: "-a N", desc: "A desc" 25 | 26 | flag :help, short: "-h", long: "--help", desc: "Print help information" 27 | 28 | def execute 29 | puts "bar a=#{params[:a]} rest=#{params.remaining}" 30 | end 31 | end) 32 | 33 | stub_const("A", Class.new(TTY::Runner) do 34 | commands do 35 | on "foo", run: "foo_command" 36 | 37 | on "bar", run: "bar_command#execute" 38 | end 39 | end) 40 | end 41 | 42 | it "displays help information" do 43 | help_info = unindent(<<-EOS) 44 | Usage: rspec foo [OPTIONS] A B 45 | 46 | Arguments: 47 | A A desc 48 | B B desc 49 | 50 | Options: 51 | -h, --help Print usage 52 | EOS 53 | 54 | expect { 55 | expect { A.run(%w[foo -h]) }.to raise_error(SystemExit) 56 | }.to output(help_info).to_stderr 57 | end 58 | 59 | it "parses parameters" do 60 | expect { 61 | A.run(%w[foo 1 2]) 62 | }.to output("foo a=1 b=2\n").to_stdout 63 | end 64 | 65 | it "collects remaining parameters" do 66 | expect { 67 | A.run(%w[bar -a 1 2]) 68 | }.to output("bar a=1 rest=[\"2\"]\n").to_stdout 69 | end 70 | 71 | it "redefines help flag in 'bar' command" do 72 | help_info = unindent(<<-EOS) 73 | Usage: rspec bar [OPTIONS] 74 | 75 | Options: 76 | -h, --help Print help information 77 | -a A desc 78 | EOS 79 | 80 | expect { 81 | expect { A.run(%w[bar -h]) }.to raise_error(SystemExit) 82 | }.to output(help_info).to_stderr 83 | end 84 | end 85 | 86 | context "defined command without action" do 87 | it "raises error when action definition is missing" do 88 | stub_const("FooCommand", Class.new do 89 | include TTY::Option 90 | 91 | # no action 92 | end) 93 | 94 | stub_const("X", Class.new(TTY::Runner) do 95 | commands do 96 | on "foo", run: "foo_command#execute" 97 | end 98 | end) 99 | 100 | expect { 101 | X.run(%w[foo]) 102 | }.to raise_error(TTY::Runner::Error, "missing command action: \"execute\"") 103 | end 104 | end 105 | 106 | context "anonymous command" do 107 | before do 108 | stub_const("B", Class.new(TTY::Runner) do 109 | commands do 110 | on "foo" do 111 | run do 112 | command :foo 113 | 114 | argument :a, desc: "A desc" 115 | 116 | option :b, short: "-b N", desc: "B desc" 117 | 118 | def call 119 | puts "foo a=#{params[:a]} b=#{params[:b]}" 120 | end 121 | end 122 | end 123 | end 124 | end) 125 | end 126 | 127 | it "parses parameters" do 128 | expect { 129 | B.run(%w[foo 1 -b 2]) 130 | }.to output("foo a=1 b=2\n").to_stdout 131 | end 132 | 133 | it "displays help information" do 134 | help_info = unindent(<<-EOS) 135 | Usage: rspec foo [OPTIONS] A 136 | 137 | Arguments: 138 | A A desc 139 | 140 | Options: 141 | -h, --help Print usage 142 | -b B desc 143 | EOS 144 | 145 | expect { 146 | expect { B.run(%w[foo -h]) }.to raise_error(SystemExit) 147 | }.to output(help_info).to_stderr 148 | end 149 | end 150 | 151 | context "anonymous class without action" do 152 | it "raises error when action definition is missing" do 153 | stub_const("X", Class.new(TTY::Runner) do 154 | commands do 155 | on "foo" do 156 | run do 157 | # no action 158 | end 159 | end 160 | end 161 | end) 162 | 163 | expect { 164 | X.run(%w[foo]) 165 | }.to raise_error(TTY::Runner::Error, "missing command action: \"call\"") 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /spec/unit/commands_dir_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TTY::Runner, "commands_dir" do 4 | context "without namespace" do 5 | it "loads commands from a relative directory" do 6 | files = [ 7 | ["commands/foo_command.rb", <<-EOS], 8 | class FooCommand 9 | def call 10 | puts "running FooCommand" 11 | end 12 | end 13 | EOS 14 | ["commands/foo/bar_command.rb", <<-EOS] 15 | module Foo 16 | class BarCommand 17 | def call 18 | puts "running Foo::BarCommand" 19 | end 20 | end 21 | end 22 | EOS 23 | ] 24 | 25 | with_files(files) do 26 | stub_const("A", Class.new(TTY::Runner) do 27 | commands_dir "commands" 28 | 29 | commands do 30 | on "foo", run: "foo_command" do 31 | on "bar", run: "bar_command" 32 | end 33 | end 34 | end) 35 | 36 | expect(::File.directory?("commands")).to eq(true) 37 | 38 | expect { A.run(%w[foo]) }.to output("running FooCommand\n").to_stdout 39 | 40 | expect { A.run(%w[foo bar]) }.to output("running Foo::BarCommand\n").to_stdout 41 | end 42 | end 43 | 44 | after do 45 | remove_const :FooCommand 46 | remove_const :Foo 47 | end 48 | end 49 | 50 | context "with namespace" do 51 | it "loads commands from a directory with a Commands namespace" do 52 | stub_const("Commands", Module.new) 53 | 54 | files = [ 55 | ["commands/foo_command.rb", <<-EOS], 56 | module Commands 57 | class FooCommand 58 | def call 59 | puts "running FooCommand" 60 | end 61 | end 62 | end 63 | EOS 64 | ["commands/foo/bar_command.rb", <<-EOS] 65 | module Commands 66 | module Foo 67 | class BarCommand 68 | def call 69 | puts "running Foo::BarCommand" 70 | end 71 | end 72 | end 73 | end 74 | EOS 75 | ] 76 | 77 | with_files(files) do 78 | stub_const("B", Class.new(TTY::Runner) do 79 | commands_dir "commands" 80 | 81 | commands namespace: Commands do 82 | on "foo", run: "foo_command" do 83 | on "bar", run: "bar_command" 84 | end 85 | end 86 | end) 87 | 88 | expect(::File.directory?("commands")).to eq(true) 89 | 90 | expect { B.run(%w[foo]) }.to output("running FooCommand\n").to_stdout 91 | 92 | expect { B.run(%w[foo bar]) }.to output("running Foo::BarCommand\n").to_stdout 93 | end 94 | end 95 | end 96 | 97 | context "when multiple dirs & namespaces" do 98 | it "loads commands from multiple directories with different namespaces" do 99 | stub_const("ACommands", Module.new) 100 | stub_const("BCommands", Module.new) 101 | 102 | files = [ 103 | ["a_dir/foo_command.rb", <<-EOS], 104 | module ACommands 105 | class FooCommand 106 | def call 107 | puts "running ACommands::FooCommand in a_dir" 108 | end 109 | end 110 | end 111 | EOS 112 | ["b_dir/bar_command.rb", <<-EOS], 113 | module BCommands 114 | class BarCommand 115 | def call 116 | puts "running BCommands::BarCommand in b_dir" 117 | end 118 | end 119 | end 120 | EOS 121 | ] 122 | 123 | with_files(files) do 124 | stub_const("C", Class.new(TTY::Runner) do 125 | commands_dir "a_dir", namespace: ACommands 126 | 127 | commands_dir "b_dir", namespace: BCommands 128 | 129 | commands do 130 | on "foo", run: "foo_command" 131 | on "bar", run: "bar_command" 132 | end 133 | end) 134 | 135 | expect(::File.directory?("a_dir")).to eq(true) 136 | expect(::File.directory?("b_dir")).to eq(true) 137 | 138 | expect { 139 | C.run(%w[foo]) 140 | }.to output("running ACommands::FooCommand in a_dir\n").to_stdout 141 | 142 | expect { 143 | C.run(%w[bar]) 144 | }.to output("running BCommands::BarCommand in b_dir\n").to_stdout 145 | end 146 | end 147 | end 148 | 149 | context "when non-existent directory" do 150 | it "fails to find commands directory" do 151 | expect { 152 | TTY::Runner.commands_dir "#{__dir__}/unknown" 153 | }.to raise_error(TTY::Runner::Error, 154 | "directory #{__dir__}/unknown does not exist") 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /spec/unit/loader_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TTY::Runner::Loader do 4 | context "#add_dir" do 5 | it "has not directories by default" do 6 | loader = described_class.new 7 | 8 | expect(loader.dir_mappings).to be_empty 9 | expect(loader.command_dirs).to be_empty 10 | end 11 | 12 | it "adds only a directory and defaults the namespace to an Object" do 13 | loader = described_class.new 14 | 15 | loader.add_dir "." 16 | 17 | expect(loader.dir_mappings).to eq({ Dir.pwd => Object }) 18 | expect(loader.command_dirs).to eq([Dir.pwd]) 19 | end 20 | 21 | it "adds a directory with a namespace mapping" do 22 | stub_const("Commands", Module.new) 23 | loader = described_class.new 24 | 25 | loader.add_dir ".", namespace: Commands 26 | 27 | expect(loader.dir_mappings).to eq({ Dir.pwd => Commands }) 28 | expect(loader.command_dirs).to eq([Dir.pwd]) 29 | end 30 | 31 | it "adds two directories where one has a custom namespace" do 32 | parent_dir = ::File.expand_path("..") 33 | loader = described_class.new 34 | 35 | loader.add_dir "." 36 | loader.add_dir "..", namespace: Module 37 | 38 | expect(loader.dir_mappings).to eq({ Dir.pwd => Object, parent_dir => Module }) 39 | expect(loader.command_dirs).to eq([Dir.pwd, parent_dir]) 40 | end 41 | 42 | it "fails to add directory that doesn't exist" do 43 | loader = described_class.new 44 | unknown_dir = ::File.expand_path("unknown") 45 | 46 | expect { 47 | loader.add_dir "unknown" 48 | }.to raise_error(TTY::Runner::Error, 49 | "directory #{unknown_dir} does not exist") 50 | end 51 | 52 | it "fails to add directory with a namespace that isn't a class or module " do 53 | loader = described_class.new 54 | 55 | expect { 56 | loader.add_dir ".", namespace: :unknown 57 | }.to raise_error(TTY::Runner::Error, 58 | "invalid namespace: :unknown, needs to be " \ 59 | "a class or module.") 60 | end 61 | end 62 | 63 | context "#load_command" do 64 | it "loads a top level command from the current directory" do 65 | files = [ 66 | ["foo_command.rb", "class FooCommand; end"] 67 | ] 68 | with_files files do 69 | loader = described_class.new 70 | loader.add_dir "." 71 | 72 | expect(Object.const_defined?(:FooCommand)).to eq(false) 73 | 74 | command = loader.load_command "foo_command" 75 | 76 | expect(command).to eq(FooCommand) 77 | end 78 | remove_const :FooCommand 79 | end 80 | 81 | it "loads a top level command from the 'cli/commands' directory" do 82 | files = [ 83 | ["cli/commands/foo_command.rb", "class FooCommand; end"] 84 | ] 85 | with_files files do 86 | loader = described_class.new 87 | loader.add_dir "cli/commands" 88 | 89 | expect(Object.const_defined?(:FooCommand)).to eq(false) 90 | 91 | command = loader.load_command "foo_command" 92 | 93 | expect(command).to eq(FooCommand) 94 | end 95 | remove_const :FooCommand 96 | end 97 | 98 | it "loads a nested command from the current directory" do 99 | stub_const("Foo", Module.new) 100 | files = [ 101 | ["foo/bar_command.rb", "class Foo::BarCommand; end"] 102 | ] 103 | with_files files do 104 | loader = described_class.new 105 | loader.add_dir "." 106 | 107 | expect(Foo.const_defined?(:BarCommand)).to eq(false) 108 | 109 | command = loader.load_command "foo", "bar_command" 110 | 111 | expect(command).to eq(Foo::BarCommand) 112 | end 113 | remove_const :BarCommand, parent: Foo 114 | end 115 | 116 | it "loads a nested command from the 'cli/commands' directory" do 117 | stub_const("Foo", Module.new) 118 | files = [ 119 | ["cli/commands/foo/bar_command.rb", "class Foo::BarCommand; end"] 120 | ] 121 | with_files files do 122 | loader = described_class.new 123 | loader.add_dir "cli/commands" 124 | 125 | expect(Foo.const_defined?(:BarCommand)).to eq(false) 126 | 127 | command = loader.load_command "foo", "bar_command" 128 | 129 | expect(command).to eq(Foo::BarCommand) 130 | end 131 | remove_const :BarCommand, parent: Foo 132 | end 133 | 134 | it "loads a command inside a namespace from the current directory" do 135 | stub_const("Commands", Module.new) 136 | files = [ 137 | ["foo_command.rb", "class Commands::FooCommand; end"] 138 | ] 139 | with_files files do 140 | loader = described_class.new 141 | loader.add_dir ".", namespace: Commands 142 | 143 | expect(Commands.const_defined?(:FooCommand)).to eq(false) 144 | 145 | command = loader.load_command "foo_command" 146 | 147 | expect(command).to eq(Commands::FooCommand) 148 | end 149 | remove_const :FooCommand, parent: Commands 150 | end 151 | 152 | it "loads a command inside a namespace from the 'cli/commands' directory" do 153 | stub_const("Commands", Module.new) 154 | files = [ 155 | ["cli/commands/foo_command.rb", "class Commands::FooCommand; end"] 156 | ] 157 | with_files files do 158 | loader = described_class.new 159 | loader.add_dir "cli/commands", namespace: Commands 160 | 161 | expect(Commands.const_defined?(:FooCommand)).to eq(false) 162 | 163 | command = loader.load_command "foo_command" 164 | 165 | expect(command).to eq(Commands::FooCommand) 166 | end 167 | remove_const :FooCommand, parent: Commands 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /lib/tty/runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "monitor" 4 | 5 | require_relative "runner/interceptor" 6 | require_relative "runner/loader" 7 | require_relative "runner/parser" 8 | require_relative "runner/router" 9 | require_relative "runner/version" 10 | 11 | module TTY 12 | class Runner 13 | class Error < StandardError; end 14 | 15 | @commands_block = nil 16 | @commands_namespace = Object 17 | @program_name = ::File.basename($0, ".*") 18 | 19 | module ClassMethods 20 | attr_reader :commands_block 21 | 22 | attr_reader :commands_namespace 23 | 24 | attr_reader :program_name 25 | 26 | # Copy class instance variables into the subclass 27 | def inherited(subclass) 28 | super 29 | subclass.instance_variable_set(:@commands_block, commands_block) 30 | subclass.instance_variable_set(:@commands_namespace, commands_namespace) 31 | subclass.instance_variable_set(:@program_name, program_name.dup) 32 | end 33 | 34 | # Commands loader 35 | # 36 | # @api private 37 | def _loader 38 | @_loader ||= Loader.new 39 | end 40 | 41 | # Define a directory to load 42 | # 43 | # @param [String] dir 44 | # the commands directory path 45 | # @param [Object] namespace 46 | # the namespace for all commands inside the directory 47 | # 48 | # @api public 49 | def commands_dir(dir, namespace: Object) 50 | _loader.add_dir(dir, namespace: namespace) 51 | end 52 | 53 | # Run commands 54 | # 55 | # @example 56 | # TTY::Runner.run 57 | # 58 | # @param [Array] argv 59 | # the command line arguments 60 | # @param [Hash] env 61 | # the hash of environment variables 62 | # @param [IO] output 63 | # the output stream 64 | # 65 | # @api public 66 | def run(argv = ARGV, env = ENV, output: $stdout) 67 | new(output: output).call(argv, env) 68 | end 69 | 70 | # The entry point of setting up applications commands. 71 | # 72 | # @example 73 | # TTY::Runner.commands do |c| 74 | # c.on "foo", run: FooCommand 75 | # end 76 | # 77 | # This should be called only once per class. 78 | # 79 | # @api public 80 | def commands(namespace: Object, &block) 81 | unless block 82 | raise Error, "no block provided" 83 | end 84 | unless namespace.is_a?(Module) 85 | raise Error, "invalid namespace: #{namespace.inspect}, " \ 86 | "needs to be a class or module." 87 | end 88 | 89 | @commands_block = block 90 | @commands_namespace = namespace 91 | end 92 | 93 | # Configure name for the runner 94 | # 95 | # @example 96 | # TTY::Runner.program "foo" 97 | # 98 | # @api public 99 | def program(name) 100 | @program_name = name 101 | end 102 | end 103 | 104 | attr_reader :_router 105 | 106 | attr_reader :_parser 107 | 108 | def initialize(output: $stdout) 109 | @output = output 110 | @_router = Router.new(mod_extension: TTY::Option) 111 | @lock = Monitor.new 112 | if self.class.commands_block 113 | @_router.evaluate(&self.class.commands_block) 114 | end 115 | @_parser = Parser.new(@_router.context) 116 | end 117 | 118 | # Process command line arguments 119 | # 120 | # @param [Array] argv 121 | # the command line arguments 122 | # @param [Hash] env 123 | # the hash of environment variables 124 | # 125 | # @api public 126 | def call(argv = ARGV, env = ENV) 127 | context, prefix, any_match = *_parser.parse(argv, env) 128 | 129 | if context 130 | if context.runnable? 131 | invoke_command(context) 132 | else 133 | @output.puts usage(context, prefix: prefix) 134 | end 135 | elsif !any_match 136 | @output.puts("Command '#{_parser.unknown_command}' not found") 137 | end 138 | end 139 | 140 | private 141 | 142 | # Invoke runnable from command context 143 | # 144 | # @api private 145 | def invoke_command(context) 146 | @lock.synchronize do 147 | command, action = *split_runnable(context) 148 | runnable = load_command_class(context, command) 149 | 150 | if runnable.is_a?(Module) 151 | Interceptor.intercept(runnable, action: action) 152 | runnable = runnable.new 153 | end 154 | 155 | if runnable.respond_to?(action) 156 | runnable.__send__(action, *runnable_args(runnable, action)) 157 | else 158 | raise Error, "missing command action: #{action.to_s.inspect}" 159 | end 160 | end 161 | end 162 | 163 | # Split runnable into command and action 164 | # 165 | # @param [Context] context 166 | # 167 | # @return [Array] 168 | # 169 | # @api private 170 | def split_runnable(context) 171 | command = context.runnable 172 | if command.is_a?(::String) 173 | command, action = *command.to_s.split(/#/) 174 | end 175 | [command, action || context.action] 176 | end 177 | 178 | # @api private 179 | def load_command_class(context, command) 180 | return command if command.respond_to?(:call) 181 | 182 | case command 183 | when ::Class 184 | command 185 | when ::String, ::Symbol 186 | to_runnable_class(context, command) 187 | else 188 | raise Error, "unsupported runnable: #{command.inspect}" 189 | end 190 | end 191 | 192 | # @api private 193 | def to_runnable_class(context, command) 194 | return command unless command.is_a?(::String) 195 | 196 | cmds = [] 197 | until context.parent.root? 198 | context = context.parent 199 | cmds.unshift(context.name) 200 | end 201 | cmds << command 202 | 203 | self.class._loader.load_command(*cmds, namespace: 204 | self.class.commands_namespace) 205 | end 206 | 207 | # @api private 208 | def runnable_args(runnable, action) 209 | arity = if runnable.respond_to?(:arity) 210 | runnable.arity 211 | else 212 | runnable.method(action.to_sym).arity 213 | end 214 | 215 | if arity < 1 216 | [] 217 | elsif arity == 1 218 | [@_parser.remaining_argv] 219 | end 220 | end 221 | 222 | # Print commands usage information 223 | # 224 | # @api private 225 | def usage(context, prefix: "") 226 | indent = " " * 2 227 | longest_name = context.map { |name, _| name.length }.max 228 | list = context.each_with_object([]) do |(name, cmd_context), acc| 229 | next if name.empty? 230 | cmd = format("%s%s%-#{longest_name}s", indent, 231 | "#{prefix}#{' ' unless prefix.empty?}", name) 232 | cmd += " #{cmd_context.desc}" if cmd_context.desc 233 | acc << cmd 234 | end 235 | list.sort! { |cmd_a, cmd_b| cmd_a <=> cmd_b } 236 | 237 | "Commands:\n#{list.join("\n")}" 238 | end 239 | 240 | extend ClassMethods 241 | end # Runner 242 | end # TTY 243 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | TTY Toolkit logo 3 |
4 | 5 | # TTY::Runner 6 | 7 | [![Gem Version](https://badge.fury.io/rb/tty-runner.svg)][gem] 8 | [![Actions CI](https://github.com/piotrmurach/tty-runner/workflows/CI/badge.svg?branch=master)][gh_actions_ci] 9 | [![Build status](https://ci.appveyor.com/api/projects/status/re0e9nyi6gavni77?svg=true)][appveyor] 10 | [![Maintainability](https://api.codeclimate.com/v1/badges/03169126a4ba2d031ece/maintainability)][codeclimate] 11 | [![Coverage Status](https://coveralls.io/repos/github/piotrmurach/tty-runner/badge.svg)][coverage] 12 | [![Inline docs](http://inch-ci.org/github/piotrmurach/tty-runner.svg?branch=master)][inchpages] 13 | 14 | [gem]: http://badge.fury.io/rb/tty-runner 15 | [gh_actions_ci]: https://github.com/piotrmurach/tty-runner/actions?query=workflow%3ACI 16 | [appveyor]: https://ci.appveyor.com/project/piotrmurach/tty-runner 17 | [codeclimate]: https://codeclimate.com/github/piotrmurach/tty-runner/maintainability 18 | [coverage]: https://coveralls.io/github/piotrmurach/tty-runner 19 | [inchpages]: http://inch-ci.org/github/piotrmurach/tty-runner 20 | 21 | > A command routing tree for terminal applications. 22 | 23 | **TTY::Runner** provides independent command running component for [TTY](https://github.com/piotrmurach/tty) toolkit. 24 | 25 | ## Installation 26 | 27 | Add this line to your application's Gemfile: 28 | 29 | ```ruby 30 | gem "tty-runner" 31 | ``` 32 | 33 | And then execute: 34 | 35 | $ bundle install 36 | 37 | Or install it yourself as: 38 | 39 | $ gem install tty-runner 40 | 41 | ## Contents 42 | 43 | * [1. Usage](#1-usage) 44 | * [2. API](#2-api) 45 | * [2.1 on](#21-on) 46 | * [2.2 run](#22-run) 47 | * [2.3 mount](#23-mount) 48 | 49 | ## 1. Usage 50 | 51 | Here's an example of an application showing routing of commands and subcommands: 52 | 53 | ```ruby 54 | # app.rb 55 | require "tty-runner" 56 | 57 | class App < TTY::Runner 58 | # The command line application commands are declared with the 'commands' method. 59 | commands do 60 | # Runs code inside a block when no commands are given. This is not 61 | # required as by default all commands will be listed instead. 62 | run do 63 | def call(argv) 64 | puts "root" 65 | end 66 | end 67 | 68 | # Matches when bare 'config' command is issued and by default 69 | # lists all immediate subcommands. 70 | on "config" do 71 | # Matches 'config add' subcommand and loads 'Config::AddCommand' object 72 | # based on the snake case name from the ':run' value. The 'Config::AddCommand' 73 | # needs to only implement a 'call' method that will be automatically invoked. 74 | on "add", "Add a new entry", run: "add_command" 75 | 76 | # The :run keyword accepts any callable object like a proc that will be 77 | # lazily evaluated when the 'config remove' command or 'config rm' alias 78 | # are matched. 79 | on "remove", aliases: %w[rm], run: -> { puts "removing from config..." } 80 | 81 | # The command can be given in an "command#action" format either via :run 82 | # keyword or using the 'run' helper method. 83 | # This will automatically convert 'get_command' into 'Config::GetCommand' 84 | # when 'config get' command is entered and invoke the 'execute' method. 85 | on "get" do 86 | run "get_command#execute" 87 | end 88 | 89 | # The 'run' helper can also accept a block that will be converted to 90 | # a command object when 'edit' subcommand is matched. It expects 91 | # a 'call' method implementation that optionally gets the rest of 92 | # unparsed command line arguments as a parameter. 93 | on "edit" do 94 | run do 95 | def call(argv) 96 | puts "editing with #{argv}" 97 | end 98 | end 99 | end 100 | end 101 | 102 | on "tag" do 103 | # This will match all commands starting with 'tag' and continue 104 | # matching process with subcommands from 'TagCommands' runner that 105 | # needs to be an instance of 'TTY::Runner'. This way you can compose 106 | # complex applications from smaller routing pieces. 107 | mount TagCommands 108 | end 109 | end 110 | end 111 | 112 | # Another 'TTY::Runner' application with commands that can be mounted 113 | # inside another runner application. This way you can build complex 114 | # command line applications from smaller parts. 115 | class TagCommands < TTY::Runner 116 | commands do 117 | on "create" do 118 | run -> { puts "tag creating..." } 119 | end 120 | 121 | on "delete" do 122 | run -> { puts "tag deleting..." } 123 | end 124 | end 125 | end 126 | ``` 127 | 128 | Then run your application with `run`: 129 | 130 | ```ruby 131 | App.run 132 | ``` 133 | 134 | When no arguments are provided, the top level run block will trigger: 135 | 136 | ``` 137 | app.rb 138 | # => 139 | # root 140 | ``` 141 | 142 | Supplying `config` command will list all the subcommands: 143 | 144 | ``` 145 | app.rb config 146 | # => 147 | # config add 148 | # config edit 149 | # config get 150 | # config remove 151 | ``` 152 | 153 | And when specific subcommand `rm` within the `config` scope is given: 154 | 155 | ``` 156 | app.rb config rm 157 | # => 158 | # removing from config... 159 | ``` 160 | 161 | We can also run mounted `create` subcommand from `TagCommands` runner under the `tag` command: 162 | 163 | ``` 164 | app.rb tag create 165 | # => 166 | # tag creating... 167 | ``` 168 | 169 | ## 2. API 170 | 171 | ### 2.1 on 172 | 173 | Using the `on` you can specify the name for the command that will match the command line input. With the `:run` parameter you can specify a command object to run. Supported values include an object that respond to `call` method or a string given as a snake case representing an object with corresponding action. 174 | 175 | Here are few examples how to specify a command to run: 176 | 177 | ```ruby 178 | on "cmd", run: -> { } # a proc to call 179 | on "cmd", run: Command # a Command object to instantiate and call 180 | on "cmd", run: "command" # invokes 'call' method by default 181 | on "cmd", run: "command#action" # specified custom 'action' method 182 | on "cmd", run: "command", action: "action" # specifies custom 'action' 183 | ``` 184 | 185 | The same values can be provided to the `run` method inside the block: 186 | 187 | ```ruby 188 | on "cmd" do 189 | run "command#action" 190 | end 191 | ``` 192 | 193 | The `on` method also serves as a namespace for other (sub)commands. There is no limit on how deeply you can nest commands. 194 | 195 | ```ruby 196 | on "foo", run: FooCommand do # matches 'foo' and invokes 'call' on FooCommand instance 197 | on "bar", run: "bar_command" do # matches 'foo bar' and invokes 'call' on Foo::BarCommand instance 198 | on "baz" do # matches 'foo bar baz' and 199 | run "baz_command#execute" # invokes 'execute' on Foo::Bar::BazCommand instance 200 | end 201 | end 202 | end 203 | ``` 204 | 205 | ### 2.2 run 206 | 207 | There are two ways to specify a command, with a `:run` keyword or a `run` helper. 208 | 209 | The `:run` keyword is used by `on` method and accepts the following values as a command: 210 | 211 | ```ruby 212 | on "cmd", run: -> { ... } # a proc object 213 | on "cmd", run: ->(argv) { ... } # a proc object with optional unparsed arguments 214 | on "cmd", run: FooCommand # a FooCommand object with 'call' method 215 | on "cmd", run: "foo_command" # expands name to a FooCommand object 216 | on "cmd", run: "foo_command#action" # expands name to a FooCommand object with 'action' method 217 | ``` 218 | 219 | The `run` helper supports all of the above values but differs with the ability to create a more complex command on-the-fly by specifying it inside a block. 220 | 221 | For example, the following creates a command that will be run when 'foo' is entered in the terminal: 222 | 223 | ```ruby 224 | on "foo" do 225 | run do 226 | def call(argv) 227 | ... 228 | end 229 | end 230 | end 231 | ``` 232 | 233 | The [tty-option](https://github.com/piotrmurach/tty-option) supercharges the `run` helper with many methods for argument and option parsing as well as generating command documentation. Please read the documentation to learn what is possible. 234 | 235 | For a quick example, to add 'foo' command with one argument and `--baz` option, we can do: 236 | 237 | ```ruby 238 | on "foo" do 239 | run do 240 | program "app" 241 | 242 | command "foo" 243 | 244 | desc "Run foo command" 245 | 246 | argument :bar do 247 | required 248 | desc "The bar argument" 249 | end 250 | 251 | option :baz do 252 | short "-b" 253 | long "--baz list" 254 | arity one_or_more 255 | convert :int_list 256 | desc "The baz option" 257 | end 258 | 259 | def call 260 | puts params["bar"] 261 | puts params["baz"] 262 | end 263 | end 264 | end 265 | ``` 266 | 267 | When run with the following command line inputs: 268 | 269 | ``` 270 | app foo one --baz 11 12 271 | ``` 272 | 273 | The output would produce: 274 | 275 | ``` 276 | one 277 | [11, 12] 278 | ``` 279 | 280 | You will automatically get `-h` and `--help` options for free, so running: 281 | 282 | ``` 283 | app foo --help 284 | ``` 285 | 286 | Will output: 287 | 288 | ``` 289 | Usage: app foo [OPTIONS] BAR 290 | 291 | Run foo command 292 | 293 | Arguments: 294 | BAR The bar argument 295 | 296 | Options: 297 | -b, --baz The baz option 298 | -h, --help Print usage 299 | ``` 300 | 301 | ### 2.3 mount 302 | 303 | In cases when your application grows in complexity and has many commands and each of these in turn has many subcommands, you can split and group commands into separate runner applications. 304 | 305 | For example, given a `FooSubcommands` runner application that groups all `foo` related subcommands: 306 | 307 | ```ruby 308 | # foo_subcommands.rb 309 | 310 | class FooSubcommands < TTY::Runner 311 | commands do 312 | on "bar", run: -> { puts "run bar" } 313 | on "baz", run: -> { puts "run baz" } 314 | end 315 | end 316 | ``` 317 | 318 | Using `mount`, we can nest our subcommands inside the `foo` command in the main application runner like so: 319 | 320 | ```ruby 321 | require_relative "foo_subcommands" 322 | 323 | class App < TTY::Runner 324 | commands do 325 | on "foo" do 326 | mount FooSubcommands 327 | end 328 | end 329 | end 330 | ``` 331 | 332 | See [mount example](https://github.com/piotrmurach/tty-runner/blob/master/examples/mount.rb). 333 | 334 | ## Development 335 | 336 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 337 | 338 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 339 | 340 | ## Contributing 341 | 342 | Bug reports and pull requests are welcome on GitHub at https://github.com/piotrmurach/tty-runner. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/piotrmurach/tty-runner/blob/master/CODE_OF_CONDUCT.md). 343 | 344 | 345 | ## License 346 | 347 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 348 | 349 | ## Code of Conduct 350 | 351 | Everyone interacting in the TTY::Runner project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/piotrmurach/tty-runner/blob/master/CODE_OF_CONDUCT.md). 352 | 353 | ## Copyright 354 | 355 | Copyright (c) 2020 Piotr Murach. See LICENSE for further details. 356 | -------------------------------------------------------------------------------- /spec/unit/run_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TTY::Runner do 4 | let(:stdout) { StringIO.new } 5 | 6 | context "matching commands with runnable procs" do 7 | before do 8 | stub_const("A", Class.new(TTY::Runner) do 9 | commands do 10 | run -> { puts "running root" } 11 | 12 | on "foo" do 13 | on "foo", "Foo foo desc", run: -> { puts "running foo foo" } 14 | 15 | on "bar" do 16 | desc "Foo bar desc" 17 | 18 | run -> { puts "running foo bar" } 19 | 20 | on "baz", "Foo bar baz desc" do 21 | run do 22 | def call(argv) 23 | puts "running foo bar baz with #{argv}" 24 | end 25 | end 26 | end 27 | end 28 | end 29 | 30 | on "bar", "Bar desc" do 31 | run ->(argv) { puts "running bar with #{argv}" } 32 | end 33 | end 34 | end) 35 | end 36 | 37 | it "matches no commands" do 38 | expect { 39 | A.run([]) 40 | }.to output("running root\n").to_stdout 41 | end 42 | 43 | it "shows available 'foo' subcommands when no runnable found" do 44 | A.run(%w[foo], output: stdout) 45 | stdout.rewind 46 | expect(stdout.string).to eq([ 47 | "Commands:", 48 | " foo bar Foo bar desc", 49 | " foo foo Foo foo desc\n" 50 | ].join("\n")) 51 | end 52 | 53 | it "matches top level 'bar' command" do 54 | expect { 55 | A.run(%w[bar]) 56 | }.to output("running bar with []\n").to_stdout 57 | end 58 | 59 | it "matches similarly named nested 'foo' subcommand" do 60 | expect { 61 | A.run(%w[foo foo]) 62 | }.to output("running foo foo\n").to_stdout 63 | end 64 | 65 | it "matches one level deep 'bar' subcommand" do 66 | expect { 67 | A.run(%w[foo bar]) 68 | }.to output("running foo bar\n").to_stdout 69 | end 70 | 71 | it "matches two levels deep 'baz' subcommand" do 72 | expect { 73 | A.run(%w[foo bar baz a b]) 74 | }.to output("running foo bar baz with [\"a\", \"b\"]\n").to_stdout 75 | end 76 | 77 | it "consumes only matching argument on top level" do 78 | expect { 79 | A.run(%w[bar extra]) 80 | }.to output("running bar with [\"extra\"]\n").to_stdout 81 | end 82 | 83 | it "consumes only matching arguments one level deep" do 84 | A.run(%w[foo bar extra], output: stdout) 85 | stdout.rewind 86 | expect(stdout.string).to eq("Command 'foo bar extra' not found\n") 87 | end 88 | 89 | it "fails to match top level command" do 90 | A.run(%w[unknown], output: stdout) 91 | stdout.rewind 92 | expect(stdout.string).to eq("Command 'unknown' not found\n") 93 | end 94 | end 95 | 96 | context "matching commands with runnable objects" do 97 | before do 98 | stub_const("Foo::BarCommand", Class.new do 99 | def call(argv) 100 | puts "running foo bar" 101 | end 102 | end) 103 | 104 | stub_const("Foo::BazCommand", Class.new do 105 | def call(argv) 106 | puts "running foo baz" 107 | end 108 | end) 109 | 110 | stub_const("Foo::QuxCommand", Class.new do 111 | def execute(argv) 112 | puts "running foo qux" 113 | end 114 | end) 115 | 116 | stub_const("BarCommand", Class.new do 117 | def execute(argv) 118 | puts "running bar" 119 | end 120 | end) 121 | 122 | stub_const("BazCommand", Class.new do 123 | def execute(argv) 124 | puts "running baz" 125 | end 126 | end) 127 | 128 | stub_const("B", Class.new(TTY::Runner) do 129 | commands do 130 | on "foo" do 131 | on :bar, run: Foo::BarCommand 132 | 133 | on :baz do 134 | run "baz_command" 135 | end 136 | 137 | on :qux do 138 | run "qux_command#execute" 139 | end 140 | 141 | on :quux do 142 | run "qux_command", action: :execute 143 | end 144 | end 145 | 146 | on "bar", run: "bar_command#execute" 147 | 148 | on "baz", run: "baz_command", action: "execute" 149 | 150 | on "qux", run: true 151 | end 152 | end) 153 | end 154 | 155 | it "matches no commands" do 156 | B.run([], output: stdout) 157 | stdout.rewind 158 | expect(stdout.string).to eq([ 159 | "Commands:", 160 | " bar", 161 | " baz", 162 | " foo", 163 | " qux\n" 164 | ].join("\n")) 165 | end 166 | 167 | it "shows available 'foo' subcommands when no runnable found" do 168 | B.run(%w[foo], output: stdout) 169 | stdout.rewind 170 | expect(stdout.string).to eq([ 171 | "Commands:", 172 | " foo bar ", 173 | " foo baz ", 174 | " foo quux", 175 | " foo qux \n" 176 | ].join("\n")) 177 | end 178 | 179 | it "matches top level 'bar' command" do 180 | expect { 181 | B.run(%w[bar]) 182 | }.to output("running bar\n").to_stdout 183 | end 184 | 185 | it "matches top level 'baz' command with custom action" do 186 | expect { 187 | B.run(%w[baz]) 188 | }.to output("running baz\n").to_stdout 189 | end 190 | 191 | it "matches one level deep 'bar' subcommand" do 192 | expect { 193 | B.run(%w[foo bar]) 194 | }.to output("running foo bar\n").to_stdout 195 | end 196 | 197 | it "matches one level deep 'baz' subcommand" do 198 | expect { 199 | B.run(%w[foo baz]) 200 | }.to output("running foo baz\n").to_stdout 201 | end 202 | 203 | it "matches one level deep 'qux' subcommand with custom action" do 204 | expect { 205 | B.run(%w[foo qux]) 206 | }.to output("running foo qux\n").to_stdout 207 | end 208 | 209 | it "matches one level deep 'quux' subcommand with custom action" do 210 | expect { 211 | B.run(%w[foo quux]) 212 | }.to output("running foo qux\n").to_stdout 213 | end 214 | 215 | it "fails to recognize runnable type" do 216 | expect { 217 | B.run(%w[qux]) 218 | }.to raise_error(TTY::Runner::Error, 219 | "unsupported runnable: true") 220 | end 221 | end 222 | 223 | context "uses different objects as matchers" do 224 | before do 225 | stub_const("C", Class.new(TTY::Runner) do 226 | commands do 227 | on :foo, run: -> { puts "matched foo"} 228 | on -> { "bar" }, run: -> { puts "matched bar" } 229 | on "baz", run: -> { puts "" } 230 | end 231 | end) 232 | end 233 | 234 | it "matches with :foo symbol" do 235 | expect { 236 | C.run(%w[foo]) 237 | }.to output("matched foo\n").to_stdout 238 | end 239 | 240 | it "matches with proc" do 241 | expect { 242 | C.run(%w[bar]) 243 | }.to output("matched bar\n").to_stdout 244 | end 245 | end 246 | 247 | context "mounting commands" do 248 | before do 249 | stub_const("D", Class.new(TTY::Runner) do 250 | commands do 251 | on "bar", run: -> { puts "running bar" } 252 | end 253 | end) 254 | 255 | stub_const("E", Class.new(TTY::Runner) do 256 | commands do 257 | on "baz", run: -> { puts "running baz" } 258 | end 259 | end) 260 | 261 | stub_const("F", Class.new(TTY::Runner) do 262 | commands do 263 | on "foo" do 264 | mount D 265 | end 266 | 267 | mount E 268 | end 269 | end) 270 | end 271 | 272 | it "mounts 'baz' command at the top level" do 273 | expect { 274 | F.run(%w[baz]) 275 | }.to output("running baz\n").to_stdout 276 | end 277 | 278 | it "mounts 'bar' command inside 'foo' command" do 279 | expect { 280 | F.run(%w[foo bar]) 281 | }.to output("running bar\n").to_stdout 282 | end 283 | 284 | it "doesn't mount none runner type" do 285 | stub_const("G", Class.new(TTY::Runner) do 286 | commands do 287 | mount Class 288 | end 289 | end) 290 | 291 | expect { 292 | G.run 293 | }.to raise_error(TTY::Runner::Error, 294 | "A TTY::Runner type must be given") 295 | end 296 | end 297 | 298 | context "prints nested commands" do 299 | before do 300 | stub_const("G", Class.new(TTY::Runner) do 301 | commands do 302 | on :foo do 303 | on :bar do 304 | on :baz do 305 | on :qux, run: -> { puts "running qux" } 306 | 307 | on :quux, run: -> { puts "running quux" } 308 | end 309 | end 310 | end 311 | end 312 | end) 313 | end 314 | 315 | it "shows all deeply nested commands" do 316 | G.run(%w[foo bar baz], output: stdout) 317 | stdout.rewind 318 | expect(stdout.string).to eq([ 319 | "Commands:", 320 | " foo bar baz quux", 321 | " foo bar baz qux \n" 322 | ].join("\n")) 323 | end 324 | end 325 | 326 | context "matching commands with runnable procs" do 327 | before do 328 | stub_const("H", Class.new(TTY::Runner) do 329 | commands do 330 | on "foo", aliases: %w[fo f] do 331 | run -> { puts "running foo"} 332 | 333 | on "bar", aliases: %w[r] do 334 | run -> { puts "running foo bar"} 335 | end 336 | 337 | on "baz", aliases: %w[z] do 338 | run -> { puts "running foo baz" } 339 | end 340 | end 341 | 342 | on "qux", aliases: %w[q], run: -> { puts "running qux" } 343 | end 344 | end) 345 | end 346 | 347 | it "runs aliased command 'fo' -> 'foo' at the top level" do 348 | expect { 349 | H.run(%w[fo]) 350 | }.to output("running foo\n").to_stdout 351 | end 352 | 353 | it "runs aliased command 'f' -> 'foo' for subcommand" do 354 | expect { 355 | H.run(%w[f]) 356 | }.to output("running foo\n").to_stdout 357 | end 358 | 359 | it "runs aliased command 'r' -> 'bar' for subcommand" do 360 | expect { 361 | H.run(%w[foo r]) 362 | }.to output("running foo bar\n").to_stdout 363 | end 364 | 365 | it "runs aliased command 'z' -> 'baz' for subcommand" do 366 | expect { 367 | H.run(%w[foo z]) 368 | }.to output("running foo baz\n").to_stdout 369 | end 370 | 371 | it "runs aliased command 'q' -> 'qux' at the top level with inlined runnable" do 372 | expect { 373 | H.run(%w[q]) 374 | }.to output("running qux\n").to_stdout 375 | end 376 | end 377 | 378 | context "matches runanble subcommand with runnable parent command" do 379 | before do 380 | stub_const("I", Class.new(TTY::Runner) do 381 | commands do 382 | on "foo", run: -> { puts "running foo"} do 383 | on "bar", run: -> { puts "running bar" } 384 | end 385 | end 386 | end) 387 | end 388 | 389 | it "runs the top level 'foo' command with runnable 'bar' subcommand" do 390 | expect { 391 | I.run(%w[foo]) 392 | }.to output("running foo\n").to_stdout 393 | end 394 | 395 | it "runs 'bar' subcommand with runnable 'foo' parent" do 396 | expect { 397 | I.run(%w[foo bar]) 398 | }.to output("running bar\n").to_stdout 399 | end 400 | end 401 | 402 | context "errors" do 403 | it "doesn't allow empty commands" do 404 | expect { 405 | TTY::Runner.commands 406 | }.to raise_error(described_class::Error, 407 | "no block provided") 408 | end 409 | 410 | it "doesn't allow mixing object with block in run" do 411 | stub_const("J", Class.new(TTY::Runner) do 412 | commands do 413 | run(Object) { puts "run" } 414 | end 415 | end) 416 | expect { 417 | J.run 418 | }.to raise_error(described_class::Error, 419 | "cannot provide both command object and block") 420 | end 421 | 422 | it "doesn't allow using object in match condition" do 423 | stub_const("K", Class.new(TTY::Runner) do 424 | commands do 425 | on Object, run: -> { } 426 | end 427 | end) 428 | expect { 429 | K.run 430 | }.to raise_error(described_class::Error, "unsupported matcher: Object") 431 | end 432 | 433 | it "allows runner instance creation without commands definition" do 434 | stub_const("L", Class.new(TTY::Runner)) 435 | expect { L.run }.to_not raise_error 436 | end 437 | end 438 | end 439 | --------------------------------------------------------------------------------