├── .devcontainer ├── Dockerfile ├── base.Dockerfile └── devcontainer.json ├── .document ├── .github └── workflows │ ├── lint.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.md ├── README.md ├── Thorfile ├── bin └── thor ├── lib ├── thor.rb └── thor │ ├── actions.rb │ ├── actions │ ├── create_file.rb │ ├── create_link.rb │ ├── directory.rb │ ├── empty_directory.rb │ ├── file_manipulation.rb │ └── inject_into_file.rb │ ├── base.rb │ ├── command.rb │ ├── core_ext │ └── hash_with_indifferent_access.rb │ ├── error.rb │ ├── group.rb │ ├── invocation.rb │ ├── line_editor.rb │ ├── line_editor │ ├── basic.rb │ └── readline.rb │ ├── nested_context.rb │ ├── parser.rb │ ├── parser │ ├── argument.rb │ ├── arguments.rb │ ├── option.rb │ └── options.rb │ ├── rake_compat.rb │ ├── runner.rb │ ├── shell.rb │ ├── shell │ ├── basic.rb │ ├── color.rb │ ├── column_printer.rb │ ├── html.rb │ ├── lcs_diff.rb │ ├── table_printer.rb │ ├── terminal.rb │ └── wrapped_printer.rb │ ├── util.rb │ └── version.rb ├── spec ├── actions │ ├── create_file_spec.rb │ ├── create_link_spec.rb │ ├── directory_spec.rb │ ├── empty_directory_spec.rb │ ├── file_manipulation_spec.rb │ └── inject_into_file_spec.rb ├── actions_spec.rb ├── base_spec.rb ├── command_spec.rb ├── core_ext │ └── hash_with_indifferent_access_spec.rb ├── encoding_spec.rb ├── exit_condition_spec.rb ├── fixtures │ ├── application.rb │ ├── application_helper.rb │ ├── app{1} │ │ └── README │ ├── command.thor │ ├── doc │ │ ├── %file_name%.rb.tt │ │ ├── COMMENTER │ │ ├── README │ │ ├── README.zh │ │ ├── block_helper.rb │ │ ├── components │ │ │ └── .empty_directory │ │ ├── config.rb │ │ ├── config.yaml.tt │ │ └── excluding │ │ │ └── %file_name%.rb.tt │ ├── encoding_implicit.thor │ ├── encoding_other.thor │ ├── encoding_with_utf8.thor │ ├── enum.thor │ ├── exit_status.thor │ ├── group.thor │ ├── help.thor │ ├── invoke.thor │ ├── path with spaces │ ├── preserve │ │ ├── %filename%.sh │ │ └── script.sh │ ├── script.thor │ ├── subcommand.thor │ ├── template │ │ └── bad_config.yaml.tt │ └── verbose.thor ├── group_spec.rb ├── helper.rb ├── invocation_spec.rb ├── line_editor │ ├── basic_spec.rb │ └── readline_spec.rb ├── line_editor_spec.rb ├── nested_context_spec.rb ├── no_warnings_spec.rb ├── parser │ ├── argument_spec.rb │ ├── arguments_spec.rb │ ├── option_spec.rb │ └── options_spec.rb ├── quality_spec.rb ├── rake_compat_spec.rb ├── register_spec.rb ├── runner_spec.rb ├── script_exit_status_spec.rb ├── shell │ ├── basic_spec.rb │ ├── color_spec.rb │ └── html_spec.rb ├── shell_spec.rb ├── sort_spec.rb ├── subcommand_spec.rb ├── thor_spec.rb └── util_spec.rb └── thor.gemspec /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.0, 2, 2.7, 2.6, 3-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 2.6-bullseye, 3-buster, 3.0-buster, 2-buster, 2.7-buster, 2.6-buster 2 | ARG VARIANT=2-bullseye 3 | FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} 4 | 5 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 6 | ARG NODE_VERSION="none" 7 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 8 | 9 | # [Optional] Uncomment this section to install additional OS packages. 10 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 11 | # && apt-get -y install --no-install-recommends 12 | 13 | # [Optional] Uncomment this line to install additional gems. 14 | # RUN gem install 15 | 16 | # [Optional] Uncomment this line to install global node packages. 17 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/base.Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.0, 2, 2.7, 2.6, 3-bullseye, 3.0-bullseye, 2-bullseye, 2.7-bullseye, 2.6-bullseye, 3-buster, 3.0-buster, 2-buster, 2.7-buster, 2.6-buster 2 | ARG VARIANT=2-bullseye 3 | FROM ruby:${VARIANT} 4 | 5 | # Copy library scripts to execute 6 | COPY library-scripts/*.sh library-scripts/*.env /tmp/library-scripts/ 7 | 8 | # [Option] Install zsh 9 | ARG INSTALL_ZSH="true" 10 | # [Option] Upgrade OS packages to their latest versions 11 | ARG UPGRADE_PACKAGES="true" 12 | # Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. 13 | ARG USERNAME=vscode 14 | ARG USER_UID=1000 15 | ARG USER_GID=$USER_UID 16 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 17 | # Remove imagemagick due to https://security-tracker.debian.org/tracker/CVE-2019-10131 18 | && apt-get purge -y imagemagick imagemagick-6-common \ 19 | # Install common packages, non-root user, rvm, core build tools 20 | && bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" "true" "true" \ 21 | && bash /tmp/library-scripts/ruby-debian.sh "none" "${USERNAME}" "true" "true" \ 22 | && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* 23 | 24 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 25 | ARG NODE_VERSION="none" 26 | ENV NVM_DIR=/usr/local/share/nvm 27 | ENV NVM_SYMLINK_CURRENT=true \ 28 | PATH=${NVM_DIR}/current/bin:${PATH} 29 | RUN bash /tmp/library-scripts/node-debian.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}" \ 30 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* 31 | 32 | # Remove library scripts for final image 33 | RUN rm -rf /tmp/library-scripts 34 | 35 | # [Optional] Uncomment this section to install additional OS packages. 36 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 37 | # && apt-get -y install --no-install-recommends 38 | 39 | # [Optional] Uncomment this line to install additional gems. 40 | # RUN gem install 41 | 42 | # [Optional] Uncomment this line to install global node packages. 43 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/ruby 3 | { 4 | "name": "Ruby", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "args": { 8 | // Update 'VARIANT' to pick a Ruby version: 3, 3.0, 2, 2.7, 2.6 9 | // Append -bullseye or -buster to pin to an OS version. 10 | // Use -bullseye variants on local on arm64/Apple Silicon. 11 | "VARIANT": "3-bullseye", 12 | // Options 13 | "NODE_VERSION": "lts/*" 14 | } 15 | }, 16 | 17 | // Add the IDs of extensions you want installed when the container is created. 18 | "customizations": { 19 | "vscode": { 20 | "extensions": [ 21 | "Shopify.ruby-lsp" 22 | ] 23 | } 24 | }, 25 | 26 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 27 | // "forwardPorts": [], 28 | 29 | // Use 'postCreateCommand' to run commands after the container is created. 30 | // "postCreateCommand": "ruby --version", 31 | 32 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 33 | "remoteUser": "vscode", 34 | 35 | "features": { 36 | "github-cli": "latest" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/*.rb 2 | lib/**/*.rb 3 | - 4 | CHANGELOG.rdoc 5 | LICENSE.md 6 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Run linters 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: ruby/setup-ruby@v1 9 | with: 10 | ruby-version: 3.1 11 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 12 | - run: bundle exec rubocop 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | permissions: 10 | contents: write 11 | id-token: write 12 | 13 | environment: release 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | bundler-cache: true 23 | ruby-version: 3.3.4 24 | - name: Configure trusted publishing credentials 25 | uses: rubygems/configure-rubygems-credentials@v1.0.0 26 | - name: Run release rake task 27 | run: bundle exec thor release 28 | shell: bash 29 | - name: Wait for release to propagate 30 | run: gem exec rubygems-await pkg/*.gem 31 | shell: bash 32 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | ruby: ['2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4','head'] 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: ${{ matrix.ruby }} 15 | bundler: ${{ (matrix.ruby < '3' && '2.4.21') || 'latest' }} 16 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 17 | - run: bundle exec thor spec 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | *.gem 3 | *.rbc 4 | *.sw[a-p] 5 | *.tmproj 6 | *.tmproject 7 | *.un~ 8 | *~ 9 | .Spotlight-V100 10 | .Trashes 11 | ._* 12 | .bundle 13 | .config 14 | .directory 15 | .elc 16 | .emacs.desktop 17 | .emacs.desktop.lock 18 | .idea 19 | .redcar 20 | .rvmrc 21 | .yardoc 22 | Desktop.ini 23 | Gemfile.lock 24 | lint_gems.rb.lock 25 | Icon? 26 | InstalledFiles 27 | Session.vim 28 | \#*\# 29 | _yardoc 30 | auto-save-list 31 | coverage 32 | /doc/ 33 | lib/bundler/man 34 | pkg 35 | pkg/* 36 | rdoc 37 | spec/reports 38 | spec/sandbox 39 | test/tmp 40 | test/version_tmp 41 | tmp 42 | tmtags 43 | tramp 44 | .rbx 45 | b/ 46 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | -w --color 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.6 3 | DisabledByDefault: true 4 | SuggestExtensions: false 5 | Exclude: 6 | - spec/sandbox/**/* 7 | - spec/fixtures/**/* 8 | - vendor/bundle/**/** 9 | 10 | Style/HashSyntax: 11 | EnforcedStyle: ruby19 12 | 13 | # No spaces inside hash literals 14 | Layout/SpaceInsideHashLiteralBraces: 15 | EnforcedStyle: no_space 16 | 17 | # Enforce outdenting of access modifiers (i.e. public, private, protected) 18 | Layout/AccessModifierIndentation: 19 | EnforcedStyle: outdent 20 | 21 | Layout/EmptyLinesAroundAccessModifier: 22 | Enabled: true 23 | 24 | # Align ends correctly 25 | Layout/EndAlignment: 26 | EnforcedStyleAlignWith: variable 27 | Exclude: 28 | - 'lib/thor/actions.rb' 29 | - 'lib/thor/error.rb' 30 | - 'lib/thor/shell/basic.rb' 31 | - 'lib/thor/parser/option.rb' 32 | 33 | # Indentation of when/else 34 | Layout/CaseIndentation: 35 | EnforcedStyle: end 36 | IndentOneStep: false 37 | 38 | Style/StringLiterals: 39 | EnforcedStyle: double_quotes 40 | 41 | Lint/AssignmentInCondition: 42 | Exclude: 43 | - 'lib/thor/line_editor/readline.rb' 44 | - 'lib/thor/parser/arguments.rb' 45 | 46 | Security/Eval: 47 | Exclude: 48 | - 'spec/helper.rb' 49 | 50 | Lint/SuppressedException: 51 | Exclude: 52 | - 'lib/thor/line_editor/readline.rb' 53 | 54 | Lint/PercentStringArray: 55 | Exclude: 56 | - 'spec/parser/options_spec.rb' 57 | 58 | Lint/UnusedMethodArgument: 59 | Exclude: 60 | - 'lib/thor.rb' 61 | - 'lib/thor/base.rb' 62 | - 'lib/thor/command.rb' 63 | - 'lib/thor/parser/arguments.rb' 64 | - 'lib/thor/shell/html.rb' 65 | - 'spec/actions/empty_directory_spec.rb' 66 | - 'spec/fixtures/invoke.thor' 67 | 68 | Naming/AccessorMethodName: 69 | Exclude: 70 | - 'lib/thor/line_editor/basic.rb' 71 | - 'spec/fixtures/group.thor' 72 | - 'spec/sandbox/group.thor' 73 | 74 | Style/ClassAndModuleChildren: 75 | Exclude: 76 | - 'lib/thor/group.rb' 77 | - 'lib/thor/runner.rb' 78 | - 'spec/shell_spec.rb' 79 | - 'spec/util_spec.rb' 80 | 81 | Style/ClassVars: 82 | Exclude: 83 | - 'lib/thor/util.rb' 84 | - 'spec/util_spec.rb' 85 | 86 | Naming/ConstantName: 87 | Exclude: 88 | - 'spec/line_editor_spec.rb' 89 | 90 | Style/GlobalVars: 91 | Exclude: 92 | - 'bin/thor' 93 | - 'lib/thor.rb' 94 | - 'lib/thor/base.rb' 95 | - 'lib/thor/shell/basic.rb' 96 | - 'spec/helper.rb' 97 | - 'spec/rake_compat_spec.rb' 98 | - 'spec/register_spec.rb' 99 | - 'spec/thor_spec.rb' 100 | 101 | Layout/FirstArrayElementIndentation: 102 | EnforcedStyle: consistent 103 | 104 | Lint/MissingSuper: 105 | Exclude: 106 | - 'lib/thor/error.rb' 107 | - 'spec/rake_compat_spec.rb' 108 | 109 | Style/MissingRespondToMissing: 110 | Exclude: 111 | - 'lib/thor/core_ext/hash_with_indifferent_access.rb' 112 | - 'lib/thor/runner.rb' 113 | - 'spec/fixtures/script.thor' 114 | - 'spec/sandbox/script.thor' 115 | 116 | Style/NumericLiteralPrefix: 117 | Exclude: 118 | - 'spec/actions/file_manipulation_spec.rb' 119 | 120 | Style/NumericPredicate: 121 | Exclude: 122 | - 'spec/**/*' 123 | - 'lib/thor/parser/option.rb' 124 | 125 | Style/PerlBackrefs: 126 | Exclude: 127 | - 'lib/thor/actions/empty_directory.rb' 128 | - 'lib/thor/core_ext/hash_with_indifferent_access.rb' 129 | - 'lib/thor/parser/arguments.rb' 130 | - 'lib/thor/parser/options.rb' 131 | 132 | Style/TrailingUnderscoreVariable: 133 | Exclude: 134 | - 'lib/thor/group.rb' 135 | 136 | Layout/TrailingWhitespace: 137 | Exclude: 138 | - 'spec/shell/basic_spec.rb' 139 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Pull Requests 2 | ------------- 3 | Here are some reasons why a pull request may not be merged: 4 | 5 | 1. It hasn’t been reviewed. 6 | 2. It doesn’t include specs for new functionality. 7 | 3. It doesn’t include documentation for new functionality. 8 | 4. It changes behavior without changing the relevant documentation, comments, or specs. 9 | 5. It changes behavior of an existing public API, breaking backward compatibility. 10 | 6. It breaks the tests on a supported platform. 11 | 7. It doesn’t merge cleanly (requiring Git rebasing and conflict resolution). 12 | 13 | If you would like to help in this process, you can start by evaluating open pull requests against the criteria above. For example, if a pull request does not include specs for new functionality, you can add a comment like: “If you would like this feature to be added to Thor, please add specs to ensure that it does not break in the future.” This will help move a pull request closer to being merged. 14 | 15 | Include this emoji in the top of your ticket to signal to us that you read this file: 🌈 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rake" 4 | 5 | group :development do 6 | gem "pry" 7 | gem "pry-byebug" 8 | gem "rubocop", "~> 1.30" 9 | end 10 | 11 | group :test do 12 | gem "childlabor" 13 | gem "coveralls_reborn", "~> 0.23.1", require: false 14 | gem "rspec", ">= 3.2" 15 | gem "rspec-mocks", ">= 3" 16 | gem "simplecov", ">= 0.13" 17 | gem "webmock", ">= 3.14" 18 | gem "rdoc" 19 | gem "readline" 20 | end 21 | 22 | gemspec 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 Yehuda Katz, Eric Hodel, et al. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Thor 2 | ==== 3 | 4 | [![Gem Version](http://img.shields.io/gem/v/thor.svg)][gem] 5 | 6 | [gem]: https://rubygems.org/gems/thor 7 | 8 | Description 9 | ----------- 10 | Thor is a simple and efficient tool for building self-documenting command line 11 | utilities. It removes the pain of parsing command line options, writing 12 | "USAGE:" banners, and can also be used as an alternative to the [Rake][rake] 13 | build tool. The syntax is Rake-like, so it should be familiar to most Rake 14 | users. 15 | 16 | Please note: Thor, by design, is a system tool created to allow seamless file and url 17 | access, which should not receive application user input. It relies on [open-uri][open-uri], 18 | which, combined with application user input, would provide a command injection attack 19 | vector. 20 | 21 | [rake]: https://github.com/ruby/rake 22 | [open-uri]: https://ruby-doc.org/stdlib-2.5.1/libdoc/open-uri/rdoc/index.html 23 | 24 | Installation 25 | ------------ 26 | gem install thor 27 | 28 | Usage and documentation 29 | ----------------------- 30 | Please see the [wiki][] for basic usage and other documentation on using Thor. You can also check out the [official homepage][homepage]. 31 | 32 | [wiki]: https://github.com/rails/thor/wiki 33 | [homepage]: http://whatisthor.com/ 34 | 35 | Contributing 36 | ------------ 37 | If you would like to help, please read the [CONTRIBUTING][] file for suggestions. 38 | 39 | [contributing]: CONTRIBUTING.md 40 | 41 | License 42 | ------- 43 | Released under the MIT License. See the [LICENSE][] file for further details. 44 | 45 | [license]: LICENSE.md 46 | -------------------------------------------------------------------------------- /Thorfile: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __FILE__) 2 | 3 | require "bundler" 4 | require "thor/rake_compat" 5 | 6 | class Default < Thor 7 | include Thor::RakeCompat 8 | Bundler::GemHelper.install_tasks 9 | 10 | desc "build", "Build thor-#{Thor::VERSION}.gem into the pkg directory" 11 | def build 12 | Rake::Task["build"].execute 13 | end 14 | 15 | desc "install", "Build and install thor-#{Thor::VERSION}.gem into system gems" 16 | def install 17 | Rake::Task["install"].execute 18 | end 19 | 20 | desc "release", "Create tag v#{Thor::VERSION} and build and push thor-#{Thor::VERSION}.gem to Rubygems" 21 | def release 22 | Rake::Task["release"].invoke 23 | end 24 | 25 | desc "spec", "Run RSpec code examples" 26 | def spec 27 | exec "bundle exec rspec spec" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /bin/thor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # -*- mode: ruby -*- 3 | 4 | require "thor/runner" 5 | $thor_runner = true 6 | Thor::Runner.start 7 | -------------------------------------------------------------------------------- /lib/thor/actions/create_file.rb: -------------------------------------------------------------------------------- 1 | require_relative "empty_directory" 2 | 3 | class Thor 4 | module Actions 5 | # Create a new file relative to the destination root with the given data, 6 | # which is the return value of a block or a data string. 7 | # 8 | # ==== Parameters 9 | # destination:: the relative path to the destination root. 10 | # data:: the data to append to the file. 11 | # config:: give :verbose => false to not log the status. 12 | # 13 | # ==== Examples 14 | # 15 | # create_file "lib/fun_party.rb" do 16 | # hostname = ask("What is the virtual hostname I should use?") 17 | # "vhost.name = #{hostname}" 18 | # end 19 | # 20 | # create_file "config/apache.conf", "your apache config" 21 | # 22 | def create_file(destination, *args, &block) 23 | config = args.last.is_a?(Hash) ? args.pop : {} 24 | data = args.first 25 | action CreateFile.new(self, destination, block || data.to_s, config) 26 | end 27 | alias_method :add_file, :create_file 28 | 29 | # CreateFile is a subset of Template, which instead of rendering a file with 30 | # ERB, it gets the content from the user. 31 | # 32 | class CreateFile < EmptyDirectory #:nodoc: 33 | attr_reader :data 34 | 35 | def initialize(base, destination, data, config = {}) 36 | @data = data 37 | super(base, destination, config) 38 | end 39 | 40 | # Checks if the content of the file at the destination is identical to the rendered result. 41 | # 42 | # ==== Returns 43 | # Boolean:: true if it is identical, false otherwise. 44 | # 45 | def identical? 46 | # binread uses ASCII-8BIT, so to avoid false negatives, the string must use the same 47 | exists? && File.binread(destination) == String.new(render).force_encoding("ASCII-8BIT") 48 | end 49 | 50 | # Holds the content to be added to the file. 51 | # 52 | def render 53 | @render ||= if data.is_a?(Proc) 54 | data.call 55 | else 56 | data 57 | end 58 | end 59 | 60 | def invoke! 61 | invoke_with_conflict_check do 62 | require "fileutils" 63 | FileUtils.mkdir_p(File.dirname(destination)) 64 | File.open(destination, "wb", config[:perm]) { |f| f.write render } 65 | end 66 | given_destination 67 | end 68 | 69 | protected 70 | 71 | # Now on conflict we check if the file is identical or not. 72 | # 73 | def on_conflict_behavior(&block) 74 | if identical? 75 | say_status :identical, :blue 76 | else 77 | options = base.options.merge(config) 78 | force_or_skip_or_conflict(options[:force], options[:skip], &block) 79 | end 80 | end 81 | 82 | # If force is true, run the action, otherwise check if it's not being 83 | # skipped. If both are false, show the file_collision menu, if the menu 84 | # returns true, force it, otherwise skip. 85 | # 86 | def force_or_skip_or_conflict(force, skip, &block) 87 | if force 88 | say_status :force, :yellow 89 | yield unless pretend? 90 | elsif skip 91 | say_status :skip, :yellow 92 | else 93 | say_status :conflict, :red 94 | force_or_skip_or_conflict(force_on_collision?, true, &block) 95 | end 96 | end 97 | 98 | # Shows the file collision menu to the user and gets the result. 99 | # 100 | def force_on_collision? 101 | base.shell.file_collision(destination) { render } 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/thor/actions/create_link.rb: -------------------------------------------------------------------------------- 1 | require_relative "create_file" 2 | 3 | class Thor 4 | module Actions 5 | # Create a new file relative to the destination root from the given source. 6 | # 7 | # ==== Parameters 8 | # destination:: the relative path to the destination root. 9 | # source:: the relative path to the source root. 10 | # config:: give :verbose => false to not log the status. 11 | # :: give :symbolic => false for hard link. 12 | # 13 | # ==== Examples 14 | # 15 | # create_link "config/apache.conf", "/etc/apache.conf" 16 | # 17 | def create_link(destination, *args) 18 | config = args.last.is_a?(Hash) ? args.pop : {} 19 | source = args.first 20 | action CreateLink.new(self, destination, source, config) 21 | end 22 | alias_method :add_link, :create_link 23 | 24 | # CreateLink is a subset of CreateFile, which instead of taking a block of 25 | # data, just takes a source string from the user. 26 | # 27 | class CreateLink < CreateFile #:nodoc: 28 | attr_reader :data 29 | 30 | # Checks if the content of the file at the destination is identical to the rendered result. 31 | # 32 | # ==== Returns 33 | # Boolean:: true if it is identical, false otherwise. 34 | # 35 | def identical? 36 | source = File.expand_path(render, File.dirname(destination)) 37 | exists? && File.identical?(source, destination) 38 | end 39 | 40 | def invoke! 41 | invoke_with_conflict_check do 42 | require "fileutils" 43 | FileUtils.mkdir_p(File.dirname(destination)) 44 | # Create a symlink by default 45 | config[:symbolic] = true if config[:symbolic].nil? 46 | File.unlink(destination) if exists? 47 | if config[:symbolic] 48 | File.symlink(render, destination) 49 | else 50 | File.link(render, destination) 51 | end 52 | end 53 | given_destination 54 | end 55 | 56 | def exists? 57 | super || File.symlink?(destination) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/thor/actions/directory.rb: -------------------------------------------------------------------------------- 1 | require_relative "empty_directory" 2 | 3 | class Thor 4 | module Actions 5 | # Copies recursively the files from source directory to root directory. 6 | # If any of the files finishes with .tt, it's considered to be a template 7 | # and is placed in the destination without the extension .tt. If any 8 | # empty directory is found, it's copied and all .empty_directory files are 9 | # ignored. If any file name is wrapped within % signs, the text within 10 | # the % signs will be executed as a method and replaced with the returned 11 | # value. Let's suppose a doc directory with the following files: 12 | # 13 | # doc/ 14 | # components/.empty_directory 15 | # README 16 | # rdoc.rb.tt 17 | # %app_name%.rb 18 | # 19 | # When invoked as: 20 | # 21 | # directory "doc" 22 | # 23 | # It will create a doc directory in the destination with the following 24 | # files (assuming that the `app_name` method returns the value "blog"): 25 | # 26 | # doc/ 27 | # components/ 28 | # README 29 | # rdoc.rb 30 | # blog.rb 31 | # 32 | # Encoded path note: Since Thor internals use Object#respond_to? to check if it can 33 | # expand %something%, this `something` should be a public method in the class calling 34 | # #directory. If a method is private, Thor stack raises PrivateMethodEncodedError. 35 | # 36 | # ==== Parameters 37 | # source:: the relative path to the source root. 38 | # destination:: the relative path to the destination root. 39 | # config:: give :verbose => false to not log the status. 40 | # If :recursive => false, does not look for paths recursively. 41 | # If :mode => :preserve, preserve the file mode from the source. 42 | # If :exclude_pattern => /regexp/, prevents copying files that match that regexp. 43 | # 44 | # ==== Examples 45 | # 46 | # directory "doc" 47 | # directory "doc", "docs", :recursive => false 48 | # 49 | def directory(source, *args, &block) 50 | config = args.last.is_a?(Hash) ? args.pop : {} 51 | destination = args.first || source 52 | action Directory.new(self, source, destination || source, config, &block) 53 | end 54 | 55 | class Directory < EmptyDirectory #:nodoc: 56 | attr_reader :source 57 | 58 | def initialize(base, source, destination = nil, config = {}, &block) 59 | @source = File.expand_path(Dir[Util.escape_globs(base.find_in_source_paths(source.to_s))].first) 60 | @block = block 61 | super(base, destination, {recursive: true}.merge(config)) 62 | end 63 | 64 | def invoke! 65 | base.empty_directory given_destination, config 66 | execute! 67 | end 68 | 69 | def revoke! 70 | execute! 71 | end 72 | 73 | protected 74 | 75 | def execute! 76 | lookup = Util.escape_globs(source) 77 | lookup = config[:recursive] ? File.join(lookup, "**") : lookup 78 | lookup = file_level_lookup(lookup) 79 | 80 | files(lookup).sort.each do |file_source| 81 | next if File.directory?(file_source) 82 | next if config[:exclude_pattern] && file_source.match(config[:exclude_pattern]) 83 | file_destination = File.join(given_destination, file_source.gsub(source, ".")) 84 | file_destination.gsub!("/./", "/") 85 | 86 | case file_source 87 | when /\.empty_directory$/ 88 | dirname = File.dirname(file_destination).gsub(%r{/\.$}, "") 89 | next if dirname == given_destination 90 | base.empty_directory(dirname, config) 91 | when /#{TEMPLATE_EXTNAME}$/ 92 | base.template(file_source, file_destination[0..-4], config, &@block) 93 | else 94 | base.copy_file(file_source, file_destination, config, &@block) 95 | end 96 | end 97 | end 98 | 99 | def file_level_lookup(previous_lookup) 100 | File.join(previous_lookup, "*") 101 | end 102 | 103 | def files(lookup) 104 | Dir.glob(lookup, File::FNM_DOTMATCH) 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/thor/actions/empty_directory.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | module Actions 3 | # Creates an empty directory. 4 | # 5 | # ==== Parameters 6 | # destination:: the relative path to the destination root. 7 | # config:: give :verbose => false to not log the status. 8 | # 9 | # ==== Examples 10 | # 11 | # empty_directory "doc" 12 | # 13 | def empty_directory(destination, config = {}) 14 | action EmptyDirectory.new(self, destination, config) 15 | end 16 | 17 | # Class which holds create directory logic. This is the base class for 18 | # other actions like create_file and directory. 19 | # 20 | # This implementation is based in Templater actions, created by Jonas Nicklas 21 | # and Michael S. Klishin under MIT LICENSE. 22 | # 23 | class EmptyDirectory #:nodoc: 24 | attr_reader :base, :destination, :given_destination, :relative_destination, :config 25 | 26 | # Initializes given the source and destination. 27 | # 28 | # ==== Parameters 29 | # base:: A Thor::Base instance 30 | # source:: Relative path to the source of this file 31 | # destination:: Relative path to the destination of this file 32 | # config:: give :verbose => false to not log the status. 33 | # 34 | def initialize(base, destination, config = {}) 35 | @base = base 36 | @config = {verbose: true}.merge(config) 37 | self.destination = destination 38 | end 39 | 40 | # Checks if the destination file already exists. 41 | # 42 | # ==== Returns 43 | # Boolean:: true if the file exists, false otherwise. 44 | # 45 | def exists? 46 | ::File.exist?(destination) 47 | end 48 | 49 | def invoke! 50 | invoke_with_conflict_check do 51 | require "fileutils" 52 | ::FileUtils.mkdir_p(destination) 53 | end 54 | end 55 | 56 | def revoke! 57 | say_status :remove, :red 58 | require "fileutils" 59 | ::FileUtils.rm_rf(destination) if !pretend? && exists? 60 | given_destination 61 | end 62 | 63 | protected 64 | 65 | # Shortcut for pretend. 66 | # 67 | def pretend? 68 | base.options[:pretend] 69 | end 70 | 71 | # Sets the absolute destination value from a relative destination value. 72 | # It also stores the given and relative destination. Let's suppose our 73 | # script is being executed on "dest", it sets the destination root to 74 | # "dest". The destination, given_destination and relative_destination 75 | # are related in the following way: 76 | # 77 | # inside "bar" do 78 | # empty_directory "baz" 79 | # end 80 | # 81 | # destination #=> dest/bar/baz 82 | # relative_destination #=> bar/baz 83 | # given_destination #=> baz 84 | # 85 | def destination=(destination) 86 | return unless destination 87 | @given_destination = convert_encoded_instructions(destination.to_s) 88 | @destination = ::File.expand_path(@given_destination, base.destination_root) 89 | @relative_destination = base.relative_to_original_destination_root(@destination) 90 | end 91 | 92 | # Filenames in the encoded form are converted. If you have a file: 93 | # 94 | # %file_name%.rb 95 | # 96 | # It calls #file_name from the base and replaces %-string with the 97 | # return value (should be String) of #file_name: 98 | # 99 | # user.rb 100 | # 101 | # The method referenced can be either public or private. 102 | # 103 | def convert_encoded_instructions(filename) 104 | filename.gsub(/%(.*?)%/) do |initial_string| 105 | method = $1.strip 106 | base.respond_to?(method, true) ? base.send(method) : initial_string 107 | end 108 | end 109 | 110 | # Receives a hash of options and just execute the block if some 111 | # conditions are met. 112 | # 113 | def invoke_with_conflict_check(&block) 114 | if exists? 115 | on_conflict_behavior(&block) 116 | else 117 | yield unless pretend? 118 | say_status :create, :green 119 | end 120 | 121 | destination 122 | rescue Errno::EISDIR, Errno::EEXIST 123 | on_file_clash_behavior 124 | end 125 | 126 | def on_file_clash_behavior 127 | say_status :file_clash, :red 128 | end 129 | 130 | # What to do when the destination file already exists. 131 | # 132 | def on_conflict_behavior 133 | say_status :exist, :blue 134 | end 135 | 136 | # Shortcut to say_status shell method. 137 | # 138 | def say_status(status, color) 139 | base.shell.say_status status, relative_destination, color if config[:verbose] 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/thor/actions/inject_into_file.rb: -------------------------------------------------------------------------------- 1 | require_relative "empty_directory" 2 | 3 | class Thor 4 | module Actions 5 | # Injects the given content into a file. Different from gsub_file, this 6 | # method is reversible. 7 | # 8 | # ==== Parameters 9 | # destination:: Relative path to the destination root 10 | # data:: Data to add to the file. Can be given as a block. 11 | # config:: give :verbose => false to not log the status and the flag 12 | # for injection (:after or :before) or :force => true for 13 | # insert two or more times the same content. 14 | # 15 | # ==== Examples 16 | # 17 | # insert_into_file "config/environment.rb", "config.gem :thor", :after => "Rails::Initializer.run do |config|\n" 18 | # 19 | # insert_into_file "config/environment.rb", :after => "Rails::Initializer.run do |config|\n" do 20 | # gems = ask "Which gems would you like to add?" 21 | # gems.split(" ").map{ |gem| " config.gem :#{gem}" }.join("\n") 22 | # end 23 | # 24 | WARNINGS = {unchanged_no_flag: "File unchanged! Either the supplied flag value not found or the content has already been inserted!"} 25 | 26 | def insert_into_file(destination, *args, &block) 27 | data = block_given? ? block : args.shift 28 | 29 | config = args.shift || {} 30 | config[:after] = /\z/ unless config.key?(:before) || config.key?(:after) 31 | 32 | action InjectIntoFile.new(self, destination, data, config) 33 | end 34 | alias_method :inject_into_file, :insert_into_file 35 | 36 | class InjectIntoFile < EmptyDirectory #:nodoc: 37 | attr_reader :replacement, :flag, :behavior 38 | 39 | def initialize(base, destination, data, config) 40 | super(base, destination, {verbose: true}.merge(config)) 41 | 42 | @behavior, @flag = if @config.key?(:after) 43 | [:after, @config.delete(:after)] 44 | else 45 | [:before, @config.delete(:before)] 46 | end 47 | 48 | @replacement = data.is_a?(Proc) ? data.call : data 49 | @flag = Regexp.escape(@flag) unless @flag.is_a?(Regexp) 50 | end 51 | 52 | def invoke! 53 | content = if @behavior == :after 54 | '\0' + replacement 55 | else 56 | replacement + '\0' 57 | end 58 | 59 | if exists? 60 | if replace!(/#{flag}/, content, config[:force]) 61 | say_status(:invoke) 62 | elsif replacement_present? 63 | say_status(:unchanged, color: :blue) 64 | else 65 | say_status(:unchanged, warning: WARNINGS[:unchanged_no_flag], color: :red) 66 | end 67 | else 68 | unless pretend? 69 | raise Thor::Error, "The file #{ destination } does not appear to exist" 70 | end 71 | end 72 | end 73 | 74 | def revoke! 75 | say_status :revoke 76 | 77 | regexp = if @behavior == :after 78 | content = '\1\2' 79 | /(#{flag})(.*)(#{Regexp.escape(replacement)})/m 80 | else 81 | content = '\2\3' 82 | /(#{Regexp.escape(replacement)})(.*)(#{flag})/m 83 | end 84 | 85 | replace!(regexp, content, true) 86 | end 87 | 88 | protected 89 | 90 | def say_status(behavior, warning: nil, color: nil) 91 | status = if behavior == :invoke 92 | if flag == /\A/ 93 | :prepend 94 | elsif flag == /\z/ 95 | :append 96 | else 97 | :insert 98 | end 99 | elsif warning 100 | warning 101 | elsif behavior == :unchanged 102 | :unchanged 103 | else 104 | :subtract 105 | end 106 | 107 | super(status, (color || config[:verbose])) 108 | end 109 | 110 | def content 111 | @content ||= File.read(destination) 112 | end 113 | 114 | def replacement_present? 115 | content.include?(replacement) 116 | end 117 | 118 | # Adds the content to the file. 119 | # 120 | def replace!(regexp, string, force) 121 | if force || !replacement_present? 122 | success = content.gsub!(regexp, string) 123 | 124 | File.open(destination, "wb") { |file| file.write(content) } unless pretend? 125 | success 126 | end 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/thor/command.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | class Command < Struct.new(:name, :description, :long_description, :wrap_long_description, :usage, :options, :options_relation, :ancestor_name) 3 | FILE_REGEXP = /^#{Regexp.escape(File.dirname(__FILE__))}/ 4 | 5 | def initialize(name, description, long_description, wrap_long_description, usage, options = nil, options_relation = nil) 6 | super(name.to_s, description, long_description, wrap_long_description, usage, options || {}, options_relation || {}) 7 | end 8 | 9 | def initialize_copy(other) #:nodoc: 10 | super(other) 11 | self.options = other.options.dup if other.options 12 | self.options_relation = other.options_relation.dup if other.options_relation 13 | end 14 | 15 | def hidden? 16 | false 17 | end 18 | 19 | # By default, a command invokes a method in the thor class. You can change this 20 | # implementation to create custom commands. 21 | def run(instance, args = []) 22 | arity = nil 23 | 24 | if private_method?(instance) 25 | instance.class.handle_no_command_error(name) 26 | elsif public_method?(instance) 27 | arity = instance.method(name).arity 28 | instance.__send__(name, *args) 29 | elsif local_method?(instance, :method_missing) 30 | instance.__send__(:method_missing, name.to_sym, *args) 31 | else 32 | instance.class.handle_no_command_error(name) 33 | end 34 | rescue ArgumentError => e 35 | handle_argument_error?(instance, e, caller) ? instance.class.handle_argument_error(self, e, args, arity) : (raise e) 36 | rescue NoMethodError => e 37 | handle_no_method_error?(instance, e, caller) ? instance.class.handle_no_command_error(name) : (raise e) 38 | end 39 | 40 | # Returns the formatted usage by injecting given required arguments 41 | # and required options into the given usage. 42 | def formatted_usage(klass, namespace = true, subcommand = false) 43 | if ancestor_name 44 | formatted = "#{ancestor_name} ".dup # add space 45 | elsif namespace 46 | namespace = klass.namespace 47 | formatted = "#{namespace.gsub(/^(default)/, '')}:".dup 48 | end 49 | formatted ||= "#{klass.namespace.split(':').last} ".dup if subcommand 50 | 51 | formatted ||= "".dup 52 | 53 | Array(usage).map do |specific_usage| 54 | formatted_specific_usage = formatted 55 | 56 | formatted_specific_usage += required_arguments_for(klass, specific_usage) 57 | 58 | # Add required options 59 | formatted_specific_usage += " #{required_options}" 60 | 61 | # Strip and go! 62 | formatted_specific_usage.strip 63 | end.join("\n") 64 | end 65 | 66 | def method_exclusive_option_names #:nodoc: 67 | self.options_relation[:exclusive_option_names] || [] 68 | end 69 | 70 | def method_at_least_one_option_names #:nodoc: 71 | self.options_relation[:at_least_one_option_names] || [] 72 | end 73 | 74 | protected 75 | 76 | # Add usage with required arguments 77 | def required_arguments_for(klass, usage) 78 | if klass && !klass.arguments.empty? 79 | usage.to_s.gsub(/^#{name}/) do |match| 80 | match << " " << klass.arguments.map(&:usage).compact.join(" ") 81 | end 82 | else 83 | usage.to_s 84 | end 85 | end 86 | 87 | def not_debugging?(instance) 88 | !(instance.class.respond_to?(:debugging) && instance.class.debugging) 89 | end 90 | 91 | def required_options 92 | @required_options ||= options.map { |_, o| o.usage if o.required? }.compact.sort.join(" ") 93 | end 94 | 95 | # Given a target, checks if this class name is a public method. 96 | def public_method?(instance) #:nodoc: 97 | !(instance.public_methods & [name.to_s, name.to_sym]).empty? 98 | end 99 | 100 | def private_method?(instance) 101 | !(instance.private_methods & [name.to_s, name.to_sym]).empty? 102 | end 103 | 104 | def local_method?(instance, name) 105 | methods = instance.public_methods(false) + instance.private_methods(false) + instance.protected_methods(false) 106 | !(methods & [name.to_s, name.to_sym]).empty? 107 | end 108 | 109 | def sans_backtrace(backtrace, caller) #:nodoc: 110 | saned = backtrace.reject { |frame| frame =~ FILE_REGEXP || (frame =~ /\.java:/ && RUBY_PLATFORM =~ /java/) || (frame =~ %r{^kernel/} && RUBY_ENGINE =~ /rbx/) } 111 | saned - caller 112 | end 113 | 114 | def handle_argument_error?(instance, error, caller) 115 | not_debugging?(instance) && (error.message =~ /wrong number of arguments/ || error.message =~ /given \d*, expected \d*/) && begin 116 | saned = sans_backtrace(error.backtrace, caller) 117 | saned.empty? || saned.size == 1 118 | end 119 | end 120 | 121 | def handle_no_method_error?(instance, error, caller) 122 | not_debugging?(instance) && 123 | error.message =~ /^undefined method `#{name}' for #{Regexp.escape(instance.to_s)}$/ 124 | end 125 | end 126 | Task = Command 127 | 128 | # A command that is hidden in help messages but still invocable. 129 | class HiddenCommand < Command 130 | def hidden? 131 | true 132 | end 133 | end 134 | HiddenTask = HiddenCommand 135 | 136 | # A dynamic command that handles method missing scenarios. 137 | class DynamicCommand < Command 138 | def initialize(name, options = nil) 139 | super(name.to_s, "A dynamically-generated command", name.to_s, nil, name.to_s, options) 140 | end 141 | 142 | def run(instance, args = []) 143 | if (instance.methods & [name.to_s, name.to_sym]).empty? 144 | super 145 | else 146 | instance.class.handle_no_command_error(name) 147 | end 148 | end 149 | end 150 | DynamicTask = DynamicCommand 151 | end 152 | -------------------------------------------------------------------------------- /lib/thor/core_ext/hash_with_indifferent_access.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | module CoreExt #:nodoc: 3 | # A hash with indifferent access and magic predicates. 4 | # 5 | # hash = Thor::CoreExt::HashWithIndifferentAccess.new 'foo' => 'bar', 'baz' => 'bee', 'force' => true 6 | # 7 | # hash[:foo] #=> 'bar' 8 | # hash['foo'] #=> 'bar' 9 | # hash.foo? #=> true 10 | # 11 | class HashWithIndifferentAccess < ::Hash #:nodoc: 12 | def initialize(hash = {}) 13 | super() 14 | hash.each do |key, value| 15 | self[convert_key(key)] = value 16 | end 17 | end 18 | 19 | def [](key) 20 | super(convert_key(key)) 21 | end 22 | 23 | def []=(key, value) 24 | super(convert_key(key), value) 25 | end 26 | 27 | def delete(key) 28 | super(convert_key(key)) 29 | end 30 | 31 | def except(*keys) 32 | dup.tap do |hash| 33 | keys.each { |key| hash.delete(convert_key(key)) } 34 | end 35 | end 36 | 37 | def fetch(key, *args) 38 | super(convert_key(key), *args) 39 | end 40 | 41 | def slice(*keys) 42 | super(*keys.map{ |key| convert_key(key) }) 43 | end 44 | 45 | def key?(key) 46 | super(convert_key(key)) 47 | end 48 | 49 | def values_at(*indices) 50 | indices.map { |key| self[convert_key(key)] } 51 | end 52 | 53 | def merge(other) 54 | dup.merge!(other) 55 | end 56 | 57 | def merge!(other) 58 | other.each do |key, value| 59 | self[convert_key(key)] = value 60 | end 61 | self 62 | end 63 | 64 | def reverse_merge(other) 65 | self.class.new(other).merge(self) 66 | end 67 | 68 | def reverse_merge!(other_hash) 69 | replace(reverse_merge(other_hash)) 70 | end 71 | 72 | def replace(other_hash) 73 | super(other_hash) 74 | end 75 | 76 | # Convert to a Hash with String keys. 77 | def to_hash 78 | Hash.new(default).merge!(self) 79 | end 80 | 81 | protected 82 | 83 | def convert_key(key) 84 | key.is_a?(Symbol) ? key.to_s : key 85 | end 86 | 87 | # Magic predicates. For instance: 88 | # 89 | # options.force? # => !!options['force'] 90 | # options.shebang # => "/usr/lib/local/ruby" 91 | # options.test_framework?(:rspec) # => options[:test_framework] == :rspec 92 | # 93 | def method_missing(method, *args) 94 | method = method.to_s 95 | if method =~ /^(\w+)\?$/ 96 | if args.empty? 97 | !!self[$1] 98 | else 99 | self[$1] == args.first 100 | end 101 | else 102 | self[method] 103 | end 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/thor/error.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | Correctable = if defined?(DidYouMean::SpellChecker) && defined?(DidYouMean::Correctable) # rubocop:disable Naming/ConstantName 3 | Module.new do 4 | def to_s 5 | super + DidYouMean.formatter.message_for(corrections) 6 | end 7 | 8 | def corrections 9 | @corrections ||= self.class.const_get(:SpellChecker).new(self).corrections 10 | end 11 | end 12 | end 13 | 14 | # Thor::Error is raised when it's caused by wrong usage of thor classes. Those 15 | # errors have their backtrace suppressed and are nicely shown to the user. 16 | # 17 | # Errors that are caused by the developer, like declaring a method which 18 | # overwrites a thor keyword, SHOULD NOT raise a Thor::Error. This way, we 19 | # ensure that developer errors are shown with full backtrace. 20 | class Error < StandardError 21 | end 22 | 23 | # Raised when a command was not found. 24 | class UndefinedCommandError < Error 25 | class SpellChecker 26 | attr_reader :error 27 | 28 | def initialize(error) 29 | @error = error 30 | end 31 | 32 | def corrections 33 | @corrections ||= spell_checker.correct(error.command).map(&:inspect) 34 | end 35 | 36 | def spell_checker 37 | DidYouMean::SpellChecker.new(dictionary: error.all_commands) 38 | end 39 | end 40 | 41 | attr_reader :command, :all_commands 42 | 43 | def initialize(command, all_commands, namespace) 44 | @command = command 45 | @all_commands = all_commands 46 | 47 | message = "Could not find command #{command.inspect}" 48 | message = namespace ? "#{message} in #{namespace.inspect} namespace." : "#{message}." 49 | 50 | super(message) 51 | end 52 | 53 | prepend Correctable if Correctable 54 | end 55 | UndefinedTaskError = UndefinedCommandError 56 | 57 | class AmbiguousCommandError < Error 58 | end 59 | AmbiguousTaskError = AmbiguousCommandError 60 | 61 | # Raised when a command was found, but not invoked properly. 62 | class InvocationError < Error 63 | end 64 | 65 | class UnknownArgumentError < Error 66 | class SpellChecker 67 | attr_reader :error 68 | 69 | def initialize(error) 70 | @error = error 71 | end 72 | 73 | def corrections 74 | @corrections ||= 75 | error.unknown.flat_map { |unknown| spell_checker.correct(unknown) }.uniq.map(&:inspect) 76 | end 77 | 78 | def spell_checker 79 | @spell_checker ||= DidYouMean::SpellChecker.new(dictionary: error.switches) 80 | end 81 | end 82 | 83 | attr_reader :switches, :unknown 84 | 85 | def initialize(switches, unknown) 86 | @switches = switches 87 | @unknown = unknown 88 | 89 | super("Unknown switches #{unknown.map(&:inspect).join(', ')}") 90 | end 91 | 92 | prepend Correctable if Correctable 93 | end 94 | 95 | class RequiredArgumentMissingError < InvocationError 96 | end 97 | 98 | class MalformattedArgumentError < InvocationError 99 | end 100 | 101 | class ExclusiveArgumentError < InvocationError 102 | end 103 | 104 | class AtLeastOneRequiredArgumentError < InvocationError 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/thor/invocation.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | module Invocation 3 | def self.included(base) #:nodoc: 4 | super(base) 5 | base.extend ClassMethods 6 | end 7 | 8 | module ClassMethods 9 | # This method is responsible for receiving a name and find the proper 10 | # class and command for it. The key is an optional parameter which is 11 | # available only in class methods invocations (i.e. in Thor::Group). 12 | def prepare_for_invocation(key, name) #:nodoc: 13 | case name 14 | when Symbol, String 15 | Thor::Util.find_class_and_command_by_namespace(name.to_s, !key) 16 | else 17 | name 18 | end 19 | end 20 | end 21 | 22 | # Make initializer aware of invocations and the initialization args. 23 | def initialize(args = [], options = {}, config = {}, &block) #:nodoc: 24 | @_invocations = config[:invocations] || Hash.new { |h, k| h[k] = [] } 25 | @_initializer = [args, options, config] 26 | super 27 | end 28 | 29 | # Make the current command chain accessible with in a Thor-(sub)command 30 | def current_command_chain 31 | @_invocations.values.flatten.map(&:to_sym) 32 | end 33 | 34 | # Receives a name and invokes it. The name can be a string (either "command" or 35 | # "namespace:command"), a Thor::Command, a Class or a Thor instance. If the 36 | # command cannot be guessed by name, it can also be supplied as second argument. 37 | # 38 | # You can also supply the arguments, options and configuration values for 39 | # the command to be invoked, if none is given, the same values used to 40 | # initialize the invoker are used to initialize the invoked. 41 | # 42 | # When no name is given, it will invoke the default command of the current class. 43 | # 44 | # ==== Examples 45 | # 46 | # class A < Thor 47 | # def foo 48 | # invoke :bar 49 | # invoke "b:hello", ["Erik"] 50 | # end 51 | # 52 | # def bar 53 | # invoke "b:hello", ["Erik"] 54 | # end 55 | # end 56 | # 57 | # class B < Thor 58 | # def hello(name) 59 | # puts "hello #{name}" 60 | # end 61 | # end 62 | # 63 | # You can notice that the method "foo" above invokes two commands: "bar", 64 | # which belongs to the same class and "hello" which belongs to the class B. 65 | # 66 | # By using an invocation system you ensure that a command is invoked only once. 67 | # In the example above, invoking "foo" will invoke "b:hello" just once, even 68 | # if it's invoked later by "bar" method. 69 | # 70 | # When class A invokes class B, all arguments used on A initialization are 71 | # supplied to B. This allows lazy parse of options. Let's suppose you have 72 | # some rspec commands: 73 | # 74 | # class Rspec < Thor::Group 75 | # class_option :mock_framework, :type => :string, :default => :rr 76 | # 77 | # def invoke_mock_framework 78 | # invoke "rspec:#{options[:mock_framework]}" 79 | # end 80 | # end 81 | # 82 | # As you noticed, it invokes the given mock framework, which might have its 83 | # own options: 84 | # 85 | # class Rspec::RR < Thor::Group 86 | # class_option :style, :type => :string, :default => :mock 87 | # end 88 | # 89 | # Since it's not rspec concern to parse mock framework options, when RR 90 | # is invoked all options are parsed again, so RR can extract only the options 91 | # that it's going to use. 92 | # 93 | # If you want Rspec::RR to be initialized with its own set of options, you 94 | # have to do that explicitly: 95 | # 96 | # invoke "rspec:rr", [], :style => :foo 97 | # 98 | # Besides giving an instance, you can also give a class to invoke: 99 | # 100 | # invoke Rspec::RR, [], :style => :foo 101 | # 102 | def invoke(name = nil, *args) 103 | if name.nil? 104 | warn "[Thor] Calling invoke() without argument is deprecated. Please use invoke_all instead.\n#{caller.join("\n")}" 105 | return invoke_all 106 | end 107 | 108 | args.unshift(nil) if args.first.is_a?(Array) || args.first.nil? 109 | command, args, opts, config = args 110 | 111 | klass, command = _retrieve_class_and_command(name, command) 112 | raise "Missing Thor class for invoke #{name}" unless klass 113 | raise "Expected Thor class, got #{klass}" unless klass <= Thor::Base 114 | 115 | args, opts, config = _parse_initialization_options(args, opts, config) 116 | klass.send(:dispatch, command, args, opts, config) do |instance| 117 | instance.parent_options = options 118 | end 119 | end 120 | 121 | # Invoke the given command if the given args. 122 | def invoke_command(command, *args) #:nodoc: 123 | current = @_invocations[self.class] 124 | 125 | unless current.include?(command.name) 126 | current << command.name 127 | command.run(self, *args) 128 | end 129 | end 130 | alias_method :invoke_task, :invoke_command 131 | 132 | # Invoke all commands for the current instance. 133 | def invoke_all #:nodoc: 134 | self.class.all_commands.map { |_, command| invoke_command(command) } 135 | end 136 | 137 | # Invokes using shell padding. 138 | def invoke_with_padding(*args) 139 | with_padding { invoke(*args) } 140 | end 141 | 142 | protected 143 | 144 | # Configuration values that are shared between invocations. 145 | def _shared_configuration #:nodoc: 146 | {invocations: @_invocations} 147 | end 148 | 149 | # This method simply retrieves the class and command to be invoked. 150 | # If the name is nil or the given name is a command in the current class, 151 | # use the given name and return self as class. Otherwise, call 152 | # prepare_for_invocation in the current class. 153 | def _retrieve_class_and_command(name, sent_command = nil) #:nodoc: 154 | if name.nil? 155 | [self.class, nil] 156 | elsif self.class.all_commands[name.to_s] 157 | [self.class, name.to_s] 158 | else 159 | klass, command = self.class.prepare_for_invocation(nil, name) 160 | [klass, command || sent_command] 161 | end 162 | end 163 | alias_method :_retrieve_class_and_task, :_retrieve_class_and_command 164 | 165 | # Initialize klass using values stored in the @_initializer. 166 | def _parse_initialization_options(args, opts, config) #:nodoc: 167 | stored_args, stored_opts, stored_config = @_initializer 168 | 169 | args ||= stored_args.dup 170 | opts ||= stored_opts.dup 171 | 172 | config ||= {} 173 | config = stored_config.merge(_shared_configuration).merge!(config) 174 | 175 | [args, opts, config] 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/thor/line_editor.rb: -------------------------------------------------------------------------------- 1 | require_relative "line_editor/basic" 2 | require_relative "line_editor/readline" 3 | 4 | class Thor 5 | module LineEditor 6 | def self.readline(prompt, options = {}) 7 | best_available.new(prompt, options).readline 8 | end 9 | 10 | def self.best_available 11 | [ 12 | Thor::LineEditor::Readline, 13 | Thor::LineEditor::Basic 14 | ].detect(&:available?) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/thor/line_editor/basic.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | module LineEditor 3 | class Basic 4 | attr_reader :prompt, :options 5 | 6 | def self.available? 7 | true 8 | end 9 | 10 | def initialize(prompt, options) 11 | @prompt = prompt 12 | @options = options 13 | end 14 | 15 | def readline 16 | $stdout.print(prompt) 17 | get_input 18 | end 19 | 20 | private 21 | 22 | def get_input 23 | if echo? 24 | $stdin.gets 25 | else 26 | # Lazy-load io/console since it is gem-ified as of 2.3 27 | require "io/console" 28 | $stdin.noecho(&:gets) 29 | end 30 | end 31 | 32 | def echo? 33 | options.fetch(:echo, true) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/thor/line_editor/readline.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | module LineEditor 3 | class Readline < Basic 4 | def self.available? 5 | begin 6 | require "readline" 7 | rescue LoadError 8 | end 9 | 10 | Object.const_defined?(:Readline) 11 | end 12 | 13 | def readline 14 | if echo? 15 | ::Readline.completion_append_character = nil 16 | # rb-readline does not allow Readline.completion_proc= to receive nil. 17 | if complete = completion_proc 18 | ::Readline.completion_proc = complete 19 | end 20 | ::Readline.readline(prompt, add_to_history?) 21 | else 22 | super 23 | end 24 | end 25 | 26 | private 27 | 28 | def add_to_history? 29 | options.fetch(:add_to_history, true) 30 | end 31 | 32 | def completion_proc 33 | if use_path_completion? 34 | proc { |text| PathCompletion.new(text).matches } 35 | elsif completion_options.any? 36 | proc do |text| 37 | completion_options.select { |option| option.start_with?(text) } 38 | end 39 | end 40 | end 41 | 42 | def completion_options 43 | options.fetch(:limited_to, []) 44 | end 45 | 46 | def use_path_completion? 47 | options.fetch(:path, false) 48 | end 49 | 50 | class PathCompletion 51 | attr_reader :text 52 | private :text 53 | 54 | def initialize(text) 55 | @text = text 56 | end 57 | 58 | def matches 59 | relative_matches 60 | end 61 | 62 | private 63 | 64 | def relative_matches 65 | absolute_matches.map { |path| path.sub(base_path, "") } 66 | end 67 | 68 | def absolute_matches 69 | Dir[glob_pattern].map do |path| 70 | if File.directory?(path) 71 | "#{path}/" 72 | else 73 | path 74 | end 75 | end 76 | end 77 | 78 | def glob_pattern 79 | "#{base_path}#{text}*" 80 | end 81 | 82 | def base_path 83 | "#{Dir.pwd}/" 84 | end 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/thor/nested_context.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | class NestedContext 3 | def initialize 4 | @depth = 0 5 | end 6 | 7 | def enter 8 | push 9 | 10 | yield 11 | ensure 12 | pop 13 | end 14 | 15 | def entered? 16 | @depth.positive? 17 | end 18 | 19 | private 20 | 21 | def push 22 | @depth += 1 23 | end 24 | 25 | def pop 26 | @depth -= 1 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/thor/parser.rb: -------------------------------------------------------------------------------- 1 | require_relative "parser/argument" 2 | require_relative "parser/arguments" 3 | require_relative "parser/option" 4 | require_relative "parser/options" 5 | -------------------------------------------------------------------------------- /lib/thor/parser/argument.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | class Argument #:nodoc: 3 | VALID_TYPES = [:numeric, :hash, :array, :string] 4 | 5 | attr_reader :name, :description, :enum, :required, :type, :default, :banner 6 | alias_method :human_name, :name 7 | 8 | def initialize(name, options = {}) 9 | class_name = self.class.name.split("::").last 10 | 11 | type = options[:type] 12 | 13 | raise ArgumentError, "#{class_name} name can't be nil." if name.nil? 14 | raise ArgumentError, "Type :#{type} is not valid for #{class_name.downcase}s." if type && !valid_type?(type) 15 | 16 | @name = name.to_s 17 | @description = options[:desc] 18 | @required = options.key?(:required) ? options[:required] : true 19 | @type = (type || :string).to_sym 20 | @default = options[:default] 21 | @banner = options[:banner] || default_banner 22 | @enum = options[:enum] 23 | 24 | validate! # Trigger specific validations 25 | end 26 | 27 | def print_default 28 | if @type == :array and @default.is_a?(Array) 29 | @default.map(&:dump).join(" ") 30 | else 31 | @default 32 | end 33 | end 34 | 35 | def usage 36 | required? ? banner : "[#{banner}]" 37 | end 38 | 39 | def required? 40 | required 41 | end 42 | 43 | def show_default? 44 | case default 45 | when Array, String, Hash 46 | !default.empty? 47 | else 48 | default 49 | end 50 | end 51 | 52 | def enum_to_s 53 | if enum.respond_to? :join 54 | enum.join(", ") 55 | else 56 | "#{enum.first}..#{enum.last}" 57 | end 58 | end 59 | 60 | protected 61 | 62 | def validate! 63 | raise ArgumentError, "An argument cannot be required and have default value." if required? && !default.nil? 64 | raise ArgumentError, "An argument cannot have an enum other than an enumerable." if @enum && !@enum.is_a?(Enumerable) 65 | end 66 | 67 | def valid_type?(type) 68 | self.class::VALID_TYPES.include?(type.to_sym) 69 | end 70 | 71 | def default_banner 72 | case type 73 | when :boolean 74 | nil 75 | when :string, :default 76 | human_name.upcase 77 | when :numeric 78 | "N" 79 | when :hash 80 | "key:value" 81 | when :array 82 | "one two three" 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/thor/parser/arguments.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | class Arguments #:nodoc: 3 | NUMERIC = /[-+]?(\d*\.\d+|\d+)/ 4 | 5 | # Receives an array of args and returns two arrays, one with arguments 6 | # and one with switches. 7 | # 8 | def self.split(args) 9 | arguments = [] 10 | 11 | args.each do |item| 12 | break if item.is_a?(String) && item =~ /^-/ 13 | arguments << item 14 | end 15 | 16 | [arguments, args[Range.new(arguments.size, -1)]] 17 | end 18 | 19 | def self.parse(*args) 20 | to_parse = args.pop 21 | new(*args).parse(to_parse) 22 | end 23 | 24 | # Takes an array of Thor::Argument objects. 25 | # 26 | def initialize(arguments = []) 27 | @assigns = {} 28 | @non_assigned_required = [] 29 | @switches = arguments 30 | 31 | arguments.each do |argument| 32 | if !argument.default.nil? 33 | @assigns[argument.human_name] = argument.default.dup 34 | elsif argument.required? 35 | @non_assigned_required << argument 36 | end 37 | end 38 | end 39 | 40 | def parse(args) 41 | @pile = args.dup 42 | 43 | @switches.each do |argument| 44 | break unless peek 45 | @non_assigned_required.delete(argument) 46 | @assigns[argument.human_name] = send(:"parse_#{argument.type}", argument.human_name) 47 | end 48 | 49 | check_requirement! 50 | @assigns 51 | end 52 | 53 | def remaining 54 | @pile 55 | end 56 | 57 | private 58 | 59 | def no_or_skip?(arg) 60 | arg =~ /^--(no|skip)-([-\w]+)$/ 61 | $2 62 | end 63 | 64 | def last? 65 | @pile.empty? 66 | end 67 | 68 | def peek 69 | @pile.first 70 | end 71 | 72 | def shift 73 | @pile.shift 74 | end 75 | 76 | def unshift(arg) 77 | if arg.is_a?(Array) 78 | @pile = arg + @pile 79 | else 80 | @pile.unshift(arg) 81 | end 82 | end 83 | 84 | def current_is_value? 85 | peek && peek.to_s !~ /^-{1,2}\S+/ 86 | end 87 | 88 | # Runs through the argument array getting strings that contains ":" and 89 | # mark it as a hash: 90 | # 91 | # [ "name:string", "age:integer" ] 92 | # 93 | # Becomes: 94 | # 95 | # { "name" => "string", "age" => "integer" } 96 | # 97 | def parse_hash(name) 98 | return shift if peek.is_a?(Hash) 99 | hash = {} 100 | 101 | while current_is_value? && peek.include?(":") 102 | key, value = shift.split(":", 2) 103 | raise MalformattedArgumentError, "You can't specify '#{key}' more than once in option '#{name}'; got #{key}:#{hash[key]} and #{key}:#{value}" if hash.include? key 104 | hash[key] = value 105 | end 106 | hash 107 | end 108 | 109 | # Runs through the argument array getting all strings until no string is 110 | # found or a switch is found. 111 | # 112 | # ["a", "b", "c"] 113 | # 114 | # And returns it as an array: 115 | # 116 | # ["a", "b", "c"] 117 | # 118 | def parse_array(name) 119 | return shift if peek.is_a?(Array) 120 | 121 | array = [] 122 | 123 | while current_is_value? 124 | value = shift 125 | 126 | if !value.empty? 127 | validate_enum_value!(name, value, "Expected all values of '%s' to be one of %s; got %s") 128 | end 129 | 130 | array << value 131 | end 132 | array 133 | end 134 | 135 | # Check if the peek is numeric format and return a Float or Integer. 136 | # Check if the peek is included in enum if enum is provided. 137 | # Otherwise raises an error. 138 | # 139 | def parse_numeric(name) 140 | return shift if peek.is_a?(Numeric) 141 | 142 | unless peek =~ NUMERIC && $& == peek 143 | raise MalformattedArgumentError, "Expected numeric value for '#{name}'; got #{peek.inspect}" 144 | end 145 | 146 | value = $&.index(".") ? shift.to_f : shift.to_i 147 | 148 | validate_enum_value!(name, value, "Expected '%s' to be one of %s; got %s") 149 | 150 | value 151 | end 152 | 153 | # Parse string: 154 | # for --string-arg, just return the current value in the pile 155 | # for --no-string-arg, nil 156 | # Check if the peek is included in enum if enum is provided. Otherwise raises an error. 157 | # 158 | def parse_string(name) 159 | if no_or_skip?(name) 160 | nil 161 | else 162 | value = shift 163 | 164 | validate_enum_value!(name, value, "Expected '%s' to be one of %s; got %s") 165 | 166 | value 167 | end 168 | end 169 | 170 | # Raises an error if the switch is an enum and the values aren't included on it. 171 | # 172 | def validate_enum_value!(name, value, message) 173 | return unless @switches.is_a?(Hash) 174 | 175 | switch = @switches[name] 176 | 177 | return unless switch 178 | 179 | if switch.enum && !switch.enum.include?(value) 180 | raise MalformattedArgumentError, message % [name, switch.enum_to_s, value] 181 | end 182 | end 183 | 184 | # Raises an error if @non_assigned_required array is not empty. 185 | # 186 | def check_requirement! 187 | return if @non_assigned_required.empty? 188 | names = @non_assigned_required.map do |o| 189 | o.respond_to?(:switch_name) ? o.switch_name : o.human_name 190 | end.join("', '") 191 | class_name = self.class.name.split("::").last.downcase 192 | raise RequiredArgumentMissingError, "No value provided for required #{class_name} '#{names}'" 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/thor/parser/option.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | class Option < Argument #:nodoc: 3 | attr_reader :aliases, :group, :lazy_default, :hide, :repeatable 4 | 5 | VALID_TYPES = [:boolean, :numeric, :hash, :array, :string] 6 | 7 | def initialize(name, options = {}) 8 | @check_default_type = options[:check_default_type] 9 | options[:required] = false unless options.key?(:required) 10 | @repeatable = options.fetch(:repeatable, false) 11 | super 12 | @lazy_default = options[:lazy_default] 13 | @group = options[:group].to_s.capitalize if options[:group] 14 | @aliases = normalize_aliases(options[:aliases]) 15 | @hide = options[:hide] 16 | end 17 | 18 | # This parse quick options given as method_options. It makes several 19 | # assumptions, but you can be more specific using the option method. 20 | # 21 | # parse :foo => "bar" 22 | # #=> Option foo with default value bar 23 | # 24 | # parse [:foo, :baz] => "bar" 25 | # #=> Option foo with default value bar and alias :baz 26 | # 27 | # parse :foo => :required 28 | # #=> Required option foo without default value 29 | # 30 | # parse :foo => 2 31 | # #=> Option foo with default value 2 and type numeric 32 | # 33 | # parse :foo => :numeric 34 | # #=> Option foo without default value and type numeric 35 | # 36 | # parse :foo => true 37 | # #=> Option foo with default value true and type boolean 38 | # 39 | # The valid types are :boolean, :numeric, :hash, :array and :string. If none 40 | # is given a default type is assumed. This default type accepts arguments as 41 | # string (--foo=value) or booleans (just --foo). 42 | # 43 | # By default all options are optional, unless :required is given. 44 | # 45 | def self.parse(key, value) 46 | if key.is_a?(Array) 47 | name, *aliases = key 48 | else 49 | name = key 50 | aliases = [] 51 | end 52 | 53 | name = name.to_s 54 | default = value 55 | 56 | type = case value 57 | when Symbol 58 | default = nil 59 | if VALID_TYPES.include?(value) 60 | value 61 | elsif required = (value == :required) # rubocop:disable Lint/AssignmentInCondition 62 | :string 63 | end 64 | when TrueClass, FalseClass 65 | :boolean 66 | when Numeric 67 | :numeric 68 | when Hash, Array, String 69 | value.class.name.downcase.to_sym 70 | end 71 | 72 | new(name.to_s, required: required, type: type, default: default, aliases: aliases) 73 | end 74 | 75 | def switch_name 76 | @switch_name ||= dasherized? ? name : dasherize(name) 77 | end 78 | 79 | def human_name 80 | @human_name ||= dasherized? ? undasherize(name) : name 81 | end 82 | 83 | def usage(padding = 0) 84 | sample = if banner && !banner.to_s.empty? 85 | "#{switch_name}=#{banner}".dup 86 | else 87 | switch_name 88 | end 89 | 90 | sample = "[#{sample}]".dup unless required? 91 | 92 | if boolean? && name != "force" && !name.match(/\A(no|skip)[\-_]/) 93 | sample << ", [#{dasherize('no-' + human_name)}], [#{dasherize('skip-' + human_name)}]" 94 | end 95 | 96 | aliases_for_usage.ljust(padding) + sample 97 | end 98 | 99 | def aliases_for_usage 100 | if aliases.empty? 101 | "" 102 | else 103 | "#{aliases.join(', ')}, " 104 | end 105 | end 106 | 107 | def show_default? 108 | case default 109 | when TrueClass, FalseClass 110 | true 111 | else 112 | super 113 | end 114 | end 115 | 116 | VALID_TYPES.each do |type| 117 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 118 | def #{type}? 119 | self.type == #{type.inspect} 120 | end 121 | RUBY 122 | end 123 | 124 | protected 125 | 126 | def validate! 127 | raise ArgumentError, "An option cannot be boolean and required." if boolean? && required? 128 | validate_default_type! 129 | end 130 | 131 | def validate_default_type! 132 | default_type = case @default 133 | when nil 134 | return 135 | when TrueClass, FalseClass 136 | required? ? :string : :boolean 137 | when Numeric 138 | :numeric 139 | when Symbol 140 | :string 141 | when Hash, Array, String 142 | @default.class.name.downcase.to_sym 143 | end 144 | 145 | expected_type = (@repeatable && @type != :hash) ? :array : @type 146 | 147 | if default_type != expected_type 148 | err = "Expected #{expected_type} default value for '#{switch_name}'; got #{@default.inspect} (#{default_type})" 149 | 150 | if @check_default_type 151 | raise ArgumentError, err 152 | elsif @check_default_type == nil 153 | Thor.deprecation_warning "#{err}.\n" + 154 | "This will be rejected in the future unless you explicitly pass the options `check_default_type: false`" + 155 | " or call `allow_incompatible_default_type!` in your code" 156 | end 157 | end 158 | end 159 | 160 | def dasherized? 161 | name.index("-") == 0 162 | end 163 | 164 | def undasherize(str) 165 | str.sub(/^-{1,2}/, "") 166 | end 167 | 168 | def dasherize(str) 169 | (str.length > 1 ? "--" : "-") + str.tr("_", "-") 170 | end 171 | 172 | private 173 | 174 | def normalize_aliases(aliases) 175 | Array(aliases).map { |short| short.to_s.sub(/^(?!\-)/, "-") } 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/thor/rake_compat.rb: -------------------------------------------------------------------------------- 1 | require "rake" 2 | require "rake/dsl_definition" 3 | 4 | class Thor 5 | # Adds a compatibility layer to your Thor classes which allows you to use 6 | # rake package tasks. For example, to use rspec rake tasks, one can do: 7 | # 8 | # require 'thor/rake_compat' 9 | # require 'rspec/core/rake_task' 10 | # 11 | # class Default < Thor 12 | # include Thor::RakeCompat 13 | # 14 | # RSpec::Core::RakeTask.new(:spec) do |t| 15 | # t.spec_opts = ['--options', './.rspec'] 16 | # t.spec_files = FileList['spec/**/*_spec.rb'] 17 | # end 18 | # end 19 | # 20 | module RakeCompat 21 | include Rake::DSL if defined?(Rake::DSL) 22 | 23 | def self.rake_classes 24 | @rake_classes ||= [] 25 | end 26 | 27 | def self.included(base) 28 | super(base) 29 | # Hack. Make rakefile point to invoker, so rdoc task is generated properly. 30 | rakefile = File.basename(caller[0].match(/(.*):\d+/)[1]) 31 | Rake.application.instance_variable_set(:@rakefile, rakefile) 32 | rake_classes << base 33 | end 34 | end 35 | end 36 | 37 | # override task on (main), for compatibility with Rake 0.9 38 | instance_eval do 39 | alias rake_namespace namespace 40 | 41 | def task(*) 42 | task = super 43 | 44 | if klass = Thor::RakeCompat.rake_classes.last # rubocop:disable Lint/AssignmentInCondition 45 | non_namespaced_name = task.name.split(":").last 46 | 47 | description = non_namespaced_name 48 | description << task.arg_names.map { |n| n.to_s.upcase }.join(" ") 49 | description.strip! 50 | 51 | klass.desc description, Rake.application.last_description || non_namespaced_name 52 | Rake.application.last_description = nil 53 | klass.send :define_method, non_namespaced_name do |*args| 54 | Rake::Task[task.name.to_sym].invoke(*args) 55 | end 56 | end 57 | 58 | task 59 | end 60 | 61 | def namespace(name) 62 | if klass = Thor::RakeCompat.rake_classes.last # rubocop:disable Lint/AssignmentInCondition 63 | const_name = Thor::Util.camel_case(name.to_s).to_sym 64 | klass.const_set(const_name, Class.new(Thor)) 65 | new_klass = klass.const_get(const_name) 66 | Thor::RakeCompat.rake_classes << new_klass 67 | end 68 | 69 | super 70 | Thor::RakeCompat.rake_classes.pop 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/thor/shell.rb: -------------------------------------------------------------------------------- 1 | require "rbconfig" 2 | 3 | class Thor 4 | module Base 5 | class << self 6 | attr_writer :shell 7 | 8 | # Returns the shell used in all Thor classes. If you are in a Unix platform 9 | # it will use a colored log, otherwise it will use a basic one without color. 10 | # 11 | def shell 12 | @shell ||= if ENV["THOR_SHELL"] && !ENV["THOR_SHELL"].empty? 13 | Thor::Shell.const_get(ENV["THOR_SHELL"]) 14 | elsif RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ && !ENV["ANSICON"] 15 | Thor::Shell::Basic 16 | else 17 | Thor::Shell::Color 18 | end 19 | end 20 | end 21 | end 22 | 23 | module Shell 24 | SHELL_DELEGATED_METHODS = [:ask, :error, :set_color, :yes?, :no?, :say, :say_error, :say_status, :print_in_columns, :print_table, :print_wrapped, :file_collision, :terminal_width] 25 | attr_writer :shell 26 | 27 | autoload :Basic, File.expand_path("shell/basic", __dir__) 28 | autoload :Color, File.expand_path("shell/color", __dir__) 29 | autoload :HTML, File.expand_path("shell/html", __dir__) 30 | 31 | # Add shell to initialize config values. 32 | # 33 | # ==== Configuration 34 | # shell:: An instance of the shell to be used. 35 | # 36 | # ==== Examples 37 | # 38 | # class MyScript < Thor 39 | # argument :first, :type => :numeric 40 | # end 41 | # 42 | # MyScript.new [1.0], { :foo => :bar }, :shell => Thor::Shell::Basic.new 43 | # 44 | def initialize(args = [], options = {}, config = {}) 45 | super 46 | self.shell = config[:shell] 47 | shell.base ||= self if shell.respond_to?(:base) 48 | end 49 | 50 | # Holds the shell for the given Thor instance. If no shell is given, 51 | # it gets a default shell from Thor::Base.shell. 52 | def shell 53 | @shell ||= Thor::Base.shell.new 54 | end 55 | 56 | # Common methods that are delegated to the shell. 57 | SHELL_DELEGATED_METHODS.each do |method| 58 | module_eval <<-METHOD, __FILE__, __LINE__ + 1 59 | def #{method}(*args,&block) 60 | shell.#{method}(*args,&block) 61 | end 62 | METHOD 63 | end 64 | 65 | # Yields the given block with padding. 66 | def with_padding 67 | shell.padding += 1 68 | yield 69 | ensure 70 | shell.padding -= 1 71 | end 72 | 73 | protected 74 | 75 | # Allow shell to be shared between invocations. 76 | # 77 | def _shared_configuration #:nodoc: 78 | super.merge!(shell: shell) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/thor/shell/color.rb: -------------------------------------------------------------------------------- 1 | require_relative "basic" 2 | require_relative "lcs_diff" 3 | 4 | class Thor 5 | module Shell 6 | # Inherit from Thor::Shell::Basic and add set_color behavior. Check 7 | # Thor::Shell::Basic to see all available methods. 8 | # 9 | class Color < Basic 10 | include LCSDiff 11 | 12 | # Embed in a String to clear all previous ANSI sequences. 13 | CLEAR = "\e[0m" 14 | # The start of an ANSI bold sequence. 15 | BOLD = "\e[1m" 16 | 17 | # Set the terminal's foreground ANSI color to black. 18 | BLACK = "\e[30m" 19 | # Set the terminal's foreground ANSI color to red. 20 | RED = "\e[31m" 21 | # Set the terminal's foreground ANSI color to green. 22 | GREEN = "\e[32m" 23 | # Set the terminal's foreground ANSI color to yellow. 24 | YELLOW = "\e[33m" 25 | # Set the terminal's foreground ANSI color to blue. 26 | BLUE = "\e[34m" 27 | # Set the terminal's foreground ANSI color to magenta. 28 | MAGENTA = "\e[35m" 29 | # Set the terminal's foreground ANSI color to cyan. 30 | CYAN = "\e[36m" 31 | # Set the terminal's foreground ANSI color to white. 32 | WHITE = "\e[37m" 33 | 34 | # Set the terminal's background ANSI color to black. 35 | ON_BLACK = "\e[40m" 36 | # Set the terminal's background ANSI color to red. 37 | ON_RED = "\e[41m" 38 | # Set the terminal's background ANSI color to green. 39 | ON_GREEN = "\e[42m" 40 | # Set the terminal's background ANSI color to yellow. 41 | ON_YELLOW = "\e[43m" 42 | # Set the terminal's background ANSI color to blue. 43 | ON_BLUE = "\e[44m" 44 | # Set the terminal's background ANSI color to magenta. 45 | ON_MAGENTA = "\e[45m" 46 | # Set the terminal's background ANSI color to cyan. 47 | ON_CYAN = "\e[46m" 48 | # Set the terminal's background ANSI color to white. 49 | ON_WHITE = "\e[47m" 50 | 51 | # Set color by using a string or one of the defined constants. If a third 52 | # option is set to true, it also adds bold to the string. This is based 53 | # on Highline implementation and it automatically appends CLEAR to the end 54 | # of the returned String. 55 | # 56 | # Pass foreground, background and bold options to this method as 57 | # symbols. 58 | # 59 | # Example: 60 | # 61 | # set_color "Hi!", :red, :on_white, :bold 62 | # 63 | # The available colors are: 64 | # 65 | # :bold 66 | # :black 67 | # :red 68 | # :green 69 | # :yellow 70 | # :blue 71 | # :magenta 72 | # :cyan 73 | # :white 74 | # :on_black 75 | # :on_red 76 | # :on_green 77 | # :on_yellow 78 | # :on_blue 79 | # :on_magenta 80 | # :on_cyan 81 | # :on_white 82 | def set_color(string, *colors) 83 | if colors.compact.empty? || !can_display_colors? 84 | string 85 | elsif colors.all? { |color| color.is_a?(Symbol) || color.is_a?(String) } 86 | ansi_colors = colors.map { |color| lookup_color(color) } 87 | "#{ansi_colors.join}#{string}#{CLEAR}" 88 | else 89 | # The old API was `set_color(color, bold=boolean)`. We 90 | # continue to support the old API because you should never 91 | # break old APIs unnecessarily :P 92 | foreground, bold = colors 93 | foreground = self.class.const_get(foreground.to_s.upcase) if foreground.is_a?(Symbol) 94 | 95 | bold = bold ? BOLD : "" 96 | "#{bold}#{foreground}#{string}#{CLEAR}" 97 | end 98 | end 99 | 100 | protected 101 | 102 | def can_display_colors? 103 | are_colors_supported? && !are_colors_disabled? 104 | end 105 | 106 | def are_colors_supported? 107 | stdout.tty? && ENV["TERM"] != "dumb" 108 | end 109 | 110 | def are_colors_disabled? 111 | !ENV["NO_COLOR"].nil? && !ENV["NO_COLOR"].empty? 112 | end 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/thor/shell/column_printer.rb: -------------------------------------------------------------------------------- 1 | require_relative "terminal" 2 | 3 | class Thor 4 | module Shell 5 | class ColumnPrinter 6 | attr_reader :stdout, :options 7 | 8 | def initialize(stdout, options = {}) 9 | @stdout = stdout 10 | @options = options 11 | @indent = options[:indent].to_i 12 | end 13 | 14 | def print(array) 15 | return if array.empty? 16 | colwidth = (array.map { |el| el.to_s.size }.max || 0) + 2 17 | array.each_with_index do |value, index| 18 | # Don't output trailing spaces when printing the last column 19 | if ((((index + 1) % (Terminal.terminal_width / colwidth))).zero? && !index.zero?) || index + 1 == array.length 20 | stdout.puts value 21 | else 22 | stdout.printf("%-#{colwidth}s", value) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | 30 | -------------------------------------------------------------------------------- /lib/thor/shell/html.rb: -------------------------------------------------------------------------------- 1 | require_relative "basic" 2 | require_relative "lcs_diff" 3 | 4 | class Thor 5 | module Shell 6 | # Inherit from Thor::Shell::Basic and add set_color behavior. Check 7 | # Thor::Shell::Basic to see all available methods. 8 | # 9 | class HTML < Basic 10 | include LCSDiff 11 | 12 | # The start of an HTML bold sequence. 13 | BOLD = "font-weight: bold" 14 | 15 | # Set the terminal's foreground HTML color to black. 16 | BLACK = "color: black" 17 | # Set the terminal's foreground HTML color to red. 18 | RED = "color: red" 19 | # Set the terminal's foreground HTML color to green. 20 | GREEN = "color: green" 21 | # Set the terminal's foreground HTML color to yellow. 22 | YELLOW = "color: yellow" 23 | # Set the terminal's foreground HTML color to blue. 24 | BLUE = "color: blue" 25 | # Set the terminal's foreground HTML color to magenta. 26 | MAGENTA = "color: magenta" 27 | # Set the terminal's foreground HTML color to cyan. 28 | CYAN = "color: cyan" 29 | # Set the terminal's foreground HTML color to white. 30 | WHITE = "color: white" 31 | 32 | # Set the terminal's background HTML color to black. 33 | ON_BLACK = "background-color: black" 34 | # Set the terminal's background HTML color to red. 35 | ON_RED = "background-color: red" 36 | # Set the terminal's background HTML color to green. 37 | ON_GREEN = "background-color: green" 38 | # Set the terminal's background HTML color to yellow. 39 | ON_YELLOW = "background-color: yellow" 40 | # Set the terminal's background HTML color to blue. 41 | ON_BLUE = "background-color: blue" 42 | # Set the terminal's background HTML color to magenta. 43 | ON_MAGENTA = "background-color: magenta" 44 | # Set the terminal's background HTML color to cyan. 45 | ON_CYAN = "background-color: cyan" 46 | # Set the terminal's background HTML color to white. 47 | ON_WHITE = "background-color: white" 48 | 49 | # Set color by using a string or one of the defined constants. If a third 50 | # option is set to true, it also adds bold to the string. This is based 51 | # on Highline implementation and it automatically appends CLEAR to the end 52 | # of the returned String. 53 | # 54 | def set_color(string, *colors) 55 | if colors.all? { |color| color.is_a?(Symbol) || color.is_a?(String) } 56 | html_colors = colors.map { |color| lookup_color(color) } 57 | "#{Thor::Util.escape_html(string)}" 58 | else 59 | color, bold = colors 60 | html_color = self.class.const_get(color.to_s.upcase) if color.is_a?(Symbol) 61 | styles = [html_color] 62 | styles << BOLD if bold 63 | "#{Thor::Util.escape_html(string)}" 64 | end 65 | end 66 | 67 | # Ask something to the user and receives a response. 68 | # 69 | # ==== Example 70 | # ask("What is your name?") 71 | # 72 | # TODO: Implement #ask for Thor::Shell::HTML 73 | def ask(statement, color = nil) 74 | raise NotImplementedError, "Implement #ask for Thor::Shell::HTML" 75 | end 76 | 77 | protected 78 | 79 | def can_display_colors? 80 | true 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/thor/shell/lcs_diff.rb: -------------------------------------------------------------------------------- 1 | module LCSDiff 2 | protected 3 | 4 | # Overwrite show_diff to show diff with colors if Diff::LCS is 5 | # available. 6 | def show_diff(destination, content) #:nodoc: 7 | if diff_lcs_loaded? && ENV["THOR_DIFF"].nil? && ENV["RAILS_DIFF"].nil? 8 | actual = File.binread(destination).to_s.split("\n") 9 | content = content.to_s.split("\n") 10 | 11 | Diff::LCS.sdiff(actual, content).each do |diff| 12 | output_diff_line(diff) 13 | end 14 | else 15 | super 16 | end 17 | end 18 | 19 | private 20 | 21 | def output_diff_line(diff) #:nodoc: 22 | case diff.action 23 | when "-" 24 | say "- #{diff.old_element.chomp}", :red, true 25 | when "+" 26 | say "+ #{diff.new_element.chomp}", :green, true 27 | when "!" 28 | say "- #{diff.old_element.chomp}", :red, true 29 | say "+ #{diff.new_element.chomp}", :green, true 30 | else 31 | say " #{diff.old_element.chomp}", nil, true 32 | end 33 | end 34 | 35 | # Check if Diff::LCS is loaded. If it is, use it to create pretty output 36 | # for diff. 37 | def diff_lcs_loaded? #:nodoc: 38 | return true if defined?(Diff::LCS) 39 | return @diff_lcs_loaded unless @diff_lcs_loaded.nil? 40 | 41 | @diff_lcs_loaded = begin 42 | require "diff/lcs" 43 | true 44 | rescue LoadError 45 | false 46 | end 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /lib/thor/shell/table_printer.rb: -------------------------------------------------------------------------------- 1 | require_relative "column_printer" 2 | require_relative "terminal" 3 | 4 | class Thor 5 | module Shell 6 | class TablePrinter < ColumnPrinter 7 | BORDER_SEPARATOR = :separator 8 | 9 | def initialize(stdout, options = {}) 10 | super 11 | @formats = [] 12 | @maximas = [] 13 | @colwidth = options[:colwidth] 14 | @truncate = options[:truncate] == true ? Terminal.terminal_width : options[:truncate] 15 | @padding = 1 16 | end 17 | 18 | def print(array) 19 | return if array.empty? 20 | 21 | prepare(array) 22 | 23 | print_border_separator if options[:borders] 24 | 25 | array.each do |row| 26 | if options[:borders] && row == BORDER_SEPARATOR 27 | print_border_separator 28 | next 29 | end 30 | 31 | sentence = "".dup 32 | 33 | row.each_with_index do |column, index| 34 | sentence << format_cell(column, row.size, index) 35 | end 36 | 37 | sentence = truncate(sentence) 38 | sentence << "|" if options[:borders] 39 | stdout.puts indentation + sentence 40 | 41 | end 42 | print_border_separator if options[:borders] 43 | end 44 | 45 | private 46 | 47 | def prepare(array) 48 | array = array.reject{|row| row == BORDER_SEPARATOR } 49 | 50 | @formats << "%-#{@colwidth + 2}s".dup if @colwidth 51 | start = @colwidth ? 1 : 0 52 | 53 | colcount = array.max { |a, b| a.size <=> b.size }.size 54 | 55 | start.upto(colcount - 1) do |index| 56 | maxima = array.map { |row| row[index] ? row[index].to_s.size : 0 }.max 57 | 58 | @maximas << maxima 59 | @formats << if options[:borders] 60 | "%-#{maxima}s".dup 61 | elsif index == colcount - 1 62 | # Don't output 2 trailing spaces when printing the last column 63 | "%-s".dup 64 | else 65 | "%-#{maxima + 2}s".dup 66 | end 67 | end 68 | 69 | @formats << "%s" 70 | end 71 | 72 | def format_cell(column, row_size, index) 73 | maxima = @maximas[index] 74 | 75 | f = if column.is_a?(Numeric) 76 | if options[:borders] 77 | # With borders we handle padding separately 78 | "%#{maxima}s" 79 | elsif index == row_size - 1 80 | # Don't output 2 trailing spaces when printing the last column 81 | "%#{maxima}s" 82 | else 83 | "%#{maxima}s " 84 | end 85 | else 86 | @formats[index] 87 | end 88 | 89 | cell = "".dup 90 | cell << "|" + " " * @padding if options[:borders] 91 | cell << f % column.to_s 92 | cell << " " * @padding if options[:borders] 93 | cell 94 | end 95 | 96 | def print_border_separator 97 | separator = @maximas.map do |maxima| 98 | "+" + "-" * (maxima + 2 * @padding) 99 | end 100 | stdout.puts indentation + separator.join + "+" 101 | end 102 | 103 | def truncate(string) 104 | return string unless @truncate 105 | chars = string.chars.to_a 106 | if chars.length <= @truncate 107 | chars.join 108 | else 109 | chars[0, @truncate - 3 - @indent].join + "..." 110 | end 111 | end 112 | 113 | def indentation 114 | " " * @indent 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/thor/shell/terminal.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | module Shell 3 | module Terminal 4 | DEFAULT_TERMINAL_WIDTH = 80 5 | 6 | class << self 7 | # This code was copied from Rake, available under MIT-LICENSE 8 | # Copyright (c) 2003, 2004 Jim Weirich 9 | def terminal_width 10 | result = if ENV["THOR_COLUMNS"] 11 | ENV["THOR_COLUMNS"].to_i 12 | else 13 | unix? ? dynamic_width : DEFAULT_TERMINAL_WIDTH 14 | end 15 | result < 10 ? DEFAULT_TERMINAL_WIDTH : result 16 | rescue 17 | DEFAULT_TERMINAL_WIDTH 18 | end 19 | 20 | def unix? 21 | RUBY_PLATFORM =~ /(aix|darwin|linux|(net|free|open)bsd|cygwin|solaris)/i 22 | end 23 | 24 | private 25 | 26 | # Calculate the dynamic width of the terminal 27 | def dynamic_width 28 | @dynamic_width ||= (dynamic_width_stty.nonzero? || dynamic_width_tput) 29 | end 30 | 31 | def dynamic_width_stty 32 | `stty size 2>/dev/null`.split[1].to_i 33 | end 34 | 35 | def dynamic_width_tput 36 | `tput cols 2>/dev/null`.to_i 37 | end 38 | 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/thor/shell/wrapped_printer.rb: -------------------------------------------------------------------------------- 1 | require_relative "column_printer" 2 | require_relative "terminal" 3 | 4 | class Thor 5 | module Shell 6 | class WrappedPrinter < ColumnPrinter 7 | def print(message) 8 | width = Terminal.terminal_width - @indent 9 | paras = message.split("\n\n") 10 | 11 | paras.map! do |unwrapped| 12 | words = unwrapped.split(" ") 13 | counter = words.first.length 14 | words.inject do |memo, word| 15 | word = word.gsub(/\n\005/, "\n").gsub(/\005/, "\n") 16 | counter = 0 if word.include? "\n" 17 | if (counter + word.length + 1) < width 18 | memo = "#{memo} #{word}" 19 | counter += (word.length + 1) 20 | else 21 | memo = "#{memo}\n#{word}" 22 | counter = word.length 23 | end 24 | memo 25 | end 26 | end.compact! 27 | 28 | paras.each do |para| 29 | para.split("\n").each do |line| 30 | stdout.puts line.insert(0, " " * @indent) 31 | end 32 | stdout.puts unless para == paras.last 33 | end 34 | end 35 | end 36 | end 37 | end 38 | 39 | -------------------------------------------------------------------------------- /lib/thor/version.rb: -------------------------------------------------------------------------------- 1 | class Thor 2 | VERSION = "1.3.2" 3 | end 4 | -------------------------------------------------------------------------------- /spec/actions/create_file_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/actions" 3 | 4 | describe Thor::Actions::CreateFile do 5 | before do 6 | @silence = false 7 | ::FileUtils.rm_rf(destination_root) 8 | end 9 | 10 | def create_file(destination = nil, config = {}, options = {}, contents = "CONFIGURATION") 11 | @base = MyCounter.new([1, 2], options, destination_root: destination_root) 12 | allow(@base).to receive(:file_name).and_return("rdoc") 13 | 14 | @action = Thor::Actions::CreateFile.new(@base, destination, contents, {verbose: !@silence}.merge(config)) 15 | end 16 | 17 | def invoke! 18 | capture(:stdout) { @action.invoke! } 19 | end 20 | 21 | def revoke! 22 | capture(:stdout) { @action.revoke! } 23 | end 24 | 25 | def silence! 26 | @silence = true 27 | end 28 | 29 | describe "#invoke!" do 30 | it "creates a file" do 31 | create_file("doc/config.rb") 32 | invoke! 33 | expect(File.exist?(File.join(destination_root, "doc/config.rb"))).to be true 34 | end 35 | 36 | it "allows setting file permissions" do 37 | create_file("config/private.key", perm: 0o600) 38 | invoke! 39 | 40 | stat = File.stat(File.join(destination_root, "config/private.key")) 41 | expect(stat.mode.to_s(8)).to eq "100600" 42 | end 43 | 44 | it "does not create a file if pretending" do 45 | create_file("doc/config.rb", {}, pretend: true) 46 | invoke! 47 | expect(File.exist?(File.join(destination_root, "doc/config.rb"))).to be false 48 | end 49 | 50 | it "shows created status to the user" do 51 | create_file("doc/config.rb") 52 | expect(invoke!).to eq(" create doc/config.rb\n") 53 | end 54 | 55 | it "does not show any information if log status is false" do 56 | silence! 57 | create_file("doc/config.rb") 58 | expect(invoke!).to be_empty 59 | end 60 | 61 | it "returns the given destination" do 62 | capture(:stdout) do 63 | expect(create_file("doc/config.rb").invoke!).to eq("doc/config.rb") 64 | end 65 | end 66 | 67 | it "converts encoded instructions" do 68 | create_file("doc/%file_name%.rb.tt") 69 | invoke! 70 | expect(File.exist?(File.join(destination_root, "doc/rdoc.rb.tt"))).to be true 71 | end 72 | 73 | describe "when file exists" do 74 | before do 75 | create_file("doc/config.rb") 76 | invoke! 77 | end 78 | 79 | describe "and is identical" do 80 | it "shows identical status" do 81 | create_file("doc/config.rb") 82 | invoke! 83 | expect(invoke!).to eq(" identical doc/config.rb\n") 84 | end 85 | end 86 | 87 | describe "and is not identical" do 88 | before do 89 | File.open(File.join(destination_root, "doc/config.rb"), "w") { |f| f.write("FOO = 3") } 90 | end 91 | 92 | it "shows forced status to the user if force is given" do 93 | expect(create_file("doc/config.rb", {}, force: true)).not_to be_identical 94 | expect(invoke!).to eq(" force doc/config.rb\n") 95 | end 96 | 97 | it "shows skipped status to the user if skip is given" do 98 | expect(create_file("doc/config.rb", {}, skip: true)).not_to be_identical 99 | expect(invoke!).to eq(" skip doc/config.rb\n") 100 | end 101 | 102 | it "shows forced status to the user if force is configured" do 103 | expect(create_file("doc/config.rb", force: true)).not_to be_identical 104 | expect(invoke!).to eq(" force doc/config.rb\n") 105 | end 106 | 107 | it "shows skipped status to the user if skip is configured" do 108 | expect(create_file("doc/config.rb", skip: true)).not_to be_identical 109 | expect(invoke!).to eq(" skip doc/config.rb\n") 110 | end 111 | 112 | it "shows conflict status to the user" do 113 | file = File.join(destination_root, "doc/config.rb") 114 | expect(create_file("doc/config.rb")).not_to be_identical 115 | expect(Thor::LineEditor).to receive(:readline).with("Overwrite #{file}? (enter \"h\" for help) [Ynaqdhm] ", anything).and_return("s") 116 | 117 | content = invoke! 118 | expect(content).to match(%r{conflict doc/config\.rb}) 119 | expect(content).to match(%r{skip doc/config\.rb}) 120 | end 121 | 122 | it "creates the file if the file collision menu returns true" do 123 | create_file("doc/config.rb") 124 | expect(Thor::LineEditor).to receive(:readline).and_return("y") 125 | expect(invoke!).to match(%r{force doc/config\.rb}) 126 | end 127 | 128 | it "skips the file if the file collision menu returns false" do 129 | create_file("doc/config.rb") 130 | expect(Thor::LineEditor).to receive(:readline).and_return("n") 131 | expect(invoke!).to match(%r{skip doc/config\.rb}) 132 | end 133 | 134 | it "executes the block given to show file content" do 135 | create_file("doc/config.rb") 136 | expect(Thor::LineEditor).to receive(:readline).and_return("d", "n") 137 | expect(@base.shell).to receive(:system).with(/diff -u/) 138 | invoke! 139 | end 140 | 141 | it "executes the block given to run merge tool" do 142 | create_file("doc/config.rb") 143 | allow(@base.shell).to receive(:merge_tool).and_return("meld") 144 | expect(Thor::LineEditor).to receive(:readline).and_return("m") 145 | expect(@base.shell).to receive(:system).with(/meld/) 146 | invoke! 147 | end 148 | end 149 | end 150 | 151 | context "when file exists and it causes a file clash" do 152 | before do 153 | create_file("doc/config") 154 | invoke! 155 | end 156 | 157 | it "generates a file clash" do 158 | create_file("doc/config/config.rb") 159 | expect(invoke!).to eq(" file_clash doc/config/config.rb\n") 160 | end 161 | end 162 | 163 | context "when directory exists and it causes a file clash" do 164 | before do 165 | create_file("doc/config/hello") 166 | invoke! 167 | end 168 | 169 | it "generates a file clash" do 170 | create_file("doc/config") 171 | expect(invoke!) .to eq(" file_clash doc/config\n") 172 | end 173 | end 174 | end 175 | 176 | describe "#revoke!" do 177 | it "removes the destination file" do 178 | create_file("doc/config.rb") 179 | invoke! 180 | revoke! 181 | expect(File.exist?(@action.destination)).to be false 182 | end 183 | 184 | it "does not raise an error if the file does not exist" do 185 | create_file("doc/config.rb") 186 | revoke! 187 | expect(File.exist?(@action.destination)).to be false 188 | end 189 | end 190 | 191 | describe "#exists?" do 192 | it "returns true if the destination file exists" do 193 | create_file("doc/config.rb") 194 | expect(@action.exists?).to be false 195 | invoke! 196 | expect(@action.exists?).to be true 197 | end 198 | end 199 | 200 | describe "#identical?" do 201 | it "returns true if the destination file exists and is identical" do 202 | create_file("doc/config.rb") 203 | expect(@action.identical?).to be false 204 | invoke! 205 | expect(@action.identical?).to be true 206 | end 207 | 208 | it "returns true if the destination file exists and is identical and contains multi-byte UTF-8 codepoints" do 209 | create_file("doc/config.rb", {}, {}, "€") 210 | expect(@action.identical?).to be false 211 | invoke! 212 | expect(@action.identical?).to be true 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /spec/actions/create_link_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/actions" 3 | require "tempfile" 4 | 5 | describe Thor::Actions::CreateLink, unless: windows? do 6 | before do 7 | @hardlink_to = File.join(Dir.tmpdir, "linkdest.rb") 8 | ::FileUtils.rm_rf(destination_root) 9 | ::FileUtils.rm_rf(@hardlink_to) 10 | end 11 | 12 | let(:config) { {} } 13 | let(:options) { {} } 14 | 15 | let(:base) do 16 | base = MyCounter.new([1, 2], options, destination_root: destination_root) 17 | allow(base).to receive(:file_name).and_return("rdoc") 18 | base 19 | end 20 | 21 | let(:tempfile) { Tempfile.new("config.rb") } 22 | 23 | let(:source) { tempfile.path } 24 | 25 | let(:destination) { "doc/config.rb" } 26 | 27 | let(:action) do 28 | Thor::Actions::CreateLink.new(base, destination, source, config) 29 | end 30 | 31 | def invoke! 32 | capture(:stdout) { action.invoke! } 33 | end 34 | 35 | def revoke! 36 | capture(:stdout) { action.revoke! } 37 | end 38 | 39 | describe "#invoke!" do 40 | context "specifying :symbolic => true" do 41 | let(:config) { {symbolic: true} } 42 | 43 | it "creates a symbolic link" do 44 | invoke! 45 | destination_path = File.join(destination_root, "doc/config.rb") 46 | expect(File.exist?(destination_path)).to be true 47 | expect(File.symlink?(destination_path)).to be true 48 | end 49 | end 50 | 51 | context "specifying :symbolic => false" do 52 | let(:config) { {symbolic: false} } 53 | let(:destination) { @hardlink_to } 54 | 55 | it "creates a hard link" do 56 | invoke! 57 | destination_path = @hardlink_to 58 | expect(File.exist?(destination_path)).to be true 59 | expect(File.symlink?(destination_path)).to be false 60 | end 61 | end 62 | 63 | it "creates a symbolic link by default" do 64 | invoke! 65 | destination_path = File.join(destination_root, "doc/config.rb") 66 | expect(File.exist?(destination_path)).to be true 67 | expect(File.symlink?(destination_path)).to be true 68 | end 69 | 70 | context "specifying :pretend => true" do 71 | let(:options) { {pretend: true} } 72 | it "does not create a link" do 73 | invoke! 74 | expect(File.exist?(File.join(destination_root, "doc/config.rb"))).to be false 75 | end 76 | end 77 | 78 | it "shows created status to the user" do 79 | expect(invoke!).to eq(" create doc/config.rb\n") 80 | end 81 | 82 | context "specifying :verbose => false" do 83 | let(:config) { {verbose: false} } 84 | it "does not show any information" do 85 | expect(invoke!).to be_empty 86 | end 87 | end 88 | end 89 | 90 | describe "#identical?" do 91 | it "returns true if the destination link exists and is identical" do 92 | expect(action.identical?).to be false 93 | invoke! 94 | expect(action.identical?).to be true 95 | end 96 | 97 | context "with source path relative to destination" do 98 | let(:source) do 99 | destination_path = File.dirname(File.join(destination_root, destination)) 100 | Pathname.new(super()).relative_path_from(Pathname.new(destination_path)).to_s 101 | end 102 | 103 | it "returns true if the destination link exists and is identical" do 104 | expect(action.identical?).to be false 105 | invoke! 106 | expect(action.identical?).to be true 107 | end 108 | end 109 | end 110 | 111 | describe "#revoke!" do 112 | it "removes the symbolic link of non-existent destination" do 113 | invoke! 114 | File.delete(tempfile.path) 115 | revoke! 116 | expect(File.symlink?(action.destination)).to be false 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/actions/directory_spec.rb: -------------------------------------------------------------------------------- 1 | require "tmpdir" 2 | require "helper" 3 | require "thor/actions" 4 | 5 | describe Thor::Actions::Directory do 6 | before do 7 | ::FileUtils.rm_rf(destination_root) 8 | allow(invoker).to receive(:file_name).and_return("rdoc") 9 | end 10 | 11 | def invoker 12 | @invoker ||= WhinyGenerator.new([1, 2], {}, destination_root: destination_root) 13 | end 14 | 15 | def revoker 16 | @revoker ||= WhinyGenerator.new([1, 2], {}, destination_root: destination_root, behavior: :revoke) 17 | end 18 | 19 | def invoke!(*args, &block) 20 | capture(:stdout) { invoker.directory(*args, &block) } 21 | end 22 | 23 | def revoke!(*args, &block) 24 | capture(:stdout) { revoker.directory(*args, &block) } 25 | end 26 | 27 | def exists_and_identical?(source_path, destination_path) 28 | %w(config.rb README).each do |file| 29 | source = File.join(source_root, source_path, file) 30 | destination = File.join(destination_root, destination_path, file) 31 | 32 | expect(File.exist?(destination)).to be true 33 | expect(FileUtils.identical?(source, destination)).to be true 34 | end 35 | end 36 | 37 | describe "#invoke!" do 38 | it "raises an error if the source does not exist" do 39 | expect do 40 | invoke! "unknown" 41 | end.to raise_error(Thor::Error, /Could not find "unknown" in any of your source paths/) 42 | end 43 | 44 | it "does not create a directory in pretend mode" do 45 | invoke! "doc", "ghost", pretend: true 46 | expect(File.exist?("ghost")).to be false 47 | end 48 | 49 | it "copies the whole directory recursively to the default destination" do 50 | invoke! "doc" 51 | exists_and_identical?("doc", "doc") 52 | end 53 | 54 | it "copies the whole directory recursively to the specified destination" do 55 | invoke! "doc", "docs" 56 | exists_and_identical?("doc", "docs") 57 | end 58 | 59 | it "copies only the first level files if recursive" do 60 | invoke! ".", "commands", recursive: false 61 | 62 | file = File.join(destination_root, "commands", "group.thor") 63 | expect(File.exist?(file)).to be true 64 | 65 | file = File.join(destination_root, "commands", "doc") 66 | expect(File.exist?(file)).to be false 67 | 68 | file = File.join(destination_root, "commands", "doc", "README") 69 | expect(File.exist?(file)).to be false 70 | end 71 | 72 | it "ignores files within excluding/ directories when exclude_pattern is provided" do 73 | invoke! "doc", "docs", exclude_pattern: %r{excluding/} 74 | file = File.join(destination_root, "docs", "excluding", "rdoc.rb") 75 | expect(File.exist?(file)).to be false 76 | end 77 | 78 | it "copies and evaluates files within excluding/ directory when no exclude_pattern is present" do 79 | invoke! "doc", "docs" 80 | file = File.join(destination_root, "docs", "excluding", "rdoc.rb") 81 | expect(File.exist?(file)).to be true 82 | expect(File.read(file)).to eq("BAR = BAR\n") 83 | end 84 | 85 | it "copies files from the source relative to the current path" do 86 | invoker.inside "doc" do 87 | invoke! "." 88 | end 89 | exists_and_identical?("doc", "doc") 90 | end 91 | 92 | it "copies and evaluates templates" do 93 | invoke! "doc", "docs" 94 | file = File.join(destination_root, "docs", "rdoc.rb") 95 | expect(File.exist?(file)).to be true 96 | expect(File.read(file)).to eq("FOO = FOO\n") 97 | end 98 | 99 | it "copies directories and preserves file mode" do 100 | invoke! "preserve", "preserved", mode: :preserve 101 | original = File.join(source_root, "preserve", "script.sh") 102 | copy = File.join(destination_root, "preserved", "script.sh") 103 | expect(File.stat(original).mode).to eq(File.stat(copy).mode) 104 | end 105 | 106 | it "copies directories" do 107 | invoke! "doc", "docs" 108 | file = File.join(destination_root, "docs", "components") 109 | expect(File.exist?(file)).to be true 110 | expect(File.directory?(file)).to be true 111 | end 112 | 113 | it "does not copy .empty_directory files" do 114 | invoke! "doc", "docs" 115 | file = File.join(destination_root, "docs", "components", ".empty_directory") 116 | expect(File.exist?(file)).to be false 117 | end 118 | 119 | it "copies directories even if they are empty" do 120 | invoke! "doc/components", "docs/components" 121 | file = File.join(destination_root, "docs", "components") 122 | expect(File.exist?(file)).to be true 123 | end 124 | 125 | it "does not copy empty directories twice" do 126 | content = invoke!("doc/components", "docs/components") 127 | expect(content).not_to match(/exist/) 128 | end 129 | 130 | it "logs status" do 131 | content = invoke!("doc") 132 | expect(content).to match(%r{create doc/README}) 133 | expect(content).to match(%r{create doc/config\.rb}) 134 | expect(content).to match(%r{create doc/rdoc\.rb}) 135 | expect(content).to match(%r{create doc/components}) 136 | end 137 | 138 | it "yields a block" do 139 | checked = false 140 | invoke!("doc") do |content| 141 | checked ||= !!(content =~ /FOO/) 142 | end 143 | expect(checked).to be true 144 | end 145 | 146 | it "works with glob characters in the path" do 147 | content = invoke!("app{1}") 148 | expect(content).to match(%r{create app\{1\}/README}) 149 | end 150 | 151 | context "windows temp directories", if: windows? do 152 | let(:spec_dir) { File.join(@temp_dir, "spec") } 153 | 154 | before(:each) do 155 | @temp_dir = Dir.mktmpdir("thor") 156 | Dir.mkdir(spec_dir) 157 | File.new(File.join(spec_dir, "spec_helper.rb"), "w").close 158 | end 159 | 160 | after(:each) { FileUtils.rm_rf(@temp_dir) } 161 | it "works with windows temp dir" do 162 | invoke! spec_dir, "specs" 163 | file = File.join(destination_root, "specs") 164 | expect(File.exist?(file)).to be true 165 | expect(File.directory?(file)).to be true 166 | end 167 | end 168 | end 169 | 170 | describe "#revoke!" do 171 | it "removes the destination file" do 172 | invoke! "doc" 173 | revoke! "doc" 174 | 175 | expect(File.exist?(File.join(destination_root, "doc", "README"))).to be false 176 | expect(File.exist?(File.join(destination_root, "doc", "config.rb"))).to be false 177 | expect(File.exist?(File.join(destination_root, "doc", "components"))).to be false 178 | end 179 | 180 | it "works with glob characters in the path" do 181 | invoke! "app{1}" 182 | expect(File.exist?(File.join(destination_root, "app{1}", "README"))).to be true 183 | 184 | revoke! "app{1}" 185 | expect(File.exist?(File.join(destination_root, "app{1}", "README"))).to be false 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /spec/actions/empty_directory_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/actions" 3 | 4 | describe Thor::Actions::EmptyDirectory do 5 | before do 6 | ::FileUtils.rm_rf(destination_root) 7 | end 8 | 9 | def empty_directory(destination, options = {}) 10 | @action = Thor::Actions::EmptyDirectory.new(base, destination) 11 | end 12 | 13 | def invoke! 14 | capture(:stdout) { @action.invoke! } 15 | end 16 | 17 | def revoke! 18 | capture(:stdout) { @action.revoke! } 19 | end 20 | 21 | def base 22 | @base ||= MyCounter.new([1, 2], {}, destination_root: destination_root) 23 | end 24 | 25 | describe "#destination" do 26 | it "returns the full destination with the destination_root" do 27 | expect(empty_directory("doc").destination).to eq(File.join(destination_root, "doc")) 28 | end 29 | 30 | it "takes relative root into account" do 31 | base.inside("doc") do 32 | expect(empty_directory("contents").destination).to eq(File.join(destination_root, "doc", "contents")) 33 | end 34 | end 35 | end 36 | 37 | describe "#relative_destination" do 38 | it "returns the relative destination to the original destination root" do 39 | base.inside("doc") do 40 | expect(empty_directory("contents").relative_destination).to eq("doc/contents") 41 | end 42 | end 43 | end 44 | 45 | describe "#given_destination" do 46 | it "returns the destination supplied by the user" do 47 | base.inside("doc") do 48 | expect(empty_directory("contents").given_destination).to eq("contents") 49 | end 50 | end 51 | end 52 | 53 | describe "#invoke!" do 54 | it "copies the file to the specified destination" do 55 | empty_directory("doc") 56 | invoke! 57 | expect(File.exist?(File.join(destination_root, "doc"))).to be true 58 | end 59 | 60 | it "shows created status to the user" do 61 | empty_directory("doc") 62 | expect(invoke!).to eq(" create doc\n") 63 | end 64 | 65 | it "does not create a directory if pretending" do 66 | base.inside("foo", pretend: true) do 67 | empty_directory("ghost") 68 | end 69 | expect(File.exist?(File.join(base.destination_root, "ghost"))).to be false 70 | end 71 | 72 | describe "when directory exists" do 73 | it "shows exist status" do 74 | empty_directory("doc") 75 | invoke! 76 | expect(invoke!).to eq(" exist doc\n") 77 | end 78 | end 79 | end 80 | 81 | describe "#revoke!" do 82 | it "removes the destination file" do 83 | empty_directory("doc") 84 | invoke! 85 | revoke! 86 | expect(File.exist?(@action.destination)).to be false 87 | end 88 | end 89 | 90 | describe "#exists?" do 91 | it "returns true if the destination file exists" do 92 | empty_directory("doc") 93 | expect(@action.exists?).to be false 94 | invoke! 95 | expect(@action.exists?).to be true 96 | end 97 | end 98 | 99 | context "protected methods" do 100 | describe "#convert_encoded_instructions" do 101 | before do 102 | empty_directory("test_dir") 103 | allow(@action.base).to receive(:file_name).and_return("expected") 104 | end 105 | 106 | it "accepts and executes a 'legal' %\w+% encoded instruction" do 107 | expect(@action.send(:convert_encoded_instructions, "%file_name%.txt")).to eq("expected.txt") 108 | end 109 | 110 | it "accepts and executes a private %\w+% encoded instruction" do 111 | @action.base.extend Module.new { 112 | def private_file_name 113 | "expected" 114 | end 115 | private :private_file_name 116 | } 117 | expect(@action.send(:convert_encoded_instructions, "%private_file_name%.txt")).to eq("expected.txt") 118 | end 119 | 120 | it "ignores an 'illegal' %\w+% encoded instruction" do 121 | expect(@action.send(:convert_encoded_instructions, "%some_name%.txt")).to eq("%some_name%.txt") 122 | end 123 | 124 | it "ignores incorrectly encoded instruction" do 125 | expect(@action.send(:convert_encoded_instructions, "%some.name%.txt")).to eq("%some.name%.txt") 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/actions/inject_into_file_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "helper" 3 | require "thor/actions" 4 | 5 | describe Thor::Actions::InjectIntoFile do 6 | before do 7 | ::FileUtils.rm_rf(destination_root) 8 | ::FileUtils.cp_r(source_root, destination_root) 9 | end 10 | 11 | def invoker(options = {}) 12 | @invoker ||= MyCounter.new([1, 2], options, destination_root: destination_root) 13 | end 14 | 15 | def revoker 16 | @revoker ||= MyCounter.new([1, 2], {}, destination_root: destination_root, behavior: :revoke) 17 | end 18 | 19 | def invoke!(*args, &block) 20 | capture(:stdout) { invoker.insert_into_file(*args, &block) } 21 | end 22 | 23 | def revoke!(*args, &block) 24 | capture(:stdout) { revoker.insert_into_file(*args, &block) } 25 | end 26 | 27 | def file 28 | File.join(destination_root, "doc/README") 29 | end 30 | 31 | describe "#invoke!" do 32 | it "changes the file adding content after the flag" do 33 | invoke! "doc/README", "\nmore content", after: "__start__" 34 | expect(File.read(file)).to eq("__start__\nmore content\nREADME\n__end__\n") 35 | end 36 | 37 | it "changes the file adding content before the flag" do 38 | invoke! "doc/README", "more content\n", before: "__end__" 39 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") 40 | end 41 | 42 | it "appends content to the file if before and after arguments not provided" do 43 | invoke!("doc/README", "more content\n") 44 | expect(File.read(file)).to eq("__start__\nREADME\n__end__\nmore content\n") 45 | end 46 | 47 | it "does not change the file if replacement present in the file" do 48 | invoke!("doc/README", "more specific content\n") 49 | expect(invoke!("doc/README", "more specific content\n")).to( 50 | eq(" unchanged doc/README\n") 51 | ) 52 | end 53 | 54 | it "does not change the file and logs the warning if flag not found in the file" do 55 | expect(invoke!("doc/README", "more content\n", after: "whatever")).to( 56 | eq("#{Thor::Actions::WARNINGS[:unchanged_no_flag]} doc/README\n") 57 | ) 58 | end 59 | 60 | it "accepts data as a block" do 61 | invoke! "doc/README", before: "__end__" do 62 | "more content\n" 63 | end 64 | 65 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") 66 | end 67 | 68 | it "logs status" do 69 | expect(invoke!("doc/README", "\nmore content", after: "__start__")).to eq(" insert doc/README\n") 70 | end 71 | 72 | it "logs status if pretending" do 73 | invoker(pretend: true) 74 | expect(invoke!("doc/README", "\nmore content", after: "__start__")).to eq(" insert doc/README\n") 75 | end 76 | 77 | it "does not change the file if pretending" do 78 | invoker pretend: true 79 | invoke! "doc/README", "\nmore content", after: "__start__" 80 | expect(File.read(file)).to eq("__start__\nREADME\n__end__\n") 81 | end 82 | 83 | it "does not change the file if already includes content" do 84 | invoke! "doc/README", before: "__end__" do 85 | "more content\n" 86 | end 87 | 88 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") 89 | 90 | invoke! "doc/README", before: "__end__" do 91 | "more content\n" 92 | end 93 | 94 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") 95 | end 96 | 97 | it "does not change the file if already includes content using before with capture" do 98 | invoke! "doc/README", before: /(__end__)/ do 99 | "more content\n" 100 | end 101 | 102 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") 103 | 104 | invoke! "doc/README", before: /(__end__)/ do 105 | "more content\n" 106 | end 107 | 108 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") 109 | end 110 | 111 | it "does not change the file if already includes content using after with capture" do 112 | invoke! "doc/README", after: /(README\n)/ do 113 | "more content\n" 114 | end 115 | 116 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") 117 | 118 | invoke! "doc/README", after: /(README\n)/ do 119 | "more content\n" 120 | end 121 | 122 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") 123 | end 124 | 125 | it "does not attempt to change the file if it doesn't exist - instead raises Thor::Error" do 126 | expect do 127 | invoke! "idontexist", before: "something" do 128 | "any content" 129 | end 130 | end.to raise_error(Thor::Error, /does not appear to exist/) 131 | expect(File.exist?("idontexist")).to be_falsey 132 | end 133 | 134 | it "does not attempt to change the file if it doesn't exist and pretending" do 135 | expect do 136 | invoker pretend: true 137 | invoke! "idontexist", before: "something" do 138 | "any content" 139 | end 140 | end.not_to raise_error 141 | expect(File.exist?("idontexist")).to be_falsey 142 | end 143 | 144 | it "does change the file if already includes content and :force is true" do 145 | invoke! "doc/README", before: "__end__" do 146 | "more content\n" 147 | end 148 | 149 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") 150 | 151 | invoke! "doc/README", before: "__end__", force: true do 152 | "more content\n" 153 | end 154 | 155 | expect(File.read(file)).to eq("__start__\nREADME\nmore content\nmore content\n__end__\n") 156 | end 157 | 158 | it "can insert chinese" do 159 | encoding_original = Encoding.default_external 160 | 161 | begin 162 | silence_warnings do 163 | Encoding.default_external = Encoding.find("UTF-8") 164 | end 165 | invoke! "doc/README.zh", "\n中文", after: "__start__" 166 | expect(File.read(File.join(destination_root, "doc/README.zh"))).to eq("__start__\n中文\n说明\n__end__\n") 167 | ensure 168 | silence_warnings do 169 | Encoding.default_external = encoding_original 170 | end 171 | end 172 | end 173 | end 174 | 175 | describe "#revoke!" do 176 | it "subtracts the destination file after injection" do 177 | invoke! "doc/README", "\nmore content", after: "__start__" 178 | revoke! "doc/README", "\nmore content", after: "__start__" 179 | expect(File.read(file)).to eq("__start__\nREADME\n__end__\n") 180 | end 181 | 182 | it "subtracts the destination file before injection" do 183 | invoke! "doc/README", "more content\n", before: "__start__" 184 | revoke! "doc/README", "more content\n", before: "__start__" 185 | expect(File.read(file)).to eq("__start__\nREADME\n__end__\n") 186 | end 187 | 188 | it "subtracts even with double after injection" do 189 | invoke! "doc/README", "\nmore content", after: "__start__" 190 | invoke! "doc/README", "\nanother stuff", after: "__start__" 191 | revoke! "doc/README", "\nmore content", after: "__start__" 192 | expect(File.read(file)).to eq("__start__\nanother stuff\nREADME\n__end__\n") 193 | end 194 | 195 | it "subtracts even with double before injection" do 196 | invoke! "doc/README", "more content\n", before: "__start__" 197 | invoke! "doc/README", "another stuff\n", before: "__start__" 198 | revoke! "doc/README", "more content\n", before: "__start__" 199 | expect(File.read(file)).to eq("another stuff\n__start__\nREADME\n__end__\n") 200 | end 201 | 202 | it "subtracts when prepending" do 203 | invoke! "doc/README", "more content\n", after: /\A/ 204 | invoke! "doc/README", "another stuff\n", after: /\A/ 205 | revoke! "doc/README", "more content\n", after: /\A/ 206 | expect(File.read(file)).to eq("another stuff\n__start__\nREADME\n__end__\n") 207 | end 208 | 209 | it "subtracts when appending" do 210 | invoke! "doc/README", "more content\n", before: /\z/ 211 | invoke! "doc/README", "another stuff\n", before: /\z/ 212 | revoke! "doc/README", "more content\n", before: /\z/ 213 | expect(File.read(file)).to eq("__start__\nREADME\n__end__\nanother stuff\n") 214 | end 215 | 216 | it "shows progress information to the user" do 217 | invoke!("doc/README", "\nmore content", after: "__start__") 218 | expect(revoke!("doc/README", "\nmore content", after: "__start__")).to eq(" subtract doc/README\n") 219 | end 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /spec/command_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor::Command do 4 | def command(options = {}, usage = "can_has") 5 | options.each do |key, value| 6 | options[key] = Thor::Option.parse(key, value) 7 | end 8 | 9 | @command ||= Thor::Command.new(:can_has, "I can has cheezburger", "I can has cheezburger\nLots and lots of it", nil, usage, options) 10 | end 11 | 12 | describe "#formatted_usage" do 13 | it "includes namespace within usage" do 14 | object = Struct.new(:namespace, :arguments).new("foo", []) 15 | expect(command(bar: :required).formatted_usage(object)).to eq("foo:can_has --bar=BAR") 16 | end 17 | 18 | it "includes subcommand name within subcommand usage" do 19 | object = Struct.new(:namespace, :arguments).new("main:foo", []) 20 | expect(command(bar: :required).formatted_usage(object, false, true)).to eq("foo can_has --bar=BAR") 21 | end 22 | 23 | it "removes default from namespace" do 24 | object = Struct.new(:namespace, :arguments).new("default:foo", []) 25 | expect(command(bar: :required).formatted_usage(object)).to eq(":foo:can_has --bar=BAR") 26 | end 27 | 28 | it "injects arguments into usage" do 29 | options = {required: true, type: :string} 30 | object = Struct.new(:namespace, :arguments).new("foo", [Thor::Argument.new(:bar, options)]) 31 | expect(command(foo: :required).formatted_usage(object)).to eq("foo:can_has BAR --foo=FOO") 32 | end 33 | 34 | it "allows multiple usages" do 35 | object = Struct.new(:namespace, :arguments).new("foo", []) 36 | expect(command({bar: :required}, ["can_has FOO", "can_has BAR"]).formatted_usage(object, false)).to eq("can_has FOO --bar=BAR\ncan_has BAR --bar=BAR") 37 | end 38 | end 39 | 40 | describe "#dynamic" do 41 | it "creates a dynamic command with the given name" do 42 | expect(Thor::DynamicCommand.new("command").name).to eq("command") 43 | expect(Thor::DynamicCommand.new("command").description).to eq("A dynamically-generated command") 44 | expect(Thor::DynamicCommand.new("command").usage).to eq("command") 45 | expect(Thor::DynamicCommand.new("command").options).to eq({}) 46 | end 47 | 48 | it "does not invoke an existing method" do 49 | dub = double 50 | expect(dub.class).to receive(:handle_no_command_error).with("to_s") 51 | Thor::DynamicCommand.new("to_s").run(dub) 52 | end 53 | end 54 | 55 | describe "#dup" do 56 | it "dup options hash" do 57 | command = Thor::Command.new("can_has", nil, nil, nil, nil, foo: true, bar: :required) 58 | command.dup.options.delete(:foo) 59 | expect(command.options[:foo]).to be 60 | end 61 | end 62 | 63 | describe "#run" do 64 | it "runs a command by calling a method in the given instance" do 65 | dub = double 66 | expect(dub).to receive(:can_has) { |*args| args } 67 | expect(command.run(dub, [1, 2, 3])).to eq([1, 2, 3]) 68 | end 69 | 70 | it "raises an error if the method to be invoked is private" do 71 | klass = Class.new do 72 | def self.handle_no_command_error(name) 73 | name 74 | end 75 | 76 | def can_has 77 | "fail" 78 | end 79 | private :can_has 80 | end 81 | 82 | expect(command.run(klass.new)).to eq("can_has") 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/core_ext/hash_with_indifferent_access_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/core_ext/hash_with_indifferent_access" 3 | 4 | describe Thor::CoreExt::HashWithIndifferentAccess do 5 | before do 6 | @hash = Thor::CoreExt::HashWithIndifferentAccess.new :foo => "bar", "baz" => "bee", :force => true 7 | end 8 | 9 | it "has values accessible by either strings or symbols" do 10 | expect(@hash["foo"]).to eq("bar") 11 | expect(@hash[:foo]).to eq("bar") 12 | 13 | expect(@hash.values_at(:foo, :baz)).to eq(%w(bar bee)) 14 | expect(@hash.delete(:foo)).to eq("bar") 15 | end 16 | 17 | it "supports except" do 18 | unexcepted_hash = @hash.dup 19 | @hash.except("foo") 20 | expect(@hash).to eq(unexcepted_hash) 21 | 22 | expect(@hash.except("foo")).to eq("baz" => "bee", "force" => true) 23 | expect(@hash.except("foo", "baz")).to eq("force" => true) 24 | expect(@hash.except(:foo)).to eq("baz" => "bee", "force" => true) 25 | expect(@hash.except(:foo, :baz)).to eq("force" => true) 26 | end 27 | 28 | it "supports fetch" do 29 | expect(@hash.fetch("foo")).to eq("bar") 30 | expect(@hash.fetch("foo", nil)).to eq("bar") 31 | expect(@hash.fetch(:foo)).to eq("bar") 32 | expect(@hash.fetch(:foo, nil)).to eq("bar") 33 | 34 | expect(@hash.fetch("baz")).to eq("bee") 35 | expect(@hash.fetch("baz", nil)).to eq("bee") 36 | expect(@hash.fetch(:baz)).to eq("bee") 37 | expect(@hash.fetch(:baz, nil)).to eq("bee") 38 | 39 | expect { @hash.fetch(:missing) }.to raise_error(IndexError) 40 | expect(@hash.fetch(:missing, :found)).to eq(:found) 41 | end 42 | 43 | it "supports slice" do 44 | expect(@hash.slice("foo")).to eq({"foo" => "bar"}) 45 | expect(@hash.slice(:foo)).to eq({"foo" => "bar"}) 46 | 47 | expect(@hash.slice("baz")).to eq({"baz" => "bee"}) 48 | expect(@hash.slice(:baz)).to eq({"baz" => "bee"}) 49 | 50 | expect(@hash.slice("foo", "baz")).to eq({"foo" => "bar", "baz" => "bee"}) 51 | expect(@hash.slice(:foo, :baz)).to eq({"foo" => "bar", "baz" => "bee"}) 52 | 53 | expect(@hash.slice("missing")).to eq({}) 54 | expect(@hash.slice(:missing)).to eq({}) 55 | end 56 | 57 | it "has key checkable by either strings or symbols" do 58 | expect(@hash.key?("foo")).to be true 59 | expect(@hash.key?(:foo)).to be true 60 | expect(@hash.key?("nothing")).to be false 61 | expect(@hash.key?(:nothing)).to be false 62 | end 63 | 64 | it "handles magic boolean predicates" do 65 | expect(@hash.force?).to be true 66 | expect(@hash.foo?).to be true 67 | expect(@hash.nothing?).to be false 68 | end 69 | 70 | it "handles magic comparisons" do 71 | expect(@hash.foo?("bar")).to be true 72 | expect(@hash.foo?("bee")).to be false 73 | end 74 | 75 | it "maps methods to keys" do 76 | expect(@hash.foo).to eq(@hash["foo"]) 77 | end 78 | 79 | it "merges keys independent if they are symbols or strings" do 80 | @hash["force"] = false 81 | @hash[:baz] = "boom" 82 | expect(@hash[:force]).to eq(false) 83 | expect(@hash["baz"]).to eq("boom") 84 | end 85 | 86 | it "creates a new hash by merging keys independent if they are symbols or strings" do 87 | other = @hash.merge("force" => false, :baz => "boom") 88 | expect(other[:force]).to eq(false) 89 | expect(other["baz"]).to eq("boom") 90 | end 91 | 92 | it "converts to a traditional hash" do 93 | expect(@hash.to_hash.class).to eq(Hash) 94 | expect(@hash).to eq("foo" => "bar", "baz" => "bee", "force" => true) 95 | end 96 | 97 | it "handles reverse_merge" do 98 | other = {:foo => "qux", "boo" => "bae"} 99 | new_hash = @hash.reverse_merge(other) 100 | 101 | expect(@hash.object_id).not_to eq(new_hash.object_id) 102 | expect(new_hash[:foo]).to eq("bar") 103 | expect(new_hash[:boo]).to eq("bae") 104 | end 105 | 106 | it "handles reverse_merge!" do 107 | other = {:foo => "qux", "boo" => "bae"} 108 | new_hash = @hash.reverse_merge!(other) 109 | 110 | expect(@hash.object_id).to eq(new_hash.object_id) 111 | expect(new_hash[:foo]).to eq("bar") 112 | expect(new_hash[:boo]).to eq("bae") 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/encoding_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/base" 3 | 4 | 5 | describe "file's encoding" do 6 | def load_thorfile(filename) 7 | Thor::Util.load_thorfile(File.expand_path("./fixtures/#{filename}", __dir__)) 8 | end 9 | 10 | it "respects explicit UTF-8" do 11 | load_thorfile("encoding_with_utf8.thor") 12 | expect(capture(:stdout) { Thor::Sandbox::EncodingWithUtf8.new.invoke(:encoding) }).to match(/ok/) 13 | end 14 | it "respects explicit non-UTF-8" do 15 | load_thorfile("encoding_other.thor") 16 | expect(capture(:stdout) { Thor::Sandbox::EncodingOther.new.invoke(:encoding) }).to match(/ok/) 17 | end 18 | it "has implicit UTF-8" do 19 | load_thorfile("encoding_implicit.thor") 20 | expect(capture(:stdout) { Thor::Sandbox::EncodingImplicit.new.invoke(:encoding) }).to match(/ok/) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/exit_condition_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/base" 3 | 4 | describe "Exit conditions" do 5 | it "exits 0, not bubble up EPIPE, if EPIPE is raised" do 6 | epiped = false 7 | 8 | command = Class.new(Thor) do 9 | desc "my_action", "testing EPIPE" 10 | define_method :my_action do 11 | epiped = true 12 | raise Errno::EPIPE 13 | end 14 | end 15 | 16 | expect { command.start(["my_action"]) }.to raise_error(SystemExit) 17 | expect(epiped).to eq(true) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/fixtures/application.rb: -------------------------------------------------------------------------------- 1 | class Application < Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/fixtures/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /spec/fixtures/app{1}/README: -------------------------------------------------------------------------------- 1 | __start__ 2 | README 3 | __end__ 4 | -------------------------------------------------------------------------------- /spec/fixtures/command.thor: -------------------------------------------------------------------------------- 1 | # module: random 2 | 3 | class Amazing < Thor 4 | def self.exit_on_failure? 5 | false 6 | end 7 | 8 | desc "describe NAME", "say that someone is amazing" 9 | method_options :forcefully => :boolean 10 | def describe(name, opts) 11 | ret = "#{name} is amazing" 12 | puts opts["forcefully"] ? ret.upcase : ret 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/fixtures/doc/%file_name%.rb.tt: -------------------------------------------------------------------------------- 1 | FOO = <%= "FOO" %> 2 | -------------------------------------------------------------------------------- /spec/fixtures/doc/COMMENTER: -------------------------------------------------------------------------------- 1 | __start__ 2 | # greenblue 3 | # 4 | # yellowblue 5 | #yellowred 6 | #greenred 7 | orange 8 | purple 9 | ind#igo 10 | # ind#igo 11 | # spaces_between 12 | __end__ 13 | -------------------------------------------------------------------------------- /spec/fixtures/doc/README: -------------------------------------------------------------------------------- 1 | __start__ 2 | README 3 | __end__ 4 | -------------------------------------------------------------------------------- /spec/fixtures/doc/README.zh: -------------------------------------------------------------------------------- 1 | __start__ 2 | 说明 3 | __end__ 4 | -------------------------------------------------------------------------------- /spec/fixtures/doc/block_helper.rb: -------------------------------------------------------------------------------- 1 | <% world do -%> 2 | Hello 3 | <% end -%> 4 | -------------------------------------------------------------------------------- /spec/fixtures/doc/components/.empty_directory: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/thor/44c4cac36b31167ef20a089f4ea03a9d62c85b29/spec/fixtures/doc/components/.empty_directory -------------------------------------------------------------------------------- /spec/fixtures/doc/config.rb: -------------------------------------------------------------------------------- 1 | class <%= @klass %>; end 2 | -------------------------------------------------------------------------------- /spec/fixtures/doc/config.yaml.tt: -------------------------------------------------------------------------------- 1 | --- Hi from yaml 2 | -------------------------------------------------------------------------------- /spec/fixtures/doc/excluding/%file_name%.rb.tt: -------------------------------------------------------------------------------- 1 | BAR = <%= "BAR" %> 2 | -------------------------------------------------------------------------------- /spec/fixtures/encoding_implicit.thor: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EncodingImplicit < Thor 4 | SOME_STRING = "Some λέξεις 一些词 🎉" 5 | 6 | desc "encoding", "tests that encoding is correct" 7 | 8 | def encoding 9 | puts "#{SOME_STRING.inspect}: #{SOME_STRING.encoding}:" 10 | if SOME_STRING.encoding.name == "UTF-8" 11 | puts "ok" 12 | else 13 | puts "expected #{SOME_STRING.encoding.name} to equal UTF-8" 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/fixtures/encoding_other.thor: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/thor/44c4cac36b31167ef20a089f4ea03a9d62c85b29/spec/fixtures/encoding_other.thor -------------------------------------------------------------------------------- /spec/fixtures/encoding_with_utf8.thor: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # frozen_string_literal: true 3 | 4 | class EncodingWithUtf8 < Thor 5 | SOME_STRING = "Some λέξεις 一些词 🎉" 6 | 7 | desc "encoding", "tests that encoding is correct" 8 | 9 | def encoding 10 | puts "#{SOME_STRING.inspect}: #{SOME_STRING.encoding}:" 11 | if SOME_STRING.encoding.name == "UTF-8" 12 | puts "ok" 13 | else 14 | puts "expected #{SOME_STRING.encoding.name} to equal UTF-8" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/fixtures/enum.thor: -------------------------------------------------------------------------------- 1 | class Enum < Thor::Group 2 | include Thor::Actions 3 | 4 | desc "snack" 5 | class_option "fruit", :aliases => "-f", :type => :string, :enum => %w(apple banana) 6 | def snack 7 | puts options['fruit'] 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /spec/fixtures/exit_status.thor: -------------------------------------------------------------------------------- 1 | require "thor" 2 | 3 | class ExitStatus < Thor 4 | def self.exit_on_failure? 5 | true 6 | end 7 | 8 | desc "error", "exit with a planned error" 9 | def error 10 | raise Thor::Error.new("planned error") 11 | end 12 | 13 | desc "ok", "exit with no error" 14 | def ok 15 | end 16 | end 17 | 18 | ExitStatus.start(ARGV) 19 | 20 | -------------------------------------------------------------------------------- /spec/fixtures/group.thor: -------------------------------------------------------------------------------- 1 | class MyCounter < Thor::Group 2 | include Thor::Actions 3 | add_runtime_options! 4 | 5 | def self.exit_on_failure? 6 | false 7 | end 8 | 9 | def self.get_from_super 10 | from_superclass(:get_from_super, 13) 11 | end 12 | 13 | source_root File.expand_path(File.dirname(__FILE__)) 14 | source_paths << File.expand_path("broken", File.dirname(__FILE__)) 15 | 16 | argument :first, :type => :numeric 17 | argument :second, :type => :numeric, :default => 2 18 | 19 | class_option :third, :type => :numeric, :desc => "The third argument", :default => 3, 20 | :banner => "THREE", :aliases => "-t" 21 | class_option :fourth, :type => :numeric, :desc => "The fourth argument" 22 | class_option :simple, :type => :numeric, :aliases => 'z' 23 | class_option :symbolic, :type => :numeric, :aliases => [:y, :r] 24 | class_option :array, :type => :array, :default => ['foo','bar'] 25 | 26 | desc <<-FOO 27 | Description: 28 | This generator runs three commands: one, two and three. 29 | FOO 30 | 31 | def one 32 | first 33 | end 34 | 35 | def two 36 | second 37 | end 38 | 39 | def three 40 | options[:third] 41 | end 42 | 43 | def four 44 | options[:fourth] 45 | end 46 | 47 | def five 48 | options[:simple] 49 | end 50 | 51 | def six 52 | options[:symbolic] 53 | end 54 | 55 | def self.inherited(base) 56 | super 57 | base.source_paths.unshift(File.expand_path(File.join(File.dirname(__FILE__), "doc"))) 58 | end 59 | 60 | no_commands do 61 | def world(&block) 62 | result = capture(&block) 63 | concat(result.strip + " world!") 64 | end 65 | end 66 | end 67 | 68 | class ClearCounter < MyCounter 69 | remove_argument :first, :second, :undefine => true 70 | remove_class_option :third 71 | 72 | def self.source_root 73 | File.expand_path(File.join(File.dirname(__FILE__), "bundle")) 74 | end 75 | end 76 | 77 | class BrokenCounter < MyCounter 78 | namespace "app:broken:counter" 79 | class_option :fail, :type => :boolean, :default => false 80 | 81 | class << self 82 | undef_method :source_root 83 | end 84 | 85 | def one 86 | options[:first] 87 | end 88 | 89 | def four 90 | respond_to?(:fail) 91 | end 92 | 93 | def five 94 | options[:fail] ? this_method_does_not_exist : 5 95 | end 96 | end 97 | 98 | class WhinyGenerator < Thor::Group 99 | include Thor::Actions 100 | 101 | def self.source_root 102 | File.expand_path(File.dirname(__FILE__)) 103 | end 104 | 105 | def wrong_arity(required) 106 | end 107 | end 108 | 109 | class CommandConflict < Thor::Group 110 | desc "A group with the same name as a default command" 111 | def group 112 | puts "group" 113 | end 114 | end 115 | 116 | class ParentGroup < Thor::Group 117 | private 118 | def foo 119 | "foo" 120 | end 121 | 122 | def baz(name = 'baz') 123 | name 124 | end 125 | end 126 | 127 | class ChildGroup < ParentGroup 128 | def bar 129 | "bar" 130 | end 131 | 132 | public_command :foo, :baz 133 | end 134 | -------------------------------------------------------------------------------- /spec/fixtures/help.thor: -------------------------------------------------------------------------------- 1 | Bundler.require :development, :default 2 | 3 | class Help < Thor 4 | 5 | desc :bugs, "ALL THE BUGZ!" 6 | option "--not_help", :type => :boolean 7 | def bugs 8 | puts "Invoked!" 9 | end 10 | 11 | end 12 | 13 | Help.start(ARGV) 14 | -------------------------------------------------------------------------------- /spec/fixtures/invoke.thor: -------------------------------------------------------------------------------- 1 | class A < Thor 2 | include Thor::Actions 3 | 4 | desc "one", "invoke one" 5 | def one 6 | p 1 7 | invoke :two 8 | invoke :three 9 | end 10 | 11 | desc "two", "invoke two" 12 | def two 13 | p 2 14 | invoke :three 15 | end 16 | 17 | desc "three", "invoke three" 18 | def three 19 | p 3 20 | end 21 | 22 | desc "four", "invoke four" 23 | def four 24 | p 4 25 | invoke "defined:five" 26 | end 27 | 28 | desc "five N", "check if number is equal 5" 29 | def five(number) 30 | number == 5 31 | end 32 | 33 | desc "invoker", "invoke a b command" 34 | def invoker(*args) 35 | invoke :b, :one, ["Jose"] 36 | end 37 | end 38 | 39 | class B < Thor 40 | class_option :last_name, :type => :string 41 | 42 | desc "one FIRST_NAME", "invoke one" 43 | def one(first_name) 44 | "#{options.last_name}, #{first_name}" 45 | end 46 | 47 | desc "two", "invoke two" 48 | def two 49 | options 50 | end 51 | 52 | desc "three", "invoke three" 53 | def three 54 | self 55 | end 56 | 57 | desc "four", "invoke four" 58 | option :defaulted_value, :type => :string, :default => 'default' 59 | def four 60 | options.defaulted_value 61 | end 62 | end 63 | 64 | class C < Thor::Group 65 | include Thor::Actions 66 | 67 | def one 68 | p 1 69 | end 70 | 71 | def two 72 | p 2 73 | end 74 | 75 | def three 76 | p 3 77 | end 78 | end 79 | 80 | class Defined < Thor::Group 81 | class_option :unused, :type => :boolean, :desc => "This option has no use" 82 | 83 | def one 84 | p 1 85 | invoke "a:two" 86 | invoke "a:three" 87 | invoke "a:four" 88 | invoke "defined:five" 89 | end 90 | 91 | def five 92 | p 5 93 | end 94 | 95 | def print_status 96 | say_status :finished, :counting 97 | end 98 | end 99 | 100 | class E < Thor::Group 101 | invoke Defined 102 | end 103 | 104 | class F < Thor::Group 105 | invoke "b:one" do |instance, klass, command| 106 | instance.invoke klass, command, [ "Jose" ], :last_name => "Valim" 107 | end 108 | end 109 | 110 | class G < Thor::Group 111 | class_option :invoked, :type => :string, :default => "defined" 112 | invoke_from_option :invoked 113 | end 114 | 115 | class H < Thor::Group 116 | class_option :defined, :type => :boolean, :default => true 117 | invoke_from_option :defined 118 | end 119 | 120 | class I < Thor 121 | desc "two", "Two" 122 | def two 123 | current_command_chain 124 | end 125 | end 126 | 127 | class J < Thor 128 | desc "i", "I" 129 | subcommand :one, I 130 | end 131 | 132 | -------------------------------------------------------------------------------- /spec/fixtures/path with spaces: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rails/thor/44c4cac36b31167ef20a089f4ea03a9d62c85b29/spec/fixtures/path with spaces -------------------------------------------------------------------------------- /spec/fixtures/preserve/%filename%.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit 0 4 | -------------------------------------------------------------------------------- /spec/fixtures/preserve/script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | exit 0 4 | -------------------------------------------------------------------------------- /spec/fixtures/script.thor: -------------------------------------------------------------------------------- 1 | class MyScript < Thor 2 | check_unknown_options! :except => :with_optional 3 | 4 | def self.exit_on_failure? 5 | false 6 | end 7 | 8 | attr_accessor :some_attribute 9 | attr_writer :another_attribute 10 | attr_reader :another_attribute 11 | 12 | private 13 | attr_reader :private_attribute 14 | 15 | public 16 | group :script 17 | default_command :example_default_command 18 | 19 | map "-T" => :animal, ["-f", "--foo"] => :foo 20 | 21 | map "animal_prison" => "zoo" 22 | 23 | desc "zoo", "zoo around" 24 | def zoo 25 | true 26 | end 27 | 28 | desc "animal TYPE", "horse around" 29 | 30 | no_commands do 31 | no_commands do 32 | def this_is_not_a_command 33 | end 34 | end 35 | 36 | def neither_is_this 37 | end 38 | end 39 | 40 | def animal(type) 41 | [type] 42 | end 43 | 44 | map "hid" => "hidden" 45 | 46 | desc "hidden TYPE", "this is hidden", :hide => true 47 | def hidden(type) 48 | [type] 49 | end 50 | 51 | map "fu" => "zoo" 52 | 53 | desc "foo BAR", < :boolean, :desc => "Force to do some fooing" 59 | def foo(bar) 60 | [bar, options] 61 | end 62 | 63 | method_option :all, :desc => "Do bazing for all the things" 64 | desc ["baz THING", "baz --all"], "super cool" 65 | def baz(thing = nil) 66 | raise if thing.nil? && !options.include?(:all) 67 | end 68 | 69 | desc "example_default_command", "example!" 70 | method_options :with => :string 71 | def example_default_command 72 | options.empty? ? "default command" : options 73 | end 74 | 75 | desc "call_myself_with_wrong_arity", "get the right error" 76 | def call_myself_with_wrong_arity 77 | call_myself_with_wrong_arity(4) 78 | end 79 | 80 | desc "call_unexistent_method", "Call unexistent method inside a command" 81 | def call_unexistent_method 82 | boom! 83 | end 84 | 85 | desc "long_description", "a" * 80 86 | long_desc <<-D 87 | This is a really really really long description. 88 | Here you go. So very long. 89 | 90 | It even has two paragraphs. 91 | D 92 | def long_description 93 | end 94 | 95 | desc "name-with-dashes", "Ensure normalization of command names" 96 | def name_with_dashes 97 | end 98 | 99 | desc "long_description", "a" * 80 100 | long_desc <<-D, wrap: false 101 | No added indentation, Inline 102 | whatespace not merged, 103 | Linebreaks preserved 104 | and 105 | indentation 106 | too 107 | D 108 | def long_description_unwrapped 109 | end 110 | 111 | method_options :all => :boolean 112 | method_option :lazy, :lazy_default => "yes" 113 | method_option :lazy_numeric, :type => :numeric, :lazy_default => 42 114 | method_option :lazy_array, :type => :array, :lazy_default => %w[eat at joes] 115 | method_option :lazy_hash, :type => :hash, :lazy_default => {'swedish' => 'meatballs'} 116 | desc "with_optional NAME", "invoke with optional name" 117 | def with_optional(name=nil, *args) 118 | [name, options, args] 119 | end 120 | 121 | class AnotherScript < Thor 122 | desc "baz", "do some bazing" 123 | def baz 124 | end 125 | end 126 | 127 | desc "send", "send as a command name" 128 | def send 129 | true 130 | end 131 | 132 | private 133 | 134 | def method_missing(meth, *args) 135 | if meth == :boom! 136 | super 137 | else 138 | [meth, args] 139 | end 140 | end 141 | 142 | desc "what", "what" 143 | def what 144 | end 145 | end 146 | 147 | class MyChildScript < MyScript 148 | remove_command :name_with_dashes 149 | 150 | method_options :force => :boolean, :param => :numeric 151 | def initialize(*args) 152 | super 153 | end 154 | 155 | desc "zoo", "zoo around" 156 | method_options :param => :required 157 | def zoo 158 | options 159 | end 160 | 161 | desc "animal TYPE", "horse around" 162 | def animal(type) 163 | [type, options] 164 | end 165 | method_option :other, :type => :string, :default => "method default", :for => :animal 166 | desc "animal KIND", "fish around", :for => :animal 167 | 168 | desc "boom", "explodes everything" 169 | def boom 170 | end 171 | 172 | remove_command :boom, :undefine => true 173 | end 174 | 175 | class Barn < Thor 176 | def self.exit_on_failure? 177 | false 178 | end 179 | 180 | desc "open [ITEM]", "open the barn door" 181 | def open(item = nil) 182 | if item == "shotgun" 183 | puts "That's going to leave a mark." 184 | else 185 | puts "Open sesame!" 186 | end 187 | end 188 | 189 | desc "paint [COLOR]", "paint the barn" 190 | method_option :coats, :type => :numeric, :default => 2, :desc => 'how many coats of paint' 191 | def paint(color='red') 192 | puts "#{options[:coats]} coats of #{color} paint" 193 | end 194 | end 195 | 196 | class PackageNameScript < Thor 197 | package_name "Baboon" 198 | end 199 | 200 | module Scripts 201 | class MyScript < MyChildScript 202 | argument :accessor, :type => :string 203 | class_options :force => :boolean 204 | method_option :new_option, :type => :string, :for => :example_default_command 205 | 206 | def zoo 207 | self.accessor 208 | end 209 | end 210 | 211 | class MyDefaults < Thor 212 | check_unknown_options! 213 | 214 | def self.exit_on_failure? 215 | false 216 | end 217 | 218 | namespace :default 219 | desc "cow", "prints 'moo'" 220 | def cow 221 | puts "moo" 222 | end 223 | 224 | desc "command_conflict", "only gets called when prepended with a colon" 225 | def command_conflict 226 | puts "command" 227 | end 228 | 229 | desc "barn", "commands to manage the barn" 230 | subcommand "barn", Barn 231 | end 232 | 233 | class ChildDefault < Thor 234 | namespace "default:child" 235 | end 236 | 237 | class Arities < Thor 238 | def self.exit_on_failure? 239 | false 240 | end 241 | 242 | desc "zero_args", "takes zero args" 243 | def zero_args 244 | end 245 | 246 | desc "one_arg ARG", "takes one arg" 247 | def one_arg(arg) 248 | end 249 | 250 | desc "two_args ARG1 ARG2", "takes two args" 251 | def two_args(arg1, arg2) 252 | end 253 | 254 | desc "optional_arg [ARG]", "takes an optional arg" 255 | def optional_arg(arg='default') 256 | end 257 | 258 | desc ["multiple_usages ARG --foo", "multiple_usages ARG --bar"], "takes mutually exclusive combinations of args and flags" 259 | def multiple_usages(arg) 260 | end 261 | end 262 | end 263 | 264 | class Apple < Thor 265 | namespace :fruits 266 | desc 'apple', 'apple'; def apple; end 267 | desc 'rotten-apple', 'rotten apple'; def rotten_apple; end 268 | map "ra" => :rotten_apple 269 | end 270 | 271 | class Pear < Thor 272 | namespace :fruits 273 | desc 'pear', 'pear'; def pear; end 274 | end 275 | 276 | class MyClassOptionScript < Thor 277 | class_option :free 278 | 279 | class_exclusive do 280 | class_option :one 281 | class_option :two 282 | end 283 | 284 | class_at_least_one do 285 | class_option :three 286 | class_option :four 287 | end 288 | 289 | desc "mix", "" 290 | exclusive do 291 | at_least_one do 292 | option :five 293 | option :six 294 | option :seven 295 | end 296 | end 297 | def mix 298 | end 299 | end 300 | 301 | class MyOptionScript < Thor 302 | desc "exclusive", "" 303 | exclusive do 304 | method_option :one 305 | method_option :two 306 | method_option :three 307 | end 308 | method_option :after1 309 | method_option :after2 310 | def exclusive 311 | end 312 | 313 | exclusive :after1, :after2, {:for => :exclusive} 314 | 315 | desc "at_least_one", "" 316 | at_least_one do 317 | method_option :one 318 | method_option :two 319 | method_option :three 320 | end 321 | method_option :after1 322 | method_option :after2 323 | def at_least_one 324 | end 325 | at_least_one :after1, :after2, :for => :at_least_one 326 | 327 | desc "only_one", "" 328 | exclusive do 329 | at_least_one do 330 | option :one 331 | option :two 332 | option :three 333 | end 334 | end 335 | def only_one 336 | end 337 | 338 | desc "no_relastions", "" 339 | option :no_rel1 340 | option :no_rel2 341 | def no_relations 342 | end 343 | end 344 | -------------------------------------------------------------------------------- /spec/fixtures/subcommand.thor: -------------------------------------------------------------------------------- 1 | module TestSubcommands 2 | 3 | class Subcommand < Thor 4 | desc "print_opt", "My method" 5 | def print_opt 6 | print options["opt"] 7 | end 8 | end 9 | 10 | class Parent < Thor 11 | class_option "opt" 12 | 13 | desc "sub", "My subcommand" 14 | subcommand "sub", Subcommand 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /spec/fixtures/template/bad_config.yaml.tt: -------------------------------------------------------------------------------- 1 | --- Hi from yaml 2 | <%= unresolved_variable %> 3 | -------------------------------------------------------------------------------- /spec/fixtures/verbose.thor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | $VERBOSE = true 4 | 5 | require 'thor' 6 | 7 | class Test < Thor 8 | def self.exit_on_failure? 9 | true 10 | end 11 | end 12 | 13 | Test.start(ARGV) 14 | -------------------------------------------------------------------------------- /spec/group_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor::Group do 4 | describe "command" do 5 | it "allows to use private methods from parent class as commands" do 6 | expect(ChildGroup.start).to eq(%w(bar foo baz)) 7 | expect(ChildGroup.new.baz("bar")).to eq("bar") 8 | end 9 | end 10 | 11 | describe "#start" do 12 | it "invokes all the commands under the Thor group" do 13 | expect(MyCounter.start(%w(1 2 --third 3))).to eq([1, 2, 3, nil, nil, nil]) 14 | end 15 | 16 | it "uses argument's default value" do 17 | expect(MyCounter.start(%w(1 --third 3))).to eq([1, 2, 3, nil, nil, nil]) 18 | end 19 | 20 | it "invokes all the commands in the Thor group and its parents" do 21 | expect(BrokenCounter.start(%w(1 2 --third 3))).to eq([nil, 2, 3, false, 5, nil]) 22 | end 23 | 24 | it "raises an error if a required argument is added after a non-required" do 25 | expect do 26 | MyCounter.argument(:foo, type: :string) 27 | end.to raise_error(ArgumentError, 'You cannot have "foo" as required argument after the non-required argument "second".') 28 | end 29 | 30 | it "raises when an exception happens within the command call" do 31 | if RUBY_VERSION < "3.4.0" 32 | expect { BrokenCounter.start(%w(1 2 --fail)) }.to raise_error(NameError, /undefined local variable or method `this_method_does_not_exist'/) 33 | else 34 | expect { BrokenCounter.start(%w(1 2 --fail)) }.to raise_error(NameError, /undefined local variable or method 'this_method_does_not_exist'/) 35 | end 36 | end 37 | 38 | it "raises an error when a Thor group command expects arguments" do 39 | expect { WhinyGenerator.start }.to raise_error(ArgumentError, /thor wrong_arity takes 1 argument, but it should not/) 40 | end 41 | 42 | it "invokes help message if any of the shortcuts are given" do 43 | expect(MyCounter).to receive(:help) 44 | MyCounter.start(%w(-h)) 45 | end 46 | end 47 | 48 | describe "#desc" do 49 | it "sets the description for a given class" do 50 | expect(MyCounter.desc).to eq("Description:\n This generator runs three commands: one, two and three.\n") 51 | end 52 | 53 | it "can be inherited" do 54 | expect(BrokenCounter.desc).to eq("Description:\n This generator runs three commands: one, two and three.\n") 55 | end 56 | 57 | it "can be nil" do 58 | expect(WhinyGenerator.desc).to be nil 59 | end 60 | end 61 | 62 | describe "#help" do 63 | before do 64 | @content = capture(:stdout) { MyCounter.help(Thor::Base.shell.new) } 65 | end 66 | 67 | it "provides usage information" do 68 | expect(@content).to match(/my_counter N \[N\]/) 69 | end 70 | 71 | it "shows description" do 72 | expect(@content).to match(/Description:/) 73 | expect(@content).to match(/This generator runs three commands: one, two and three./) 74 | end 75 | 76 | it "shows options information" do 77 | expect(@content).to match(/Options/) 78 | expect(@content).to match(/\[\-\-third=THREE\]/) 79 | end 80 | end 81 | 82 | describe "#invoke" do 83 | before do 84 | @content = capture(:stdout) { E.start } 85 | end 86 | 87 | it "allows to invoke a class from the class binding" do 88 | expect(@content).to match(/1\n2\n3\n4\n5\n/) 89 | end 90 | 91 | it "shows invocation information to the user" do 92 | expect(@content).to match(/invoke Defined/) 93 | end 94 | 95 | it "uses padding on status generated by the invoked class" do 96 | expect(@content).to match(/finished counting/) 97 | end 98 | 99 | it "allows invocation to be configured with blocks" do 100 | capture(:stdout) do 101 | expect(F.start).to eq(["Valim, Jose"]) 102 | end 103 | end 104 | 105 | it "shows invoked options on help" do 106 | content = capture(:stdout) { E.help(Thor::Base.shell.new) } 107 | expect(content).to match(/Defined options:/) 108 | expect(content).to match(/\[--unused\]/) 109 | expect(content).to match(/# This option has no use/) 110 | end 111 | end 112 | 113 | describe "#invoke_from_option" do 114 | describe "with default type" do 115 | before do 116 | @content = capture(:stdout) { G.start } 117 | end 118 | 119 | it "allows to invoke a class from the class binding by a default option" do 120 | expect(@content).to match(/1\n2\n3\n4\n5\n/) 121 | end 122 | 123 | it "does not invoke if the option is nil" do 124 | expect(capture(:stdout) { G.start(%w(--skip-invoked)) }).not_to match(/invoke/) 125 | end 126 | 127 | it "prints a message if invocation cannot be found" do 128 | content = capture(:stdout) { G.start(%w(--invoked unknown)) } 129 | expect(content).to match(/error unknown \[not found\]/) 130 | end 131 | 132 | it "allows to invoke a class from the class binding by the given option" do 133 | error = nil 134 | content = capture(:stdout) do 135 | error = capture(:stderr) do 136 | G.start(%w(--invoked e)) 137 | end 138 | end 139 | expect(content).to match(/invoke e/) 140 | expect(error).to match(/ERROR: "thor two" was called with arguments/) 141 | end 142 | 143 | it "shows invocation information to the user" do 144 | expect(@content).to match(/invoke defined/) 145 | end 146 | 147 | it "uses padding on status generated by the invoked class" do 148 | expect(@content).to match(/finished counting/) 149 | end 150 | 151 | it "shows invoked options on help" do 152 | content = capture(:stdout) { G.help(Thor::Base.shell.new) } 153 | expect(content).to match(/defined options:/) 154 | expect(content).to match(/\[--unused\]/) 155 | expect(content).to match(/# This option has no use/) 156 | end 157 | end 158 | 159 | describe "with boolean type" do 160 | before do 161 | @content = capture(:stdout) { H.start } 162 | end 163 | 164 | it "allows to invoke a class from the class binding by a default option" do 165 | expect(@content).to match(/1\n2\n3\n4\n5\n/) 166 | end 167 | 168 | it "does not invoke if the option is false" do 169 | expect(capture(:stdout) { H.start(%w(--no-defined)) }).not_to match(/invoke/) 170 | end 171 | 172 | it "shows invocation information to the user" do 173 | expect(@content).to match(/invoke defined/) 174 | end 175 | 176 | it "uses padding on status generated by the invoked class" do 177 | expect(@content).to match(/finished counting/) 178 | end 179 | 180 | it "shows invoked options on help" do 181 | content = capture(:stdout) { H.help(Thor::Base.shell.new) } 182 | expect(content).to match(/defined options:/) 183 | expect(content).to match(/\[--unused\]/) 184 | expect(content).to match(/# This option has no use/) 185 | end 186 | end 187 | end 188 | 189 | describe "#command_exists?" do 190 | it "returns true for a command that is defined in the class" do 191 | expect(MyCounter.command_exists?("one")).to be true 192 | end 193 | 194 | it "returns false for a command that is not defined in the class" do 195 | expect(MyCounter.command_exists?("zero")).to be false 196 | end 197 | end 198 | 199 | describe "edge-cases" do 200 | it "can handle boolean options followed by arguments" do 201 | klass = Class.new(Thor::Group) do 202 | desc "say hi to name" 203 | argument :name, type: :string 204 | class_option :loud, type: :boolean 205 | 206 | def hi 207 | self.name = name.upcase if options[:loud] 208 | "Hi #{name}" 209 | end 210 | end 211 | 212 | expect(klass.start(%w(jose))).to eq(["Hi jose"]) 213 | expect(klass.start(%w(jose --loud))).to eq(["Hi JOSE"]) 214 | expect(klass.start(%w(--loud jose))).to eq(["Hi JOSE"]) 215 | end 216 | 217 | it "provides extra args as `args`" do 218 | klass = Class.new(Thor::Group) do 219 | desc "say hi to name" 220 | argument :name, type: :string 221 | class_option :loud, type: :boolean 222 | 223 | def hi 224 | self.name = name.upcase if options[:loud] 225 | out = "Hi #{name}" 226 | out << ": " << args.join(", ") unless args.empty? 227 | out 228 | end 229 | end 230 | 231 | expect(klass.start(%w(jose))).to eq(["Hi jose"]) 232 | expect(klass.start(%w(jose --loud))).to eq(["Hi JOSE"]) 233 | expect(klass.start(%w(--loud jose))).to eq(["Hi JOSE"]) 234 | end 235 | end 236 | end 237 | -------------------------------------------------------------------------------- /spec/helper.rb: -------------------------------------------------------------------------------- 1 | $TESTING = true 2 | 3 | require "simplecov" 4 | require "coveralls" 5 | 6 | SimpleCov.formatters = [SimpleCov::Formatter::HTMLFormatter, Coveralls::SimpleCov::Formatter] 7 | 8 | SimpleCov.start do 9 | add_filter "/spec" 10 | minimum_coverage(90) 11 | end 12 | 13 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib")) 14 | require "thor" 15 | require "thor/group" 16 | require "stringio" 17 | 18 | require "rdoc" 19 | require "rspec" 20 | require "diff/lcs" # You need diff/lcs installed to run specs (but not to run Thor). 21 | require "webmock/rspec" 22 | 23 | WebMock.disable_net_connect!(allow: "coveralls.io") 24 | 25 | # Set shell to basic 26 | ENV["THOR_COLUMNS"] = "10000" 27 | $0 = "thor" 28 | $thor_runner = true 29 | ARGV.clear 30 | Thor::Base.shell = Thor::Shell::Basic 31 | 32 | # Load fixtures 33 | load File.join(File.dirname(__FILE__), "fixtures", "enum.thor") 34 | load File.join(File.dirname(__FILE__), "fixtures", "group.thor") 35 | load File.join(File.dirname(__FILE__), "fixtures", "invoke.thor") 36 | load File.join(File.dirname(__FILE__), "fixtures", "script.thor") 37 | load File.join(File.dirname(__FILE__), "fixtures", "subcommand.thor") 38 | load File.join(File.dirname(__FILE__), "fixtures", "command.thor") 39 | 40 | RSpec.configure do |config| 41 | config.before do 42 | ARGV.replace [] 43 | end 44 | 45 | config.expect_with :rspec do |c| 46 | c.syntax = :expect 47 | end 48 | 49 | def capture(stream) 50 | begin 51 | stream = stream.to_s 52 | eval "$#{stream} = StringIO.new" 53 | yield 54 | result = eval("$#{stream}").string 55 | ensure 56 | eval("$#{stream} = #{stream.upcase}") 57 | end 58 | 59 | result 60 | end 61 | 62 | def source_root 63 | File.join(File.dirname(__FILE__), "fixtures") 64 | end 65 | 66 | def destination_root 67 | File.join(File.dirname(__FILE__), "sandbox") 68 | end 69 | 70 | # This code was adapted from Ruby on Rails, available under MIT-LICENSE 71 | # Copyright (c) 2004-2013 David Heinemeier Hansson 72 | def silence_warnings 73 | old_verbose = $VERBOSE 74 | $VERBOSE = nil 75 | yield 76 | ensure 77 | $VERBOSE = old_verbose 78 | end 79 | 80 | # true if running on windows, used for conditional spec skips 81 | # 82 | # @return [TrueClass/FalseClass] 83 | def windows? 84 | Gem.win_platform? 85 | end 86 | 87 | alias silence capture 88 | end 89 | -------------------------------------------------------------------------------- /spec/invocation_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/base" 3 | 4 | describe Thor::Invocation do 5 | describe "#invoke" do 6 | it "invokes a command inside another command" do 7 | expect(capture(:stdout) { A.new.invoke(:two) }).to eq("2\n3\n") 8 | end 9 | 10 | it "invokes a command just once" do 11 | expect(capture(:stdout) { A.new.invoke(:one) }).to eq("1\n2\n3\n") 12 | end 13 | 14 | it "invokes a command just once even if they belongs to different classes" do 15 | expect(capture(:stdout) { Defined.new.invoke(:one) }).to eq("1\n2\n3\n4\n5\n") 16 | end 17 | 18 | it "invokes a command with arguments" do 19 | expect(A.new.invoke(:five, [5])).to be true 20 | expect(A.new.invoke(:five, [7])).to be false 21 | end 22 | 23 | it "invokes the default command if none is given to a Thor class" do 24 | content = capture(:stdout) { A.new.invoke("b") } 25 | expect(content).to match(/Commands/) 26 | expect(content).to match(/LAST_NAME/) 27 | end 28 | 29 | it "accepts a class as argument without a command to invoke" do 30 | content = capture(:stdout) { A.new.invoke(B) } 31 | expect(content).to match(/Commands/) 32 | expect(content).to match(/LAST_NAME/) 33 | end 34 | 35 | it "accepts a class as argument with a command to invoke" do 36 | base = A.new([], last_name: "Valim") 37 | expect(base.invoke(B, :one, %w(Jose))).to eq("Valim, Jose") 38 | end 39 | 40 | it "allows customized options to be given" do 41 | base = A.new([], last_name: "Wrong") 42 | expect(base.invoke(B, :one, %w(Jose), last_name: "Valim")).to eq("Valim, Jose") 43 | end 44 | 45 | it "reparses options in the new class" do 46 | expect(A.start(%w(invoker --last-name Valim))).to eq("Valim, Jose") 47 | end 48 | 49 | it "shares initialize options with invoked class" do 50 | expect(A.new([], foo: :bar).invoke("b:two")).to eq("foo" => :bar) 51 | end 52 | 53 | it "uses default options from invoked class if no matching arguments are given" do 54 | expect(A.new([]).invoke("b:four")).to eq("default") 55 | end 56 | 57 | it "overrides default options if options are passed to the invoker" do 58 | expect(A.new([], defaulted_value: "not default").invoke("b:four")).to eq("not default") 59 | end 60 | 61 | it "returns the command chain" do 62 | expect(I.new.invoke("two")).to eq([:two]) 63 | 64 | expect(J.start(%w(one two))).to eq([:one, :two]) 65 | end 66 | 67 | it "dump configuration values to be used in the invoked class" do 68 | base = A.new 69 | expect(base.invoke("b:three").shell).to eq(base.shell) 70 | end 71 | 72 | it "allow extra configuration values to be given" do 73 | base = A.new 74 | shell = Thor::Base.shell.new 75 | expect(base.invoke("b:three", [], {}, shell: shell).shell).to eq(shell) 76 | end 77 | 78 | it "invokes a Thor::Group and all of its commands" do 79 | expect(capture(:stdout) { A.new.invoke(:c) }).to eq("1\n2\n3\n") 80 | end 81 | 82 | it "does not invoke a Thor::Group twice" do 83 | base = A.new 84 | silence(:stdout) { base.invoke(:c) } 85 | expect(capture(:stdout) { base.invoke(:c) }).to be_empty 86 | end 87 | 88 | it "does not invoke any of Thor::Group commands twice" do 89 | base = A.new 90 | silence(:stdout) { base.invoke(:c) } 91 | expect(capture(:stdout) { base.invoke("c:one") }).to be_empty 92 | end 93 | 94 | it "raises Thor::UndefinedCommandError if the command can't be found" do 95 | expect do 96 | A.new.invoke("foo:bar") 97 | end.to raise_error(Thor::UndefinedCommandError) 98 | end 99 | 100 | it "raises Thor::UndefinedCommandError if the command can't be found even if all commands were already executed" do 101 | base = C.new 102 | silence(:stdout) { base.invoke_all } 103 | 104 | expect do 105 | base.invoke("foo:bar") 106 | end.to raise_error(Thor::UndefinedCommandError) 107 | end 108 | 109 | it "raises an error if a non Thor class is given" do 110 | expect do 111 | A.new.invoke(Object) 112 | end.to raise_error(RuntimeError, "Expected Thor class, got Object") 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/line_editor/basic_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor::LineEditor::Basic do 4 | describe ".available?" do 5 | it "returns true" do 6 | expect(Thor::LineEditor::Basic).to be_available 7 | end 8 | end 9 | 10 | describe "#readline" do 11 | it "uses $stdin and $stdout to get input from the user" do 12 | expect($stdout).to receive(:print).with("Enter your name ") 13 | expect($stdin).to receive(:gets).and_return("George") 14 | expect($stdin).not_to receive(:noecho) 15 | editor = Thor::LineEditor::Basic.new("Enter your name ", {}) 16 | expect(editor.readline).to eq("George") 17 | end 18 | 19 | it "disables echo when asked to" do 20 | expect($stdout).to receive(:print).with("Password: ") 21 | noecho_stdin = double("noecho_stdin") 22 | expect(noecho_stdin).to receive(:gets).and_return("secret") 23 | expect($stdin).to receive(:noecho).and_yield(noecho_stdin) 24 | editor = Thor::LineEditor::Basic.new("Password: ", echo: false) 25 | expect(editor.readline).to eq("secret") 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/line_editor/readline_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor::LineEditor::Readline do 4 | before do 5 | # Eagerly check Readline availability before mocking 6 | Thor::LineEditor::Readline.available? 7 | unless defined? ::Readline 8 | ::Readline = double("Readline") 9 | allow(::Readline).to receive(:completion_append_character=).with(nil) 10 | end 11 | end 12 | 13 | describe ".available?" do 14 | it "returns true when ::Readline exists" do 15 | allow(Object).to receive(:const_defined?).with(:Readline).and_return(true) 16 | expect(described_class).to be_available 17 | end 18 | 19 | it "returns false when ::Readline does not exist" do 20 | allow(Object).to receive(:const_defined?).with(:Readline).and_return(false) 21 | expect(described_class).not_to be_available 22 | end 23 | end 24 | 25 | describe "#readline" do 26 | it "invokes the readline library" do 27 | expect(::Readline).to receive(:readline).with("> ", true).and_return("foo") 28 | expect(::Readline).to_not receive(:completion_proc=) 29 | editor = Thor::LineEditor::Readline.new("> ", {}) 30 | expect(editor.readline).to eq("foo") 31 | end 32 | 33 | it "supports the add_to_history option" do 34 | expect(::Readline).to receive(:readline).with("> ", false).and_return("foo") 35 | expect(::Readline).to_not receive(:completion_proc=) 36 | editor = Thor::LineEditor::Readline.new("> ", add_to_history: false) 37 | expect(editor.readline).to eq("foo") 38 | end 39 | 40 | it "provides tab completion when given a limited_to option" do 41 | expect(::Readline).to receive(:readline) 42 | expect(::Readline).to receive(:completion_proc=) do |proc| 43 | expect(proc.call("")).to eq %w(Apples Chicken Chocolate) 44 | expect(proc.call("Ch")).to eq %w(Chicken Chocolate) 45 | expect(proc.call("Chi")).to eq ["Chicken"] 46 | end 47 | 48 | editor = Thor::LineEditor::Readline.new("Best food: ", limited_to: %w(Apples Chicken Chocolate)) 49 | editor.readline 50 | end 51 | 52 | it "provides path tab completion when given the path option" do 53 | expect(::Readline).to receive(:readline) 54 | expect(::Readline).to receive(:completion_proc=) do |proc| 55 | expect(proc.call("../line_ed").sort).to eq ["../line_editor/", "../line_editor_spec.rb"].sort 56 | end 57 | 58 | editor = Thor::LineEditor::Readline.new("Path to file: ", path: true) 59 | Dir.chdir(File.dirname(__FILE__)) { editor.readline } 60 | end 61 | 62 | it "uses STDIN when asked not to echo input" do 63 | expect($stdout).to receive(:print).with("Password: ") 64 | noecho_stdin = double("noecho_stdin") 65 | expect(noecho_stdin).to receive(:gets).and_return("secret") 66 | expect($stdin).to receive(:noecho).and_yield(noecho_stdin) 67 | editor = Thor::LineEditor::Readline.new("Password: ", echo: false) 68 | expect(editor.readline).to eq("secret") 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/line_editor_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "readline" 3 | 4 | describe Thor::LineEditor, "on a system with Readline support" do 5 | before do 6 | @original_readline = ::Readline 7 | Object.send(:remove_const, :Readline) 8 | ::Readline = double("Readline") 9 | end 10 | 11 | after do 12 | Object.send(:remove_const, :Readline) 13 | ::Readline = @original_readline 14 | end 15 | 16 | describe ".readline" do 17 | it "uses the Readline line editor" do 18 | editor = double("Readline") 19 | expect(Thor::LineEditor::Readline).to receive(:new).with("Enter your name ", {default: "Brian"}).and_return(editor) 20 | expect(editor).to receive(:readline).and_return("George") 21 | expect(Thor::LineEditor.readline("Enter your name ", default: "Brian")).to eq("George") 22 | end 23 | end 24 | end 25 | 26 | describe Thor::LineEditor, "on a system without Readline support" do 27 | before do 28 | @original_readline = ::Readline 29 | Object.send(:remove_const, :Readline) 30 | end 31 | 32 | after do 33 | ::Readline = @original_readline 34 | end 35 | 36 | describe ".readline" do 37 | it "uses the Basic line editor" do 38 | editor = double("Basic") 39 | expect(Thor::LineEditor::Basic).to receive(:new).with("Enter your name ", {default: "Brian"}).and_return(editor) 40 | expect(editor).to receive(:readline).and_return("George") 41 | expect(Thor::LineEditor.readline("Enter your name ", default: "Brian")).to eq("George") 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/nested_context_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor::NestedContext do 4 | subject(:context) { described_class.new } 5 | 6 | describe "#enter" do 7 | it "is never empty within the entered block" do 8 | context.enter do 9 | context.enter {} 10 | 11 | expect(context).to be_entered 12 | end 13 | end 14 | 15 | it "is empty when outside of all blocks" do 16 | context.enter { context.enter {} } 17 | expect(context).not_to be_entered 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/no_warnings_spec.rb: -------------------------------------------------------------------------------- 1 | require "open3" 2 | 3 | context "when $VERBOSE is enabled" do 4 | it "prints no warnings" do 5 | root = File.expand_path("..", __dir__) 6 | _, err, = Open3.capture3("ruby -I #{root}/lib #{root}/spec/fixtures/verbose.thor") 7 | 8 | expect(err).to be_empty 9 | end 10 | 11 | it "prints no warnings even when erroring" do 12 | root = File.expand_path("..", __dir__) 13 | _, err, = Open3.capture3("ruby -I #{root}/lib #{root}/spec/fixtures/verbose.thor noop") 14 | expect(err).to_not match(/warning:/) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/parser/argument_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/parser" 3 | 4 | describe Thor::Argument do 5 | def argument(name, options = {}) 6 | @argument ||= Thor::Argument.new(name, options) 7 | end 8 | 9 | describe "errors" do 10 | it "raises an error if name is not supplied" do 11 | expect do 12 | argument(nil) 13 | end.to raise_error(ArgumentError, "Argument name can't be nil.") 14 | end 15 | 16 | it "raises an error if type is unknown" do 17 | expect do 18 | argument(:command, type: :unknown) 19 | end.to raise_error(ArgumentError, "Type :unknown is not valid for arguments.") 20 | end 21 | 22 | it "raises an error if argument is required and has default values" do 23 | expect do 24 | argument(:command, type: :string, default: "bar", required: true) 25 | end.to raise_error(ArgumentError, "An argument cannot be required and have default value.") 26 | end 27 | 28 | it "raises an error if enum isn't enumerable" do 29 | expect do 30 | argument(:command, type: :string, enum: "bar") 31 | end.to raise_error(ArgumentError, "An argument cannot have an enum other than an enumerable.") 32 | end 33 | end 34 | 35 | describe "#usage" do 36 | it "returns usage for string types" do 37 | expect(argument(:foo, type: :string).usage).to eq("FOO") 38 | end 39 | 40 | it "returns usage for numeric types" do 41 | expect(argument(:foo, type: :numeric).usage).to eq("N") 42 | end 43 | 44 | it "returns usage for array types" do 45 | expect(argument(:foo, type: :array).usage).to eq("one two three") 46 | end 47 | 48 | it "returns usage for hash types" do 49 | expect(argument(:foo, type: :hash).usage).to eq("key:value") 50 | end 51 | end 52 | 53 | describe "#print_default" do 54 | it "prints arrays in a copy pasteable way" do 55 | expect(argument(:foo, { 56 | required: false, 57 | type: :array, 58 | default: ["one","two"] 59 | }).print_default).to eq('"one" "two"') 60 | end 61 | it "prints arrays with a single string default as before" do 62 | expect(argument(:foo, { 63 | required: false, 64 | type: :array, 65 | default: "foobar" 66 | }).print_default).to eq("foobar") 67 | end 68 | it "prints none arrays as default" do 69 | expect(argument(:foo, { 70 | required: false, 71 | type: :numeric, 72 | default: 13, 73 | }).print_default).to eq(13) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/parser/arguments_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/parser" 3 | 4 | describe Thor::Arguments do 5 | def create(opts = {}) 6 | arguments = opts.map do |type, default| 7 | options = {required: default.nil?, type: type, default: default} 8 | Thor::Argument.new(type.to_s, options) 9 | end 10 | 11 | arguments.sort! { |a, b| b.name <=> a.name } 12 | @opt = Thor::Arguments.new(arguments) 13 | end 14 | 15 | def parse(*args) 16 | @opt.parse(args) 17 | end 18 | 19 | describe "#parse" do 20 | it "parses arguments in the given order" do 21 | create string: nil, numeric: nil 22 | expect(parse("name", "13")["string"]).to eq("name") 23 | expect(parse("name", "13")["numeric"]).to eq(13) 24 | expect(parse("name", "+13")["numeric"]).to eq(13) 25 | expect(parse("name", "+13.3")["numeric"]).to eq(13.3) 26 | expect(parse("name", "-13")["numeric"]).to eq(-13) 27 | expect(parse("name", "-13.3")["numeric"]).to eq(-13.3) 28 | end 29 | 30 | it "accepts hashes" do 31 | create string: nil, hash: nil 32 | expect(parse("product", "title:string", "age:integer")["string"]).to eq("product") 33 | expect(parse("product", "title:string", "age:integer")["hash"]).to eq("title" => "string", "age" => "integer") 34 | expect(parse("product", "url:http://www.amazon.com/gp/product/123")["hash"]).to eq("url" => "http://www.amazon.com/gp/product/123") 35 | end 36 | 37 | it "accepts arrays" do 38 | create string: nil, array: nil 39 | expect(parse("product", "title", "age")["string"]).to eq("product") 40 | expect(parse("product", "title", "age")["array"]).to eq(%w(title age)) 41 | end 42 | 43 | it "accepts - as an array argument" do 44 | create array: nil 45 | expect(parse("-")["array"]).to eq(%w(-)) 46 | expect(parse("-", "title", "-")["array"]).to eq(%w(- title -)) 47 | end 48 | 49 | describe "with no inputs" do 50 | it "and no arguments returns an empty hash" do 51 | create 52 | expect(parse).to eq({}) 53 | end 54 | 55 | it "and required arguments raises an error" do 56 | create string: nil, numeric: nil 57 | expect { parse }.to raise_error(Thor::RequiredArgumentMissingError, "No value provided for required arguments 'string', 'numeric'") 58 | end 59 | 60 | it "and default arguments returns default values" do 61 | create string: "name", numeric: 13 62 | expect(parse).to eq("string" => "name", "numeric" => 13) 63 | end 64 | end 65 | 66 | it "returns the input if it's already parsed" do 67 | create string: nil, hash: nil, array: nil, numeric: nil 68 | expect(parse("", 0, {}, [])).to eq("string" => "", "numeric" => 0, "hash" => {}, "array" => []) 69 | end 70 | 71 | it "returns the default value if none is provided" do 72 | create string: "foo", numeric: 3.0 73 | expect(parse("bar")).to eq("string" => "bar", "numeric" => 3.0) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/quality_spec.rb: -------------------------------------------------------------------------------- 1 | describe "The library itself" do 2 | def check_for_spec_defs_with_single_quotes(filename) 3 | failing_lines = [] 4 | 5 | File.readlines(filename).each_with_index do |line, number| 6 | failing_lines << number + 1 if line =~ /^ *(describe|it|context) {1}'{1}/ 7 | end 8 | 9 | "#{filename} uses inconsistent single quotes on lines #{failing_lines.join(', ')}" unless failing_lines.empty? 10 | end 11 | 12 | def check_for_tab_characters(filename) 13 | failing_lines = [] 14 | File.readlines(filename).each_with_index do |line, number| 15 | failing_lines << number + 1 if line =~ /\t/ 16 | end 17 | 18 | "#{filename} has tab characters on lines #{failing_lines.join(', ')}" unless failing_lines.empty? 19 | end 20 | 21 | def check_for_extra_spaces(filename) 22 | failing_lines = [] 23 | File.readlines(filename).each_with_index do |line, number| 24 | next if line =~ /^\s+#.*\s+\n$/ 25 | failing_lines << number + 1 if line =~ /\s+\n$/ 26 | end 27 | 28 | "#{filename} has spaces on the EOL on lines #{failing_lines.join(', ')}" unless failing_lines.empty? 29 | end 30 | 31 | RSpec::Matchers.define :be_well_formed do 32 | failure_message do |actual| 33 | actual.join("\n") 34 | end 35 | 36 | match(&:empty?) 37 | end 38 | 39 | it "has no malformed whitespace" do 40 | exempt = /\.gitmodules|\.marshal|fixtures|vendor|spec|ssl_certs|LICENSE|.devcontainer/ 41 | error_messages = [] 42 | Dir.chdir(File.expand_path("../..", __FILE__)) do 43 | `git ls-files`.split("\n").each do |filename| 44 | next if filename =~ exempt 45 | error_messages << check_for_tab_characters(filename) 46 | error_messages << check_for_extra_spaces(filename) 47 | end 48 | end 49 | expect(error_messages.compact).to be_well_formed 50 | end 51 | 52 | it "uses double-quotes consistently in specs" do 53 | included = /spec/ 54 | error_messages = [] 55 | Dir.chdir(File.expand_path("../", __FILE__)) do 56 | `git ls-files`.split("\n").each do |filename| 57 | next unless filename =~ included 58 | error_messages << check_for_spec_defs_with_single_quotes(filename) 59 | end 60 | end 61 | expect(error_messages.compact).to be_well_formed 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/rake_compat_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | require "thor/rake_compat" 3 | require "rake/tasklib" 4 | 5 | $main = self 6 | 7 | class RakeTask < Rake::TaskLib 8 | def initialize 9 | define 10 | end 11 | 12 | def define 13 | $main.instance_eval do 14 | desc "Say it's cool" 15 | task :cool do 16 | puts "COOL" 17 | end 18 | 19 | namespace :hiper_mega do 20 | task :super do 21 | puts "HIPER MEGA SUPER" 22 | end 23 | end 24 | end 25 | end 26 | end 27 | 28 | class ThorTask < Thor 29 | include Thor::RakeCompat 30 | RakeTask.new 31 | end 32 | 33 | describe Thor::RakeCompat do 34 | it "sets the rakefile application" do 35 | expect(%w(rake_compat_spec.rb Thorfile)).to include(Rake.application.rakefile) 36 | end 37 | 38 | it "adds rake tasks to thor classes too" do 39 | task = ThorTask.tasks["cool"] 40 | expect(task).to be 41 | end 42 | 43 | it "uses rake tasks descriptions on thor" do 44 | expect(ThorTask.tasks["cool"].description).to eq("Say it's cool") 45 | end 46 | 47 | it "gets usage from rake tasks name" do 48 | expect(ThorTask.tasks["cool"].usage).to eq("cool") 49 | end 50 | 51 | it "uses non namespaced name as description if non is available" do 52 | expect(ThorTask::HiperMega.tasks["super"].description).to eq("super") 53 | end 54 | 55 | it "converts namespaces to classes" do 56 | expect(ThorTask.const_get(:HiperMega)).to eq(ThorTask::HiperMega) 57 | end 58 | 59 | it "does not add tasks from higher namespaces in lowers namespaces" do 60 | expect(ThorTask.tasks["super"]).not_to be 61 | end 62 | 63 | it "invoking the thor task invokes the rake task" do 64 | expect(capture(:stdout) do 65 | ThorTask.start %w(cool) 66 | end).to eq("COOL\n") 67 | 68 | expect(capture(:stdout) do 69 | ThorTask::HiperMega.start %w(super) 70 | end).to eq("HIPER MEGA SUPER\n") 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/register_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | class BoringVendorProvidedCLI < Thor 4 | desc "boring", "do boring stuff" 5 | def boring 6 | puts "bored. " 7 | end 8 | end 9 | 10 | class ExcitingPluginCLI < Thor 11 | desc "hooray", "say hooray!" 12 | def hooray 13 | puts "hooray!" 14 | end 15 | 16 | desc "fireworks", "exciting fireworks!" 17 | def fireworks 18 | puts "kaboom!" 19 | end 20 | end 21 | 22 | class SuperSecretPlugin < Thor 23 | default_command :squirrel 24 | 25 | desc "squirrel", "All of secret squirrel's secrets" 26 | def squirrel 27 | puts "I love nuts" 28 | end 29 | end 30 | 31 | class GroupPlugin < Thor::Group 32 | desc "part one" 33 | def part_one 34 | puts "part one" 35 | end 36 | 37 | desc "part two" 38 | def part_two 39 | puts "part two" 40 | end 41 | end 42 | 43 | class ClassOptionGroupPlugin < Thor::Group 44 | class_option :who, 45 | type: :string, 46 | aliases: "-w", 47 | default: "zebra" 48 | end 49 | 50 | class PluginInheritingFromClassOptionsGroup < ClassOptionGroupPlugin 51 | desc "animal" 52 | def animal 53 | p options[:who] 54 | end 55 | end 56 | 57 | class PluginWithDefault < Thor 58 | desc "say MSG", "print MSG" 59 | def say(msg) 60 | puts msg 61 | end 62 | 63 | default_command :say 64 | end 65 | 66 | class PluginWithDefaultMultipleArguments < Thor 67 | desc "say MSG [MSG]", "print multiple messages" 68 | def say(*args) 69 | puts args 70 | end 71 | 72 | default_command :say 73 | end 74 | 75 | class PluginWithDefaultcommandAndDeclaredArgument < Thor 76 | desc "say MSG [MSG]", "print multiple messages" 77 | argument :msg 78 | def say 79 | puts msg 80 | end 81 | 82 | default_command :say 83 | end 84 | 85 | class SubcommandWithDefault < Thor 86 | default_command :default 87 | 88 | desc "default", "default subcommand" 89 | def default 90 | puts "default" 91 | end 92 | 93 | desc "with_args", "subcommand with arguments" 94 | def with_args(*args) 95 | puts "received arguments: " + args.join(",") 96 | end 97 | end 98 | 99 | BoringVendorProvidedCLI.register( 100 | ExcitingPluginCLI, 101 | "exciting", 102 | "do exciting things", 103 | "Various non-boring actions" 104 | ) 105 | 106 | BoringVendorProvidedCLI.register( 107 | SuperSecretPlugin, 108 | "secret", 109 | "secret stuff", 110 | "Nothing to see here. Move along.", 111 | hide: true 112 | ) 113 | 114 | BoringVendorProvidedCLI.register( 115 | GroupPlugin, 116 | "groupwork", 117 | "Do a bunch of things in a row", 118 | "purple monkey dishwasher" 119 | ) 120 | 121 | BoringVendorProvidedCLI.register( 122 | PluginInheritingFromClassOptionsGroup, 123 | "zoo", 124 | "zoo [-w animal]", 125 | "Shows a provided animal or just zebra" 126 | ) 127 | 128 | BoringVendorProvidedCLI.register( 129 | PluginWithDefault, 130 | "say", 131 | "say message", 132 | "subcommands ftw" 133 | ) 134 | 135 | BoringVendorProvidedCLI.register( 136 | PluginWithDefaultMultipleArguments, 137 | "say_multiple", 138 | "say message", 139 | "subcommands ftw" 140 | ) 141 | 142 | BoringVendorProvidedCLI.register( 143 | PluginWithDefaultcommandAndDeclaredArgument, 144 | "say_argument", 145 | "say message", 146 | "subcommands ftw" 147 | ) 148 | 149 | BoringVendorProvidedCLI.register(SubcommandWithDefault, 150 | "subcommand", "subcommand", "Run subcommands") 151 | 152 | describe ".register-ing a Thor subclass" do 153 | it "registers the plugin as a subcommand" do 154 | fireworks_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(exciting fireworks)) } 155 | expect(fireworks_output).to eq("kaboom!\n") 156 | end 157 | 158 | it "includes the plugin's usage in the help" do 159 | help_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(help)) } 160 | expect(help_output).to include("do exciting things") 161 | end 162 | 163 | context "with a default command," do 164 | it "invokes the default command correctly" do 165 | output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(say hello)) } 166 | expect(output).to include("hello") 167 | end 168 | 169 | it "invokes the default command correctly with multiple args" do 170 | output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(say_multiple hello adam)) } 171 | expect(output).to include("hello") 172 | expect(output).to include("adam") 173 | end 174 | 175 | it "invokes the default command correctly with a declared argument" do 176 | output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(say_argument hello)) } 177 | expect(output).to include("hello") 178 | end 179 | 180 | it "displays the subcommand's help message" do 181 | output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(subcommand help)) } 182 | expect(output).to include("default subcommand") 183 | expect(output).to include("subcommand with argument") 184 | end 185 | 186 | it "invokes commands with their actual args" do 187 | output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(subcommand with_args actual_argument)) } 188 | expect(output.strip).to eql("received arguments: actual_argument") 189 | end 190 | end 191 | 192 | context "when $thor_runner is false" do 193 | it "includes the plugin's subcommand name in subcommand's help" do 194 | begin 195 | $thor_runner = false 196 | help_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(exciting)) } 197 | expect(help_output).to include("thor exciting fireworks") 198 | ensure 199 | $thor_runner = true 200 | end 201 | end 202 | end 203 | 204 | context "when hidden" do 205 | it "omits the hidden plugin's usage from the help" do 206 | help_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(help)) } 207 | expect(help_output).not_to include("secret stuff") 208 | end 209 | 210 | it "registers the plugin as a subcommand" do 211 | secret_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(secret squirrel)) } 212 | expect(secret_output).to eq("I love nuts\n") 213 | end 214 | end 215 | end 216 | 217 | describe ".register-ing a Thor::Group subclass" do 218 | it "registers the group as a single command" do 219 | group_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(groupwork)) } 220 | expect(group_output).to eq("part one\npart two\n") 221 | end 222 | end 223 | 224 | describe ".register-ing a Thor::Group subclass with class options" do 225 | it "works w/o command options" do 226 | group_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(zoo)) } 227 | expect(group_output).to match(/zebra/) 228 | end 229 | 230 | it "works w/command options" do 231 | group_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(zoo -w lion)) } 232 | expect(group_output).to match(/lion/) 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /spec/script_exit_status_spec.rb: -------------------------------------------------------------------------------- 1 | describe "when the Thor class's exit_with_failure? method returns true" do 2 | def thor_command(command) 3 | gem_dir= File.expand_path("#{File.dirname(__FILE__)}/..") 4 | lib_path= "#{gem_dir}/lib" 5 | script_path= "#{gem_dir}/spec/fixtures/exit_status.thor" 6 | ruby_lib= ENV["RUBYLIB"].nil? ? lib_path : "#{lib_path}:#{ENV['RUBYLIB']}" 7 | 8 | full_command= "ruby #{script_path} #{command}" 9 | r,w= IO.pipe 10 | pid= spawn({"RUBYLIB" => ruby_lib}, 11 | full_command, 12 | {out: w, err: [:child, :out]}) 13 | w.close 14 | 15 | _, exit_status= Process.wait2(pid) 16 | r.read 17 | r.close 18 | 19 | exit_status.exitstatus 20 | end 21 | 22 | it "a command that raises a Thor::Error exits with a status of 1" do 23 | expect(thor_command("error")).to eq(1) 24 | end 25 | 26 | it "a command that does not raise a Thor::Error exits with a status of 0" do 27 | expect(thor_command("ok")).to eq(0) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/shell/color_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor::Shell::Color do 4 | def shell 5 | @shell ||= Thor::Shell::Color.new 6 | end 7 | 8 | before do 9 | allow($stdout).to receive(:tty?).and_return(true) 10 | allow(ENV).to receive(:[]).and_return(nil) 11 | allow(ENV).to receive(:[]).with("TERM").and_return("ansi") 12 | allow_any_instance_of(StringIO).to receive(:tty?).and_return(true) 13 | end 14 | 15 | describe "#ask" do 16 | it "sets the color if specified and tty?" do 17 | expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? \e[0m", anything).and_return("yes") 18 | shell.ask "Is this green?", :green 19 | 20 | expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? [Yes, No, Maybe] \e[0m", anything).and_return("Yes") 21 | shell.ask "Is this green?", :green, limited_to: %w(Yes No Maybe) 22 | end 23 | 24 | it "does not set the color if specified and NO_COLOR is set to a non-empty value" do 25 | allow(ENV).to receive(:[]).with("NO_COLOR").and_return("non-empty value") 26 | expect(Thor::LineEditor).to receive(:readline).with("Is this green? ", anything).and_return("yes") 27 | shell.ask "Is this green?", :green 28 | 29 | expect(Thor::LineEditor).to receive(:readline).with("Is this green? [Yes, No, Maybe] ", anything).and_return("Yes") 30 | shell.ask "Is this green?", :green, limited_to: %w(Yes No Maybe) 31 | end 32 | 33 | it "sets the color when NO_COLOR is ignored because the environment variable is nil" do 34 | allow(ENV).to receive(:[]).with("NO_COLOR").and_return(nil) 35 | expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? \e[0m", anything).and_return("yes") 36 | shell.ask "Is this green?", :green 37 | 38 | expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? [Yes, No, Maybe] \e[0m", anything).and_return("Yes") 39 | shell.ask "Is this green?", :green, limited_to: %w(Yes No Maybe) 40 | end 41 | 42 | it "sets the color when NO_COLOR is ignored because the environment variable is an empty-string" do 43 | allow(ENV).to receive(:[]).with("NO_COLOR").and_return("") 44 | expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? \e[0m", anything).and_return("yes") 45 | shell.ask "Is this green?", :green 46 | 47 | expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? [Yes, No, Maybe] \e[0m", anything).and_return("Yes") 48 | shell.ask "Is this green?", :green, limited_to: %w(Yes No Maybe) 49 | end 50 | 51 | it "handles an Array of colors" do 52 | expect(Thor::LineEditor).to receive(:readline).with("\e[32m\e[47m\e[1mIs this green on white? \e[0m", anything).and_return("yes") 53 | shell.ask "Is this green on white?", [:green, :on_white, :bold] 54 | end 55 | 56 | it "supports the legacy color syntax" do 57 | expect(Thor::LineEditor).to receive(:readline).with("\e[1m\e[34mIs this legacy blue? \e[0m", anything).and_return("yes") 58 | shell.ask "Is this legacy blue?", [:blue, true] 59 | end 60 | end 61 | 62 | describe "#say" do 63 | it "set the color if specified and tty?" do 64 | out = capture(:stdout) do 65 | shell.say "Wow! Now we have colors!", :green 66 | end 67 | 68 | expect(out.chomp).to eq("\e[32mWow! Now we have colors!\e[0m") 69 | end 70 | 71 | it "does not set the color if output is not a tty" do 72 | out = capture(:stdout) do 73 | expect($stdout).to receive(:tty?).and_return(false) 74 | shell.say "Wow! Now we have colors!", :green 75 | end 76 | 77 | expect(out.chomp).to eq("Wow! Now we have colors!") 78 | end 79 | 80 | it "does not set the color if NO_COLOR is set to any value that is not an empty string" do 81 | allow(ENV).to receive(:[]).with("NO_COLOR").and_return("non-empty string value") 82 | out = capture(:stdout) do 83 | shell.say "NO_COLOR is enforced! We should not have colors!", :green 84 | end 85 | 86 | expect(out.chomp).to eq("NO_COLOR is enforced! We should not have colors!") 87 | end 88 | 89 | it "colors are still used and NO_COLOR is ignored if the environment variable is nil" do 90 | allow(ENV).to receive(:[]).with("NO_COLOR").and_return(nil) 91 | out = capture(:stdout) do 92 | shell.say "NO_COLOR is ignored! We have colors!", :green 93 | end 94 | 95 | expect(out.chomp).to eq("\e[32mNO_COLOR is ignored! We have colors!\e[0m") 96 | end 97 | 98 | it "colors are still used and NO_COLOR is ignored if the environment variable is an empty-string" do 99 | allow(ENV).to receive(:[]).with("NO_COLOR").and_return("") 100 | out = capture(:stdout) do 101 | shell.say "NO_COLOR is ignored! We have colors!", :green 102 | end 103 | 104 | expect(out.chomp).to eq("\e[32mNO_COLOR is ignored! We have colors!\e[0m") 105 | end 106 | 107 | it "does not use a new line even with colors" do 108 | out = capture(:stdout) do 109 | shell.say "Wow! Now we have colors! ", :green 110 | end 111 | 112 | expect(out.chomp).to eq("\e[32mWow! Now we have colors! \e[0m") 113 | end 114 | 115 | it "handles an Array of colors" do 116 | out = capture(:stdout) do 117 | shell.say "Wow! Now we have colors *and* background colors", [:green, :on_red, :bold] 118 | end 119 | 120 | expect(out.chomp).to eq("\e[32m\e[41m\e[1mWow! Now we have colors *and* background colors\e[0m") 121 | end 122 | 123 | it "supports the legacy color syntax" do 124 | out = capture(:stdout) do 125 | shell.say "Wow! This still works?", [:blue, true] 126 | end 127 | 128 | expect(out.chomp).to eq("\e[1m\e[34mWow! This still works?\e[0m") 129 | end 130 | end 131 | 132 | describe "#say_status" do 133 | it "uses color to say status" do 134 | out = capture(:stdout) do 135 | shell.say_status :conflict, "README", :red 136 | end 137 | 138 | expect(out.chomp).to eq("\e[1m\e[31m conflict\e[0m README") 139 | end 140 | end 141 | 142 | describe "#set_color" do 143 | it "colors a string with a foreground color" do 144 | red = shell.set_color "hi!", :red 145 | expect(red).to eq("\e[31mhi!\e[0m") 146 | end 147 | 148 | it "colors a string with a background color" do 149 | on_red = shell.set_color "hi!", :white, :on_red 150 | expect(on_red).to eq("\e[37m\e[41mhi!\e[0m") 151 | end 152 | 153 | it "colors a string with a bold color" do 154 | bold = shell.set_color "hi!", :white, true 155 | expect(bold).to eq("\e[1m\e[37mhi!\e[0m") 156 | 157 | bold = shell.set_color "hi!", :white, :bold 158 | expect(bold).to eq("\e[37m\e[1mhi!\e[0m") 159 | 160 | bold = shell.set_color "hi!", :white, :on_red, :bold 161 | expect(bold).to eq("\e[37m\e[41m\e[1mhi!\e[0m") 162 | end 163 | 164 | it "does nothing when there are no colors" do 165 | colorless = shell.set_color "hi!", nil 166 | expect(colorless).to eq("hi!") 167 | 168 | colorless = shell.set_color "hi!" 169 | expect(colorless).to eq("hi!") 170 | end 171 | 172 | it "does nothing when stdout is not a tty" do 173 | allow($stdout).to receive(:tty?).and_return(false) 174 | colorless = shell.set_color "hi!", :white 175 | expect(colorless).to eq("hi!") 176 | end 177 | 178 | it "does nothing when the TERM environment variable is set to 'dumb'" do 179 | allow(ENV).to receive(:[]).with("TERM").and_return("dumb") 180 | colorless = shell.set_color "hi!", :white 181 | expect(colorless).to eq("hi!") 182 | end 183 | 184 | it "does nothing when the NO_COLOR environment variable is set to a non-empty string" do 185 | allow(ENV).to receive(:[]).with("NO_COLOR").and_return("non-empty value") 186 | allow($stdout).to receive(:tty?).and_return(true) 187 | colorless = shell.set_color "hi!", :white 188 | expect(colorless).to eq("hi!") 189 | end 190 | 191 | it "sets color when the NO_COLOR environment variable is ignored for being nil" do 192 | allow(ENV).to receive(:[]).with("NO_COLOR").and_return(nil) 193 | allow($stdout).to receive(:tty?).and_return(true) 194 | 195 | red = shell.set_color "hi!", :red 196 | expect(red).to eq("\e[31mhi!\e[0m") 197 | 198 | on_red = shell.set_color "hi!", :white, :on_red 199 | expect(on_red).to eq("\e[37m\e[41mhi!\e[0m") 200 | end 201 | 202 | it "sets color when the NO_COLOR environment variable is ignored for being an empty string" do 203 | allow(ENV).to receive(:[]).with("NO_COLOR").and_return("") 204 | allow($stdout).to receive(:tty?).and_return(true) 205 | 206 | red = shell.set_color "hi!", :red 207 | expect(red).to eq("\e[31mhi!\e[0m") 208 | 209 | on_red = shell.set_color "hi!", :white, :on_red 210 | expect(on_red).to eq("\e[37m\e[41mhi!\e[0m") 211 | end 212 | end 213 | 214 | describe "#file_collision" do 215 | describe "when a block is given" do 216 | it "invokes the diff command" do 217 | allow($stdout).to receive(:print) 218 | allow($stdout).to receive(:tty?).and_return(true) 219 | expect(Thor::LineEditor).to receive(:readline).and_return("d", "n") 220 | 221 | output = capture(:stdout) { shell.file_collision("spec/fixtures/doc/README") { "README\nEND\n" } } 222 | expect(output).to match(/\e\[31m\- __start__\e\[0m/) 223 | expect(output).to match(/^ README/) 224 | expect(output).to match(/\e\[32m\+ END\e\[0m/) 225 | end 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /spec/shell/html_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor::Shell::HTML do 4 | def shell 5 | @shell ||= Thor::Shell::HTML.new 6 | end 7 | 8 | describe "#say" do 9 | it "sets the color if specified" do 10 | out = capture(:stdout) { shell.say "Wow! Now we have colors!", :green } 11 | expect(out.chomp).to eq('Wow! Now we have colors!') 12 | end 13 | 14 | it "sets bold if specified" do 15 | out = capture(:stdout) { shell.say "Wow! Now we have colors *and* bold!", [:green, :bold] } 16 | expect(out.chomp).to eq('Wow! Now we have colors *and* bold!') 17 | end 18 | 19 | it "does not use a new line even with colors" do 20 | out = capture(:stdout) { shell.say "Wow! Now we have colors! ", :green } 21 | expect(out.chomp).to eq('Wow! Now we have colors! ') 22 | end 23 | end 24 | 25 | describe "#say_status" do 26 | it "uses color to say status" do 27 | expect($stdout).to receive(:print).with(" conflict README\n") 28 | shell.say_status :conflict, "README", :red 29 | end 30 | end 31 | 32 | describe "#set_color" do 33 | it "escapes HTML content when using the default colors" do 34 | expect(shell.set_color("", :blue)).to eq "<htmlcontent>" 35 | end 36 | 37 | it "escapes HTML content when not using the default colors" do 38 | expect(shell.set_color("", [:nocolor])).to eq "<htmlcontent>" 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/shell_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor::Shell do 4 | def shell 5 | @shell ||= Thor::Base.shell.new 6 | end 7 | 8 | describe "#initialize" do 9 | it "sets shell value" do 10 | base = MyCounter.new [1, 2], {}, shell: shell 11 | expect(base.shell).to eq(shell) 12 | end 13 | 14 | it "sets the base value on the shell if an accessor is available" do 15 | base = MyCounter.new [1, 2], {}, shell: shell 16 | expect(shell.base).to eq(base) 17 | end 18 | end 19 | 20 | describe "#shell" do 21 | it "returns the shell in use" do 22 | expect(MyCounter.new([1, 2]).shell).to be_kind_of(Thor::Base.shell) 23 | end 24 | 25 | it "uses $THOR_SHELL" do 26 | class Thor::Shell::TestShell < Thor::Shell::Basic; end 27 | 28 | expect(Thor::Base.shell).to eq(shell.class) 29 | ENV["THOR_SHELL"] = "TestShell" 30 | Thor::Base.shell = nil 31 | expect(Thor::Base.shell).to eq(Thor::Shell::TestShell) 32 | ENV["THOR_SHELL"] = "" 33 | Thor::Base.shell = shell.class 34 | expect(Thor::Base.shell).to eq(shell.class) 35 | end 36 | end 37 | 38 | describe "with_padding" do 39 | it "uses padding for inside block outputs" do 40 | base = MyCounter.new([1, 2]) 41 | base.with_padding do 42 | expect(capture(:stdout) { base.say_status :padding, "cool" }.strip).to eq("padding cool") 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/sort_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor do 4 | def shell 5 | @shell ||= Thor::Base.shell.new 6 | end 7 | 8 | describe "#sort - default" do 9 | my_script = Class.new(Thor) do 10 | desc "a", "First Command" 11 | def a; end 12 | 13 | desc "z", "Last Command" 14 | def z; end 15 | end 16 | 17 | before do 18 | @content = capture(:stdout) { my_script.help(shell) } 19 | end 20 | 21 | it "sorts them lexicographillay" do 22 | expect(@content).to match(/:a.+:help.+:z/m) 23 | end 24 | end 25 | 26 | 27 | describe "#sort - simple override" do 28 | my_script = Class.new(Thor) do 29 | desc "a", "First Command" 30 | def a; end 31 | 32 | desc "z", "Last Command" 33 | def z; end 34 | 35 | def self.sort_commands!(list) 36 | list.sort! 37 | list.reverse! 38 | end 39 | 40 | end 41 | 42 | before do 43 | @content = capture(:stdout) { my_script.help(shell) } 44 | end 45 | 46 | it "sorts them in reverse" do 47 | expect(@content).to match(/:z.+:help.+:a/m) 48 | end 49 | end 50 | 51 | 52 | describe "#sort - simple override" do 53 | my_script = Class.new(Thor) do 54 | desc "a", "First Command" 55 | def a; end 56 | 57 | desc "z", "Last Command" 58 | def z; end 59 | 60 | def self.sort_commands!(list) 61 | list.sort_by! do |a,b| 62 | a[0] == :help ? -1 : a[0] <=> b[0] 63 | end 64 | end 65 | end 66 | 67 | before do 68 | @content = capture(:stdout) { my_script.help(shell) } 69 | end 70 | 71 | it "puts help first then sorts them lexicographillay" do 72 | expect(@content).to match(/:help.+:a.+:z/m) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/subcommand_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe Thor do 4 | describe "#subcommand" do 5 | it "maps a given subcommand to another Thor subclass" do 6 | barn_help = capture(:stdout) { Scripts::MyDefaults.start(%w(barn)) } 7 | expect(barn_help).to include("barn help [COMMAND] # Describe subcommands or one specific subcommand") 8 | end 9 | 10 | it "passes commands to subcommand classes" do 11 | expect(capture(:stdout) { Scripts::MyDefaults.start(%w(barn open)) }.strip).to eq("Open sesame!") 12 | end 13 | 14 | it "passes arguments to subcommand classes" do 15 | expect(capture(:stdout) { Scripts::MyDefaults.start(%w(barn open shotgun)) }.strip).to eq("That's going to leave a mark.") 16 | end 17 | 18 | it "ignores unknown options (the subcommand class will handle them)" do 19 | expect(capture(:stdout) { Scripts::MyDefaults.start(%w(barn paint blue --coats 4)) }.strip).to eq("4 coats of blue paint") 20 | end 21 | 22 | it "passes parsed options to subcommands" do 23 | output = capture(:stdout) { TestSubcommands::Parent.start(%w(sub print_opt --opt output)) } 24 | expect(output).to eq("output") 25 | end 26 | 27 | it "accepts the help switch and calls the help command on the subcommand" do 28 | output = capture(:stdout) { TestSubcommands::Parent.start(%w(sub print_opt --help)) } 29 | sub_help = capture(:stdout) { TestSubcommands::Parent.start(%w(sub help print_opt)) } 30 | expect(output).to eq(sub_help) 31 | end 32 | 33 | it "accepts the help short switch and calls the help command on the subcommand" do 34 | output = capture(:stdout) { TestSubcommands::Parent.start(%w(sub print_opt -h)) } 35 | sub_help = capture(:stdout) { TestSubcommands::Parent.start(%w(sub help print_opt)) } 36 | expect(output).to eq(sub_help) 37 | end 38 | 39 | it "the help command on the subcommand and after it should result in the same output" do 40 | output = capture(:stdout) { TestSubcommands::Parent.start(%w(sub help)) } 41 | sub_help = capture(:stdout) { TestSubcommands::Parent.start(%w(help sub)) } 42 | expect(output).to eq(sub_help) 43 | end 44 | end 45 | 46 | context "subcommand with an arg" do 47 | module SubcommandTest1 48 | class Child1 < Thor 49 | desc "foo NAME", "Fooo" 50 | def foo(name) 51 | puts "#{name} was given" 52 | end 53 | end 54 | 55 | class Parent < Thor 56 | desc "child1", "child1 description" 57 | subcommand "child1", Child1 58 | 59 | def self.exit_on_failure? 60 | false 61 | end 62 | end 63 | end 64 | 65 | it "shows subcommand name and method name" do 66 | sub_help = capture(:stderr) { SubcommandTest1::Parent.start(%w(child1 foo)) } 67 | expect(sub_help).to eq ['ERROR: "thor child1 foo" was called with no arguments', 'Usage: "thor child1 foo NAME"', ""].join("\n") 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/util_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | module Thor::Util 4 | def self.clear_user_home! 5 | @@user_home = nil 6 | end 7 | end 8 | 9 | describe Thor::Util do 10 | describe "#find_by_namespace" do 11 | it "returns 'default' if no namespace is given" do 12 | expect(Thor::Util.find_by_namespace("")).to eq(Scripts::MyDefaults) 13 | end 14 | 15 | it "adds 'default' if namespace starts with :" do 16 | expect(Thor::Util.find_by_namespace(":child")).to eq(Scripts::ChildDefault) 17 | end 18 | 19 | it "returns nil if the namespace can't be found" do 20 | expect(Thor::Util.find_by_namespace("thor:core_ext:hash_with_indifferent_access")).to be nil 21 | end 22 | 23 | it "returns a class if it matches the namespace" do 24 | expect(Thor::Util.find_by_namespace("app:broken:counter")).to eq(BrokenCounter) 25 | end 26 | 27 | it "matches classes default namespace" do 28 | expect(Thor::Util.find_by_namespace("scripts:my_script")).to eq(Scripts::MyScript) 29 | end 30 | end 31 | 32 | describe "#namespace_from_thor_class" do 33 | it "replaces constant nesting with command namespacing" do 34 | expect(Thor::Util.namespace_from_thor_class("Foo::Bar::Baz")).to eq("foo:bar:baz") 35 | end 36 | 37 | it "snake-cases component strings" do 38 | expect(Thor::Util.namespace_from_thor_class("FooBar::BarBaz::BazBoom")).to eq("foo_bar:bar_baz:baz_boom") 39 | end 40 | 41 | it "accepts class and module objects" do 42 | expect(Thor::Util.namespace_from_thor_class(Thor::CoreExt::HashWithIndifferentAccess)).to eq("thor:core_ext:hash_with_indifferent_access") 43 | expect(Thor::Util.namespace_from_thor_class(Thor::Util)).to eq("thor:util") 44 | end 45 | 46 | it "removes Thor::Sandbox namespace" do 47 | expect(Thor::Util.namespace_from_thor_class("Thor::Sandbox::Package")).to eq("package") 48 | end 49 | end 50 | 51 | describe "#namespaces_in_content" do 52 | it "returns an array of names of constants defined in the string" do 53 | list = Thor::Util.namespaces_in_content("class Foo; class Bar < Thor; end; end; class Baz; class Bat; end; end") 54 | expect(list).to include("foo:bar") 55 | expect(list).not_to include("bar:bat") 56 | end 57 | 58 | it "doesn't put the newly-defined constants in the enclosing namespace" do 59 | Thor::Util.namespaces_in_content("class Blat; end") 60 | expect(defined?(Blat)).not_to be 61 | expect(defined?(Thor::Sandbox::Blat)).to be 62 | end 63 | end 64 | 65 | describe "#snake_case" do 66 | it "preserves no-cap strings" do 67 | expect(Thor::Util.snake_case("foo")).to eq("foo") 68 | expect(Thor::Util.snake_case("foo_bar")).to eq("foo_bar") 69 | end 70 | 71 | it "downcases all-caps strings" do 72 | expect(Thor::Util.snake_case("FOO")).to eq("foo") 73 | expect(Thor::Util.snake_case("FOO_BAR")).to eq("foo_bar") 74 | end 75 | 76 | it "downcases initial-cap strings" do 77 | expect(Thor::Util.snake_case("Foo")).to eq("foo") 78 | end 79 | 80 | it "replaces camel-casing with underscores" do 81 | expect(Thor::Util.snake_case("FooBarBaz")).to eq("foo_bar_baz") 82 | expect(Thor::Util.snake_case("Foo_BarBaz")).to eq("foo_bar_baz") 83 | end 84 | 85 | it "places underscores between multiple capitals" do 86 | expect(Thor::Util.snake_case("ABClass")).to eq("a_b_class") 87 | end 88 | end 89 | 90 | describe "#find_class_and_command_by_namespace" do 91 | it "returns a Thor::Group class if full namespace matches" do 92 | expect(Thor::Util.find_class_and_command_by_namespace("my_counter")).to eq([MyCounter, nil]) 93 | end 94 | 95 | it "returns a Thor class if full namespace matches" do 96 | expect(Thor::Util.find_class_and_command_by_namespace("thor")).to eq([Thor, nil]) 97 | end 98 | 99 | it "returns a Thor class and the command name" do 100 | expect(Thor::Util.find_class_and_command_by_namespace("thor:help")).to eq([Thor, "help"]) 101 | end 102 | 103 | it "falls back in the namespace:command look up even if a full namespace does not match" do 104 | Thor.const_set(:Help, Module.new) 105 | expect(Thor::Util.find_class_and_command_by_namespace("thor:help")).to eq([Thor, "help"]) 106 | Thor.send :remove_const, :Help 107 | end 108 | 109 | it "falls back on the default namespace class if nothing else matches" do 110 | expect(Thor::Util.find_class_and_command_by_namespace("test")).to eq([Scripts::MyDefaults, "test"]) 111 | end 112 | 113 | it "returns correct Thor class and the command name when shared namespaces" do 114 | expect(Thor::Util.find_class_and_command_by_namespace("fruits:apple")).to eq([Apple, "apple"]) 115 | expect(Thor::Util.find_class_and_command_by_namespace("fruits:pear")).to eq([Pear, "pear"]) 116 | end 117 | 118 | it "returns correct Thor class and the command name with hypen when shared namespaces" do 119 | expect(Thor::Util.find_class_and_command_by_namespace("fruits:rotten-apple")).to eq([Apple, "rotten-apple"]) 120 | end 121 | 122 | it "returns correct Thor class and the associated alias command name when shared namespaces" do 123 | expect(Thor::Util.find_class_and_command_by_namespace("fruits:ra")).to eq([Apple, "ra"]) 124 | end 125 | end 126 | 127 | describe "#thor_classes_in" do 128 | it "returns thor classes inside the given class" do 129 | expect(Thor::Util.thor_classes_in(MyScript)).to eq([MyScript::AnotherScript]) 130 | expect(Thor::Util.thor_classes_in(MyScript::AnotherScript)).to be_empty 131 | end 132 | end 133 | 134 | describe "#user_home" do 135 | before do 136 | allow(ENV).to receive(:[]) 137 | Thor::Util.clear_user_home! 138 | end 139 | 140 | it "returns the user path if no variable is set on the environment" do 141 | expect(Thor::Util.user_home).to eq(File.expand_path("~")) 142 | end 143 | 144 | it "returns the *nix system path if file cannot be expanded and separator does not exist" do 145 | expect(File).to receive(:expand_path).with("~").and_raise(RuntimeError) 146 | previous_value = File::ALT_SEPARATOR 147 | capture(:stderr) { File.const_set(:ALT_SEPARATOR, false) } 148 | expect(Thor::Util.user_home).to eq("/") 149 | capture(:stderr) { File.const_set(:ALT_SEPARATOR, previous_value) } 150 | end 151 | 152 | it "returns the windows system path if file cannot be expanded and a separator exists" do 153 | expect(File).to receive(:expand_path).with("~").and_raise(RuntimeError) 154 | previous_value = File::ALT_SEPARATOR 155 | capture(:stderr) { File.const_set(:ALT_SEPARATOR, true) } 156 | expect(Thor::Util.user_home).to eq("C:/") 157 | capture(:stderr) { File.const_set(:ALT_SEPARATOR, previous_value) } 158 | end 159 | 160 | it "returns HOME/.thor if set" do 161 | allow(ENV).to receive(:[]).with("HOME").and_return("/home/user/") 162 | expect(Thor::Util.user_home).to eq("/home/user/") 163 | end 164 | 165 | it "returns path with HOMEDRIVE and HOMEPATH if set" do 166 | allow(ENV).to receive(:[]).with("HOMEDRIVE").and_return("D:/") 167 | allow(ENV).to receive(:[]).with("HOMEPATH").and_return("Documents and Settings/James") 168 | expect(Thor::Util.user_home).to eq("D:/Documents and Settings/James") 169 | end 170 | 171 | it "returns APPDATA/.thor if set" do 172 | allow(ENV).to receive(:[]).with("APPDATA").and_return("/home/user/") 173 | expect(Thor::Util.user_home).to eq("/home/user/") 174 | end 175 | end 176 | 177 | describe "#thor_root_glob" do 178 | before do 179 | allow(ENV).to receive(:[]) 180 | Thor::Util.clear_user_home! 181 | end 182 | 183 | it "escapes globs in path" do 184 | allow(ENV).to receive(:[]).with("HOME").and_return("/home/user{1}/") 185 | expect(Dir).to receive(:[]).with('/home/user\\{1\\}/.thor/*').and_return([]) 186 | expect(Thor::Util.thor_root_glob).to eq([]) 187 | end 188 | end 189 | 190 | describe "#globs_for" do 191 | it "escapes globs in path" do 192 | expect(Thor::Util.globs_for("/home/apps{1}")).to eq([ 193 | '/home/apps\\{1\\}/Thorfile', 194 | '/home/apps\\{1\\}/*.thor', 195 | '/home/apps\\{1\\}/tasks/*.thor', 196 | '/home/apps\\{1\\}/lib/tasks/**/*.thor' 197 | ]) 198 | end 199 | end 200 | 201 | describe "#escape_globs" do 202 | it "escapes ? * { } [ ] glob characters" do 203 | expect(Thor::Util.escape_globs("apps?")).to eq('apps\\?') 204 | expect(Thor::Util.escape_globs("apps*")).to eq('apps\\*') 205 | expect(Thor::Util.escape_globs("apps {1}")).to eq('apps \\{1\\}') 206 | expect(Thor::Util.escape_globs("apps [1]")).to eq('apps \\[1\\]') 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /thor.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib/", __FILE__) 3 | $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) 4 | require "thor/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "thor" 8 | spec.version = Thor::VERSION 9 | spec.licenses = %w(MIT) 10 | spec.authors = ["Yehuda Katz", "José Valim"] 11 | spec.email = "ruby-thor@googlegroups.com" 12 | spec.homepage = "http://whatisthor.com/" 13 | spec.description = "Thor is a toolkit for building powerful command-line interfaces." 14 | spec.summary = spec.description 15 | 16 | spec.metadata = { 17 | "bug_tracker_uri" => "https://github.com/rails/thor/issues", 18 | "changelog_uri" => "https://github.com/rails/thor/releases/tag/v#{Thor::VERSION}", 19 | "documentation_uri" => "http://whatisthor.com/", 20 | "source_code_uri" => "https://github.com/rails/thor/tree/v#{Thor::VERSION}", 21 | "wiki_uri" => "https://github.com/rails/thor/wiki", 22 | "rubygems_mfa_required" => "true", 23 | } 24 | 25 | spec.required_ruby_version = ">= 2.6.0" 26 | spec.required_rubygems_version = ">= 1.3.5" 27 | 28 | spec.files = %w(.document thor.gemspec) + Dir["*.md", "bin/*", "lib/**/*.rb"] 29 | spec.executables = %w(thor) 30 | spec.require_paths = %w(lib) 31 | 32 | spec.add_development_dependency "bundler", ">= 1.0", "< 3" 33 | end 34 | --------------------------------------------------------------------------------