diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..74da96bbe --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,17 @@ +# [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 +ARG VARIANT=2-bullseye +FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment this line to install additional gems. +# RUN gem install + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 \ No newline at end of file diff --git a/.devcontainer/base.Dockerfile b/.devcontainer/base.Dockerfile new file mode 100644 index 000000000..3254fe8e6 --- /dev/null +++ b/.devcontainer/base.Dockerfile @@ -0,0 +1,43 @@ +# [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 +ARG VARIANT=2-bullseye +FROM ruby:${VARIANT} + +# Copy library scripts to execute +COPY library-scripts/*.sh library-scripts/*.env /tmp/library-scripts/ + +# [Option] Install zsh +ARG INSTALL_ZSH="true" +# [Option] Upgrade OS packages to their latest versions +ARG UPGRADE_PACKAGES="true" +# Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=$USER_UID +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + # Remove imagemagick due to https://security-tracker.debian.org/tracker/CVE-2019-10131 + && apt-get purge -y imagemagick imagemagick-6-common \ + # Install common packages, non-root user, rvm, core build tools + && bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" "true" "true" \ + && bash /tmp/library-scripts/ruby-debian.sh "none" "${USERNAME}" "true" "true" \ + && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +ENV NVM_DIR=/usr/local/share/nvm +ENV NVM_SYMLINK_CURRENT=true \ + PATH=${NVM_DIR}/current/bin:${PATH} +RUN bash /tmp/library-scripts/node-debian.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}" \ + && apt-get clean -y && rm -rf /var/lib/apt/lists/* + + # Remove library scripts for final image +RUN rm -rf /tmp/library-scripts + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment this line to install additional gems. +# RUN gem install + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..df2b22429 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,38 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/ruby +{ + "name": "Ruby", + "build": { + "dockerfile": "Dockerfile", + "args": { + // Update 'VARIANT' to pick a Ruby version: 3, 3.0, 2, 2.7, 2.6 + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local on arm64/Apple Silicon. + "VARIANT": "3-bullseye", + // Options + "NODE_VERSION": "lts/*" + } + }, + + // Add the IDs of extensions you want installed when the container is created. + "customizations": { + "vscode": { + "extensions": [ + "Shopify.ruby-lsp" + ] + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "ruby --version", + + // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode", + + "features": { + "github-cli": "latest" + } +} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..64aeb1d4f --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,12 @@ +name: Run linters +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1 + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - run: bundle exec rubocop diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 660e0755a..736a7b7c8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,7 +5,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: [2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 3.0, head] + ruby: ['2.6', '2.7', '3.0', '3.1', '3.2', 'head'] runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 2271be594..1e037f799 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ .yardoc Desktop.ini Gemfile.lock +lint_gems.rb.lock Icon? InstalledFiles Session.vim diff --git a/.rubocop.yml b/.rubocop.yml index 9bd4573ba..69943b31a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,37 +1,41 @@ -inherit_from: .rubocop_todo.yml - AllCops: - TargetRubyVersion: 2.0 + TargetRubyVersion: 2.6 DisabledByDefault: true + SuggestExtensions: false Exclude: - spec/sandbox/**/* - spec/fixtures/**/* + - vendor/bundle/**/** -# Enforce Ruby 1.8-compatible hash syntax -HashSyntax: - EnforcedStyle: hash_rockets +Style/HashSyntax: + EnforcedStyle: ruby19 # No spaces inside hash literals -SpaceInsideHashLiteralBraces: +Layout/SpaceInsideHashLiteralBraces: EnforcedStyle: no_space # Enforce outdenting of access modifiers (i.e. public, private, protected) -AccessModifierIndentation: +Layout/AccessModifierIndentation: EnforcedStyle: outdent -EmptyLinesAroundAccessModifier: +Layout/EmptyLinesAroundAccessModifier: Enabled: true # Align ends correctly -EndAlignment: +Layout/EndAlignment: EnforcedStyleAlignWith: variable + Exclude: + - 'lib/thor/actions.rb' + - 'lib/thor/error.rb' + - 'lib/thor/shell/basic.rb' + - 'lib/thor/parser/option.rb' # Indentation of when/else -CaseIndentation: +Layout/CaseIndentation: EnforcedStyle: end IndentOneStep: false -StringLiterals: +Style/StringLiterals: EnforcedStyle: double_quotes Lint/AssignmentInCondition: @@ -39,16 +43,11 @@ Lint/AssignmentInCondition: - 'lib/thor/line_editor/readline.rb' - 'lib/thor/parser/arguments.rb' -Lint/EndAlignment: - Exclude: - - 'lib/thor/actions.rb' - - 'lib/thor/parser/option.rb' - Security/Eval: Exclude: - 'spec/helper.rb' -Lint/HandleExceptions: +Lint/SuppressedException: Exclude: - 'lib/thor/line_editor/readline.rb' @@ -99,10 +98,15 @@ Style/GlobalVars: - 'spec/register_spec.rb' - 'spec/thor_spec.rb' -Layout/IndentArray: +Layout/FirstArrayElementIndentation: EnforcedStyle: consistent -Style/MethodMissing: +Lint/MissingSuper: + Exclude: + - 'lib/thor/error.rb' + - 'spec/rake_compat_spec.rb' + +Style/MissingRespondToMissing: Exclude: - 'lib/thor/core_ext/hash_with_indifferent_access.rb' - 'lib/thor/runner.rb' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml deleted file mode 100644 index 07b308970..000000000 --- a/.rubocop_todo.yml +++ /dev/null @@ -1,59 +0,0 @@ -# This configuration was generated by -# `rubocop --auto-gen-config` -# on 2020-02-14 14:37:56 -0500 using RuboCop version 0.50.0. -# The point is for the user to remove these configuration records -# one by one as the offenses are removed from the code base. -# Note that changes in the inspected code, or installation of new -# versions of RuboCop, may require this file to be generated again. - -# Offense count: 4 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. -# SupportedStyles: outdent, indent -Layout/AccessModifierIndentation: - Exclude: - - 'lib/thor/nested_context.rb' - -# Offense count: 10 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces. -# SupportedStyles: space, no_space, compact -# SupportedStylesForEmptyBraces: space, no_space -Layout/SpaceInsideHashLiteralBraces: - Exclude: - - 'lib/thor/actions/inject_into_file.rb' - - 'spec/actions_spec.rb' - - 'spec/command_spec.rb' - -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyleAlignWith, SupportedStylesAlignWith, AutoCorrect. -# SupportedStylesAlignWith: keyword, variable, start_of_line -Lint/EndAlignment: - Exclude: - - 'lib/thor/error.rb' - -# Offense count: 65 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols. -# SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys -Style/HashSyntax: - Exclude: - - 'lib/thor/actions/inject_into_file.rb' - - 'spec/actions/inject_into_file_spec.rb' - -# Offense count: 24 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, ConsistentQuotesInMultiline. -# SupportedStyles: single_quotes, double_quotes -Style/StringLiterals: - Exclude: - - 'Gemfile' - - 'lib/thor/actions.rb' - - 'lib/thor/actions/inject_into_file.rb' - - 'lib/thor/base.rb' - - 'lib/thor/error.rb' - - 'lib/thor/parser/option.rb' - - 'lib/thor/shell/color.rb' - - 'spec/parser/options_spec.rb' - - 'spec/script_exit_status_spec.rb' diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index b67956f6a..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,228 +0,0 @@ -# 1.1.0 -* Don't use ANSI colors when terminal is dumb. -* Ensure default option/argument is not erroneously aliased. -* Fixes a bug in the calculation of the print_wrapped method. -* Obey `:mute` and `options[:quiet]` in `Shell#say`. -* Support Ruby 3.0. -* Add force option to the `gsub_file` action. - -# 1.0.1 -* Fix thor when `thor/base` and `thor/group` are required without `thor.rb`. -* Handle relative source path in `create_link`. - -# 1.0.0 -* Drop support to Ruby 1.8 and 1.9. -* Deprecate relying on default `exit_on_failure?`. - In preparation to make Thor commands exit when there is a failure we are deprecating - defining a command without defining what behavior is expected when there is a failure. - - To fix the deprecation you need to define a class method called `exit_on_failure?` returning - - `false` if you want the current behavior or `true` if you want the new behavior. -* Deprecate defining an option with the default value using a different type as defined in the option. -* Allow options to be repeatable. See #674. - -# 0.20.3 -* Support old versions of `did_you_mean`. - -# 0.20.2 -* Fix `did_you_mean` support. - -# 0.20.1 -* Support new versions of ERB. -* Fix `check_unknown_options!` to not check the content that was not parsed, i.e. after a `--` or after the first unknown with `stop_on_unknown_option!` -* Add `did_you_mean` support. - -## 0.20.0 -* Add `check_default_type!` to check if the default value of an option matches the defined type. - It removes the warning on usage and gives the command authors the possibility to check for programming errors. - -* Add `disable_required_check!` to disable check for required options in some commands. - It is a substitute of `disable_class_options` that was not working as intended. - -* Add `inject_into_module`. - -## 0.19.4, release 2016-11-28 -* Rename `Thor::Base#thor_reserved_word?` to `#is_thor_reserved_word?` - -## 0.19.3, release 2016-11-27 -* Output a warning instead of raising an exception when a default option value doesn't match its specified type - -## 0.19.2, release 2016-11-26 -* Fix bug with handling of colors passed to `ask` (and methods like `yes?` and `no?` which it underpins) -* Allow numeric arguments to be negative -* Ensure that default option values are of the specified type (e.g. you can't specify `"foo"` as the default for a numeric option), but make symbols and strings interchangeable -* Add `Thor::Shell::Basic#indent` method for intending output -* Fix `remove_command` for an inherited command (see #451) -* Allow hash arguments to only have each key provided once (see #455) -* Allow commands to disable class options, for instance for "help" commands (see #363) -* Do not generate a negative option (`--no-no-foo`) for already negative boolean options (`--no-foo`) -* Improve compatibility of `Thor::CoreExt::HashWithIndifferentAccess` with Ruby standard library `Hash` -* Allow specifying a custom binding for template evaluation (e.g. `#key?` and `#fetch`) -* Fix support for subcommand-specific "help"s -* Use a string buffer when handling ERB for Ruby 2.3 compatibility -* Update dependencies - -## 0.19.1, release 2014-03-24 -* Fix `say` non-String break regression - -## 0.19.0, release 2014-03-22 -* Add support for a default to #ask -* Avoid @namespace not initialized warning -* Avoid private attribute? warning -* Fix initializing with unknown options -* Loosen required_rubygems_version for compatibility with Ubuntu 10.04 -* Shell#ask: support a noecho option for stdin -* Shell#ask: change API to be :echo => false -* Display a message without a stack trace for ambiguous commands -* Make say and say_status thread safe -* Dependency for console io version check -* Alias --help to help on subcommands -* Use mime-types 1.x for Ruby 1.8.7 compatibility for Ruby 1.8 only -* Accept .tt files as templates -* Check if numeric value is in enum -* Use Readline for user input -* Fix dispatching of subcommands (concerning :help and *args) -* Fix warnings when running specs with `$VERBOSE = true` -* Make subcommand help more consistent -* Make the current command chain accessible in command - -## 0.18.1, release 2013-03-30 -* Revert regressions found in 0.18.0 - -## 0.18.0, release 2013-03-26 -* Remove rake2thor -* Only display colors if output medium supports colors -* Pass parent_options to subcommands -* Fix non-dash-prefixed aliases -* Make error messages more helpful -* Rename "task" to "command" -* Add the method to allow for custom package name - -## 0.17.0, release 2013-01-24 -* Add better support for tasks that accept arbitrary additional arguments (e.g. things like `bundle exec`) -* Add #stop_on_unknown_option! -* Only strip from stdin.gets if it wasn't ended with EOF -* Allow "send" as a task name -* Allow passing options as arguments after "--" -* Autoload Thor::Group - -## 0.16.0, release 2012-08-14 -* Add enum to string arguments - -## 0.15.4, release 2012-06-29 -* Fix regression when destination root contains reserved regexp characters - -## 0.15.3, release 2012-06-18 -* Support strict_args_position! for backwards compatibility -* Escape Dir glob characters in paths - -## 0.15.2, released 2012-05-07 -* Added print_in_columns -* Exposed terminal_width as a public API - -## 0.15.1, release 2012-05-06 -* Fix Ruby 1.8 truncation bug with unicode chars -* Fix shell delegate methods to pass their block -* Don't output trailing spaces when printing the last column in a table - -## 0.15, released 2012-04-29 -* Alias method_options to options -* Refactor say to allow multiple colors -* Exposed error as a public API -* Exposed file_collision as a public API -* Exposed print_wrapped as a public API -* Exposed set_color as a public API -* Fix number-formatting bugs in print_table -* Fix "indent" typo in print_table -* Fix Errno::EPIPE when piping tasks to `head` -* More friendly error messages - -## 0.14, released 2010-07-25 -* Added CreateLink class and #link_file method -* Made Thor::Actions#run use system as default method for system calls -* Allow use of private methods from superclass as tasks -* Added mute(&block) method which allows to run block without any output -* Removed config[:pretend] -* Enabled underscores for command line switches -* Added Thor::Base.basename which is used by both Thor.banner and Thor::Group.banner -* Deprecated invoke() without arguments -* Added :only and :except to check_unknown_options - -## 0.13, released 2010-02-03 -* Added :lazy_default which is only triggered if a switch is given -* Added Thor::Shell::HTML -* Added subcommands -* Decoupled Thor::Group and Thor, so it's easier to vendor -* Added check_unknown_options! in case you want error messages to be raised in valid switches -* run(command) should return the results of command - -## 0.12, released 2010-01-02 -* Methods generated by attr_* are automatically not marked as tasks -* inject_into_file does not add the same content twice, unless :force is set -* Removed rr in favor to rspec mock framework -* Improved output for thor -T -* [#7] Do not force white color on status -* [#8] Yield a block with the filename on directory - -## 0.11, released 2009-07-01 -* Added a rake compatibility layer. It allows you to use spec and rdoc tasks on - Thor classes. -* BACKWARDS INCOMPATIBLE: aliases are not generated automatically anymore - since it may cause wrong behavior in the invocation system. -* thor help now show information about any class/task. All those calls are - possible: - - thor help describe - thor help describe:amazing - Or even with default namespaces: - - thor help :spec -* Thor::Runner now invokes the default task if none is supplied: - - thor describe # invokes the default task, usually help -* Thor::Runner now works with mappings: - - thor describe -h -* Added some documentation and code refactoring. - -## 0.9.8, released 2008-10-20 -* Fixed some tiny issues that were introduced lately. - -## 0.9.7, released 2008-10-13 -* Setting global method options on the initialize method works as expected: - All other tasks will accept these global options in addition to their own. -* Added 'group' notion to Thor task sets (class Thor); by default all tasks - are in the 'standard' group. Running 'thor -T' will only show the standard - tasks - adding --all will show all tasks. You can also filter on a specific - group using the --group option: thor -T --group advanced - -## 0.9.6, released 2008-09-13 -* Generic improvements - -## 0.9.5, released 2008-08-27 -* Improve Windows compatibility -* Update (incorrect) README and task.thor sample file -* Options hash is now frozen (once returned) -* Allow magic predicates on options object. For instance: `options.force?` -* Add support for :numeric type -* BACKWARDS INCOMPATIBLE: Refactor Thor::Options. You cannot access shorthand forms in options hash anymore (for instance, options[:f]) -* Allow specifying optional args with default values: method_options(:user => "mislav") -* Don't write options for nil or false values. This allows, for example, turning color off when running specs. -* Exit with the status of the spec command to help CI stuff out some. - -## 0.9.4, released 2008-08-13 -* Try to add Windows compatibility. -* BACKWARDS INCOMPATIBLE: options hash is now accessed as a property in your class and is not passed as last argument anymore -* Allow options at the beginning of the argument list as well as the end. -* Make options available with symbol keys in addition to string keys. -* Allow true to be passed to Thor#method_options to denote a boolean option. -* If loading a thor file fails, don't give up, just print a warning and keep going. -* Make sure that we re-raise errors if they happened further down the pipe than we care about. -* Only delete the old file on updating when the installation of the new one is a success -* Make it Ruby 1.8.5 compatible. -* Don't raise an error if a boolean switch is defined multiple times. -* Thor::Options now doesn't parse through things that look like options but aren't. -* Add URI detection to install task, and make sure we don't append ".thor" to URIs -* Add rake2thor to the gem binfiles. -* Make sure local Thorfiles override system-wide ones. diff --git a/Gemfile b/Gemfile index bc5f7c539..c82a4c3e2 100644 --- a/Gemfile +++ b/Gemfile @@ -5,16 +5,16 @@ gem "rake" group :development do gem "pry" gem "pry-byebug" + gem "rubocop", "~> 1.30" end group :test do gem "childlabor" - gem "coveralls", ">= 0.8.19" + gem "coveralls_reborn", "~> 0.23.1", require: false gem "rspec", ">= 3.2" gem "rspec-mocks", ">= 3" - gem "rubocop", "~> 0.50.0" gem "simplecov", ">= 0.13" - gem "webmock" + gem "webmock", ">= 3.14" end gemspec diff --git a/README.md b/README.md index 9c77b6b6d..02529a961 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,8 @@ Thor ==== [![Gem Version](http://img.shields.io/gem/v/thor.svg)][gem] -[![Build Status](http://img.shields.io/travis/erikhuda/thor.svg)][travis] -[![Code Climate](http://img.shields.io/codeclimate/github/erikhuda/thor.svg)][codeclimate] -[![Coverage Status](http://img.shields.io/coveralls/erikhuda/thor.svg)][coveralls] [gem]: https://rubygems.org/gems/thor -[travis]: http://travis-ci.org/erikhuda/thor -[codeclimate]: https://codeclimate.com/github/erikhuda/thor -[coveralls]: https://coveralls.io/r/erikhuda/thor Description ----------- @@ -35,7 +29,7 @@ Usage and documentation ----------------------- Please see the [wiki][] for basic usage and other documentation on using Thor. You can also checkout the [official homepage][homepage]. -[wiki]: https://github.com/erikhuda/thor/wiki +[wiki]: https://github.com/rails/thor/wiki [homepage]: http://whatisthor.com/ Contributing diff --git a/lib/thor.rb b/lib/thor.rb index ccc8d6e64..13225ca08 100644 --- a/lib/thor.rb +++ b/lib/thor.rb @@ -65,8 +65,15 @@ def desc(usage, description, options = {}) # Defines the long description of the next command. # + # Long description is by default indented, line-wrapped and repeated whitespace merged. + # In order to print long description verbatim, with indentation and spacing exactly + # as found in the code, use the +wrap+ option + # + # long_desc 'your very long description', wrap: false + # # ==== Parameters # long description + # options # def long_desc(long_description, options = {}) if options[:for] @@ -74,6 +81,7 @@ def long_desc(long_description, options = {}) command.long_description = long_description if long_description else @long_desc = long_description + @long_desc_wrap = options[:wrap] != false end end @@ -133,7 +141,7 @@ def method_options(options = nil) # # magic # end # - # method_option :foo => :bar, :for => :previous_command + # method_option :foo, :for => :previous_command # # def next_command # # magic @@ -155,6 +163,9 @@ def method_options(options = nil) # if you want to override the inverse option name. # def method_option(name, options = {}) + unless [ Symbol, String ].any? { |klass| name.is_a?(klass) } + raise ArgumentError, "Expected a Symbol or String, got #{name.inspect}" + end scope = if options[:for] find_and_refresh_command(options[:for]).options else @@ -165,6 +176,81 @@ def method_option(name, options = {}) end alias_method :option, :method_option + # Adds and declares option group for exclusive options in the + # block and arguments. You can declare options as the outside of the block. + # + # If :for is given as option, it allows you to change the options from + # a previous defined command. + # + # ==== Parameters + # Array[Thor::Option.name] + # options:: :for is applied for previous defined command. + # + # ==== Examples + # + # exclusive do + # option :one + # option :two + # end + # + # Or + # + # option :one + # option :two + # exclusive :one, :two + # + # If you give "--one" and "--two" at the same time ExclusiveArgumentsError + # will be raised. + # + def method_exclusive(*args, &block) + register_options_relation_for(:method_options, + :method_exclusive_option_names, *args, &block) + end + alias_method :exclusive, :method_exclusive + + # Adds and declares option group for required at least one of options in the + # block of arguments. You can declare options as the outside of the block. + # + # If :for is given as option, it allows you to change the options from + # a previous defined command. + # + # ==== Parameters + # Array[Thor::Option.name] + # options:: :for is applied for previous defined command. + # + # ==== Examples + # + # at_least_one do + # option :one + # option :two + # end + # + # Or + # + # option :one + # option :two + # at_least_one :one, :two + # + # If you do not give "--one" and "--two" AtLeastOneRequiredArgumentError + # will be raised. + # + # You can use at_least_one and exclusive at the same time. + # + # exclusive do + # at_least_one do + # option :one + # option :two + # end + # end + # + # Then it is required either only one of "--one" or "--two". + # + def method_at_least_one(*args, &block) + register_options_relation_for(:method_options, + :method_at_least_one_option_names, *args, &block) + end + alias_method :at_least_one, :method_at_least_one + # Prints help information for the given command. # # ==== Parameters @@ -180,9 +266,16 @@ def command_help(shell, command_name) shell.say " #{banner(command).split("\n").join("\n ")}" shell.say class_options_help(shell, nil => command.options.values) + print_exclusive_options(shell, command) + print_at_least_one_required_options(shell, command) + if command.long_description shell.say "Description:" - shell.print_wrapped(command.long_description, :indent => 2) + if command.wrap_long_description + shell.print_wrapped(command.long_description, indent: 2) + else + shell.say command.long_description + end else shell.say command.description end @@ -199,7 +292,7 @@ def help(shell, subcommand = false) Thor::Util.thor_classes_in(self).each do |klass| list += klass.printable_commands(false) end - list.sort! { |a, b| a[0] <=> b[0] } + sort_commands!(list) if defined?(@package_name) && @package_name shell.say "#{@package_name} commands:" @@ -207,9 +300,11 @@ def help(shell, subcommand = false) shell.say "Commands:" end - shell.print_table(list, :indent => 2, :truncate => true) + shell.print_table(list, indent: 2, truncate: true) shell.say class_options_help(shell) + print_exclusive_options(shell) + print_at_least_one_required_options(shell) end # Returns commands ready to be printed. @@ -240,7 +335,7 @@ def subcommand(subcommand, subcommand_class) define_method(subcommand) do |*args| args, opts = Thor::Arguments.split(args) - invoke_args = [args, opts, {:invoked_via_subcommand => true, :class_options => options}] + invoke_args = [args, opts, {invoked_via_subcommand: true, class_options: options}] invoke_args.unshift "help" if opts.delete("--help") || opts.delete("-h") invoke subcommand_class, *invoke_args end @@ -348,6 +443,24 @@ def disable_required_check?(command) #:nodoc: protected + # Returns this class exclusive options array set. + # + # ==== Returns + # Array[Array[Thor::Option.name]] + # + def method_exclusive_option_names #:nodoc: + @method_exclusive_option_names ||= [] + end + + # Returns this class at least one of required options array set. + # + # ==== Returns + # Array[Array[Thor::Option.name]] + # + def method_at_least_one_option_names #:nodoc: + @method_at_least_one_option_names ||= [] + end + def stop_on_unknown_option #:nodoc: @stop_on_unknown_option ||= [] end @@ -357,8 +470,30 @@ def disable_required_check #:nodoc: @disable_required_check ||= [:help] end + def print_exclusive_options(shell, command = nil) # :nodoc: + opts = [] + opts = command.method_exclusive_option_names unless command.nil? + opts += class_exclusive_option_names + unless opts.empty? + shell.say "Exclusive Options:" + shell.print_table(opts.map{ |ex| ex.map{ |e| "--#{e}"}}, indent: 2 ) + shell.say + end + end + + def print_at_least_one_required_options(shell, command = nil) # :nodoc: + opts = [] + opts = command.method_at_least_one_option_names unless command.nil? + opts += class_at_least_one_option_names + unless opts.empty? + shell.say "Required At Least One:" + shell.print_table(opts.map{ |ex| ex.map{ |e| "--#{e}"}}, indent: 2 ) + shell.say + end + end + # The method responsible for dispatching given the args. - def dispatch(meth, given_args, given_opts, config) #:nodoc: # rubocop:disable MethodLength + def dispatch(meth, given_args, given_opts, config) #:nodoc: meth ||= retrieve_command_name(given_args) command = all_commands[normalize_command_name(meth)] @@ -417,12 +552,16 @@ def create_command(meth) #:nodoc: @usage ||= nil @desc ||= nil @long_desc ||= nil + @long_desc_wrap ||= nil @hide ||= nil if @usage && @desc base_class = @hide ? Thor::HiddenCommand : Thor::Command - commands[meth] = base_class.new(meth, @desc, @long_desc, @usage, method_options) - @usage, @desc, @long_desc, @method_options, @hide = nil + relations = {exclusive_option_names: method_exclusive_option_names, + at_least_one_option_names: method_at_least_one_option_names} + commands[meth] = base_class.new(meth, @desc, @long_desc, @long_desc_wrap, @usage, method_options, relations) + @usage, @desc, @long_desc, @long_desc_wrap, @method_options, @hide = nil + @method_exclusive_option_names, @method_at_least_one_option_names = nil true elsif all_commands[meth] || meth == "method_missing" true @@ -497,6 +636,14 @@ def help(command = nil, subcommand = true); super; end " end alias_method :subtask_help, :subcommand_help + + # Sort the commands, lexicographically by default. + # + # Can be overridden in the subclass to change the display order of the + # commands. + def sort_commands!(list) + list.sort! { |a, b| a[0] <=> b[0] } + end end include Thor::Base diff --git a/lib/thor/actions.rb b/lib/thor/actions.rb index bc8d56ffc..50475452d 100644 --- a/lib/thor/actions.rb +++ b/lib/thor/actions.rb @@ -46,17 +46,17 @@ def source_paths_for_search # Add runtime options that help actions execution. # def add_runtime_options! - class_option :force, :type => :boolean, :aliases => "-f", :group => :runtime, - :desc => "Overwrite files that already exist" + class_option :force, type: :boolean, aliases: "-f", group: :runtime, + desc: "Overwrite files that already exist" - class_option :pretend, :type => :boolean, :aliases => "-p", :group => :runtime, - :desc => "Run but do not make any changes" + class_option :pretend, type: :boolean, aliases: "-p", group: :runtime, + desc: "Run but do not make any changes" - class_option :quiet, :type => :boolean, :aliases => "-q", :group => :runtime, - :desc => "Suppress status output" + class_option :quiet, type: :boolean, aliases: "-q", group: :runtime, + desc: "Suppress status output" - class_option :skip, :type => :boolean, :aliases => "-s", :group => :runtime, - :desc => "Skip files that already exist" + class_option :skip, type: :boolean, aliases: "-s", group: :runtime, + desc: "Skip files that already exist" end end @@ -113,9 +113,9 @@ def destination_root=(root) # def relative_to_original_destination_root(path, remove_dot = true) root = @destination_stack[0] - if path.start_with?(root) && [File::SEPARATOR, File::ALT_SEPARATOR, nil, ''].include?(path[root.size..root.size]) + if path.start_with?(root) && [File::SEPARATOR, File::ALT_SEPARATOR, nil, ""].include?(path[root.size..root.size]) path = path.dup - path[0...root.size] = '.' + path[0...root.size] = "." remove_dot ? (path[2..-1] || "") : path else path @@ -161,6 +161,8 @@ def find_in_source_paths(file) # to the block you provide. The path is set back to the previous path when # the method exits. # + # Returns the value yielded by the block. + # # ==== Parameters # dir:: the directory to move to. # config:: give :verbose => true to log and use padding. @@ -173,22 +175,24 @@ def inside(dir = "", config = {}, &block) shell.padding += 1 if verbose @destination_stack.push File.expand_path(dir, destination_root) - # If the directory doesnt exist and we're not pretending + # If the directory doesn't exist and we're not pretending if !File.exist?(destination_root) && !pretend require "fileutils" FileUtils.mkdir_p(destination_root) end + result = nil if pretend # In pretend mode, just yield down to the block - block.arity == 1 ? yield(destination_root) : yield + result = block.arity == 1 ? yield(destination_root) : yield else require "fileutils" - FileUtils.cd(destination_root) { block.arity == 1 ? yield(destination_root) : yield } + FileUtils.cd(destination_root) { result = block.arity == 1 ? yield(destination_root) : yield } end @destination_stack.pop shell.padding -= 1 if verbose + result end # Goes to the root and execute the given block. @@ -221,7 +225,7 @@ def apply(path, config = {}) require "open-uri" URI.open(path, "Accept" => "application/x-thor-template", &:read) else - open(path, &:read) + File.open(path, &:read) end instance_eval(contents, path) @@ -280,7 +284,7 @@ def run(command, config = {}) # def run_ruby_script(command, config = {}) return unless behavior == :invoke - run command, config.merge(:with => Thor::Util.ruby_command) + run command, config.merge(with: Thor::Util.ruby_command) end # Run a thor command. A hash of options can be given and it's converted to @@ -311,7 +315,7 @@ def thor(command, *args) args.push Thor::Options.to_switches(config) command = args.join(" ").strip - run command, :with => :thor, :verbose => verbose, :pretend => pretend, :capture => capture + run command, with: :thor, verbose: verbose, pretend: pretend, capture: capture end protected @@ -319,7 +323,7 @@ def thor(command, *args) # Allow current root to be shared between invocations. # def _shared_configuration #:nodoc: - super.merge!(:destination_root => destination_root) + super.merge!(destination_root: destination_root) end def _cleanup_options_and_set(options, key) #:nodoc: diff --git a/lib/thor/actions/create_file.rb b/lib/thor/actions/create_file.rb index fa7b22cbb..382838ca2 100644 --- a/lib/thor/actions/create_file.rb +++ b/lib/thor/actions/create_file.rb @@ -43,7 +43,8 @@ def initialize(base, destination, data, config = {}) # Boolean:: true if it is identical, false otherwise. # def identical? - exists? && File.binread(destination) == render + # binread uses ASCII-8BIT, so to avoid false negatives, the string must use the same + exists? && File.binread(destination) == String.new(render).force_encoding("ASCII-8BIT") end # Holds the content to be added to the file. @@ -60,7 +61,7 @@ def invoke! invoke_with_conflict_check do require "fileutils" FileUtils.mkdir_p(File.dirname(destination)) - File.open(destination, "wb") { |f| f.write render } + File.open(destination, "wb", config[:perm]) { |f| f.write render } end given_destination end diff --git a/lib/thor/actions/directory.rb b/lib/thor/actions/directory.rb index e133dc88a..e57bec9e1 100644 --- a/lib/thor/actions/directory.rb +++ b/lib/thor/actions/directory.rb @@ -58,7 +58,7 @@ class Directory < EmptyDirectory #:nodoc: def initialize(base, source, destination = nil, config = {}, &block) @source = File.expand_path(Dir[Util.escape_globs(base.find_in_source_paths(source.to_s))].first) @block = block - super(base, destination, {:recursive => true}.merge(config)) + super(base, destination, {recursive: true}.merge(config)) end def invoke! diff --git a/lib/thor/actions/empty_directory.rb b/lib/thor/actions/empty_directory.rb index fcd4e30cc..46a602361 100644 --- a/lib/thor/actions/empty_directory.rb +++ b/lib/thor/actions/empty_directory.rb @@ -33,7 +33,7 @@ class EmptyDirectory #:nodoc: # def initialize(base, destination, config = {}) @base = base - @config = {:verbose => true}.merge(config) + @config = {verbose: true}.merge(config) self.destination = destination end diff --git a/lib/thor/actions/file_manipulation.rb b/lib/thor/actions/file_manipulation.rb index e6c720fd2..e4aa8e890 100644 --- a/lib/thor/actions/file_manipulation.rb +++ b/lib/thor/actions/file_manipulation.rb @@ -66,12 +66,15 @@ def link_file(source, *args) # ==== Parameters # source:: the address of the given content. # destination:: the relative path to the destination root. - # config:: give :verbose => false to not log the status. + # config:: give :verbose => false to not log the status, and + # :http_headers => to add headers to an http request. # # ==== Examples # # get "http://gist.github.com/103208", "doc/README" # + # get "http://gist.github.com/103208", "doc/README", :http_headers => {"Content-Type" => "application/json"} + # # get "http://gist.github.com/103208" do |content| # content.split("\n").first # end @@ -82,10 +85,10 @@ def get(source, *args, &block) render = if source =~ %r{^https?\://} require "open-uri" - URI.send(:open, source) { |input| input.binmode.read } + URI.send(:open, source, config.fetch(:http_headers, {})) { |input| input.binmode.read } else source = File.expand_path(find_in_source_paths(source.to_s)) - open(source) { |input| input.binmode.read } + File.open(source) { |input| input.binmode.read } end destination ||= if block_given? @@ -120,12 +123,7 @@ def template(source, *args, &block) context = config.delete(:context) || instance_eval("binding") create_file destination, nil, config do - match = ERB.version.match(/(\d+\.\d+\.\d+)/) - capturable_erb = if match && match[1] >= "2.2.0" # Ruby 2.6+ - CapturableERB.new(::File.binread(source), :trim_mode => "-", :eoutvar => "@output_buffer") - else - CapturableERB.new(::File.binread(source), nil, "-", "@output_buffer") - end + capturable_erb = CapturableERB.new(::File.binread(source), trim_mode: "-", eoutvar: "@output_buffer") content = capturable_erb.tap do |erb| erb.filename = source end.result(context) @@ -252,7 +250,7 @@ def inject_into_module(path, module_name, *args, &block) # flag:: the regexp or string to be replaced # replacement:: the replacement, can be also given as a block # config:: give :verbose => false to not log the status, and - # :force => true, to force the replacement regardles of runner behavior. + # :force => true, to force the replacement regardless of runner behavior. # # ==== Example # @@ -331,7 +329,7 @@ def remove_file(path, config = {}) path = File.expand_path(path, destination_root) say_status :remove, relative_to_original_destination_root(path), config.fetch(:verbose, true) - if !options[:pretend] && File.exist?(path) + if !options[:pretend] && (File.exist?(path) || File.symlink?(path)) require "fileutils" ::FileUtils.rm_rf(path) end diff --git a/lib/thor/actions/inject_into_file.rb b/lib/thor/actions/inject_into_file.rb index 4f7791dad..be13ddb2c 100644 --- a/lib/thor/actions/inject_into_file.rb +++ b/lib/thor/actions/inject_into_file.rb @@ -21,7 +21,7 @@ module Actions # gems.split(" ").map{ |gem| " config.gem :#{gem}" }.join("\n") # end # - WARNINGS = { unchanged_no_flag: 'File unchanged! The supplied flag value not found!' } + WARNINGS = {unchanged_no_flag: "File unchanged! Either the supplied flag value not found or the content has already been inserted!"} def insert_into_file(destination, *args, &block) data = block_given? ? block : args.shift @@ -37,7 +37,7 @@ class InjectIntoFile < EmptyDirectory #:nodoc: attr_reader :replacement, :flag, :behavior def initialize(base, destination, data, config) - super(base, destination, {:verbose => true}.merge(config)) + super(base, destination, {verbose: true}.merge(config)) @behavior, @flag = if @config.key?(:after) [:after, @config.delete(:after)] @@ -59,6 +59,8 @@ def invoke! if exists? if replace!(/#{flag}/, content, config[:force]) say_status(:invoke) + elsif replacement_present? + say_status(:unchanged, color: :blue) else say_status(:unchanged, warning: WARNINGS[:unchanged_no_flag], color: :red) end @@ -96,6 +98,8 @@ def say_status(behavior, warning: nil, color: nil) end elsif warning warning + elsif behavior == :unchanged + :unchanged else :subtract end @@ -103,18 +107,21 @@ def say_status(behavior, warning: nil, color: nil) super(status, (color || config[:verbose])) end + def content + @content ||= File.read(destination) + end + + def replacement_present? + content.include?(replacement) + end + # Adds the content to the file. # def replace!(regexp, string, force) - return if pretend? - content = File.read(destination) - before, after = content.split(regexp, 2) - snippet = (behavior == :after ? after : before).to_s - - if force || !snippet.include?(replacement) + if force || !replacement_present? success = content.gsub!(regexp, string) - File.open(destination, "wb") { |file| file.write(content) } + File.open(destination, "wb") { |file| file.write(content) } unless pretend? success end end diff --git a/lib/thor/base.rb b/lib/thor/base.rb index 657880cc2..d5f5bea0c 100644 --- a/lib/thor/base.rb +++ b/lib/thor/base.rb @@ -24,9 +24,9 @@ class Thor class << self def deprecation_warning(message) #:nodoc: - unless ENV['THOR_SILENCE_DEPRECATION'] + unless ENV["THOR_SILENCE_DEPRECATION"] warn "Deprecation warning: #{message}\n" + - 'You can silence deprecations warning by setting the environment variable THOR_SILENCE_DEPRECATION.' + "You can silence deprecations warning by setting the environment variable THOR_SILENCE_DEPRECATION." end end end @@ -60,6 +60,7 @@ def initialize(args = [], local_options = {}, config = {}) command_options = config.delete(:command_options) # hook for start parse_options = parse_options.merge(command_options) if command_options + if local_options.is_a?(Array) array_options = local_options hash_options = {} @@ -73,9 +74,24 @@ def initialize(args = [], local_options = {}, config = {}) # Let Thor::Options parse the options first, so it can remove # declared options from the array. This will leave us with # a list of arguments that weren't declared. - stop_on_unknown = self.class.stop_on_unknown_option? config[:current_command] - disable_required_check = self.class.disable_required_check? config[:current_command] - opts = Thor::Options.new(parse_options, hash_options, stop_on_unknown, disable_required_check) + current_command = config[:current_command] + stop_on_unknown = self.class.stop_on_unknown_option? current_command + + # Give a relation of options. + # After parsing, Thor::Options check whether right relations are kept + relations = if current_command.nil? + {exclusive_option_names: [], at_least_one_option_names: []} + else + current_command.options_relation + end + + self.class.class_exclusive_option_names.map { |n| relations[:exclusive_option_names] << n } + self.class.class_at_least_one_option_names.map { |n| relations[:at_least_one_option_names] << n } + + disable_required_check = self.class.disable_required_check? current_command + + opts = Thor::Options.new(parse_options, hash_options, stop_on_unknown, disable_required_check, relations) + self.options = opts.parse(array_options) self.options = config[:class_options].merge(options) if config[:class_options] @@ -310,9 +326,92 @@ def class_options(options = nil) # :hide:: -- If you want to hide this option from the help. # def class_option(name, options = {}) + unless [ Symbol, String ].any? { |klass| name.is_a?(klass) } + raise ArgumentError, "Expected a Symbol or String, got #{name.inspect}" + end build_option(name, options, class_options) end + # Adds and declares option group for exclusive options in the + # block and arguments. You can declare options as the outside of the block. + # + # ==== Parameters + # Array[Thor::Option.name] + # + # ==== Examples + # + # class_exclusive do + # class_option :one + # class_option :two + # end + # + # Or + # + # class_option :one + # class_option :two + # class_exclusive :one, :two + # + # If you give "--one" and "--two" at the same time ExclusiveArgumentsError + # will be raised. + # + def class_exclusive(*args, &block) + register_options_relation_for(:class_options, + :class_exclusive_option_names, *args, &block) + end + + # Adds and declares option group for required at least one of options in the + # block and arguments. You can declare options as the outside of the block. + # + # ==== Examples + # + # class_at_least_one do + # class_option :one + # class_option :two + # end + # + # Or + # + # class_option :one + # class_option :two + # class_at_least_one :one, :two + # + # If you do not give "--one" and "--two" AtLeastOneRequiredArgumentError + # will be raised. + # + # You can use class_at_least_one and class_exclusive at the same time. + # + # class_exclusive do + # class_at_least_one do + # class_option :one + # class_option :two + # end + # end + # + # Then it is required either only one of "--one" or "--two". + # + def class_at_least_one(*args, &block) + register_options_relation_for(:class_options, + :class_at_least_one_option_names, *args, &block) + end + + # Returns this class exclusive options array set, looking up in the ancestors chain. + # + # ==== Returns + # Array[Array[Thor::Option.name]] + # + def class_exclusive_option_names + @class_exclusive_option_names ||= from_superclass(:class_exclusive_option_names, []) + end + + # Returns this class at least one of required options array set, looking up in the ancestors chain. + # + # ==== Returns + # Array[Array[Thor::Option.name]] + # + def class_at_least_one_option_names + @class_at_least_one_option_names ||= from_superclass(:class_at_least_one_option_names, []) + end + # Removes a previous defined argument. If :undefine is given, undefine # accessors as well. # @@ -506,7 +605,7 @@ def start(given_args = ARGV, config = {}) # def public_command(*names) names.each do |name| - class_eval "def #{name}(*); super end" + class_eval "def #{name}(*); super end", __FILE__, __LINE__ end end alias_method :public_task, :public_command @@ -558,20 +657,19 @@ def print_options(shell, options, group_name = nil) return if options.empty? list = [] - padding = options.map { |o| o.aliases.size }.max.to_i * 4 - + padding = options.map { |o| o.aliases_for_usage.size }.max.to_i options.each do |option| next if option.hide item = [option.usage(padding)] item.push(option.description ? "# #{option.description}" : "") list << item - list << ["", "# Default: #{option.default}"] if option.show_default? - list << ["", "# Possible values: #{option.enum.join(', ')}"] if option.enum + list << ["", "# Default: #{option.print_default}"] if option.show_default? + list << ["", "# Possible values: #{option.enum_to_s}"] if option.enum end shell.say(group_name ? "#{group_name} options:" : "Options:") - shell.print_table(list, :indent => 2) + shell.print_table(list, indent: 2) shell.say "" end @@ -588,7 +686,7 @@ def is_thor_reserved_word?(word, type) #:nodoc: # options:: Described in both class_option and method_option. # scope:: Options hash that is being built up def build_option(name, options, scope) #:nodoc: - scope[name] = Thor::Option.new(name, {:check_default_type => check_default_type}.merge!(options)) + scope[name] = Thor::Option.new(name, {check_default_type: check_default_type}.merge!(options)) end # Receives a hash of options, parse them and add to the scope. This is a @@ -610,7 +708,7 @@ def build_options(options, scope) #:nodoc: def find_and_refresh_command(name) #:nodoc: if commands[name.to_s] commands[name.to_s] - elsif command = all_commands[name.to_s] # rubocop:disable AssignmentInCondition + elsif command = all_commands[name.to_s] # rubocop:disable Lint/AssignmentInCondition commands[name.to_s] = command.clone else raise ArgumentError, "You supplied :for => #{name.inspect}, but the command #{name.inspect} could not be found." @@ -618,7 +716,7 @@ def find_and_refresh_command(name) #:nodoc: end alias_method :find_and_refresh_task, :find_and_refresh_command - # Everytime someone inherits from a Thor class, register the klass + # Every time someone inherits from a Thor class, register the klass # and file into baseclass. def inherited(klass) super(klass) @@ -694,6 +792,34 @@ def initialize_added #:nodoc: def dispatch(command, given_args, given_opts, config) #:nodoc: raise NotImplementedError end + + # Register a relation of options for target(method_option/class_option) + # by args and block. + def register_options_relation_for(target, relation, *args, &block) # :nodoc: + opt = args.pop if args.last.is_a? Hash + opt ||= {} + names = args.map{ |arg| arg.to_s } + names += built_option_names(target, opt, &block) if block_given? + command_scope_member(relation, opt) << names + end + + # Get target(method_options or class_options) options + # of before and after by block evaluation. + def built_option_names(target, opt = {}, &block) # :nodoc: + before = command_scope_member(target, opt).map{ |k,v| v.name } + instance_eval(&block) + after = command_scope_member(target, opt).map{ |k,v| v.name } + after - before + end + + # Get command scope member by name. + def command_scope_member(name, options = {}) # :nodoc: + if options[:for] + find_and_refresh_command(options[:for]).send(name) + else + send(name) + end + end end end end diff --git a/lib/thor/command.rb b/lib/thor/command.rb index f2dcca494..c6bdf74bb 100644 --- a/lib/thor/command.rb +++ b/lib/thor/command.rb @@ -1,14 +1,15 @@ class Thor - class Command < Struct.new(:name, :description, :long_description, :usage, :options, :ancestor_name) + class Command < Struct.new(:name, :description, :long_description, :wrap_long_description, :usage, :options, :options_relation, :ancestor_name) FILE_REGEXP = /^#{Regexp.escape(File.dirname(__FILE__))}/ - def initialize(name, description, long_description, usage, options = nil) - super(name.to_s, description, long_description, usage, options || {}) + def initialize(name, description, long_description, wrap_long_description, usage, options = nil, options_relation = nil) + super(name.to_s, description, long_description, wrap_long_description, usage, options || {}, options_relation || {}) end def initialize_copy(other) #:nodoc: super(other) self.options = other.options.dup if other.options + self.options_relation = other.options_relation.dup if other.options_relation end def hidden? @@ -62,6 +63,14 @@ def formatted_usage(klass, namespace = true, subcommand = false) end.join("\n") end + def method_exclusive_option_names #:nodoc: + self.options_relation[:exclusive_option_names] || [] + end + + def method_at_least_one_option_names #:nodoc: + self.options_relation[:at_least_one_option_names] || [] + end + protected # Add usage with required arguments @@ -127,7 +136,7 @@ def hidden? # A dynamic command that handles method missing scenarios. class DynamicCommand < Command def initialize(name, options = nil) - super(name.to_s, "A dynamically-generated command", name.to_s, name.to_s, options) + super(name.to_s, "A dynamically-generated command", name.to_s, nil, name.to_s, options) end def run(instance, args = []) diff --git a/lib/thor/core_ext/hash_with_indifferent_access.rb b/lib/thor/core_ext/hash_with_indifferent_access.rb index be208a16d..c937d289c 100644 --- a/lib/thor/core_ext/hash_with_indifferent_access.rb +++ b/lib/thor/core_ext/hash_with_indifferent_access.rb @@ -38,6 +38,10 @@ def fetch(key, *args) super(convert_key(key), *args) end + def slice(*keys) + super(*keys.map{ |key| convert_key(key) }) + end + def key?(key) super(convert_key(key)) end diff --git a/lib/thor/error.rb b/lib/thor/error.rb index c7c285906..11fa250c2 100644 --- a/lib/thor/error.rb +++ b/lib/thor/error.rb @@ -1,18 +1,15 @@ class Thor Correctable = if defined?(DidYouMean::SpellChecker) && defined?(DidYouMean::Correctable) # rubocop:disable Naming/ConstantName - # In order to support versions of Ruby that don't have keyword - # arguments, we need our own spell checker class that doesn't take key - # words. Even though this code wouldn't be hit because of the check - # above, it's still necessary because the interpreter would otherwise be - # unable to parse the file. - class NoKwargSpellChecker < DidYouMean::SpellChecker # :nodoc: - def initialize(dictionary) - @dictionary = dictionary - end - end - - DidYouMean::Correctable - end + Module.new do + def to_s + super + DidYouMean.formatter.message_for(corrections) + end + + def corrections + @corrections ||= self.class.const_get(:SpellChecker).new(self).corrections + end + end + end # Thor::Error is raised when it's caused by wrong usage of thor classes. Those # errors have their backtrace suppressed and are nicely shown to the user. @@ -37,7 +34,7 @@ def corrections end def spell_checker - NoKwargSpellChecker.new(error.all_commands) + DidYouMean::SpellChecker.new(dictionary: error.all_commands) end end @@ -79,7 +76,7 @@ def corrections end def spell_checker - @spell_checker ||= NoKwargSpellChecker.new(error.switches) + @spell_checker ||= DidYouMean::SpellChecker.new(dictionary: error.switches) end end @@ -101,10 +98,9 @@ class RequiredArgumentMissingError < InvocationError class MalformattedArgumentError < InvocationError end - if Correctable - DidYouMean::SPELL_CHECKERS.merge!( - 'Thor::UndefinedCommandError' => UndefinedCommandError::SpellChecker, - 'Thor::UnknownArgumentError' => UnknownArgumentError::SpellChecker - ) + class ExclusiveArgumentError < InvocationError + end + + class AtLeastOneRequiredArgumentError < InvocationError end end diff --git a/lib/thor/group.rb b/lib/thor/group.rb index d36e57c85..263ccfe6a 100644 --- a/lib/thor/group.rb +++ b/lib/thor/group.rb @@ -169,7 +169,7 @@ def class_options_help(shell, groups = {}) #:nodoc: # options are added to group_options hash. Options that already exists # in base_options are not added twice. # - def get_options_from_invocations(group_options, base_options) #:nodoc: # rubocop:disable MethodLength + def get_options_from_invocations(group_options, base_options) #:nodoc: invocations.each do |name, from_option| value = if from_option option = class_options[name] diff --git a/lib/thor/invocation.rb b/lib/thor/invocation.rb index 4c7f89366..4c0e4fac1 100644 --- a/lib/thor/invocation.rb +++ b/lib/thor/invocation.rb @@ -143,7 +143,7 @@ def invoke_with_padding(*args) # Configuration values that are shared between invocations. def _shared_configuration #:nodoc: - {:invocations => @_invocations} + {invocations: @_invocations} end # This method simply retrieves the class and command to be invoked. diff --git a/lib/thor/nested_context.rb b/lib/thor/nested_context.rb index 74f56bbdb..5460112bb 100644 --- a/lib/thor/nested_context.rb +++ b/lib/thor/nested_context.rb @@ -13,10 +13,10 @@ def enter end def entered? - @depth > 0 + @depth.positive? end - private + private def push @depth += 1 diff --git a/lib/thor/parser/argument.rb b/lib/thor/parser/argument.rb index 7b628ef23..01820111f 100644 --- a/lib/thor/parser/argument.rb +++ b/lib/thor/parser/argument.rb @@ -24,6 +24,17 @@ def initialize(name, options = {}) validate! # Trigger specific validations end + def print_default + if @type == :array and @default.is_a?(Array) + @default.map { |x| + p = x.gsub('"','\\"') + "\"#{p}\"" + }.join(" ") + else + @default + end + end + def usage required? ? banner : "[#{banner}]" end @@ -41,11 +52,19 @@ def show_default? end end + def enum_to_s + if enum.respond_to? :join + enum.join(", ") + else + "#{enum.first}..#{enum.last}" + end + end + protected def validate! raise ArgumentError, "An argument cannot be required and have default value." if required? && !default.nil? - raise ArgumentError, "An argument cannot have an enum other than an array." if @enum && !@enum.is_a?(Array) + raise ArgumentError, "An argument cannot have an enum other than an enumerable." if @enum && !@enum.is_a?(Enumerable) end def valid_type?(type) diff --git a/lib/thor/parser/arguments.rb b/lib/thor/parser/arguments.rb index 05c1a659c..c02823559 100644 --- a/lib/thor/parser/arguments.rb +++ b/lib/thor/parser/arguments.rb @@ -1,5 +1,5 @@ class Thor - class Arguments #:nodoc: # rubocop:disable ClassLength + class Arguments #:nodoc: NUMERIC = /[-+]?(\d*\.\d+|\d+)/ # Receives an array of args and returns two arrays, one with arguments @@ -30,11 +30,7 @@ def initialize(arguments = []) arguments.each do |argument| if !argument.default.nil? - begin - @assigns[argument.human_name] = argument.default.dup - rescue TypeError # Compatibility shim for un-dup-able Fixnum in Ruby < 2.4 - @assigns[argument.human_name] = argument.default - end + @assigns[argument.human_name] = argument.default.dup elsif argument.required? @non_assigned_required << argument end @@ -121,8 +117,18 @@ def parse_hash(name) # def parse_array(name) return shift if peek.is_a?(Array) + array = [] - array << shift while current_is_value? + + while current_is_value? + value = shift + + if !value.empty? + validate_enum_value!(name, value, "Expected all values of '%s' to be one of %s; got %s") + end + + array << value + end array end @@ -138,11 +144,9 @@ def parse_numeric(name) end value = $&.index(".") ? shift.to_f : shift.to_i - if @switches.is_a?(Hash) && switch = @switches[name] - if switch.enum && !switch.enum.include?(value) - raise MalformattedArgumentError, "Expected '#{name}' to be one of #{switch.enum.join(', ')}; got #{value}" - end - end + + validate_enum_value!(name, value, "Expected '%s' to be one of %s; got %s") + value end @@ -156,15 +160,27 @@ def parse_string(name) nil else value = shift - if @switches.is_a?(Hash) && switch = @switches[name] - if switch.enum && !switch.enum.include?(value) - raise MalformattedArgumentError, "Expected '#{name}' to be one of #{switch.enum.join(', ')}; got #{value}" - end - end + + validate_enum_value!(name, value, "Expected '%s' to be one of %s; got %s") + value end end + # Raises an error if the switch is an enum and the values aren't included on it. + # + def validate_enum_value!(name, value, message) + return unless @switches.is_a?(Hash) + + switch = @switches[name] + + return unless switch + + if switch.enum && !switch.enum.include?(value) + raise MalformattedArgumentError, message % [name, switch.enum_to_s, value] + end + end + # Raises an error if @non_assigned_required array is not empty. # def check_requirement! diff --git a/lib/thor/parser/option.rb b/lib/thor/parser/option.rb index 23092462c..c909c5cfb 100644 --- a/lib/thor/parser/option.rb +++ b/lib/thor/parser/option.rb @@ -11,7 +11,7 @@ def initialize(name, options = {}) super @lazy_default = options[:lazy_default] @group = options[:group].to_s.capitalize if options[:group] - @aliases = Array(options[:aliases]) + @aliases = normalize_aliases(options[:aliases]) @hide = options[:hide] @inverse = options[:inverse] end @@ -59,7 +59,7 @@ def self.parse(key, value) default = nil if VALID_TYPES.include?(value) value - elsif required = (value == :required) # rubocop:disable AssignmentInCondition + elsif required = (value == :required) # rubocop:disable Lint/AssignmentInCondition :string end when TrueClass, FalseClass @@ -70,7 +70,7 @@ def self.parse(key, value) value.class.name.downcase.to_sym end - new(name.to_s, :required => required, :type => type, :default => default, :aliases => aliases) + new(name.to_s, required: required, type: type, default: default, aliases: aliases) end def switch_name @@ -84,10 +84,23 @@ def human_name def usage(padding = 0) sample = [ sample_banner, inverse_sample ].compact.join(", ") + aliases_for_usage.ljust(padding) + sample + end + + def aliases_for_usage if aliases.empty? - (" " * padding) << sample + "" + else + "#{aliases.join(', ')}, " + end + end + + def show_default? + case default + when TrueClass, FalseClass + true else - "#{aliases.join(', ')}, #{sample}" + super end end @@ -111,7 +124,7 @@ def sample_banner end def inverse_sample - return if !boolean? || name =~ /^(force|no-.*)$/ + return if !boolean? || name =~ /^(force|\Ano[\-_])$/ case @inverse when Symbol, String @@ -149,8 +162,8 @@ def validate_default_type! raise ArgumentError, err elsif @check_default_type == nil Thor.deprecation_warning "#{err}.\n" + - 'This will be rejected in the future unless you explicitly pass the options `check_default_type: false`' + - ' or call `allow_incompatible_default_type!` in your code' + "This will be rejected in the future unless you explicitly pass the options `check_default_type: false`" + + " or call `allow_incompatible_default_type!` in your code" end end end @@ -166,5 +179,11 @@ def undasherize(str) def dasherize(str) (str.length > 1 ? "--" : "-") + str.tr("_", "-") end + + private + + def normalize_aliases(aliases) + Array(aliases).map { |short| short.to_s.sub(/^(?!\-)/, "-") } + end end end diff --git a/lib/thor/parser/options.rb b/lib/thor/parser/options.rb index 3d560d3de..984a8ab27 100644 --- a/lib/thor/parser/options.rb +++ b/lib/thor/parser/options.rb @@ -1,5 +1,5 @@ class Thor - class Options < Arguments #:nodoc: # rubocop:disable ClassLength + class Options < Arguments #:nodoc: LONG_RE = /^(--\w+(?:-\w+)*)$/ SHORT_RE = /^(-[a-z])$/i EQ_RE = /^(--\w+(?:-\w+)*|-[a-z])=(.*)$/i @@ -29,8 +29,10 @@ def self.to_switches(options) # # If +stop_on_unknown+ is true, #parse will stop as soon as it encounters # an unknown option or a regular argument. - def initialize(hash_options = {}, defaults = {}, stop_on_unknown = false, disable_required_check = false) + def initialize(hash_options = {}, defaults = {}, stop_on_unknown = false, disable_required_check = false, relations = {}) @stop_on_unknown = stop_on_unknown + @exclusives = (relations[:exclusive_option_names] || []).select{|array| !array.empty?} + @at_least_ones = (relations[:at_least_one_option_names] || []).select{|array| !array.empty?} @disable_required_check = disable_required_check options = hash_options.values super(options) @@ -45,12 +47,12 @@ def initialize(hash_options = {}, defaults = {}, stop_on_unknown = false, disabl @switches = {} @extra = [] @stopped_parsing_after_extra_index = nil + @is_treated_as_value = false options.each do |option| @switches[option.switch_name] = option - option.aliases.each do |short| - name = short.to_s.sub(/^(?!\-)/, "-") + option.aliases.each do |name| @shorts[name] ||= option.switch_name end end @@ -74,8 +76,19 @@ def peek end end - def parse(args) # rubocop:disable MethodLength + def shift + @is_treated_as_value = false + super + end + + def unshift(arg, is_value: false) + @is_treated_as_value = is_value + super(arg) + end + + def parse(args) # rubocop:disable Metrics/MethodLength @pile = args.dup + @is_treated_as_value = false @parsing_options = true while peek @@ -88,7 +101,10 @@ def parse(args) # rubocop:disable MethodLength when SHORT_SQ_RE unshift($1.split("").map { |f| "-#{f}" }) next - when EQ_RE, SHORT_NUM + when EQ_RE + unshift($2, is_value: true) + switch = $1 + when SHORT_NUM unshift($2) switch = $1 when LONG_RE, SHORT_RE @@ -117,12 +133,38 @@ def parse(args) # rubocop:disable MethodLength end check_requirement! unless @disable_required_check + check_exclusive! + check_at_least_one! assigns = Thor::CoreExt::HashWithIndifferentAccess.new(@assigns) assigns.freeze assigns end + def check_exclusive! + opts = @assigns.keys + # When option A and B are exclusive, if A and B are given at the same time, + # the diffrence of argument array size will decrease. + found = @exclusives.find{ |ex| (ex - opts).size < ex.size - 1 } + if found + names = names_to_switch_names(found & opts).map{|n| "'#{n}'"} + class_name = self.class.name.split("::").last.downcase + fail ExclusiveArgumentError, "Found exclusive #{class_name} #{names.join(", ")}" + end + end + + def check_at_least_one! + opts = @assigns.keys + # When at least one is required of the options A and B, + # if the both options were not given, none? would be true. + found = @at_least_ones.find{ |one_reqs| one_reqs.none?{ |o| opts.include? o} } + if found + names = names_to_switch_names(found).map{|n| "'#{n}'"} + class_name = self.class.name.split("::").last.downcase + fail AtLeastOneRequiredArgumentError, "Not found at least one of required #{class_name} #{names.join(", ")}" + end + end + def check_unknown! to_check = @stopped_parsing_after_extra_index ? @extra[0...@stopped_parsing_after_extra_index] : @extra @@ -133,6 +175,17 @@ def check_unknown! protected + # Option names changes to swith name or human name + def names_to_switch_names(names = []) + @switches.map do |_, o| + if names.include? o.name + o.respond_to?(:switch_name) ? o.switch_name : o.human_name + else + nil + end + end.compact + end + def assign_result!(option, result) if option.repeatable && option.type == :hash (@assigns[option.human_name] ||= {}).merge!(result) @@ -148,6 +201,7 @@ def assign_result!(option, result) # Two booleans are returned. The first is true if the current value # starts with a hyphen; the second is true if it is a registered switch. def current_is_switch? + return [false, false] if @is_treated_as_value case peek when LONG_RE, SHORT_RE, EQ_RE, SHORT_NUM [true, switch?($1)] @@ -159,6 +213,7 @@ def current_is_switch? end def current_is_switch_formatted? + return false if @is_treated_as_value case peek when LONG_RE, SHORT_RE, EQ_RE, SHORT_NUM, SHORT_SQ_RE true @@ -168,6 +223,7 @@ def current_is_switch_formatted? end def current_is_value? + return true if @is_treated_as_value peek && (!parsing_options? || super) end @@ -176,7 +232,7 @@ def switch?(arg) end def switch_option(arg) - if match = no_or_skip?(arg) # rubocop:disable AssignmentInCondition + if match = no_or_skip?(arg) # rubocop:disable Lint/AssignmentInCondition @switches[arg] || @switches["--#{match}"] else @switches[arg] diff --git a/lib/thor/rake_compat.rb b/lib/thor/rake_compat.rb index 36e1edf37..f458b2d82 100644 --- a/lib/thor/rake_compat.rb +++ b/lib/thor/rake_compat.rb @@ -41,7 +41,7 @@ def self.included(base) def task(*) task = super - if klass = Thor::RakeCompat.rake_classes.last # rubocop:disable AssignmentInCondition + if klass = Thor::RakeCompat.rake_classes.last # rubocop:disable Lint/AssignmentInCondition non_namespaced_name = task.name.split(":").last description = non_namespaced_name @@ -59,7 +59,7 @@ def task(*) end def namespace(name) - if klass = Thor::RakeCompat.rake_classes.last # rubocop:disable AssignmentInCondition + if klass = Thor::RakeCompat.rake_classes.last # rubocop:disable Lint/AssignmentInCondition const_name = Thor::Util.camel_case(name.to_s).to_sym klass.const_set(const_name, Class.new(Thor)) new_klass = klass.const_get(const_name) diff --git a/lib/thor/runner.rb b/lib/thor/runner.rb index 7b1b8d9b1..e1a0f3394 100644 --- a/lib/thor/runner.rb +++ b/lib/thor/runner.rb @@ -2,12 +2,10 @@ require_relative "group" require "yaml" -require "digest/md5" +require "digest/sha2" require "pathname" -class Thor::Runner < Thor #:nodoc: # rubocop:disable ClassLength - autoload :OpenURI, "open-uri" - +class Thor::Runner < Thor #:nodoc: map "-T" => :list, "-i" => :install, "-u" => :update, "-v" => :version def self.banner(command, all = false, subcommand = false) @@ -25,7 +23,7 @@ def help(meth = nil) initialize_thorfiles(meth) klass, command = Thor::Util.find_class_and_command_by_namespace(meth) self.class.handle_no_command_error(command, false) if klass.nil? - klass.start(["-h", command].compact, :shell => shell) + klass.start(["-h", command].compact, shell: shell) else super end @@ -40,30 +38,42 @@ def method_missing(meth, *args) klass, command = Thor::Util.find_class_and_command_by_namespace(meth) self.class.handle_no_command_error(command, false) if klass.nil? args.unshift(command) if command - klass.start(args, :shell => shell) + klass.start(args, shell: shell) end desc "install NAME", "Install an optionally named Thor file into your system commands" - method_options :as => :string, :relative => :boolean, :force => :boolean - def install(name) # rubocop:disable MethodLength + method_options as: :string, relative: :boolean, force: :boolean + def install(name) # rubocop:disable Metrics/MethodLength initialize_thorfiles - # If a directory name is provided as the argument, look for a 'main.thor' - # command in said directory. - begin - if File.directory?(File.expand_path(name)) - base = File.join(name, "main.thor") - package = :directory - contents = open(base, &:read) - else - base = name - package = :file - contents = open(name, &:read) + is_uri = name =~ %r{^https?\://} + + if is_uri + base = name + package = :file + require "open-uri" + begin + contents = URI.open(name, &:read) + rescue OpenURI::HTTPError + raise Error, "Error opening URI '#{name}'" + end + else + # If a directory name is provided as the argument, look for a 'main.thor' + # command in said directory. + begin + if File.directory?(File.expand_path(name)) + base = File.join(name, "main.thor") + package = :directory + contents = File.open(base, &:read) + else + base = name + package = :file + require "open-uri" + contents = URI.open(name, &:read) + end + rescue Errno::ENOENT + raise Error, "Error opening file '#{name}'" end - rescue OpenURI::HTTPError - raise Error, "Error opening URI '#{name}'" - rescue Errno::ENOENT - raise Error, "Error opening file '#{name}'" end say "Your Thorfile contains:" @@ -84,16 +94,16 @@ def install(name) # rubocop:disable MethodLength as = basename if as.empty? end - location = if options[:relative] || name =~ %r{^https?://} + location = if options[:relative] || is_uri name else File.expand_path(name) end thor_yaml[as] = { - :filename => Digest::MD5.hexdigest(name + as), - :location => location, - :namespaces => Thor::Util.namespaces_in_content(contents, base) + filename: Digest::SHA256.hexdigest(name + as), + location: location, + namespaces: Thor::Util.namespaces_in_content(contents, base) } save_yaml(thor_yaml) @@ -154,14 +164,14 @@ def update(name) end desc "installed", "List the installed Thor modules and commands" - method_options :internal => :boolean + method_options internal: :boolean def installed initialize_thorfiles(nil, true) display_klasses(true, options["internal"]) end desc "list [SEARCH]", "List the available thor commands (--substring means .*SEARCH)" - method_options :substring => :boolean, :group => :string, :all => :boolean, :debug => :boolean + method_options substring: :boolean, group: :string, all: :boolean, debug: :boolean def list(search = "") initialize_thorfiles @@ -303,7 +313,7 @@ def display_commands(namespace, list) #:nodoc: say shell.set_color(namespace, :blue, true) say "-" * namespace.size - print_table(list, :truncate => true) + print_table(list, truncate: true) say end alias_method :display_tasks, :display_commands diff --git a/lib/thor/shell.rb b/lib/thor/shell.rb index f3719ce54..0e6113a19 100644 --- a/lib/thor/shell.rb +++ b/lib/thor/shell.rb @@ -75,7 +75,7 @@ def with_padding # Allow shell to be shared between invocations. # def _shared_configuration #:nodoc: - super.merge!(:shell => shell) + super.merge!(shell: shell) end end end diff --git a/lib/thor/shell/basic.rb b/lib/thor/shell/basic.rb index 23320db0e..e20f132c0 100644 --- a/lib/thor/shell/basic.rb +++ b/lib/thor/shell/basic.rb @@ -1,8 +1,10 @@ +require_relative "column_printer" +require_relative "table_printer" +require_relative "wrapped_printer" + class Thor module Shell class Basic - DEFAULT_TERMINAL_WIDTH = 80 - attr_accessor :base attr_reader :padding @@ -128,13 +130,14 @@ def say_error(message = "", color = nil, force_new_line = (message.to_s !~ /( |\ def say_status(status, message, log_status = true) return if quiet? || log_status == false spaces = " " * (padding + 1) - color = log_status.is_a?(Symbol) ? log_status : :green - status = status.to_s.rjust(12) + margin = " " * status.length + spaces + + color = log_status.is_a?(Symbol) ? log_status : :green status = set_color status, color, true if color - buffer = "#{status}#{spaces}#{message}" - buffer = "#{buffer}\n" unless buffer.end_with?("\n") + message = message.to_s.chomp.gsub(/(? false) =~ is?(:yes)) + !!(ask(statement, color, add_to_history: false) =~ is?(:yes)) end # Make a question the to user and returns true if the user replies "n" or # "no". # def no?(statement, color = nil) - !!(ask(statement, color, :add_to_history => false) =~ is?(:no)) + !!(ask(statement, color, add_to_history: false) =~ is?(:no)) end # Prints values in columns @@ -160,16 +163,8 @@ def no?(statement, color = nil) # Array[String, String, ...] # def print_in_columns(array) - return if array.empty? - colwidth = (array.map { |el| el.to_s.size }.max || 0) + 2 - array.each_with_index do |value, index| - # Don't output trailing spaces when printing the last column - if ((((index + 1) % (terminal_width / colwidth))).zero? && !index.zero?) || index + 1 == array.length - stdout.puts value - else - stdout.printf("%-#{colwidth}s", value) - end - end + printer = ColumnPrinter.new(stdout) + printer.print(array) end # Prints a table. @@ -180,58 +175,11 @@ def print_in_columns(array) # ==== Options # indent:: Indent the first column by indent value. # colwidth:: Force the first column to colwidth spaces wide. + # borders:: Adds ascii borders. # - def print_table(array, options = {}) # rubocop:disable MethodLength - return if array.empty? - - formats = [] - indent = options[:indent].to_i - colwidth = options[:colwidth] - options[:truncate] = terminal_width if options[:truncate] == true - - formats << "%-#{colwidth + 2}s".dup if colwidth - start = colwidth ? 1 : 0 - - colcount = array.max { |a, b| a.size <=> b.size }.size - - maximas = [] - - start.upto(colcount - 1) do |index| - maxima = array.map { |row| row[index] ? row[index].to_s.size : 0 }.max - maximas << maxima - formats << if index == colcount - 1 - # Don't output 2 trailing spaces when printing the last column - "%-s".dup - else - "%-#{maxima + 2}s".dup - end - end - - formats[0] = formats[0].insert(0, " " * indent) - formats << "%s" - - array.each do |row| - sentence = "".dup - - row.each_with_index do |column, index| - maxima = maximas[index] - - f = if column.is_a?(Numeric) - if index == row.size - 1 - # Don't output 2 trailing spaces when printing the last column - "%#{maxima}s" - else - "%#{maxima}s " - end - else - formats[index] - end - sentence << f % column.to_s - end - - sentence = truncate(sentence, options[:truncate]) if options[:truncate] - stdout.puts sentence - end + def print_table(array, options = {}) # rubocop:disable Metrics/MethodLength + printer = TablePrinter.new(stdout, options) + printer.print(array) end # Prints a long string, word-wrapping the text to the current width of the @@ -244,33 +192,8 @@ def print_table(array, options = {}) # rubocop:disable MethodLength # indent:: Indent each line of the printed paragraph by indent value. # def print_wrapped(message, options = {}) - indent = options[:indent] || 0 - width = terminal_width - indent - paras = message.split("\n\n") - - paras.map! do |unwrapped| - words = unwrapped.split(" ") - counter = words.first.length - words.inject do |memo, word| - word = word.gsub(/\n\005/, "\n").gsub(/\005/, "\n") - counter = 0 if word.include? "\n" - if (counter + word.length + 1) < width - memo = "#{memo} #{word}" - counter += (word.length + 1) - else - memo = "#{memo}\n#{word}" - counter = word.length - end - memo - end - end.compact! - - paras.each do |para| - para.split("\n").each do |line| - stdout.puts line.insert(0, " " * indent) - end - stdout.puts unless para == paras.last - end + printer = WrappedPrinter.new(stdout, options) + printer.print(message) end # Deals with file collision and returns true if the file should be @@ -288,7 +211,7 @@ def file_collision(destination) loop do answer = ask( %[Overwrite #{destination}? (enter "h" for help) #{options}], - :add_to_history => false + add_to_history: false ) case answer @@ -315,24 +238,11 @@ def file_collision(destination) say "Please specify merge tool to `THOR_MERGE` env." else - say file_collision_help + say file_collision_help(block_given?) end end end - # This code was copied from Rake, available under MIT-LICENSE - # Copyright (c) 2003, 2004 Jim Weirich - def terminal_width - result = if ENV["THOR_COLUMNS"] - ENV["THOR_COLUMNS"].to_i - else - unix? ? dynamic_width : DEFAULT_TERMINAL_WIDTH - end - result < 10 ? DEFAULT_TERMINAL_WIDTH : result - rescue - DEFAULT_TERMINAL_WIDTH - end - # Called if something goes wrong during the execution. This is used by Thor # internally and should not be used inside your scripts. If something went # wrong, you can always raise an exception. If you raise a Thor::Error, it @@ -383,16 +293,21 @@ def is?(value) #:nodoc: end end - def file_collision_help #:nodoc: - <<-HELP + def file_collision_help(block_given) #:nodoc: + help = <<-HELP Y - yes, overwrite n - no, do not overwrite a - all, overwrite this and all others q - quit, abort - d - diff, show the differences between the old and the new h - help, show this help - m - merge, run merge tool HELP + if block_given + help << <<-HELP + d - diff, show the differences between the old and the new + m - merge, run merge tool + HELP + end + help end def show_diff(destination, content) #:nodoc: @@ -410,46 +325,8 @@ def quiet? #:nodoc: mute? || (base && base.options[:quiet]) end - # Calculate the dynamic width of the terminal - def dynamic_width - @dynamic_width ||= (dynamic_width_stty.nonzero? || dynamic_width_tput) - end - - def dynamic_width_stty - `stty size 2>/dev/null`.split[1].to_i - end - - def dynamic_width_tput - `tput cols 2>/dev/null`.to_i - end - def unix? - RUBY_PLATFORM =~ /(aix|darwin|linux|(net|free|open)bsd|cygwin|solaris|irix|hpux)/i - end - - def truncate(string, width) - as_unicode do - chars = string.chars.to_a - if chars.length <= width - chars.join - else - chars[0, width - 3].join + "..." - end - end - end - - if "".respond_to?(:encode) - def as_unicode - yield - end - else - def as_unicode - old = $KCODE - $KCODE = "U" - yield - ensure - $KCODE = old - end + Terminal.unix? end def ask_simply(statement, color, options) diff --git a/lib/thor/shell/color.rb b/lib/thor/shell/color.rb index 33e9d9d99..78e507e5a 100644 --- a/lib/thor/shell/color.rb +++ b/lib/thor/shell/color.rb @@ -1,4 +1,5 @@ require_relative "basic" +require_relative "lcs_diff" class Thor module Shell @@ -6,6 +7,8 @@ module Shell # Thor::Shell::Basic to see all available methods. # class Color < Basic + include LCSDiff + # Embed in a String to clear all previous ANSI sequences. CLEAR = "\e[0m" # The start of an ANSI bold sequence. @@ -105,52 +108,7 @@ def are_colors_supported? end def are_colors_disabled? - !ENV['NO_COLOR'].nil? - end - - # Overwrite show_diff to show diff with colors if Diff::LCS is - # available. - # - def show_diff(destination, content) #:nodoc: - if diff_lcs_loaded? && ENV["THOR_DIFF"].nil? && ENV["RAILS_DIFF"].nil? - actual = File.binread(destination).to_s.split("\n") - content = content.to_s.split("\n") - - Diff::LCS.sdiff(actual, content).each do |diff| - output_diff_line(diff) - end - else - super - end - end - - def output_diff_line(diff) #:nodoc: - case diff.action - when "-" - say "- #{diff.old_element.chomp}", :red, true - when "+" - say "+ #{diff.new_element.chomp}", :green, true - when "!" - say "- #{diff.old_element.chomp}", :red, true - say "+ #{diff.new_element.chomp}", :green, true - else - say " #{diff.old_element.chomp}", nil, true - end - end - - # Check if Diff::LCS is loaded. If it is, use it to create pretty output - # for diff. - # - def diff_lcs_loaded? #:nodoc: - return true if defined?(Diff::LCS) - return @diff_lcs_loaded unless @diff_lcs_loaded.nil? - - @diff_lcs_loaded = begin - require "diff/lcs" - true - rescue LoadError - false - end + !ENV["NO_COLOR"].nil? && !ENV["NO_COLOR"].empty? end end end diff --git a/lib/thor/shell/column_printer.rb b/lib/thor/shell/column_printer.rb new file mode 100644 index 000000000..983b4fdec --- /dev/null +++ b/lib/thor/shell/column_printer.rb @@ -0,0 +1,29 @@ +require_relative "terminal" + +class Thor + module Shell + class ColumnPrinter + attr_reader :stdout, :options + + def initialize(stdout, options = {}) + @stdout = stdout + @options = options + @indent = options[:indent].to_i + end + + def print(array) + return if array.empty? + colwidth = (array.map { |el| el.to_s.size }.max || 0) + 2 + array.each_with_index do |value, index| + # Don't output trailing spaces when printing the last column + if ((((index + 1) % (Terminal.terminal_width / colwidth))).zero? && !index.zero?) || index + 1 == array.length + stdout.puts value + else + stdout.printf("%-#{colwidth}s", value) + end + end + end + end + end +end + diff --git a/lib/thor/shell/html.rb b/lib/thor/shell/html.rb index 352a6f553..547a9da59 100644 --- a/lib/thor/shell/html.rb +++ b/lib/thor/shell/html.rb @@ -1,4 +1,5 @@ require_relative "basic" +require_relative "lcs_diff" class Thor module Shell @@ -6,6 +7,8 @@ module Shell # Thor::Shell::Basic to see all available methods. # class HTML < Basic + include LCSDiff + # The start of an HTML bold sequence. BOLD = "font-weight: bold" @@ -76,51 +79,6 @@ def ask(statement, color = nil) def can_display_colors? true end - - # Overwrite show_diff to show diff with colors if Diff::LCS is - # available. - # - def show_diff(destination, content) #:nodoc: - if diff_lcs_loaded? && ENV["THOR_DIFF"].nil? && ENV["RAILS_DIFF"].nil? - actual = File.binread(destination).to_s.split("\n") - content = content.to_s.split("\n") - - Diff::LCS.sdiff(actual, content).each do |diff| - output_diff_line(diff) - end - else - super - end - end - - def output_diff_line(diff) #:nodoc: - case diff.action - when "-" - say "- #{diff.old_element.chomp}", :red, true - when "+" - say "+ #{diff.new_element.chomp}", :green, true - when "!" - say "- #{diff.old_element.chomp}", :red, true - say "+ #{diff.new_element.chomp}", :green, true - else - say " #{diff.old_element.chomp}", nil, true - end - end - - # Check if Diff::LCS is loaded. If it is, use it to create pretty output - # for diff. - # - def diff_lcs_loaded? #:nodoc: - return true if defined?(Diff::LCS) - return @diff_lcs_loaded unless @diff_lcs_loaded.nil? - - @diff_lcs_loaded = begin - require "diff/lcs" - true - rescue LoadError - false - end - end end end end diff --git a/lib/thor/shell/lcs_diff.rb b/lib/thor/shell/lcs_diff.rb new file mode 100644 index 000000000..81268a9f0 --- /dev/null +++ b/lib/thor/shell/lcs_diff.rb @@ -0,0 +1,49 @@ +module LCSDiff +protected + + # Overwrite show_diff to show diff with colors if Diff::LCS is + # available. + def show_diff(destination, content) #:nodoc: + if diff_lcs_loaded? && ENV["THOR_DIFF"].nil? && ENV["RAILS_DIFF"].nil? + actual = File.binread(destination).to_s.split("\n") + content = content.to_s.split("\n") + + Diff::LCS.sdiff(actual, content).each do |diff| + output_diff_line(diff) + end + else + super + end + end + +private + + def output_diff_line(diff) #:nodoc: + case diff.action + when "-" + say "- #{diff.old_element.chomp}", :red, true + when "+" + say "+ #{diff.new_element.chomp}", :green, true + when "!" + say "- #{diff.old_element.chomp}", :red, true + say "+ #{diff.new_element.chomp}", :green, true + else + say " #{diff.old_element.chomp}", nil, true + end + end + + # Check if Diff::LCS is loaded. If it is, use it to create pretty output + # for diff. + def diff_lcs_loaded? #:nodoc: + return true if defined?(Diff::LCS) + return @diff_lcs_loaded unless @diff_lcs_loaded.nil? + + @diff_lcs_loaded = begin + require "diff/lcs" + true + rescue LoadError + false + end + end + +end diff --git a/lib/thor/shell/table_printer.rb b/lib/thor/shell/table_printer.rb new file mode 100644 index 000000000..888289541 --- /dev/null +++ b/lib/thor/shell/table_printer.rb @@ -0,0 +1,134 @@ +require_relative "column_printer" +require_relative "terminal" + +class Thor + module Shell + class TablePrinter < ColumnPrinter + BORDER_SEPARATOR = :separator + + def initialize(stdout, options = {}) + super + @formats = [] + @maximas = [] + @colwidth = options[:colwidth] + @truncate = options[:truncate] == true ? Terminal.terminal_width : options[:truncate] + @padding = 1 + end + + def print(array) + return if array.empty? + + prepare(array) + + print_border_separator if options[:borders] + + array.each do |row| + if options[:borders] && row == BORDER_SEPARATOR + print_border_separator + next + end + + sentence = "".dup + + row.each_with_index do |column, index| + sentence << format_cell(column, row.size, index) + end + + sentence = truncate(sentence) + sentence << "|" if options[:borders] + stdout.puts indentation + sentence + + end + print_border_separator if options[:borders] + end + + private + + def prepare(array) + array = array.reject{|row| row == BORDER_SEPARATOR } + + @formats << "%-#{@colwidth + 2}s".dup if @colwidth + start = @colwidth ? 1 : 0 + + colcount = array.max { |a, b| a.size <=> b.size }.size + + start.upto(colcount - 1) do |index| + maxima = array.map { |row| row[index] ? row[index].to_s.size : 0 }.max + + @maximas << maxima + @formats << if options[:borders] + "%-#{maxima}s".dup + elsif index == colcount - 1 + # Don't output 2 trailing spaces when printing the last column + "%-s".dup + else + "%-#{maxima + 2}s".dup + end + end + + @formats << "%s" + end + + def format_cell(column, row_size, index) + maxima = @maximas[index] + + f = if column.is_a?(Numeric) + if options[:borders] + # With borders we handle padding separately + "%#{maxima}s" + elsif index == row_size - 1 + # Don't output 2 trailing spaces when printing the last column + "%#{maxima}s" + else + "%#{maxima}s " + end + else + @formats[index] + end + + cell = "".dup + cell << "|" + " " * @padding if options[:borders] + cell << f % column.to_s + cell << " " * @padding if options[:borders] + cell + end + + def print_border_separator + separator = @maximas.map do |maxima| + "+" + "-" * (maxima + 2 * @padding) + end + stdout.puts indentation + separator.join + "+" + end + + def truncate(string) + return string unless @truncate + as_unicode do + chars = string.chars.to_a + if chars.length <= @truncate + chars.join + else + chars[0, @truncate - 3 - @indent].join + "..." + end + end + end + + def indentation + " " * @indent + end + + if "".respond_to?(:encode) + def as_unicode + yield + end + else + def as_unicode + old = $KCODE # rubocop:disable Style/GlobalVars + $KCODE = "U" # rubocop:disable Style/GlobalVars + yield + ensure + $KCODE = old # rubocop:disable Style/GlobalVars + end + end + end + end +end diff --git a/lib/thor/shell/terminal.rb b/lib/thor/shell/terminal.rb new file mode 100644 index 000000000..5716f0031 --- /dev/null +++ b/lib/thor/shell/terminal.rb @@ -0,0 +1,42 @@ +class Thor + module Shell + module Terminal + DEFAULT_TERMINAL_WIDTH = 80 + + class << self + # This code was copied from Rake, available under MIT-LICENSE + # Copyright (c) 2003, 2004 Jim Weirich + def terminal_width + result = if ENV["THOR_COLUMNS"] + ENV["THOR_COLUMNS"].to_i + else + unix? ? dynamic_width : DEFAULT_TERMINAL_WIDTH + end + result < 10 ? DEFAULT_TERMINAL_WIDTH : result + rescue + DEFAULT_TERMINAL_WIDTH + end + + def unix? + RUBY_PLATFORM =~ /(aix|darwin|linux|(net|free|open)bsd|cygwin|solaris)/i + end + + private + + # Calculate the dynamic width of the terminal + def dynamic_width + @dynamic_width ||= (dynamic_width_stty.nonzero? || dynamic_width_tput) + end + + def dynamic_width_stty + `stty size 2>/dev/null`.split[1].to_i + end + + def dynamic_width_tput + `tput cols 2>/dev/null`.to_i + end + + end + end + end +end diff --git a/lib/thor/shell/wrapped_printer.rb b/lib/thor/shell/wrapped_printer.rb new file mode 100644 index 000000000..a079c1d23 --- /dev/null +++ b/lib/thor/shell/wrapped_printer.rb @@ -0,0 +1,38 @@ +require_relative "column_printer" +require_relative "terminal" + +class Thor + module Shell + class WrappedPrinter < ColumnPrinter + def print(message) + width = Terminal.terminal_width - @indent + paras = message.split("\n\n") + + paras.map! do |unwrapped| + words = unwrapped.split(" ") + counter = words.first.length + words.inject do |memo, word| + word = word.gsub(/\n\005/, "\n").gsub(/\005/, "\n") + counter = 0 if word.include? "\n" + if (counter + word.length + 1) < width + memo = "#{memo} #{word}" + counter += (word.length + 1) + else + memo = "#{memo}\n#{word}" + counter = word.length + end + memo + end + end.compact! + + paras.each do |para| + para.split("\n").each do |line| + stdout.puts line.insert(0, " " * @indent) + end + stdout.puts unless para == paras.last + end + end + end + end +end + diff --git a/lib/thor/util.rb b/lib/thor/util.rb index 628c60485..8a2ccef10 100644 --- a/lib/thor/util.rb +++ b/lib/thor/util.rb @@ -90,7 +90,7 @@ def thor_classes_in(klass) def snake_case(str) return str.downcase if str =~ /^[A-Z_]+$/ str.gsub(/\B[A-Z]/, '_\&').squeeze("_") =~ /_*(.*)/ - $+.downcase + Regexp.last_match(-1).downcase end # Receives a string and convert it to camel case. camel_case returns CamelCase. @@ -130,9 +130,10 @@ def camel_case(str) # def find_class_and_command_by_namespace(namespace, fallback = true) if namespace.include?(":") # look for a namespaced command - pieces = namespace.split(":") - command = pieces.pop - klass = Thor::Util.find_by_namespace(pieces.join(":")) + *pieces, command = namespace.split(":") + namespace = pieces.join(":") + namespace = "default" if namespace.empty? + klass = Thor::Base.subclasses.detect { |thor| thor.namespace == namespace && thor.commands.keys.include?(command) } end unless klass # look for a Thor::Group with the right name klass = Thor::Util.find_by_namespace(namespace) @@ -150,7 +151,7 @@ def find_class_and_command_by_namespace(namespace, fallback = true) # inside the sandbox to avoid namespacing conflicts. # def load_thorfile(path, content = nil, debug = false) - content ||= File.binread(path) + content ||= File.read(path) begin Thor::Sandbox.class_eval(content, path) @@ -189,7 +190,7 @@ def user_home # Returns the root where thor files are located, depending on the OS. # def thor_root - File.join(user_home, ".thor").tr('\\', "/") + File.join(user_home, ".thor").tr("\\", "/") end # Returns the files in the thor root. On Windows thor_root will be something @@ -236,7 +237,7 @@ def ruby_command # symlink points to 'ruby_install_name' ruby = alternate_ruby if linked_ruby == ruby_name || linked_ruby == ruby end - rescue NotImplementedError # rubocop:disable HandleExceptions + rescue NotImplementedError # rubocop:disable Lint/HandleExceptions # just ignore on windows end end diff --git a/lib/thor/version.rb b/lib/thor/version.rb index 954dd4201..2002eee59 100644 --- a/lib/thor/version.rb +++ b/lib/thor/version.rb @@ -1,3 +1,3 @@ class Thor - VERSION = "1.1.0" + VERSION = "1.3.0" end diff --git a/spec/actions/create_file_spec.rb b/spec/actions/create_file_spec.rb index 1e0c934e0..963814e17 100644 --- a/spec/actions/create_file_spec.rb +++ b/spec/actions/create_file_spec.rb @@ -7,11 +7,11 @@ ::FileUtils.rm_rf(destination_root) end - def create_file(destination = nil, config = {}, options = {}) - @base = MyCounter.new([1, 2], options, :destination_root => destination_root) + def create_file(destination = nil, config = {}, options = {}, contents = "CONFIGURATION") + @base = MyCounter.new([1, 2], options, destination_root: destination_root) allow(@base).to receive(:file_name).and_return("rdoc") - @action = Thor::Actions::CreateFile.new(@base, destination, "CONFIGURATION", {:verbose => !@silence}.merge(config)) + @action = Thor::Actions::CreateFile.new(@base, destination, contents, {verbose: !@silence}.merge(config)) end def invoke! @@ -33,8 +33,16 @@ def silence! expect(File.exist?(File.join(destination_root, "doc/config.rb"))).to be true end + it "allows setting file permissions" do + create_file("config/private.key", perm: 0o600) + invoke! + + stat = File.stat(File.join(destination_root, "config/private.key")) + expect(stat.mode.to_s(8)).to eq "100600" + end + it "does not create a file if pretending" do - create_file("doc/config.rb", {}, :pretend => true) + create_file("doc/config.rb", {}, pretend: true) invoke! expect(File.exist?(File.join(destination_root, "doc/config.rb"))).to be false end @@ -82,22 +90,22 @@ def silence! end it "shows forced status to the user if force is given" do - expect(create_file("doc/config.rb", {}, :force => true)).not_to be_identical + expect(create_file("doc/config.rb", {}, force: true)).not_to be_identical expect(invoke!).to eq(" force doc/config.rb\n") end it "shows skipped status to the user if skip is given" do - expect(create_file("doc/config.rb", {}, :skip => true)).not_to be_identical + expect(create_file("doc/config.rb", {}, skip: true)).not_to be_identical expect(invoke!).to eq(" skip doc/config.rb\n") end it "shows forced status to the user if force is configured" do - expect(create_file("doc/config.rb", :force => true)).not_to be_identical + expect(create_file("doc/config.rb", force: true)).not_to be_identical expect(invoke!).to eq(" force doc/config.rb\n") end it "shows skipped status to the user if skip is configured" do - expect(create_file("doc/config.rb", :skip => true)).not_to be_identical + expect(create_file("doc/config.rb", skip: true)).not_to be_identical expect(invoke!).to eq(" skip doc/config.rb\n") end @@ -196,5 +204,12 @@ def silence! invoke! expect(@action.identical?).to be true end + + it "returns true if the destination file exists and is identical and contains multi-byte UTF-8 codepoints" do + create_file("doc/config.rb", {}, {}, "€") + expect(@action.identical?).to be false + invoke! + expect(@action.identical?).to be true + end end end diff --git a/spec/actions/create_link_spec.rb b/spec/actions/create_link_spec.rb index 2f79b692f..9625f7367 100644 --- a/spec/actions/create_link_spec.rb +++ b/spec/actions/create_link_spec.rb @@ -2,7 +2,7 @@ require "thor/actions" require "tempfile" -describe Thor::Actions::CreateLink, :unless => windows? do +describe Thor::Actions::CreateLink, unless: windows? do before do @hardlink_to = File.join(Dir.tmpdir, "linkdest.rb") ::FileUtils.rm_rf(destination_root) @@ -13,7 +13,7 @@ let(:options) { {} } let(:base) do - base = MyCounter.new([1, 2], options, :destination_root => destination_root) + base = MyCounter.new([1, 2], options, destination_root: destination_root) allow(base).to receive(:file_name).and_return("rdoc") base end @@ -38,7 +38,7 @@ def revoke! describe "#invoke!" do context "specifying :symbolic => true" do - let(:config) { {:symbolic => true} } + let(:config) { {symbolic: true} } it "creates a symbolic link" do invoke! @@ -49,7 +49,7 @@ def revoke! end context "specifying :symbolic => false" do - let(:config) { {:symbolic => false} } + let(:config) { {symbolic: false} } let(:destination) { @hardlink_to } it "creates a hard link" do @@ -68,7 +68,7 @@ def revoke! end context "specifying :pretend => true" do - let(:options) { {:pretend => true} } + let(:options) { {pretend: true} } it "does not create a link" do invoke! expect(File.exist?(File.join(destination_root, "doc/config.rb"))).to be false @@ -80,7 +80,7 @@ def revoke! end context "specifying :verbose => false" do - let(:config) { {:verbose => false} } + let(:config) { {verbose: false} } it "does not show any information" do expect(invoke!).to be_empty end diff --git a/spec/actions/directory_spec.rb b/spec/actions/directory_spec.rb index a1c5860cc..ea80e40f0 100644 --- a/spec/actions/directory_spec.rb +++ b/spec/actions/directory_spec.rb @@ -9,11 +9,11 @@ end def invoker - @invoker ||= WhinyGenerator.new([1, 2], {}, :destination_root => destination_root) + @invoker ||= WhinyGenerator.new([1, 2], {}, destination_root: destination_root) end def revoker - @revoker ||= WhinyGenerator.new([1, 2], {}, :destination_root => destination_root, :behavior => :revoke) + @revoker ||= WhinyGenerator.new([1, 2], {}, destination_root: destination_root, behavior: :revoke) end def invoke!(*args, &block) @@ -42,7 +42,7 @@ def exists_and_identical?(source_path, destination_path) end it "does not create a directory in pretend mode" do - invoke! "doc", "ghost", :pretend => true + invoke! "doc", "ghost", pretend: true expect(File.exist?("ghost")).to be false end @@ -57,7 +57,7 @@ def exists_and_identical?(source_path, destination_path) end it "copies only the first level files if recursive" do - invoke! ".", "commands", :recursive => false + invoke! ".", "commands", recursive: false file = File.join(destination_root, "commands", "group.thor") expect(File.exist?(file)).to be true @@ -70,7 +70,7 @@ def exists_and_identical?(source_path, destination_path) end it "ignores files within excluding/ directories when exclude_pattern is provided" do - invoke! "doc", "docs", :exclude_pattern => %r{excluding/} + invoke! "doc", "docs", exclude_pattern: %r{excluding/} file = File.join(destination_root, "docs", "excluding", "rdoc.rb") expect(File.exist?(file)).to be false end @@ -97,7 +97,7 @@ def exists_and_identical?(source_path, destination_path) end it "copies directories and preserves file mode" do - invoke! "preserve", "preserved", :mode => :preserve + invoke! "preserve", "preserved", mode: :preserve original = File.join(source_root, "preserve", "script.sh") copy = File.join(destination_root, "preserved", "script.sh") expect(File.stat(original).mode).to eq(File.stat(copy).mode) @@ -148,7 +148,7 @@ def exists_and_identical?(source_path, destination_path) expect(content).to match(%r{create app\{1\}/README}) end - context "windows temp directories", :if => windows? do + context "windows temp directories", if: windows? do let(:spec_dir) { File.join(@temp_dir, "spec") } before(:each) do diff --git a/spec/actions/empty_directory_spec.rb b/spec/actions/empty_directory_spec.rb index 7304a24e2..97fe3d95b 100644 --- a/spec/actions/empty_directory_spec.rb +++ b/spec/actions/empty_directory_spec.rb @@ -19,7 +19,7 @@ def revoke! end def base - @base ||= MyCounter.new([1, 2], {}, :destination_root => destination_root) + @base ||= MyCounter.new([1, 2], {}, destination_root: destination_root) end describe "#destination" do @@ -63,7 +63,7 @@ def base end it "does not create a directory if pretending" do - base.inside("foo", :pretend => true) do + base.inside("foo", pretend: true) do empty_directory("ghost") end expect(File.exist?(File.join(base.destination_root, "ghost"))).to be false diff --git a/spec/actions/file_manipulation_spec.rb b/spec/actions/file_manipulation_spec.rb index 8d9541993..a1b902af3 100644 --- a/spec/actions/file_manipulation_spec.rb +++ b/spec/actions/file_manipulation_spec.rb @@ -2,7 +2,7 @@ describe Thor::Actions do def runner(options = {}, behavior = :invoke) - @runner ||= MyCounter.new([1], options, :destination_root => destination_root, :behavior => behavior) + @runner ||= MyCounter.new([1], options, destination_root: destination_root, behavior: behavior) end def action(*args, &block) @@ -33,7 +33,7 @@ def file it "does not execute the command if pretending" do expect(FileUtils).not_to receive(:chmod_R) - runner(:pretend => true) + runner(pretend: true) action :chmod, "foo", 0755 end @@ -44,7 +44,7 @@ def file it "does not log status if required" do expect(FileUtils).to receive(:chmod_R).with(0755, file) - expect(action(:chmod, "foo", 0755, :verbose => false)).to be_empty + expect(action(:chmod, "foo", 0755, verbose: false)).to be_empty end end @@ -67,7 +67,7 @@ def file end it "copies file from source to default destination and preserves file mode" do - action :copy_file, "preserve/script.sh", :mode => :preserve + action :copy_file, "preserve/script.sh", mode: :preserve original = File.join(source_root, "preserve/script.sh") copy = File.join(destination_root, "preserve/script.sh") expect(File.stat(original).mode).to eq(File.stat(copy).mode) @@ -75,7 +75,7 @@ def file it "copies file from source to default destination and preserves file mode for templated filenames" do expect(runner).to receive(:filename).and_return("app") - action :copy_file, "preserve/%filename%.sh", :mode => :preserve + action :copy_file, "preserve/%filename%.sh", mode: :preserve original = File.join(source_root, "preserve/%filename%.sh") copy = File.join(destination_root, "preserve/app.sh") expect(File.stat(original).mode).to eq(File.stat(copy).mode) @@ -93,7 +93,7 @@ def file end end - describe "#link_file", :unless => windows? do + describe "#link_file", unless: windows? do it "links file from source to default destination" do action :link_file, "command.thor" exists_and_identical?("command.thor", "command.thor") @@ -144,7 +144,7 @@ def file it "accepts http remote sources" do body = "__start__\nHTTPFILE\n__end__\n" - stub_request(:get, "http://example.com/file.txt").to_return(:body => body.dup) + stub_request(:get, "http://example.com/file.txt").to_return(body: body.dup) action :get, "http://example.com/file.txt" do |content| expect(a_request(:get, "http://example.com/file.txt")).to have_been_made expect(content).to eq(body) @@ -153,12 +153,22 @@ def file it "accepts https remote sources" do body = "__start__\nHTTPSFILE\n__end__\n" - stub_request(:get, "https://example.com/file.txt").to_return(:body => body.dup) + stub_request(:get, "https://example.com/file.txt").to_return(body: body.dup) action :get, "https://example.com/file.txt" do |content| expect(a_request(:get, "https://example.com/file.txt")).to have_been_made expect(content).to eq(body) end end + + it "accepts http headers" do + body = "__start__\nHTTPFILE\n__end__\n" + headers = {"Content-Type" => "application/json"} + stub_request(:get, "https://example.com/file.txt").with(headers: headers).to_return(body: body.dup) + action :get, "https://example.com/file.txt", {http_headers: headers} do |content| + expect(a_request(:get, "https://example.com/file.txt")).to have_been_made + expect(content).to eq(body) + end + end end describe "#template" do @@ -214,7 +224,7 @@ def file it "accepts a context to use as the binding" do begin @klass = "FooBar" - action :template, "doc/config.rb", :context => eval("binding") + action :template, "doc/config.rb", context: eval("binding") expect(File.read(File.join(destination_root, "doc/config.rb"))).to eq("class FooBar; end\n") ensure remove_instance_variable(:@klass) @@ -256,13 +266,20 @@ def file expect(File.exist?(file)).to be false end + it "removes broken symlinks too" do + link_path = File.join(destination_root, "broken_symlink") + ::FileUtils.ln_s("invalid_reference", link_path) + action :remove_file, "broken_symlink" + expect(File.symlink?(link_path) || File.exist?(link_path)).to be false + end + it "removes directories too" do action :remove_dir, "doc" expect(File.exist?(File.join(destination_root, "doc"))).to be false end it "does not remove if pretending" do - runner(:pretend => true) + runner(pretend: true) action :remove_file, "doc/README" expect(File.exist?(file)).to be true end @@ -272,7 +289,7 @@ def file end it "does not log status if required" do - expect(action(:remove_file, "doc/README", :verbose => false)).to be_empty + expect(action(:remove_file, "doc/README", verbose: false)).to be_empty end end @@ -284,7 +301,7 @@ def file end it "does not replace if pretending" do - runner(:pretend => true) + runner(pretend: true) action :gsub_file, "doc/README", "__start__", "START" expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") end @@ -299,7 +316,7 @@ def file end it "does not log status if required" do - expect(action(:gsub_file, file, "__", :verbose => false) { |match| match * 2 }).to be_empty + expect(action(:gsub_file, file, "__", verbose: false) { |match| match * 2 }).to be_empty end end @@ -312,7 +329,7 @@ def file end it "does not replace if pretending" do - runner({ :pretend => true }, :revoke) + runner({pretend: true}, :revoke) action :gsub_file, "doc/README", "__start__", "START" expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") end @@ -330,37 +347,37 @@ def file it "does not log status if required" do runner({}, :revoke) - expect(action(:gsub_file, file, "__", :verbose => false) { |match| match * 2 }).to be_empty + expect(action(:gsub_file, file, "__", verbose: false) { |match| match * 2 }).to be_empty end end context "and force option" do it "replaces the content in the file" do runner({}, :revoke) - action :gsub_file, "doc/README", "__start__", "START", :force => true + action :gsub_file, "doc/README", "__start__", "START", force: true expect(File.binread(file)).to eq("START\nREADME\n__end__\n") end it "does not replace if pretending" do - runner({ :pretend => true }, :revoke) - action :gsub_file, "doc/README", "__start__", "START", :force => true + runner({pretend: true}, :revoke) + action :gsub_file, "doc/README", "__start__", "START", force: true expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") end it "replaces the content in the file when given a block" do runner({}, :revoke) - action(:gsub_file, "doc/README", "__start__", :force => true) { |match| match.gsub("__", "").upcase } + action(:gsub_file, "doc/README", "__start__", force: true) { |match| match.gsub("__", "").upcase } expect(File.binread(file)).to eq("START\nREADME\n__end__\n") end it "logs status" do runner({}, :revoke) - expect(action(:gsub_file, "doc/README", "__start__", "START", :force => true)).to eq(" gsub doc/README\n") + expect(action(:gsub_file, "doc/README", "__start__", "START", force: true)).to eq(" gsub doc/README\n") end it "does not log status if required" do runner({}, :revoke) - expect(action(:gsub_file, file, "__", :verbose => false, :force => true) { |match| match * 2 }).to be_empty + expect(action(:gsub_file, file, "__", verbose: false, force: true) { |match| match * 2 }).to be_empty end end end diff --git a/spec/actions/inject_into_file_spec.rb b/spec/actions/inject_into_file_spec.rb index 36b8d7b5d..6ba6dcd6e 100644 --- a/spec/actions/inject_into_file_spec.rb +++ b/spec/actions/inject_into_file_spec.rb @@ -9,11 +9,11 @@ end def invoker(options = {}) - @invoker ||= MyCounter.new([1, 2], options, :destination_root => destination_root) + @invoker ||= MyCounter.new([1, 2], options, destination_root: destination_root) end def revoker - @revoker ||= MyCounter.new([1, 2], {}, :destination_root => destination_root, :behavior => :revoke) + @revoker ||= MyCounter.new([1, 2], {}, destination_root: destination_root, behavior: :revoke) end def invoke!(*args, &block) @@ -30,30 +30,27 @@ def file describe "#invoke!" do it "changes the file adding content after the flag" do - invoke! "doc/README", "\nmore content", :after => "__start__" + invoke! "doc/README", "\nmore content", after: "__start__" expect(File.read(file)).to eq("__start__\nmore content\nREADME\n__end__\n") end - it "ignores duplicates before the after flag" do - invoke! "doc/README", "\nREADME", :after => "README" - expect(File.read(file)).to eq("__start__\nREADME\nREADME\n__end__\n") - end - it "changes the file adding content before the flag" do - invoke! "doc/README", "more content\n", :before => "__end__" + invoke! "doc/README", "more content\n", before: "__end__" expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") end - it "ignores duplicates before the before flag" do - invoke! "doc/README", "README\n", :before => "README" - expect(File.read(file)).to eq("__start__\nREADME\nREADME\n__end__\n") - end - it "appends content to the file if before and after arguments not provided" do invoke!("doc/README", "more content\n") expect(File.read(file)).to eq("__start__\nREADME\n__end__\nmore content\n") end + it "does not change the file if replacement present in the file" do + invoke!("doc/README", "more specific content\n") + expect(invoke!("doc/README", "more specific content\n")).to( + eq(" unchanged doc/README\n") + ) + end + it "does not change the file and logs the warning if flag not found in the file" do expect(invoke!("doc/README", "more content\n", after: "whatever")).to( eq("#{Thor::Actions::WARNINGS[:unchanged_no_flag]} doc/README\n") @@ -61,7 +58,7 @@ def file end it "accepts data as a block" do - invoke! "doc/README", :before => "__end__" do + invoke! "doc/README", before: "__end__" do "more content\n" end @@ -69,23 +66,56 @@ def file end it "logs status" do - expect(invoke!("doc/README", "\nmore content", :after => "__start__")).to eq(" insert doc/README\n") + expect(invoke!("doc/README", "\nmore content", after: "__start__")).to eq(" insert doc/README\n") + end + + it "logs status if pretending" do + invoker(pretend: true) + expect(invoke!("doc/README", "\nmore content", after: "__start__")).to eq(" insert doc/README\n") end it "does not change the file if pretending" do - invoker :pretend => true - invoke! "doc/README", "\nmore content", :after => "__start__" + invoker pretend: true + invoke! "doc/README", "\nmore content", after: "__start__" expect(File.read(file)).to eq("__start__\nREADME\n__end__\n") end it "does not change the file if already includes content" do - invoke! "doc/README", :before => "__end__" do + invoke! "doc/README", before: "__end__" do "more content\n" end expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") - invoke! "doc/README", :before => "__end__" do + invoke! "doc/README", before: "__end__" do + "more content\n" + end + + expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") + end + + it "does not change the file if already includes content using before with capture" do + invoke! "doc/README", before: /(__end__)/ do + "more content\n" + end + + expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") + + invoke! "doc/README", before: /(__end__)/ do + "more content\n" + end + + expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") + end + + it "does not change the file if already includes content using after with capture" do + invoke! "doc/README", after: /(README\n)/ do + "more content\n" + end + + expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") + + invoke! "doc/README", after: /(README\n)/ do "more content\n" end @@ -94,7 +124,7 @@ def file it "does not attempt to change the file if it doesn't exist - instead raises Thor::Error" do expect do - invoke! "idontexist", :before => "something" do + invoke! "idontexist", before: "something" do "any content" end end.to raise_error(Thor::Error, /does not appear to exist/) @@ -103,8 +133,8 @@ def file it "does not attempt to change the file if it doesn't exist and pretending" do expect do - invoker :pretend => true - invoke! "idontexist", :before => "something" do + invoker pretend: true + invoke! "idontexist", before: "something" do "any content" end end.not_to raise_error @@ -112,13 +142,13 @@ def file end it "does change the file if already includes content and :force is true" do - invoke! "doc/README", :before => "__end__" do + invoke! "doc/README", before: "__end__" do "more content\n" end expect(File.read(file)).to eq("__start__\nREADME\nmore content\n__end__\n") - invoke! "doc/README", :before => "__end__", :force => true do + invoke! "doc/README", before: "__end__", force: true do "more content\n" end @@ -129,59 +159,63 @@ def file encoding_original = Encoding.default_external begin - Encoding.default_external = Encoding.find("UTF-8") - invoke! "doc/README.zh", "\n中文", :after => "__start__" + silence_warnings do + Encoding.default_external = Encoding.find("UTF-8") + end + invoke! "doc/README.zh", "\n中文", after: "__start__" expect(File.read(File.join(destination_root, "doc/README.zh"))).to eq("__start__\n中文\n说明\n__end__\n") ensure - Encoding.default_external = encoding_original + silence_warnings do + Encoding.default_external = encoding_original + end end end end describe "#revoke!" do it "subtracts the destination file after injection" do - invoke! "doc/README", "\nmore content", :after => "__start__" - revoke! "doc/README", "\nmore content", :after => "__start__" + invoke! "doc/README", "\nmore content", after: "__start__" + revoke! "doc/README", "\nmore content", after: "__start__" expect(File.read(file)).to eq("__start__\nREADME\n__end__\n") end it "subtracts the destination file before injection" do - invoke! "doc/README", "more content\n", :before => "__start__" - revoke! "doc/README", "more content\n", :before => "__start__" + invoke! "doc/README", "more content\n", before: "__start__" + revoke! "doc/README", "more content\n", before: "__start__" expect(File.read(file)).to eq("__start__\nREADME\n__end__\n") end it "subtracts even with double after injection" do - invoke! "doc/README", "\nmore content", :after => "__start__" - invoke! "doc/README", "\nanother stuff", :after => "__start__" - revoke! "doc/README", "\nmore content", :after => "__start__" + invoke! "doc/README", "\nmore content", after: "__start__" + invoke! "doc/README", "\nanother stuff", after: "__start__" + revoke! "doc/README", "\nmore content", after: "__start__" expect(File.read(file)).to eq("__start__\nanother stuff\nREADME\n__end__\n") end it "subtracts even with double before injection" do - invoke! "doc/README", "more content\n", :before => "__start__" - invoke! "doc/README", "another stuff\n", :before => "__start__" - revoke! "doc/README", "more content\n", :before => "__start__" + invoke! "doc/README", "more content\n", before: "__start__" + invoke! "doc/README", "another stuff\n", before: "__start__" + revoke! "doc/README", "more content\n", before: "__start__" expect(File.read(file)).to eq("another stuff\n__start__\nREADME\n__end__\n") end it "subtracts when prepending" do - invoke! "doc/README", "more content\n", :after => /\A/ - invoke! "doc/README", "another stuff\n", :after => /\A/ - revoke! "doc/README", "more content\n", :after => /\A/ + invoke! "doc/README", "more content\n", after: /\A/ + invoke! "doc/README", "another stuff\n", after: /\A/ + revoke! "doc/README", "more content\n", after: /\A/ expect(File.read(file)).to eq("another stuff\n__start__\nREADME\n__end__\n") end it "subtracts when appending" do - invoke! "doc/README", "more content\n", :before => /\z/ - invoke! "doc/README", "another stuff\n", :before => /\z/ - revoke! "doc/README", "more content\n", :before => /\z/ + invoke! "doc/README", "more content\n", before: /\z/ + invoke! "doc/README", "another stuff\n", before: /\z/ + revoke! "doc/README", "more content\n", before: /\z/ expect(File.read(file)).to eq("__start__\nREADME\n__end__\nanother stuff\n") end it "shows progress information to the user" do - invoke!("doc/README", "\nmore content", :after => "__start__") - expect(revoke!("doc/README", "\nmore content", :after => "__start__")).to eq(" subtract doc/README\n") + invoke!("doc/README", "\nmore content", after: "__start__") + expect(revoke!("doc/README", "\nmore content", after: "__start__")).to eq(" subtract doc/README\n") end end end diff --git a/spec/actions_spec.rb b/spec/actions_spec.rb index 904881ce9..f34de63a3 100644 --- a/spec/actions_spec.rb +++ b/spec/actions_spec.rb @@ -2,7 +2,7 @@ describe Thor::Actions do def runner(options = {}) - @runner ||= MyCounter.new([1], options, :destination_root => destination_root) + @runner ||= MyCounter.new([1], options, destination_root: destination_root) end def action(*args, &block) @@ -28,18 +28,18 @@ def file end it "can have behavior revoke" do - expect(MyCounter.new([1], {}, :behavior => :revoke).behavior).to eq(:revoke) + expect(MyCounter.new([1], {}, behavior: :revoke).behavior).to eq(:revoke) end it "when behavior is set to force, overwrite options" do - runner = MyCounter.new([1], {:force => false, :skip => true}, :behavior => :force) + runner = MyCounter.new([1], {force: false, skip: true}, behavior: :force) expect(runner.behavior).to eq(:invoke) expect(runner.options.force).to be true expect(runner.options.skip).not_to be true end it "when behavior is set to skip, overwrite options" do - runner = MyCounter.new([1], %w(--force), :behavior => :skip) + runner = MyCounter.new([1], %w(--force), behavior: :skip) expect(runner.behavior).to eq(:invoke) expect(runner.options.force).not_to be true expect(runner.options.skip).to be true @@ -86,7 +86,7 @@ def file expect(runner.relative_to_original_destination_root("/test/file")).to eq("/test/file") end - it "doesn't remove the root path from the absolute path if it is not at the begining" do + it "doesn't remove the root path from the absolute path if it is not at the beginning" do runner.destination_root = "/app" expect(runner.relative_to_original_destination_root("/something/app/project")).to eq("/something/app/project") end @@ -102,7 +102,7 @@ def file end it "does not fail with files containing regexp characters" do - runner = MyCounter.new([1], {}, :destination_root => File.join(destination_root, "fo[o-b]ar")) + runner = MyCounter.new([1], {}, destination_root: File.join(destination_root, "fo[o-b]ar")) expect(runner.relative_to_original_destination_root("bar")).to eq("bar") end @@ -165,23 +165,31 @@ def file end end + it "returns the value yielded by the block" do + expect(runner.inside("foo") { 123 }).to eq(123) + end + describe "when pretending" do it "no directories should be created" do - runner.inside("bar", :pretend => true) {} + runner.inside("bar", pretend: true) {} expect(File.exist?("bar")).to be false end + + it "returns the value yielded by the block" do + expect(runner.inside("foo") { 123 }).to eq(123) + end end describe "when verbose" do it "logs status" do expect(capture(:stdout) do - runner.inside("foo", :verbose => true) {} + runner.inside("foo", verbose: true) {} end).to match(/inside foo/) end it "uses padding in next status" do expect(capture(:stdout) do - runner.inside("foo", :verbose => true) do + runner.inside("foo", verbose: true) do runner.say_status :cool, :padding end end).to match(/cool padding/) @@ -189,7 +197,7 @@ def file it "removes padding after block" do expect(capture(:stdout) do - runner.inside("foo", :verbose => true) {} + runner.inside("foo", verbose: true) {} runner.say_status :no, :padding end).to match(/no padding/) end @@ -226,7 +234,7 @@ def file allow(@template).to receive(:read).and_return(@template) @file = "/" - allow(runner).to receive(:open).and_return(@template) + allow(File).to receive(:open).and_return(@template) end it "accepts a URL as the path" do @@ -247,7 +255,7 @@ def file it "accepts a local file path with spaces" do @file = File.expand_path("fixtures/path with spaces", File.dirname(__FILE__)) - expect(runner).to receive(:open).with(@file).and_return(@template) + expect(File).to receive(:open).with(@file).and_return(@template) action(:apply, @file) end @@ -265,7 +273,7 @@ def file end it "does not log status" do - content = action(:apply, @file, :verbose => false) + content = action(:apply, @file, verbose: false) expect(content).to match(/cool padding/) expect(content).not_to match(/apply http/) end @@ -286,12 +294,12 @@ def file end it "does not log status if required" do - expect(action(:run, "ls", :verbose => false)).to be_empty + expect(action(:run, "ls", verbose: false)).to be_empty end it "accepts a color as status" do expect(runner.shell).to receive(:say_status).with(:run, 'ls from "."', :yellow) - action :run, "ls", :verbose => :yellow + action :run, "ls", verbose: :yellow end end @@ -299,36 +307,36 @@ def file it "doesn't execute the command" do runner = MyCounter.new([1], %w(--pretend)) expect(runner).not_to receive(:system) - runner.run("ls", :verbose => false) + runner.run("ls", verbose: false) end end describe "when not capturing" do it "aborts when abort_on_failure is given and command fails" do - expect { action :run, "false", :abort_on_failure => true }.to raise_error(SystemExit) + expect { action :run, "false", abort_on_failure: true }.to raise_error(SystemExit) end it "succeeds when abort_on_failure is given and command succeeds" do - expect { action :run, "true", :abort_on_failure => true }.not_to raise_error + expect { action :run, "true", abort_on_failure: true }.not_to raise_error end it "supports env option" do - expect { action :run, "echo $BAR", :env => { "BAR" => "foo" } }.to output("foo\n").to_stdout_from_any_process + expect { action :run, "echo $BAR", env: {"BAR" => "foo"} }.to output("foo\n").to_stdout_from_any_process end end describe "when capturing" do it "aborts when abort_on_failure is given, capture is given and command fails" do - expect { action :run, "false", :abort_on_failure => true, :capture => true }.to raise_error(SystemExit) + expect { action :run, "false", abort_on_failure: true, capture: true }.to raise_error(SystemExit) end it "succeeds when abort_on_failure is given and command succeeds" do - expect { action :run, "true", :abort_on_failure => true, :capture => true }.not_to raise_error + expect { action :run, "true", abort_on_failure: true, capture: true }.not_to raise_error end it "supports env option" do silence(:stdout) do - expect(runner.run "echo $BAR", :env => { "BAR" => "foo" }, :capture => true).to eq("foo\n") + expect(runner.run "echo $BAR", env: {"BAR" => "foo"}, capture: true).to eq("foo\n") end end end @@ -343,7 +351,7 @@ def file end it "does not abort when abort_on_failure is false even if the command fails" do - expect { action :run, "false", :abort_on_failure => false }.not_to raise_error + expect { action :run, "false", abort_on_failure: false }.not_to raise_error end end end @@ -363,14 +371,14 @@ def file end it "does not log status if required" do - expect(action(:run_ruby_script, "script.rb", :verbose => false)).to be_empty + expect(action(:run_ruby_script, "script.rb", verbose: false)).to be_empty end end describe "#thor" do it "executes the thor command" do expect(runner).to receive(:system).with("thor list") - action :thor, :list, :verbose => true + action :thor, :list, verbose: true end it "converts extra arguments to command arguments" do @@ -380,10 +388,10 @@ def file it "converts options hash to switches" do expect(runner).to receive(:system).with("thor list foo bar --foo") - action :thor, :list, "foo", "bar", :foo => true + action :thor, :list, "foo", "bar", foo: true expect(runner).to receive(:system).with("thor list --foo 1 2 3") - action :thor, :list, :foo => [1, 2, 3] + action :thor, :list, foo: [1, 2, 3] end it "logs status" do @@ -393,12 +401,12 @@ def file it "does not log status if required" do expect(runner).to receive(:system).with("thor list --foo 1 2 3") - expect(action(:thor, :list, :foo => [1, 2, 3], :verbose => false)).to be_empty + expect(action(:thor, :list, foo: [1, 2, 3], verbose: false)).to be_empty end it "captures the output when :capture is given" do - expect(runner).to receive(:run).with("list", hash_including(:capture => true)) - action :thor, :list, :capture => true + expect(runner).to receive(:run).with("list", hash_including(capture: true)) + action :thor, :list, capture: true end end end diff --git a/spec/base_spec.rb b/spec/base_spec.rb index e5a9561c2..7ffef5fde 100644 --- a/spec/base_spec.rb +++ b/spec/base_spec.rb @@ -27,7 +27,7 @@ def hello end it "allows options to be given as symbols or strings" do - base = MyCounter.new [1, 2], :third => 4 + base = MyCounter.new [1, 2], third: 4 expect(base.options[:third]).to eq(4) base = MyCounter.new [1, 2], "third" => 4 @@ -35,12 +35,12 @@ def hello end it "creates options with indifferent access" do - base = MyCounter.new [1, 2], :third => 3 + base = MyCounter.new [1, 2], third: 3 expect(base.options["third"]).to eq(3) end it "creates options with magic predicates" do - base = MyCounter.new [1, 2], :third => 3 + base = MyCounter.new [1, 2], third: 3 expect(base.options.third).to eq(3) end end @@ -70,6 +70,55 @@ def hello end end + describe "#class_exclusive_option_names" do + it "returns the exclusive option names for the class" do + expect(MyClassOptionScript.class_exclusive_option_names.size).to be(1) + expect(MyClassOptionScript.class_exclusive_option_names.first.size).to be(2) + end + end + + describe "#class_at_least_one_option_names" do + it "returns the at least one of option names for the class" do + expect(MyClassOptionScript.class_at_least_one_option_names.size).to be(1) + expect(MyClassOptionScript.class_at_least_one_option_names.first.size).to be(2) + end + end + + describe "#class_exclusive" do + it "raise error when exclusive options are given" do + begin + ENV["THOR_DEBUG"] = "1" + expect do + MyClassOptionScript.start %w[mix --one --two --three --five] + end.to raise_error(Thor::ExclusiveArgumentError, "Found exclusive options '--one', '--two'") + + expect do + MyClassOptionScript.start %w[mix --one --three --five --six] + end.to raise_error(Thor::ExclusiveArgumentError, "Found exclusive options '--five', '--six'") + ensure + ENV["THOR_DEBUG"] = nil + end + end + end + + describe "#class_at_least_one" do + it "raise error when at least one of required options are not given" do + begin + ENV["THOR_DEBUG"] = "1" + + expect do + MyClassOptionScript.start %w[mix --five] + end.to raise_error(Thor::AtLeastOneRequiredArgumentError, "Not found at least one of required options '--three', '--four'") + + expect do + MyClassOptionScript.start %w[mix --one --three] + end.to raise_error(Thor::AtLeastOneRequiredArgumentError, "Not found at least one of required options '--five', '--six', '--seven'") + ensure + ENV["THOR_DEBUG"] = nil + end + end + end + describe ":aliases" do it "supports string aliases without a dash prefix" do expect(MyCounter.start(%w(1 2 -z 3))[4]).to eq(3) @@ -132,6 +181,10 @@ def hello expect(@content).to match(/# Default: 3/) end + it "prints arrays as copy pasteables" do + expect(@content).to match(/Default: "foo" "bar"/) + end + it "shows options in different groups" do expect(@content).to match(/Options\:/) expect(@content).to match(/Runtime options\:/) @@ -139,8 +192,9 @@ def hello end it "use padding in options that do not have aliases" do - expect(@content).to match(/^ -t, \[--third/) + expect(@content).to match(/^ -t, \[--third/) expect(@content).to match(/^ \[--fourth/) + expect(@content).to match(/^ -y, -r, \[--symbolic/) end it "allows extra options to be given" do @@ -195,7 +249,7 @@ def hello expect(Thor::Base.subclass_files[File.expand_path(thorfile)]).to eq([ MyScript, MyScript::AnotherScript, MyChildScript, Barn, PackageNameScript, Scripts::MyScript, Scripts::MyDefaults, - Scripts::ChildDefault, Scripts::Arities + Scripts::ChildDefault, Scripts::Arities, Apple, Pear, MyClassOptionScript, MyOptionScript ]) end @@ -259,7 +313,7 @@ def hello it "raises an error instead of rescuing if :debug option is given" do expect do - MyScript.start %w(what), :debug => true + MyScript.start %w(what), debug: true end.to raise_error(Thor::UndefinedCommandError, 'Could not find command "what" in "my_script" namespace.') end diff --git a/spec/command_spec.rb b/spec/command_spec.rb index 4e9df7e8b..5efdb3923 100644 --- a/spec/command_spec.rb +++ b/spec/command_spec.rb @@ -6,34 +6,34 @@ def command(options = {}, usage = "can_has") options[key] = Thor::Option.parse(key, value) end - @command ||= Thor::Command.new(:can_has, "I can has cheezburger", "I can has cheezburger\nLots and lots of it", usage, options) + @command ||= Thor::Command.new(:can_has, "I can has cheezburger", "I can has cheezburger\nLots and lots of it", nil, usage, options) end describe "#formatted_usage" do it "includes namespace within usage" do object = Struct.new(:namespace, :arguments).new("foo", []) - expect(command(:bar => :required).formatted_usage(object)).to eq("foo:can_has --bar=BAR") + expect(command(bar: :required).formatted_usage(object)).to eq("foo:can_has --bar=BAR") end it "includes subcommand name within subcommand usage" do object = Struct.new(:namespace, :arguments).new("main:foo", []) - expect(command(:bar => :required).formatted_usage(object, false, true)).to eq("foo can_has --bar=BAR") + expect(command(bar: :required).formatted_usage(object, false, true)).to eq("foo can_has --bar=BAR") end it "removes default from namespace" do object = Struct.new(:namespace, :arguments).new("default:foo", []) - expect(command(:bar => :required).formatted_usage(object)).to eq(":foo:can_has --bar=BAR") + expect(command(bar: :required).formatted_usage(object)).to eq(":foo:can_has --bar=BAR") end it "injects arguments into usage" do - options = {:required => true, :type => :string} + options = {required: true, type: :string} object = Struct.new(:namespace, :arguments).new("foo", [Thor::Argument.new(:bar, options)]) - expect(command(:foo => :required).formatted_usage(object)).to eq("foo:can_has BAR --foo=FOO") + expect(command(foo: :required).formatted_usage(object)).to eq("foo:can_has BAR --foo=FOO") end it "allows multiple usages" do object = Struct.new(:namespace, :arguments).new("foo", []) - 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") + 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") end end @@ -54,7 +54,7 @@ def command(options = {}, usage = "can_has") describe "#dup" do it "dup options hash" do - command = Thor::Command.new("can_has", nil, nil, nil, :foo => true, :bar => :required) + command = Thor::Command.new("can_has", nil, nil, nil, nil, foo: true, bar: :required) command.dup.options.delete(:foo) expect(command.options[:foo]).to be end diff --git a/spec/core_ext/hash_with_indifferent_access_spec.rb b/spec/core_ext/hash_with_indifferent_access_spec.rb index 5e52ada35..69357a2da 100644 --- a/spec/core_ext/hash_with_indifferent_access_spec.rb +++ b/spec/core_ext/hash_with_indifferent_access_spec.rb @@ -40,6 +40,20 @@ expect(@hash.fetch(:missing, :found)).to eq(:found) end + it "supports slice" do + expect(@hash.slice("foo")).to eq({"foo" => "bar"}) + expect(@hash.slice(:foo)).to eq({"foo" => "bar"}) + + expect(@hash.slice("baz")).to eq({"baz" => "bee"}) + expect(@hash.slice(:baz)).to eq({"baz" => "bee"}) + + expect(@hash.slice("foo", "baz")).to eq({"foo" => "bar", "baz" => "bee"}) + expect(@hash.slice(:foo, :baz)).to eq({"foo" => "bar", "baz" => "bee"}) + + expect(@hash.slice("missing")).to eq({}) + expect(@hash.slice(:missing)).to eq({}) + end + it "has key checkable by either strings or symbols" do expect(@hash.key?("foo")).to be true expect(@hash.key?(:foo)).to be true diff --git a/spec/encoding_spec.rb b/spec/encoding_spec.rb new file mode 100644 index 000000000..71e3c86b6 --- /dev/null +++ b/spec/encoding_spec.rb @@ -0,0 +1,22 @@ +require "helper" +require "thor/base" + + +describe "file's encoding" do + def load_thorfile(filename) + Thor::Util.load_thorfile(File.expand_path("./fixtures/#{filename}", __dir__)) + end + + it "respects explicit UTF-8" do + load_thorfile("encoding_with_utf8.thor") + expect(capture(:stdout) { Thor::Sandbox::EncodingWithUtf8.new.invoke(:encoding) }).to match(/ok/) + end + it "respects explicit non-UTF-8" do + load_thorfile("encoding_other.thor") + expect(capture(:stdout) { Thor::Sandbox::EncodingOther.new.invoke(:encoding) }).to match(/ok/) + end + it "has implicit UTF-8" do + load_thorfile("encoding_implicit.thor") + expect(capture(:stdout) { Thor::Sandbox::EncodingImplicit.new.invoke(:encoding) }).to match(/ok/) + end +end diff --git a/spec/fixtures/encoding_implicit.thor b/spec/fixtures/encoding_implicit.thor new file mode 100644 index 000000000..f9fae8def --- /dev/null +++ b/spec/fixtures/encoding_implicit.thor @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class EncodingImplicit < Thor + SOME_STRING = "Some λέξεις 一些词 🎉" + + desc "encoding", "tests that encoding is correct" + + def encoding + puts "#{SOME_STRING.inspect}: #{SOME_STRING.encoding}:" + if SOME_STRING.encoding.name == "UTF-8" + puts "ok" + else + puts "expected #{SOME_STRING.encoding.name} to equal UTF-8" + end + end +end diff --git a/spec/fixtures/encoding_other.thor b/spec/fixtures/encoding_other.thor new file mode 100644 index 000000000..1f1202f48 --- /dev/null +++ b/spec/fixtures/encoding_other.thor @@ -0,0 +1,17 @@ +# encoding: ISO-8859-7 +# frozen_string_literal: true + +class EncodingOther < Thor + SOME_STRING = "Some " + + desc "encoding", "tests that encoding is correct" + + def encoding + puts "#{SOME_STRING.inspect}: #{SOME_STRING.encoding}:" + if SOME_STRING.encoding.name == "ISO-8859-7" + puts "ok" + else + puts "expected #{SOME_STRING.encoding.name} to equal ISO-8859-7" + end + end +end diff --git a/spec/fixtures/encoding_with_utf8.thor b/spec/fixtures/encoding_with_utf8.thor new file mode 100644 index 000000000..af6842d60 --- /dev/null +++ b/spec/fixtures/encoding_with_utf8.thor @@ -0,0 +1,17 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +class EncodingWithUtf8 < Thor + SOME_STRING = "Some λέξεις 一些词 🎉" + + desc "encoding", "tests that encoding is correct" + + def encoding + puts "#{SOME_STRING.inspect}: #{SOME_STRING.encoding}:" + if SOME_STRING.encoding.name == "UTF-8" + puts "ok" + else + puts "expected #{SOME_STRING.encoding.name} to equal UTF-8" + end + end +end diff --git a/spec/fixtures/group.thor b/spec/fixtures/group.thor index af907c993..8866c22fc 100644 --- a/spec/fixtures/group.thor +++ b/spec/fixtures/group.thor @@ -21,6 +21,7 @@ class MyCounter < Thor::Group class_option :fourth, :type => :numeric, :desc => "The fourth argument" class_option :simple, :type => :numeric, :aliases => 'z' class_option :symbolic, :type => :numeric, :aliases => [:y, :r] + class_option :array, :type => :array, :default => ['foo','bar'] desc <<-FOO Description: diff --git a/spec/fixtures/script.thor b/spec/fixtures/script.thor index f4bca4497..92fb0cb07 100644 --- a/spec/fixtures/script.thor +++ b/spec/fixtures/script.thor @@ -96,6 +96,18 @@ END def name_with_dashes end + desc "long_description", "a" * 80 + long_desc <<-D, wrap: false +No added indentation, Inline +whatespace not merged, +Linebreaks preserved + and + indentation + too + D + def long_description_unwrapped + end + method_options :all => :boolean method_option :lazy, :lazy_default => "yes" method_option :lazy_numeric, :type => :numeric, :lazy_default => 42 @@ -249,3 +261,80 @@ module Scripts end end +class Apple < Thor + namespace :fruits + desc 'apple', 'apple'; def apple; end +end + +class Pear < Thor + namespace :fruits + desc 'pear', 'pear'; def pear; end +end +class MyClassOptionScript < Thor + class_option :free + + class_exclusive do + class_option :one + class_option :two + end + + class_at_least_one do + class_option :three + class_option :four + end + + desc "mix", "" + exclusive do + at_least_one do + option :five + option :six + option :seven + end + end + def mix + end +end + +class MyOptionScript < Thor + desc "exclusive", "" + exclusive do + method_option :one + method_option :two + method_option :three + end + method_option :after1 + method_option :after2 + def exclusive + end + + exclusive :after1, :after2, {:for => :exclusive} + + desc "at_least_one", "" + at_least_one do + method_option :one + method_option :two + method_option :three + end + method_option :after1 + method_option :after2 + def at_least_one + end + at_least_one :after1, :after2, :for => :at_least_one + + desc "only_one", "" + exclusive do + at_least_one do + option :one + option :two + option :three + end + end + def only_one + end + + desc "no_relastions", "" + option :no_rel1 + option :no_rel2 + def no_relations + end +end diff --git a/spec/group_spec.rb b/spec/group_spec.rb index 5a088c190..660a40d55 100644 --- a/spec/group_spec.rb +++ b/spec/group_spec.rb @@ -23,7 +23,7 @@ it "raises an error if a required argument is added after a non-required" do expect do - MyCounter.argument(:foo, :type => :string) + MyCounter.argument(:foo, type: :string) end.to raise_error(ArgumentError, 'You cannot have "foo" as required argument after the non-required argument "second".') end @@ -186,8 +186,8 @@ it "can handle boolean options followed by arguments" do klass = Class.new(Thor::Group) do desc "say hi to name" - argument :name, :type => :string - class_option :loud, :type => :boolean + argument :name, type: :string + class_option :loud, type: :boolean def hi self.name = name.upcase if options[:loud] @@ -203,8 +203,8 @@ def hi it "provides extra args as `args`" do klass = Class.new(Thor::Group) do desc "say hi to name" - argument :name, :type => :string - class_option :loud, :type => :boolean + argument :name, type: :string + class_option :loud, type: :boolean def hi self.name = name.upcase if options[:loud] diff --git a/spec/helper.rb b/spec/helper.rb index c19dd6f27..b322f4464 100644 --- a/spec/helper.rb +++ b/spec/helper.rb @@ -20,7 +20,7 @@ require "diff/lcs" # You need diff/lcs installed to run specs (but not to run Thor). require "webmock/rspec" -WebMock.disable_net_connect!(:allow => "coveralls.io") +WebMock.disable_net_connect!(allow: "coveralls.io") # Set shell to basic ENV["THOR_COLUMNS"] = "10000" diff --git a/spec/invocation_spec.rb b/spec/invocation_spec.rb index eb6d805d2..e584eba77 100644 --- a/spec/invocation_spec.rb +++ b/spec/invocation_spec.rb @@ -33,13 +33,13 @@ end it "accepts a class as argument with a command to invoke" do - base = A.new([], :last_name => "Valim") + base = A.new([], last_name: "Valim") expect(base.invoke(B, :one, %w(Jose))).to eq("Valim, Jose") end it "allows customized options to be given" do - base = A.new([], :last_name => "Wrong") - expect(base.invoke(B, :one, %w(Jose), :last_name => "Valim")).to eq("Valim, Jose") + base = A.new([], last_name: "Wrong") + expect(base.invoke(B, :one, %w(Jose), last_name: "Valim")).to eq("Valim, Jose") end it "reparses options in the new class" do @@ -47,7 +47,7 @@ end it "shares initialize options with invoked class" do - expect(A.new([], :foo => :bar).invoke("b:two")).to eq("foo" => :bar) + expect(A.new([], foo: :bar).invoke("b:two")).to eq("foo" => :bar) end it "uses default options from invoked class if no matching arguments are given" do @@ -55,7 +55,7 @@ end it "overrides default options if options are passed to the invoker" do - expect(A.new([], :defaulted_value => "not default").invoke("b:four")).to eq("not default") + expect(A.new([], defaulted_value: "not default").invoke("b:four")).to eq("not default") end it "returns the command chain" do @@ -72,7 +72,7 @@ it "allow extra configuration values to be given" do base = A.new shell = Thor::Base.shell.new - expect(base.invoke("b:three", [], {}, :shell => shell).shell).to eq(shell) + expect(base.invoke("b:three", [], {}, shell: shell).shell).to eq(shell) end it "invokes a Thor::Group and all of its commands" do diff --git a/spec/line_editor/basic_spec.rb b/spec/line_editor/basic_spec.rb index 0aefe8c74..df88dc595 100644 --- a/spec/line_editor/basic_spec.rb +++ b/spec/line_editor/basic_spec.rb @@ -21,7 +21,7 @@ noecho_stdin = double("noecho_stdin") expect(noecho_stdin).to receive(:gets).and_return("secret") expect($stdin).to receive(:noecho).and_yield(noecho_stdin) - editor = Thor::LineEditor::Basic.new("Password: ", :echo => false) + editor = Thor::LineEditor::Basic.new("Password: ", echo: false) expect(editor.readline).to eq("secret") end end diff --git a/spec/line_editor/readline_spec.rb b/spec/line_editor/readline_spec.rb index 7ab67aaa9..a7daa8bd4 100644 --- a/spec/line_editor/readline_spec.rb +++ b/spec/line_editor/readline_spec.rb @@ -33,7 +33,7 @@ it "supports the add_to_history option" do expect(::Readline).to receive(:readline).with("> ", false).and_return("foo") expect(::Readline).to_not receive(:completion_proc=) - editor = Thor::LineEditor::Readline.new("> ", :add_to_history => false) + editor = Thor::LineEditor::Readline.new("> ", add_to_history: false) expect(editor.readline).to eq("foo") end @@ -45,7 +45,7 @@ expect(proc.call("Chi")).to eq ["Chicken"] end - editor = Thor::LineEditor::Readline.new("Best food: ", :limited_to => %w(Apples Chicken Chocolate)) + editor = Thor::LineEditor::Readline.new("Best food: ", limited_to: %w(Apples Chicken Chocolate)) editor.readline end @@ -55,7 +55,7 @@ expect(proc.call("../line_ed").sort).to eq ["../line_editor/", "../line_editor_spec.rb"].sort end - editor = Thor::LineEditor::Readline.new("Path to file: ", :path => true) + editor = Thor::LineEditor::Readline.new("Path to file: ", path: true) Dir.chdir(File.dirname(__FILE__)) { editor.readline } end @@ -64,7 +64,7 @@ noecho_stdin = double("noecho_stdin") expect(noecho_stdin).to receive(:gets).and_return("secret") expect($stdin).to receive(:noecho).and_yield(noecho_stdin) - editor = Thor::LineEditor::Readline.new("Password: ", :echo => false) + editor = Thor::LineEditor::Readline.new("Password: ", echo: false) expect(editor.readline).to eq("secret") end end diff --git a/spec/line_editor_spec.rb b/spec/line_editor_spec.rb index 575fd336e..4f1ff24a5 100644 --- a/spec/line_editor_spec.rb +++ b/spec/line_editor_spec.rb @@ -1,43 +1,44 @@ require "helper" +require "readline" describe Thor::LineEditor, "on a system with Readline support" do before do - @original_readline = ::Readline if defined? ::Readline - silence_warnings { ::Readline = double("Readline") } + @original_readline = ::Readline + Object.send(:remove_const, :Readline) + ::Readline = double("Readline") end after do - silence_warnings { ::Readline = @original_readline } + Object.send(:remove_const, :Readline) + ::Readline = @original_readline end describe ".readline" do it "uses the Readline line editor" do editor = double("Readline") - expect(Thor::LineEditor::Readline).to receive(:new).with("Enter your name ", :default => "Brian").and_return(editor) + expect(Thor::LineEditor::Readline).to receive(:new).with("Enter your name ", {default: "Brian"}).and_return(editor) expect(editor).to receive(:readline).and_return("George") - expect(Thor::LineEditor.readline("Enter your name ", :default => "Brian")).to eq("George") + expect(Thor::LineEditor.readline("Enter your name ", default: "Brian")).to eq("George") end end end describe Thor::LineEditor, "on a system without Readline support" do before do - if defined? ::Readline - @original_readline = ::Readline - Object.send(:remove_const, :Readline) - end + @original_readline = ::Readline + Object.send(:remove_const, :Readline) end after do - silence_warnings { ::Readline = @original_readline } + ::Readline = @original_readline end describe ".readline" do it "uses the Basic line editor" do editor = double("Basic") - expect(Thor::LineEditor::Basic).to receive(:new).with("Enter your name ", :default => "Brian").and_return(editor) + expect(Thor::LineEditor::Basic).to receive(:new).with("Enter your name ", {default: "Brian"}).and_return(editor) expect(editor).to receive(:readline).and_return("George") - expect(Thor::LineEditor.readline("Enter your name ", :default => "Brian")).to eq("George") + expect(Thor::LineEditor.readline("Enter your name ", default: "Brian")).to eq("George") end end end diff --git a/spec/parser/argument_spec.rb b/spec/parser/argument_spec.rb index 1bc946908..6bf631e63 100644 --- a/spec/parser/argument_spec.rb +++ b/spec/parser/argument_spec.rb @@ -15,38 +15,62 @@ def argument(name, options = {}) it "raises an error if type is unknown" do expect do - argument(:command, :type => :unknown) + argument(:command, type: :unknown) end.to raise_error(ArgumentError, "Type :unknown is not valid for arguments.") end it "raises an error if argument is required and has default values" do expect do - argument(:command, :type => :string, :default => "bar", :required => true) + argument(:command, type: :string, default: "bar", required: true) end.to raise_error(ArgumentError, "An argument cannot be required and have default value.") end - it "raises an error if enum isn't an array" do + it "raises an error if enum isn't enumerable" do expect do - argument(:command, :type => :string, :enum => "bar") - end.to raise_error(ArgumentError, "An argument cannot have an enum other than an array.") + argument(:command, type: :string, enum: "bar") + end.to raise_error(ArgumentError, "An argument cannot have an enum other than an enumerable.") end end describe "#usage" do it "returns usage for string types" do - expect(argument(:foo, :type => :string).usage).to eq("FOO") + expect(argument(:foo, type: :string).usage).to eq("FOO") end it "returns usage for numeric types" do - expect(argument(:foo, :type => :numeric).usage).to eq("N") + expect(argument(:foo, type: :numeric).usage).to eq("N") end it "returns usage for array types" do - expect(argument(:foo, :type => :array).usage).to eq("one two three") + expect(argument(:foo, type: :array).usage).to eq("one two three") end it "returns usage for hash types" do - expect(argument(:foo, :type => :hash).usage).to eq("key:value") + expect(argument(:foo, type: :hash).usage).to eq("key:value") + end + end + + describe "#print_default" do + it "prints arrays in a copy pasteable way" do + expect(argument(:foo, { + required: false, + type: :array, + default: ["one","two"] + }).print_default).to eq('"one" "two"') + end + it "prints arrays with a single string default as before" do + expect(argument(:foo, { + required: false, + type: :array, + default: "foobar" + }).print_default).to eq("foobar") + end + it "prints none arrays as default" do + expect(argument(:foo, { + required: false, + type: :numeric, + default: 13, + }).print_default).to eq(13) end end end diff --git a/spec/parser/arguments_spec.rb b/spec/parser/arguments_spec.rb index ab5d9d7f6..4e60f8add 100644 --- a/spec/parser/arguments_spec.rb +++ b/spec/parser/arguments_spec.rb @@ -4,7 +4,7 @@ describe Thor::Arguments do def create(opts = {}) arguments = opts.map do |type, default| - options = {:required => default.nil?, :type => type, :default => default} + options = {required: default.nil?, type: type, default: default} Thor::Argument.new(type.to_s, options) end @@ -18,7 +18,7 @@ def parse(*args) describe "#parse" do it "parses arguments in the given order" do - create :string => nil, :numeric => nil + create string: nil, numeric: nil expect(parse("name", "13")["string"]).to eq("name") expect(parse("name", "13")["numeric"]).to eq(13) expect(parse("name", "+13")["numeric"]).to eq(13) @@ -28,20 +28,20 @@ def parse(*args) end it "accepts hashes" do - create :string => nil, :hash => nil + create string: nil, hash: nil expect(parse("product", "title:string", "age:integer")["string"]).to eq("product") expect(parse("product", "title:string", "age:integer")["hash"]).to eq("title" => "string", "age" => "integer") expect(parse("product", "url:http://www.amazon.com/gp/product/123")["hash"]).to eq("url" => "http://www.amazon.com/gp/product/123") end it "accepts arrays" do - create :string => nil, :array => nil + create string: nil, array: nil expect(parse("product", "title", "age")["string"]).to eq("product") expect(parse("product", "title", "age")["array"]).to eq(%w(title age)) end it "accepts - as an array argument" do - create :array => nil + create array: nil expect(parse("-")["array"]).to eq(%w(-)) expect(parse("-", "title", "-")["array"]).to eq(%w(- title -)) end @@ -53,23 +53,23 @@ def parse(*args) end it "and required arguments raises an error" do - create :string => nil, :numeric => nil + create string: nil, numeric: nil expect { parse }.to raise_error(Thor::RequiredArgumentMissingError, "No value provided for required arguments 'string', 'numeric'") end it "and default arguments returns default values" do - create :string => "name", :numeric => 13 + create string: "name", numeric: 13 expect(parse).to eq("string" => "name", "numeric" => 13) end end it "returns the input if it's already parsed" do - create :string => nil, :hash => nil, :array => nil, :numeric => nil + create string: nil, hash: nil, array: nil, numeric: nil expect(parse("", 0, {}, [])).to eq("string" => "", "numeric" => 0, "hash" => {}, "array" => []) end it "returns the default value if none is provided" do - create :string => "foo", :numeric => 3.0 + create string: "foo", numeric: 3.0 expect(parse("bar")).to eq("string" => "bar", "numeric" => 3.0) end end diff --git a/spec/parser/option_spec.rb b/spec/parser/option_spec.rb index 6787e177a..8bd0f72ee 100644 --- a/spec/parser/option_spec.rb +++ b/spec/parser/option_spec.rb @@ -47,11 +47,11 @@ def option(name, options = {}) describe "with value as hash" do it "has default type :hash" do - expect(parse(:foo, :a => :b).type).to eq(:hash) + expect(parse(:foo, a: :b).type).to eq(:hash) end it "has default value equal to the hash" do - expect(parse(:foo, :a => :b).default).to eq(:a => :b) + expect(parse(:foo, a: :b).default).to eq(a: :b) end end @@ -105,11 +105,11 @@ def option(name, options = {}) describe "with key as an array" do it "sets the first items in the array to the name" do - expect(parse([:foo, :bar, :baz], true).name).to eq("foo") + expect(parse([:foo, :b, "--bar"], true).name).to eq("foo") end - it "sets all other items as aliases" do - expect(parse([:foo, :bar, :baz], true).aliases).to eq([:bar, :baz]) + it "sets all other items as normalized aliases" do + expect(parse([:foo, :b, "--bar"], true).aliases).to eq(["-b", "--bar"]) end end end @@ -129,62 +129,62 @@ def option(name, options = {}) end it "can be required and have default values" do - option = option("foo", :required => true, :type => :string, :default => "bar") + option = option("foo", required: true, type: :string, default: "bar") expect(option.default).to eq("bar") expect(option).to be_required end it "raises an error if default is inconsistent with type and check_default_type is true" do expect do - option("foo_bar", :type => :numeric, :default => "baz", :check_default_type => true) + option("foo_bar", type: :numeric, default: "baz", check_default_type: true) end.to raise_error(ArgumentError, 'Expected numeric default value for \'--foo-bar\'; got "baz" (string)') end it "raises an error if repeatable and default is inconsistent with type and check_default_type is true" do expect do - option("foo_bar", :type => :numeric, :repeatable => true, :default => "baz", :check_default_type => true) + option("foo_bar", type: :numeric, repeatable: true, default: "baz", check_default_type: true) end.to raise_error(ArgumentError, 'Expected array default value for \'--foo-bar\'; got "baz" (string)') end it "raises an error type hash is repeatable and default is inconsistent with type and check_default_type is true" do expect do - option("foo_bar", :type => :hash, :repeatable => true, :default => "baz", :check_default_type => true) + option("foo_bar", type: :hash, repeatable: true, default: "baz", check_default_type: true) end.to raise_error(ArgumentError, 'Expected hash default value for \'--foo-bar\'; got "baz" (string)') end it "does not raises an error if type hash is repeatable and default is consistent with type and check_default_type is true" do expect do - option("foo_bar", :type => :hash, :repeatable => true, :default => {}, :check_default_type => true) + option("foo_bar", type: :hash, repeatable: true, default: {}, check_default_type: true) end.not_to raise_error end it "does not raises an error if repeatable and default is consistent with type and check_default_type is true" do expect do - option("foo_bar", :type => :numeric, :repeatable => true, :default => [1], :check_default_type => true) + option("foo_bar", type: :numeric, repeatable: true, default: [1], check_default_type: true) end.not_to raise_error end it "does not raises an error if default is an symbol and type string and check_default_type is true" do expect do - option("foo", :type => :string, :default => :bar, :check_default_type => true) + option("foo", type: :string, default: :bar, check_default_type: true) end.not_to raise_error end it "does not raises an error if default is inconsistent with type and check_default_type is false" do expect do - option("foo_bar", :type => :numeric, :default => "baz", :check_default_type => false) + option("foo_bar", type: :numeric, default: "baz", check_default_type: false) end.not_to raise_error end it "boolean options cannot be required" do expect do - option("foo", :required => true, :type => :boolean) + option("foo", required: true, type: :boolean) end.to raise_error(ArgumentError, "An option cannot be boolean and required.") end it "does not raises an error if default is a boolean and it is required" do expect do - option("foo", :required => true, :default => true) + option("foo", required: true, default: true) end.not_to raise_error end @@ -233,16 +233,20 @@ def option(name, options = {}) expect(parse(:'no-foo', :boolean).usage).not_to include("[--no-no-foo]") end + it "does not document a negative option for an underscored negative boolean" do + expect(parse(:no_foo, :boolean).usage).not_to include("[--no-no-foo]") + end + it "documents a negative option for a positive boolean starting with 'no'" do expect(parse(:'nougat', :boolean).usage).to include("[--no-nougat]") end it "uses banner when supplied" do - expect(option(:foo, :required => false, :type => :string, :banner => "BAR").usage).to eq("[--foo=BAR]") + expect(option(:foo, required: false, type: :string, banner: "BAR").usage).to eq("[--foo=BAR]") end it "checks when banner is an empty string" do - expect(option(:foo, :required => false, :type => :string, :banner => "").usage).to eq("[--foo]") + expect(option(:foo, required: false, type: :string, banner: "").usage).to eq("[--foo]") end it "suppresses the creation of a --no-option when explicitly requested" do @@ -275,6 +279,27 @@ def option(name, options = {}) it "does not negate the aliases" do expect(parse([:foo, "-f", "-b"], :boolean).usage).to eq("-f, -b, [--foo], [--no-foo]") end + + it "normalizes the aliases" do + expect(parse([:foo, :f, "-b"], :required).usage).to eq("-f, -b, --foo=FOO") + end + end + end + + describe "#print_default" do + it "prints boolean with true default value" do + expect(option(:foo, { + required: false, + type: :boolean, + default: true + }).print_default).to eq(true) + end + it "prints boolean with false default value" do + expect(option(:foo, { + required: false, + type: :boolean, + default: false + }).print_default).to eq(false) end end end diff --git a/spec/parser/options_spec.rb b/spec/parser/options_spec.rb index 12dbda36a..200a5d5fd 100644 --- a/spec/parser/options_spec.rb +++ b/spec/parser/options_spec.rb @@ -2,11 +2,15 @@ require "thor/parser" describe Thor::Options do - def create(opts, defaults = {}, stop_on_unknown = false) + def create(opts, defaults = {}, stop_on_unknown = false, exclusives = [], at_least_ones = []) + relation = { + exclusive_option_names: exclusives, + at_least_one_option_names: at_least_ones + } opts.each do |key, value| opts[key] = Thor::Option.parse(key, value) unless value.is_a?(Thor::Option) end - @opt = Thor::Options.new(opts, defaults, stop_on_unknown) + @opt = Thor::Options.new(opts, defaults, stop_on_unknown, false, relation) end def parse(*args) @@ -23,40 +27,40 @@ def remaining describe "#to_switches" do it "turns true values into a flag" do - expect(Thor::Options.to_switches(:color => true)).to eq("--color") + expect(Thor::Options.to_switches(color: true)).to eq("--color") end it "ignores nil" do - expect(Thor::Options.to_switches(:color => nil)).to eq("") + expect(Thor::Options.to_switches(color: nil)).to eq("") end it "ignores false" do - expect(Thor::Options.to_switches(:color => false)).to eq("") + expect(Thor::Options.to_switches(color: false)).to eq("") end it "avoids extra spaces" do - expect(Thor::Options.to_switches(:color => false, :foo => nil)).to eq("") + expect(Thor::Options.to_switches(color: false, foo: nil)).to eq("") end it "writes --name value for anything else" do - expect(Thor::Options.to_switches(:format => "specdoc")).to eq('--format "specdoc"') + expect(Thor::Options.to_switches(format: "specdoc")).to eq('--format "specdoc"') end it "joins several values" do - switches = Thor::Options.to_switches(:color => true, :foo => "bar").split(" ").sort + switches = Thor::Options.to_switches(color: true, foo: "bar").split(" ").sort expect(switches).to eq(%w("bar" --color --foo)) end it "accepts arrays" do - expect(Thor::Options.to_switches(:count => [1, 2, 3])).to eq("--count 1 2 3") + expect(Thor::Options.to_switches(count: [1, 2, 3])).to eq("--count 1 2 3") end it "accepts hashes" do - expect(Thor::Options.to_switches(:count => {:a => :b})).to eq("--count a:b") + expect(Thor::Options.to_switches(count: {a: :b})).to eq("--count a:b") end it "accepts underscored options" do - expect(Thor::Options.to_switches(:under_score_option => "foo bar")).to eq('--under_score_option "foo bar"') + expect(Thor::Options.to_switches(under_score_option: "foo bar")).to eq('--under_score_option "foo bar"') end end @@ -95,80 +99,82 @@ def remaining end it "returns the default value if none is provided" do - create :foo => "baz", :bar => :required + create foo: "baz", bar: :required expect(parse("--bar", "boom")["foo"]).to eq("baz") end it "returns the default value from defaults hash to required arguments" do - create Hash[:bar => :required], Hash[:bar => "baz"] + create Hash[bar: :required], Hash[bar: "baz"] expect(parse["bar"]).to eq("baz") end it "gives higher priority to defaults given in the hash" do - create Hash[:bar => true], Hash[:bar => false] + create Hash[bar: true], Hash[bar: false] expect(parse["bar"]).to eq(false) end it "raises an error for unknown switches" do - create :foo => "baz", :bar => :required + create foo: "baz", bar: :required parse("--bar", "baz", "--baz", "unknown") expected = "Unknown switches \"--baz\"" expected << "\nDid you mean? \"--bar\"" if Thor::Correctable - expect { check_unknown! }.to raise_error(Thor::UnknownArgumentError, expected) + expect { check_unknown! }.to raise_error(Thor::UnknownArgumentError) do |error| + expect(error.to_s).to eq(expected) + end end it "skips leading non-switches" do - create(:foo => "baz") + create(foo: "baz") expect(parse("asdf", "--foo", "bar")).to eq("foo" => "bar") end it "correctly recognizes things that look kind of like options, but aren't, as not options" do - create(:foo => "baz") + create(foo: "baz") expect(parse("--asdf---asdf", "baz", "--foo", "--asdf---dsf--asdf")).to eq("foo" => "--asdf---dsf--asdf") check_unknown! end it "accepts underscores in commandline args hash for boolean" do - create :foo_bar => :boolean + create foo_bar: :boolean expect(parse("--foo_bar")["foo_bar"]).to eq(true) expect(parse("--no_foo_bar")["foo_bar"]).to eq(false) end it "accepts underscores in commandline args hash for strings" do - create :foo_bar => :string, :baz_foo => :string + create foo_bar: :string, baz_foo: :string expect(parse("--foo_bar", "baz")["foo_bar"]).to eq("baz") expect(parse("--baz_foo", "foo bar")["baz_foo"]).to eq("foo bar") end it "interprets everything after -- as args instead of options" do - create(:foo => :string, :bar => :required) + create(foo: :string, bar: :required) expect(parse(%w(--bar abc moo -- --foo def -a))).to eq("bar" => "abc") expect(remaining).to eq(%w(moo --foo def -a)) end it "ignores -- when looking for single option values" do - create(:foo => :string, :bar => :required) + create(foo: :string, bar: :required) expect(parse(%w(--bar -- --foo def -a))).to eq("bar" => "--foo") expect(remaining).to eq(%w(def -a)) end it "ignores -- when looking for array option values" do - create(:foo => :array) + create(foo: :array) expect(parse(%w(--foo a b -- c d -e))).to eq("foo" => %w(a b c d -e)) expect(remaining).to eq([]) end it "ignores -- when looking for hash option values" do - create(:foo => :hash) + create(foo: :hash) expect(parse(%w(--foo a:b -- c:d -e))).to eq("foo" => {"a" => "b", "c" => "d"}) expect(remaining).to eq(%w(-e)) end it "ignores trailing --" do - create(:foo => :string) + create(foo: :string) expect(parse(%w(--foo --))).to eq("foo" => nil) expect(remaining).to eq([]) end @@ -208,15 +214,15 @@ def remaining end it "does not raises an error if the required option has a default value" do - options = {:required => true, :type => :string, :default => "baz"} - create :foo => Thor::Option.new("foo", options), :bar => :boolean + options = {required: true, type: :string, default: "baz"} + create foo: Thor::Option.new("foo", options), bar: :boolean expect { parse("--bar") }.not_to raise_error end end context "when stop_on_unknown is true" do before do - create({:foo => :string, :verbose => :boolean}, {}, true) + create({foo: :string, verbose: :boolean}, {}, true) end it "stops parsing on first non-option" do @@ -250,6 +256,66 @@ def remaining end end + context "when exclusives is given" do + before do + create({foo: :boolean, bar: :boolean, baz: :boolean, qux: :boolean}, {}, false, + [["foo", "bar"], ["baz","qux"]]) + end + + it "raises an error if exclusive argumets are given" do + expect{parse(%w[--foo --bar])}.to raise_error(Thor::ExclusiveArgumentError, "Found exclusive options '--foo', '--bar'") + end + + it "does not raise an error if exclusive argumets are not given" do + expect{parse(%w[--foo --baz])}.not_to raise_error + end + end + + context "when at_least_ones is given" do + before do + create({foo: :string, bar: :boolean, baz: :boolean, qux: :boolean}, {}, false, + [], [["foo", "bar"], ["baz","qux"]]) + end + + it "raises an error if at least one of required argumet is not given" do + expect{parse(%w[--baz])}.to raise_error(Thor::AtLeastOneRequiredArgumentError, "Not found at least one of required options '--foo', '--bar'") + end + + it "does not raise an error if at least one of required argument is given" do + expect{parse(%w[--foo --baz])}.not_to raise_error + end + end + + context "when exclusives is given" do + before do + create({foo: :boolean, bar: :boolean, baz: :boolean, qux: :boolean}, {}, false, + [["foo", "bar"], ["baz","qux"]]) + end + + it "raises an error if exclusive argumets are given" do + expect{parse(%w[--foo --bar])}.to raise_error(Thor::ExclusiveArgumentError, "Found exclusive options '--foo', '--bar'") + end + + it "does not raise an error if exclusive argumets are not given" do + expect{parse(%w[--foo --baz])}.not_to raise_error + end + end + + context "when at_least_ones is given" do + before do + create({foo: :string, bar: :boolean, baz: :boolean, qux: :boolean}, {}, false, + [], [["foo", "bar"], ["baz","qux"]]) + end + + it "raises an error if at least one of required argumet is not given" do + expect{parse(%w[--baz])}.to raise_error(Thor::AtLeastOneRequiredArgumentError, "Not found at least one of required options '--foo', '--bar'") + end + + it "does not raise an error if at least one of required argument is given" do + expect{parse(%w[--foo --baz])}.not_to raise_error + end + end + describe "with :string type" do before do create %w(--foo -f) => :required @@ -263,10 +329,12 @@ def remaining expect(parse("-f=12")["foo"]).to eq("12") expect(parse("--foo=12")["foo"]).to eq("12") expect(parse("--foo=bar=baz")["foo"]).to eq("bar=baz") + expect(parse("--foo=-bar")["foo"]).to eq("-bar") + expect(parse("--foo=-bar -baz")["foo"]).to eq("-bar -baz") end it "must accept underscores switch=value assignment" do - create :foo_bar => :required + create foo_bar: :required expect(parse("--foo_bar=http://example.com/under_score/")["foo_bar"]).to eq("http://example.com/under_score/") end @@ -297,13 +365,13 @@ def remaining it "raises error when value isn't in enum" do enum = %w(apple banana) - create :fruit => Thor::Option.new("fruit", :type => :string, :enum => enum) + create fruit: Thor::Option.new("fruit", type: :string, enum: enum) expect { parse("--fruit", "orange") }.to raise_error(Thor::MalformattedArgumentError, "Expected '--fruit' to be one of #{enum.join(', ')}; got orange") end it "does not erroneously mutate defaults" do - create :foo => Thor::Option.new("foo", :type => :string, :repeatable => true, :required => false, :default => []) + create foo: Thor::Option.new("foo", type: :string, repeatable: true, required: false, default: []) expect(parse("--foo=bar", "--foo", "12")["foo"]).to eq(["bar", "12"]) expect(@opt.instance_variable_get(:@switches)["--foo"].default).to eq([]) end @@ -364,7 +432,7 @@ def remaining end it "accepts inputs in the human name format" do - create :foo_bar => :boolean + create foo_bar: :boolean expect(parse("--foo-bar")["foo_bar"]).to eq(true) expect(parse("--no-foo-bar")["foo_bar"]).to eq(false) expect(parse("--skip-foo-bar")["foo_bar"]).to eq(false) @@ -386,7 +454,7 @@ def remaining end it "allows multiple values if repeatable is specified" do - create :verbose => Thor::Option.new("verbose", :type => :boolean, :aliases => '-v', :repeatable => true) + create verbose: Thor::Option.new("verbose", type: :boolean, aliases: "-v", repeatable: true) expect(parse("-v", "-v", "-v")["verbose"].count).to eq(3) end end @@ -398,6 +466,7 @@ def remaining it "accepts a switch= assignment" do expect(parse("--attributes=name:string", "age:integer")["attributes"]).to eq("name" => "string", "age" => "integer") + expect(parse("--attributes=-name:string", "age:integer", "--gender:string")["attributes"]).to eq("-name" => "string", "age" => "integer") end it "accepts a switch assignment" do @@ -413,7 +482,7 @@ def remaining end it "allows multiple values if repeatable is specified" do - create :attributes => Thor::Option.new("attributes", :type => :hash, :repeatable => true) + create attributes: Thor::Option.new("attributes", type: :hash, repeatable: true) expect(parse("--attributes", "name:one", "foo:1", "--attributes", "name:two", "bar:2")["attributes"]).to eq({"name"=>"two", "foo"=>"1", "bar" => "2"}) end end @@ -425,6 +494,7 @@ def remaining it "accepts a switch= assignment" do expect(parse("--attributes=a", "b", "c")["attributes"]).to eq(%w(a b c)) + expect(parse("--attributes=-a", "b", "-c")["attributes"]).to eq(%w(-a b)) end it "accepts a switch assignment" do @@ -436,9 +506,16 @@ def remaining end it "allows multiple values if repeatable is specified" do - create :attributes => Thor::Option.new("attributes", :type => :array, :repeatable => true) + create attributes: Thor::Option.new("attributes", type: :array, repeatable: true) expect(parse("--attributes", "1", "2", "--attributes", "3", "4")["attributes"]).to eq([["1", "2"], ["3", "4"]]) end + + it "raises error when value isn't in enum" do + enum = %w(apple banana) + create fruit: Thor::Option.new("fruits", type: :array, enum: enum) + expect { parse("--fruits=", "apple", "banana", "strawberry") }.to raise_error(Thor::MalformattedArgumentError, + "Expected all values of '--fruits' to be one of #{enum.join(', ')}; got strawberry") + end end describe "with :numeric type" do @@ -459,15 +536,22 @@ def remaining "Expected numeric value for '-n'; got \"foo\"") end - it "raises error when value isn't in enum" do + it "raises error when value isn't in Array enum" do enum = [1, 2] - create :limit => Thor::Option.new("limit", :type => :numeric, :enum => enum) + create limit: Thor::Option.new("limit", type: :numeric, enum: enum) + expect { parse("--limit", "3") }.to raise_error(Thor::MalformattedArgumentError, + "Expected '--limit' to be one of 1, 2; got 3") + end + + it "raises error when value isn't in Range enum" do + enum = 1..2 + create limit: Thor::Option.new("limit", type: :numeric, enum: enum) expect { parse("--limit", "3") }.to raise_error(Thor::MalformattedArgumentError, - "Expected '--limit' to be one of #{enum.join(', ')}; got 3") + "Expected '--limit' to be one of 1..2; got 3") end it "allows multiple values if repeatable is specified" do - create :run => Thor::Option.new("run", :type => :numeric, :repeatable => true) + create run: Thor::Option.new("run", type: :numeric, repeatable: true) expect(parse("--run", "1", "--run", "2")["run"]).to eq([1, 2]) end end diff --git a/spec/quality_spec.rb b/spec/quality_spec.rb index 99efb6246..a6ceb3ea2 100644 --- a/spec/quality_spec.rb +++ b/spec/quality_spec.rb @@ -37,7 +37,7 @@ def check_for_extra_spaces(filename) end it "has no malformed whitespace" do - exempt = /\.gitmodules|\.marshal|fixtures|vendor|spec|ssl_certs|LICENSE/ + exempt = /\.gitmodules|\.marshal|fixtures|vendor|spec|ssl_certs|LICENSE|.devcontainer/ error_messages = [] Dir.chdir(File.expand_path("../..", __FILE__)) do `git ls-files`.split("\n").each do |filename| diff --git a/spec/register_spec.rb b/spec/register_spec.rb index d168473eb..d7524882d 100644 --- a/spec/register_spec.rb +++ b/spec/register_spec.rb @@ -42,9 +42,9 @@ def part_two class ClassOptionGroupPlugin < Thor::Group class_option :who, - :type => :string, - :aliases => "-w", - :default => "zebra" + type: :string, + aliases: "-w", + default: "zebra" end class PluginInheritingFromClassOptionsGroup < ClassOptionGroupPlugin @@ -108,7 +108,7 @@ def with_args(*args) "secret", "secret stuff", "Nothing to see here. Move along.", - :hide => true + hide: true ) BoringVendorProvidedCLI.register( diff --git a/spec/runner_spec.rb b/spec/runner_spec.rb index 6a8cc67fa..ea31fd2ff 100644 --- a/spec/runner_spec.rb +++ b/spec/runner_spec.rb @@ -118,13 +118,13 @@ def when_no_thorfiles_exist end describe "commands" do + let(:location) { "#{File.dirname(__FILE__)}/fixtures/command.thor" } before do - @location = "#{File.dirname(__FILE__)}/fixtures/command.thor" @original_yaml = { "random" => { - :location => @location, - :filename => "4a33b894ffce85d7b412fc1b36f88fe0", - :namespaces => %w(amazing) + location: location, + filename: "4a33b894ffce85d7b412fc1b36f88fe0", + namespaces: %w(amazing) } } @@ -214,31 +214,52 @@ def when_no_thorfiles_exist end describe "install/update" do - before do - allow(FileUtils).to receive(:mkdir_p) - allow(FileUtils).to receive(:touch) - allow(Thor::LineEditor).to receive(:readline).and_return("Y") - - path = File.join(Thor::Util.thor_root, Digest::MD5.hexdigest(@location + "random")) - expect(File).to receive(:open).with(path, "w") - end + context "with local thor files" do + before do + allow(FileUtils).to receive(:mkdir_p) + allow(FileUtils).to receive(:touch) + allow(Thor::LineEditor).to receive(:readline).and_return("Y") + + path = File.join(Thor::Util.thor_root, Digest::SHA256.hexdigest(location + "random")) + expect(File).to receive(:open).with(path, "w") + end - it "updates existing thor files" do - path = File.join(Thor::Util.thor_root, @original_yaml["random"][:filename]) - if File.directory? path - expect(FileUtils).to receive(:rm_rf).with(path) - else - expect(File).to receive(:delete).with(path) + it "updates existing thor files" do + path = File.join(Thor::Util.thor_root, @original_yaml["random"][:filename]) + if File.directory? path + expect(FileUtils).to receive(:rm_rf).with(path) + else + expect(File).to receive(:delete).with(path) + end + silence_warnings do + silence(:stdout) { Thor::Runner.start(%w(update random)) } + end end - silence_warnings do - silence(:stdout) { Thor::Runner.start(%w(update random)) } + + it "installs thor files" do + ARGV.replace %W(install #{location}) + silence_warnings do + silence(:stdout) { Thor::Runner.start } + end end end - it "installs thor files" do - ARGV.replace %W(install #{@location}) - silence_warnings do - silence(:stdout) { Thor::Runner.start } + context "with remote thor files" do + let(:location) { "https://example.com/Thorfile" } + + it "installs thor files" do + allow(Thor::LineEditor).to receive(:readline).and_return("Y", "random") + stub_request(:get, location).to_return(body: "class Foo < Thor; end") + path = File.join(Thor::Util.thor_root, Digest::SHA256.hexdigest(location + "random")) + expect(File).to receive(:open).with(path, "w") + expect { silence(:stdout) { Thor::Runner.start(%W(install #{location})) } }.not_to raise_error + end + + it "shows proper errors" do + expect(Thor::Runner).to receive :exit + expect(URI).to receive(:open).with(location).and_raise(OpenURI::HTTPError.new("foo", StringIO.new)) + content = capture(:stderr) { Thor::Runner.start(%W(install #{location})) } + expect(content).to include("Error opening URI '#{location}'") end end end diff --git a/spec/script_exit_status_spec.rb b/spec/script_exit_status_spec.rb index 49ed5439c..6021b2b45 100644 --- a/spec/script_exit_status_spec.rb +++ b/spec/script_exit_status_spec.rb @@ -3,13 +3,13 @@ def thor_command(command) gem_dir= File.expand_path("#{File.dirname(__FILE__)}/..") lib_path= "#{gem_dir}/lib" script_path= "#{gem_dir}/spec/fixtures/exit_status.thor" - ruby_lib= ENV['RUBYLIB'].nil? ? lib_path : "#{lib_path}:#{ENV['RUBYLIB']}" + ruby_lib= ENV["RUBYLIB"].nil? ? lib_path : "#{lib_path}:#{ENV['RUBYLIB']}" full_command= "ruby #{script_path} #{command}" r,w= IO.pipe - pid= spawn({'RUBYLIB' => ruby_lib}, + pid= spawn({"RUBYLIB" => ruby_lib}, full_command, - {:out => w, :err => [:child, :out]}) + {out: w, err: [:child, :out]}) w.close _, exit_status= Process.wait2(pid) diff --git a/spec/shell/basic_spec.rb b/spec/shell/basic_spec.rb index e4e877690..37fae04af 100644 --- a/spec/shell/basic_spec.rb +++ b/spec/shell/basic_spec.rb @@ -63,87 +63,87 @@ def shell end it "prints a message to the user and does not echo stdin if the echo option is set to false" do - expect($stdout).to receive(:print).with('What\'s your password? ') + expect($stdout).to receive(:print).with("What's your password? ") expect($stdin).to receive(:noecho).and_return("mysecretpass") - expect(shell.ask("What's your password?", :echo => false)).to eq("mysecretpass") + expect(shell.ask("What's your password?", echo: false)).to eq("mysecretpass") end it "prints a message to the user with the available options, expects case-sensitive matching, and determines the correctness of the answer" do flavors = %w(strawberry chocolate vanilla) - expect(Thor::LineEditor).to receive(:readline).with('What\'s your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ', :limited_to => flavors).and_return("chocolate") - expect(shell.ask('What\'s your favorite Neopolitan flavor?', :limited_to => flavors)).to eq("chocolate") + expect(Thor::LineEditor).to receive(:readline).with("What's your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ", {limited_to: flavors}).and_return("chocolate") + expect(shell.ask("What's your favorite Neopolitan flavor?", limited_to: flavors)).to eq("chocolate") end it "prints a message to the user with the available options, expects case-sensitive matching, and reasks the question after an incorrect response" do flavors = %w(strawberry chocolate vanilla) expect($stdout).to receive(:print).with("Your response must be one of: [strawberry, chocolate, vanilla]. Please try again.\n") - expect(Thor::LineEditor).to receive(:readline).with('What\'s your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ', :limited_to => flavors).and_return("moose tracks", "chocolate") - expect(shell.ask('What\'s your favorite Neopolitan flavor?', :limited_to => flavors)).to eq("chocolate") + expect(Thor::LineEditor).to receive(:readline).with("What's your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ", {limited_to: flavors}).and_return("moose tracks", "chocolate") + expect(shell.ask("What's your favorite Neopolitan flavor?", limited_to: flavors)).to eq("chocolate") end it "prints a message to the user with the available options, expects case-sensitive matching, and reasks the question after a case-insensitive match" do flavors = %w(strawberry chocolate vanilla) expect($stdout).to receive(:print).with("Your response must be one of: [strawberry, chocolate, vanilla]. Please try again.\n") - expect(Thor::LineEditor).to receive(:readline).with('What\'s your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ', :limited_to => flavors).and_return("cHoCoLaTe", "chocolate") - expect(shell.ask('What\'s your favorite Neopolitan flavor?', :limited_to => flavors)).to eq("chocolate") + expect(Thor::LineEditor).to receive(:readline).with("What's your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ", {limited_to: flavors}).and_return("cHoCoLaTe", "chocolate") + expect(shell.ask("What's your favorite Neopolitan flavor?", limited_to: flavors)).to eq("chocolate") end it "prints a message to the user with the available options, expects case-insensitive matching, and determines the correctness of the answer" do flavors = %w(strawberry chocolate vanilla) - expect(Thor::LineEditor).to receive(:readline).with('What\'s your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ', :limited_to => flavors, :case_insensitive => true).and_return("CHOCOLATE") - expect(shell.ask('What\'s your favorite Neopolitan flavor?', :limited_to => flavors, :case_insensitive => true)).to eq("chocolate") + expect(Thor::LineEditor).to receive(:readline).with("What's your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ", {limited_to: flavors, case_insensitive: true}).and_return("CHOCOLATE") + expect(shell.ask("What's your favorite Neopolitan flavor?", limited_to: flavors, case_insensitive: true)).to eq("chocolate") end it "prints a message to the user with the available options, expects case-insensitive matching, and reasks the question after an incorrect response" do flavors = %w(strawberry chocolate vanilla) expect($stdout).to receive(:print).with("Your response must be one of: [strawberry, chocolate, vanilla]. Please try again.\n") - expect(Thor::LineEditor).to receive(:readline).with('What\'s your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ', :limited_to => flavors, :case_insensitive => true).and_return("moose tracks", "chocolate") - expect(shell.ask('What\'s your favorite Neopolitan flavor?', :limited_to => flavors, :case_insensitive => true)).to eq("chocolate") + expect(Thor::LineEditor).to receive(:readline).with("What's your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] ", {limited_to: flavors, case_insensitive: true}).and_return("moose tracks", "chocolate") + expect(shell.ask("What's your favorite Neopolitan flavor?", limited_to: flavors, case_insensitive: true)).to eq("chocolate") end it "prints a message to the user containing a default and sets the default if only enter is pressed" do - expect(Thor::LineEditor).to receive(:readline).with('What\'s your favorite Neopolitan flavor? (vanilla) ', :default => "vanilla").and_return("") - expect(shell.ask('What\'s your favorite Neopolitan flavor?', :default => "vanilla")).to eq("vanilla") + expect(Thor::LineEditor).to receive(:readline).with("What's your favorite Neopolitan flavor? (vanilla) ", {default: "vanilla"}).and_return("") + expect(shell.ask("What's your favorite Neopolitan flavor?", default: "vanilla")).to eq("vanilla") end it "prints a message to the user with the available options and reasks the question after an incorrect response and then returns the default" do flavors = %w(strawberry chocolate vanilla) expect($stdout).to receive(:print).with("Your response must be one of: [strawberry, chocolate, vanilla]. Please try again.\n") - expect(Thor::LineEditor).to receive(:readline).with('What\'s your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] (vanilla) ', :default => "vanilla", :limited_to => flavors).and_return("moose tracks", "") - expect(shell.ask("What's your favorite Neopolitan flavor?", :default => "vanilla", :limited_to => flavors)).to eq("vanilla") + expect(Thor::LineEditor).to receive(:readline).with("What's your favorite Neopolitan flavor? [strawberry, chocolate, vanilla] (vanilla) ", {default: "vanilla", limited_to: flavors}).and_return("moose tracks", "") + expect(shell.ask("What's your favorite Neopolitan flavor?", default: "vanilla", limited_to: flavors)).to eq("vanilla") end end describe "#yes?" do it "asks the user and returns true if the user replies yes" do - expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", :add_to_history => false).and_return("y") + expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", {add_to_history: false}).and_return("y") expect(shell.yes?("Should I overwrite it?")).to be true end it "asks the user and returns false if the user replies no" do - expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", :add_to_history => false).and_return("n") + expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", {add_to_history: false}).and_return("n") expect(shell.yes?("Should I overwrite it?")).not_to be true end it "asks the user and returns false if the user replies with an answer other than yes or no" do - expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", :add_to_history => false).and_return("foobar") + expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", {add_to_history: false}).and_return("foobar") expect(shell.yes?("Should I overwrite it?")).to be false end end describe "#no?" do it "asks the user and returns true if the user replies no" do - expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", :add_to_history => false).and_return("n") + expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", {add_to_history: false}).and_return("n") expect(shell.no?("Should I overwrite it?")).to be true end it "asks the user and returns false if the user replies yes" do - expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", :add_to_history => false).and_return("Yes") + expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", {add_to_history: false}).and_return("Yes") expect(shell.no?("Should I overwrite it?")).to be false end it "asks the user and returns false if the user replies with an answer other than yes or no" do - expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", :add_to_history => false).and_return("foobar") + expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", {add_to_history: false}).and_return("foobar") expect(shell.no?("Should I overwrite it?")).to be false end end @@ -183,7 +183,7 @@ def shell it "does not print a message if base is set to quiet" do shell.base = MyCounter.new [1, 2] - expect(shell.base).to receive(:options).and_return(:quiet => true) + expect(shell.base).to receive(:options).and_return(quiet: true) expect($stdout).not_to receive(:print) shell.say("Running...") @@ -225,7 +225,7 @@ def shell it "does not print a message if base is set to quiet" do shell.base = MyCounter.new [1, 2] - expect(shell.base).to receive(:options).and_return(:quiet => true) + expect(shell.base).to receive(:options).and_return(quiet: true) expect($stderr).not_to receive(:print) shell.say_error("Running...") @@ -258,7 +258,7 @@ def shell end context "with indentation" do - subject(:wrap_text) { described_class.new.print_wrapped(message, :indent => 4) } + subject(:wrap_text) { described_class.new.print_wrapped(message, indent: 4) } let(:expected_output) do " Creates a back-up of the given folder by compressing it in a .tar.gz file\n"\ @@ -283,6 +283,20 @@ def shell shell.say_status(:create, "") end + it "indents a multiline message" do + status = :foobar + lines = ["first line", "second line", " third line", " fourth line"] + + expect($stdout).to receive(:print) do |string| + formatted_status = string[/^\s*#{status}\s*/] + margin = " " * formatted_status.length + + expect(string).to eq(formatted_status + lines.join("\n#{margin}") + "\n") + end + + shell.say_status(status, lines.join("\n") + "\n") + end + it "does not print a message if base is muted" do expect(shell).to receive(:mute?).and_return(true) expect($stdout).not_to receive(:print) @@ -294,7 +308,7 @@ def shell it "does not print a message if base is set to quiet" do base = MyCounter.new [1, 2] - expect(base).to receive(:options).and_return(:quiet => true) + expect(base).to receive(:options).and_return(quiet: true) expect($stdout).not_to receive(:print) shell.base = base @@ -343,7 +357,7 @@ def shell end it "prints a table with indentation" do - content = capture(:stdout) { shell.print_table(@table, :indent => 2) } + content = capture(:stdout) { shell.print_table(@table, indent: 2) } expect(content).to eq(<<-TABLE) abc #123 first three #0 empty @@ -354,8 +368,7 @@ def shell it "uses maximum terminal width" do @table << ["def", "#456", "Lançam foo bar"] @table << ["ghi", "#789", "بالله عليكم"] - expect(shell).to receive(:terminal_width).and_return(20) - content = capture(:stdout) { shell.print_table(@table, :indent => 2, :truncate => true) } + content = capture(:stdout) { shell.print_table(@table, indent: 2, truncate: 20) } expect(content).to eq(<<-TABLE) abc #123 firs... #0 empty @@ -366,7 +379,7 @@ def #456 Lanç... end it "honors the colwidth option" do - content = capture(:stdout) { shell.print_table(@table, :colwidth => 10) } + content = capture(:stdout) { shell.print_table(@table, colwidth: 10) } expect(content).to eq(<<-TABLE) abc #123 first three #0 empty @@ -378,7 +391,7 @@ def #456 Lanç... 2.times { @table.first.pop } content = capture(:stdout) { shell.print_table(@table) } expect(content).to eq(<<-TABLE) -abc +abc#{" "} #0 empty xyz #786 last three TABLE @@ -386,7 +399,7 @@ def #456 Lanç... it "prints a table with small numbers and right-aligns them" do table = [ - ["Name", "Number", "Color"], # rubocop: disable WordArray + ["Name", "Number", "Color"], # rubocop: disable Style/WordArray ["Erik", 1, "green"] ] content = capture(:stdout) { shell.print_table(table) } @@ -398,7 +411,7 @@ def #456 Lanç... it "doesn't output extra spaces for right-aligned columns in the last column" do table = [ - ["Name", "Number"], # rubocop: disable WordArray + ["Name", "Number"], # rubocop: disable Style/WordArray ["Erik", 1] ] content = capture(:stdout) { shell.print_table(table) } @@ -410,20 +423,72 @@ def #456 Lanç... it "prints a table with big numbers" do table = [ - ["Name", "Number", "Color"], # rubocop: disable WordArray + ["Name", "Number", "Color"], # rubocop: disable Style/WordArray ["Erik", 1_234_567_890_123, "green"] ] content = capture(:stdout) { shell.print_table(table) } expect(content).to eq(<<-TABLE) Name Number Color Erik 1234567890123 green + TABLE + end + + it "prints a table with borders" do + content = capture(:stdout) { shell.print_table(@table, borders: true) } + expect(content).to eq(<<-TABLE) ++-----+------+-------------+ +| abc | #123 | first three | +| | #0 | empty | +| xyz | #786 | last three | ++-----+------+-------------+ +TABLE + end + + it "prints a table with borders and separators" do + @table.insert(1, :separator) + content = capture(:stdout) { shell.print_table(@table, borders: true) } + expect(content).to eq(<<-TABLE) ++-----+------+-------------+ +| abc | #123 | first three | ++-----+------+-------------+ +| | #0 | empty | +| xyz | #786 | last three | ++-----+------+-------------+ +TABLE + end + + it "prints a table with borders and small numbers and right-aligns them" do + table = [ + ["Name", "Number", "Color"], # rubocop: disable Style/WordArray + ["Erik", 1, "green"] + ] + content = capture(:stdout) { shell.print_table(table, borders: true) } + expect(content).to eq(<<-TABLE) ++------+--------+-------+ +| Name | Number | Color | +| Erik | 1 | green | ++------+--------+-------+ +TABLE + end + + it "prints a table with borders and indentation" do + table = [ + ["Name", "Number", "Color"], # rubocop: disable Style/WordArray + ["Erik", 1, "green"] + ] + content = capture(:stdout) { shell.print_table(table, borders: true, indent: 2) } + expect(content).to eq(<<-TABLE) + +------+--------+-------+ + | Name | Number | Color | + | Erik | 1 | green | + +------+--------+-------+ TABLE end end describe "#file_collision" do it "shows a menu with options" do - expect(Thor::LineEditor).to receive(:readline).with('Overwrite foo? (enter "h" for help) [Ynaqh] ', :add_to_history => false).and_return("n") + expect(Thor::LineEditor).to receive(:readline).with('Overwrite foo? (enter "h" for help) [Ynaqh] ', {add_to_history: false}).and_return("n") shell.file_collision("foo") end @@ -464,7 +529,7 @@ def #456 Lanç... end it "always returns true if the user chooses always" do - expect(Thor::LineEditor).to receive(:readline).with('Overwrite foo? (enter "h" for help) [Ynaqh] ', :add_to_history => false).and_return("a") + expect(Thor::LineEditor).to receive(:readline).with('Overwrite foo? (enter "h" for help) [Ynaqh] ', {add_to_history: false}).and_return("a") expect(shell.file_collision("foo")).to be true @@ -474,7 +539,7 @@ def #456 Lanç... describe "when a block is given" do it "displays diff and merge options to the user" do - expect(Thor::LineEditor).to receive(:readline).with('Overwrite foo? (enter "h" for help) [Ynaqdhm] ', :add_to_history => false).and_return("s") + expect(Thor::LineEditor).to receive(:readline).with('Overwrite foo? (enter "h" for help) [Ynaqdhm] ', {add_to_history: false}).and_return("s") shell.file_collision("foo") {} end diff --git a/spec/shell/color_spec.rb b/spec/shell/color_spec.rb index a0f9686a4..3b40c0b35 100644 --- a/spec/shell/color_spec.rb +++ b/spec/shell/color_spec.rb @@ -18,16 +18,34 @@ def shell shell.ask "Is this green?", :green expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? [Yes, No, Maybe] \e[0m", anything).and_return("Yes") - shell.ask "Is this green?", :green, :limited_to => %w(Yes No Maybe) + shell.ask "Is this green?", :green, limited_to: %w(Yes No Maybe) end - it "does not set the color if specified and NO_COLOR is set" do - allow(ENV).to receive(:[]).with("NO_COLOR").and_return("") + it "does not set the color if specified and NO_COLOR is set to a non-empty value" do + allow(ENV).to receive(:[]).with("NO_COLOR").and_return("non-empty value") expect(Thor::LineEditor).to receive(:readline).with("Is this green? ", anything).and_return("yes") shell.ask "Is this green?", :green expect(Thor::LineEditor).to receive(:readline).with("Is this green? [Yes, No, Maybe] ", anything).and_return("Yes") - shell.ask "Is this green?", :green, :limited_to => %w(Yes No Maybe) + shell.ask "Is this green?", :green, limited_to: %w(Yes No Maybe) + end + + it "sets the color when NO_COLOR is ignored because the environment variable is nil" do + allow(ENV).to receive(:[]).with("NO_COLOR").and_return(nil) + expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? \e[0m", anything).and_return("yes") + shell.ask "Is this green?", :green + + expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? [Yes, No, Maybe] \e[0m", anything).and_return("Yes") + shell.ask "Is this green?", :green, limited_to: %w(Yes No Maybe) + end + + it "sets the color when NO_COLOR is ignored because the environment variable is an empty-string" do + allow(ENV).to receive(:[]).with("NO_COLOR").and_return("") + expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? \e[0m", anything).and_return("yes") + shell.ask "Is this green?", :green + + expect(Thor::LineEditor).to receive(:readline).with("\e[32mIs this green? [Yes, No, Maybe] \e[0m", anything).and_return("Yes") + shell.ask "Is this green?", :green, limited_to: %w(Yes No Maybe) end it "handles an Array of colors" do @@ -59,13 +77,31 @@ def shell expect(out.chomp).to eq("Wow! Now we have colors!") end - it "does not set the color if NO_COLOR is set" do + it "does not set the color if NO_COLOR is set to any value that is not an empty string" do + allow(ENV).to receive(:[]).with("NO_COLOR").and_return("non-empty string value") + out = capture(:stdout) do + shell.say "NO_COLOR is enforced! We should not have colors!", :green + end + + expect(out.chomp).to eq("NO_COLOR is enforced! We should not have colors!") + end + + it "colors are still used and NO_COLOR is ignored if the environment variable is nil" do + allow(ENV).to receive(:[]).with("NO_COLOR").and_return(nil) + out = capture(:stdout) do + shell.say "NO_COLOR is ignored! We have colors!", :green + end + + expect(out.chomp).to eq("\e[32mNO_COLOR is ignored! We have colors!\e[0m") + end + + it "colors are still used and NO_COLOR is ignored if the environment variable is an empty-string" do allow(ENV).to receive(:[]).with("NO_COLOR").and_return("") out = capture(:stdout) do - shell.say "Wow! Now we have colors!", :green + shell.say "NO_COLOR is ignored! We have colors!", :green end - expect(out.chomp).to eq("Wow! Now we have colors!") + expect(out.chomp).to eq("\e[32mNO_COLOR is ignored! We have colors!\e[0m") end it "does not use a new line even with colors" do @@ -145,12 +181,34 @@ def shell expect(colorless).to eq("hi!") end - it "does nothing when the NO_COLOR environment variable is set" do - allow(ENV).to receive(:[]).with("NO_COLOR").and_return("") + it "does nothing when the NO_COLOR environment variable is set to a non-empty string" do + allow(ENV).to receive(:[]).with("NO_COLOR").and_return("non-empty value") allow($stdout).to receive(:tty?).and_return(true) colorless = shell.set_color "hi!", :white expect(colorless).to eq("hi!") end + + it "sets color when the NO_COLOR environment variable is ignored for being nil" do + allow(ENV).to receive(:[]).with("NO_COLOR").and_return(nil) + allow($stdout).to receive(:tty?).and_return(true) + + red = shell.set_color "hi!", :red + expect(red).to eq("\e[31mhi!\e[0m") + + on_red = shell.set_color "hi!", :white, :on_red + expect(on_red).to eq("\e[37m\e[41mhi!\e[0m") + end + + it "sets color when the NO_COLOR environment variable is ignored for being an empty string" do + allow(ENV).to receive(:[]).with("NO_COLOR").and_return("") + allow($stdout).to receive(:tty?).and_return(true) + + red = shell.set_color "hi!", :red + expect(red).to eq("\e[31mhi!\e[0m") + + on_red = shell.set_color "hi!", :white, :on_red + expect(on_red).to eq("\e[37m\e[41mhi!\e[0m") + end end describe "#file_collision" do diff --git a/spec/shell/html_spec.rb b/spec/shell/html_spec.rb index d94864e46..811c490a5 100644 --- a/spec/shell/html_spec.rb +++ b/spec/shell/html_spec.rb @@ -30,7 +30,7 @@ def shell end describe "#set_color" do - it "escapes HTML content when unsing the default colors" do + it "escapes HTML content when using the default colors" do expect(shell.set_color("", :blue)).to eq "<htmlcontent>" end diff --git a/spec/shell_spec.rb b/spec/shell_spec.rb index 50adcf3f7..87c038d53 100644 --- a/spec/shell_spec.rb +++ b/spec/shell_spec.rb @@ -7,12 +7,12 @@ def shell describe "#initialize" do it "sets shell value" do - base = MyCounter.new [1, 2], {}, :shell => shell + base = MyCounter.new [1, 2], {}, shell: shell expect(base.shell).to eq(shell) end it "sets the base value on the shell if an accessor is available" do - base = MyCounter.new [1, 2], {}, :shell => shell + base = MyCounter.new [1, 2], {}, shell: shell expect(shell.base).to eq(base) end end diff --git a/spec/sort_spec.rb b/spec/sort_spec.rb new file mode 100644 index 000000000..450549203 --- /dev/null +++ b/spec/sort_spec.rb @@ -0,0 +1,75 @@ +require "helper" + +describe Thor do + def shell + @shell ||= Thor::Base.shell.new + end + + describe "#sort - default" do + my_script = Class.new(Thor) do + desc "a", "First Command" + def a; end + + desc "z", "Last Command" + def z; end + end + + before do + @content = capture(:stdout) { my_script.help(shell) } + end + + it "sorts them lexicographillay" do + expect(@content).to match(/:a.+:help.+:z/m) + end + end + + + describe "#sort - simple override" do + my_script = Class.new(Thor) do + desc "a", "First Command" + def a; end + + desc "z", "Last Command" + def z; end + + def self.sort_commands!(list) + list.sort! + list.reverse! + end + + end + + before do + @content = capture(:stdout) { my_script.help(shell) } + end + + it "sorts them in reverse" do + expect(@content).to match(/:z.+:help.+:a/m) + end + end + + + describe "#sort - simple override" do + my_script = Class.new(Thor) do + desc "a", "First Command" + def a; end + + desc "z", "Last Command" + def z; end + + def self.sort_commands!(list) + list.sort_by! do |a,b| + a[0] == :help ? -1 : a[0] <=> b[0] + end + end + end + + before do + @content = capture(:stdout) { my_script.help(shell) } + end + + it "puts help first then sorts them lexicographillay" do + expect(@content).to match(/:help.+:a.+:z/m) + end + end +end diff --git a/spec/thor_spec.rb b/spec/thor_spec.rb index 77f423c53..6d03eb418 100644 --- a/spec/thor_spec.rb +++ b/spec/thor_spec.rb @@ -91,8 +91,8 @@ describe "#stop_on_unknown_option!" do my_script = Class.new(Thor) do - class_option "verbose", :type => :boolean - class_option "mode", :type => :string + class_option "verbose", type: :boolean + class_option "mode", type: :string stop_on_unknown_option! :exec @@ -140,9 +140,9 @@ def boring(*args) stop_on_unknown_option! :foo, :bar end it "affects all specified commands" do - expect(klass.stop_on_unknown_option?(double(:name => "foo"))).to be true - expect(klass.stop_on_unknown_option?(double(:name => "bar"))).to be true - expect(klass.stop_on_unknown_option?(double(:name => "baz"))).to be false + expect(klass.stop_on_unknown_option?(double(name: "foo"))).to be true + expect(klass.stop_on_unknown_option?(double(name: "bar"))).to be true + expect(klass.stop_on_unknown_option?(double(name: "baz"))).to be false end end @@ -152,9 +152,9 @@ def boring(*args) stop_on_unknown_option! :bar end it "affects all specified commands" do - expect(klass.stop_on_unknown_option?(double(:name => "foo"))).to be true - expect(klass.stop_on_unknown_option?(double(:name => "bar"))).to be true - expect(klass.stop_on_unknown_option?(double(:name => "baz"))).to be false + expect(klass.stop_on_unknown_option?(double(name: "foo"))).to be true + expect(klass.stop_on_unknown_option?(double(name: "bar"))).to be true + expect(klass.stop_on_unknown_option?(double(name: "baz"))).to be false end end @@ -164,8 +164,8 @@ def boring(*args) context "along with check_unknown_options!" do my_script2 = Class.new(Thor) do - class_option "verbose", :type => :boolean - class_option "mode", :type => :string + class_option "verbose", type: :boolean + class_option "mode", type: :string check_unknown_options! stop_on_unknown_option! :exec @@ -219,8 +219,8 @@ def self.exit_on_failure? describe "#check_unknown_options!" do my_script = Class.new(Thor) do - class_option "verbose", :type => :boolean - class_option "mode", :type => :string + class_option "verbose", type: :boolean + class_option "mode", type: :string check_unknown_options! desc "checked", "a command with checked" @@ -280,7 +280,7 @@ def self.exit_on_failure? describe "#disable_required_check!" do my_script = Class.new(Thor) do - class_option "foo", :required => true + class_option "foo", required: true disable_required_check! :boring @@ -309,7 +309,7 @@ def self.exit_on_failure? end it "does affects help by default" do - expect(my_script.disable_required_check?(double(:name => "help"))).to be true + expect(my_script.disable_required_check?(double(name: "help"))).to be true end context "when provided with multiple command names" do @@ -318,10 +318,10 @@ def self.exit_on_failure? end it "affects all specified commands" do - expect(klass.disable_required_check?(double(:name => "help"))).to be true - expect(klass.disable_required_check?(double(:name => "foo"))).to be true - expect(klass.disable_required_check?(double(:name => "bar"))).to be true - expect(klass.disable_required_check?(double(:name => "baz"))).to be false + expect(klass.disable_required_check?(double(name: "help"))).to be true + expect(klass.disable_required_check?(double(name: "foo"))).to be true + expect(klass.disable_required_check?(double(name: "bar"))).to be true + expect(klass.disable_required_check?(double(name: "baz"))).to be false end end @@ -332,10 +332,10 @@ def self.exit_on_failure? end it "affects all specified commands" do - expect(klass.disable_required_check?(double(:name => "help"))).to be true - expect(klass.disable_required_check?(double(:name => "foo"))).to be true - expect(klass.disable_required_check?(double(:name => "bar"))).to be true - expect(klass.disable_required_check?(double(:name => "baz"))).to be false + expect(klass.disable_required_check?(double(name: "help"))).to be true + expect(klass.disable_required_check?(double(name: "foo"))).to be true + expect(klass.disable_required_check?(double(name: "bar"))).to be true + expect(klass.disable_required_check?(double(name: "baz"))).to be false end end end @@ -395,7 +395,7 @@ def self.exit_on_failure? expect(capture(:stdout) { MyScript.start(%w(help)) }).not_to match(/this is hidden/m) end - it "but the command is still invokable, does not show the command in help" do + it "but the command is still invocable, does not show the command in help" do expect(MyScript.start(%w(hidden yesyes))).to eq(%w(yesyes)) end end @@ -422,6 +422,26 @@ def self.exit_on_failure? end end + describe "#method_exclusive" do + it "returns the exclusive option names for the class" do + cmd = MyOptionScript.commands["exclusive"] + exclusives = cmd.options_relation[:exclusive_option_names] + expect(exclusives.size).to be(2) + expect(exclusives.first).to eq(%w[one two three]) + expect(exclusives.last).to eq(%w[after1 after2]) + end + end + + describe "#method_at_least_one" do + it "returns the at least one of option names for the class" do + cmd = MyOptionScript.commands["at_least_one"] + at_least_ones = cmd.options_relation[:at_least_one_option_names] + expect(at_least_ones.size).to be(2) + expect(at_least_ones.first).to eq(%w[one two three]) + expect(at_least_ones.last).to eq(%w[after1 after2]) + end + end + describe "#start" do it "calls a no-param method when no params are passed" do expect(MyScript.start(%w(zoo))).to eq(true) @@ -496,13 +516,13 @@ def self.exit_on_failure? it "raises an exception and displays a message that explains the ambiguity" do shell = Thor::Base.shell.new expect(shell).to receive(:error).with("Ambiguous command call matches [call_myself_with_wrong_arity, call_unexistent_method]") - MyScript.start(%w(call), :shell => shell) + MyScript.start(%w(call), shell: shell) end it "raises an exception when there is an alias" do shell = Thor::Base.shell.new expect(shell).to receive(:error).with("Ambiguous command f matches [foo, fu]") - MyScript.start(%w(f), :shell => shell) + MyScript.start(%w(f), shell: shell) end end end @@ -526,7 +546,7 @@ def shell end it "uses the maximum terminal size to show commands" do - expect(@shell).to receive(:terminal_width).and_return(80) + expect(Thor::Shell::Terminal).to receive(:terminal_width).and_return(80) content = capture(:stdout) { MyScript.help(shell) } expect(content).to match(/aaa\.\.\.$/) end @@ -550,6 +570,26 @@ def shell content = capture(:stdout) { Scripts::MyScript.help(shell) } expect(content).to match(/zoo ACCESSOR \-\-param\=PARAM/) end + + it "prints class exclusive options" do + content = capture(:stdout) { MyClassOptionScript.help(shell) } + expect(content).to match(/Exclusive Options:\n\s+--one\s+--two\n/) + end + + it "does not print class exclusive options" do + content = capture(:stdout) { Scripts::MyScript.help(shell) } + expect(content).not_to match(/Exclusive Options:/) + end + + it "prints class at least one of requred options" do + content = capture(:stdout) { MyClassOptionScript.help(shell) } + expect(content).to match(/Required At Least One:\n\s+--three\s+--four\n/) + end + + it "does not print class at least one of required options" do + content = capture(:stdout) { Scripts::MyScript.help(shell) } + expect(content).not_to match(/Required At Least One:/) + end end describe "for a specific command" do @@ -602,11 +642,41 @@ def shell HELP end + it "prints long description unwrapped if asked for" do + expect(capture(:stdout) { MyScript.command_help(shell, "long_description_unwrapped") }).to eq(<<-HELP) +Usage: + thor my_script:long_description + +Description: +No added indentation, Inline +whatespace not merged, +Linebreaks preserved + and + indentation + too +HELP + end + it "doesn't assign the long description to the next command without one" do expect(capture(:stdout) do MyScript.command_help(shell, "name_with_dashes") end).not_to match(/so very long/i) end + + it "prints exclusive and at least one options" do + message = expect(capture(:stdout) do + MyClassOptionScript.command_help(shell, "mix") + end) + message.to match(/Exclusive Options:\n\s+--five\s+--six\s+--seven\n\s+--one\s+--two/) + message.to match(/Required At Least One:\n\s+--five\s+--six\s+--seven\n\s+--three\s+--four/) + end + it "does not print exclusive and at least one options" do + message = expect(capture(:stdout) do + MyOptionScript.command_help(shell, "no_relations") + end) + message.not_to match(/Exclusive Options:/) + message.not_to match(/Rquired At Least One:/) + end end describe "instance method" do @@ -622,7 +692,7 @@ def shell context "with required class_options" do let(:klass) do Class.new(Thor) do - class_option :foo, :required => true + class_option :foo, required: true desc "bar", "do something" def bar; end @@ -667,7 +737,7 @@ def bar; end describe "edge-cases" do it "can handle boolean options followed by arguments" do klass = Class.new(Thor) do - method_option :loud, :type => :boolean + method_option :loud, type: :boolean desc "hi NAME", "say hi to name" def hi(name) name = name.upcase if options[:loud] @@ -680,6 +750,22 @@ def hi(name) expect(klass.start(%w(hi --loud jose))).to eq("Hi JOSE") end + it "method_option raises an ArgumentError if name is not a Symbol or String" do + expect do + Class.new(Thor) do + method_option loud: true, type: :boolean + end + end.to raise_error(ArgumentError, "Expected a Symbol or String, got {:loud=>true, :type=>:boolean}") + end + + it "class_option raises an ArgumentError if name is not a Symbol or String" do + expect do + Class.new(Thor) do + class_option loud: true, type: :boolean + end + end.to raise_error(ArgumentError, "Expected a Symbol or String, got {:loud=>true, :type=>:boolean}") + end + it "passes through unknown options" do klass = Class.new(Thor) do desc "unknown", "passing unknown options" @@ -725,7 +811,7 @@ def unknown(*args) it "issues a deprecation warning on incompatible types by default" do expect do Class.new(Thor) do - option "bar", :type => :numeric, :default => "foo" + option "bar", type: :numeric, default: "foo" end end.to output(/^Deprecation warning/).to_stderr end @@ -735,7 +821,7 @@ def unknown(*args) Class.new(Thor) do allow_incompatible_default_type! - option "bar", :type => :numeric, :default => "foo" + option "bar", type: :numeric, default: "foo" end end.not_to output.to_stderr end @@ -743,7 +829,7 @@ def unknown(*args) it "allows incompatible types if `check_default_type: false` is given" do expect do Class.new(Thor) do - option "bar", :type => :numeric, :default => "foo", :check_default_type => false + option "bar", type: :numeric, default: "foo", check_default_type: false end end.not_to output.to_stderr end @@ -753,7 +839,7 @@ def unknown(*args) Class.new(Thor) do check_default_type! - option "bar", :type => :numeric, :default => "foo" + option "bar", type: :numeric, default: "foo" end end.to raise_error(ArgumentError, "Expected numeric default value for '--bar'; got \"foo\" (string)") end diff --git a/spec/util_spec.rb b/spec/util_spec.rb index 9914706ca..80665edc3 100644 --- a/spec/util_spec.rb +++ b/spec/util_spec.rb @@ -109,6 +109,11 @@ def self.clear_user_home! it "falls back on the default namespace class if nothing else matches" do expect(Thor::Util.find_class_and_command_by_namespace("test")).to eq([Scripts::MyDefaults, "test"]) end + + it "returns correct Thor class and the command name when shared namespaces" do + expect(Thor::Util.find_class_and_command_by_namespace("fruits:apple")).to eq([Apple, "apple"]) + expect(Thor::Util.find_class_and_command_by_namespace("fruits:pear")).to eq([Pear, "pear"]) + end end describe "#thor_classes_in" do diff --git a/thor.gemspec b/thor.gemspec index f63b7d30a..6f26e4e35 100644 --- a/thor.gemspec +++ b/thor.gemspec @@ -4,25 +4,30 @@ $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) require "thor/version" Gem::Specification.new do |spec| - spec.add_development_dependency "bundler", ">= 1.0", "< 3" + spec.name = "thor" + spec.version = Thor::VERSION + spec.licenses = %w(MIT) spec.authors = ["Yehuda Katz", "José Valim"] - spec.description = "Thor is a toolkit for building powerful command-line interfaces." spec.email = "ruby-thor@googlegroups.com" - spec.executables = %w(thor) - spec.files = %w(.document thor.gemspec) + Dir["*.md", "bin/*", "lib/**/*.rb"] spec.homepage = "http://whatisthor.com/" - spec.licenses = %w(MIT) - spec.name = "thor" + spec.description = "Thor is a toolkit for building powerful command-line interfaces." + spec.summary = spec.description + spec.metadata = { - "bug_tracker_uri" => "https://github.com/erikhuda/thor/issues", - "changelog_uri" => "https://github.com/erikhuda/thor/blob/master/CHANGELOG.md", + "bug_tracker_uri" => "https://github.com/rails/thor/issues", + "changelog_uri" => "https://github.com/rails/thor/releases/tag/v#{Thor::VERSION}", "documentation_uri" => "http://whatisthor.com/", - "source_code_uri" => "https://github.com/erikhuda/thor/tree/v#{Thor::VERSION}", - "wiki_uri" => "https://github.com/erikhuda/thor/wiki" + "source_code_uri" => "https://github.com/rails/thor/tree/v#{Thor::VERSION}", + "wiki_uri" => "https://github.com/rails/thor/wiki", + "rubygems_mfa_required" => "true", } - spec.require_paths = %w(lib) - spec.required_ruby_version = ">= 2.0.0" + + spec.required_ruby_version = ">= 2.6.0" spec.required_rubygems_version = ">= 1.3.5" - spec.summary = spec.description - spec.version = Thor::VERSION + + spec.files = %w(.document thor.gemspec) + Dir["*.md", "bin/*", "lib/**/*.rb"] + spec.executables = %w(thor) + spec.require_paths = %w(lib) + + spec.add_development_dependency "bundler", ">= 1.0", "< 3" end