diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..c4e5e5dca --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,8 @@ +{ + "name": "thor", + "image": "ghcr.io/rails/devcontainer/images/ruby:3.4.5", + + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..5ace4600a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..5b7917e45 --- /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@v4 + - 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/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..ecd521adf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + release: + types: [published] + +jobs: + release: + permissions: + contents: write + id-token: write + + environment: release + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: 3.4.4 + - name: Configure trusted publishing credentials + uses: rubygems/configure-rubygems-credentials@v1.0.0 + - name: Run release rake task + run: bundle exec thor release + shell: bash + - name: Wait for release to propagate + run: gem exec rubygems-await pkg/*.gem + shell: bash diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..c7ccee5d6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,17 @@ +name: Run tests +on: [push, pull_request] +jobs: + test: + strategy: + fail-fast: false + matrix: + ruby: ['2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4','head'] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler: ${{ (matrix.ruby < '3' && '2.4.21') || 'latest' }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - run: bundle exec thor spec diff --git a/.gitignore b/.gitignore index 76bb4a98c..1e037f799 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ .yardoc Desktop.ini Gemfile.lock +lint_gems.rb.lock Icon? InstalledFiles Session.vim @@ -28,7 +29,7 @@ Session.vim _yardoc auto-save-list coverage -doc/ +/doc/ lib/bundler/man pkg pkg/* diff --git a/.rspec b/.rspec index 0ea59b016..39e19fd1a 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1 @@ ---color ---fail-fast ---order random +-w --color diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..69943b31a --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,138 @@ +AllCops: + TargetRubyVersion: 2.6 + DisabledByDefault: true + SuggestExtensions: false + Exclude: + - spec/sandbox/**/* + - spec/fixtures/**/* + - vendor/bundle/**/** + +Style/HashSyntax: + EnforcedStyle: ruby19 + +# No spaces inside hash literals +Layout/SpaceInsideHashLiteralBraces: + EnforcedStyle: no_space + +# Enforce outdenting of access modifiers (i.e. public, private, protected) +Layout/AccessModifierIndentation: + EnforcedStyle: outdent + +Layout/EmptyLinesAroundAccessModifier: + Enabled: true + +# Align ends correctly +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 +Layout/CaseIndentation: + EnforcedStyle: end + IndentOneStep: false + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Lint/AssignmentInCondition: + Exclude: + - 'lib/thor/line_editor/readline.rb' + - 'lib/thor/parser/arguments.rb' + +Security/Eval: + Exclude: + - 'spec/helper.rb' + +Lint/SuppressedException: + Exclude: + - 'lib/thor/line_editor/readline.rb' + +Lint/PercentStringArray: + Exclude: + - 'spec/parser/options_spec.rb' + +Lint/UnusedMethodArgument: + Exclude: + - 'lib/thor.rb' + - 'lib/thor/base.rb' + - 'lib/thor/command.rb' + - 'lib/thor/parser/arguments.rb' + - 'lib/thor/shell/html.rb' + - 'spec/actions/empty_directory_spec.rb' + - 'spec/fixtures/invoke.thor' + +Naming/AccessorMethodName: + Exclude: + - 'lib/thor/line_editor/basic.rb' + - 'spec/fixtures/group.thor' + - 'spec/sandbox/group.thor' + +Style/ClassAndModuleChildren: + Exclude: + - 'lib/thor/group.rb' + - 'lib/thor/runner.rb' + - 'spec/shell_spec.rb' + - 'spec/util_spec.rb' + +Style/ClassVars: + Exclude: + - 'lib/thor/util.rb' + - 'spec/util_spec.rb' + +Naming/ConstantName: + Exclude: + - 'spec/line_editor_spec.rb' + +Style/GlobalVars: + Exclude: + - 'bin/thor' + - 'lib/thor.rb' + - 'lib/thor/base.rb' + - 'lib/thor/shell/basic.rb' + - 'spec/helper.rb' + - 'spec/rake_compat_spec.rb' + - 'spec/register_spec.rb' + - 'spec/thor_spec.rb' + +Layout/FirstArrayElementIndentation: + EnforcedStyle: consistent + +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' + - 'spec/fixtures/script.thor' + - 'spec/sandbox/script.thor' + +Style/NumericLiteralPrefix: + Exclude: + - 'spec/actions/file_manipulation_spec.rb' + +Style/NumericPredicate: + Exclude: + - 'spec/**/*' + - 'lib/thor/parser/option.rb' + +Style/PerlBackrefs: + Exclude: + - 'lib/thor/actions/empty_directory.rb' + - 'lib/thor/core_ext/hash_with_indifferent_access.rb' + - 'lib/thor/parser/arguments.rb' + - 'lib/thor/parser/options.rb' + +Style/TrailingUnderscoreVariable: + Exclude: + - 'lib/thor/group.rb' + +Layout/TrailingWhitespace: + Exclude: + - 'spec/shell/basic_spec.rb' diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 23e615769..000000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -bundler_args: --without development -language: ruby -rvm: - - 1.8.7 - - 1.9.2 - - 1.9.3 - - 2.0.0 -script: bundle exec thor spec diff --git a/CHANGELOG.rdoc b/CHANGELOG.rdoc deleted file mode 100644 index 955174267..000000000 --- a/CHANGELOG.rdoc +++ /dev/null @@ -1,142 +0,0 @@ -== 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..12f06e044 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,15 @@ +Pull Requests +------------- +Here are some reasons why a pull request may not be merged: + +1. It hasn’t been reviewed. +2. It doesn’t include specs for new functionality. +3. It doesn’t include documentation for new functionality. +4. It changes behavior without changing the relevant documentation, comments, or specs. +5. It changes behavior of an existing public API, breaking backward compatibility. +6. It breaks the tests on a supported platform. +7. It doesn’t merge cleanly (requiring Git rebasing and conflict resolution). + +If you would like to help in this process, you can start by evaluating open pull requests against the criteria above. For example, if a pull request does not include specs for new functionality, you can add a comment like: “If you would like this feature to be added to Thor, please add specs to ensure that it does not break in the future.” This will help move a pull request closer to being merged. + +Include this emoji in the top of your ticket to signal to us that you read this file: 🌈 diff --git a/Gemfile b/Gemfile index b4bb92dbf..de8e841a3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,19 +1,22 @@ -source :rubygems +source "https://rubygems.org" -gemspec - -gem 'rake', '~> 0.9' -gem 'rdoc', '~> 3.9' +gem "rake" group :development do - gem 'pry' - gem 'pry-debugger', :platforms => :mri_19 + gem "pry" + gem "pry-byebug" + gem "rubocop", "~> 1.30" end group :test do - gem 'childlabor' - gem 'fakeweb', '~> 1.3' - gem 'rspec', '~> 2.11' - gem 'rspec-mocks', :git => 'git://github.com/rspec/rspec-mocks.git' - gem 'simplecov' + gem "childlabor" + gem "coveralls_reborn", "~> 0.23.1", require: false + gem "rspec", ">= 3.2" + gem "rspec-mocks", ">= 3" + gem "simplecov", ">= 0.13" + gem "webmock", ">= 3.14" + gem "rdoc" + gem "readline" end + +gemspec diff --git a/README.md b/README.md index 9c860838f..fed04b7ad 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -[![Build Status](https://secure.travis-ci.org/wycats/thor.png?branch=master)](http://travis-ci.org/wycats/thor) - Thor ==== +[![Gem Version](http://img.shields.io/gem/v/thor.svg)][gem] + +[gem]: https://rubygems.org/gems/thor + Description ----------- Thor is a simple and efficient tool for building self-documenting command line @@ -11,7 +13,13 @@ utilities. It removes the pain of parsing command line options, writing build tool. The syntax is Rake-like, so it should be familiar to most Rake users. -[rake]: https://github.com/jimweirich/rake +Please note: Thor, by design, is a system tool created to allow seamless file and url +access, which should not receive application user input. It relies on [open-uri][open-uri], +which, combined with application user input, would provide a command injection attack +vector. + +[rake]: https://github.com/ruby/rake +[open-uri]: https://ruby-doc.org/stdlib-2.5.1/libdoc/open-uri/rdoc/index.html Installation ------------ @@ -19,10 +27,19 @@ Installation Usage and documentation ----------------------- -Please see [the wiki](https://github.com/wycats/thor/wiki) for basic usage and other documentation on using Thor. +Please see the [wiki][] for basic usage and other documentation on using Thor. You can also check out the [official homepage][homepage]. + +[wiki]: https://github.com/rails/thor/wiki +[homepage]: http://whatisthor.com/ + +Contributing +------------ +If you would like to help, please read the [CONTRIBUTING][] file for suggestions. + +[contributing]: CONTRIBUTING.md License ------- -Released under the MIT License. See the [LICENSE][license] file for further details. +Released under the MIT License. See the [LICENSE][] file for further details. -[license]: https://github.com/wycats/thor/blob/master/LICENSE.md +[license]: LICENSE.md diff --git a/Thorfile b/Thorfile index 9f62810e8..847d1fac1 100644 --- a/Thorfile +++ b/Thorfile @@ -1,8 +1,7 @@ -# encoding: utf-8 -$:.unshift File.expand_path("../lib", __FILE__) +$LOAD_PATH.unshift File.expand_path("../lib", __FILE__) -require 'bundler' -require 'thor/rake_compat' +require "bundler" +require "thor/rake_compat" class Default < Thor include Thor::RakeCompat @@ -20,11 +19,11 @@ class Default < Thor desc "release", "Create tag v#{Thor::VERSION} and build and push thor-#{Thor::VERSION}.gem to Rubygems" def release - Rake::Task["release"].execute + Rake::Task["release"].invoke end desc "spec", "Run RSpec code examples" def spec - exec "rspec --color --format=documentation spec" + exec "bundle exec rspec spec" end end diff --git a/bin/rake2thor b/bin/rake2thor deleted file mode 100755 index fbfb3c766..000000000 --- a/bin/rake2thor +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env ruby - -require 'ruby2ruby' -require 'parse_tree' -if Ruby2Ruby::VERSION >= "1.2.0" - require 'parse_tree_extensions' -end -require 'rake' - -input = ARGV[0] || 'Rakefile' -output = ARGV[1] || 'Thorfile' - -$requires = [] - -module Kernel - def require_with_record(file) - $requires << file if caller[1] =~ /rake2thor:/ - require_without_record file - end - alias_method :require_without_record, :require - alias_method :require, :require_with_record -end - -load input - -@private_methods = [] - -def file_task_name(name) - "compile_" + name.gsub('/', '_slash_').gsub('.', '_dot_').gsub(/\W/, '_') -end - -def method_for_task(task) - file_task = task.is_a?(Rake::FileTask) - comment = task.instance_variable_get('@comment') - prereqs = task.instance_variable_get('@prerequisites').select(&Rake::Task.method(:task_defined?)) - actions = task.instance_variable_get('@actions') - name = task.name.gsub(/^([^:]+:)+/, '') - name = file_task_name(name) if file_task - meth = '' - - meth << "desc #{name.inspect}, #{comment.inspect}\n" if comment - meth << "def #{name}\n" - - meth << prereqs.map do |pre| - pre = pre.to_s - pre = file_task_name(pre) if Rake::Task[pre].is_a?(Rake::FileTask) - ' ' + pre - end.join("\n") - - meth << "\n\n" unless prereqs.empty? || actions.empty? - - meth << actions.map do |act| - act = act.to_ruby - unless act.gsub!(/^proc \{ \|(\w+)\|\n/, - " \\1 = Struct.new(:name).new(#{name.inspect}) # A crude mock Rake::Task object\n") - act.gsub!(/^proc \{\n/, '') - end - act.gsub(/\n\}$/, '') - end.join("\n") - - meth << "\nend" - - if file_task - @private_methods << meth - return - end - - meth -end - -body = Rake::Task.tasks.map(&method(:method_for_task)).compact.map { |meth| meth.gsub(/^/, ' ') }.join("\n\n") - -unless @private_methods.empty? - body << "\n\n private\n\n" - body << @private_methods.map { |meth| meth.gsub(/^/, ' ') }.join("\n\n") -end - -requires = $requires.map { |r| "require #{r.inspect}" }.join("\n") - -File.open(output, 'w') { |f| f.write(< + # options + # + def package_name(name, _ = {}) + @package_name = name.nil? || name == "" ? nil : name + end + + # Sets the default command when thor is executed without an explicit command to be called. # # ==== Parameters - # meth:: name of the default task - # - def default_task(meth=nil) - @default_task = case meth - when :none - 'help' - when nil - @default_task || from_superclass(:default_task, 'help') + # meth:: name of the default command + # + def default_command(meth = nil) + if meth + @default_command = meth == :none ? "help" : meth.to_s else - meth.to_s + @default_command ||= from_superclass(:default_command, "help") end end + alias_method :default_task, :default_command # Registers another Thor subclass as a command. # @@ -26,7 +34,7 @@ def default_task(meth=nil) # command:: Subcommand name to use # usage:: Short usage for the subcommand # description:: Description for the subcommand - def register(klass, subcommand_name, usage, description, options={}) + def register(klass, subcommand_name, usage, description, options = {}) if klass <= Thor::Group desc usage, description, options define_method(subcommand_name) { |*args| invoke(klass, args) } @@ -36,38 +44,48 @@ def register(klass, subcommand_name, usage, description, options={}) end end - # Defines the usage and the description of the next task. + # Defines the usage and the description of the next command. # # ==== Parameters # usage # description # options # - def desc(usage, description, options={}) + def desc(usage, description, options = {}) if options[:for] - task = find_and_refresh_task(options[:for]) - task.usage = usage if usage - task.description = description if description + command = find_and_refresh_command(options[:for]) + command.usage = usage if usage + command.description = description if description else - @usage, @desc, @hide = usage, description, options[:hide] || false + @usage = usage + @desc = description + @hide = options[:hide] || false end end - # Defines the long description of the next task. + # 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={}) + def long_desc(long_description, options = {}) if options[:for] - task = find_and_refresh_task(options[:for]) - task.long_description = long_description if long_description + command = find_and_refresh_command(options[:for]) + command.long_description = long_description if long_description else @long_desc = long_description + @long_desc_wrap = options[:wrap] != false end end - # Maps an input to a task. If you define: + # Maps an input to a command. If you define: # # map "-T" => "list" # @@ -75,18 +93,23 @@ def long_desc(long_description, options={}) # # thor -T # - # Will invoke the list task. + # Will invoke the list command. # # ==== Parameters - # Hash[String|Array => Symbol]:: Maps the string or the strings in the array to the given task. + # Hash[String|Array => Symbol]:: Maps the string or the strings in the array to the given command. # - def map(mappings=nil) + def map(mappings = nil, **kw) @map ||= from_superclass(:map, {}) + if mappings && !kw.empty? + mappings = kw.merge!(mappings) + else + mappings ||= kw + end if mappings mappings.each do |key, value| if key.respond_to?(:each) - key.each {|subkey| @map[subkey] = value} + key.each { |subkey| @map[subkey] = value } else @map[key] = value end @@ -96,31 +119,31 @@ def map(mappings=nil) @map end - # Declares the options for the next task to be declared. + # Declares the options for the next command to be declared. # # ==== Parameters # Hash[Symbol => Object]:: The hash key is the name of the option and the value # is the type of the option. Can be :string, :array, :hash, :boolean, :numeric # or :required (string). If you give a value, the type of the value is used. # - def method_options(options=nil) + def method_options(options = nil) @method_options ||= {} build_options(options, @method_options) if options @method_options end - alias options method_options + alias_method :options, :method_options # Adds an option to the set of method options. If :for is given as option, - # it allows you to change the options from a previous defined task. + # it allows you to change the options from a previous defined command. # - # def previous_task + # def previous_command # # magic # end # - # method_option :foo => :bar, :for => :previous_task + # method_option :foo, :for => :previous_command # - # def next_task + # def next_command # # magic # end # @@ -137,40 +160,125 @@ def method_options(options=nil) # :banner - String to show on usage notes. # :hide - If you want to hide this option from the help. # - def method_option(name, options={}) + 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_task(options[:for]).options + find_and_refresh_command(options[:for]).options else method_options end build_option(name, options, scope) 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 - alias option method_option + # 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 task. + # Prints help information for the given command. # # ==== Parameters # shell - # task_name + # command_name # - def task_help(shell, task_name) - meth = normalize_task_name(task_name) - task = all_tasks[meth] - handle_no_task_error(meth) unless task + def command_help(shell, command_name) + meth = normalize_command_name(command_name) + command = all_commands[meth] + handle_no_command_error(meth) unless command shell.say "Usage:" - shell.say " #{banner(task)}" + shell.say " #{banner(command).split("\n").join("\n ")}" shell.say - class_options_help(shell, nil => task.options.map { |_, o| o }) - if task.long_description + 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(task.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 task.description + shell.say command.description end end + alias_method :task_help, :command_help # Prints help information for this class. # @@ -178,49 +286,69 @@ def task_help(shell, task_name) # shell # def help(shell, subcommand = false) - list = printable_tasks(true, subcommand) + list = printable_commands(true, subcommand) Thor::Util.thor_classes_in(self).each do |klass| - list += klass.printable_tasks(false) + list += klass.printable_commands(false) end - list.sort!{ |a,b| a[0] <=> b[0] } + sort_commands!(list) - shell.say "Tasks:" - shell.print_table(list, :indent => 2, :truncate => true) + if defined?(@package_name) && @package_name + shell.say "#{@package_name} commands:" + else + shell.say "Commands:" + end + + 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 tasks ready to be printed. - def printable_tasks(all = true, subcommand = false) - (all ? all_tasks : tasks).map do |_, task| - next if task.hidden? + # Returns commands ready to be printed. + def printable_commands(all = true, subcommand = false) + (all ? all_commands : commands).map do |_, command| + next if command.hidden? item = [] - item << banner(task, false, subcommand) - item << (task.description ? "# #{task.description.gsub(/\s+/m,' ')}" : "") + item << banner(command, false, subcommand) + item << (command.description ? "# #{command.description.gsub(/\s+/m, ' ')}" : "") item end.compact end + alias_method :printable_tasks, :printable_commands def subcommands @subcommands ||= from_superclass(:subcommands, []) end + alias_method :subtasks, :subcommands + + def subcommand_classes + @subcommand_classes ||= {} + end def subcommand(subcommand, subcommand_class) - self.subcommands << subcommand.to_s + subcommands << subcommand.to_s subcommand_class.subcommand_help subcommand + subcommand_classes[subcommand.to_s] = subcommand_class define_method(subcommand) do |*args| args, opts = Thor::Arguments.split(args) - invoke subcommand_class, args, opts, :invoked_via_subcommand => true + 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 + subcommand_class.commands.each do |_meth, command| + command.ancestor_name = subcommand end end + alias_method :subtask, :subcommand # Extend check unknown options to accept a hash of conditions. # # === Parameters # options: A hash containing :only and/or :except keys - def check_unknown_options!(options={}) - @check_unknown_options ||= Hash.new + def check_unknown_options!(options = {}) + @check_unknown_options ||= {} options.each do |key, value| if value @check_unknown_options[key] = Array(value) @@ -236,10 +364,10 @@ def check_unknown_options?(config) #:nodoc: options = check_unknown_options return false unless options - task = config[:current_task] - return true unless task + command = config[:current_command] + return true unless command - name = task.name + name = command.name if subcommands.include?(name) false @@ -253,16 +381,16 @@ def check_unknown_options?(config) #:nodoc: end # Stop parsing of options as soon as an unknown option or a regular - # argument is encountered. All remaining arguments are passed to the task. - # This is useful if you have a task that can receive arbitrary additional + # argument is encountered. All remaining arguments are passed to the command. + # This is useful if you have a command that can receive arbitrary additional # options, and where those additional options should not be handled by # Thor. # # ==== Example # - # To better understand how this is useful, let's consider a task that calls + # To better understand how this is useful, let's consider a command that calls # an external command. A user may want to pass arbitrary options and - # arguments to that command. The task itself also accepts some options, + # arguments to that command. The command itself also accepts some options, # which should be handled by Thor. # # class_option "verbose", :type => :boolean @@ -288,161 +416,259 @@ def check_unknown_options?(config) #:nodoc: # --verbose foo # # ==== Parameters - # Symbol ...:: A list of tasks that should be affected. - def stop_on_unknown_option!(*task_names) - @stop_on_unknown_option ||= Set.new - @stop_on_unknown_option.merge(task_names) - end - - def stop_on_unknown_option?(task) #:nodoc: - !!@stop_on_unknown_option && @stop_on_unknown_option.include?(task.name.to_sym) - end - - protected - - # The method responsible for dispatching given the args. - def dispatch(meth, given_args, given_opts, config) #:nodoc: - # There is an edge case when dispatching from a subcommand. - # A problem occurs invoking the default task. This case occurs - # when arguments are passed and a default task is defined, and - # the first given_args does not match the default task. - # Thor use "help" by default so we skip that case. - # Note the call to retrieve_task_name. It's called with - # given_args.dup since that method calls args.shift. Then lookup - # the task normally. If the first item in given_args is not - # a task then use the default task. The given_args will be - # intact later since dup was used. - if config[:invoked_via_subcommand] && given_args.size >= 1 && default_task != "help" && given_args.first != default_task - meth ||= retrieve_task_name(given_args.dup) - task = all_tasks[normalize_task_name(meth)] - task ||= all_tasks[normalize_task_name(default_task)] - else - meth ||= retrieve_task_name(given_args) - task = all_tasks[normalize_task_name(meth)] - end + # Symbol ...:: A list of commands that should be affected. + def stop_on_unknown_option!(*command_names) + @stop_on_unknown_option = stop_on_unknown_option | command_names + end - if task - args, opts = Thor::Options.split(given_args) - if stop_on_unknown_option?(task) && !args.empty? - # given_args starts with a non-option, so we treat everything as - # ordinary arguments - args.concat opts - opts.clear - end - else - args, opts = given_args, nil - task = Thor::DynamicTask.new(meth) - end + def stop_on_unknown_option?(command) #:nodoc: + command && stop_on_unknown_option.include?(command.name.to_sym) + end - opts = given_opts || opts || [] - config.merge!(:current_task => task, :task_options => task.options) + # Disable the check for required options for the given commands. + # This is useful if you have a command that does not need the required options + # to work, like help. + # + # ==== Parameters + # Symbol ...:: A list of commands that should be affected. + def disable_required_check!(*command_names) + @disable_required_check = disable_required_check | command_names + end - instance = new(args, opts, config) - yield instance if block_given? - args = instance.args - trailing = args[Range.new(arguments.size, -1)] - instance.invoke_task(task, trailing || []) - end + def disable_required_check?(command) #:nodoc: + command && disable_required_check.include?(command.name.to_sym) + end - # The banner for this class. You can customize it if you are invoking the - # thor class by another ways which is not the Thor::Runner. It receives - # the task that is going to be invoked and a boolean which indicates if - # the namespace should be displayed as arguments. - # - def banner(task, namespace = nil, subcommand = false) - "#{basename} #{task.formatted_usage(self, $thor_runner, subcommand)}" - end + # Checks if a specified command exists. + # + # ==== Parameters + # command_name:: The name of the command to check for existence. + # + # ==== Returns + # Boolean:: +true+ if the command exists, +false+ otherwise. + def command_exists?(command_name) #:nodoc: + commands.keys.include?(normalize_command_name(command_name)) + end + + 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 + + # help command has the required check disabled by default. + def disable_required_check #:nodoc: + @disable_required_check ||= [:help] + end - def baseclass #:nodoc: - Thor + 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 create_task(meth) #:nodoc: - if @usage && @desc - base_class = @hide ? Thor::HiddenTask : Thor::Task - tasks[meth] = base_class.new(meth, @desc, @long_desc, @usage, method_options) - @usage, @desc, @long_desc, @method_options, @hide = nil - true - elsif self.all_tasks[meth] || meth == "method_missing" - true - else - puts "[WARNING] Attempted to create task #{meth.inspect} without usage or description. " << - "Call desc if you want this method to be available as task or declare it inside a " << - "no_tasks{} block. Invoked from #{caller[1].inspect}." - false - 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: + meth ||= retrieve_command_name(given_args) + command = all_commands[normalize_command_name(meth)] - def initialize_added #:nodoc: - class_options.merge!(method_options) - @method_options = nil + if !command && config[:invoked_via_subcommand] + # We're a subcommand and our first argument didn't match any of our + # commands. So we put it back and call our default command. + given_args.unshift(meth) + command = all_commands[normalize_command_name(default_command)] end - # Retrieve the task name from given args. - def retrieve_task_name(args) #:nodoc: - meth = args.first.to_s unless args.empty? - if meth && (map[meth] || meth !~ /^\-/) - args.shift - else - nil + if command + args, opts = Thor::Options.split(given_args) + if stop_on_unknown_option?(command) && !args.empty? + # given_args starts with a non-option, so we treat everything as + # ordinary arguments + args.concat opts + opts.clear end + else + args = given_args + opts = nil + command = dynamic_command_class.new(meth) end - # receives a (possibly nil) task name and returns a name that is in - # the tasks hash. In addition to normalizing aliases, this logic - # will determine if a shortened command is an unambiguous substring of - # a task or alias. - # - # +normalize_task_name+ also converts names like +animal-prison+ - # into +animal_prison+. - def normalize_task_name(meth) #:nodoc: - return default_task.to_s.gsub('-', '_') unless meth - - possibilities = find_task_possibilities(meth) - if possibilities.size > 1 - raise ArgumentError, "Ambiguous task #{meth} matches [#{possibilities.join(', ')}]" - elsif possibilities.size < 1 - meth = meth || default_task - elsif map[meth] - meth = map[meth] - else - meth = possibilities.first - end + opts = given_opts || opts || [] + config[:current_command] = command + config[:command_options] = command.options + + instance = new(args, opts, config) + yield instance if block_given? + args = instance.args + trailing = args[Range.new(arguments.size, -1)] + instance.invoke_command(command, trailing || []) + end + + # The banner for this class. You can customize it if you are invoking the + # thor class by another ways which is not the Thor::Runner. It receives + # the command that is going to be invoked and a boolean which indicates if + # the namespace should be displayed as arguments. + # + def banner(command, namespace = nil, subcommand = false) + command.formatted_usage(self, $thor_runner, subcommand).split("\n").map do |formatted_usage| + "#{basename} #{formatted_usage}" + end.join("\n") + end - meth.to_s.gsub('-','_') # treat foo-bar as foo_bar + def baseclass #:nodoc: + Thor + end + + def dynamic_command_class #:nodoc: + Thor::DynamicCommand + end + + 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 + 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 + else + puts "[WARNING] Attempted to create command #{meth.inspect} without usage or description. " \ + "Call desc if you want this method to be available as command or declare it inside a " \ + "no_commands{} block. Invoked from #{caller[1].inspect}." + false end + end + alias_method :create_task, :create_command - # this is the logic that takes the task name passed in by the user - # and determines whether it is an unambiguous substrings of a task or - # alias name. - def find_task_possibilities(meth) - len = meth.to_s.length - possibilities = all_tasks.merge(map).keys.select { |n| meth == n[0, len] }.sort - unique_possibilities = possibilities.map { |k| map[k] || k }.uniq - - if possibilities.include?(meth) - [meth] - elsif unique_possibilities.size == 1 - unique_possibilities - else - possibilities - end + def initialize_added #:nodoc: + class_options.merge!(method_options) + @method_options = nil + end + + # Retrieve the command name from given args. + def retrieve_command_name(args) #:nodoc: + meth = args.first.to_s unless args.empty? + args.shift if meth && (map[meth] || meth !~ /^\-/) + end + alias_method :retrieve_task_name, :retrieve_command_name + + # receives a (possibly nil) command name and returns a name that is in + # the commands hash. In addition to normalizing aliases, this logic + # will determine if a shortened command is an unambiguous substring of + # a command or alias. + # + # +normalize_command_name+ also converts names like +animal-prison+ + # into +animal_prison+. + def normalize_command_name(meth) #:nodoc: + return default_command.to_s.tr("-", "_") unless meth + + possibilities = find_command_possibilities(meth) + raise AmbiguousTaskError, "Ambiguous command #{meth} matches [#{possibilities.join(', ')}]" if possibilities.size > 1 + + if possibilities.empty? + meth ||= default_command + elsif map[meth] + meth = map[meth] + else + meth = possibilities.first end - def subcommand_help(cmd) - desc "help [COMMAND]", "Describe subcommands or one specific subcommand" - class_eval <<-RUBY - def help(task = nil, subcommand = true); super; end - RUBY + meth.to_s.tr("-", "_") # treat foo-bar as foo_bar + end + alias_method :normalize_task_name, :normalize_command_name + + # this is the logic that takes the command name passed in by the user + # and determines whether it is an unambiguous substrings of a command or + # alias name. + def find_command_possibilities(meth) + len = meth.to_s.length + possibilities = all_commands.merge(map).keys.select { |n| meth == n[0, len] }.sort + unique_possibilities = possibilities.map { |k| map[k] || k }.uniq + + if possibilities.include?(meth) + [meth] + elsif unique_possibilities.size == 1 + unique_possibilities + else + possibilities end + end + alias_method :find_task_possibilities, :find_command_possibilities + + def subcommand_help(cmd) + desc "help [COMMAND]", "Describe subcommands or one specific subcommand" + class_eval " + 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 map HELP_MAPPINGS => :help - desc "help [TASK]", "Describe available tasks or one specific task" - def help(task = nil, subcommand = false) - task ? self.class.task_help(shell, task) : self.class.help(shell, subcommand) + desc "help [COMMAND]", "Describe available commands or one specific command" + def help(command = nil, subcommand = false) + if command + if self.class.subcommands.include? command + self.class.subcommand_classes[command].help(shell, true) + else + self.class.command_help(shell, command) + end + else + self.class.help(shell, subcommand) + end end end diff --git a/lib/thor/actions.rb b/lib/thor/actions.rb index 44a45afac..50475452d 100644 --- a/lib/thor/actions.rb +++ b/lib/thor/actions.rb @@ -1,18 +1,16 @@ -require 'fileutils' -require 'uri' -require 'thor/core_ext/file_binary_read' -require 'thor/actions/create_file' -require 'thor/actions/create_link' -require 'thor/actions/directory' -require 'thor/actions/empty_directory' -require 'thor/actions/file_manipulation' -require 'thor/actions/inject_into_file' +require_relative "actions/create_file" +require_relative "actions/create_link" +require_relative "actions/directory" +require_relative "actions/empty_directory" +require_relative "actions/file_manipulation" +require_relative "actions/inject_into_file" class Thor module Actions attr_accessor :behavior def self.included(base) #:nodoc: + super(base) base.extend ClassMethods end @@ -26,9 +24,9 @@ def source_paths end # Stores and return the source root for this class - def source_root(path=nil) + def source_root(path = nil) @_source_root = path if path - @_source_root + @_source_root ||= nil end # Returns the source paths in the following order: @@ -39,8 +37,8 @@ def source_root(path=nil) # def source_paths_for_search paths = [] - paths += self.source_paths - paths << self.source_root if self.source_root + paths += source_paths + paths << source_root if source_root paths += from_superclass(:source_paths, []) paths end @@ -48,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 @@ -71,7 +69,7 @@ def add_runtime_options! # # destination_root:: The root directory needed for some actions. # - def initialize(args=[], options={}, config={}) + def initialize(args = [], options = {}, config = {}) self.behavior = case config[:behavior].to_s when "force", "skip" _cleanup_options_and_set(options, config[:behavior]) @@ -107,16 +105,18 @@ def destination_root # def destination_root=(root) @destination_stack ||= [] - @destination_stack[0] = File.expand_path(root || '') + @destination_stack[0] = File.expand_path(root || "") end # Returns the given path relative to the absolute root (ie, root where # the script started). # - def relative_to_original_destination_root(path, remove_dot=true) - path = path.dup - if path.gsub!(@destination_stack[0], '.') - remove_dot ? (path[2..-1] || '') : path + 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]) + path = path.dup + path[0...root.size] = "." + remove_dot ? (path[2..-1] || "") : path else path end @@ -131,24 +131,27 @@ def source_paths # Receives a file or directory and search for it in the source paths. # def find_in_source_paths(file) + possible_files = [file, file + TEMPLATE_EXTNAME] relative_root = relative_to_original_destination_root(destination_root, false) source_paths.each do |source| - source_file = File.expand_path(file, File.join(source, relative_root)) - return source_file if File.exists?(source_file) + possible_files.each do |f| + source_file = File.expand_path(f, File.join(source, relative_root)) + return source_file if File.exist?(source_file) + end end - message = "Could not find #{file.inspect} in any of your source paths. " + message = "Could not find #{file.inspect} in any of your source paths. ".dup unless self.class.source_root message << "Please invoke #{self.class.name}.source_root(PATH) with the PATH containing your templates. " end - if source_paths.empty? - message << "Currently you have no source paths." - else - message << "Your current source paths are: \n#{source_paths.join("\n")}" - end + message << if source_paths.empty? + "Currently you have no source paths." + else + "Your current source paths are: \n#{source_paths.join("\n")}" + end raise Error, message end @@ -158,11 +161,13 @@ 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. # - def inside(dir='', config={}, &block) + def inside(dir = "", config = {}, &block) verbose = config.fetch(:verbose, false) pretend = options[:pretend] @@ -170,20 +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 - FileUtils.cd(destination_root) { block.arity == 1 ? yield(destination_root) : yield } + require "fileutils" + 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. @@ -204,18 +213,19 @@ def in_root # # apply "recipes/jquery.rb" # - def apply(path, config={}) + def apply(path, config = {}) verbose = config.fetch(:verbose, true) - is_uri = path =~ /^https?\:\/\// + is_uri = path =~ %r{^https?\://} path = find_in_source_paths(path) unless is_uri say_status :apply, path, verbose shell.padding += 1 if verbose - if is_uri - contents = open(path, "Accept" => "application/x-thor-template") {|io| io.read } + contents = if is_uri + require "open-uri" + URI.open(path, "Accept" => "application/x-thor-template", &:read) else - contents = open(path) {|io| io.read } + File.open(path, &:read) end instance_eval(contents, path) @@ -227,7 +237,7 @@ def apply(path, config={}) # ==== Parameters # command:: the command to be executed. # config:: give :verbose => false to not log the status, :capture => true to hide to output. Specify :with - # to append an executable to command executation. + # to append an executable to command execution. # # ==== Example # @@ -235,7 +245,7 @@ def apply(path, config={}) # run('ln -s ~/edge rails') # end # - def run(command, config={}) + def run(command, config = {}) return unless behavior == :invoke destination = relative_to_original_destination_root(destination_root, false) @@ -248,9 +258,22 @@ def run(command, config={}) say_status :run, desc, config.fetch(:verbose, true) - unless options[:pretend] - config[:capture] ? `#{command}` : system("#{command}") + return if options[:pretend] + + env_splat = [config[:env]] if config[:env] + + if config[:capture] + require "open3" + result, status = Open3.capture2e(*env_splat, command.to_s) + success = status.success? + else + result = system(*env_splat, command.to_s) + success = result end + + abort if !success && config.fetch(:abort_on_failure, self.class.exit_on_failure?) + + result end # Executes a ruby script (taking into account WIN32 platform quirks). @@ -259,17 +282,17 @@ def run(command, config={}) # command:: the command to be executed. # config:: give :verbose => false to not log the status. # - def run_ruby_script(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 # switches. # # ==== Parameters - # task:: the task to be invoked - # args:: arguments to the task + # command:: the command to be invoked + # args:: arguments to the command # config:: give :verbose => false to not log the status, :capture => true to hide to output. # Other options are given as parameter to Thor. # @@ -282,37 +305,36 @@ def run_ruby_script(command, config={}) # thor :list, :all => true, :substring => 'rails' # #=> thor list --all --substring=rails # - def thor(task, *args) + def thor(command, *args) config = args.last.is_a?(Hash) ? args.pop : {} verbose = config.key?(:verbose) ? config.delete(:verbose) : true pretend = config.key?(:pretend) ? config.delete(:pretend) : false capture = config.key?(:capture) ? config.delete(:capture) : false - args.unshift task + args.unshift(command) args.push Thor::Options.to_switches(config) - command = args.join(' ').strip + 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 + protected - # Allow current root to be shared between invocations. - # - def _shared_configuration #:nodoc: - super.merge!(:destination_root => self.destination_root) - end + # Allow current root to be shared between invocations. + # + def _shared_configuration #:nodoc: + super.merge!(destination_root: destination_root) + end - def _cleanup_options_and_set(options, key) #:nodoc: - case options - when Array - %w(--force -f --skip -s).each { |i| options.delete(i) } - options << "--#{key}" - when Hash - [:force, :skip, "force", "skip"].each { |i| options.delete(i) } - options.merge!(key => true) - end + def _cleanup_options_and_set(options, key) #:nodoc: + case options + when Array + %w(--force -f --skip -s).each { |i| options.delete(i) } + options << "--#{key}" + when Hash + [:force, :skip, "force", "skip"].each { |i| options.delete(i) } + options.merge!(key => true) end - + end end end diff --git a/lib/thor/actions/create_file.rb b/lib/thor/actions/create_file.rb index ed5973a42..382838ca2 100644 --- a/lib/thor/actions/create_file.rb +++ b/lib/thor/actions/create_file.rb @@ -1,8 +1,7 @@ -require 'thor/actions/empty_directory' +require_relative "empty_directory" class Thor module Actions - # Create a new file relative to the destination root with the given data, # which is the return value of a block or a data string. # @@ -25,7 +24,7 @@ def create_file(destination, *args, &block) data = args.first action CreateFile.new(self, destination, block || data.to_s, config) end - alias :add_file :create_file + alias_method :add_file, :create_file # CreateFile is a subset of Template, which instead of rendering a file with # ERB, it gets the content from the user. @@ -33,7 +32,7 @@ def create_file(destination, *args, &block) class CreateFile < EmptyDirectory #:nodoc: attr_reader :data - def initialize(base, destination, data, config={}) + def initialize(base, destination, data, config = {}) @data = data super(base, destination, config) end @@ -44,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. @@ -59,47 +59,47 @@ def render 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 - protected - - # Now on conflict we check if the file is identical or not. - # - def on_conflict_behavior(&block) - if identical? - say_status :identical, :blue - else - options = base.options.merge(config) - force_or_skip_or_conflict(options[:force], options[:skip], &block) - end - end + protected - # If force is true, run the action, otherwise check if it's not being - # skipped. If both are false, show the file_collision menu, if the menu - # returns true, force it, otherwise skip. - # - def force_or_skip_or_conflict(force, skip, &block) - if force - say_status :force, :yellow - block.call unless pretend? - elsif skip - say_status :skip, :yellow - else - say_status :conflict, :red - force_or_skip_or_conflict(force_on_collision?, true, &block) - end + # Now on conflict we check if the file is identical or not. + # + def on_conflict_behavior(&block) + if identical? + say_status :identical, :blue + else + options = base.options.merge(config) + force_or_skip_or_conflict(options[:force], options[:skip], &block) end + end - # Shows the file collision menu to the user and gets the result. - # - def force_on_collision? - base.shell.file_collision(destination){ render } + # If force is true, run the action, otherwise check if it's not being + # skipped. If both are false, show the file_collision menu, if the menu + # returns true, force it, otherwise skip. + # + def force_or_skip_or_conflict(force, skip, &block) + if force + say_status :force, :yellow + yield unless pretend? + elsif skip + say_status :skip, :yellow + else + say_status :conflict, :red + force_or_skip_or_conflict(force_on_collision?, true, &block) end + end + # Shows the file collision menu to the user and gets the result. + # + def force_on_collision? + base.shell.file_collision(destination) { render } + end end end end diff --git a/lib/thor/actions/create_link.rb b/lib/thor/actions/create_link.rb index 864a1e992..3c6014279 100644 --- a/lib/thor/actions/create_link.rb +++ b/lib/thor/actions/create_link.rb @@ -1,8 +1,7 @@ -require 'thor/actions/create_file' +require_relative "create_file" class Thor module Actions - # Create a new file relative to the destination root from the given source. # # ==== Parameters @@ -15,12 +14,12 @@ module Actions # # create_link "config/apache.conf", "/etc/apache.conf" # - def create_link(destination, *args, &block) + def create_link(destination, *args) config = args.last.is_a?(Hash) ? args.pop : {} source = args.first action CreateLink.new(self, destination, source, config) end - alias :add_link :create_link + alias_method :add_link, :create_link # CreateLink is a subset of CreateFile, which instead of taking a block of # data, just takes a source string from the user. @@ -34,11 +33,13 @@ class CreateLink < CreateFile #:nodoc: # Boolean:: true if it is identical, false otherwise. # def identical? - exists? && File.identical?(render, destination) + source = File.expand_path(render, File.dirname(destination)) + exists? && File.identical?(source, destination) end def invoke! invoke_with_conflict_check do + require "fileutils" FileUtils.mkdir_p(File.dirname(destination)) # Create a symlink by default config[:symbolic] = true if config[:symbolic].nil? @@ -52,6 +53,9 @@ def invoke! given_destination end + def exists? + super || File.symlink?(destination) + end end end end diff --git a/lib/thor/actions/directory.rb b/lib/thor/actions/directory.rb index 1505461cb..e57bec9e1 100644 --- a/lib/thor/actions/directory.rb +++ b/lib/thor/actions/directory.rb @@ -1,4 +1,4 @@ -require 'thor/actions/empty_directory' +require_relative "empty_directory" class Thor module Actions @@ -39,6 +39,7 @@ module Actions # config:: give :verbose => false to not log the status. # If :recursive => false, does not look for paths recursively. # If :mode => :preserve, preserve the file mode from the source. + # If :exclude_pattern => /regexp/, prevents copying files that match that regexp. # # ==== Examples # @@ -54,10 +55,10 @@ def directory(source, *args, &block) class Directory < EmptyDirectory #:nodoc: attr_reader :source - def initialize(base, source, destination=nil, config={}, &block) - @source = File.expand_path(base.find_in_source_paths(source.to_s)) + 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! @@ -69,49 +70,39 @@ def revoke! execute! end - protected + protected - def execute! - lookup = Util.escape_globs(source) - lookup = config[:recursive] ? File.join(lookup, '**') : lookup - lookup = file_level_lookup(lookup) + def execute! + lookup = Util.escape_globs(source) + lookup = config[:recursive] ? File.join(lookup, "**") : lookup + lookup = file_level_lookup(lookup) - files(lookup).sort.each do |file_source| - next if File.directory?(file_source) - file_destination = File.join(given_destination, file_source.gsub(source, '.')) - file_destination.gsub!('/./', '/') + files(lookup).sort.each do |file_source| + next if File.directory?(file_source) + next if config[:exclude_pattern] && file_source.match(config[:exclude_pattern]) + file_destination = File.join(given_destination, file_source.gsub(source, ".")) + file_destination.gsub!("/./", "/") - case file_source - when /\.empty_directory$/ - dirname = File.dirname(file_destination).gsub(/\/\.$/, '') - next if dirname == given_destination - base.empty_directory(dirname, config) - when /\.tt$/ - destination = base.template(file_source, file_destination[0..-4], config, &@block) - else - destination = base.copy_file(file_source, file_destination, config, &@block) - end + case file_source + when /\.empty_directory$/ + dirname = File.dirname(file_destination).gsub(%r{/\.$}, "") + next if dirname == given_destination + base.empty_directory(dirname, config) + when /#{TEMPLATE_EXTNAME}$/ + base.template(file_source, file_destination[0..-4], config, &@block) + else + base.copy_file(file_source, file_destination, config, &@block) end end + end - if RUBY_VERSION < '2.0' - def file_level_lookup(previous_lookup) - File.join(previous_lookup, '{*,.[a-z]*}') - end - - def files(lookup) - Dir[lookup] - end - else - def file_level_lookup(previous_lookup) - File.join(previous_lookup, '*') - end - - def files(lookup) - Dir.glob(lookup, File::FNM_DOTMATCH) - end - end + def file_level_lookup(previous_lookup) + File.join(previous_lookup, "*") + end + def files(lookup) + Dir.glob(lookup, File::FNM_DOTMATCH) + end end end end diff --git a/lib/thor/actions/empty_directory.rb b/lib/thor/actions/empty_directory.rb index 93d3e2a83..46a602361 100644 --- a/lib/thor/actions/empty_directory.rb +++ b/lib/thor/actions/empty_directory.rb @@ -1,6 +1,5 @@ class Thor module Actions - # Creates an empty directory. # # ==== Parameters @@ -11,7 +10,7 @@ module Actions # # empty_directory "doc" # - def empty_directory(destination, config={}) + def empty_directory(destination, config = {}) action EmptyDirectory.new(self, destination, config) end @@ -32,8 +31,9 @@ class EmptyDirectory #:nodoc: # destination:: Relative path to the destination of this file # config:: give :verbose => false to not log the status. # - def initialize(base, destination, config={}) - @base, @config = base, { :verbose => true }.merge(config) + def initialize(base, destination, config = {}) + @base = base + @config = {verbose: true}.merge(config) self.destination = destination end @@ -43,111 +43,101 @@ def initialize(base, destination, config={}) # Boolean:: true if the file exists, false otherwise. # def exists? - ::File.exists?(destination) + ::File.exist?(destination) end def invoke! invoke_with_conflict_check do + require "fileutils" ::FileUtils.mkdir_p(destination) end end def revoke! say_status :remove, :red + require "fileutils" ::FileUtils.rm_rf(destination) if !pretend? && exists? given_destination end - protected + protected - # Shortcut for pretend. - # - def pretend? - base.options[:pretend] - end + # Shortcut for pretend. + # + def pretend? + base.options[:pretend] + end - # Sets the absolute destination value from a relative destination value. - # It also stores the given and relative destination. Let's suppose our - # script is being executed on "dest", it sets the destination root to - # "dest". The destination, given_destination and relative_destination - # are related in the following way: - # - # inside "bar" do - # empty_directory "baz" - # end - # - # destination #=> dest/bar/baz - # relative_destination #=> bar/baz - # given_destination #=> baz - # - def destination=(destination) - if destination - @given_destination = convert_encoded_instructions(destination.to_s) - @destination = ::File.expand_path(@given_destination, base.destination_root) - @relative_destination = base.relative_to_original_destination_root(@destination) - end - end + # Sets the absolute destination value from a relative destination value. + # It also stores the given and relative destination. Let's suppose our + # script is being executed on "dest", it sets the destination root to + # "dest". The destination, given_destination and relative_destination + # are related in the following way: + # + # inside "bar" do + # empty_directory "baz" + # end + # + # destination #=> dest/bar/baz + # relative_destination #=> bar/baz + # given_destination #=> baz + # + def destination=(destination) + return unless destination + @given_destination = convert_encoded_instructions(destination.to_s) + @destination = ::File.expand_path(@given_destination, base.destination_root) + @relative_destination = base.relative_to_original_destination_root(@destination) + end - # Filenames in the encoded form are converted. If you have a file: - # - # %file_name%.rb - # - # It calls #file_name from the base and replaces %-string with the - # return value (should be String) of #file_name: - # - # user.rb - # - # The method referenced by %-string SHOULD be public. Otherwise you - # get the exception with the corresponding error message. - # - def convert_encoded_instructions(filename) - filename.gsub(/%(.*?)%/) do |initial_string| - call_public_method($1.strip) or initial_string - end + # Filenames in the encoded form are converted. If you have a file: + # + # %file_name%.rb + # + # It calls #file_name from the base and replaces %-string with the + # return value (should be String) of #file_name: + # + # user.rb + # + # The method referenced can be either public or private. + # + def convert_encoded_instructions(filename) + filename.gsub(/%(.*?)%/) do |initial_string| + method = $1.strip + base.respond_to?(method, true) ? base.send(method) : initial_string end + end - # Calls `base`'s public method `sym`. - # Returns:: result of `base.sym` or `nil` if `sym` wasn't found in - # `base` - # Raises:: Thor::PrivateMethodEncodedError if `sym` references - # a private method. - def call_public_method(sym) - if base.respond_to?(sym) - base.send(sym) - elsif base.respond_to?(sym, true) - raise Thor::PrivateMethodEncodedError, - "Method #{base.class}##{sym} should be public, not private" - else - nil - end + # Receives a hash of options and just execute the block if some + # conditions are met. + # + def invoke_with_conflict_check(&block) + if exists? + on_conflict_behavior(&block) + else + yield unless pretend? + say_status :create, :green end - # Receives a hash of options and just execute the block if some - # conditions are met. - # - def invoke_with_conflict_check(&block) - if exists? - on_conflict_behavior(&block) - else - say_status :create, :green - block.call unless pretend? - end - - destination - end + destination + rescue Errno::EISDIR, Errno::EEXIST + on_file_clash_behavior + end - # What to do when the destination file already exists. - # - def on_conflict_behavior(&block) - say_status :exist, :blue - end + def on_file_clash_behavior + say_status :file_clash, :red + end - # Shortcut to say_status shell method. - # - def say_status(status, color) - base.shell.say_status status, relative_destination, color if config[:verbose] - end + # What to do when the destination file already exists. + # + def on_conflict_behavior + say_status :exist, :blue + end + # Shortcut to say_status shell method. + # + def say_status(status, color) + base.shell.say_status status, relative_destination, color if config[:verbose] + end end end end diff --git a/lib/thor/actions/file_manipulation.rb b/lib/thor/actions/file_manipulation.rb index daefe403c..9c637cb24 100644 --- a/lib/thor/actions/file_manipulation.rb +++ b/lib/thor/actions/file_manipulation.rb @@ -1,9 +1,7 @@ -require 'erb' -require 'open-uri' +require "erb" class Thor module Actions - # Copies the file from the relative source to the relative destination. If # the destination is not given it's assumed to be equal to the source. # @@ -12,7 +10,6 @@ module Actions # destination:: the relative path to the destination root. # config:: give :verbose => false to not log the status, and # :mode => :preserve, to preserve the file mode from the source. - # # ==== Examples # @@ -25,14 +22,14 @@ def copy_file(source, *args, &block) destination = args.first || source source = File.expand_path(find_in_source_paths(source.to_s)) - create_file destination, nil, config do + resulting_destination = create_file destination, nil, config do content = File.binread(source) - content = block.call(content) if block + content = yield(content) if block content end if config[:mode] == :preserve mode = File.stat(source).mode - chmod(destination, mode, config) + chmod(resulting_destination, mode, config) end end @@ -50,7 +47,7 @@ def copy_file(source, *args, &block) # # link_file "doc/README" # - def link_file(source, *args, &block) + def link_file(source, *args) config = args.last.is_a?(Hash) ? args.pop : {} destination = args.first || source source = File.expand_path(find_in_source_paths(source.to_s)) @@ -62,15 +59,21 @@ def link_file(source, *args, &block) # destination. If a block is given instead of destination, the content of # the url is yielded and used as location. # + # +get+ relies on open-uri, so passing application user input would provide + # a command injection attack vector. + # # ==== 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 @@ -79,11 +82,16 @@ def get(source, *args, &block) config = args.last.is_a?(Hash) ? args.pop : {} destination = args.first - source = File.expand_path(find_in_source_paths(source.to_s)) unless source =~ /^https?\:\/\// - render = open(source) {|input| input.binmode.read } + render = if source =~ %r{^https?\://} + require "open-uri" + URI.send(:open, source, config.fetch(:http_headers, {})) { |input| input.binmode.read } + else + source = File.expand_path(find_in_source_paths(source.to_s)) + File.open(source) { |input| input.binmode.read } + end destination ||= if block_given? - block.arity == 1 ? block.call(render) : block.call + block.arity == 1 ? yield(render) : yield else File.basename(source) end @@ -108,14 +116,17 @@ def get(source, *args, &block) # def template(source, *args, &block) config = args.last.is_a?(Hash) ? args.pop : {} - destination = args.first || source.sub(/\.tt$/, '') + destination = args.first || source.sub(/#{TEMPLATE_EXTNAME}$/, "") source = File.expand_path(find_in_source_paths(source.to_s)) - context = instance_eval('binding') + context = config.delete(:context) || instance_eval("binding") create_file destination, nil, config do - content = ERB.new(::File.binread(source), nil, '-', '@output_buffer').result(context) - content = block.call(content) if block + capturable_erb = CapturableERB.new(::File.binread(source), trim_mode: "-", eoutvar: "@output_buffer") + content = capturable_erb.tap do |erb| + erb.filename = source + end.result(context) + content = yield(content) if block content end end @@ -129,13 +140,16 @@ def template(source, *args, &block) # # ==== Example # - # chmod "script/*", 0755 + # chmod "script/server", 0755 # - def chmod(path, mode, config={}) + def chmod(path, mode, config = {}) return unless behavior == :invoke path = File.expand_path(path, destination_root) say_status :chmod, relative_to_original_destination_root(path), config.fetch(:verbose, true) - FileUtils.chmod_R(mode, path) unless options[:pretend] + unless options[:pretend] + require "fileutils" + FileUtils.chmod_R(mode, path) + end end # Prepend text to a file. Since it depends on insert_into_file, it's reversible. @@ -155,7 +169,7 @@ def chmod(path, mode, config={}) # def prepend_to_file(path, *args, &block) config = args.last.is_a?(Hash) ? args.pop : {} - config.merge!(:after => /\A/) + config[:after] = /\A/ insert_into_file(path, *(args << config), &block) end alias_method :prepend_file, :prepend_to_file @@ -177,7 +191,7 @@ def prepend_to_file(path, *args, &block) # def append_to_file(path, *args, &block) config = args.last.is_a?(Hash) ? args.pop : {} - config.merge!(:before => /\z/) + config[:before] = /\z/ insert_into_file(path, *(args << config), &block) end alias_method :append_file, :append_to_file @@ -193,25 +207,78 @@ def append_to_file(path, *args, &block) # # ==== Examples # - # inject_into_class "app/controllers/application_controller.rb", ApplicationController, " filter_parameter :password\n" + # inject_into_class "app/controllers/application_controller.rb", "ApplicationController", " filter_parameter :password\n" # - # inject_into_class "app/controllers/application_controller.rb", ApplicationController do + # inject_into_class "app/controllers/application_controller.rb", "ApplicationController" do # " filter_parameter :password\n" # end # def inject_into_class(path, klass, *args, &block) config = args.last.is_a?(Hash) ? args.pop : {} - config.merge!(:after => /class #{klass}\n|class #{klass} .*\n/) + config[:after] = /class #{klass}\n|class #{klass} .*\n/ + insert_into_file(path, *(args << config), &block) + end + + # Injects text right after the module definition. Since it depends on + # insert_into_file, it's reversible. + # + # ==== Parameters + # path:: path of the file to be changed + # module_name:: the module to be manipulated + # data:: the data to append to the class, can be also given as a block. + # config:: give :verbose => false to not log the status. + # + # ==== Examples + # + # inject_into_module "app/helpers/application_helper.rb", "ApplicationHelper", " def help; 'help'; end\n" + # + # inject_into_module "app/helpers/application_helper.rb", "ApplicationHelper" do + # " def help; 'help'; end\n" + # end + # + def inject_into_module(path, module_name, *args, &block) + config = args.last.is_a?(Hash) ? args.pop : {} + config[:after] = /module #{module_name}\n|module #{module_name} .*\n/ insert_into_file(path, *(args << config), &block) end + # Run a regular expression replacement on a file, raising an error if the + # contents of the file are not changed. + # + # ==== Parameters + # path:: path of the file to be changed + # 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 regardless of runner behavior. + # + # ==== Example + # + # gsub_file! 'app/controllers/application_controller.rb', /#\s*(filter_parameter_logging :password)/, '\1' + # + # gsub_file! 'README', /rake/, :green do |match| + # match << " no more. Use thor!" + # end + # + def gsub_file!(path, flag, *args, &block) + config = args.last.is_a?(Hash) ? args.pop : {} + + return unless behavior == :invoke || config.fetch(:force, false) + + path = File.expand_path(path, destination_root) + say_status :gsub, relative_to_original_destination_root(path), config.fetch(:verbose, true) + + actually_gsub_file(path, flag, args, true, &block) unless options[:pretend] + end + # Run a regular expression replacement on a file. # # ==== Parameters # path:: path of the file to be changed # 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. + # config:: give :verbose => false to not log the status, and + # :force => true, to force the replacement regardless of runner behavior. # # ==== Example # @@ -222,22 +289,18 @@ def inject_into_class(path, klass, *args, &block) # end # def gsub_file(path, flag, *args, &block) - return unless behavior == :invoke config = args.last.is_a?(Hash) ? args.pop : {} + return unless behavior == :invoke || config.fetch(:force, false) + path = File.expand_path(path, destination_root) say_status :gsub, relative_to_original_destination_root(path), config.fetch(:verbose, true) - unless options[:pretend] - content = File.binread(path) - content.gsub!(flag, *args, &block) - File.open(path, 'wb') { |file| file.write(content) } - end + actually_gsub_file(path, flag, args, false, &block) unless options[:pretend] end - # Uncomment all lines matching a given regex. It will leave the space - # which existed before the comment hash in tact but will remove any spacing - # between the comment hash and the beginning of the line. + # Uncomment all lines matching a given regex. Preserves indentation before + # the comment hash and removes the hash and any immediate following space. # # ==== Parameters # path:: path of the file to be changed @@ -251,7 +314,7 @@ def gsub_file(path, flag, *args, &block) def uncomment_lines(path, flag, *args) flag = flag.respond_to?(:source) ? flag.source : flag - gsub_file(path, /^(\s*)#[[:blank:]]*(.*#{flag})/, '\1\2', *args) + gsub_file(path, /^(\s*)#[[:blank:]]?(.*#{flag})/, '\1\2', *args) end # Comment all lines matching a given regex. It will leave the space @@ -270,7 +333,7 @@ def uncomment_lines(path, flag, *args) def comment_lines(path, flag, *args) flag = flag.respond_to?(:source) ? flag.source : flag - gsub_file(path, /^(\s*)([^#|\n]*#{flag})/, '\1# \2', *args) + gsub_file(path, /^(\s*)([^#\n]*#{flag})/, '\1# \2', *args) end # Removes a file at the given location. @@ -284,31 +347,61 @@ def comment_lines(path, flag, *args) # remove_file 'README' # remove_file 'app/controllers/application_controller.rb' # - def remove_file(path, config={}) + def remove_file(path, config = {}) return unless behavior == :invoke - path = File.expand_path(path, destination_root) + path = File.expand_path(path, destination_root) say_status :remove, relative_to_original_destination_root(path), config.fetch(:verbose, true) - ::FileUtils.rm_rf(path) if !options[:pretend] && File.exists?(path) + if !options[:pretend] && (File.exist?(path) || File.symlink?(path)) + require "fileutils" + ::FileUtils.rm_rf(path) + end end - alias :remove_dir :remove_file + alias_method :remove_dir, :remove_file - private attr_accessor :output_buffer + private :output_buffer, :output_buffer= + + private + def concat(string) @output_buffer.concat(string) end - def capture(*args, &block) - with_output_buffer { block.call(*args) } + def capture(*args) + with_output_buffer { yield(*args) } end - def with_output_buffer(buf = '') #:nodoc: - self.output_buffer, old_buffer = buf, output_buffer + def with_output_buffer(buf = "".dup) #:nodoc: + raise ArgumentError, "Buffer cannot be a frozen object" if buf.frozen? + old_buffer = output_buffer + self.output_buffer = buf yield output_buffer ensure self.output_buffer = old_buffer end + + def actually_gsub_file(path, flag, args, error_on_no_change, &block) + content = File.binread(path) + success = content.gsub!(flag, *args, &block) + + if success.nil? && error_on_no_change + raise Thor::Error, "The content of #{path} did not change" + end + + File.open(path, "wb") { |file| file.write(content) } + end + + # Thor::Actions#capture depends on what kind of buffer is used in ERB. + # Thus CapturableERB fixes ERB to use String buffer. + class CapturableERB < ERB + def set_eoutvar(compiler, eoutvar = "_erbout") + compiler.put_cmd = "#{eoutvar}.concat" + compiler.insert_cmd = "#{eoutvar}.concat" + compiler.pre_cmd = ["#{eoutvar} = ''.dup"] + compiler.post_cmd = [eoutvar] + end + end end end diff --git a/lib/thor/actions/inject_into_file.rb b/lib/thor/actions/inject_into_file.rb index c48cfab54..be13ddb2c 100644 --- a/lib/thor/actions/inject_into_file.rb +++ b/lib/thor/actions/inject_into_file.rb @@ -1,8 +1,7 @@ -require 'thor/actions/empty_directory' +require_relative "empty_directory" class Thor module Actions - # Injects the given content into a file. Different from gsub_file, this # method is reversible. # @@ -22,12 +21,14 @@ module Actions # gems.split(" ").map{ |gem| " config.gem :#{gem}" }.join("\n") # end # + 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) - if block_given? - data, config = block, args.shift - else - data, config = args.shift, args.shift - end + data = block_given? ? block : args.shift + + config = args.shift || {} + config[:after] = /\z/ unless config.key?(:before) || config.key?(:after) + action InjectIntoFile.new(self, destination, data, config) end alias_method :inject_into_file, :insert_into_file @@ -36,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)] @@ -49,15 +50,25 @@ def initialize(base, destination, data, config) end def invoke! - say_status :invoke - content = if @behavior == :after '\0' + replacement else replacement + '\0' end - replace!(/#{flag}/, content, config[:force]) + 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 + else + unless pretend? + raise Thor::Error, "The file #{ destination } does not appear to exist" + end + end end def revoke! @@ -74,36 +85,46 @@ def revoke! replace!(regexp, content, true) end - protected - - def say_status(behavior) - status = if behavior == :invoke - if flag == /\A/ - :prepend - elsif flag == /\z/ - :append - else - :insert - end + protected + + def say_status(behavior, warning: nil, color: nil) + status = if behavior == :invoke + if flag == /\A/ + :prepend + elsif flag == /\z/ + :append else - :subtract + :insert end - - super(status, config[:verbose]) + elsif warning + warning + elsif behavior == :unchanged + :unchanged + else + :subtract end - # Adds the content to the file. - # - def replace!(regexp, string, force) - unless base.options[:pretend] - content = File.binread(destination) - if force || !content.include?(replacement) - content.gsub!(regexp, string) - File.open(destination, 'wb') { |file| file.write(content) } - end - end - end + 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) + if force || !replacement_present? + success = content.gsub!(regexp, string) + + File.open(destination, "wb") { |file| file.write(content) } unless pretend? + success + end + end end end end diff --git a/lib/thor/base.rb b/lib/thor/base.rb index 1a09141ec..d5f5bea0c 100644 --- a/lib/thor/base.rb +++ b/lib/thor/base.rb @@ -1,16 +1,17 @@ -require 'thor/core_ext/hash_with_indifferent_access' -require 'thor/core_ext/ordered_hash' -require 'thor/error' -require 'thor/shell' -require 'thor/invocation' -require 'thor/parser' -require 'thor/task' -require 'thor/util' +require_relative "command" +require_relative "core_ext/hash_with_indifferent_access" +require_relative "error" +require_relative "invocation" +require_relative "nested_context" +require_relative "parser" +require_relative "shell" +require_relative "line_editor" +require_relative "util" class Thor - autoload :Actions, 'thor/actions' - autoload :RakeCompat, 'thor/rake_compat' - autoload :Group, 'thor/group' + autoload :Actions, File.expand_path("actions", __dir__) + autoload :RakeCompat, File.expand_path("rake_compat", __dir__) + autoload :Group, File.expand_path("group", __dir__) # Shortcuts for help. HELP_MAPPINGS = %w(-h -? --help -D) @@ -19,6 +20,17 @@ class Thor THOR_RESERVED_WORDS = %w(invoke shell options behavior root destination_root relative_root action add_file create_file in_root inside run run_ruby_script) + TEMPLATE_EXTNAME = ".tt" + + class << self + def deprecation_warning(message) #:nodoc: + unless ENV["THOR_SILENCE_DEPRECATION"] + warn "Deprecation warning: #{message}\n" + + "You can silence deprecations warning by setting the environment variable THOR_SILENCE_DEPRECATION." + end + end + end + module Base attr_accessor :options, :parent_options, :args @@ -38,7 +50,7 @@ module Base # # config:: Configuration for this Thor class. # - def initialize(args=[], options={}, config={}) + def initialize(args = [], local_options = {}, config = {}) parse_options = self.class.class_options # The start method splits inbound arguments at the first argument @@ -46,22 +58,42 @@ def initialize(args=[], options={}, config={}) # new, passing in the two halves of the arguments Array as the # first two parameters. - if options.is_a?(Array) - task_options = config.delete(:task_options) # hook for start - parse_options = parse_options.merge(task_options) if task_options - array_options, hash_options = options, {} + 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 = {} else # Handle the case where the class was explicitly instantiated # with pre-parsed options. - array_options, hash_options = [], options + array_options = [] + hash_options = local_options end # 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_task] - opts = Thor::Options.new(parse_options, hash_options, stop_on_unknown) + 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] # If unknown options are disallowed, make sure that none of the # remaining arguments looks like an option. @@ -76,13 +108,14 @@ def initialize(args=[], options={}, config={}) to_parse += opts.remaining unless self.class.strict_args_position?(config) thor_args = Thor::Arguments.new(self.class.arguments) - thor_args.parse(to_parse).each { |k,v| __send__("#{k}=", v) } + thor_args.parse(to_parse).each { |k, v| __send__("#{k}=", v) } @args = thor_args.remaining end class << self def included(base) #:nodoc: - base.send :extend, ClassMethods + super(base) + base.extend ClassMethods base.send :include, Invocation base.send :include, Shell end @@ -102,11 +135,11 @@ def subclasses # Hash[path => Class] # def subclass_files - @subclass_files ||= Hash.new{ |h,k| h[k] = [] } + @subclass_files ||= Hash.new { |h, k| h[k] = [] } end # Whenever a class inherits from Thor or Thor::Group, we should track the - # class and the file on Thor::Base. This is the method responsable for it. + # class and the file on Thor::Base. This is the method responsible for it. # def register_klass_file(klass) #:nodoc: file = caller[1].match(/(.*):\d+/)[1] @@ -119,15 +152,15 @@ def register_klass_file(klass) #:nodoc: module ClassMethods def attr_reader(*) #:nodoc: - no_tasks { super } + no_commands { super } end def attr_writer(*) #:nodoc: - no_tasks { super } + no_commands { super } end def attr_accessor(*) #:nodoc: - no_tasks { super } + no_commands { super } end # If you want to raise an error for unknown options, call check_unknown_options! @@ -144,10 +177,34 @@ def check_unknown_options?(config) #:nodoc: !!check_unknown_options end + # If you want to raise an error when the default value of an option does not match + # the type call check_default_type! + # This will be the default; for compatibility a deprecation warning is issued if necessary. + def check_default_type! + @check_default_type = true + end + + # If you want to use defaults that don't match the type of an option, + # either specify `check_default_type: false` or call `allow_incompatible_default_type!` + def allow_incompatible_default_type! + @check_default_type = false + end + + def check_default_type #:nodoc: + @check_default_type = from_superclass(:check_default_type, nil) unless defined?(@check_default_type) + @check_default_type + end + # If true, option parsing is suspended as soon as an unknown option or a # regular argument is encountered. All remaining arguments are passed to - # the task as regular arguments. - def stop_on_unknown_option?(task_name) #:nodoc: + # the command as regular arguments. + def stop_on_unknown_option?(command_name) #:nodoc: + false + end + + # If true, option set will not suspend the execution of the command when + # a required option is not provided. + def disable_required_check?(command_name) #:nodoc: false end @@ -172,11 +229,11 @@ def strict_args_position?(config) #:nodoc: # is how they are parsed from the command line, arguments are retrieved # from position: # - # thor task NAME + # thor command NAME # # Instead of: # - # thor task --name=NAME + # thor command --name=NAME # # Besides, arguments are used inside your code as an accessor (self.argument), # while options are all kept in a hash (self.options). @@ -201,9 +258,9 @@ def strict_args_position?(config) #:nodoc: # ==== Errors # ArgumentError:: Raised if you supply a required argument after a non required one. # - def argument(name, options={}) + def argument(name, options = {}) is_thor_reserved_word?(name, :argument) - no_tasks { attr_accessor name } + no_commands { attr_accessor name } required = if options.key?(:optional) !options[:optional] @@ -215,11 +272,13 @@ def argument(name, options={}) remove_argument name - arguments.each do |argument| - next if argument.required? - raise ArgumentError, "You cannot have #{name.to_s.inspect} as required argument after " << - "the non-required argument #{argument.human_name.inspect}." - end if required + if required + arguments.each do |argument| + next if argument.required? + raise ArgumentError, "You cannot have #{name.to_s.inspect} as required argument after " \ + "the non-required argument #{argument.human_name.inspect}." + end + end options[:required] = required @@ -244,7 +303,7 @@ def arguments # ==== Parameters # Hash[Symbol => Object] # - def class_options(options=nil) + def class_options(options = nil) @class_options ||= from_superclass(:class_options, {}) build_options(options, @class_options) if options @class_options @@ -266,10 +325,93 @@ def class_options(options=nil) # :banner:: -- String to show on usage notes. # :hide:: -- If you want to hide this option from the help. # - def class_option(name, options={}) + 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. # @@ -307,87 +449,96 @@ def remove_class_option(*names) end # Defines the group. This is used when thor list is invoked so you can specify - # that only tasks from a pre-defined group will be shown. Defaults to standard. + # that only commands from a pre-defined group will be shown. Defaults to standard. # # ==== Parameters # name # - def group(name=nil) - @group = case name - when nil - @group || from_superclass(:group, 'standard') + def group(name = nil) + if name + @group = name.to_s else - name.to_s + @group ||= from_superclass(:group, "standard") end end - # Returns the tasks for this Thor class. + # Returns the commands for this Thor class. # # ==== Returns - # OrderedHash:: An ordered hash with tasks names as keys and Thor::Task - # objects as values. + # Hash:: An ordered hash with commands names as keys and Thor::Command + # objects as values. # - def tasks - @tasks ||= Thor::CoreExt::OrderedHash.new + def commands + @commands ||= Hash.new end + alias_method :tasks, :commands - # Returns the tasks for this Thor class and all subclasses. + # Returns the commands for this Thor class and all subclasses. # # ==== Returns - # OrderedHash:: An ordered hash with tasks names as keys and Thor::Task - # objects as values. + # Hash:: An ordered hash with commands names as keys and Thor::Command + # objects as values. # - def all_tasks - @all_tasks ||= from_superclass(:all_tasks, Thor::CoreExt::OrderedHash.new) - @all_tasks.merge(tasks) + def all_commands + @all_commands ||= from_superclass(:all_commands, Hash.new) + @all_commands.merge!(commands) end + alias_method :all_tasks, :all_commands - # Removes a given task from this Thor class. This is usually done if you + # Removes a given command from this Thor class. This is usually done if you # are inheriting from another class and don't want it to be available # anymore. # - # By default it only remove the mapping to the task. But you can supply + # By default it only remove the mapping to the command. But you can supply # :undefine => true to undefine the method from the class as well. # # ==== Parameters - # name:: The name of the task to be removed - # options:: You can give :undefine => true if you want tasks the method + # name:: The name of the command to be removed + # options:: You can give :undefine => true if you want commands the method # to be undefined from the class as well. # - def remove_task(*names) + def remove_command(*names) options = names.last.is_a?(Hash) ? names.pop : {} names.each do |name| - tasks.delete(name.to_s) - all_tasks.delete(name.to_s) + commands.delete(name.to_s) + all_commands.delete(name.to_s) undef_method name if options[:undefine] end end + alias_method :remove_task, :remove_command - # All methods defined inside the given block are not added as tasks. + # All methods defined inside the given block are not added as commands. # # So you can do: # # class MyScript < Thor - # no_tasks do - # def this_is_not_a_task + # no_commands do + # def this_is_not_a_command # end # end # end # - # You can also add the method and remove it from the task list: + # You can also add the method and remove it from the command list: # # class MyScript < Thor - # def this_is_not_a_task + # def this_is_not_a_command # end - # remove_task :this_is_not_a_task + # remove_command :this_is_not_a_command # end # - def no_tasks - @no_tasks = true - yield - ensure - @no_tasks = false + def no_commands(&block) + no_commands_context.enter(&block) + end + + alias_method :no_tasks, :no_commands + + def no_commands_context + @no_commands_context ||= NestedContext.new + end + + def no_commands? + no_commands_context.entered? end # Sets the namespace for the Thor or Thor::Group class. By default the @@ -400,7 +551,7 @@ def no_tasks # # namespace :my_scripts # - # You change how your tasks are invoked: + # You change how your commands are invoked: # # thor my_scripts -h # @@ -408,247 +559,267 @@ def no_tasks # # namespace :default # - # Your tasks can be invoked with a shortcut. Instead of: + # Your commands can be invoked with a shortcut. Instead of: # - # thor :my_task + # thor :my_command # - def namespace(name=nil) - @namespace = case name - when nil - @namespace || Thor::Util.namespace_from_thor_class(self) - else + def namespace(name = nil) + if name @namespace = name.to_s + else + @namespace ||= Thor::Util.namespace_from_thor_class(self) end end - # Parses the task and options from the given args, instantiate the class - # and invoke the task. This method is used when the arguments must be parsed + # Parses the command and options from the given args, instantiate the class + # and invoke the command. This method is used when the arguments must be parsed # from an array. If you are inside Ruby and want to use a Thor class, you # can simply initialize it: # # script = MyScript.new(args, options, config) - # script.invoke(:task, first_arg, second_arg, third_arg) + # script.invoke(:command, first_arg, second_arg, third_arg) # - def start(given_args=ARGV, config={}) + def start(given_args = ARGV, config = {}) config[:shell] ||= Thor::Base.shell.new dispatch(nil, given_args.dup, nil, config) rescue Thor::Error => e - ENV["THOR_DEBUG"] == "1" ? (raise e) : config[:shell].error(e.message) - exit(1) if exit_on_failure? + config[:debug] || ENV["THOR_DEBUG"] == "1" ? (raise e) : config[:shell].error(e.message) + exit(false) if exit_on_failure? rescue Errno::EPIPE - # This happens if a thor task is piped to something like `head`, + # This happens if a thor command is piped to something like `head`, # which closes the pipe when it's done reading. This will also # mean that if the pipe is closed, further unnecessary # computation will not occur. - exit(0) + exit(true) end - # Allows to use private methods from parent in child classes as tasks. + # Allows to use private methods from parent in child classes as commands. # # ==== Parameters - # names:: Method names to be used as tasks + # names:: Method names to be used as commands # # ==== Examples # - # public_task :foo - # public_task :foo, :bar, :baz + # public_command :foo + # public_command :foo, :bar, :baz # - def public_task(*names) + 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 - def handle_no_task_error(task, has_namespace = $thor_runner) #:nodoc: - if has_namespace - raise UndefinedTaskError, "Could not find task #{task.inspect} in #{namespace.inspect} namespace." - else - raise UndefinedTaskError, "Could not find task #{task.inspect}." - end + def handle_no_command_error(command, has_namespace = $thor_runner) #:nodoc: + raise UndefinedCommandError.new(command, all_commands.keys, (namespace if has_namespace)) end - - def handle_argument_error(task, error, arity=nil) #:nodoc: - msg = "#{basename} #{task.name}" - if arity - required = arity < 0 ? (-1 - arity) : arity - if required == 0 - msg << " should have no arguments" - else - msg << " requires at least #{required} argument" - msg << "s" if required > 1 - end - else - msg = "call #{msg} as" - end - - msg << ": #{self.banner(task).inspect}." + alias_method :handle_no_task_error, :handle_no_command_error + + def handle_argument_error(command, error, args, arity) #:nodoc: + name = [command.ancestor_name, command.name].compact.join(" ") + msg = "ERROR: \"#{basename} #{name}\" was called with ".dup + msg << "no arguments" if args.empty? + msg << "arguments " << args.inspect unless args.empty? + msg << "\nUsage: \"#{banner(command).split("\n").join("\"\n \"")}\"" raise InvocationError, msg end - protected - - # Prints the class options per group. If an option does not belong to - # any group, it's printed as Class option. - # - def class_options_help(shell, groups={}) #:nodoc: - # Group options by group - class_options.each do |_, value| - groups[value.group] ||= [] - groups[value.group] << value - end + # A flag that makes the process exit with status 1 if any error happens. + def exit_on_failure? + Thor.deprecation_warning "Thor exit with status 0 on errors. To keep this behavior, you must define `exit_on_failure?` in `#{self.name}`" + false + end - # Deal with default group - global_options = groups.delete(nil) || [] - print_options(shell, global_options) + protected - # Print all others - groups.each do |group_name, options| - print_options(shell, options, group_name) - end + # Prints the class options per group. If an option does not belong to + # any group, it's printed as Class option. + # + def class_options_help(shell, groups = {}) #:nodoc: + # Group options by group + class_options.each do |_, value| + groups[value.group] ||= [] + groups[value.group] << value end - # Receives a set of options and print them. - def print_options(shell, options, group_name=nil) - return if options.empty? + # Deal with default group + global_options = groups.delete(nil) || [] + print_options(shell, global_options) - list = [] - padding = options.collect{ |o| o.aliases.size }.max.to_i * 4 + # Print all others + groups.each do |group_name, options| + print_options(shell, options, group_name) + end + end - options.each do |option| - unless option.hide - item = [ option.usage(padding) ] - item.push(option.description ? "# #{option.description}" : "") + # Receives a set of options and print them. + def print_options(shell, options, group_name = nil) + return if options.empty? - list << item - list << [ "", "# Default: #{option.default}" ] if option.show_default? - list << [ "", "# Possible values: #{option.enum.join(', ')}" ] if option.enum - end - end + list = [] + 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}" : "") - shell.say(group_name ? "#{group_name} options:" : "Options:") - shell.print_table(list, :indent => 2) - shell.say "" + list << item + list << ["", "# Default: #{option.print_default}"] if option.show_default? + list << ["", "# Possible values: #{option.enum_to_s}"] if option.enum end - # Raises an error if the word given is a Thor reserved word. - def is_thor_reserved_word?(word, type) #:nodoc: - return false unless THOR_RESERVED_WORDS.include?(word.to_s) - raise "#{word.inspect} is a Thor reserved word and cannot be defined as #{type}" - end + shell.say(group_name ? "#{group_name} options:" : "Options:") + shell.print_table(list, indent: 2) + shell.say "" + end - # Build an option and adds it to the given scope. - # - # ==== Parameters - # name:: The name of the argument. - # 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, options) - end + # Raises an error if the word given is a Thor reserved word. + def is_thor_reserved_word?(word, type) #:nodoc: + return false unless THOR_RESERVED_WORDS.include?(word.to_s) + raise "#{word.inspect} is a Thor reserved word and cannot be defined as #{type}" + end - # Receives a hash of options, parse them and add to the scope. This is a - # fast way to set a bunch of options: - # - # build_options :foo => true, :bar => :required, :baz => :string - # - # ==== Parameters - # Hash[Symbol => Object] - def build_options(options, scope) #:nodoc: - options.each do |key, value| - scope[key] = Thor::Option.parse(key, value) - end - end + # Build an option and adds it to the given scope. + # + # ==== Parameters + # name:: The name of the argument. + # 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)) + end - # Finds a task with the given name. If the task belongs to the current - # class, just return it, otherwise dup it and add the fresh copy to the - # current task hash. - def find_and_refresh_task(name) #:nodoc: - task = if task = tasks[name.to_s] - task - elsif task = all_tasks[name.to_s] - tasks[name.to_s] = task.clone - else - raise ArgumentError, "You supplied :for => #{name.inspect}, but the task #{name.inspect} could not be found." - end + # Receives a hash of options, parse them and add to the scope. This is a + # fast way to set a bunch of options: + # + # build_options :foo => true, :bar => :required, :baz => :string + # + # ==== Parameters + # Hash[Symbol => Object] + def build_options(options, scope) #:nodoc: + options.each do |key, value| + scope[key] = Thor::Option.parse(key, value) end + end - # Everytime someone inherits from a Thor class, register the klass - # and file into baseclass. - def inherited(klass) - Thor::Base.register_klass_file(klass) - klass.instance_variable_set(:@no_tasks, false) + # Finds a command with the given name. If the command belongs to the current + # class, just return it, otherwise dup it and add the fresh copy to the + # current command hash. + 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 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." end + end + alias_method :find_and_refresh_task, :find_and_refresh_command + + # Every time someone inherits from a Thor class, register the klass + # and file into baseclass. + def inherited(klass) + super(klass) + Thor::Base.register_klass_file(klass) + klass.instance_variable_set(:@no_commands, 0) + end - # Fire this callback whenever a method is added. Added methods are - # tracked as tasks by invoking the create_task method. - def method_added(meth) - meth = meth.to_s + # Fire this callback whenever a method is added. Added methods are + # tracked as commands by invoking the create_command method. + def method_added(meth) + super(meth) + meth = meth.to_s - if meth == "initialize" - initialize_added - return - end + if meth == "initialize" + initialize_added + return + end - # Return if it's not a public instance method - return unless public_instance_methods.include?(meth) || - public_instance_methods.include?(meth.to_sym) + # Return if it's not a public instance method + return unless public_method_defined?(meth.to_sym) - return if @no_tasks || !create_task(meth) + return if no_commands? || !create_command(meth) - is_thor_reserved_word?(meth, :task) - Thor::Base.register_klass_file(self) - end + is_thor_reserved_word?(meth, :command) + Thor::Base.register_klass_file(self) + end - # Retrieves a value from superclass. If it reaches the baseclass, - # returns default. - def from_superclass(method, default=nil) - if self == baseclass || !superclass.respond_to?(method, true) - default - else - value = superclass.send(method) - - if value - if value.is_a?(TrueClass) || value.is_a?(Symbol) - value - else - value.dup - end - end + # Retrieves a value from superclass. If it reaches the baseclass, + # returns default. + def from_superclass(method, default = nil) + if self == baseclass || !superclass.respond_to?(method, true) + default + else + value = superclass.send(method) + + # Ruby implements `dup` on Object, but raises a `TypeError` + # if the method is called on immediates. As a result, we + # don't have a good way to check whether dup will succeed + # without calling it and rescuing the TypeError. + begin + value.dup + rescue TypeError + value end - end - # A flag that makes the process exit with status 1 if any error happens. - def exit_on_failure? - false end + end - # - # The basename of the program invoking the thor class. - # - def basename - File.basename($0).split(' ').first - end + # + # The basename of the program invoking the thor class. + # + def basename + File.basename($PROGRAM_NAME).split(" ").first + end - # SIGNATURE: Sets the baseclass. This is where the superclass lookup - # finishes. - def baseclass #:nodoc: - end + # SIGNATURE: Sets the baseclass. This is where the superclass lookup + # finishes. + def baseclass #:nodoc: + end - # SIGNATURE: Creates a new task if valid_task? is true. This method is - # called when a new method is added to the class. - def create_task(meth) #:nodoc: - end + # SIGNATURE: Creates a new command if valid_command? is true. This method is + # called when a new method is added to the class. + def create_command(meth) #:nodoc: + end + alias_method :create_task, :create_command - # SIGNATURE: Defines behavior when the initialize method is added to the - # class. - def initialize_added #:nodoc: - end + # SIGNATURE: Defines behavior when the initialize method is added to the + # class. + def initialize_added #:nodoc: + end - # SIGNATURE: The hook invoked by start. - def dispatch(task, given_args, given_opts, config) #:nodoc: - raise NotImplementedError - end + # SIGNATURE: The hook invoked by start. + 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 new file mode 100644 index 000000000..c6bdf74bb --- /dev/null +++ b/lib/thor/command.rb @@ -0,0 +1,151 @@ +class Thor + 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, 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? + false + end + + # By default, a command invokes a method in the thor class. You can change this + # implementation to create custom commands. + def run(instance, args = []) + arity = nil + + if private_method?(instance) + instance.class.handle_no_command_error(name) + elsif public_method?(instance) + arity = instance.method(name).arity + instance.__send__(name, *args) + elsif local_method?(instance, :method_missing) + instance.__send__(:method_missing, name.to_sym, *args) + else + instance.class.handle_no_command_error(name) + end + rescue ArgumentError => e + handle_argument_error?(instance, e, caller) ? instance.class.handle_argument_error(self, e, args, arity) : (raise e) + rescue NoMethodError => e + handle_no_method_error?(instance, e, caller) ? instance.class.handle_no_command_error(name) : (raise e) + end + + # Returns the formatted usage by injecting given required arguments + # and required options into the given usage. + def formatted_usage(klass, namespace = true, subcommand = false) + if ancestor_name + formatted = "#{ancestor_name} ".dup # add space + elsif namespace + namespace = klass.namespace + formatted = "#{namespace.gsub(/^(default)/, '')}:".dup + end + formatted ||= "#{klass.namespace.split(':').last} ".dup if subcommand + + formatted ||= "".dup + + Array(usage).map do |specific_usage| + formatted_specific_usage = formatted + + formatted_specific_usage += required_arguments_for(klass, specific_usage) + + # Add required options + formatted_specific_usage += " #{required_options}" + + # Strip and go! + formatted_specific_usage.strip + 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 + def required_arguments_for(klass, usage) + if klass && !klass.arguments.empty? + usage.to_s.gsub(/^#{name}/) do |match| + match << " " << klass.arguments.map(&:usage).compact.join(" ") + end + else + usage.to_s + end + end + + def not_debugging?(instance) + !(instance.class.respond_to?(:debugging) && instance.class.debugging) + end + + def required_options + @required_options ||= options.map { |_, o| o.usage if o.required? }.compact.sort.join(" ") + end + + # Given a target, checks if this class name is a public method. + def public_method?(instance) #:nodoc: + !(instance.public_methods & [name.to_s, name.to_sym]).empty? + end + + def private_method?(instance) + !(instance.private_methods & [name.to_s, name.to_sym]).empty? + end + + def local_method?(instance, name) + methods = instance.public_methods(false) + instance.private_methods(false) + instance.protected_methods(false) + !(methods & [name.to_s, name.to_sym]).empty? + end + + def sans_backtrace(backtrace, caller) #:nodoc: + saned = backtrace.reject { |frame| frame =~ FILE_REGEXP || (frame =~ /\.java:/ && RUBY_PLATFORM =~ /java/) || (frame =~ %r{^kernel/} && RUBY_ENGINE =~ /rbx/) } + saned - caller + end + + def handle_argument_error?(instance, error, caller) + not_debugging?(instance) && (error.message =~ /wrong number of arguments/ || error.message =~ /given \d*, expected \d*/) && begin + saned = sans_backtrace(error.backtrace, caller) + saned.empty? || saned.size == 1 + end + end + + def handle_no_method_error?(instance, error, caller) + not_debugging?(instance) && + error.message =~ /^undefined method `#{name}' for #{Regexp.escape(instance.to_s)}$/ + end + end + Task = Command + + # A command that is hidden in help messages but still invocable. + class HiddenCommand < Command + def hidden? + true + end + end + HiddenTask = HiddenCommand + + # 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, nil, name.to_s, options) + end + + def run(instance, args = []) + if (instance.methods & [name.to_s, name.to_sym]).empty? + super + else + instance.class.handle_no_command_error(name) + end + end + end + DynamicTask = DynamicCommand +end diff --git a/lib/thor/core_ext/file_binary_read.rb b/lib/thor/core_ext/file_binary_read.rb deleted file mode 100644 index d6af7e44b..000000000 --- a/lib/thor/core_ext/file_binary_read.rb +++ /dev/null @@ -1,9 +0,0 @@ -class File #:nodoc: - - unless File.respond_to?(:binread) - def self.binread(file) - File.open(file, 'rb') { |f| f.read } - end - end - -end diff --git a/lib/thor/core_ext/hash_with_indifferent_access.rb b/lib/thor/core_ext/hash_with_indifferent_access.rb index 0a583e679..c937d289c 100644 --- a/lib/thor/core_ext/hash_with_indifferent_access.rb +++ b/lib/thor/core_ext/hash_with_indifferent_access.rb @@ -1,6 +1,5 @@ class Thor module CoreExt #:nodoc: - # A hash with indifferent access and magic predicates. # # hash = Thor::CoreExt::HashWithIndifferentAccess.new 'foo' => 'bar', 'baz' => 'bee', 'force' => true @@ -10,8 +9,7 @@ module CoreExt #:nodoc: # hash.foo? #=> true # class HashWithIndifferentAccess < ::Hash #:nodoc: - - def initialize(hash={}) + def initialize(hash = {}) super() hash.each do |key, value| self[convert_key(key)] = value @@ -30,8 +28,26 @@ def delete(key) super(convert_key(key)) end + def except(*keys) + dup.tap do |hash| + keys.each { |key| hash.delete(convert_key(key)) } + end + end + + 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 + def values_at(*indices) - indices.collect { |key| self[convert_key(key)] } + indices.map { |key| self[convert_key(key)] } end def merge(other) @@ -45,36 +61,47 @@ def merge!(other) self end + def reverse_merge(other) + self.class.new(other).merge(self) + end + + def reverse_merge!(other_hash) + replace(reverse_merge(other_hash)) + end + + def replace(other_hash) + super(other_hash) + end + # Convert to a Hash with String keys. def to_hash Hash.new(default).merge!(self) end - protected + protected - def convert_key(key) - key.is_a?(Symbol) ? key.to_s : key - end + def convert_key(key) + key.is_a?(Symbol) ? key.to_s : key + end - # Magic predicates. For instance: - # - # options.force? # => !!options['force'] - # options.shebang # => "/usr/lib/local/ruby" - # options.test_framework?(:rspec) # => options[:test_framework] == :rspec - # - def method_missing(method, *args, &block) - method = method.to_s - if method =~ /^(\w+)\?$/ - if args.empty? - !!self[$1] - else - self[$1] == args.first - end + # Magic predicates. For instance: + # + # options.force? # => !!options['force'] + # options.shebang # => "/usr/lib/local/ruby" + # options.test_framework?(:rspec) # => options[:test_framework] == :rspec + # + def method_missing(method, *args) + method = method.to_s + if method =~ /^(\w+)\?$/ + if args.empty? + !!self[$1] else - self[method] + self[$1] == args.first end + else + self[method] end - + end end end end diff --git a/lib/thor/core_ext/ordered_hash.rb b/lib/thor/core_ext/ordered_hash.rb deleted file mode 100644 index 27fea5bb3..000000000 --- a/lib/thor/core_ext/ordered_hash.rb +++ /dev/null @@ -1,100 +0,0 @@ -class Thor - module CoreExt #:nodoc: - - if RUBY_VERSION >= '1.9' - class OrderedHash < ::Hash - end - else - # This class is based on the Ruby 1.9 ordered hashes. - # - # It keeps the semantics and most of the efficiency of normal hashes - # while also keeping track of the order in which elements were set. - # - class OrderedHash #:nodoc: - include Enumerable - - Node = Struct.new(:key, :value, :next, :prev) - - def initialize - @hash = {} - end - - def [](key) - @hash[key] && @hash[key].value - end - - def []=(key, value) - if node = @hash[key] - node.value = value - else - node = Node.new(key, value) - - if @first.nil? - @first = @last = node - else - node.prev = @last - @last.next = node - @last = node - end - end - - @hash[key] = node - value - end - - def delete(key) - if node = @hash[key] - prev_node = node.prev - next_node = node.next - - next_node.prev = prev_node if next_node - prev_node.next = next_node if prev_node - - @first = next_node if @first == node - @last = prev_node if @last == node - - value = node.value - end - - @hash.delete(key) - value - end - - def keys - self.map { |k, v| k } - end - - def values - self.map { |k, v| v } - end - - def each - return unless @first - yield [@first.key, @first.value] - node = @first - yield [node.key, node.value] while node = node.next - self - end - - def merge(other) - hash = self.class.new - - self.each do |key, value| - hash[key] = value - end - - other.each do |key, value| - hash[key] = value - end - - hash - end - - def empty? - @hash.empty? - end - end - end - - end -end diff --git a/lib/thor/error.rb b/lib/thor/error.rb index 532db462b..11fa250c2 100644 --- a/lib/thor/error.rb +++ b/lib/thor/error.rb @@ -1,25 +1,95 @@ class Thor + Correctable = if defined?(DidYouMean::SpellChecker) && defined?(DidYouMean::Correctable) # rubocop:disable Naming/ConstantName + 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. # # Errors that are caused by the developer, like declaring a method which - # overwrites a thor keyword, it SHOULD NOT raise a Thor::Error. This way, we + # overwrites a thor keyword, SHOULD NOT raise a Thor::Error. This way, we # ensure that developer errors are shown with full backtrace. - # class Error < StandardError end - # Raised when a task was not found. - # - class UndefinedTaskError < Error + # Raised when a command was not found. + class UndefinedCommandError < Error + class SpellChecker + attr_reader :error + + def initialize(error) + @error = error + end + + def corrections + @corrections ||= spell_checker.correct(error.command).map(&:inspect) + end + + def spell_checker + DidYouMean::SpellChecker.new(dictionary: error.all_commands) + end + end + + attr_reader :command, :all_commands + + def initialize(command, all_commands, namespace) + @command = command + @all_commands = all_commands + + message = "Could not find command #{command.inspect}" + message = namespace ? "#{message} in #{namespace.inspect} namespace." : "#{message}." + + super(message) + end + + prepend Correctable if Correctable end + UndefinedTaskError = UndefinedCommandError - # Raised when a task was found, but not invoked properly. - # + class AmbiguousCommandError < Error + end + AmbiguousTaskError = AmbiguousCommandError + + # Raised when a command was found, but not invoked properly. class InvocationError < Error end class UnknownArgumentError < Error + class SpellChecker + attr_reader :error + + def initialize(error) + @error = error + end + + def corrections + @corrections ||= + error.unknown.flat_map { |unknown| spell_checker.correct(unknown) }.uniq.map(&:inspect) + end + + def spell_checker + @spell_checker ||= DidYouMean::SpellChecker.new(dictionary: error.switches) + end + end + + attr_reader :switches, :unknown + + def initialize(switches, unknown) + @switches = switches + @unknown = unknown + + super("Unknown switches #{unknown.map(&:inspect).join(', ')}") + end + + prepend Correctable if Correctable end class RequiredArgumentMissingError < InvocationError @@ -28,8 +98,9 @@ class RequiredArgumentMissingError < InvocationError class MalformattedArgumentError < InvocationError end - # Raised when a user tries to call a private method encoded in templated filename. - # - class PrivateMethodEncodedError < Error + class ExclusiveArgumentError < InvocationError + end + + class AtLeastOneRequiredArgumentError < InvocationError end end diff --git a/lib/thor/group.rb b/lib/thor/group.rb index 11d79d1cd..48df46b99 100644 --- a/lib/thor/group.rb +++ b/lib/thor/group.rb @@ -1,9 +1,9 @@ -require 'thor/base' +require_relative "base" # Thor has a special class called Thor::Group. The main difference to Thor class -# is that it invokes all tasks at once. It also include some methods that allows +# is that it invokes all commands at once. It also include some methods that allows # invocations to be done at the class method, which are not available to Thor -# tasks. +# commands. class Thor::Group class << self # The description for this Thor::Group. If none is provided, but a source root @@ -13,12 +13,11 @@ class << self # ==== Parameters # description:: The description for this Thor::Group. # - def desc(description=nil) - @desc = case description - when nil - @desc || from_superclass(:desc, nil) + def desc(description = nil) + if description + @desc = description else - description + @desc ||= from_superclass(:desc, nil) end end @@ -32,7 +31,7 @@ def help(shell) shell.say " #{banner}\n" shell.say class_options_help(shell) - shell.say self.desc if self.desc + shell.say desc if desc end # Stores invocations for this class merging with superclass values. @@ -48,7 +47,7 @@ def invocation_blocks #:nodoc: end # Invoke the given namespace or class given. It adds an instance - # method that will invoke the klass and task. You can give a block to + # method that will invoke the klass and command. You can give a block to # configure how it will be invoked. # # The namespace/class given will have its options showed on the help @@ -62,14 +61,14 @@ def invoke(*names, &block) invocations[name] = false invocation_blocks[name] = block if block_given? - class_eval <<-METHOD, __FILE__, __LINE__ + class_eval <<-METHOD, __FILE__, __LINE__ + 1 def _invoke_#{name.to_s.gsub(/\W/, '_')} - klass, task = self.class.prepare_for_invocation(nil, #{name.inspect}) + klass, command = self.class.prepare_for_invocation(nil, #{name.inspect}) if klass say_status :invoke, #{name.inspect}, #{verbose.inspect} block = self.class.invocation_blocks[#{name.inspect}] - _invoke_for_class_method klass, task, &block + _invoke_for_class_method klass, command, &block else say_status :error, %(#{name.inspect} [not found]), :red end @@ -100,7 +99,7 @@ def _invoke_#{name.to_s.gsub(/\W/, '_')} # In some cases you want to customize how a specified hook is going to be # invoked. You can do that by overwriting the class method # prepare_for_invocation. The class method must necessarily return a klass - # and an optional task. + # and an optional command. # # ==== Custom invocations # @@ -114,25 +113,25 @@ def invoke_from_option(*names, &block) names.each do |name| unless class_options.key?(name) - raise ArgumentError, "You have to define the option #{name.inspect} " << - "before setting invoke_from_option." + raise ArgumentError, "You have to define the option #{name.inspect} " \ + "before setting invoke_from_option." end invocations[name] = true invocation_blocks[name] = block if block_given? - class_eval <<-METHOD, __FILE__, __LINE__ + class_eval <<-METHOD, __FILE__, __LINE__ + 1 def _invoke_from_option_#{name.to_s.gsub(/\W/, '_')} return unless options[#{name.inspect}] value = options[#{name.inspect}] value = #{name.inspect} if TrueClass === value - klass, task = self.class.prepare_for_invocation(#{name.inspect}, value) + klass, command = self.class.prepare_for_invocation(#{name.inspect}, value) if klass say_status :invoke, value, #{verbose.inspect} block = self.class.invocation_blocks[#{name.inspect}] - _invoke_for_class_method klass, task, &block + _invoke_for_class_method klass, command, &block else say_status :error, %(\#{value} [not found]), :red end @@ -149,7 +148,7 @@ def _invoke_from_option_#{name.to_s.gsub(/\W/, '_')} # def remove_invocation(*names) names.each do |name| - remove_task(name) + remove_command(name) remove_class_option(name) invocations.delete(name) invocation_blocks.delete(name) @@ -159,7 +158,7 @@ def remove_invocation(*names) # Overwrite class options help to allow invoked generators options to be # shown recursively when invoking a generator. # - def class_options_help(shell, groups={}) #:nodoc: + def class_options_help(shell, groups = {}) #:nodoc: get_options_from_invocations(groups, class_options) do |klass| klass.send(:get_options_from_invocations, groups, class_options) end @@ -189,96 +188,104 @@ def get_options_from_invocations(group_options, base_options) #:nodoc: group_options[human_name] ||= [] group_options[human_name] += klass.class_options.values.select do |class_option| base_options[class_option.name.to_sym].nil? && class_option.group.nil? && - !group_options.values.flatten.any? { |i| i.name == class_option.name } + !group_options.values.flatten.any? { |i| i.name == class_option.name } end yield klass if block_given? end end - # Returns tasks ready to be printed. - def printable_tasks(*) + # Returns commands ready to be printed. + def printable_commands(*) item = [] item << banner - item << (desc ? "# #{desc.gsub(/\s+/m,' ')}" : "") + item << (desc ? "# #{desc.gsub(/\s+/m, ' ')}" : "") [item] end + alias_method :printable_tasks, :printable_commands - def handle_argument_error(task, error, arity=nil) #:nodoc: - if arity > 0 - msg = "#{basename} #{task.name} takes #{arity} argument" - msg << "s" if arity > 1 - msg << ", but it should not." - else - msg = "You should not pass arguments to #{basename} #{task.name}." - end - + def handle_argument_error(command, error, _args, arity) #:nodoc: + msg = "#{basename} #{command.name} takes #{arity} argument".dup + msg << "s" if arity > 1 + msg << ", but it should not." raise error, msg end - protected + # Checks if a specified command exists. + # + # ==== Parameters + # command_name:: The name of the command to check for existence. + # + # ==== Returns + # Boolean:: +true+ if the command exists, +false+ otherwise. + def command_exists?(command_name) #:nodoc: + commands.keys.include?(command_name) + end - # The method responsible for dispatching given the args. - def dispatch(task, given_args, given_opts, config) #:nodoc: - if Thor::HELP_MAPPINGS.include?(given_args.first) - help(config[:shell]) - return - end + protected - args, opts = Thor::Options.split(given_args) - opts = given_opts || opts + # The method responsible for dispatching given the args. + def dispatch(command, given_args, given_opts, config) #:nodoc: + if Thor::HELP_MAPPINGS.include?(given_args.first) + help(config[:shell]) + return + end - instance = new(args, opts, config) - yield instance if block_given? - args = instance.args + args, opts = Thor::Options.split(given_args) + opts = given_opts || opts - if task - instance.invoke_task(all_tasks[task]) - else - instance.invoke_all - end - end + instance = new(args, opts, config) + yield instance if block_given? - # The banner for this class. You can customize it if you are invoking the - # thor class by another ways which is not the Thor::Runner. - def banner - "#{basename} #{self_task.formatted_usage(self, false)}" + if command + instance.invoke_command(all_commands[command]) + else + instance.invoke_all end + end - # Represents the whole class as a task. - def self_task #:nodoc: - Thor::DynamicTask.new(self.namespace, class_options) - end + # The banner for this class. You can customize it if you are invoking the + # thor class by another ways which is not the Thor::Runner. + def banner + "#{basename} #{self_command.formatted_usage(self, false)}" + end - def baseclass #:nodoc: - Thor::Group - end + # Represents the whole class as a command. + def self_command #:nodoc: + Thor::DynamicCommand.new(namespace, class_options) + end + alias_method :self_task, :self_command - def create_task(meth) #:nodoc: - tasks[meth.to_s] = Thor::Task.new(meth, nil, nil, nil, nil) - true - end + def baseclass #:nodoc: + Thor::Group + end + + def create_command(meth) #:nodoc: + commands[meth.to_s] = Thor::Command.new(meth, nil, nil, nil, nil) + true + end + alias_method :create_task, :create_command end include Thor::Base - protected +protected # Shortcut to invoke with padding and block handling. Use internally by # invoke and invoke_from_option class methods. - def _invoke_for_class_method(klass, task=nil, *args, &block) #:nodoc: + def _invoke_for_class_method(klass, command = nil, *args, &block) #:nodoc: with_padding do if block case block.arity when 3 - block.call(self, klass, task) + yield(self, klass, command) when 2 - block.call(self, klass) + yield(self, klass) when 1 instance_exec(klass, &block) end else - invoke klass, task, *args + invoke klass, command, *args end end end diff --git a/lib/thor/invocation.rb b/lib/thor/invocation.rb index 71db7c81d..4c0e4fac1 100644 --- a/lib/thor/invocation.rb +++ b/lib/thor/invocation.rb @@ -1,17 +1,18 @@ class Thor module Invocation def self.included(base) #:nodoc: + super(base) base.extend ClassMethods end module ClassMethods # This method is responsible for receiving a name and find the proper - # class and task for it. The key is an optional parameter which is + # class and command for it. The key is an optional parameter which is # available only in class methods invocations (i.e. in Thor::Group). def prepare_for_invocation(key, name) #:nodoc: case name when Symbol, String - Thor::Util.find_class_and_task_by_namespace(name.to_s, !key) + Thor::Util.find_class_and_command_by_namespace(name.to_s, !key) else name end @@ -19,32 +20,37 @@ def prepare_for_invocation(key, name) #:nodoc: end # Make initializer aware of invocations and the initialization args. - def initialize(args=[], options={}, config={}, &block) #:nodoc: - @_invocations = config[:invocations] || Hash.new { |h,k| h[k] = [] } - @_initializer = [ args, options, config ] + def initialize(args = [], options = {}, config = {}, &block) #:nodoc: + @_invocations = config[:invocations] || Hash.new { |h, k| h[k] = [] } + @_initializer = [args, options, config] super end - # Receives a name and invokes it. The name can be a string (either "task" or - # "namespace:task"), a Thor::Task, a Class or a Thor instance. If the task - # cannot be guessed by name, it can also be supplied as second argument. + # Make the current command chain accessible with in a Thor-(sub)command + def current_command_chain + @_invocations.values.flatten.map(&:to_sym) + end + + # Receives a name and invokes it. The name can be a string (either "command" or + # "namespace:command"), a Thor::Command, a Class or a Thor instance. If the + # command cannot be guessed by name, it can also be supplied as second argument. # # You can also supply the arguments, options and configuration values for - # the task to be invoked, if none is given, the same values used to + # the command to be invoked, if none is given, the same values used to # initialize the invoker are used to initialize the invoked. # - # When no name is given, it will invoke the default task of the current class. + # When no name is given, it will invoke the default command of the current class. # # ==== Examples # # class A < Thor # def foo # invoke :bar - # invoke "b:hello", ["José"] + # invoke "b:hello", ["Erik"] # end # # def bar - # invoke "b:hello", ["José"] + # invoke "b:hello", ["Erik"] # end # end # @@ -54,16 +60,16 @@ def initialize(args=[], options={}, config={}, &block) #:nodoc: # end # end # - # You can notice that the method "foo" above invokes two tasks: "bar", + # You can notice that the method "foo" above invokes two commands: "bar", # which belongs to the same class and "hello" which belongs to the class B. # - # By using an invocation system you ensure that a task is invoked only once. + # By using an invocation system you ensure that a command is invoked only once. # In the example above, invoking "foo" will invoke "b:hello" just once, even # if it's invoked later by "bar" method. # # When class A invokes class B, all arguments used on A initialization are # supplied to B. This allows lazy parse of options. Let's suppose you have - # some rspec tasks: + # some rspec commands: # # class Rspec < Thor::Group # class_option :mock_framework, :type => :string, :default => :rr @@ -93,37 +99,39 @@ def initialize(args=[], options={}, config={}, &block) #:nodoc: # # invoke Rspec::RR, [], :style => :foo # - def invoke(name=nil, *args) + def invoke(name = nil, *args) if name.nil? warn "[Thor] Calling invoke() without argument is deprecated. Please use invoke_all instead.\n#{caller.join("\n")}" return invoke_all end - args.unshift(nil) if Array === args.first || NilClass === args.first - task, args, opts, config = args + args.unshift(nil) if args.first.is_a?(Array) || args.first.nil? + command, args, opts, config = args - klass, task = _retrieve_class_and_task(name, task) + klass, command = _retrieve_class_and_command(name, command) + raise "Missing Thor class for invoke #{name}" unless klass raise "Expected Thor class, got #{klass}" unless klass <= Thor::Base args, opts, config = _parse_initialization_options(args, opts, config) - klass.send(:dispatch, task, args, opts, config) do |instance| + klass.send(:dispatch, command, args, opts, config) do |instance| instance.parent_options = options end end - # Invoke the given task if the given args. - def invoke_task(task, *args) #:nodoc: + # Invoke the given command if the given args. + def invoke_command(command, *args) #:nodoc: current = @_invocations[self.class] - unless current.include?(task.name) - current << task.name - task.run(self, *args) + unless current.include?(command.name) + current << command.name + command.run(self, *args) end end + alias_method :invoke_task, :invoke_command - # Invoke all tasks for the current instance. + # Invoke all commands for the current instance. def invoke_all #:nodoc: - self.class.all_tasks.map { |_, task| invoke_task(task) } + self.class.all_commands.map { |_, command| invoke_command(command) } end # Invokes using shell padding. @@ -131,40 +139,40 @@ def invoke_with_padding(*args) with_padding { invoke(*args) } end - protected + protected - # Configuration values that are shared between invocations. - def _shared_configuration #:nodoc: - { :invocations => @_invocations } - end + # Configuration values that are shared between invocations. + def _shared_configuration #:nodoc: + {invocations: @_invocations} + end - # This method simply retrieves the class and task to be invoked. - # If the name is nil or the given name is a task in the current class, - # use the given name and return self as class. Otherwise, call - # prepare_for_invocation in the current class. - def _retrieve_class_and_task(name, sent_task=nil) #:nodoc: - case - when name.nil? - [self.class, nil] - when self.class.all_tasks[name.to_s] - [self.class, name.to_s] - else - klass, task = self.class.prepare_for_invocation(nil, name) - [klass, task || sent_task] - end + # This method simply retrieves the class and command to be invoked. + # If the name is nil or the given name is a command in the current class, + # use the given name and return self as class. Otherwise, call + # prepare_for_invocation in the current class. + def _retrieve_class_and_command(name, sent_command = nil) #:nodoc: + if name.nil? + [self.class, nil] + elsif self.class.all_commands[name.to_s] + [self.class, name.to_s] + else + klass, command = self.class.prepare_for_invocation(nil, name) + [klass, command || sent_command] end + end + alias_method :_retrieve_class_and_task, :_retrieve_class_and_command - # Initialize klass using values stored in the @_initializer. - def _parse_initialization_options(args, opts, config) #:nodoc: - stored_args, stored_opts, stored_config = @_initializer + # Initialize klass using values stored in the @_initializer. + def _parse_initialization_options(args, opts, config) #:nodoc: + stored_args, stored_opts, stored_config = @_initializer - args ||= stored_args.dup - opts ||= stored_opts.dup + args ||= stored_args.dup + opts ||= stored_opts.dup - config ||= {} - config = stored_config.merge(_shared_configuration).merge!(config) + config ||= {} + config = stored_config.merge(_shared_configuration).merge!(config) - [ args, opts, config ] - end + [args, opts, config] + end end end diff --git a/lib/thor/line_editor.rb b/lib/thor/line_editor.rb new file mode 100644 index 000000000..caea3afa2 --- /dev/null +++ b/lib/thor/line_editor.rb @@ -0,0 +1,17 @@ +require_relative "line_editor/basic" +require_relative "line_editor/readline" + +class Thor + module LineEditor + def self.readline(prompt, options = {}) + best_available.new(prompt, options).readline + end + + def self.best_available + [ + Thor::LineEditor::Readline, + Thor::LineEditor::Basic + ].detect(&:available?) + end + end +end diff --git a/lib/thor/line_editor/basic.rb b/lib/thor/line_editor/basic.rb new file mode 100644 index 000000000..32c3d862f --- /dev/null +++ b/lib/thor/line_editor/basic.rb @@ -0,0 +1,37 @@ +class Thor + module LineEditor + class Basic + attr_reader :prompt, :options + + def self.available? + true + end + + def initialize(prompt, options) + @prompt = prompt + @options = options + end + + def readline + $stdout.print(prompt) + get_input + end + + private + + def get_input + if echo? + $stdin.gets + else + # Lazy-load io/console since it is gem-ified as of 2.3 + require "io/console" + $stdin.noecho(&:gets) + end + end + + def echo? + options.fetch(:echo, true) + end + end + end +end diff --git a/lib/thor/line_editor/readline.rb b/lib/thor/line_editor/readline.rb new file mode 100644 index 000000000..2fd925d96 --- /dev/null +++ b/lib/thor/line_editor/readline.rb @@ -0,0 +1,88 @@ +class Thor + module LineEditor + class Readline < Basic + def self.available? + begin + require "readline" + rescue LoadError + end + + Object.const_defined?(:Readline) + end + + def readline + if echo? + ::Readline.completion_append_character = nil + # rb-readline does not allow Readline.completion_proc= to receive nil. + if complete = completion_proc + ::Readline.completion_proc = complete + end + ::Readline.readline(prompt, add_to_history?) + else + super + end + end + + private + + def add_to_history? + options.fetch(:add_to_history, true) + end + + def completion_proc + if use_path_completion? + proc { |text| PathCompletion.new(text).matches } + elsif completion_options.any? + proc do |text| + completion_options.select { |option| option.start_with?(text) } + end + end + end + + def completion_options + options.fetch(:limited_to, []) + end + + def use_path_completion? + options.fetch(:path, false) + end + + class PathCompletion + attr_reader :text + private :text + + def initialize(text) + @text = text + end + + def matches + relative_matches + end + + private + + def relative_matches + absolute_matches.map { |path| path.sub(base_path, "") } + end + + def absolute_matches + Dir[glob_pattern].map do |path| + if File.directory?(path) + "#{path}/" + else + path + end + end + end + + def glob_pattern + "#{base_path}#{text}*" + end + + def base_path + "#{Dir.pwd}/" + end + end + end + end +end diff --git a/lib/thor/nested_context.rb b/lib/thor/nested_context.rb new file mode 100644 index 000000000..5460112bb --- /dev/null +++ b/lib/thor/nested_context.rb @@ -0,0 +1,29 @@ +class Thor + class NestedContext + def initialize + @depth = 0 + end + + def enter + push + + yield + ensure + pop + end + + def entered? + @depth.positive? + end + + private + + def push + @depth += 1 + end + + def pop + @depth -= 1 + end + end +end diff --git a/lib/thor/parser.rb b/lib/thor/parser.rb index 57a3f6e1a..45394732c 100644 --- a/lib/thor/parser.rb +++ b/lib/thor/parser.rb @@ -1,4 +1,4 @@ -require 'thor/parser/argument' -require 'thor/parser/arguments' -require 'thor/parser/option' -require 'thor/parser/options' +require_relative "parser/argument" +require_relative "parser/arguments" +require_relative "parser/option" +require_relative "parser/options" diff --git a/lib/thor/parser/argument.rb b/lib/thor/parser/argument.rb index 39ef9f29b..0cd1f7bfb 100644 --- a/lib/thor/parser/argument.rb +++ b/lib/thor/parser/argument.rb @@ -1,11 +1,11 @@ class Thor class Argument #:nodoc: - VALID_TYPES = [ :numeric, :hash, :array, :string ] + VALID_TYPES = [:numeric, :hash, :array, :string] attr_reader :name, :description, :enum, :required, :type, :default, :banner - alias :human_name :name + alias_method :human_name, :name - def initialize(name, options={}) + def initialize(name, options = {}) class_name = self.class.name.split("::").last type = options[:type] @@ -24,6 +24,14 @@ def initialize(name, options={}) validate! # Trigger specific validations end + def print_default + if @type == :array and @default.is_a?(Array) + @default.map(&:dump).join(" ") + else + @default + end + end + def usage required? ? banner : "[#{banner}]" end @@ -41,34 +49,38 @@ def show_default? end end - protected - - def validate! - if required? && !default.nil? - raise ArgumentError, "An argument cannot be required and have default value." - elsif @enum && !@enum.is_a?(Array) - raise ArgumentError, "An argument cannot have an enum other than an array." - end + def enum_to_s + if enum.respond_to? :join + enum.join(", ") + else + "#{enum.first}..#{enum.last}" end + end - def valid_type?(type) - self.class::VALID_TYPES.include?(type.to_sym) - end + protected - def default_banner - case type - when :boolean - nil - when :string, :default - human_name.upcase - when :numeric - "N" - when :hash - "key:value" - when :array - "one two three" - end - end + 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 enumerable." if @enum && !@enum.is_a?(Enumerable) + end + def valid_type?(type) + self.class::VALID_TYPES.include?(type.to_sym) + end + + def default_banner + case type + when :boolean + nil + when :string, :default + human_name.upcase + when :numeric + "N" + when :hash + "key:value" + when :array + "one two three" + end + end end end diff --git a/lib/thor/parser/arguments.rb b/lib/thor/parser/arguments.rb index f86166d91..c02823559 100644 --- a/lib/thor/parser/arguments.rb +++ b/lib/thor/parser/arguments.rb @@ -1,6 +1,6 @@ class Thor class Arguments #:nodoc: - NUMERIC = /(\d*\.\d+|\d+)/ + NUMERIC = /[-+]?(\d*\.\d+|\d+)/ # Receives an array of args and returns two arrays, one with arguments # and one with switches. @@ -9,11 +9,11 @@ def self.split(args) arguments = [] args.each do |item| - break if item =~ /^-/ + break if item.is_a?(String) && item =~ /^-/ arguments << item end - return arguments, args[Range.new(arguments.size, -1)] + [arguments, args[Range.new(arguments.size, -1)]] end def self.parse(*args) @@ -23,13 +23,14 @@ def self.parse(*args) # Takes an array of Thor::Argument objects. # - def initialize(arguments=[]) - @assigns, @non_assigned_required = {}, [] + def initialize(arguments = []) + @assigns = {} + @non_assigned_required = [] @switches = arguments arguments.each do |argument| - if argument.default != nil - @assigns[argument.human_name] = argument.default + if !argument.default.nil? + @assigns[argument.human_name] = argument.default.dup elsif argument.required? @non_assigned_required << argument end @@ -53,119 +54,142 @@ def remaining @pile end - private + private - def no_or_skip?(arg) - arg =~ /^--(no|skip)-([-\w]+)$/ - $2 - end + def no_or_skip?(arg) + arg =~ /^--(no|skip)-([-\w]+)$/ + $2 + end - def last? - @pile.empty? - end + def last? + @pile.empty? + end - def peek - @pile.first - end + def peek + @pile.first + end - def shift - @pile.shift - end + def shift + @pile.shift + end - def unshift(arg) - unless arg.kind_of?(Array) - @pile.unshift(arg) - else - @pile = arg + @pile - end + def unshift(arg) + if arg.is_a?(Array) + @pile = arg + @pile + else + @pile.unshift(arg) end + end + + def current_is_value? + peek && peek.to_s !~ /^-{1,2}\S+/ + end - def current_is_value? - peek && peek.to_s !~ /^-/ + # Runs through the argument array getting strings that contains ":" and + # mark it as a hash: + # + # [ "name:string", "age:integer" ] + # + # Becomes: + # + # { "name" => "string", "age" => "integer" } + # + def parse_hash(name) + return shift if peek.is_a?(Hash) + hash = {} + + while current_is_value? && peek.include?(":") + key, value = shift.split(":", 2) + raise MalformattedArgumentError, "You can't specify '#{key}' more than once in option '#{name}'; got #{key}:#{hash[key]} and #{key}:#{value}" if hash.include? key + hash[key] = value end + hash + end + + # Runs through the argument array getting all strings until no string is + # found or a switch is found. + # + # ["a", "b", "c"] + # + # And returns it as an array: + # + # ["a", "b", "c"] + # + def parse_array(name) + return shift if peek.is_a?(Array) - # Runs through the argument array getting strings that contains ":" and - # mark it as a hash: - # - # [ "name:string", "age:integer" ] - # - # Becomes: - # - # { "name" => "string", "age" => "integer" } - # - def parse_hash(name) - return shift if peek.is_a?(Hash) - hash = {} - - while current_is_value? && peek.include?(?:) - key, value = shift.split(':',2) - hash[key] = value + array = [] + + 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 - hash + + array << value end + array + end - # Runs through the argument array getting all strings until no string is - # found or a switch is found. - # - # ["a", "b", "c"] - # - # And returns it as an array: - # - # ["a", "b", "c"] - # - def parse_array(name) - return shift if peek.is_a?(Array) - array = [] - - while current_is_value? - array << shift - end - array + # Check if the peek is numeric format and return a Float or Integer. + # Check if the peek is included in enum if enum is provided. + # Otherwise raises an error. + # + def parse_numeric(name) + return shift if peek.is_a?(Numeric) + + unless peek =~ NUMERIC && $& == peek + raise MalformattedArgumentError, "Expected numeric value for '#{name}'; got #{peek.inspect}" end - # Check if the peek is numeric format and return a Float or Integer. - # Otherwise raises an error. - # - def parse_numeric(name) - return shift if peek.is_a?(Numeric) + value = $&.index(".") ? shift.to_f : shift.to_i - unless peek =~ NUMERIC && $& == peek - raise MalformattedArgumentError, "Expected numeric value for '#{name}'; got #{peek.inspect}" - end + validate_enum_value!(name, value, "Expected '%s' to be one of %s; got %s") - $&.index('.') ? shift.to_f : shift.to_i - end + value + end - # Parse string: - # for --string-arg, just return the current value in the pile - # for --no-string-arg, nil - # - def parse_string(name) - if no_or_skip?(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 - value - end + # Parse string: + # for --string-arg, just return the current value in the pile + # for --no-string-arg, nil + # Check if the peek is included in enum if enum is provided. Otherwise raises an error. + # + def parse_string(name) + if no_or_skip?(name) + nil + else + value = shift + + validate_enum_value!(name, value, "Expected '%s' to be one of %s; got %s") + + value end + end - # Raises an error if @non_assigned_required array is not empty. - # - def check_requirement! - unless @non_assigned_required.empty? - names = @non_assigned_required.map do |o| - o.respond_to?(:switch_name) ? o.switch_name : o.human_name - end.join("', '") + # 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) - class_name = self.class.name.split('::').last.downcase - raise RequiredArgumentMissingError, "No value provided for required #{class_name} '#{names}'" - end + 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! + return if @non_assigned_required.empty? + names = @non_assigned_required.map do |o| + o.respond_to?(:switch_name) ? o.switch_name : o.human_name + end.join("', '") + class_name = self.class.name.split("::").last.downcase + raise RequiredArgumentMissingError, "No value provided for required #{class_name} '#{names}'" + end end end diff --git a/lib/thor/parser/option.rb b/lib/thor/parser/option.rb index 478106943..a369d8f11 100644 --- a/lib/thor/parser/option.rb +++ b/lib/thor/parser/option.rb @@ -1,16 +1,18 @@ class Thor class Option < Argument #:nodoc: - attr_reader :aliases, :group, :lazy_default, :hide + attr_reader :aliases, :group, :lazy_default, :hide, :repeatable VALID_TYPES = [:boolean, :numeric, :hash, :array, :string] - def initialize(name, options={}) + def initialize(name, options = {}) + @check_default_type = options[:check_default_type] options[:required] = false unless options.key?(:required) + @repeatable = options.fetch(:repeatable, false) super - @lazy_default = options[:lazy_default] - @group = options[:group].to_s.capitalize if options[:group] - @aliases = Array(options[:aliases]) - @hide = options[:hide] + @lazy_default = options[:lazy_default] + @group = options[:group].to_s.capitalize if options[:group] + @aliases = normalize_aliases(options[:aliases]) + @hide = options[:hide] end # This parse quick options given as method_options. It makes several @@ -44,7 +46,8 @@ def self.parse(key, value) if key.is_a?(Array) name, *aliases = key else - name, aliases = key, [] + name = key + aliases = [] end name = name.to_s @@ -55,7 +58,7 @@ def self.parse(key, value) default = nil if VALID_TYPES.include?(value) value - elsif required = (value == :required) + elsif required = (value == :required) # rubocop:disable Lint/AssignmentInCondition :string end when TrueClass, FalseClass @@ -65,7 +68,8 @@ def self.parse(key, value) when Hash, Array, String value.class.name.downcase.to_sym end - self.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 @@ -76,19 +80,36 @@ def human_name @human_name ||= dasherized? ? undasherize(name) : name end - def usage(padding=0) + def usage(padding = 0) sample = if banner && !banner.to_s.empty? - "#{switch_name}=#{banner}" + "#{switch_name}=#{banner}".dup else switch_name end - sample = "[#{sample}]" unless required? + sample = "[#{sample}]".dup unless required? + + if boolean? && name != "force" && !name.match(/\A(no|skip)[\-_]/) + sample << ", [#{dasherize('no-' + human_name)}], [#{dasherize('skip-' + human_name)}]" + end + aliases_for_usage.ljust(padding) + sample + end + + def aliases_for_usage if aliases.empty? - (" " * padding) << sample + "" else - "#{aliases.join(', ')}, #{sample}" + "#{aliases.join(', ')}, " + end + end + + def show_default? + case default + when TrueClass, FalseClass + true + else + super end end @@ -104,18 +125,54 @@ def #{type}? def validate! raise ArgumentError, "An option cannot be boolean and required." if boolean? && required? + validate_default_type! + end + + def validate_default_type! + default_type = case @default + when nil + return + when TrueClass, FalseClass + required? ? :string : :boolean + when Numeric + :numeric + when Symbol + :string + when Hash, Array, String + @default.class.name.downcase.to_sym + end + + expected_type = (@repeatable && @type != :hash) ? :array : @type + + if default_type != expected_type + err = "Expected #{expected_type} default value for '#{switch_name}'; got #{@default.inspect} (#{default_type})" + + if @check_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" + end + end end def dasherized? - name.index('-') == 0 + name.index("-") == 0 end def undasherize(str) - str.sub(/^-{1,2}/, '') + str.sub(/^-{1,2}/, "") end def dasherize(str) - (str.length > 1 ? "--" : "-") + str.gsub('_', '-') + (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 d7f7106a7..17d0cee5f 100644 --- a/lib/thor/parser/options.rb +++ b/lib/thor/parser/options.rb @@ -5,7 +5,7 @@ class Options < Arguments #:nodoc: EQ_RE = /^(--\w+(?:-\w+)*|-[a-z])=(.*)$/i SHORT_SQ_RE = /^-([a-z]{2,})$/i # Allow either -x -v or -xv style for single char args SHORT_NUM = /^(-[a-z])#{NUMERIC}$/i - OPTS_END = '--'.freeze + OPTS_END = "--".freeze # Receives a hash and makes it switches. def self.to_switches(options) @@ -14,23 +14,26 @@ def self.to_switches(options) when true "--#{key}" when Array - "--#{key} #{value.map{ |v| v.inspect }.join(' ')}" + "--#{key} #{value.map(&:inspect).join(' ')}" when Hash - "--#{key} #{value.map{ |k,v| "#{k}:#{v}" }.join(' ')}" + "--#{key} #{value.map { |k, v| "#{k}:#{v}" }.join(' ')}" when nil, false - "" + nil else "--#{key} #{value.inspect}" end - end.join(" ") + end.compact.join(" ") end # Takes a hash of Thor::Option and a hash with defaults. # # 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) + 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) @@ -40,13 +43,17 @@ def initialize(hash_options={}, defaults={}, stop_on_unknown=false) @non_assigned_required.delete(hash_options[key]) end - @shorts, @switches, @extra = {}, {}, [] + @shorts = {} + @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| - @shorts[short.to_s] ||= option.switch_name + option.aliases.each do |name| + @shorts[name] ||= option.switch_name end end end @@ -62,14 +69,26 @@ def peek if result == OPTS_END shift @parsing_options = false + @stopped_parsing_after_extra_index ||= @extra.size super else result end end - def parse(args) + 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 @@ -80,9 +99,12 @@ def parse(args) if is_switch case shifted when SHORT_SQ_RE - unshift($1.split('').map { |f| "-#{f}" }) + 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 @@ -91,9 +113,12 @@ def parse(args) switch = normalize_switch(switch) option = switch_option(switch) - @assigns[option.human_name] = parse_peek(switch, option) + result = parse_peek(switch, option) + assign_result!(option, result) elsif @stop_on_unknown + @parsing_options = false @extra << shifted + @stopped_parsing_after_extra_index ||= @extra.size @extra << shift while peek break elsif match @@ -107,110 +132,163 @@ def parse(args) end end - check_requirement! + 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 difference 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 + # an unknown option starts with - or -- and has no more --'s afterward. - unknown = @extra.select { |str| str =~ /^--?(?:(?!--).)*$/ } - raise UnknownArgumentError, "Unknown switches '#{unknown.join(', ')}'" unless unknown.empty? - end - - protected - - # Check if the current value in peek is a registered switch. - # - # 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? - case peek - when LONG_RE, SHORT_RE, EQ_RE, SHORT_NUM - [true, switch?($1)] - when SHORT_SQ_RE - [true, $1.split('').any? { |f| switch?("-#{f}") }] - else - [false, false] - end - end + unknown = to_check.select { |str| str =~ /^--?(?:(?!--).)*$/ } + raise UnknownArgumentError.new(@switches.keys, unknown) unless unknown.empty? + end - def current_is_switch_formatted? - case peek - when LONG_RE, SHORT_RE, EQ_RE, SHORT_NUM, SHORT_SQ_RE - true + 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 - false + nil end - end + end.compact + end - def current_is_value? - peek && (!parsing_options? || super) + def assign_result!(option, result) + if option.repeatable && option.type == :hash + (@assigns[option.human_name] ||= {}).merge!(result) + elsif option.repeatable + (@assigns[option.human_name] ||= []) << result + else + @assigns[option.human_name] = result end + end - def switch?(arg) - switch_option(normalize_switch(arg)) + # Check if the current value in peek is a registered switch. + # + # 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)] + when SHORT_SQ_RE + [true, $1.split("").any? { |f| switch?("-#{f}") }] + else + [false, false] end + end - def switch_option(arg) - if match = no_or_skip?(arg) - @switches[arg] || @switches["--#{match}"] - else - @switches[arg] - 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 + else + false end + end - # Check if the given argument is actually a shortcut. - # - def normalize_switch(arg) - (@shorts[arg] || arg).tr('_', '-') - end + def current_is_value? + return true if @is_treated_as_value + peek && (!parsing_options? || super) + end - def parsing_options? - peek - @parsing_options + def switch?(arg) + !switch_option(normalize_switch(arg)).nil? + end + + def switch_option(arg) + if match = no_or_skip?(arg) # rubocop:disable Lint/AssignmentInCondition + @switches[arg] || @switches["--#{match}"] + else + @switches[arg] end + end - # Parse boolean values which can be given as --foo=true, --foo or --no-foo. - # - def parse_boolean(switch) - if current_is_value? - if ["true", "TRUE", "t", "T", true].include?(peek) - shift - true - elsif ["false", "FALSE", "f", "F", false].include?(peek) - shift - false - else - true - end + # Check if the given argument is actually a shortcut. + # + def normalize_switch(arg) + (@shorts[arg] || arg).tr("_", "-") + end + + def parsing_options? + peek + @parsing_options + end + + # Parse boolean values which can be given as --foo=true or --foo for true values, or + # --foo=false, --no-foo or --skip-foo for false values. + # + def parse_boolean(switch) + if current_is_value? + if ["true", "TRUE", "t", "T", true].include?(peek) + shift + true + elsif ["false", "FALSE", "f", "F", false].include?(peek) + shift + false else @switches.key?(switch) || !no_or_skip?(switch) end + else + @switches.key?(switch) || !no_or_skip?(switch) end + end - # Parse the value at the peek analyzing if it requires an input or not. - # - def parse_peek(switch, option) - if parsing_options? && (current_is_switch_formatted? || last?) - if option.boolean? - # No problem for boolean types - elsif no_or_skip?(switch) - return nil # User set value to nil - elsif option.string? && !option.required? - # Return the default if there is one, else the human name - return option.lazy_default || option.default || option.human_name - elsif option.lazy_default - return option.lazy_default - else - raise MalformattedArgumentError, "No value provided for option '#{switch}'" - end + # Parse the value at the peek analyzing if it requires an input or not. + # + def parse_peek(switch, option) + if parsing_options? && (current_is_switch_formatted? || last?) + if option.boolean? + # No problem for boolean types + elsif no_or_skip?(switch) + return nil # User set value to nil + elsif option.string? && !option.required? + # Return the default if there is one, else the human name + return option.lazy_default || option.default || option.human_name + elsif option.lazy_default + return option.lazy_default + else + raise MalformattedArgumentError, "No value provided for option '#{switch}'" end - - @non_assigned_required.delete(option) - send(:"parse_#{option.type}", switch) end + + @non_assigned_required.delete(option) + send(:"parse_#{option.type}", switch) + end end end diff --git a/lib/thor/rake_compat.rb b/lib/thor/rake_compat.rb index fcb3b24d9..f458b2d82 100644 --- a/lib/thor/rake_compat.rb +++ b/lib/thor/rake_compat.rb @@ -1,5 +1,5 @@ -require 'rake' -require 'rake/dsl_definition' +require "rake" +require "rake/dsl_definition" class Thor # Adds a compatibility layer to your Thor classes which allows you to use @@ -12,7 +12,7 @@ class Thor # include Thor::RakeCompat # # RSpec::Core::RakeTask.new(:spec) do |t| - # t.spec_opts = ['--options', "./.rspec"] + # t.spec_opts = ['--options', './.rspec'] # t.spec_files = FileList['spec/**/*_spec.rb'] # end # end @@ -25,26 +25,27 @@ def self.rake_classes end def self.included(base) + super(base) # Hack. Make rakefile point to invoker, so rdoc task is generated properly. rakefile = File.basename(caller[0].match(/(.*):\d+/)[1]) Rake.application.instance_variable_set(:@rakefile, rakefile) - self.rake_classes << base + rake_classes << base end end end # override task on (main), for compatibility with Rake 0.9 -self.instance_eval do +instance_eval do alias rake_namespace namespace def task(*) task = super - if klass = Thor::RakeCompat.rake_classes.last - non_namespaced_name = task.name.split(':').last + if klass = Thor::RakeCompat.rake_classes.last # rubocop:disable Lint/AssignmentInCondition + non_namespaced_name = task.name.split(":").last description = non_namespaced_name - description << task.arg_names.map{ |n| n.to_s.upcase }.join(' ') + description << task.arg_names.map { |n| n.to_s.upcase }.join(" ") description.strip! klass.desc description, Rake.application.last_description || non_namespaced_name @@ -58,7 +59,7 @@ def task(*) end def namespace(name) - if klass = Thor::RakeCompat.rake_classes.last + 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) @@ -69,4 +70,3 @@ def namespace(name) Thor::RakeCompat.rake_classes.pop end end - diff --git a/lib/thor/runner.rb b/lib/thor/runner.rb index e32ae9ca0..270c3371f 100644 --- a/lib/thor/runner.rb +++ b/lib/thor/runner.rb @@ -1,60 +1,78 @@ -require 'thor' -require 'thor/group' -require 'thor/core_ext/file_binary_read' +require_relative "../thor" +require_relative "group" -require 'fileutils' -require 'open-uri' -require 'yaml' -require 'digest/md5' -require 'pathname' +require "digest/sha2" +require "pathname" class Thor::Runner < Thor #:nodoc: map "-T" => :list, "-i" => :install, "-u" => :update, "-v" => :version + def self.banner(command, all = false, subcommand = false) + "thor " + command.formatted_usage(self, all, subcommand) + end + + def self.exit_on_failure? + true + end + # Override Thor#help so it can give information about any class and any method. # def help(meth = nil) - if meth && !self.respond_to?(meth) + if meth && !respond_to?(meth) initialize_thorfiles(meth) - klass, task = Thor::Util.find_class_and_task_by_namespace(meth) - self.class.handle_no_task_error(task, false) if klass.nil? - klass.start(["-h", task].compact, :shell => self.shell) + 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) else super end end - # If a task is not found on Thor::Runner, method missing is invoked and - # Thor::Runner is then responsible for finding the task in all classes. + # If a command is not found on Thor::Runner, method missing is invoked and + # Thor::Runner is then responsible for finding the command in all classes. # def method_missing(meth, *args) meth = meth.to_s initialize_thorfiles(meth) - klass, task = Thor::Util.find_class_and_task_by_namespace(meth) - self.class.handle_no_task_error(task, false) if klass.nil? - args.unshift(task) if task - klass.start(args, :shell => self.shell) + 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) end - desc "install NAME", "Install an optionally named Thor file into your system tasks" - method_options :as => :string, :relative => :boolean, :force => :boolean - def install(name) + 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 Metrics/MethodLength initialize_thorfiles - # If a directory name is provided as the argument, look for a 'main.thor' - # task in said directory. - begin - if File.directory?(File.expand_path(name)) - base, package = File.join(name, "main.thor"), :directory - contents = open(base) {|input| input.read } - else - base, package = name, :file - contents = open(name) {|input| input.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:" @@ -75,16 +93,16 @@ def install(name) as = basename if as.empty? end - location = if options[:relative] || name =~ /^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) @@ -94,6 +112,7 @@ def install(name) if package == :file File.open(destination, "w") { |f| f.puts contents } else + require "fileutils" FileUtils.cp_r(name, destination) end @@ -102,7 +121,7 @@ def install(name) desc "version", "Show Thor version" def version - require 'thor/version' + require_relative "version" say "Thor #{Thor::VERSION}" end @@ -110,7 +129,8 @@ def version def uninstall(name) raise Error, "Can't find module '#{name}'" unless thor_yaml[name] say "Uninstalling #{name}." - FileUtils.rm_rf(File.join(thor_root, "#{thor_yaml[name][:filename]}")) + require "fileutils" + FileUtils.rm_rf(File.join(thor_root, (thor_yaml[name][:filename]).to_s)) thor_yaml.delete(name) save_yaml(thor_yaml) @@ -125,9 +145,10 @@ def update(name) say "Updating '#{name}' from #{thor_yaml[name][:location]}" old_filename = thor_yaml[name][:filename] - self.options = self.options.merge("as" => name) + self.options = options.merge("as" => name) if File.directory? File.expand_path(name) + require "fileutils" FileUtils.rm_rf(File.join(thor_root, old_filename)) thor_yaml.delete(old_filename) @@ -138,21 +159,19 @@ def update(name) filename = install(thor_yaml[name][:location]) end - unless filename == old_filename - File.delete(File.join(thor_root, old_filename)) - end + File.delete(File.join(thor_root, old_filename)) unless filename == old_filename end - desc "installed", "List the installed Thor modules and tasks" - method_options :internal => :boolean + desc "installed", "List the installed Thor modules and commands" + method_options internal: :boolean def installed initialize_thorfiles(nil, true) display_klasses(true, options["internal"]) end - desc "list [SEARCH]", "List the available thor tasks (--substring means .*SEARCH)" - method_options :substring => :boolean, :group => :string, :all => :boolean, :debug => :boolean - def list(search="") + desc "list [SEARCH]", "List the available thor commands (--substring means .*SEARCH)" + method_options substring: :boolean, group: :string, all: :boolean, debug: :boolean + def list(search = "") initialize_thorfiles search = ".*#{search}" if options["substring"] @@ -166,156 +185,151 @@ def list(search="") display_klasses(false, false, klasses) end - private +private - def self.banner(task, all = false, subcommand = false) - "thor " + task.formatted_usage(self, all, subcommand) - end + def thor_root + Thor::Util.thor_root + end - def thor_root - Thor::Util.thor_root + def thor_yaml + @thor_yaml ||= begin + yaml_file = File.join(thor_root, "thor.yml") + require "yaml" + yaml = YAML.load_file(yaml_file) if File.exist?(yaml_file) + yaml || {} end + end - def thor_yaml - @thor_yaml ||= begin - yaml_file = File.join(thor_root, "thor.yml") - yaml = YAML.load_file(yaml_file) if File.exists?(yaml_file) - yaml || {} - end - end + # Save the yaml file. If none exists in thor root, creates one. + # + def save_yaml(yaml) + yaml_file = File.join(thor_root, "thor.yml") - # Save the yaml file. If none exists in thor root, creates one. - # - def save_yaml(yaml) + unless File.exist?(yaml_file) + require "fileutils" + FileUtils.mkdir_p(thor_root) yaml_file = File.join(thor_root, "thor.yml") + FileUtils.touch(yaml_file) + end - unless File.exists?(yaml_file) - FileUtils.mkdir_p(thor_root) - yaml_file = File.join(thor_root, "thor.yml") - FileUtils.touch(yaml_file) - end + File.open(yaml_file, "w") { |f| f.puts yaml.to_yaml } + end - File.open(yaml_file, "w") { |f| f.puts yaml.to_yaml } + # Load the Thorfiles. If relevant_to is supplied, looks for specific files + # in the thor_root instead of loading them all. + # + # By default, it also traverses the current path until find Thor files, as + # described in thorfiles. This look up can be skipped by supplying + # skip_lookup true. + # + def initialize_thorfiles(relevant_to = nil, skip_lookup = false) + thorfiles(relevant_to, skip_lookup).each do |f| + Thor::Util.load_thorfile(f, nil, options[:debug]) unless Thor::Base.subclass_files.keys.include?(File.expand_path(f)) end + end - def self.exit_on_failure? - true - end + # Finds Thorfiles by traversing from your current directory down to the root + # directory of your system. If at any time we find a Thor file, we stop. + # + # We also ensure that system-wide Thorfiles are loaded first, so local + # Thorfiles can override them. + # + # ==== Example + # + # If we start at /Users/wycats/dev/thor ... + # + # 1. /Users/wycats/dev/thor + # 2. /Users/wycats/dev + # 3. /Users/wycats <-- we find a Thorfile here, so we stop + # + # Suppose we start at c:\Documents and Settings\james\dev\thor ... + # + # 1. c:\Documents and Settings\james\dev\thor + # 2. c:\Documents and Settings\james\dev + # 3. c:\Documents and Settings\james + # 4. c:\Documents and Settings + # 5. c:\ <-- no Thorfiles found! + # + def thorfiles(relevant_to = nil, skip_lookup = false) + thorfiles = [] - # Load the Thorfiles. If relevant_to is supplied, looks for specific files - # in the thor_root instead of loading them all. - # - # By default, it also traverses the current path until find Thor files, as - # described in thorfiles. This look up can be skipped by suppliying - # skip_lookup true. - # - def initialize_thorfiles(relevant_to=nil, skip_lookup=false) - thorfiles(relevant_to, skip_lookup).each do |f| - Thor::Util.load_thorfile(f, nil, options[:debug]) unless Thor::Base.subclass_files.keys.include?(File.expand_path(f)) + unless skip_lookup + Pathname.pwd.ascend do |path| + thorfiles = Thor::Util.globs_for(path).map { |g| Dir[g] }.flatten + break unless thorfiles.empty? end end - # Finds Thorfiles by traversing from your current directory down to the root - # directory of your system. If at any time we find a Thor file, we stop. - # - # We also ensure that system-wide Thorfiles are loaded first, so local - # Thorfiles can override them. - # - # ==== Example - # - # If we start at /Users/wycats/dev/thor ... - # - # 1. /Users/wycats/dev/thor - # 2. /Users/wycats/dev - # 3. /Users/wycats <-- we find a Thorfile here, so we stop - # - # Suppose we start at c:\Documents and Settings\james\dev\thor ... - # - # 1. c:\Documents and Settings\james\dev\thor - # 2. c:\Documents and Settings\james\dev - # 3. c:\Documents and Settings\james - # 4. c:\Documents and Settings - # 5. c:\ <-- no Thorfiles found! - # - def thorfiles(relevant_to=nil, skip_lookup=false) - thorfiles = [] - - unless skip_lookup - Pathname.pwd.ascend do |path| - thorfiles = Thor::Util.globs_for(path).map { |g| Dir[g] }.flatten - break unless thorfiles.empty? - end - end - - files = (relevant_to ? thorfiles_relevant_to(relevant_to) : Thor::Util.thor_root_glob) - files += thorfiles - files -= ["#{thor_root}/thor.yml"] + files = (relevant_to ? thorfiles_relevant_to(relevant_to) : Thor::Util.thor_root_glob) + files += thorfiles + files -= ["#{thor_root}/thor.yml"] - files.map! do |file| - File.directory?(file) ? File.join(file, "main.thor") : file - end + files.map! do |file| + File.directory?(file) ? File.join(file, "main.thor") : file end + end - # Load Thorfiles relevant to the given method. If you provide "foo:bar" it - # will load all thor files in the thor.yaml that has "foo" e "foo:bar" - # namespaces registered. - # - def thorfiles_relevant_to(meth) - lookup = [ meth, meth.split(":")[0...-1].join(":") ] - - files = thor_yaml.select do |k, v| - v[:namespaces] && !(v[:namespaces] & lookup).empty? - end + # Load Thorfiles relevant to the given method. If you provide "foo:bar" it + # will load all thor files in the thor.yaml that has "foo" e "foo:bar" + # namespaces registered. + # + def thorfiles_relevant_to(meth) + lookup = [meth, meth.split(":")[0...-1].join(":")] - files.map { |k, v| File.join(thor_root, "#{v[:filename]}") } + files = thor_yaml.select do |_, v| + v[:namespaces] && !(v[:namespaces] & lookup).empty? end - # Display information about the given klasses. If with_module is given, - # it shows a table with information extracted from the yaml file. - # - def display_klasses(with_modules=false, show_internal=false, klasses=Thor::Base.subclasses) - klasses -= [Thor, Thor::Runner, Thor::Group] unless show_internal + files.map { |_, v| File.join(thor_root, (v[:filename]).to_s) } + end - raise Error, "No Thor tasks available" if klasses.empty? - show_modules if with_modules && !thor_yaml.empty? + # Display information about the given klasses. If with_module is given, + # it shows a table with information extracted from the yaml file. + # + def display_klasses(with_modules = false, show_internal = false, klasses = Thor::Base.subclasses) + klasses -= [Thor, Thor::Runner, Thor::Group] unless show_internal - list = Hash.new { |h,k| h[k] = [] } - groups = klasses.select { |k| k.ancestors.include?(Thor::Group) } + raise Error, "No Thor commands available" if klasses.empty? + show_modules if with_modules && !thor_yaml.empty? - # Get classes which inherit from Thor - (klasses - groups).each { |k| list[k.namespace.split(":").first] += k.printable_tasks(false) } + list = Hash.new { |h, k| h[k] = [] } + groups = klasses.select { |k| k.ancestors.include?(Thor::Group) } - # Get classes which inherit from Thor::Base - groups.map! { |k| k.printable_tasks(false).first } - list["root"] = groups + # Get classes which inherit from Thor + (klasses - groups).each { |k| list[k.namespace.split(":").first] += k.printable_commands(false) } - # Order namespaces with default coming first - list = list.sort{ |a,b| a[0].sub(/^default/, '') <=> b[0].sub(/^default/, '') } - list.each { |n, tasks| display_tasks(n, tasks) unless tasks.empty? } - end + # Get classes which inherit from Thor::Base + groups.map! { |k| k.printable_commands(false).first } + list["root"] = groups - def display_tasks(namespace, list) #:nodoc: - list.sort!{ |a,b| a[0] <=> b[0] } + # Order namespaces with default coming first + list = list.sort { |a, b| a[0].sub(/^default/, "") <=> b[0].sub(/^default/, "") } + list.each { |n, commands| display_commands(n, commands) unless commands.empty? } + end - say shell.set_color(namespace, :blue, true) - say "-" * namespace.size + def display_commands(namespace, list) #:nodoc: + list.sort! { |a, b| a[0] <=> b[0] } - print_table(list, :truncate => true) - say - end + say shell.set_color(namespace, :blue, true) + say "-" * namespace.size - def show_modules #:nodoc: - info = [] - labels = ["Modules", "Namespaces"] + print_table(list, truncate: true) + say + end + alias_method :display_tasks, :display_commands - info << labels - info << [ "-" * labels[0].size, "-" * labels[1].size ] + def show_modules #:nodoc: + info = [] + labels = %w(Modules Namespaces) - thor_yaml.each do |name, hash| - info << [ name, hash[:namespaces].join(", ") ] - end + info << labels + info << ["-" * labels[0].size, "-" * labels[1].size] - print_table info - say "" + thor_yaml.each do |name, hash| + info << [name, hash[:namespaces].join(", ")] end + + print_table info + say "" + end end diff --git a/lib/thor/shell.rb b/lib/thor/shell.rb index a718c5376..0e6113a19 100644 --- a/lib/thor/shell.rb +++ b/lib/thor/shell.rb @@ -1,33 +1,32 @@ -require 'rbconfig' +require "rbconfig" class Thor module Base - # Returns the shell used in all Thor classes. If you are in a Unix platform - # it will use a colored log, otherwise it will use a basic one without color. - # - def self.shell - @shell ||= if ENV['THOR_SHELL'] && ENV['THOR_SHELL'].size > 0 - Thor::Shell.const_get(ENV['THOR_SHELL']) - elsif ((RbConfig::CONFIG['host_os'] =~ /mswin|mingw/) && !(ENV['ANSICON'])) - Thor::Shell::Basic - else - Thor::Shell::Color - end - end + class << self + attr_writer :shell - # Sets the shell used in all Thor classes. - # - def self.shell=(klass) - @shell = klass + # Returns the shell used in all Thor classes. If you are in a Unix platform + # it will use a colored log, otherwise it will use a basic one without color. + # + def shell + @shell ||= if ENV["THOR_SHELL"] && !ENV["THOR_SHELL"].empty? + Thor::Shell.const_get(ENV["THOR_SHELL"]) + elsif RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ && !ENV["ANSICON"] + Thor::Shell::Basic + else + Thor::Shell::Color + end + end end end module Shell - SHELL_DELEGATED_METHODS = [:ask, :error, :set_color, :yes?, :no?, :say, :say_status, :print_in_columns, :print_table, :print_wrapped, :file_collision, :terminal_width] + SHELL_DELEGATED_METHODS = [:ask, :error, :set_color, :yes?, :no?, :say, :say_error, :say_status, :print_in_columns, :print_table, :print_wrapped, :file_collision, :terminal_width] + attr_writer :shell - autoload :Basic, 'thor/shell/basic' - autoload :Color, 'thor/shell/color' - autoload :HTML, 'thor/shell/html' + autoload :Basic, File.expand_path("shell/basic", __dir__) + autoload :Color, File.expand_path("shell/color", __dir__) + autoload :HTML, File.expand_path("shell/html", __dir__) # Add shell to initialize config values. # @@ -42,10 +41,10 @@ module Shell # # MyScript.new [1.0], { :foo => :bar }, :shell => Thor::Shell::Basic.new # - def initialize(args=[], options={}, config={}) + def initialize(args = [], options = {}, config = {}) super self.shell = config[:shell] - self.shell.base ||= self if self.shell.respond_to?(:base) + shell.base ||= self if shell.respond_to?(:base) end # Holds the shell for the given Thor instance. If no shell is given, @@ -54,14 +53,9 @@ def shell @shell ||= Thor::Base.shell.new end - # Sets the shell for this thor class. - def shell=(shell) - @shell = shell - end - # Common methods that are delegated to the shell. SHELL_DELEGATED_METHODS.each do |method| - module_eval <<-METHOD, __FILE__, __LINE__ + module_eval <<-METHOD, __FILE__, __LINE__ + 1 def #{method}(*args,&block) shell.#{method}(*args,&block) end @@ -76,13 +70,12 @@ def with_padding shell.padding -= 1 end - protected - - # Allow shell to be shared between invocations. - # - def _shared_configuration #:nodoc: - super.merge!(:shell => self.shell) - end + protected + # Allow shell to be shared between invocations. + # + def _shared_configuration #:nodoc: + super.merge!(shell: shell) + end end end diff --git a/lib/thor/shell/basic.rb b/lib/thor/shell/basic.rb index 9c2e26d46..b38c33e7b 100644 --- a/lib/thor/shell/basic.rb +++ b/lib/thor/shell/basic.rb @@ -1,4 +1,6 @@ -require 'tempfile' +require_relative "column_printer" +require_relative "table_printer" +require_relative "wrapped_printer" class Thor module Shell @@ -9,7 +11,10 @@ class Basic # Initialize base, mute and padding to nil. # def initialize #:nodoc: - @base, @mute, @padding = nil, false, 0 + @base = nil + @mute = false + @padding = 0 + @always_force = false end # Mute everything that's inside given block @@ -33,22 +38,54 @@ def padding=(value) @padding = [0, value].max end + # Sets the output padding while executing a block and resets it. + # + def indent(count = 1) + orig_padding = padding + self.padding = padding + count + yield + self.padding = orig_padding + end + # Asks something to the user and receives a response. # + # If a default value is specified it will be presented to the user + # and allows them to select that value with an empty response. This + # option is ignored when limited answers are supplied. + # # If asked to limit the correct responses, you can pass in an # array of acceptable answers. If one of those is not supplied, # they will be shown a message stating that one of those answers # must be given and re-asked the question. # + # If asking for sensitive information, the :echo option can be set + # to false to mask user input from $stdin. + # + # If the required input is a path, then set the path option to + # true. This will enable tab completion for file paths relative + # to the current working directory on systems that support + # Readline. + # # ==== Example - # ask("What is your name?") + # ask("What is your name?") + # + # ask("What is the planet furthest from the sun?", :default => "Neptune") # - # ask("What is your favorite Neopolitan flavor?", :limited_to => ["strawberry", "chocolate", "vanilla"]) + # ask("What is your favorite Neopolitan flavor?", :limited_to => ["strawberry", "chocolate", "vanilla"]) + # + # ask("What is your password?", :echo => false) + # + # ask("Where should the file be saved?", :path => true) # def ask(statement, *args) options = args.last.is_a?(Hash) ? args.pop : {} + color = args.first - options[:limited_to] ? ask_filtered(statement, options[:limited_to], *args) : ask_simply(statement, *args) + if options[:limited_to] + ask_filtered(statement, color, options) + else + ask_simply(statement, color, options) + end end # Say (print) something to the user. If the sentence ends with a whitespace @@ -56,52 +93,68 @@ def ask(statement, *args) # are passed straight to puts (behavior got from Highline). # # ==== Example - # say("I know you knew that.") + # say("I know you knew that.") # - def say(message="", color=nil, force_new_line=(message.to_s !~ /( |\t)\Z/)) - message = message.to_s - - message = set_color(message, *color) if color + def say(message = "", color = nil, force_new_line = (message.to_s !~ /( |\t)\Z/)) + return if quiet? - spaces = " " * padding + buffer = prepare_message(message, *color) + buffer << "\n" if force_new_line && !message.to_s.end_with?("\n") - if force_new_line - stdout.puts(spaces + message) - else - stdout.print(spaces + message) - end + stdout.print(buffer) stdout.flush end + # Say (print) an error to the user. If the sentence ends with a whitespace + # or tab character, a new line is not appended (print + flush). Otherwise + # are passed straight to puts (behavior got from Highline). + # + # ==== Example + # say_error("error: something went wrong") + # + def say_error(message = "", color = nil, force_new_line = (message.to_s !~ /( |\t)\Z/)) + return if quiet? + + buffer = prepare_message(message, *color) + buffer << "\n" if force_new_line && !message.to_s.end_with?("\n") + + stderr.print(buffer) + stderr.flush + end + # Say a status with the given color and appends the message. Since this # method is used frequently by actions, it allows nil or false to be given # in log_status, avoiding the message from being shown. If a Symbol is # given in log_status, it's used as the color. # - def say_status(status, message, log_status=true) + 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 - stdout.puts "#{status}#{spaces}#{message}" + message = message.to_s.chomp.gsub(/(?:: 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={}) - return if array.empty? - - formats, indent, colwidth = [], options[:indent].to_i, options[:colwidth] - options[:truncate] = terminal_width if options[:truncate] == true - - formats << "%-#{colwidth + 2}s" 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 - if index == colcount - 1 - # Don't output 2 trailing spaces when printing the last column - formats << "%-s" - else - formats << "%-#{maxima + 2}s" - end - end - - formats[0] = formats[0].insert(0, " " * indent) - formats << "%s" - - array.each do |row| - sentence = "" - - row.each_with_index do |column, index| - maxima = maximas[index] - - if column.is_a?(Numeric) - if index == row.size - 1 - # Don't output 2 trailing spaces when printing the last column - f = "%#{maxima}s" - else - f = "%#{maxima}s " - end - else - f = 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 @@ -191,23 +191,9 @@ def print_table(array, options={}) # ==== Options # 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| - unwrapped.strip.gsub(/\n/, " ").squeeze(" "). - gsub(/.{1,#{width}}(?:\s|\Z)/){($& + 5.chr). - gsub(/\n\005/,"\n").gsub(/\005/,"\n")} - end - - paras.each do |para| - para.split("\n").each do |line| - stdout.puts line.insert(0, " " * indent) - end - stdout.puts unless para == paras.last - end + def print_wrapped(message, options = {}) + printer = WrappedPrinter.new(stdout, options) + printer.print(message) end # Deals with file collision and returns true if the file should be @@ -216,16 +202,22 @@ def print_wrapped(message, options={}) # # ==== Parameters # destination:: the destination file to solve conflicts - # block:: an optional block that returns the value to be used in diff + # block:: an optional block that returns the value to be used in diff and merge # def file_collision(destination) return true if @always_force - options = block_given? ? "[Ynaqdh]" : "[Ynaqh]" + options = block_given? ? "[Ynaqdhm]" : "[Ynaqh]" - while true - answer = ask %[Overwrite #{destination}? (enter "h" for help) #{options}] + loop do + answer = ask( + %[Overwrite #{destination}? (enter "h" for help) #{options}], + add_to_history: false + ) case answer + when nil + say "" + return true when is?(:yes), is?(:force), "" return true when is?(:no), is?(:skip) @@ -233,30 +225,24 @@ def file_collision(destination) when is?(:always) return @always_force = true when is?(:quit) - say 'Aborting...' + say "Aborting..." raise SystemExit when is?(:diff) show_diff(destination, yield) if block_given? - say 'Retrying...' + say "Retrying..." + when is?(:merge) + if block_given? && !merge_tool.empty? + merge(destination, yield) + return nil + end + + 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 - if ENV['THOR_COLUMNS'] - result = ENV['THOR_COLUMNS'].to_i - else - result = unix? ? dynamic_width : 80 - end - (result < 10) ? 80 : result - rescue - 80 - 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 @@ -269,12 +255,21 @@ def error(statement) # Apply color to the given string with optional bold. Disabled in the # Thor::Shell::Basic class. # - def set_color(string, *args) #:nodoc: + def set_color(string, *) #:nodoc: string end protected + def prepare_message(message, *color) + spaces = " " * padding + spaces + set_color(message.to_s, *color) + end + + def can_display_colors? + false + end + def lookup_color(color) return color unless color.is_a?(Symbol) self.class.const_get(color.to_s.upcase) @@ -284,10 +279,6 @@ def stdout $stdout end - def stdin - $stdin - end - def stderr $stderr end @@ -298,25 +289,32 @@ def is?(value) #:nodoc: if value.size == 1 /\A#{value}\z/i else - /\A(#{value}|#{value[0,1]})\z/i + /\A(#{value}|#{value[0, 1]})\z/i end end - def file_collision_help #:nodoc: -</dev/null}.split[1].to_i - end + def ask_simply(statement, color, options) + default = options[:default] + message = [statement, ("(#{default})" if default), nil].uniq.join(" ") + message = prepare_message(message, *color) + result = Thor::LineEditor.readline(message, options) - def dynamic_width_tput - %x{tput cols 2>/dev/null}.to_i - end + return unless result - def unix? - RUBY_PLATFORM =~ /(aix|darwin|linux|(net|free|open)bsd|cygwin|solaris|irix|hpux)/i - end + result = result.strip - 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 + if default && result == "" + default + else + result end end - if "".respond_to?(:encode) - def as_unicode - yield - end - else - def as_unicode - old, $KCODE = $KCODE, "U" - yield - ensure - $KCODE = old + def ask_filtered(statement, color, options) + answer_set = options[:limited_to] + case_insensitive = options.fetch(:case_insensitive, false) + correct_answer = nil + until correct_answer + answers = answer_set.join(", ") + answer = ask_simply("#{statement} [#{answers}]", color, options) + correct_answer = answer_match(answer_set, answer, case_insensitive) + say("Your response must be one of: [#{answers}]. Please try again.") unless correct_answer end + correct_answer end - def ask_simply(statement, color=nil) - say("#{statement} ", color) - stdin.gets.tap{|text| text.strip! if text} + def answer_match(possibilities, answer, case_insensitive) + if case_insensitive + possibilities.detect{ |possibility| possibility.downcase == answer.downcase } + else + possibilities.detect{ |possibility| possibility == answer } + end end - def ask_filtered(statement, answer_set, *args) - correct_answer = nil - until correct_answer - answer = ask_simply("#{statement} #{answer_set.inspect}", *args) - correct_answer = answer_set.include?(answer) ? answer : nil - answers = answer_set.map(&:inspect).join(", ") - say("Your response must be one of: [#{answers}]. Please try again.") unless correct_answer + def merge(destination, content) #:nodoc: + require "tempfile" + Tempfile.open([File.basename(destination), File.extname(destination)], File.dirname(destination)) do |temp| + temp.write content + temp.rewind + system(merge_tool, temp.path, destination) end - correct_answer end + def merge_tool #:nodoc: + @merge_tool ||= ENV["THOR_MERGE"] || "git difftool --no-index" + end end end end diff --git a/lib/thor/shell/color.rb b/lib/thor/shell/color.rb index 0da737e25..78e507e5a 100644 --- a/lib/thor/shell/color.rb +++ b/lib/thor/shell/color.rb @@ -1,4 +1,5 @@ -require 'thor/shell/basic' +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. @@ -77,7 +80,9 @@ class Color < Basic # :on_cyan # :on_white def set_color(string, *colors) - if colors.all? { |color| color.is_a?(Symbol) || color.is_a?(String) } + if colors.compact.empty? || !can_display_colors? + string + elsif colors.all? { |color| color.is_a?(Symbol) || color.is_a?(String) } ansi_colors = colors.map { |color| lookup_color(color) } "#{ansi_colors.join}#{string}#{CLEAR}" else @@ -92,53 +97,19 @@ def set_color(string, *colors) end end - 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 - - 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 + protected - # 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? + def can_display_colors? + are_colors_supported? && !are_colors_disabled? + end - @diff_lcs_loaded = begin - require 'diff/lcs' - true - rescue LoadError - false - end - end + def are_colors_supported? + stdout.tty? && ENV["TERM"] != "dumb" + end + def are_colors_disabled? + !ENV["NO_COLOR"].nil? && !ENV["NO_COLOR"].empty? + end 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 3bed18adf..bb3ef6661 100644 --- a/lib/thor/shell/html.rb +++ b/lib/thor/shell/html.rb @@ -1,4 +1,5 @@ -require 'thor/shell/basic' +require_relative "basic" +require_relative "lcs_diff" class Thor module Shell @@ -6,42 +7,44 @@ 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" # Set the terminal's foreground HTML color to black. - BLACK = 'color: black' + BLACK = "color: black" # Set the terminal's foreground HTML color to red. - RED = 'color: red' + RED = "color: red" # Set the terminal's foreground HTML color to green. - GREEN = 'color: green' + GREEN = "color: green" # Set the terminal's foreground HTML color to yellow. - YELLOW = 'color: yellow' + YELLOW = "color: yellow" # Set the terminal's foreground HTML color to blue. - BLUE = 'color: blue' + BLUE = "color: blue" # Set the terminal's foreground HTML color to magenta. - MAGENTA = 'color: magenta' + MAGENTA = "color: magenta" # Set the terminal's foreground HTML color to cyan. - CYAN = 'color: cyan' + CYAN = "color: cyan" # Set the terminal's foreground HTML color to white. - WHITE = 'color: white' + WHITE = "color: white" # Set the terminal's background HTML color to black. - ON_BLACK = 'background-color: black' + ON_BLACK = "background-color: black" # Set the terminal's background HTML color to red. - ON_RED = 'background-color: red' + ON_RED = "background-color: red" # Set the terminal's background HTML color to green. - ON_GREEN = 'background-color: green' + ON_GREEN = "background-color: green" # Set the terminal's background HTML color to yellow. - ON_YELLOW = 'background-color: yellow' + ON_YELLOW = "background-color: yellow" # Set the terminal's background HTML color to blue. - ON_BLUE = 'background-color: blue' + ON_BLUE = "background-color: blue" # Set the terminal's background HTML color to magenta. - ON_MAGENTA = 'background-color: magenta' + ON_MAGENTA = "background-color: magenta" # Set the terminal's background HTML color to cyan. - ON_CYAN = 'background-color: cyan' + ON_CYAN = "background-color: cyan" # Set the terminal's background HTML color to white. - ON_WHITE = 'background-color: white' + ON_WHITE = "background-color: white" # Set color by using a string or one of the defined constants. If a third # option is set to true, it also adds bold to the string. This is based @@ -51,73 +54,31 @@ class HTML < Basic def set_color(string, *colors) if colors.all? { |color| color.is_a?(Symbol) || color.is_a?(String) } html_colors = colors.map { |color| lookup_color(color) } - "#{string}" + "#{Thor::Util.escape_html(string)}" else color, bold = colors html_color = self.class.const_get(color.to_s.upcase) if color.is_a?(Symbol) styles = [html_color] styles << BOLD if bold - "#{string}" + "#{Thor::Util.escape_html(string)}" end end # Ask something to the user and receives a response. # # ==== Example - # ask("What is your name?") + # ask("What is your name?") # # TODO: Implement #ask for Thor::Shell::HTML - def ask(statement, color=nil) + def ask(statement, color = nil) raise NotImplementedError, "Implement #ask for Thor::Shell::HTML" end - 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 - - 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 + protected + def can_display_colors? + true + 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..6ca57b2b5 --- /dev/null +++ b/lib/thor/shell/table_printer.rb @@ -0,0 +1,118 @@ +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 + chars = string.chars.to_a + if chars.length <= @truncate + chars.join + else + chars[0, @truncate - 3 - @indent].join + "..." + end + end + + def indentation + " " * @indent + 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/task.rb b/lib/thor/task.rb deleted file mode 100644 index 92fb557f4..000000000 --- a/lib/thor/task.rb +++ /dev/null @@ -1,132 +0,0 @@ -class Thor - class Task < Struct.new(:name, :description, :long_description, :usage, :options) - 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 || {}) - end - - def initialize_copy(other) #:nodoc: - super(other) - self.options = other.options.dup if other.options - end - - def hidden? - false - end - - # By default, a task invokes a method in the thor class. You can change this - # implementation to create custom tasks. - def run(instance, args=[]) - arity = nil - - if private_method?(instance) - instance.class.handle_no_task_error(name) - elsif public_method?(instance) - arity = instance.method(name).arity - instance.__send__(name, *args) - elsif local_method?(instance, :method_missing) - instance.__send__(:method_missing, name.to_sym, *args) - else - instance.class.handle_no_task_error(name) - end - rescue ArgumentError => e - handle_argument_error?(instance, e, caller) ? - instance.class.handle_argument_error(self, e, arity) : (raise e) - rescue NoMethodError => e - handle_no_method_error?(instance, e, caller) ? - instance.class.handle_no_task_error(name) : (raise e) - end - - # Returns the formatted usage by injecting given required arguments - # and required options into the given usage. - def formatted_usage(klass, namespace = true, subcommand = false) - if namespace - namespace = klass.namespace - formatted = "#{namespace.gsub(/^(default)/,'')}:" - end - formatted = "#{klass.namespace.split(':').last} " if subcommand - - formatted ||= "" - - # Add usage with required arguments - formatted << if klass && !klass.arguments.empty? - usage.to_s.gsub(/^#{name}/) do |match| - match << " " << klass.arguments.map{ |a| a.usage }.compact.join(' ') - end - else - usage.to_s - end - - # Add required options - formatted << " #{required_options}" - - # Strip and go! - formatted.strip - end - - protected - - def not_debugging?(instance) - !(instance.class.respond_to?(:debugging) && instance.class.debugging) - end - - def required_options - @required_options ||= options.map{ |_, o| o.usage if o.required? }.compact.sort.join(" ") - end - - # Given a target, checks if this class name is a public method. - def public_method?(instance) #:nodoc: - !(instance.public_methods & [name.to_s, name.to_sym]).empty? - end - - def private_method?(instance) - !(instance.private_methods & [name.to_s, name.to_sym]).empty? - end - - def local_method?(instance, name) - methods = instance.public_methods(false) + instance.private_methods(false) + instance.protected_methods(false) - !(methods & [name.to_s, name.to_sym]).empty? - end - - def sans_backtrace(backtrace, caller) #:nodoc: - saned = backtrace.reject { |frame| frame =~ FILE_REGEXP || (frame =~ /\.java:/ && RUBY_PLATFORM =~ /java/) } - saned -= caller - end - - def handle_argument_error?(instance, error, caller) - not_debugging?(instance) && error.message =~ /wrong number of arguments/ && begin - saned = sans_backtrace(error.backtrace, caller) - # Ruby 1.9 always include the called method in the backtrace - saned.empty? || (saned.size == 1 && RUBY_VERSION >= "1.9") - end - end - - def handle_no_method_error?(instance, error, caller) - not_debugging?(instance) && - error.message =~ /^undefined method `#{name}' for #{Regexp.escape(instance.to_s)}$/ - end - end - - # A task that is hidden in help messages but still invocable. - class HiddenTask < Task - def hidden? - true - end - end - - # A dynamic task that handles method missing scenarios. - class DynamicTask < Task - def initialize(name, options=nil) - super(name.to_s, "A dynamically-generated task", name.to_s, name.to_s, options) - end - - def run(instance, args=[]) - if (instance.methods & [name.to_s, name.to_sym]).empty? - super - else - instance.class.handle_no_task_error(name) - end - end - end -end diff --git a/lib/thor/util.rb b/lib/thor/util.rb index d45843dde..6fa0b3d99 100644 --- a/lib/thor/util.rb +++ b/lib/thor/util.rb @@ -1,4 +1,4 @@ -require 'rbconfig' +require "rbconfig" class Thor module Sandbox #:nodoc: @@ -15,252 +15,271 @@ module Sandbox #:nodoc: # Thor::Util.load_thorfile("~/.thor/foo") # module Util + class << self + # Receives a namespace and search for it in the Thor::Base subclasses. + # + # ==== Parameters + # namespace:: The namespace to search for. + # + def find_by_namespace(namespace) + namespace = "default#{namespace}" if namespace.empty? || namespace =~ /^:/ + Thor::Base.subclasses.detect { |klass| klass.namespace == namespace } + end - # Receives a namespace and search for it in the Thor::Base subclasses. - # - # ==== Parameters - # namespace:: The namespace to search for. - # - def self.find_by_namespace(namespace) - namespace = "default#{namespace}" if namespace.empty? || namespace =~ /^:/ - Thor::Base.subclasses.find { |klass| klass.namespace == namespace } - end - - # Receives a constant and converts it to a Thor namespace. Since Thor tasks - # can be added to a sandbox, this method is also responsable for removing - # the sandbox namespace. - # - # This method should not be used in general because it's used to deal with - # older versions of Thor. On current versions, if you need to get the - # namespace from a class, just call namespace on it. - # - # ==== Parameters - # constant:: The constant to be converted to the thor path. - # - # ==== Returns - # String:: If we receive Foo::Bar::Baz it returns "foo:bar:baz" - # - def self.namespace_from_thor_class(constant) - constant = constant.to_s.gsub(/^Thor::Sandbox::/, "") - constant = snake_case(constant).squeeze(":") - constant - end + # Receives a constant and converts it to a Thor namespace. Since Thor + # commands can be added to a sandbox, this method is also responsible for + # removing the sandbox namespace. + # + # This method should not be used in general because it's used to deal with + # older versions of Thor. On current versions, if you need to get the + # namespace from a class, just call namespace on it. + # + # ==== Parameters + # constant:: The constant to be converted to the thor path. + # + # ==== Returns + # String:: If we receive Foo::Bar::Baz it returns "foo:bar:baz" + # + def namespace_from_thor_class(constant) + constant = constant.to_s.gsub(/^Thor::Sandbox::/, "") + constant = snake_case(constant).squeeze(":") + constant + end - # Given the contents, evaluate it inside the sandbox and returns the - # namespaces defined in the sandbox. - # - # ==== Parameters - # contents - # - # ==== Returns - # Array[Object] - # - def self.namespaces_in_content(contents, file=__FILE__) - old_constants = Thor::Base.subclasses.dup - Thor::Base.subclasses.clear + # Given the contents, evaluate it inside the sandbox and returns the + # namespaces defined in the sandbox. + # + # ==== Parameters + # contents + # + # ==== Returns + # Array[Object] + # + def namespaces_in_content(contents, file = __FILE__) + old_constants = Thor::Base.subclasses.dup + Thor::Base.subclasses.clear - load_thorfile(file, contents) + load_thorfile(file, contents) - new_constants = Thor::Base.subclasses.dup - Thor::Base.subclasses.replace(old_constants) + new_constants = Thor::Base.subclasses.dup + Thor::Base.subclasses.replace(old_constants) - new_constants.map!{ |c| c.namespace } - new_constants.compact! - new_constants - end - - # Returns the thor classes declared inside the given class. - # - def self.thor_classes_in(klass) - stringfied_constants = klass.constants.map { |c| c.to_s } - Thor::Base.subclasses.select do |subclass| - next unless subclass.name - stringfied_constants.include?(subclass.name.gsub("#{klass.name}::", '')) + new_constants.map!(&:namespace) + new_constants.compact! + new_constants end - end - # Receives a string and convert it to snake case. SnakeCase returns snake_case. - # - # ==== Parameters - # String - # - # ==== Returns - # String - # - def self.snake_case(str) - return str.downcase if str =~ /^[A-Z_]+$/ - str.gsub(/\B[A-Z]/, '_\&').squeeze('_') =~ /_*(.*)/ - return $+.downcase - end - - # Receives a string and convert it to camel case. camel_case returns CamelCase. - # - # ==== Parameters - # String - # - # ==== Returns - # String - # - def self.camel_case(str) - return str if str !~ /_/ && str =~ /[A-Z]+.*/ - str.split('_').map { |i| i.capitalize }.join - end - - # Receives a namespace and tries to retrieve a Thor or Thor::Group class - # from it. It first searches for a class using the all the given namespace, - # if it's not found, removes the highest entry and searches for the class - # again. If found, returns the highest entry as the class name. - # - # ==== Examples - # - # class Foo::Bar < Thor - # def baz - # end - # end - # - # class Baz::Foo < Thor::Group - # end - # - # Thor::Util.namespace_to_thor_class("foo:bar") #=> Foo::Bar, nil # will invoke default task - # Thor::Util.namespace_to_thor_class("baz:foo") #=> Baz::Foo, nil - # Thor::Util.namespace_to_thor_class("foo:bar:baz") #=> Foo::Bar, "baz" - # - # ==== Parameters - # namespace - # - def self.find_class_and_task_by_namespace(namespace, fallback = true) - if namespace.include?(?:) # look for a namespaced task - pieces = namespace.split(":") - task = pieces.pop - klass = Thor::Util.find_by_namespace(pieces.join(":")) - end - unless klass # look for a Thor::Group with the right name - klass, task = Thor::Util.find_by_namespace(namespace), nil + # Returns the thor classes declared inside the given class. + # + def thor_classes_in(klass) + stringfied_constants = klass.constants.map(&:to_s) + Thor::Base.subclasses.select do |subclass| + next unless subclass.name + stringfied_constants.include?(subclass.name.gsub("#{klass.name}::", "")) + end end - if !klass && fallback # try a task in the default namespace - task = namespace - klass = Thor::Util.find_by_namespace('') + + # Receives a string and convert it to snake case. SnakeCase returns snake_case. + # + # ==== Parameters + # String + # + # ==== Returns + # String + # + def snake_case(str) + return str.downcase if str =~ /^[A-Z_]+$/ + str.gsub(/\B[A-Z]/, '_\&').squeeze("_") =~ /_*(.*)/ + Regexp.last_match(-1).downcase end - return klass, task - end - # Receives a path and load the thor file in the path. The file is evaluated - # inside the sandbox to avoid namespacing conflicts. - # - def self.load_thorfile(path, content=nil, debug=false) - content ||= File.binread(path) + # Receives a string and convert it to camel case. camel_case returns CamelCase. + # + # ==== Parameters + # String + # + # ==== Returns + # String + # + def camel_case(str) + return str if str !~ /_/ && str =~ /[A-Z]+.*/ + str.split("_").map(&:capitalize).join + end - begin - Thor::Sandbox.class_eval(content, path) - rescue Exception => e - $stderr.puts("WARNING: unable to load thorfile #{path.inspect}: #{e.message}") - if debug - $stderr.puts(*e.backtrace) - else - $stderr.puts(e.backtrace.first) + # Receives a namespace and tries to retrieve a Thor or Thor::Group class + # from it. It first searches for a class using the all the given namespace, + # if it's not found, removes the highest entry and searches for the class + # again. If found, returns the highest entry as the class name. + # + # ==== Examples + # + # class Foo::Bar < Thor + # def baz + # end + # end + # + # class Baz::Foo < Thor::Group + # end + # + # Thor::Util.namespace_to_thor_class("foo:bar") #=> Foo::Bar, nil # will invoke default command + # Thor::Util.namespace_to_thor_class("baz:foo") #=> Baz::Foo, nil + # Thor::Util.namespace_to_thor_class("foo:bar:baz") #=> Foo::Bar, "baz" + # + # ==== Parameters + # namespace + # + def find_class_and_command_by_namespace(namespace, fallback = true) + if namespace.include?(":") # look for a namespaced command + *pieces, command = namespace.split(":") + namespace = pieces.join(":") + namespace = "default" if namespace.empty? + klass = Thor::Base.subclasses.detect { |thor| thor.namespace == namespace && thor.command_exists?(command) } + end + unless klass # look for a Thor::Group with the right name + klass = Thor::Util.find_by_namespace(namespace) + command = nil end + if !klass && fallback # try a command in the default namespace + command = namespace + klass = Thor::Util.find_by_namespace("") + end + [klass, command] end - end + alias_method :find_class_and_task_by_namespace, :find_class_and_command_by_namespace + + # Receives a path and load the thor file in the path. The file is evaluated + # inside the sandbox to avoid namespacing conflicts. + # + def load_thorfile(path, content = nil, debug = false) + content ||= File.read(path) - def self.user_home - @@user_home ||= if ENV["HOME"] - ENV["HOME"] - elsif ENV["USERPROFILE"] - ENV["USERPROFILE"] - elsif ENV["HOMEDRIVE"] && ENV["HOMEPATH"] - File.join(ENV["HOMEDRIVE"], ENV["HOMEPATH"]) - elsif ENV["APPDATA"] - ENV["APPDATA"] - else begin - File.expand_path("~") - rescue - if File::ALT_SEPARATOR - "C:/" + Thor::Sandbox.class_eval(content, path) + rescue StandardError => e + $stderr.puts("WARNING: unable to load thorfile #{path.inspect}: #{e.message}") + if debug + $stderr.puts(*e.backtrace) else - "/" + $stderr.puts(e.backtrace.first) end end end - end - # Returns the root where thor files are located, depending on the OS. - # - def self.thor_root - File.join(user_home, ".thor").gsub(/\\/, '/') - end + def user_home + @@user_home ||= if ENV["HOME"] + ENV["HOME"] + elsif ENV["USERPROFILE"] + ENV["USERPROFILE"] + elsif ENV["HOMEDRIVE"] && ENV["HOMEPATH"] + File.join(ENV["HOMEDRIVE"], ENV["HOMEPATH"]) + elsif ENV["APPDATA"] + ENV["APPDATA"] + else + begin + File.expand_path("~") + rescue + if File::ALT_SEPARATOR + "C:/" + else + "/" + end + end + end + end + + # Returns the root where thor files are located, depending on the OS. + # + def thor_root + File.join(user_home, ".thor").tr("\\", "/") + end - # Returns the files in the thor root. On Windows thor_root will be something - # like this: - # - # C:\Documents and Settings\james\.thor - # - # If we don't #gsub the \ character, Dir.glob will fail. - # - def self.thor_root_glob - files = Dir["#{escape_globs(thor_root)}/*"] + # Returns the files in the thor root. On Windows thor_root will be something + # like this: + # + # C:\Documents and Settings\james\.thor + # + # If we don't #gsub the \ character, Dir.glob will fail. + # + def thor_root_glob + files = Dir["#{escape_globs(thor_root)}/*"] - files.map! do |file| - File.directory?(file) ? File.join(file, "main.thor") : file + files.map! do |file| + File.directory?(file) ? File.join(file, "main.thor") : file + end end - end - # Where to look for Thor files. - # - def self.globs_for(path) - path = escape_globs(path) - ["#{path}/Thorfile", "#{path}/*.thor", "#{path}/tasks/*.thor", "#{path}/lib/tasks/*.thor"] - end + # Where to look for Thor files. + # + def globs_for(path) + path = escape_globs(path) + ["#{path}/Thorfile", "#{path}/*.thor", "#{path}/tasks/*.thor", "#{path}/lib/tasks/**/*.thor"] + end - # Return the path to the ruby interpreter taking into account multiple - # installations and windows extensions. - # - def self.ruby_command - @ruby_command ||= begin - ruby_name = RbConfig::CONFIG['ruby_install_name'] - ruby = File.join(RbConfig::CONFIG['bindir'], ruby_name) - ruby << RbConfig::CONFIG['EXEEXT'] + # Return the path to the ruby interpreter taking into account multiple + # installations and windows extensions. + # + def ruby_command + @ruby_command ||= begin + ruby_name = RbConfig::CONFIG["ruby_install_name"] + ruby = File.join(RbConfig::CONFIG["bindir"], ruby_name) + ruby << RbConfig::CONFIG["EXEEXT"] - # avoid using different name than ruby (on platforms supporting links) - if ruby_name != 'ruby' && File.respond_to?(:readlink) - begin - alternate_ruby = File.join(RbConfig::CONFIG['bindir'], 'ruby') - alternate_ruby << RbConfig::CONFIG['EXEEXT'] + # avoid using different name than ruby (on platforms supporting links) + if ruby_name != "ruby" && File.respond_to?(:readlink) + begin + alternate_ruby = File.join(RbConfig::CONFIG["bindir"], "ruby") + alternate_ruby << RbConfig::CONFIG["EXEEXT"] - # ruby is a symlink - if File.symlink? alternate_ruby - linked_ruby = File.readlink alternate_ruby + # ruby is a symlink + if File.symlink? alternate_ruby + linked_ruby = File.readlink alternate_ruby - # symlink points to 'ruby_install_name' - ruby = alternate_ruby if linked_ruby == ruby_name || linked_ruby == ruby + # symlink points to 'ruby_install_name' + ruby = alternate_ruby if linked_ruby == ruby_name || linked_ruby == ruby + end + rescue NotImplementedError # rubocop:disable Lint/HandleExceptions + # just ignore on windows end - rescue NotImplementedError - # just ignore on windows end + + # escape string in case path to ruby executable contain spaces. + ruby.sub!(/.*\s.*/m, '"\&"') + ruby end + end - # escape string in case path to ruby executable contain spaces. - ruby.sub!(/.*\s.*/m, '"\&"') - ruby + # Returns a string that has had any glob characters escaped. + # The glob characters are `* ? { } [ ]`. + # + # ==== Examples + # + # Thor::Util.escape_globs('[apps]') # => '\[apps\]' + # + # ==== Parameters + # String + # + # ==== Returns + # String + # + def escape_globs(path) + path.to_s.gsub(/[*?{}\[\]]/, '\\\\\\&') end - end - # Returns a string that has had any glob characters escaped. - # The glob characters are `* ? { } [ ]`. - # - # ==== Examples - # - # Thor::Util.escape_globs('[apps]') # => '\[apps\]' - # - # ==== Parameters - # String - # - # ==== Returns - # String - # - def self.escape_globs(path) - path.to_s.gsub(/[*?{}\[\]]/, '\\\\\\&') + # Returns a string that has had any HTML characters escaped. + # + # ==== Examples + # + # Thor::Util.escape_html('
') # => "<div>" + # + # ==== Parameters + # String + # + # ==== Returns + # String + # + def escape_html(string) + CGI.escapeHTML(string) + end end - end end diff --git a/lib/thor/version.rb b/lib/thor/version.rb index 7b918f88f..c6a08c984 100644 --- a/lib/thor/version.rb +++ b/lib/thor/version.rb @@ -1,3 +1,3 @@ class Thor - VERSION = "0.17.0" + VERSION = "1.4.0" end diff --git a/spec/actions/create_file_spec.rb b/spec/actions/create_file_spec.rb index f70d15f8c..45ac3c050 100644 --- a/spec/actions/create_file_spec.rb +++ b/spec/actions/create_file_spec.rb @@ -1,17 +1,17 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/actions' +require "helper" +require "thor/actions" describe Thor::Actions::CreateFile do before do + @silence = false ::FileUtils.rm_rf(destination_root) end - def create_file(destination=nil, config={}, options={}) - @base = MyCounter.new([1, 2], options, { :destination_root => destination_root }) - @base.stub!(:file_name).and_return('rdoc') + 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! @@ -30,13 +30,21 @@ def silence! it "creates a file" do create_file("doc/config.rb") invoke! - expect(File.exists?(File.join(destination_root, "doc/config.rb"))).to be_true + 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.exists?(File.join(destination_root, "doc/config.rb"))).to be_false + expect(File.exist?(File.join(destination_root, "doc/config.rb"))).to be false end it "shows created status to the user" do @@ -59,7 +67,7 @@ def silence! it "converts encoded instructions" do create_file("doc/%file_name%.rb.tt") invoke! - expect(File.exists?(File.join(destination_root, "doc/rdoc.rb.tt"))).to be_true + expect(File.exist?(File.join(destination_root, "doc/rdoc.rb.tt"))).to be true end describe "when file exists" do @@ -78,61 +86,91 @@ def silence! describe "and is not identical" do before do - File.open(File.join(destination_root, 'doc/config.rb'), 'w'){ |f| f.write("FOO = 3") } + File.open(File.join(destination_root, "doc/config.rb"), "w") { |f| f.write("FOO = 3") } 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 - it "shows conflict status to ther user" do + it "shows conflict status to the user" do + file = File.join(destination_root, "doc/config.rb") expect(create_file("doc/config.rb")).not_to be_identical - $stdin.should_receive(:gets).and_return('s') - file = File.join(destination_root, 'doc/config.rb') + expect(Thor::LineEditor).to receive(:readline).with("Overwrite #{file}? (enter \"h\" for help) [Ynaqdhm] ", anything).and_return("s") content = invoke! - expect(content).to match(/conflict doc\/config\.rb/) - expect(content).to match(/Overwrite #{file}\? \(enter "h" for help\) \[Ynaqdh\]/) - expect(content).to match(/skip doc\/config\.rb/) + expect(content).to match(%r{conflict doc/config\.rb}) + expect(content).to match(%r{skip doc/config\.rb}) end it "creates the file if the file collision menu returns true" do create_file("doc/config.rb") - $stdin.should_receive(:gets).and_return('y') - expect(invoke!).to match(/force doc\/config\.rb/) + expect(Thor::LineEditor).to receive(:readline).and_return("y") + expect(invoke!).to match(%r{force doc/config\.rb}) end it "skips the file if the file collision menu returns false" do create_file("doc/config.rb") - $stdin.should_receive(:gets).and_return('n') - expect(invoke!).to match(/skip doc\/config\.rb/) + expect(Thor::LineEditor).to receive(:readline).and_return("n") + expect(invoke!).to match(%r{skip doc/config\.rb}) end it "executes the block given to show file content" do create_file("doc/config.rb") - $stdin.should_receive(:gets).and_return('d') - $stdin.should_receive(:gets).and_return('n') - @base.shell.should_receive(:system).with(/diff -u/) + expect(Thor::LineEditor).to receive(:readline).and_return("d", "n") + expect(@base.shell).to receive(:system).with(/diff -u/) + invoke! + end + + it "executes the block given to run merge tool" do + create_file("doc/config.rb") + allow(@base.shell).to receive(:merge_tool).and_return("meld") + expect(Thor::LineEditor).to receive(:readline).and_return("m") + expect(@base.shell).to receive(:system).with("meld", /doc\/config\.rb/, /doc\/config\.rb/) invoke! end end end + + context "when file exists and it causes a file clash" do + before do + create_file("doc/config") + invoke! + end + + it "generates a file clash" do + create_file("doc/config/config.rb") + expect(invoke!).to eq(" file_clash doc/config/config.rb\n") + end + end + + context "when directory exists and it causes a file clash" do + before do + create_file("doc/config/hello") + invoke! + end + + it "generates a file clash" do + create_file("doc/config") + expect(invoke!) .to eq(" file_clash doc/config\n") + end + end end describe "#revoke!" do @@ -140,31 +178,38 @@ def silence! create_file("doc/config.rb") invoke! revoke! - expect(File.exists?(@action.destination)).to be_false + expect(File.exist?(@action.destination)).to be false end it "does not raise an error if the file does not exist" do create_file("doc/config.rb") revoke! - expect(File.exists?(@action.destination)).to be_false + expect(File.exist?(@action.destination)).to be false end end describe "#exists?" do it "returns true if the destination file exists" do create_file("doc/config.rb") - expect(@action.exists?).to be_false + expect(@action.exists?).to be false invoke! - expect(@action.exists?).to be_true + expect(@action.exists?).to be true end end describe "#identical?" do - it "returns true if the destination file and is identical" do + it "returns true if the destination file exists and is identical" do create_file("doc/config.rb") - expect(@action.identical?).to be_false + expect(@action.identical?).to be false + 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 + 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 72d5186d1..9625f7367 100644 --- a/spec/actions/create_link_spec.rb +++ b/spec/actions/create_link_spec.rb @@ -1,81 +1,119 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/actions' -require 'tempfile' +require "helper" +require "thor/actions" +require "tempfile" -describe Thor::Actions::CreateLink do +describe Thor::Actions::CreateLink, unless: windows? do before do - @hardlink_to = File.join(Dir.tmpdir, 'linkdest.rb') + @hardlink_to = File.join(Dir.tmpdir, "linkdest.rb") ::FileUtils.rm_rf(destination_root) ::FileUtils.rm_rf(@hardlink_to) end - def create_link(destination=nil, config={}, options={}) - @base = MyCounter.new([1,2], options, { :destination_root => destination_root }) - @base.stub!(:file_name).and_return('rdoc') + let(:config) { {} } + let(:options) { {} } - @tempfile = Tempfile.new("config.rb") + let(:base) do + base = MyCounter.new([1, 2], options, destination_root: destination_root) + allow(base).to receive(:file_name).and_return("rdoc") + base + end + + let(:tempfile) { Tempfile.new("config.rb") } + + let(:source) { tempfile.path } - @action = Thor::Actions::CreateLink.new(@base, destination, @tempfile.path, - { :verbose => !@silence }.merge(config)) + let(:destination) { "doc/config.rb" } + + let(:action) do + Thor::Actions::CreateLink.new(base, destination, source, config) end def invoke! - capture(:stdout) { @action.invoke! } + capture(:stdout) { action.invoke! } end - def silence! - @silence = true + def revoke! + capture(:stdout) { action.revoke! } end describe "#invoke!" do - it "creates a symbolic link for :symbolic => true" do - create_link("doc/config.rb", :symbolic => true) - invoke! - destination_path = File.join(destination_root, "doc/config.rb") - expect(File.exists?(destination_path)).to be_true - expect(File.symlink?(destination_path)).to be_true + context "specifying :symbolic => true" do + let(:config) { {symbolic: true} } + + it "creates a symbolic link" do + invoke! + destination_path = File.join(destination_root, "doc/config.rb") + expect(File.exist?(destination_path)).to be true + expect(File.symlink?(destination_path)).to be true + end end - it "creates a hard link for :symbolic => false" do - create_link(@hardlink_to, :symbolic => false) - invoke! - destination_path = @hardlink_to - expect(File.exists?(destination_path)).to be_true - expect(File.symlink?(destination_path)).to be_false + context "specifying :symbolic => false" do + let(:config) { {symbolic: false} } + let(:destination) { @hardlink_to } + + it "creates a hard link" do + invoke! + destination_path = @hardlink_to + expect(File.exist?(destination_path)).to be true + expect(File.symlink?(destination_path)).to be false + end end it "creates a symbolic link by default" do - create_link("doc/config.rb") invoke! destination_path = File.join(destination_root, "doc/config.rb") - expect(File.exists?(destination_path)).to be_true - expect(File.symlink?(destination_path)).to be_true + expect(File.exist?(destination_path)).to be true + expect(File.symlink?(destination_path)).to be true end - it "does not create a link if pretending" do - create_link("doc/config.rb", {}, :pretend => true) - invoke! - expect(File.exists?(File.join(destination_root, "doc/config.rb"))).to be_false + context "specifying :pretend => true" do + 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 + end end it "shows created status to the user" do - create_link("doc/config.rb") expect(invoke!).to eq(" create doc/config.rb\n") end - it "does not show any information if log status is false" do - silence! - create_link("doc/config.rb") - expect(invoke!).to be_empty + context "specifying :verbose => false" do + let(:config) { {verbose: false} } + it "does not show any information" do + expect(invoke!).to be_empty + end end end describe "#identical?" do it "returns true if the destination link exists and is identical" do - create_link("doc/config.rb") - expect(@action.identical?).to be_false + expect(action.identical?).to be false + invoke! + expect(action.identical?).to be true + end + + context "with source path relative to destination" do + let(:source) do + destination_path = File.dirname(File.join(destination_root, destination)) + Pathname.new(super()).relative_path_from(Pathname.new(destination_path)).to_s + end + + it "returns true if the destination link exists and is identical" do + expect(action.identical?).to be false + invoke! + expect(action.identical?).to be true + end + end + end + + describe "#revoke!" do + it "removes the symbolic link of non-existent destination" do invoke! - expect(@action.identical?).to be_true + File.delete(tempfile.path) + revoke! + expect(File.symlink?(action.destination)).to be false end end end diff --git a/spec/actions/directory_spec.rb b/spec/actions/directory_spec.rb index f930921e0..ea80e40f0 100644 --- a/spec/actions/directory_spec.rb +++ b/spec/actions/directory_spec.rb @@ -1,48 +1,49 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/actions' +require "tmpdir" +require "helper" +require "thor/actions" describe Thor::Actions::Directory do before do ::FileUtils.rm_rf(destination_root) - invoker.stub!(:file_name).and_return("rdoc") + allow(invoker).to receive(:file_name).and_return("rdoc") 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) - capture(:stdout){ invoker.directory(*args, &block) } + capture(:stdout) { invoker.directory(*args, &block) } end def revoke!(*args, &block) - capture(:stdout){ revoker.directory(*args, &block) } + capture(:stdout) { revoker.directory(*args, &block) } end def exists_and_identical?(source_path, destination_path) %w(config.rb README).each do |file| - source = File.join(source_root, source_path, file) + source = File.join(source_root, source_path, file) destination = File.join(destination_root, destination_path, file) - expect(File.exists?(destination)).to be_true - expect(FileUtils.identical?(source, destination)).to be_true + expect(File.exist?(destination)).to be true + expect(FileUtils.identical?(source, destination)).to be true end end describe "#invoke!" do it "raises an error if the source does not exist" do - expect { + expect do invoke! "unknown" - }.to raise_error(Thor::Error, /Could not find "unknown" in any of your source paths/) + end.to raise_error(Thor::Error, /Could not find "unknown" in any of your source paths/) end it "does not create a directory in pretend mode" do - invoke! "doc", "ghost", :pretend => true - expect(File.exists?("ghost")).to be_false + invoke! "doc", "ghost", pretend: true + expect(File.exist?("ghost")).to be false end it "copies the whole directory recursively to the default destination" do @@ -56,16 +57,29 @@ def exists_and_identical?(source_path, destination_path) end it "copies only the first level files if recursive" do - invoke! ".", "tasks", :recursive => false + invoke! ".", "commands", recursive: false - file = File.join(destination_root, "tasks", "group.thor") - expect(File.exists?(file)).to be_true + file = File.join(destination_root, "commands", "group.thor") + expect(File.exist?(file)).to be true - file = File.join(destination_root, "tasks", "doc") - expect(File.exists?(file)).to be_false + file = File.join(destination_root, "commands", "doc") + expect(File.exist?(file)).to be false - file = File.join(destination_root, "tasks", "doc", "README") - expect(File.exists?(file)).to be_false + file = File.join(destination_root, "commands", "doc", "README") + expect(File.exist?(file)).to be false + end + + it "ignores files within excluding/ directories when exclude_pattern is provided" do + invoke! "doc", "docs", exclude_pattern: %r{excluding/} + file = File.join(destination_root, "docs", "excluding", "rdoc.rb") + expect(File.exist?(file)).to be false + end + + it "copies and evaluates files within excluding/ directory when no exclude_pattern is present" do + invoke! "doc", "docs" + file = File.join(destination_root, "docs", "excluding", "rdoc.rb") + expect(File.exist?(file)).to be true + expect(File.read(file)).to eq("BAR = BAR\n") end it "copies files from the source relative to the current path" do @@ -78,12 +92,12 @@ def exists_and_identical?(source_path, destination_path) it "copies and evaluates templates" do invoke! "doc", "docs" file = File.join(destination_root, "docs", "rdoc.rb") - expect(File.exists?(file)).to be_true + expect(File.exist?(file)).to be true expect(File.read(file)).to eq("FOO = FOO\n") end - it "copies directories and preserved file mode" do - invoke! "preserve", "preserved", :mode => :preserve + it "copies directories and preserves file mode" do + 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) @@ -92,20 +106,20 @@ def exists_and_identical?(source_path, destination_path) it "copies directories" do invoke! "doc", "docs" file = File.join(destination_root, "docs", "components") - expect(File.exists?(file)).to be_true - expect(File.directory?(file)).to be_true + expect(File.exist?(file)).to be true + expect(File.directory?(file)).to be true end it "does not copy .empty_directory files" do invoke! "doc", "docs" file = File.join(destination_root, "docs", "components", ".empty_directory") - expect(File.exists?(file)).to be_false + expect(File.exist?(file)).to be false end it "copies directories even if they are empty" do invoke! "doc/components", "docs/components" file = File.join(destination_root, "docs", "components") - expect(File.exists?(file)).to be_true + expect(File.exist?(file)).to be true end it "does not copy empty directories twice" do @@ -115,10 +129,10 @@ def exists_and_identical?(source_path, destination_path) it "logs status" do content = invoke!("doc") - expect(content).to match(/create doc\/README/) - expect(content).to match(/create doc\/config\.rb/) - expect(content).to match(/create doc\/rdoc\.rb/) - expect(content).to match(/create doc\/components/) + expect(content).to match(%r{create doc/README}) + expect(content).to match(%r{create doc/config\.rb}) + expect(content).to match(%r{create doc/rdoc\.rb}) + expect(content).to match(%r{create doc/components}) end it "yields a block" do @@ -126,12 +140,30 @@ def exists_and_identical?(source_path, destination_path) invoke!("doc") do |content| checked ||= !!(content =~ /FOO/) end - expect(checked).to be_true + expect(checked).to be true end it "works with glob characters in the path" do content = invoke!("app{1}") - expect(content).to match(/create app\{1\}\/README/) + expect(content).to match(%r{create app\{1\}/README}) + end + + context "windows temp directories", if: windows? do + let(:spec_dir) { File.join(@temp_dir, "spec") } + + before(:each) do + @temp_dir = Dir.mktmpdir("thor") + Dir.mkdir(spec_dir) + File.new(File.join(spec_dir, "spec_helper.rb"), "w").close + end + + after(:each) { FileUtils.rm_rf(@temp_dir) } + it "works with windows temp dir" do + invoke! spec_dir, "specs" + file = File.join(destination_root, "specs") + expect(File.exist?(file)).to be true + expect(File.directory?(file)).to be true + end end end @@ -140,17 +172,17 @@ def exists_and_identical?(source_path, destination_path) invoke! "doc" revoke! "doc" - expect(File.exists?(File.join(destination_root, "doc", "README"))).to be_false - expect(File.exists?(File.join(destination_root, "doc", "config.rb"))).to be_false - expect(File.exists?(File.join(destination_root, "doc", "components"))).to be_false + expect(File.exist?(File.join(destination_root, "doc", "README"))).to be false + expect(File.exist?(File.join(destination_root, "doc", "config.rb"))).to be false + expect(File.exist?(File.join(destination_root, "doc", "components"))).to be false end it "works with glob characters in the path" do invoke! "app{1}" - expect(File.exists?(File.join(destination_root, "app{1}", "README"))).to be_true + expect(File.exist?(File.join(destination_root, "app{1}", "README"))).to be true revoke! "app{1}" - expect(File.exists?(File.join(destination_root, "app{1}", "README"))).to be_false + expect(File.exist?(File.join(destination_root, "app{1}", "README"))).to be false end end end diff --git a/spec/actions/empty_directory_spec.rb b/spec/actions/empty_directory_spec.rb index 5791f2926..97fe3d95b 100644 --- a/spec/actions/empty_directory_spec.rb +++ b/spec/actions/empty_directory_spec.rb @@ -1,12 +1,12 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/actions' +require "helper" +require "thor/actions" describe Thor::Actions::EmptyDirectory do before do ::FileUtils.rm_rf(destination_root) end - def empty_directory(destination, options={}) + def empty_directory(destination, options = {}) @action = Thor::Actions::EmptyDirectory.new(base, destination) end @@ -19,33 +19,33 @@ 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 it "returns the full destination with the destination_root" do - expect(empty_directory('doc').destination).to eq(File.join(destination_root, 'doc')) + expect(empty_directory("doc").destination).to eq(File.join(destination_root, "doc")) end it "takes relative root into account" do - base.inside('doc') do - expect(empty_directory('contents').destination).to eq(File.join(destination_root, 'doc', 'contents')) + base.inside("doc") do + expect(empty_directory("contents").destination).to eq(File.join(destination_root, "doc", "contents")) end end end describe "#relative_destination" do it "returns the relative destination to the original destination root" do - base.inside('doc') do - expect(empty_directory('contents').relative_destination).to eq('doc/contents') + base.inside("doc") do + expect(empty_directory("contents").relative_destination).to eq("doc/contents") end end end describe "#given_destination" do it "returns the destination supplied by the user" do - base.inside('doc') do - expect(empty_directory('contents').given_destination).to eq('contents') + base.inside("doc") do + expect(empty_directory("contents").given_destination).to eq("contents") end end end @@ -54,7 +54,7 @@ def base it "copies the file to the specified destination" do empty_directory("doc") invoke! - expect(File.exists?(File.join(destination_root, "doc"))).to be_true + expect(File.exist?(File.join(destination_root, "doc"))).to be true end it "shows created status to the user" do @@ -63,10 +63,10 @@ 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.exists?(File.join(base.destination_root, "ghost"))).to be_false + expect(File.exist?(File.join(base.destination_root, "ghost"))).to be false end describe "when directory exists" do @@ -83,16 +83,16 @@ def base empty_directory("doc") invoke! revoke! - expect(File.exists?(@action.destination)).to be_false + expect(File.exist?(@action.destination)).to be false end end describe "#exists?" do it "returns true if the destination file exists" do empty_directory("doc") - expect(@action.exists?).to be_false + expect(@action.exists?).to be false invoke! - expect(@action.exists?).to be_true + expect(@action.exists?).to be true end end @@ -100,13 +100,23 @@ def base describe "#convert_encoded_instructions" do before do empty_directory("test_dir") - @action.base.stub!(:file_name).and_return("expected") + allow(@action.base).to receive(:file_name).and_return("expected") end it "accepts and executes a 'legal' %\w+% encoded instruction" do expect(@action.send(:convert_encoded_instructions, "%file_name%.txt")).to eq("expected.txt") end + it "accepts and executes a private %\w+% encoded instruction" do + @action.base.extend Module.new { + def private_file_name + "expected" + end + private :private_file_name + } + expect(@action.send(:convert_encoded_instructions, "%private_file_name%.txt")).to eq("expected.txt") + end + it "ignores an 'illegal' %\w+% encoded instruction" do expect(@action.send(:convert_encoded_instructions, "%some_name%.txt")).to eq("%some_name%.txt") end @@ -114,17 +124,6 @@ def base it "ignores incorrectly encoded instruction" do expect(@action.send(:convert_encoded_instructions, "%some.name%.txt")).to eq("%some.name%.txt") end - - it "raises an error if the instruction refers to a private method" do - module PrivExt - private - def private_file_name - "something_hidden" - end - end - @action.base.extend(PrivExt) - expect { @action.send(:convert_encoded_instructions, "%private_file_name%.txt") }.to raise_error Thor::PrivateMethodEncodedError - end end end end diff --git a/spec/actions/file_manipulation_spec.rb b/spec/actions/file_manipulation_spec.rb index 81153c825..0edb51083 100644 --- a/spec/actions/file_manipulation_spec.rb +++ b/spec/actions/file_manipulation_spec.rb @@ -1,10 +1,8 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') - -class Application; end +require "helper" describe Thor::Actions do - def runner(options={}) - @runner ||= MyCounter.new([1], options, { :destination_root => destination_root }) + def runner(options = {}, behavior = :invoke) + @runner ||= MyCounter.new([1], options, destination_root: destination_root, behavior: behavior) end def action(*args, &block) @@ -12,11 +10,11 @@ def action(*args, &block) end def exists_and_identical?(source, destination) - destination = File.join(destination_root, destination) - expect(File.exists?(destination)).to be_true + destination = File.join(destination_root, destination) + expect(File.exist?(destination)).to be true - source = File.join(source_root, source) - expect(FileUtils).to be_identical(source, destination) + source = File.join(source_root, source) + expect(FileUtils).to be_identical(source, destination) end def file @@ -29,36 +27,36 @@ def file describe "#chmod" do it "executes the command given" do - FileUtils.should_receive(:chmod_R).with(0755, file) + expect(FileUtils).to receive(:chmod_R).with(0755, file) action :chmod, "foo", 0755 end - it "does not execute the command if pretending given" do - FileUtils.should_not_receive(:chmod_R) - runner(:pretend => true) + it "does not execute the command if pretending" do + expect(FileUtils).not_to receive(:chmod_R) + runner(pretend: true) action :chmod, "foo", 0755 end it "logs status" do - FileUtils.should_receive(:chmod_R).with(0755, file) + expect(FileUtils).to receive(:chmod_R).with(0755, file) expect(action(:chmod, "foo", 0755)).to eq(" chmod foo\n") end it "does not log status if required" do - FileUtils.should_receive(:chmod_R).with(0755, file) - expect(action(:chmod, "foo", 0755, :verbose => false)).to be_empty + expect(FileUtils).to receive(:chmod_R).with(0755, file) + expect(action(:chmod, "foo", 0755, verbose: false)).to be_empty end end describe "#copy_file" do it "copies file from source to default destination" do - action :copy_file, "task.thor" - exists_and_identical?("task.thor", "task.thor") + action :copy_file, "command.thor" + exists_and_identical?("command.thor", "command.thor") end it "copies file from source to the specified destination" do - action :copy_file, "task.thor", "foo.thor" - exists_and_identical?("task.thor", "foo.thor") + action :copy_file, "command.thor", "foo.thor" + exists_and_identical?("command.thor", "foo.thor") end it "copies file from the source relative to the current path" do @@ -69,33 +67,64 @@ 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) end + 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 + 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) + end + + it "shows the diff when there is a collision and source has utf-8 characters" do + previous_internal = Encoding.default_internal + + silence_warnings do + Encoding.default_internal = Encoding::UTF_8 + end + + destination = File.join(destination_root, "encoding_with_utf8.thor") + FileUtils.mkdir_p(destination_root) + + File.write(destination, "blabla") + + expect(Thor::LineEditor).to receive(:readline).and_return("d", "y") + expect(runner.shell).to receive(:system).with(/diff -u/) + action :copy_file, "encoding_with_utf8.thor" + + exists_and_identical?("encoding_with_utf8.thor", "encoding_with_utf8.thor") + ensure + silence_warnings do + Encoding.default_internal = previous_internal + end + end + it "logs status" do - expect(action(:copy_file, "task.thor")).to eq(" create task.thor\n") + expect(action(:copy_file, "command.thor")).to eq(" create command.thor\n") end it "accepts a block to change output" do - action :copy_file, "task.thor" do |content| + action :copy_file, "command.thor" do |content| "OMG" + content end - expect(File.read(File.join(destination_root, "task.thor"))).to match(/^OMG/) + expect(File.read(File.join(destination_root, "command.thor"))).to match(/^OMG/) end end - describe "#link_file" do + describe "#link_file", unless: windows? do it "links file from source to default destination" do - action :link_file, "task.thor" - exists_and_identical?("task.thor", "task.thor") + action :link_file, "command.thor" + exists_and_identical?("command.thor", "command.thor") end it "links file from source to the specified destination" do - action :link_file, "task.thor", "foo.thor" - exists_and_identical?("task.thor", "foo.thor") + action :link_file, "command.thor", "foo.thor" + exists_and_identical?("command.thor", "foo.thor") end it "links file from the source relative to the current path" do @@ -106,7 +135,7 @@ def file end it "logs status" do - expect(action(:link_file, "task.thor")).to eq(" create task.thor\n") + expect(action(:link_file, "command.thor")).to eq(" create command.thor\n") end end @@ -122,7 +151,7 @@ def file end it "allows the destination to be set as a block result" do - action(:get, "doc/README"){ |c| "docs/README" } + action(:get, "doc/README") { "docs/README" } exists_and_identical?("doc/README", "docs/README") end @@ -138,20 +167,30 @@ def file it "accepts http remote sources" do body = "__start__\nHTTPFILE\n__end__\n" - FakeWeb.register_uri(:get, 'http://example.com/file.txt', :body => body) + 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) end - FakeWeb.clean_registry end it "accepts https remote sources" do body = "__start__\nHTTPSFILE\n__end__\n" - FakeWeb.register_uri(:get, 'https://example.com/file.txt', :body => body) + 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 - FakeWeb.clean_registry end end @@ -172,34 +211,66 @@ def file end it "copies the template to the specified destination" do + runner.instance_variable_set("@klass", "Config") action :template, "doc/config.rb", "doc/configuration.rb" file = File.join(destination_root, "doc/configuration.rb") - expect(File.exists?(file)).to be_true + expect(File.exist?(file)).to be true end - it "converts enconded instructions" do - runner.should_receive(:file_name).and_return("rdoc") + it "converts encoded instructions" do + expect(runner).to receive(:file_name).and_return("rdoc") action :template, "doc/%file_name%.rb.tt" file = File.join(destination_root, "doc/rdoc.rb") - expect(File.exists?(file)).to be_true + expect(File.exist?(file)).to be true + end + + it "accepts filename without .tt for template method" do + expect(runner).to receive(:file_name).and_return("rdoc") + action :template, "doc/%file_name%.rb" + file = File.join(destination_root, "doc/rdoc.rb") + expect(File.exist?(file)).to be true end it "logs status" do + runner.instance_variable_set("@klass", "Config") expect(capture(:stdout) { runner.template("doc/config.rb") }).to eq(" create doc/config.rb\n") end it "accepts a block to change output" do + runner.instance_variable_set("@klass", "Config") action :template, "doc/config.rb" do |content| "OMG" + content end expect(File.read(File.join(destination_root, "doc/config.rb"))).to match(/^OMG/) end + it "accepts a context to use as the binding" do + begin + @klass = "FooBar" + 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) + end + end + it "guesses the destination name when given only a source" do action :template, "doc/config.yaml.tt" file = File.join(destination_root, "doc/config.yaml") - expect(File.exists?(file)).to be_true + expect(File.exist?(file)).to be true + end + + it "has proper ERB stacktraces" do + error = nil + begin + action :template, "template/bad_config.yaml.tt" + rescue => e + error = e + end + + expect(error).to be_a NameError + expect(error.backtrace.to_s).to include("bad_config.yaml.tt:2") end end @@ -215,18 +286,25 @@ def file describe "#remove_file" do it "removes the file given" do action :remove_file, "doc/README" - expect(File.exists?(file)).to be_false + 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.exists?(File.join(destination_root, "doc"))).to be_false + 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.exists?(file)).to be_true + expect(File.exist?(file)).to be true end it "logs status" do @@ -234,33 +312,200 @@ 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 + + describe "#gsub_file!" do + context "with invoke behavior" do + it "replaces the content in the file" do + action :gsub_file!, "doc/README", "__start__", "START" + expect(File.binread(file)).to eq("START\nREADME\n__end__\n") + end + + it "does not replace if pretending" do + runner(pretend: true) + action :gsub_file!, "doc/README", "__start__", "START" + expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") + end + + it "accepts a block" do + action(:gsub_file!, "doc/README", "__start__") { |match| match.gsub("__", "").upcase } + expect(File.binread(file)).to eq("START\nREADME\n__end__\n") + end + + it "logs status" do + expect(action(:gsub_file!, "doc/README", "__start__", "START")).to eq(" gsub doc/README\n") + end + + it "does not log status if required" do + expect(action(:gsub_file!, file, "__", verbose: false) { |match| match * 2 }).to be_empty + end + + it "cares if the file contents did not change" do + expect do + action :gsub_file!, "doc/README", "___start___", "START" + end.to raise_error(Thor::Error, "The content of #{destination_root}/doc/README did not change") + + expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") + end + end + + context "with revoke behavior" do + context "and no force option" do + it "does not replace the content in the file" do + runner({}, :revoke) + action :gsub_file!, "doc/README", "__start__", "START" + 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" + expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") + end + + it "does not replace the content in the file when given a block" do + runner({}, :revoke) + action(:gsub_file!, "doc/README", "__start__") { |match| match.gsub("__", "").upcase } + expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") + end + + it "does not log status" do + runner({}, :revoke) + expect(action(:gsub_file!, "doc/README", "__start__", "START")).to be_empty + end + + it "does not log status if required" do + runner({}, :revoke) + 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 + 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 + 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 } + 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") + 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 + end + end end end describe "#gsub_file" do - it "replaces the content in the file" do - action :gsub_file, "doc/README", "__start__", "START" - expect(File.binread(file)).to eq("START\nREADME\n__end__\n") - end - - it "does not replace if pretending" do - runner(:pretend => true) - action :gsub_file, "doc/README", "__start__", "START" - expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") - end - - it "accepts a block" do - action(:gsub_file, "doc/README", "__start__"){ |match| match.gsub('__', '').upcase } - expect(File.binread(file)).to eq("START\nREADME\n__end__\n") - end - - it "logs status" do - expect(action(:gsub_file, "doc/README", "__start__", "START")).to eq(" gsub doc/README\n") - end - - it "does not log status if required" do - expect(action(:gsub_file, file, "__", :verbose => false){ |match| match * 2 }).to be_empty + context "with invoke behavior" do + it "replaces the content in the file" do + action :gsub_file, "doc/README", "__start__", "START" + expect(File.binread(file)).to eq("START\nREADME\n__end__\n") + end + + it "does not replace if pretending" do + runner(pretend: true) + action :gsub_file, "doc/README", "__start__", "START" + expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") + end + + it "accepts a block" do + action(:gsub_file, "doc/README", "__start__") { |match| match.gsub("__", "").upcase } + expect(File.binread(file)).to eq("START\nREADME\n__end__\n") + end + + it "logs status" do + expect(action(:gsub_file, "doc/README", "__start__", "START")).to eq(" gsub doc/README\n") + end + + it "does not log status if required" do + expect(action(:gsub_file, file, "__", verbose: false) { |match| match * 2 }).to be_empty + end + + it "does not care if the file contents did not change" do + action :gsub_file, "doc/README", "___start___", "START" + expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") + end + end + + context "with revoke behavior" do + context "and no force option" do + it "does not replace the content in the file" do + runner({}, :revoke) + action :gsub_file, "doc/README", "__start__", "START" + 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" + expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") + end + + it "does not replace the content in the file when given a block" do + runner({}, :revoke) + action(:gsub_file, "doc/README", "__start__") { |match| match.gsub("__", "").upcase } + expect(File.binread(file)).to eq("__start__\nREADME\n__end__\n") + end + + it "does not log status" do + runner({}, :revoke) + expect(action(:gsub_file, "doc/README", "__start__", "START")).to be_empty + end + + it "does not log status if required" do + runner({}, :revoke) + 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 + 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 + 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 } + 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") + 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 + end + end end end @@ -271,7 +516,7 @@ def file end it "accepts a block" do - action(:append_to_file, "doc/README"){ "END\n" } + action(:append_to_file, "doc/README") { "END\n" } expect(File.binread(file)).to eq("__start__\nREADME\n__end__\nEND\n") end @@ -287,7 +532,7 @@ def file end it "accepts a block" do - action(:prepend_to_file, "doc/README"){ "START\n" } + action(:prepend_to_file, "doc/README") { "START\n" } expect(File.binread(file)).to eq("START\n__start__\nREADME\n__end__\n") end @@ -302,17 +547,17 @@ def file end it "appends content to a class" do - action :inject_into_class, "application.rb", Application, " filter_parameters :password\n" + action :inject_into_class, "application.rb", "Application", " filter_parameters :password\n" expect(File.binread(file)).to eq("class Application < Base\n filter_parameters :password\nend\n") end it "accepts a block" do - action(:inject_into_class, "application.rb", Application){ " filter_parameters :password\n" } + action(:inject_into_class, "application.rb", "Application") { " filter_parameters :password\n" } expect(File.binread(file)).to eq("class Application < Base\n filter_parameters :password\nend\n") end it "logs status" do - expect(action(:inject_into_class, "application.rb", Application, " filter_parameters :password\n")).to eq(" insert application.rb\n") + expect(action(:inject_into_class, "application.rb", "Application", " filter_parameters :password\n")).to eq(" insert application.rb\n") end it "does not append if class name does not match" do @@ -320,6 +565,31 @@ def file expect(File.binread(file)).to eq("class Application < Base\nend\n") end end + + describe "#inject_into_module" do + def file + File.join(destination_root, "application_helper.rb") + end + + it "appends content to a module" do + action :inject_into_module, "application_helper.rb", "ApplicationHelper", " def help; 'help'; end\n" + expect(File.binread(file)).to eq("module ApplicationHelper\n def help; 'help'; end\nend\n") + end + + it "accepts a block" do + action(:inject_into_module, "application_helper.rb", "ApplicationHelper") { " def help; 'help'; end\n" } + expect(File.binread(file)).to eq("module ApplicationHelper\n def help; 'help'; end\nend\n") + end + + it "logs status" do + expect(action(:inject_into_module, "application_helper.rb", "ApplicationHelper", " def help; 'help'; end\n")).to eq(" insert application_helper.rb\n") + end + + it "does not append if module name does not match" do + action :inject_into_module, "application_helper.rb", "App", " def help; 'help'; end\n" + expect(File.binread(file)).to eq("module ApplicationHelper\nend\n") + end + end end describe "when adjusting comments" do @@ -331,20 +601,26 @@ def file File.join(destination_root, "doc", "COMMENTER") end - unmodified_comments_file = /__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\norange\n purple\n ind#igo\n # ind#igo\n__end__/ + unmodified_comments_file = /__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\norange\n purple\n ind#igo\n # ind#igo\n # spaces_between\n__end__/ describe "#uncomment_lines" do it "uncomments all matching lines in the file" do action :uncomment_lines, "doc/COMMENTER", "green" - expect(File.binread(file)).to match(/__start__\n greenblue\n#\n# yellowblue\n#yellowred\n greenred\norange\n purple\n ind#igo\n # ind#igo\n__end__/) + expect(File.binread(file)).to match(/__start__\n greenblue\n#\n# yellowblue\n#yellowred\n greenred\norange\n purple\n ind#igo\n # ind#igo\n # spaces_between\n__end__/) action :uncomment_lines, "doc/COMMENTER", "red" - expect(File.binread(file)).to match(/__start__\n greenblue\n#\n# yellowblue\nyellowred\n greenred\norange\n purple\n ind#igo\n # ind#igo\n__end__/) + expect(File.binread(file)).to match(/__start__\n greenblue\n#\n# yellowblue\nyellowred\n greenred\norange\n purple\n ind#igo\n # ind#igo\n # spaces_between\n__end__/) end it "correctly uncomments lines with hashes in them" do action :uncomment_lines, "doc/COMMENTER", "ind#igo" - expect(File.binread(file)).to match(/__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\norange\n purple\n ind#igo\n ind#igo\n__end__/) + expect(File.binread(file)).to match(/__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\norange\n purple\n ind#igo\n ind#igo\n # spaces_between\n__end__/) + end + + it "will leave the space which existed before the comment hash in tact" do + action :uncomment_lines, "doc/COMMENTER", "ind#igo" + action :uncomment_lines, "doc/COMMENTER", "spaces_between" + expect(File.binread(file)).to match(/__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\norange\n purple\n ind#igo\n ind#igo\n spaces_between\n__end__/) end it "does not modify already uncommented lines in the file" do @@ -355,22 +631,22 @@ def file it "does not uncomment the wrong line when uncommenting lines preceded by blank commented line" do action :uncomment_lines, "doc/COMMENTER", "yellow" - expect(File.binread(file)).to match(/__start__\n # greenblue\n#\nyellowblue\nyellowred\n #greenred\norange\n purple\n ind#igo\n # ind#igo\n__end__/) + expect(File.binread(file)).to match(/__start__\n # greenblue\n#\nyellowblue\nyellowred\n #greenred\norange\n purple\n ind#igo\n # ind#igo\n # spaces_between\n__end__/) end end describe "#comment_lines" do it "comments lines which are not commented" do action :comment_lines, "doc/COMMENTER", "orange" - expect(File.binread(file)).to match(/__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\n# orange\n purple\n ind#igo\n # ind#igo\n__end__/) + expect(File.binread(file)).to match(/__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\n# orange\n purple\n ind#igo\n # ind#igo\n # spaces_between\n__end__/) action :comment_lines, "doc/COMMENTER", "purple" - expect(File.binread(file)).to match(/__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\n# orange\n # purple\n ind#igo\n # ind#igo\n__end__/) + expect(File.binread(file)).to match(/__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\n# orange\n # purple\n ind#igo\n # ind#igo\n # spaces_between\n__end__/) end it "correctly comments lines with hashes in them" do action :comment_lines, "doc/COMMENTER", "ind#igo" - expect(File.binread(file)).to match(/__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\norange\n purple\n # ind#igo\n # ind#igo\n__end__/) + expect(File.binread(file)).to match(/__start__\n # greenblue\n#\n# yellowblue\n#yellowred\n #greenred\norange\n purple\n # ind#igo\n # ind#igo\n # spaces_between\n__end__/) end it "does not modify already commented lines" do diff --git a/spec/actions/inject_into_file_spec.rb b/spec/actions/inject_into_file_spec.rb index 686383106..6ba6dcd6e 100644 --- a/spec/actions/inject_into_file_spec.rb +++ b/spec/actions/inject_into_file_spec.rb @@ -1,5 +1,6 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/actions' +# encoding: utf-8 +require "helper" +require "thor/actions" describe Thor::Actions::InjectIntoFile do before do @@ -7,12 +8,12 @@ ::FileUtils.cp_r(source_root, destination_root) end - def invoker(options={}) - @invoker ||= MyCounter.new([1,2], options, { :destination_root => destination_root }) + def invoker(options = {}) + @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) @@ -29,17 +30,35 @@ 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 "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 "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") + ) + end + it "accepts data as a block" do - invoke! "doc/README", :before => "__end__" do + invoke! "doc/README", before: "__end__" do "more content\n" end @@ -47,89 +66,156 @@ 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 include content" do - invoke! "doc/README", :before => "__end__" do + it "does not change the file if already includes content" 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 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", :before => "__end__" 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") end - it "does change the file if already include content and :force == true" do - invoke! "doc/README", :before => "__end__" do + 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 + "any content" + end + end.to raise_error(Thor::Error, /does not appear to exist/) + expect(File.exist?("idontexist")).to be_falsey + end + + 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 + "any content" + end + end.not_to raise_error + expect(File.exist?("idontexist")).to be_falsey + end + + it "does change the file if already includes content and :force is true" 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 expect(File.read(file)).to eq("__start__\nREADME\nmore content\nmore content\n__end__\n") end + it "can insert chinese" do + encoding_original = Encoding.default_external + + begin + 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 + silence_warnings do + Encoding.default_external = encoding_original + end + end + end end describe "#revoke!" do - it "substracts the destination file after injection" do - invoke! "doc/README", "\nmore content", :after => "__start__" - revoke! "doc/README", "\nmore content", :after => "__start__" + it "subtracts the destination file after injection" do + 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 "substracts the destination file before injection" do - invoke! "doc/README", "more content\n", :before => "__start__" - revoke! "doc/README", "more content\n", :before => "__start__" + it "subtracts the destination file before injection" do + 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 "substracts 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__" + 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__" expect(File.read(file)).to eq("__start__\nanother stuff\nREADME\n__end__\n") end - it "substracts 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__" + 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__" expect(File.read(file)).to eq("another stuff\n__start__\nREADME\n__end__\n") end - it "substracts 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/ + 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/ expect(File.read(file)).to eq("another stuff\n__start__\nREADME\n__end__\n") end - it "substracts 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/ + 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/ 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 52df513f8..f34de63a3 100644 --- a/spec/actions_spec.rb +++ b/spec/actions_spec.rb @@ -1,8 +1,8 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') +require "helper" describe Thor::Actions do - def runner(options={}) - @runner ||= MyCounter.new([1], options, { :destination_root => destination_root }) + def runner(options = {}) + @runner ||= MyCounter.new([1], options, destination_root: destination_root) end def action(*args, &block) @@ -28,21 +28,21 @@ 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 + 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], ["--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 + expect(runner.options.force).not_to be true + expect(runner.options.skip).to be true end end @@ -83,11 +83,26 @@ def file end it "creates proper relative paths for absolute file location" do - expect(runner.relative_to_original_destination_root('/test/file')).to eq("/test/file") + expect(runner.relative_to_original_destination_root("/test/file")).to eq("/test/file") end - it "does not fail with files constaining regexp characters" do - runner = MyCounter.new([1], {}, { :destination_root => File.join(destination_root, "fo[o-b]ar") }) + 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 + + it "doesn't removes the root path from the absolute path only if it is only the partial name of the directory" do + runner.destination_root = "/app" + expect(runner.relative_to_original_destination_root("/application/project")).to eq("/application/project") + end + + it "removes the root path from the absolute path only once" do + runner.destination_root = "/app" + expect(runner.relative_to_original_destination_root("/app/app/project")).to eq("app/project") + 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")) expect(runner.relative_to_original_destination_root("bar")).to eq("bar") end @@ -114,14 +129,14 @@ def file describe "#find_in_source_paths" do it "raises an error if source path is empty" do - expect { + expect do A.new.find_in_source_paths("foo") - }.to raise_error(Thor::Error, /Currently you have no source paths/) + end.to raise_error(Thor::Error, /Currently you have no source paths/) end it "finds a template inside the source path" do expect(runner.find_in_source_paths("doc")).to eq(File.expand_path("doc", source_root)) - expect{ runner.find_in_source_paths("README") }.to raise_error + expect { runner.find_in_source_paths("README") }.to raise_error(Thor::Error, /Could not find "README" in any of your source paths./) new_path = File.join(source_root, "doc") runner.instance_variable_set(:@source_paths, nil) @@ -146,37 +161,45 @@ def file it "creates the directory if it does not exist" do runner.inside("foo") do - expect(File.exists?(file)).to be_true + expect(File.exist?(file)).to be true 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) {} - expect(File.exists?("bar")).to be_false + 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) { - runner.inside("foo", :verbose => true) {} - }).to match(/inside foo/) + expect(capture(:stdout) do + runner.inside("foo", verbose: true) {} + end).to match(/inside foo/) end it "uses padding in next status" do - expect(capture(:stdout) { - runner.inside("foo", :verbose => true) do + expect(capture(:stdout) do + runner.inside("foo", verbose: true) do runner.say_status :cool, :padding end - }).to match(/cool padding/) + end).to match(/cool padding/) end it "removes padding after block" do - expect(capture(:stdout) { - runner.inside("foo", :verbose => true) {} + expect(capture(:stdout) do + runner.inside("foo", verbose: true) {} runner.say_status :no, :padding - }).to match(/no padding/) + end).to match(/no padding/) end end end @@ -196,7 +219,7 @@ def file it "returns to the previous state" do runner.inside("foo") do - runner.in_root { } + runner.in_root {} expect(runner.destination_root).to eq(file) end end @@ -204,31 +227,35 @@ def file describe "#apply" do before do - @template = <<-TEMPLATE + @template = <<-TEMPLATE.dup @foo = "FOO" say_status :cool, :padding TEMPLATE - @template.stub(:read).and_return(@template) + allow(@template).to receive(:read).and_return(@template) - @file = '/' - runner.stub(:open).and_return(@template) + @file = "/" + allow(File).to receive(:open).and_return(@template) end it "accepts a URL as the path" do @file = "http://gist.github.com/103208.txt" - runner.should_receive(:open).with(@file, "Accept" => "application/x-thor-template").and_return(@template) + stub_request(:get, @file) + + expect(runner).to receive(:apply).with(@file).and_return(@template) action(:apply, @file) end it "accepts a secure URL as the path" do @file = "https://gist.github.com/103208.txt" - runner.should_receive(:open).with(@file, "Accept" => "application/x-thor-template").and_return(@template) + stub_request(:get, @file) + + expect(runner).to receive(:apply).with(@file).and_return(@template) action(:apply, @file) end it "accepts a local file path with spaces" do @file = File.expand_path("fixtures/path with spaces", File.dirname(__FILE__)) - runner.should_receive(:open).with(@file).and_return(@template) + expect(File).to receive(:open).with(@file).and_return(@template) action(:apply, @file) end @@ -246,39 +273,93 @@ 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 end describe "#run" do - before do - runner.should_receive(:system).with("ls") + describe "when not pretending" do + before do + expect(runner).to receive(:system).with("ls") + end + + it "executes the command given" do + action :run, "ls" + end + + it "logs status" do + expect(action(:run, "ls")).to eq(" run ls from \".\"\n") + end + + it "does not log status if required" do + 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 + end end - it "executes the command given" do - action :run, "ls" + describe "when pretending" do + it "doesn't execute the command" do + runner = MyCounter.new([1], %w(--pretend)) + expect(runner).not_to receive(:system) + runner.run("ls", verbose: false) + end end - it "logs status" do - expect(action(:run, "ls")).to eq(" run ls from \".\"\n") + 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) + 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 + end + + it "supports env option" do + expect { action :run, "echo $BAR", env: {"BAR" => "foo"} }.to output("foo\n").to_stdout_from_any_process + end end - it "does not log status if required" do - expect(action(:run, "ls", :verbose => false)).to be_empty + 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) + 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 + end + + it "supports env option" do + silence(:stdout) do + expect(runner.run "echo $BAR", env: {"BAR" => "foo"}, capture: true).to eq("foo\n") + end + end end - it "accepts a color as status" do - runner.shell.should_receive(:say_status).with(:run, 'ls from "."', :yellow) - action :run, "ls", :verbose => :yellow + context "exit_on_failure? is true" do + before do + allow(MyCounter).to receive(:exit_on_failure?).and_return(true) + end + + it "aborts when command fails even if abort_on_failure is not given" do + expect { action :run, "false" }.to raise_error(SystemExit) + 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 + end end end describe "#run_ruby_script" do before do - Thor::Util.stub!(:ruby_command).and_return("/opt/jruby") - runner.should_receive(:system).with("/opt/jruby script.rb") + allow(Thor::Util).to receive(:ruby_command).and_return("/opt/jruby") + expect(runner).to receive(:system).with("/opt/jruby script.rb") end it "executes the ruby script" do @@ -290,42 +371,42 @@ 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 - runner.should_receive(:system).with("thor list") - action :thor, :list, :verbose => true + expect(runner).to receive(:system).with("thor list") + action :thor, :list, verbose: true end it "converts extra arguments to command arguments" do - runner.should_receive(:system).with("thor list foo bar") + expect(runner).to receive(:system).with("thor list foo bar") action :thor, :list, "foo", "bar" end it "converts options hash to switches" do - runner.should_receive(:system).with("thor list foo bar --foo") - action :thor, :list, "foo", "bar", :foo => true + expect(runner).to receive(:system).with("thor list foo bar --foo") + action :thor, :list, "foo", "bar", foo: true - runner.should_receive(:system).with("thor list --foo 1 2 3") - action :thor, :list, :foo => [1,2,3] + expect(runner).to receive(:system).with("thor list --foo 1 2 3") + action :thor, :list, foo: [1, 2, 3] end it "logs status" do - runner.should_receive(:system).with("thor list") + expect(runner).to receive(:system).with("thor list") expect(action(:thor, :list)).to eq(" run thor list from \".\"\n") end it "does not log status if required" do - runner.should_receive(:system).with("thor list --foo 1 2 3") - expect(action(:thor, :list, :foo => [1,2,3], :verbose => false)).to be_empty + 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 end it "captures the output when :capture is given" do - runner.should_receive(:`).with("thor foo bar") - action(:thor, "foo", "bar", :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 8b9965f17..2208bce97 100644 --- a/spec/base_spec.rb +++ b/spec/base_spec.rb @@ -1,5 +1,5 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') -require 'thor/base' +require "helper" +require "thor/base" class Amazing desc "hello", "say hello" @@ -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,47 +35,108 @@ def hello end it "creates options with indifferent access" do - base = MyCounter.new [1, 2], :third => 3 - expect(base.options['third']).to eq(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 - describe "#no_tasks" do - it "avoids methods being added as tasks" do - expect(MyScript.tasks.keys).to include("animal") - expect(MyScript.tasks.keys).not_to include("this_is_not_a_task") + describe "#no_commands" do + it "avoids methods being added as commands" do + expect(MyScript.commands.keys).to include("animal") + expect(MyScript.commands.keys).not_to include("this_is_not_a_command") + expect(MyScript.commands.keys).not_to include("neither_is_this") end end describe "#argument" do it "sets a value as required and creates an accessor for it" do - expect(MyCounter.start(["1", "2", "--third", "3"])[0]).to eq(1) - expect(Scripts::MyScript.start(["zoo", "my_special_param", "--param=normal_param"])).to eq("my_special_param") + expect(MyCounter.start(%w(1 2 --third 3))[0]).to eq(1) + expect(Scripts::MyScript.start(%w(zoo my_special_param --param=normal_param))).to eq("my_special_param") end it "does not set a value in the options hash" do - expect(BrokenCounter.start(["1", "2", "--third", "3"])[0]).to be_nil + expect(BrokenCounter.start(%w(1 2 --third 3))[0]).to be nil end end describe "#arguments" do it "returns the arguments for the class" do - expect(MyCounter.arguments).to have(2).items + expect(MyCounter.arguments.size).to be(2) + 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) + end + + it "supports symbol aliases" do + expect(MyCounter.start(%w(1 2 -y 3))[5]).to eq(3) + expect(MyCounter.start(%w(1 2 -r 3))[5]).to eq(3) end end describe "#class_option" do it "sets options class wise" do - expect(MyCounter.start(["1", "2", "--third", "3"])[2]).to eq(3) + expect(MyCounter.start(%w(1 2 --third 3))[2]).to eq(3) end it "does not create an accessor for it" do - expect(BrokenCounter.start(["1", "2", "--third", "3"])[3]).to be_false + expect(BrokenCounter.start(%w(1 2 --third 3))[3]).to be false end end @@ -87,7 +148,7 @@ def hello end describe "#remove_argument" do - it "removes previous defined arguments from class" do + it "removes previously defined arguments from class" do expect(ClearCounter.arguments).to be_empty end @@ -99,7 +160,7 @@ def hello describe "#remove_class_option" do it "removes previous defined class option" do - expect(ClearCounter.class_options[:third]).to be_nil + expect(ClearCounter.class_options[:third]).to be nil end end @@ -108,7 +169,7 @@ def hello @content = capture(:stdout) { MyCounter.help(Thor::Base.shell.new) } end - it "shows options description" do + it "shows option's description" do expect(@content).to match(/# The third argument/) end @@ -116,23 +177,28 @@ def hello expect(@content).to match(/\[\-\-third=THREE\]/) end - it "shows default values below description" do + it "shows default values below descriptions" do 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\:/) expect(@content).to match(/\-p, \[\-\-pretend\]/) end - it "use padding in options that does not have aliases" do - expect(@content).to match(/^ -t, \[--third/) - expect(@content).to match(/^ \[--fourth/) + it "use padding in options that do not have aliases" do + 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 - hash = { "Foo" => B.class_options.values } + hash = {"Foo" => B.class_options.values} content = capture(:stdout) { MyCounter.send(:class_options_help, Thor::Base.shell.new, hash) } expect(content).to match(/Foo options\:/) @@ -182,42 +248,43 @@ def hello thorfile = File.join(File.dirname(__FILE__), "fixtures", "script.thor") expect(Thor::Base.subclass_files[File.expand_path(thorfile)]).to eq([ MyScript, MyScript::AnotherScript, MyChildScript, Barn, - Scripts::MyScript, Scripts::MyDefaults, Scripts::ChildDefault + PackageNameScript, Scripts::MyScript, Scripts::MyDefaults, + Scripts::ChildDefault, Scripts::Arities, Apple, Pear, MyClassOptionScript, MyOptionScript ]) end it "tracks a single subclass across multiple files" do - thorfile = File.join(File.dirname(__FILE__), "fixtures", "task.thor") + thorfile = File.join(File.dirname(__FILE__), "fixtures", "command.thor") expect(Thor::Base.subclass_files[File.expand_path(thorfile)]).to include(Amazing) expect(Thor::Base.subclass_files[File.expand_path(__FILE__)]).to include(Amazing) end end - describe "#tasks" do - it "returns a list with all tasks defined in this class" do + describe "#commands" do + it "returns a list with all commands defined in this class" do expect(MyChildScript.new).to respond_to("animal") - expect(MyChildScript.tasks.keys).to include("animal") + expect(MyChildScript.commands.keys).to include("animal") end - it "raises an error if a task with reserved word is defined" do - expect { + it "raises an error if a command with reserved word is defined" do + expect do klass = Class.new(Thor::Group) klass.class_eval "def shell; end" - }.to raise_error(RuntimeError, /"shell" is a Thor reserved word and cannot be defined as task/) + end.to raise_error(RuntimeError, /"shell" is a Thor reserved word and cannot be defined as command/) end end - describe "#all_tasks" do - it "returns a list with all tasks defined in this class plus superclasses" do + describe "#all_commands" do + it "returns a list with all commands defined in this class plus superclasses" do expect(MyChildScript.new).to respond_to("foo") - expect(MyChildScript.all_tasks.keys).to include("foo") + expect(MyChildScript.all_commands.keys).to include("foo") end end - describe "#remove_task" do - it "removes the task from its tasks hash" do - expect(MyChildScript.tasks.keys).not_to include("bar") - expect(MyChildScript.tasks.keys).not_to include("boom") + describe "#remove_command" do + it "removes the command from its commands hash" do + expect(MyChildScript.all_commands.keys).not_to include("name_with_dashes") + expect(MyChildScript.commands.keys).not_to include("boom") end it "undefines the method if desired" do @@ -232,48 +299,62 @@ def hello end describe "#start" do - it "raises an error instead of rescueing if THOR_DEBUG=1 is given" do + it "raises an error instead of rescuing if THOR_DEBUG=1 is given" do begin - ENV["THOR_DEBUG"] = 1 - expect { - MyScript.start ["what", "--debug"] - }.to raise_error(Thor::UndefinedTaskError, 'Could not find task "what" in "my_script" namespace.') - rescue + ENV["THOR_DEBUG"] = "1" + + expect do + MyScript.start %w(what --debug) + end.to raise_error(Thor::UndefinedCommandError, 'Could not find command "what" in "my_script" namespace.') + ensure ENV["THOR_DEBUG"] = nil end end + it "raises an error instead of rescuing if :debug option is given" do + expect do + MyScript.start %w(what), debug: true + end.to raise_error(Thor::UndefinedCommandError, 'Could not find command "what" in "my_script" namespace.') + end + + it "suggests commands that are similar if there is a typo" do + expected = "Could not find command \"paintz\" in \"barn\" namespace.\n".dup + expected << "Did you mean? \"paint\"\n" if Thor::Correctable + + expect(capture(:stderr) { Barn.start(%w(paintz)) }).to eq(expected) + end + it "does not steal args" do - args = ["foo", "bar", "--force", "true"] + args = %w(foo bar --force true) MyScript.start(args) - expect(args).to eq(["foo", "bar", "--force", "true"]) + expect(args).to eq(%w(foo bar --force true)) end it "checks unknown options" do - expect(capture(:stderr) { - MyScript.start(["foo", "bar", "--force", "true", "--unknown", "baz"]) - }.strip).to eq("Unknown switches '--unknown'") + expect(capture(:stderr) do + MyScript.start(%w(foo bar --force true --unknown baz)) + end.strip).to eq("Unknown switches \"--unknown\"") end it "checks unknown options except specified" do - expect(capture(:stderr) { - expect(MyScript.start(["with_optional", "NAME", "--omg", "--invalid"])).to eq(["NAME", {}, ["--omg", "--invalid"]]) - }.strip).to be_empty + expect(capture(:stderr) do + expect(MyScript.start(%w(with_optional NAME --omg --invalid))).to eq(["NAME", {}, %w(--omg --invalid)]) + end.strip).to be_empty end end describe "attr_*" do - it "does not add attr_reader as a task" do - expect(capture(:stderr){ MyScript.start(["another_attribute"]) }).to match(/Could not find/) + it "does not add attr_reader as a command" do + expect(capture(:stderr) { MyScript.start(%w(another_attribute)) }).to match(/Could not find/) end - it "does not add attr_writer as a task" do - expect(capture(:stderr){ MyScript.start(["another_attribute=", "foo"]) }).to match(/Could not find/) + it "does not add attr_writer as a command" do + expect(capture(:stderr) { MyScript.start(%w(another_attribute= foo)) }).to match(/Could not find/) end - it "does not add attr_accessor as a task" do - expect(capture(:stderr){ MyScript.start(["some_attribute"]) }).to match(/Could not find/) - expect(capture(:stderr){ MyScript.start(["some_attribute=", "foo"]) }).to match(/Could not find/) + it "does not add attr_accessor as a command" do + expect(capture(:stderr) { MyScript.start(["some_attribute"]) }).to match(/Could not find/) + expect(capture(:stderr) { MyScript.start(["some_attribute=", "foo"]) }).to match(/Could not find/) end end end diff --git a/spec/command_spec.rb b/spec/command_spec.rb new file mode 100644 index 000000000..5efdb3923 --- /dev/null +++ b/spec/command_spec.rb @@ -0,0 +1,85 @@ +require "helper" + +describe Thor::Command do + def command(options = {}, usage = "can_has") + options.each do |key, value| + 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", 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") + 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") + 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") + end + + it "injects arguments into usage" do + 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") + 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") + end + end + + describe "#dynamic" do + it "creates a dynamic command with the given name" do + expect(Thor::DynamicCommand.new("command").name).to eq("command") + expect(Thor::DynamicCommand.new("command").description).to eq("A dynamically-generated command") + expect(Thor::DynamicCommand.new("command").usage).to eq("command") + expect(Thor::DynamicCommand.new("command").options).to eq({}) + end + + it "does not invoke an existing method" do + dub = double + expect(dub.class).to receive(:handle_no_command_error).with("to_s") + Thor::DynamicCommand.new("to_s").run(dub) + end + end + + describe "#dup" do + it "dup options hash" do + 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 + end + + describe "#run" do + it "runs a command by calling a method in the given instance" do + dub = double + expect(dub).to receive(:can_has) { |*args| args } + expect(command.run(dub, [1, 2, 3])).to eq([1, 2, 3]) + end + + it "raises an error if the method to be invoked is private" do + klass = Class.new do + def self.handle_no_command_error(name) + name + end + + def can_has + "fail" + end + private :can_has + end + + expect(command.run(klass.new)).to eq("can_has") + end + end +end diff --git a/spec/core_ext/hash_with_indifferent_access_spec.rb b/spec/core_ext/hash_with_indifferent_access_spec.rb index 8418e788f..69357a2da 100644 --- a/spec/core_ext/hash_with_indifferent_access_spec.rb +++ b/spec/core_ext/hash_with_indifferent_access_spec.rb @@ -1,48 +1,114 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/core_ext/hash_with_indifferent_access' +require "helper" +require "thor/core_ext/hash_with_indifferent_access" describe Thor::CoreExt::HashWithIndifferentAccess do before do - @hash = Thor::CoreExt::HashWithIndifferentAccess.new :foo => 'bar', 'baz' => 'bee', :force => true + @hash = Thor::CoreExt::HashWithIndifferentAccess.new :foo => "bar", "baz" => "bee", :force => true end it "has values accessible by either strings or symbols" do - expect(@hash['foo']).to eq('bar') - expect(@hash[:foo]).to eq('bar') + expect(@hash["foo"]).to eq("bar") + expect(@hash[:foo]).to eq("bar") - expect(@hash.values_at(:foo, :baz)).to eq(['bar', 'bee']) - expect(@hash.delete(:foo)).to eq('bar') + expect(@hash.values_at(:foo, :baz)).to eq(%w(bar bee)) + expect(@hash.delete(:foo)).to eq("bar") + end + + it "supports except" do + unexcepted_hash = @hash.dup + @hash.except("foo") + expect(@hash).to eq(unexcepted_hash) + + expect(@hash.except("foo")).to eq("baz" => "bee", "force" => true) + expect(@hash.except("foo", "baz")).to eq("force" => true) + expect(@hash.except(:foo)).to eq("baz" => "bee", "force" => true) + expect(@hash.except(:foo, :baz)).to eq("force" => true) + end + + it "supports fetch" do + expect(@hash.fetch("foo")).to eq("bar") + expect(@hash.fetch("foo", nil)).to eq("bar") + expect(@hash.fetch(:foo)).to eq("bar") + expect(@hash.fetch(:foo, nil)).to eq("bar") + + expect(@hash.fetch("baz")).to eq("bee") + expect(@hash.fetch("baz", nil)).to eq("bee") + expect(@hash.fetch(:baz)).to eq("bee") + expect(@hash.fetch(:baz, nil)).to eq("bee") + + expect { @hash.fetch(:missing) }.to raise_error(IndexError) + 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 + expect(@hash.key?("nothing")).to be false + expect(@hash.key?(:nothing)).to be false end it "handles magic boolean predicates" do - expect(@hash.force?).to be_true - expect(@hash.foo?).to be_true - expect(@hash.nothing?).to be_false + expect(@hash.force?).to be true + expect(@hash.foo?).to be true + expect(@hash.nothing?).to be false end - it "handles magic comparisions" do - expect(@hash.foo?('bar')).to be_true - expect(@hash.foo?('bee')).to be_false + it "handles magic comparisons" do + expect(@hash.foo?("bar")).to be true + expect(@hash.foo?("bee")).to be false end it "maps methods to keys" do - expect(@hash.foo).to eq(@hash['foo']) + expect(@hash.foo).to eq(@hash["foo"]) end it "merges keys independent if they are symbols or strings" do - @hash.merge!('force' => false, :baz => "boom") + @hash["force"] = false + @hash[:baz] = "boom" expect(@hash[:force]).to eq(false) - expect(@hash[:baz]).to eq("boom") + expect(@hash["baz"]).to eq("boom") end it "creates a new hash by merging keys independent if they are symbols or strings" do - other = @hash.merge('force' => false, :baz => "boom") + other = @hash.merge("force" => false, :baz => "boom") expect(other[:force]).to eq(false) - expect(other[:baz]).to eq("boom") + expect(other["baz"]).to eq("boom") end it "converts to a traditional hash" do expect(@hash.to_hash.class).to eq(Hash) - expect(@hash).to eq({ 'foo' => 'bar', 'baz' => 'bee', 'force' => true }) + expect(@hash).to eq("foo" => "bar", "baz" => "bee", "force" => true) + end + + it "handles reverse_merge" do + other = {:foo => "qux", "boo" => "bae"} + new_hash = @hash.reverse_merge(other) + + expect(@hash.object_id).not_to eq(new_hash.object_id) + expect(new_hash[:foo]).to eq("bar") + expect(new_hash[:boo]).to eq("bae") + end + + it "handles reverse_merge!" do + other = {:foo => "qux", "boo" => "bae"} + new_hash = @hash.reverse_merge!(other) + + expect(@hash.object_id).to eq(new_hash.object_id) + expect(new_hash[:foo]).to eq("bar") + expect(new_hash[:boo]).to eq("bae") end end diff --git a/spec/core_ext/ordered_hash_spec.rb b/spec/core_ext/ordered_hash_spec.rb deleted file mode 100644 index 4e57f0a98..000000000 --- a/spec/core_ext/ordered_hash_spec.rb +++ /dev/null @@ -1,115 +0,0 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/core_ext/ordered_hash' - -describe Thor::CoreExt::OrderedHash do - before do - @hash = Thor::CoreExt::OrderedHash.new - end - - describe "without any items" do - it "returns nil for an undefined key" do - expect(@hash["foo"]).to be_nil - end - - it "doesn't iterate through any items" do - @hash.each { fail } - end - - it "has an empty key and values list" do - expect(@hash.keys).to be_empty - expect(@hash.values).to be_empty - end - - it "must be empty" do - expect(@hash).to be_empty - end - end - - describe "with several items" do - before do - @hash[:foo] = "Foo!" - @hash[:bar] = "Bar!" - @hash[:baz] = "Baz!" - @hash[:bop] = "Bop!" - @hash[:bat] = "Bat!" - end - - it "returns nil for an undefined key" do - expect(@hash[:boom]).to be_nil - end - - it "returns the value for each key" do - expect(@hash[:foo]).to eq("Foo!") - expect(@hash[:bar]).to eq("Bar!") - expect(@hash[:baz]).to eq("Baz!") - expect(@hash[:bop]).to eq("Bop!") - expect(@hash[:bat]).to eq("Bat!") - end - - it "iterates through the keys and values in order of assignment" do - arr = [] - @hash.each do |key, value| - arr << [key, value] - end - expect(arr).to eq([[:foo, "Foo!"], [:bar, "Bar!"], [:baz, "Baz!"], - [:bop, "Bop!"], [:bat, "Bat!"]]) - end - - it "returns the keys in order of insertion" do - expect(@hash.keys).to eq([:foo, :bar, :baz, :bop, :bat]) - end - - it "returns the values in order of insertion" do - expect(@hash.values).to eq(["Foo!", "Bar!", "Baz!", "Bop!", "Bat!"]) - end - - it "does not move an overwritten node to the end of the ordering" do - @hash[:baz] = "Bip!" - expect(@hash.values).to eq(["Foo!", "Bar!", "Bip!", "Bop!", "Bat!"]) - - @hash[:foo] = "Bip!" - expect(@hash.values).to eq(["Bip!", "Bar!", "Bip!", "Bop!", "Bat!"]) - - @hash[:bat] = "Bip!" - expect(@hash.values).to eq(["Bip!", "Bar!", "Bip!", "Bop!", "Bip!"]) - end - - it "appends another ordered hash while preserving ordering" do - other_hash = Thor::CoreExt::OrderedHash.new - other_hash[1] = "one" - other_hash[2] = "two" - other_hash[3] = "three" - expect(@hash.merge(other_hash).values).to eq(["Foo!", "Bar!", "Baz!", "Bop!", "Bat!", "one", "two", "three"]) - end - - it "overwrites hash keys with matching appended keys" do - other_hash = Thor::CoreExt::OrderedHash.new - other_hash[:bar] = "bar" - expect(@hash.merge(other_hash)[:bar]).to eq("bar") - expect(@hash[:bar]).to eq("Bar!") - end - - it "converts to an array" do - expect(@hash.to_a).to eq([[:foo, "Foo!"], [:bar, "Bar!"], [:baz, "Baz!"], [:bop, "Bop!"], [:bat, "Bat!"]]) - end - - it "must not be empty" do - expect(@hash).not_to be_empty - end - - it "deletes values from hash" do - expect(@hash.delete(:baz)).to eq("Baz!") - expect(@hash.values).to eq(["Foo!", "Bar!", "Bop!", "Bat!"]) - - expect(@hash.delete(:foo)).to eq("Foo!") - expect(@hash.values).to eq(["Bar!", "Bop!", "Bat!"]) - - expect(@hash.delete(:bat)).to eq("Bat!") - expect(@hash.values).to eq(["Bar!", "Bop!"]) - end - - it "returns nil if the value to be deleted can't be found" do - expect(@hash.delete(:nothing)).to be_nil - end - end -end 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/exit_condition_spec.rb b/spec/exit_condition_spec.rb index 7f81c2977..9c81280d4 100644 --- a/spec/exit_condition_spec.rb +++ b/spec/exit_condition_spec.rb @@ -1,11 +1,11 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') -require 'thor/base' +require "helper" +require "thor/base" describe "Exit conditions" do it "exits 0, not bubble up EPIPE, if EPIPE is raised" do epiped = false - task = Class.new(Thor) do + command = Class.new(Thor) do desc "my_action", "testing EPIPE" define_method :my_action do epiped = true @@ -13,7 +13,7 @@ end end - expect{ task.start(["my_action"]) }.to raise_error(SystemExit) + expect { command.start(["my_action"]) }.to raise_error(SystemExit) expect(epiped).to eq(true) end end diff --git a/spec/fixtures/application_helper.rb b/spec/fixtures/application_helper.rb new file mode 100644 index 000000000..de6be7945 --- /dev/null +++ b/spec/fixtures/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/spec/fixtures/bundle/execute.rb b/spec/fixtures/bundle/execute.rb deleted file mode 100644 index 0530d877c..000000000 --- a/spec/fixtures/bundle/execute.rb +++ /dev/null @@ -1,6 +0,0 @@ -class Execute < Thor - desc "ls", "Execute ls" - def ls - system "ls" - end -end diff --git a/spec/fixtures/bundle/main.thor b/spec/fixtures/bundle/main.thor deleted file mode 100644 index 38bdfbcb7..000000000 --- a/spec/fixtures/bundle/main.thor +++ /dev/null @@ -1 +0,0 @@ -require File.join(File.dirname(__FILE__), 'execute') diff --git a/spec/fixtures/task.thor b/spec/fixtures/command.thor similarity index 84% rename from spec/fixtures/task.thor rename to spec/fixtures/command.thor index 26a02686b..20673036e 100644 --- a/spec/fixtures/task.thor +++ b/spec/fixtures/command.thor @@ -1,6 +1,10 @@ # module: random class Amazing < Thor + def self.exit_on_failure? + false + end + desc "describe NAME", "say that someone is amazing" method_options :forcefully => :boolean def describe(name, opts) diff --git a/spec/fixtures/doc/COMMENTER b/spec/fixtures/doc/COMMENTER index 384cb3a50..84e62144a 100644 --- a/spec/fixtures/doc/COMMENTER +++ b/spec/fixtures/doc/COMMENTER @@ -8,4 +8,5 @@ orange purple ind#igo # ind#igo + # spaces_between __end__ diff --git a/spec/fixtures/doc/README.zh b/spec/fixtures/doc/README.zh new file mode 100644 index 000000000..6093acc27 --- /dev/null +++ b/spec/fixtures/doc/README.zh @@ -0,0 +1,3 @@ +__start__ +说明 +__end__ diff --git a/spec/fixtures/doc/excluding/%file_name%.rb.tt b/spec/fixtures/doc/excluding/%file_name%.rb.tt new file mode 100644 index 000000000..6296c46ed --- /dev/null +++ b/spec/fixtures/doc/excluding/%file_name%.rb.tt @@ -0,0 +1 @@ +BAR = <%= "BAR" %> 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/exit_status.thor b/spec/fixtures/exit_status.thor new file mode 100644 index 000000000..5fcebbaed --- /dev/null +++ b/spec/fixtures/exit_status.thor @@ -0,0 +1,19 @@ +require "thor" + +class ExitStatus < Thor + def self.exit_on_failure? + true + end + + desc "error", "exit with a planned error" + def error + raise Thor::Error.new("planned error") + end + + desc "ok", "exit with no error" + def ok + end +end + +ExitStatus.start(ARGV) + diff --git a/spec/fixtures/group.thor b/spec/fixtures/group.thor index aaf5570d1..8866c22fc 100644 --- a/spec/fixtures/group.thor +++ b/spec/fixtures/group.thor @@ -2,6 +2,10 @@ class MyCounter < Thor::Group include Thor::Actions add_runtime_options! + def self.exit_on_failure? + false + end + def self.get_from_super from_superclass(:get_from_super, 13) end @@ -9,16 +13,19 @@ class MyCounter < Thor::Group source_root File.expand_path(File.dirname(__FILE__)) source_paths << File.expand_path("broken", File.dirname(__FILE__)) - argument :first, :type => :numeric - argument :second, :type => :numeric, :default => 2 + argument :first, :type => :numeric + argument :second, :type => :numeric, :default => 2 - class_option :third, :type => :numeric, :desc => "The third argument", :default => 3, - :banner => "THREE", :aliases => "-t" - class_option :fourth, :type => :numeric, :desc => "The fourth argument" + class_option :third, :type => :numeric, :desc => "The third argument", :default => 3, + :banner => "THREE", :aliases => "-t" + 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: - This generator runs three tasks: one, two and three. + This generator runs three commands: one, two and three. FOO def one @@ -33,12 +40,24 @@ FOO options[:third] end + def four + options[:fourth] + end + + def five + options[:simple] + end + + def six + options[:symbolic] + end + def self.inherited(base) super base.source_paths.unshift(File.expand_path(File.join(File.dirname(__FILE__), "doc"))) end - no_tasks do + no_commands do def world(&block) result = capture(&block) concat(result.strip + " world!") @@ -87,8 +106,8 @@ class WhinyGenerator < Thor::Group end end -class TaskConflict < Thor::Group - desc "A group with the same name as a default task" +class CommandConflict < Thor::Group + desc "A group with the same name as a default command" def group puts "group" end @@ -110,5 +129,5 @@ class ChildGroup < ParentGroup "bar" end - public_task :foo, :baz + public_command :foo, :baz end diff --git a/spec/fixtures/help.thor b/spec/fixtures/help.thor new file mode 100644 index 000000000..4acaf9cc2 --- /dev/null +++ b/spec/fixtures/help.thor @@ -0,0 +1,13 @@ +Bundler.require :development, :default + +class Help < Thor + + desc :bugs, "ALL THE BUGZ!" + option "--not_help", :type => :boolean + def bugs + puts "Invoked!" + end + +end + +Help.start(ARGV) diff --git a/spec/fixtures/invoke.thor b/spec/fixtures/invoke.thor index ab4cb0277..0e76e9810 100644 --- a/spec/fixtures/invoke.thor +++ b/spec/fixtures/invoke.thor @@ -30,7 +30,7 @@ class A < Thor number == 5 end - desc "invoker", "invoke a b task" + desc "invoker", "invoke a b command" def invoker(*args) invoke :b, :one, ["Jose"] end @@ -53,6 +53,12 @@ class B < Thor def three self end + + desc "four", "invoke four" + option :defaulted_value, :type => :string, :default => 'default' + def four + options.defaulted_value + end end class C < Thor::Group @@ -96,8 +102,8 @@ class E < Thor::Group end class F < Thor::Group - invoke "b:one" do |instance, klass, task| - instance.invoke klass, task, [ "Jose" ], :last_name => "Valim" + invoke "b:one" do |instance, klass, command| + instance.invoke klass, command, [ "Jose" ], :last_name => "Valim" end end @@ -110,3 +116,16 @@ class H < Thor::Group class_option :defined, :type => :boolean, :default => true invoke_from_option :defined end + +class I < Thor + desc "two", "Two" + def two + current_command_chain + end +end + +class J < Thor + desc "i", "I" + subcommand :one, I +end + diff --git a/spec/fixtures/preserve/%filename%.sh b/spec/fixtures/preserve/%filename%.sh new file mode 100755 index 000000000..c52d3c26b --- /dev/null +++ b/spec/fixtures/preserve/%filename%.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +exit 0 diff --git a/spec/fixtures/script.thor b/spec/fixtures/script.thor index df58afc65..2fbdd097c 100644 --- a/spec/fixtures/script.thor +++ b/spec/fixtures/script.thor @@ -1,12 +1,20 @@ class MyScript < Thor check_unknown_options! :except => :with_optional + def self.exit_on_failure? + false + end + attr_accessor :some_attribute attr_writer :another_attribute attr_reader :another_attribute + private + attr_reader :private_attribute + + public group :script - default_task :example_default_task + default_command :example_default_command map "-T" => :animal, ["-f", "--foo"] => :foo @@ -19,8 +27,13 @@ class MyScript < Thor desc "animal TYPE", "horse around" - no_tasks do - def this_is_not_a_task + no_commands do + no_commands do + def this_is_not_a_command + end + end + + def neither_is_this end end @@ -47,10 +60,16 @@ END [bar, options] end - desc "example_default_task", "example!" + method_option :all, :desc => "Do bazing for all the things" + desc ["baz THING", "baz --all"], "super cool" + def baz(thing = nil) + raise if thing.nil? && !options.include?(:all) + end + + desc "example_default_command", "example!" method_options :with => :string - def example_default_task - options.empty? ? "default task" : options + def example_default_command + options.empty? ? "default command" : options end desc "call_myself_with_wrong_arity", "get the right error" @@ -58,7 +77,7 @@ END call_myself_with_wrong_arity(4) end - desc "call_unexistent_method", "Call unexistent method inside a task" + desc "call_unexistent_method", "Call unexistent method inside a command" def call_unexistent_method boom! end @@ -73,10 +92,22 @@ END def long_description end - desc "name-with-dashes", "Ensure normalization of task names" + desc "name-with-dashes", "Ensure normalization of command names" 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 @@ -84,7 +115,7 @@ END method_option :lazy_hash, :type => :hash, :lazy_default => {'swedish' => 'meatballs'} desc "with_optional NAME", "invoke with optional name" def with_optional(name=nil, *args) - [ name, options, args ] + [name, options, args] end class AnotherScript < Thor @@ -93,7 +124,7 @@ END end end - desc "send", "send as a task name" + desc "send", "send as a command name" def send true end @@ -114,7 +145,7 @@ END end class MyChildScript < MyScript - remove_task :bar + remove_command :name_with_dashes method_options :force => :boolean, :param => :numeric def initialize(*args) @@ -138,10 +169,14 @@ class MyChildScript < MyScript def boom end - remove_task :boom, :undefine => true + remove_command :boom, :undefine => true end class Barn < Thor + def self.exit_on_failure? + false + end + desc "open [ITEM]", "open the barn door" def open(item = nil) if item == "shotgun" @@ -156,14 +191,17 @@ class Barn < Thor def paint(color='red') puts "#{options[:coats]} coats of #{color} paint" end +end +class PackageNameScript < Thor + package_name "Baboon" end module Scripts class MyScript < MyChildScript argument :accessor, :type => :string class_options :force => :boolean - method_option :new_option, :type => :string, :for => :example_default_task + method_option :new_option, :type => :string, :for => :example_default_command def zoo self.accessor @@ -173,15 +211,19 @@ module Scripts class MyDefaults < Thor check_unknown_options! + def self.exit_on_failure? + false + end + namespace :default desc "cow", "prints 'moo'" def cow puts "moo" end - desc "task_conflict", "only gets called when prepended with a colon" - def task_conflict - puts "task" + desc "command_conflict", "only gets called when prepended with a colon" + def command_conflict + puts "command" end desc "barn", "commands to manage the barn" @@ -191,5 +233,111 @@ module Scripts class ChildDefault < Thor namespace "default:child" end + + class Arities < Thor + def self.exit_on_failure? + false + end + + desc "zero_args", "takes zero args" + def zero_args + end + + desc "one_arg ARG", "takes one arg" + def one_arg(arg) + end + + desc "two_args ARG1 ARG2", "takes two args" + def two_args(arg1, arg2) + end + + desc "optional_arg [ARG]", "takes an optional arg" + def optional_arg(arg='default') + end + + desc ["multiple_usages ARG --foo", "multiple_usages ARG --bar"], "takes mutually exclusive combinations of args and flags" + def multiple_usages(arg) + end + end +end + +class Apple < Thor + namespace :fruits + desc 'apple', 'apple'; def apple; end + desc 'rotten-apple', 'rotten apple'; def rotten_apple; end + map "ra" => :rotten_apple +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/fixtures/subcommand.thor b/spec/fixtures/subcommand.thor new file mode 100644 index 000000000..35d0b5742 --- /dev/null +++ b/spec/fixtures/subcommand.thor @@ -0,0 +1,17 @@ +module TestSubcommands + + class Subcommand < Thor + desc "print_opt", "My method" + def print_opt + print options["opt"] + end + end + + class Parent < Thor + class_option "opt" + + desc "sub", "My subcommand" + subcommand "sub", Subcommand + end + +end diff --git a/spec/fixtures/template/bad_config.yaml.tt b/spec/fixtures/template/bad_config.yaml.tt new file mode 100644 index 000000000..c80b3fd53 --- /dev/null +++ b/spec/fixtures/template/bad_config.yaml.tt @@ -0,0 +1,2 @@ +--- Hi from yaml +<%= unresolved_variable %> diff --git a/spec/fixtures/verbose.thor b/spec/fixtures/verbose.thor new file mode 100644 index 000000000..e5bf4d4ec --- /dev/null +++ b/spec/fixtures/verbose.thor @@ -0,0 +1,13 @@ +#!/usr/bin/ruby + +$VERBOSE = true + +require 'thor' + +class Test < Thor + def self.exit_on_failure? + true + end +end + +Test.start(ARGV) diff --git a/spec/group_spec.rb b/spec/group_spec.rb index bee0f2210..b308c914e 100644 --- a/spec/group_spec.rb +++ b/spec/group_spec.rb @@ -1,57 +1,61 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') +require "helper" describe Thor::Group do - describe "task" do - it "allows to use private methods from parent class as tasks" do - expect(ChildGroup.start).to eq(["bar", "foo", "baz"]) + describe "command" do + it "allows to use private methods from parent class as commands" do + expect(ChildGroup.start).to eq(%w(bar foo baz)) expect(ChildGroup.new.baz("bar")).to eq("bar") end end describe "#start" do - it "invokes all the tasks under the Thor group" do - expect(MyCounter.start(["1", "2", "--third", "3"])).to eq([ 1, 2, 3 ]) + it "invokes all the commands under the Thor group" do + expect(MyCounter.start(%w(1 2 --third 3))).to eq([1, 2, 3, nil, nil, nil]) end - it "uses argument default value" do - expect(MyCounter.start(["1", "--third", "3"])).to eq([ 1, 2, 3 ]) + it "uses argument's default value" do + expect(MyCounter.start(%w(1 --third 3))).to eq([1, 2, 3, nil, nil, nil]) end - it "invokes all the tasks in the Thor group and his parents" do - expect(BrokenCounter.start(["1", "2", "--third", "3"])).to eq([ nil, 2, 3, false, 5 ]) + it "invokes all the commands in the Thor group and its parents" do + expect(BrokenCounter.start(%w(1 2 --third 3))).to eq([nil, 2, 3, false, 5, nil]) end it "raises an error if a required argument is added after a non-required" do - expect { - MyCounter.argument(:foo, :type => :string) - }.to raise_error(ArgumentError, 'You cannot have "foo" as required argument after the non-required argument "second".') + expect do + MyCounter.argument(:foo, type: :string) + end.to raise_error(ArgumentError, 'You cannot have "foo" as required argument after the non-required argument "second".') end - it "raises when an exception happens within the task call" do - expect{ BrokenCounter.start(["1", "2", "--fail"]) }.to raise_error + it "raises when an exception happens within the command call" do + if RUBY_VERSION < "3.4.0" + expect { BrokenCounter.start(%w(1 2 --fail)) }.to raise_error(NameError, /undefined local variable or method `this_method_does_not_exist'/) + else + expect { BrokenCounter.start(%w(1 2 --fail)) }.to raise_error(NameError, /undefined local variable or method 'this_method_does_not_exist'/) + end end - it "raises an error when a Thor group task expects arguments" do - expect{ WhinyGenerator.start }.to raise_error(ArgumentError, /thor wrong_arity takes 1 argument, but it should not/) + it "raises an error when a Thor group command expects arguments" do + expect { WhinyGenerator.start }.to raise_error(ArgumentError, /thor wrong_arity takes 1 argument, but it should not/) end - it "invokes help message if any of the shortcuts is given" do - MyCounter.should_receive(:help) - MyCounter.start(["-h"]) + it "invokes help message if any of the shortcuts are given" do + expect(MyCounter).to receive(:help) + MyCounter.start(%w(-h)) end end describe "#desc" do it "sets the description for a given class" do - expect(MyCounter.desc).to eq("Description:\n This generator runs three tasks: one, two and three.\n") + expect(MyCounter.desc).to eq("Description:\n This generator runs three commands: one, two and three.\n") end it "can be inherited" do - expect(BrokenCounter.desc).to eq("Description:\n This generator runs three tasks: one, two and three.\n") + expect(BrokenCounter.desc).to eq("Description:\n This generator runs three commands: one, two and three.\n") end it "can be nil" do - expect(WhinyGenerator.desc).to be_nil + expect(WhinyGenerator.desc).to be nil end end @@ -66,7 +70,7 @@ it "shows description" do expect(@content).to match(/Description:/) - expect(@content).to match(/This generator runs three tasks: one, two and three./) + expect(@content).to match(/This generator runs three commands: one, two and three./) end it "shows options information" do @@ -117,17 +121,23 @@ end it "does not invoke if the option is nil" do - expect(capture(:stdout) { G.start(["--skip-invoked"]) }).not_to match(/invoke/) + expect(capture(:stdout) { G.start(%w(--skip-invoked)) }).not_to match(/invoke/) end it "prints a message if invocation cannot be found" do - content = capture(:stdout) { G.start(["--invoked", "unknown"]) } + content = capture(:stdout) { G.start(%w(--invoked unknown)) } expect(content).to match(/error unknown \[not found\]/) end it "allows to invoke a class from the class binding by the given option" do - content = capture(:stdout) { G.start(["--invoked", "e"]) } + error = nil + content = capture(:stdout) do + error = capture(:stderr) do + G.start(%w(--invoked e)) + end + end expect(content).to match(/invoke e/) + expect(error).to match(/ERROR: "thor two" was called with arguments/) end it "shows invocation information to the user" do @@ -156,7 +166,7 @@ end it "does not invoke if the option is false" do - expect(capture(:stdout) { H.start(["--no-defined"]) }).not_to match(/invoke/) + expect(capture(:stdout) { H.start(%w(--no-defined)) }).not_to match(/invoke/) end it "shows invocation information to the user" do @@ -176,41 +186,51 @@ end end + describe "#command_exists?" do + it "returns true for a command that is defined in the class" do + expect(MyCounter.command_exists?("one")).to be true + end + + it "returns false for a command that is not defined in the class" do + expect(MyCounter.command_exists?("zero")).to be false + end + end + describe "edge-cases" do 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 - name.upcase! if options[:loud] + self.name = name.upcase if options[:loud] "Hi #{name}" end end - expect(klass.start(["jose"])).to eq(["Hi jose"]) - expect(klass.start(["jose", "--loud"])).to eq(["Hi JOSE"]) - expect(klass.start(["--loud", "jose"])).to eq(["Hi JOSE"]) + expect(klass.start(%w(jose))).to eq(["Hi jose"]) + expect(klass.start(%w(jose --loud))).to eq(["Hi JOSE"]) + expect(klass.start(%w(--loud jose))).to eq(["Hi JOSE"]) end 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 - name.upcase! if options[:loud] + self.name = name.upcase if options[:loud] out = "Hi #{name}" out << ": " << args.join(", ") unless args.empty? out end end - expect(klass.start(["jose"])).to eq(["Hi jose"]) - expect(klass.start(["jose", "--loud"])).to eq(["Hi JOSE"]) - expect(klass.start(["--loud", "jose"])).to eq(["Hi JOSE"]) + expect(klass.start(%w(jose))).to eq(["Hi jose"]) + expect(klass.start(%w(jose --loud))).to eq(["Hi JOSE"]) + expect(klass.start(%w(--loud jose))).to eq(["Hi JOSE"]) end end end diff --git a/spec/helper.rb b/spec/helper.rb new file mode 100644 index 000000000..b322f4464 --- /dev/null +++ b/spec/helper.rb @@ -0,0 +1,88 @@ +$TESTING = true + +require "simplecov" +require "coveralls" + +SimpleCov.formatters = [SimpleCov::Formatter::HTMLFormatter, Coveralls::SimpleCov::Formatter] + +SimpleCov.start do + add_filter "/spec" + minimum_coverage(90) +end + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib")) +require "thor" +require "thor/group" +require "stringio" + +require "rdoc" +require "rspec" +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") + +# Set shell to basic +ENV["THOR_COLUMNS"] = "10000" +$0 = "thor" +$thor_runner = true +ARGV.clear +Thor::Base.shell = Thor::Shell::Basic + +# Load fixtures +load File.join(File.dirname(__FILE__), "fixtures", "enum.thor") +load File.join(File.dirname(__FILE__), "fixtures", "group.thor") +load File.join(File.dirname(__FILE__), "fixtures", "invoke.thor") +load File.join(File.dirname(__FILE__), "fixtures", "script.thor") +load File.join(File.dirname(__FILE__), "fixtures", "subcommand.thor") +load File.join(File.dirname(__FILE__), "fixtures", "command.thor") + +RSpec.configure do |config| + config.before do + ARGV.replace [] + end + + config.expect_with :rspec do |c| + c.syntax = :expect + end + + def capture(stream) + begin + stream = stream.to_s + eval "$#{stream} = StringIO.new" + yield + result = eval("$#{stream}").string + ensure + eval("$#{stream} = #{stream.upcase}") + end + + result + end + + def source_root + File.join(File.dirname(__FILE__), "fixtures") + end + + def destination_root + File.join(File.dirname(__FILE__), "sandbox") + end + + # This code was adapted from Ruby on Rails, available under MIT-LICENSE + # Copyright (c) 2004-2013 David Heinemeier Hansson + def silence_warnings + old_verbose = $VERBOSE + $VERBOSE = nil + yield + ensure + $VERBOSE = old_verbose + end + + # true if running on windows, used for conditional spec skips + # + # @return [TrueClass/FalseClass] + def windows? + Gem.win_platform? + end + + alias silence capture +end diff --git a/spec/invocation_spec.rb b/spec/invocation_spec.rb index f9a4dbab6..e584eba77 100644 --- a/spec/invocation_spec.rb +++ b/spec/invocation_spec.rb @@ -1,53 +1,67 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') -require 'thor/base' +require "helper" +require "thor/base" describe Thor::Invocation do describe "#invoke" do - it "invokes a task inside another task" do + it "invokes a command inside another command" do expect(capture(:stdout) { A.new.invoke(:two) }).to eq("2\n3\n") end - it "invokes a task just once" do + it "invokes a command just once" do expect(capture(:stdout) { A.new.invoke(:one) }).to eq("1\n2\n3\n") end - it "invokes a task just once even if they belongs to different classes" do + it "invokes a command just once even if they belongs to different classes" do expect(capture(:stdout) { Defined.new.invoke(:one) }).to eq("1\n2\n3\n4\n5\n") end - it "invokes a task with arguments" do - expect(A.new.invoke(:five, [5])).to be_true - expect(A.new.invoke(:five, [7])).to be_false + it "invokes a command with arguments" do + expect(A.new.invoke(:five, [5])).to be true + expect(A.new.invoke(:five, [7])).to be false end - it "invokes the default task if none is given to a Thor class" do + it "invokes the default command if none is given to a Thor class" do content = capture(:stdout) { A.new.invoke("b") } - expect(content).to match(/Tasks/) + expect(content).to match(/Commands/) expect(content).to match(/LAST_NAME/) end - it "accepts a class as argument without a task to invoke" do + it "accepts a class as argument without a command to invoke" do content = capture(:stdout) { A.new.invoke(B) } - expect(content).to match(/Tasks/) + expect(content).to match(/Commands/) expect(content).to match(/LAST_NAME/) end - it "accepts a class as argument with a task to invoke" do - base = A.new([], :last_name => "Valim") - expect(base.invoke(B, :one, ["Jose"])).to eq("Valim, Jose") + it "accepts a class as argument with a command to invoke" do + 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, ["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 - expect(A.start(["invoker", "--last-name", "Valim"])).to eq("Valim, Jose") + expect(A.start(%w(invoker --last-name Valim))).to eq("Valim, Jose") 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 + expect(A.new([]).invoke("b:four")).to eq("default") + 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") + end + + it "returns the command chain" do + expect(I.new.invoke("two")).to eq([:two]) + + expect(J.start(%w(one two))).to eq([:one, :two]) end it "dump configuration values to be used in the invoked class" do @@ -56,45 +70,46 @@ end it "allow extra configuration values to be given" do - base, shell = A.new, Thor::Base.shell.new - expect(base.invoke("b:three", [], {}, :shell => shell).shell).to eq(shell) + base = A.new + shell = Thor::Base.shell.new + expect(base.invoke("b:three", [], {}, shell: shell).shell).to eq(shell) end - it "invokes a Thor::Group and all of its tasks" do + it "invokes a Thor::Group and all of its commands" do expect(capture(:stdout) { A.new.invoke(:c) }).to eq("1\n2\n3\n") end it "does not invoke a Thor::Group twice" do base = A.new - silence(:stdout){ base.invoke(:c) } + silence(:stdout) { base.invoke(:c) } expect(capture(:stdout) { base.invoke(:c) }).to be_empty end - it "does not invoke any of Thor::Group tasks twice" do + it "does not invoke any of Thor::Group commands twice" do base = A.new - silence(:stdout){ base.invoke(:c) } + silence(:stdout) { base.invoke(:c) } expect(capture(:stdout) { base.invoke("c:one") }).to be_empty end - it "raises Thor::UndefinedTaskError if the task can't be found" do - expect { + it "raises Thor::UndefinedCommandError if the command can't be found" do + expect do A.new.invoke("foo:bar") - }.to raise_error(Thor::UndefinedTaskError) + end.to raise_error(Thor::UndefinedCommandError) end - it "raises Thor::UndefinedTaskError if the task can't be found even if all tasks where already executed" do + it "raises Thor::UndefinedCommandError if the command can't be found even if all commands were already executed" do base = C.new - silence(:stdout){ base.invoke_all } + silence(:stdout) { base.invoke_all } - expect { + expect do base.invoke("foo:bar") - }.to raise_error(Thor::UndefinedTaskError) + end.to raise_error(Thor::UndefinedCommandError) end it "raises an error if a non Thor class is given" do - expect { + expect do A.new.invoke(Object) - }.to raise_error(RuntimeError, "Expected Thor class, got Object") + end.to raise_error(RuntimeError, "Expected Thor class, got Object") end end end diff --git a/spec/line_editor/basic_spec.rb b/spec/line_editor/basic_spec.rb new file mode 100644 index 000000000..df88dc595 --- /dev/null +++ b/spec/line_editor/basic_spec.rb @@ -0,0 +1,28 @@ +require "helper" + +describe Thor::LineEditor::Basic do + describe ".available?" do + it "returns true" do + expect(Thor::LineEditor::Basic).to be_available + end + end + + describe "#readline" do + it "uses $stdin and $stdout to get input from the user" do + expect($stdout).to receive(:print).with("Enter your name ") + expect($stdin).to receive(:gets).and_return("George") + expect($stdin).not_to receive(:noecho) + editor = Thor::LineEditor::Basic.new("Enter your name ", {}) + expect(editor.readline).to eq("George") + end + + it "disables echo when asked to" do + expect($stdout).to receive(:print).with("Password: ") + 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) + expect(editor.readline).to eq("secret") + end + end +end diff --git a/spec/line_editor/readline_spec.rb b/spec/line_editor/readline_spec.rb new file mode 100644 index 000000000..a7daa8bd4 --- /dev/null +++ b/spec/line_editor/readline_spec.rb @@ -0,0 +1,71 @@ +require "helper" + +describe Thor::LineEditor::Readline do + before do + # Eagerly check Readline availability before mocking + Thor::LineEditor::Readline.available? + unless defined? ::Readline + ::Readline = double("Readline") + allow(::Readline).to receive(:completion_append_character=).with(nil) + end + end + + describe ".available?" do + it "returns true when ::Readline exists" do + allow(Object).to receive(:const_defined?).with(:Readline).and_return(true) + expect(described_class).to be_available + end + + it "returns false when ::Readline does not exist" do + allow(Object).to receive(:const_defined?).with(:Readline).and_return(false) + expect(described_class).not_to be_available + end + end + + describe "#readline" do + it "invokes the readline library" do + expect(::Readline).to receive(:readline).with("> ", true).and_return("foo") + expect(::Readline).to_not receive(:completion_proc=) + editor = Thor::LineEditor::Readline.new("> ", {}) + expect(editor.readline).to eq("foo") + end + + 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) + expect(editor.readline).to eq("foo") + end + + it "provides tab completion when given a limited_to option" do + expect(::Readline).to receive(:readline) + expect(::Readline).to receive(:completion_proc=) do |proc| + expect(proc.call("")).to eq %w(Apples Chicken Chocolate) + expect(proc.call("Ch")).to eq %w(Chicken Chocolate) + expect(proc.call("Chi")).to eq ["Chicken"] + end + + editor = Thor::LineEditor::Readline.new("Best food: ", limited_to: %w(Apples Chicken Chocolate)) + editor.readline + end + + it "provides path tab completion when given the path option" do + expect(::Readline).to receive(:readline) + expect(::Readline).to receive(:completion_proc=) do |proc| + 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) + Dir.chdir(File.dirname(__FILE__)) { editor.readline } + end + + it "uses STDIN when asked not to echo input" do + expect($stdout).to receive(:print).with("Password: ") + 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) + expect(editor.readline).to eq("secret") + end + end +end diff --git a/spec/line_editor_spec.rb b/spec/line_editor_spec.rb new file mode 100644 index 000000000..4f1ff24a5 --- /dev/null +++ b/spec/line_editor_spec.rb @@ -0,0 +1,44 @@ +require "helper" +require "readline" + +describe Thor::LineEditor, "on a system with Readline support" do + before do + @original_readline = ::Readline + Object.send(:remove_const, :Readline) + ::Readline = double("Readline") + end + + after do + 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(editor).to receive(:readline).and_return("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 + @original_readline = ::Readline + Object.send(:remove_const, :Readline) + end + + after do + ::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(editor).to receive(:readline).and_return("George") + expect(Thor::LineEditor.readline("Enter your name ", default: "Brian")).to eq("George") + end + end +end diff --git a/spec/nested_context_spec.rb b/spec/nested_context_spec.rb new file mode 100644 index 000000000..2955af763 --- /dev/null +++ b/spec/nested_context_spec.rb @@ -0,0 +1,20 @@ +require "helper" + +describe Thor::NestedContext do + subject(:context) { described_class.new } + + describe "#enter" do + it "is never empty within the entered block" do + context.enter do + context.enter {} + + expect(context).to be_entered + end + end + + it "is empty when outside of all blocks" do + context.enter { context.enter {} } + expect(context).not_to be_entered + end + end +end diff --git a/spec/no_warnings_spec.rb b/spec/no_warnings_spec.rb new file mode 100644 index 000000000..09ebde691 --- /dev/null +++ b/spec/no_warnings_spec.rb @@ -0,0 +1,16 @@ +require "open3" + +context "when $VERBOSE is enabled" do + it "prints no warnings" do + root = File.expand_path("..", __dir__) + _, err, = Open3.capture3("ruby -I #{root}/lib #{root}/spec/fixtures/verbose.thor") + + expect(err).to be_empty + end + + it "prints no warnings even when erroring" do + root = File.expand_path("..", __dir__) + _, err, = Open3.capture3("ruby -I #{root}/lib #{root}/spec/fixtures/verbose.thor noop") + expect(err).to_not match(/warning:/) + end +end diff --git a/spec/parser/argument_spec.rb b/spec/parser/argument_spec.rb index 287fa41d4..6bf631e63 100644 --- a/spec/parser/argument_spec.rb +++ b/spec/parser/argument_spec.rb @@ -1,53 +1,76 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/parser' +require "helper" +require "thor/parser" describe Thor::Argument do - - def argument(name, options={}) + def argument(name, options = {}) @argument ||= Thor::Argument.new(name, options) end describe "errors" do it "raises an error if name is not supplied" do - expect { + expect do argument(nil) - }.to raise_error(ArgumentError, "Argument name can't be nil.") + end.to raise_error(ArgumentError, "Argument name can't be nil.") end it "raises an error if type is unknown" do - expect { - argument(:task, :type => :unknown) - }.to raise_error(ArgumentError, "Type :unknown is not valid for arguments.") + expect do + 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 have default values" do - expect { - argument(:task, :type => :string, :default => "bar", :required => true) - }.to raise_error(ArgumentError, "An argument cannot be required and have default value.") + it "raises an error if argument is required and has default values" do + expect do + 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 - expect { - argument(:task, :type => :string, :enum => "bar") - }.to raise_error(ArgumentError, "An argument cannot have an enum other than an array.") + 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 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 df7753761..4e60f8add 100644 --- a/spec/parser/arguments_spec.rb +++ b/spec/parser/arguments_spec.rb @@ -1,14 +1,14 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/parser' +require "helper" +require "thor/parser" describe Thor::Arguments do - def create(opts={}) + 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 - arguments.sort!{ |a,b| b.name <=> a.name } + arguments.sort! { |a, b| b.name <=> a.name } @opt = Thor::Arguments.new(arguments) end @@ -18,24 +18,34 @@ 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) + expect(parse("name", "+13.3")["numeric"]).to eq(13.3) + expect(parse("name", "-13")["numeric"]).to eq(-13) + expect(parse("name", "-13.3")["numeric"]).to eq(-13.3) 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" }) + 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 + expect(parse("-")["array"]).to eq(%w(-)) + expect(parse("-", "title", "-")["array"]).to eq(%w(- title -)) + end + describe "with no inputs" do it "and no arguments returns an empty hash" do create @@ -43,24 +53,24 @@ def parse(*args) end it "and required arguments raises an error" do - create :string => nil, :numeric => nil - expect{ parse }.to raise_error(Thor::RequiredArgumentMissingError, "No value provided for required arguments 'string', 'numeric'") + 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 - expect(parse).to eq({ "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 - expect(parse("", 0, {}, [])).to eq({ "string" => "", "numeric" => 0, "hash" => {}, "array" => [] }) + 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 - expect(parse("bar")).to eq({ "string" => "bar", "numeric" => 3.0 }) + create string: "foo", numeric: 3.0 + expect(parse("bar")).to eq("string" => "bar", "numeric" => 3.0) end end end diff --git a/spec/parser/option_spec.rb b/spec/parser/option_spec.rb index 0e707c4d8..4cc9b360f 100644 --- a/spec/parser/option_spec.rb +++ b/spec/parser/option_spec.rb @@ -1,17 +1,16 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/parser' +require "helper" +require "thor/parser" describe Thor::Option do def parse(key, value) Thor::Option.parse(key, value) end - def option(name, options={}) + def option(name, options = {}) @option ||= Thor::Option.new(name, options) end describe "#parse" do - describe "with value as a symbol" do describe "and symbol is a valid type" do it "has type equals to the symbol" do @@ -19,9 +18,9 @@ def option(name, options={}) expect(parse(:foo, :numeric).type).to eq(:numeric) end - it "has not default value" do - expect(parse(:foo, :string).default).to be_nil - expect(parse(:foo, :numeric).default).to be_nil + it "has no default value" do + expect(parse(:foo, :string).default).to be nil + expect(parse(:foo, :numeric).default).to be nil end end @@ -31,28 +30,28 @@ def option(name, options={}) end it "has no default value" do - expect(parse(:foo, :required).default).to be_nil + expect(parse(:foo, :required).default).to be nil end end describe "and symbol is not a reserved key" do - it "has type equals to :string" do + it "has type equal to :string" do expect(parse(:foo, :bar).type).to eq(:string) end it "has no default value" do - expect(parse(:foo, :bar).default).to be_nil + expect(parse(:foo, :bar).default).to be nil end end end 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 equals to the hash" do - expect(parse(:foo, :a => :b).default).to eq({ :a => :b }) + it "has default value equal to the hash" do + expect(parse(:foo, a: :b).default).to eq(a: :b) end end @@ -61,7 +60,7 @@ def option(name, options={}) expect(parse(:foo, [:a, :b]).type).to eq(:array) end - it "has default value equals to the array" do + it "has default value equal to the array" do expect(parse(:foo, [:a, :b]).default).to eq([:a, :b]) end end @@ -71,7 +70,7 @@ def option(name, options={}) expect(parse(:foo, "bar").type).to eq(:string) end - it "has default value equals to the string" do + it "has default value equal to the string" do expect(parse(:foo, "bar").default).to eq("bar") end end @@ -81,7 +80,7 @@ def option(name, options={}) expect(parse(:foo, 2.0).type).to eq(:numeric) end - it "has default value equals to the numeric" do + it "has default value equal to the numeric" do expect(parse(:foo, 2.0).default).to eq(2.0) end end @@ -92,25 +91,25 @@ def option(name, options={}) expect(parse(:foo, false).type).to eq(:boolean) end - it "has default value equals to the boolean" do + it "has default value equal to the boolean" do expect(parse(:foo, true).default).to eq(true) expect(parse(:foo, false).default).to eq(false) end end describe "with key as a symbol" do - it "sets the name equals to the key" do + it "sets the name equal to the key" do expect(parse(:foo, true).name).to eq("foo") end end 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 @@ -130,15 +129,63 @@ 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 "cannot be required and have type boolean" do - expect { - option("foo", :required => true, :type => :boolean) - }.to raise_error(ArgumentError, "An option cannot be boolean and required.") + 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) + 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) + 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) + 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) + 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) + 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) + 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) + end.not_to raise_error + end + + it "boolean options cannot be required" do + expect do + 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) + end.not_to raise_error end it "allows type predicates" do @@ -148,13 +195,12 @@ def option(name, options={}) end it "raises an error on method missing" do - expect { + expect do parse(:foo, :string).unknown? - }.to raise_error(NoMethodError) + end.to raise_error(NoMethodError) end describe "#usage" do - it "returns usage for string types" do expect(parse(:foo, :string).usage).to eq("[--foo=FOO]") end @@ -172,19 +218,38 @@ def option(name, options={}) end it "returns usage for boolean types" do - expect(parse(:foo, :boolean).usage).to eq("[--foo]") + expect(parse(:foo, :boolean).usage).to eq("[--foo], [--no-foo], [--skip-foo]") + end + + it "does not use padding when no aliases are given" do + expect(parse(:foo, :boolean).usage).to eq("[--foo], [--no-foo], [--skip-foo]") + end + + it "documents a negative option when boolean" do + expect(parse(:foo, :boolean).usage).to include("[--no-foo]") + end + + it "does not document a negative option for a negative boolean" do + expect(parse(:'no-foo', :boolean).usage).not_to include("[--no-no-foo]") + expect(parse(:'no-foo', :boolean).usage).not_to include("[--skip-no-foo]") + expect(parse(:'skip-foo', :boolean).usage).not_to include("[--no-skip-foo]") + expect(parse(:'skip-foo', :boolean).usage).not_to include("[--skip-skip-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 "uses padding when no aliases is given" do - expect(parse(:foo, :boolean).usage(4)).to eq(" [--foo]") + 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 "checkes when banner is an empty string" do - expect(option(:foo, :required => false, :type => :string, :banner => "").usage).to eq("[--foo]") + it "checks when banner is an empty string" do + expect(option(:foo, required: false, type: :string, banner: "").usage).to eq("[--foo]") end describe "with required values" do @@ -197,6 +262,31 @@ def option(name, options={}) it "does not show the usage between brackets" do expect(parse([:foo, "-f", "-b"], :required).usage).to eq("-f, -b, --foo=FOO") end + + it "does not negate the aliases" do + expect(parse([:foo, "-f", "-b"], :boolean).usage).to eq("-f, -b, [--foo], [--no-foo], [--skip-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 a9d67da59..d5c22ffbc 100644 --- a/spec/parser/options_spec.rb +++ b/spec/parser/options_spec.rb @@ -1,13 +1,16 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') -require 'thor/parser' +require "helper" +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) @@ -24,43 +27,46 @@ 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("") 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 - expect(switches).to eq(['"bar"', "--color", "--foo"]) + 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 describe "#parse" do it "allows multiple aliases for a given switch" do - create ["--foo", "--bar", "--baz"] => :string + create %w(--foo --bar --baz) => :string expect(parse("--foo", "12")["foo"]).to eq("12") expect(parse("--bar", "12")["foo"]).to eq("12") expect(parse("--baz", "12")["foo"]).to eq("12") @@ -68,102 +74,108 @@ def remaining it "allows custom short names" do create "-f" => :string - expect(parse("-f", "12")).to eq({"f" => "12"}) + expect(parse("-f", "12")).to eq("f" => "12") end it "allows custom short-name aliases" do - create ["--bar", "-f"] => :string - expect(parse("-f", "12")).to eq({"bar" => "12"}) + create %w(--bar -f) => :string + expect(parse("-f", "12")).to eq("bar" => "12") end it "accepts conjoined short switches" do - create ["--foo", "-f"] => true, ["--bar", "-b"] => true, ["--app", "-a"] => true + create %w(--foo -f) => true, %w(--bar -b) => true, %w(--app -a) => true opts = parse("-fba") - expect(opts["foo"]).to be_true - expect(opts["bar"]).to be_true - expect(opts["app"]).to be_true + expect(opts["foo"]).to be true + expect(opts["bar"]).to be true + expect(opts["app"]).to be true end it "accepts conjoined short switches with input" do - create ["--foo", "-f"] => true, ["--bar", "-b"] => true, ["--app", "-a"] => :required + create %w(--foo -f) => true, %w(--bar -b) => true, %w(--app -a) => :required opts = parse "-fba", "12" - expect(opts["foo"]).to be_true - expect(opts["bar"]).to be_true + expect(opts["foo"]).to be true + expect(opts["bar"]).to be true expect(opts["app"]).to eq("12") 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") - expect{ check_unknown! }.to raise_error(Thor::UnknownArgumentError, "Unknown switches '--baz'") + + expected = "Unknown switches \"--baz\"".dup + expected << "\nDid you mean? \"--bar\"" if Thor::Correctable + + 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"}) + 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") - expect(parse("--asdf---asdf", "baz", "--foo", "--asdf---dsf--asdf")).to eq({"foo" => "--asdf---dsf--asdf"}) + 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) - expect(parse(%w[--bar abc moo -- --foo def -a])).to eq({"bar" => "abc"}) - expect(remaining).to eq(%w[moo --foo def -a]) + 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) - expect(parse(%w[--bar -- --foo def -a])).to eq({"bar" => "--foo"}) - expect(remaining).to eq(%w[def -a]) + 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) - expect(parse(%w[--foo a b -- c d -e])).to eq({"foo" => %w[a b c d -e]}) + 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) - expect(parse(%w[--foo a:b -- c:d -e])).to eq({"foo" => {'a' => 'b', 'c' => 'd'}}) - expect(remaining).to eq(%w[-e]) + 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) - expect(parse(%w[--foo --])).to eq({"foo" => nil}) + create(foo: :string) + expect(parse(%w(--foo --))).to eq("foo" => nil) expect(remaining).to eq([]) end @@ -180,7 +192,7 @@ def remaining it "and a required switch raises an error" do create "--foo" => :required - expect{ parse }.to raise_error(Thor::RequiredArgumentMissingError, "No value provided for required options '--foo'") + expect { parse }.to raise_error(Thor::RequiredArgumentMissingError, "No value provided for required options '--foo'") end end @@ -190,58 +202,123 @@ def remaining end it "raises an error if the required switch has no argument" do - expect{ parse("--foo") }.to raise_error(Thor::MalformattedArgumentError) + expect { parse("--foo") }.to raise_error(Thor::MalformattedArgumentError) end it "raises an error if the required switch isn't given" do - expect{ parse("--bar") }.to raise_error(Thor::RequiredArgumentMissingError) + expect { parse("--bar") }.to raise_error(Thor::RequiredArgumentMissingError) end it "raises an error if the required switch is set to nil" do - expect{ parse("--no-foo") }.to raise_error(Thor::RequiredArgumentMissingError) + expect { parse("--no-foo") }.to raise_error(Thor::RequiredArgumentMissingError) 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 - expect{ parse("--bar") }.not_to raise_error + 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 - expect(parse(%w[foo --verbose])).to eq({}) - expect(remaining).to eq(["foo", "--verbose"]) + expect(parse(%w(foo --verbose))).to eq({}) + expect(remaining).to eq(%w(foo --verbose)) end it "stops parsing on unknown option" do - expect(parse(%w[--bar --verbose])).to eq({}) - expect(remaining).to eq(["--bar", "--verbose"]) + expect(parse(%w(--bar --verbose))).to eq({}) + expect(remaining).to eq(%w(--bar --verbose)) + end + + it "retains -- after it has stopped parsing" do + expect(parse(%w(--bar -- whatever))).to eq({}) + expect(remaining).to eq(%w(--bar -- whatever)) end it "still accepts options that are given before non-options" do - expect(parse(%w[--verbose foo])).to eq({"verbose" => true}) - expect(remaining).to eq(["foo"]) + expect(parse(%w(--verbose foo))).to eq("verbose" => true) + expect(remaining).to eq(%w(foo)) end it "still accepts options that require a value" do - expect(parse(%w[--foo bar baz])).to eq({"foo" => "bar"}) - expect(remaining).to eq(["baz"]) + expect(parse(%w(--foo bar baz))).to eq("foo" => "bar") + expect(remaining).to eq(%w(baz)) end it "still interprets everything after -- as args instead of options" do - expect(parse(%w[-- --verbose])).to eq({}) - expect(remaining).to eq(["--verbose"]) + expect(parse(%w(-- --verbose))).to eq({}) + expect(remaining).to eq(%w(--verbose)) + 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 ["--foo", "-f"] => :required + create %w(--foo -f) => :required end it "accepts a switch assignment" do @@ -252,21 +329,23 @@ 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 it "accepts a --no-switch format" do create "--foo" => "bar" - expect(parse("--no-foo")["foo"]).to be_nil + expect(parse("--no-foo")["foo"]).to be nil end it "does not consume an argument for --no-switch format" do create "--cheese" => :string - expect(parse('burger', '--no-cheese', 'fries')["cheese"]).to be_nil + expect(parse("burger", "--no-cheese", "fries")["cheese"]).to be nil end it "accepts a --switch format on non required types" do @@ -283,6 +362,19 @@ def remaining expect(parse("--foo=bar", "--foo", "12")["foo"]).to eq("12") expect(parse("--foo", "12", "--foo", "13")["foo"]).to eq("13") end + + it "raises error when value isn't in enum" do + enum = %w(apple banana) + 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: []) + expect(parse("--foo=bar", "--foo", "12")["foo"]).to eq(["bar", "12"]) + expect(@opt.instance_variable_get(:@switches)["--foo"].default).to eq([]) + end end describe "with :boolean type" do @@ -312,6 +404,10 @@ def remaining expect(parse("--skip-foo")["foo"]).to eq(false) end + it "accepts --[skip-]opt variant, setting false for value, even if there's a trailing non-switch" do + expect(parse("--skip-foo", "asdf")["foo"]).to eq(false) + end + it "will prefer 'no-opt' variant over inverting 'opt' if explicitly set" do create "--no-foo" => true expect(parse("--no-foo")["no-foo"]).to eq(true) @@ -322,17 +418,44 @@ def remaining expect(parse("--skip-foo")["skip-foo"]).to eq(true) end + it "will prefer 'skip-opt' variant over inverting 'opt' if explicitly set, even if there's a trailing non-switch" do + create "--skip-foo" => true + expect(parse("--skip-foo", "asdf")["skip-foo"]).to eq(true) + end + + it "will prefer 'skip-opt' variant over inverting 'opt' if explicitly set, and given a value" do + create "--skip-foo" => true + expect(parse("--skip-foo=f")["skip-foo"]).to eq(false) + expect(parse("--skip-foo=false")["skip-foo"]).to eq(false) + expect(parse("--skip-foo=t")["skip-foo"]).to eq(true) + expect(parse("--skip-foo=true")["skip-foo"]).to eq(true) + 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) end it "doesn't eat the next part of the param" do - create :foo => :boolean - expect(parse("--foo", "bar")).to eq({"foo" => true}) - expect(@opt.remaining).to eq(["bar"]) + expect(parse("--foo", "bar")).to eq("foo" => true) + expect(@opt.remaining).to eq(%w(bar)) + end + + it "doesn't eat the next part of the param with 'no-opt' variant" do + expect(parse("--no-foo", "bar")).to eq("foo" => false) + expect(@opt.remaining).to eq(%w(bar)) + end + + it "doesn't eat the next part of the param with 'skip-opt' variant" do + expect(parse("--skip-foo", "bar")).to eq("foo" => false) + expect(@opt.remaining).to eq(%w(bar)) + end + + it "allows multiple values if repeatable is specified" do + create verbose: Thor::Option.new("verbose", type: :boolean, aliases: "-v", repeatable: true) + expect(parse("-v", "-v", "-v")["verbose"].count).to eq(3) end end @@ -342,15 +465,25 @@ def remaining end 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")["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 - expect(parse("--attributes", "name:string", "age:integer")["attributes"]).to eq({"name" => "string", "age" => "integer"}) + expect(parse("--attributes", "name:string", "age:integer")["attributes"]).to eq("name" => "string", "age" => "integer") end it "must not mix values with other switches" do - expect(parse("--attributes", "name:string", "age:integer", "--baz", "cool")["attributes"]).to eq({"name" => "string", "age" => "integer"}) + expect(parse("--attributes", "name:string", "age:integer", "--baz", "cool")["attributes"]).to eq("name" => "string", "age" => "integer") + end + + it "must not allow the same hash key to be specified multiple times" do + expect { parse("--attributes", "name:string", "name:integer") }.to raise_error(Thor::MalformattedArgumentError, "You can't specify 'name' more than once in option '--attributes'; got name:string and name:integer") + end + + it "allows multiple values if repeatable is specified" do + 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 @@ -360,15 +493,28 @@ def remaining end it "accepts a switch= assignment" do - expect(parse("--attributes=a", "b", "c")["attributes"]).to eq(["a", "b", "c"]) + 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 - expect(parse("--attributes", "a", "b", "c")["attributes"]).to eq(["a", "b", "c"]) + expect(parse("--attributes", "a", "b", "c")["attributes"]).to eq(%w(a b c)) end it "must not mix values with other switches" do - expect(parse("--attributes", "a", "b", "c", "--baz", "cool")["attributes"]).to eq(["a", "b", "c"]) + expect(parse("--attributes", "a", "b", "c", "--baz", "cool")["attributes"]).to eq(%w(a b c)) + end + + it "allows multiple values if repeatable is specified" do + 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 @@ -382,14 +528,32 @@ def remaining end it "converts values to numeric types" do - expect(parse("-n", "3", "-m", ".5")).to eq({"n" => 3, "m" => 0.5}) + expect(parse("-n", "3", "-m", ".5")).to eq("n" => 3, "m" => 0.5) end it "raises error when value isn't numeric" do - expect{ parse("-n", "foo") }.to raise_error(Thor::MalformattedArgumentError, - "Expected numeric value for '-n'; got \"foo\"") + expect { parse("-n", "foo") }.to raise_error(Thor::MalformattedArgumentError, + "Expected numeric value for '-n'; got \"foo\"") + end + + it "raises error when value isn't in Array 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 1, 2; got 3") end - 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 1..2; got 3") + end + + it "allows multiple values if repeatable is specified" do + create run: Thor::Option.new("run", type: :numeric, repeatable: true) + expect(parse("--run", "1", "--run", "2")["run"]).to eq([1, 2]) + end + end end end diff --git a/spec/quality_spec.rb b/spec/quality_spec.rb new file mode 100644 index 000000000..a6ceb3ea2 --- /dev/null +++ b/spec/quality_spec.rb @@ -0,0 +1,63 @@ +describe "The library itself" do + def check_for_spec_defs_with_single_quotes(filename) + failing_lines = [] + + File.readlines(filename).each_with_index do |line, number| + failing_lines << number + 1 if line =~ /^ *(describe|it|context) {1}'{1}/ + end + + "#{filename} uses inconsistent single quotes on lines #{failing_lines.join(', ')}" unless failing_lines.empty? + end + + def check_for_tab_characters(filename) + failing_lines = [] + File.readlines(filename).each_with_index do |line, number| + failing_lines << number + 1 if line =~ /\t/ + end + + "#{filename} has tab characters on lines #{failing_lines.join(', ')}" unless failing_lines.empty? + end + + def check_for_extra_spaces(filename) + failing_lines = [] + File.readlines(filename).each_with_index do |line, number| + next if line =~ /^\s+#.*\s+\n$/ + failing_lines << number + 1 if line =~ /\s+\n$/ + end + + "#{filename} has spaces on the EOL on lines #{failing_lines.join(', ')}" unless failing_lines.empty? + end + + RSpec::Matchers.define :be_well_formed do + failure_message do |actual| + actual.join("\n") + end + + match(&:empty?) + end + + it "has no malformed whitespace" do + 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| + next if filename =~ exempt + error_messages << check_for_tab_characters(filename) + error_messages << check_for_extra_spaces(filename) + end + end + expect(error_messages.compact).to be_well_formed + end + + it "uses double-quotes consistently in specs" do + included = /spec/ + error_messages = [] + Dir.chdir(File.expand_path("../", __FILE__)) do + `git ls-files`.split("\n").each do |filename| + next unless filename =~ included + error_messages << check_for_spec_defs_with_single_quotes(filename) + end + end + expect(error_messages.compact).to be_well_formed + end +end diff --git a/spec/rake_compat_spec.rb b/spec/rake_compat_spec.rb index b081ddaef..5a20556f3 100644 --- a/spec/rake_compat_spec.rb +++ b/spec/rake_compat_spec.rb @@ -1,6 +1,6 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') -require 'thor/rake_compat' -require 'rake/tasklib' +require "helper" +require "thor/rake_compat" +require "rake/tasklib" $main = self @@ -32,7 +32,7 @@ class ThorTask < Thor describe Thor::RakeCompat do it "sets the rakefile application" do - expect(["rake_compat_spec.rb", "Thorfile"]).to include(Rake.application.rakefile) + expect(%w(rake_compat_spec.rb Thorfile)).to include(Rake.application.rakefile) end it "adds rake tasks to thor classes too" do @@ -61,12 +61,12 @@ class ThorTask < Thor end it "invoking the thor task invokes the rake task" do - expect(capture(:stdout) { - ThorTask.start ["cool"] - }).to eq("COOL\n") + expect(capture(:stdout) do + ThorTask.start %w(cool) + end).to eq("COOL\n") - expect(capture(:stdout) { - ThorTask::HiperMega.start ["super"] - }).to eq("HIPER MEGA SUPER\n") + expect(capture(:stdout) do + ThorTask::HiperMega.start %w(super) + end).to eq("HIPER MEGA SUPER\n") end end diff --git a/spec/register_spec.rb b/spec/register_spec.rb index 0b05fda6a..d7524882d 100644 --- a/spec/register_spec.rb +++ b/spec/register_spec.rb @@ -1,4 +1,4 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') +require "helper" class BoringVendorProvidedCLI < Thor desc "boring", "do boring stuff" @@ -20,7 +20,7 @@ def fireworks end class SuperSecretPlugin < Thor - default_task :squirrel + default_command :squirrel desc "squirrel", "All of secret squirrel's secrets" def squirrel @@ -42,12 +42,12 @@ def part_two class ClassOptionGroupPlugin < Thor::Group class_option :who, - :type => :string, - :aliases => "-w", - :default => "zebra" + type: :string, + aliases: "-w", + default: "zebra" end -class CompatibleWith19Plugin < ClassOptionGroupPlugin +class PluginInheritingFromClassOptionsGroup < ClassOptionGroupPlugin desc "animal" def animal p options[:who] @@ -60,7 +60,7 @@ def say(msg) puts msg end - default_task :say + default_command :say end class PluginWithDefaultMultipleArguments < Thor @@ -69,95 +69,132 @@ def say(*args) puts args end - default_task :say + default_command :say end -class PluginWithDefaultTaskAndDeclaredArgument < Thor +class PluginWithDefaultcommandAndDeclaredArgument < Thor desc "say MSG [MSG]", "print multiple messages" argument :msg def say puts msg end - default_task :say + default_command :say +end + +class SubcommandWithDefault < Thor + default_command :default + + desc "default", "default subcommand" + def default + puts "default" + end + + desc "with_args", "subcommand with arguments" + def with_args(*args) + puts "received arguments: " + args.join(",") + end end BoringVendorProvidedCLI.register( ExcitingPluginCLI, "exciting", "do exciting things", - "Various non-boring actions") + "Various non-boring actions" +) BoringVendorProvidedCLI.register( SuperSecretPlugin, "secret", "secret stuff", "Nothing to see here. Move along.", - :hide => true) + hide: true +) BoringVendorProvidedCLI.register( GroupPlugin, - 'groupwork', + "groupwork", "Do a bunch of things in a row", - "purple monkey dishwasher") + "purple monkey dishwasher" +) BoringVendorProvidedCLI.register( - CompatibleWith19Plugin, - 'zoo', + PluginInheritingFromClassOptionsGroup, + "zoo", "zoo [-w animal]", - "Shows a provided animal or just zebra") + "Shows a provided animal or just zebra" +) BoringVendorProvidedCLI.register( PluginWithDefault, - 'say', - 'say message', - 'subcommands ftw') + "say", + "say message", + "subcommands ftw" +) BoringVendorProvidedCLI.register( PluginWithDefaultMultipleArguments, - 'say_multiple', - 'say message', - 'subcommands ftw') + "say_multiple", + "say message", + "subcommands ftw" +) BoringVendorProvidedCLI.register( - PluginWithDefaultTaskAndDeclaredArgument, - 'say_argument', - 'say message', - 'subcommands ftw') + PluginWithDefaultcommandAndDeclaredArgument, + "say_argument", + "say message", + "subcommands ftw" +) + +BoringVendorProvidedCLI.register(SubcommandWithDefault, + "subcommand", "subcommand", "Run subcommands") describe ".register-ing a Thor subclass" do it "registers the plugin as a subcommand" do - fireworks_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[exciting fireworks]) } + fireworks_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(exciting fireworks)) } expect(fireworks_output).to eq("kaboom!\n") end it "includes the plugin's usage in the help" do - help_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[help]) } - expect(help_output).to include('do exciting things') + help_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(help)) } + expect(help_output).to include("do exciting things") end - it "invokes the default task correctly" do - output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[say hello]) } - expect(output).to include("hello") - end + context "with a default command," do + it "invokes the default command correctly" do + output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(say hello)) } + expect(output).to include("hello") + end - it "invokes the default task correctly with multiple args" do - output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[say_multiple hello adam]) } - expect(output).to include("hello") - expect(output).to include("adam") - end + it "invokes the default command correctly with multiple args" do + output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(say_multiple hello adam)) } + expect(output).to include("hello") + expect(output).to include("adam") + end + + it "invokes the default command correctly with a declared argument" do + output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(say_argument hello)) } + expect(output).to include("hello") + end + + it "displays the subcommand's help message" do + output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(subcommand help)) } + expect(output).to include("default subcommand") + expect(output).to include("subcommand with argument") + end - it "invokes the default task correctly with a declared argument" do - output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[say_argument hello]) } - expect(output).to include("hello") + it "invokes commands with their actual args" do + output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(subcommand with_args actual_argument)) } + expect(output.strip).to eql("received arguments: actual_argument") + end end context "when $thor_runner is false" do it "includes the plugin's subcommand name in subcommand's help" do begin $thor_runner = false - help_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[exciting]) } - expect(help_output).to include('thor exciting_plugin_c_l_i fireworks') + help_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(exciting)) } + expect(help_output).to include("thor exciting fireworks") ensure $thor_runner = true end @@ -166,12 +203,12 @@ def say context "when hidden" do it "omits the hidden plugin's usage from the help" do - help_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[help]) } - expect(help_output).not_to include('secret stuff') + help_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(help)) } + expect(help_output).not_to include("secret stuff") end it "registers the plugin as a subcommand" do - secret_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[secret squirrel]) } + secret_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(secret squirrel)) } expect(secret_output).to eq("I love nuts\n") end end @@ -179,19 +216,19 @@ def say describe ".register-ing a Thor::Group subclass" do it "registers the group as a single command" do - group_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[groupwork]) } + group_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(groupwork)) } expect(group_output).to eq("part one\npart two\n") end end -describe "1.8 and 1.9 syntax compatibility" do - it "is compatible with both 1.8 and 1.9 syntax w/o task options" do - group_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[zoo]) } +describe ".register-ing a Thor::Group subclass with class options" do + it "works w/o command options" do + group_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(zoo)) } expect(group_output).to match(/zebra/) end - it "is compatible with both 1.8 and 1.9 syntax w/task options" do - group_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w[zoo -w lion]) } + it "works w/command options" do + group_output = capture(:stdout) { BoringVendorProvidedCLI.start(%w(zoo -w lion)) } expect(group_output).to match(/lion/) end end diff --git a/spec/runner_spec.rb b/spec/runner_spec.rb index da1a27c9a..ea31fd2ff 100644 --- a/spec/runner_spec.rb +++ b/spec/runner_spec.rb @@ -1,12 +1,12 @@ -require File.expand_path(File.dirname(__FILE__) + "/spec_helper") -require 'thor/runner' +require "helper" +require "thor/runner" describe Thor::Runner do def when_no_thorfiles_exist old_dir = Dir.pwd - Dir.chdir '..' - delete = Thor::Base.subclasses.select {|e| e.namespace == 'default' } - delete.each {|e| Thor::Base.subclasses.delete e } + Dir.chdir ".." + delete = Thor::Base.subclasses.select { |e| e.namespace == "default" } + delete.each { |e| Thor::Base.subclasses.delete e } yield Thor::Base.subclasses.concat delete Dir.chdir old_dir @@ -14,177 +14,178 @@ def when_no_thorfiles_exist describe "#help" do it "shows information about Thor::Runner itself" do - expect(capture(:stdout) { Thor::Runner.start(["help"]) }).to match(/List the available thor tasks/) + expect(capture(:stdout) { Thor::Runner.start(%w(help)) }).to match(/List the available thor commands/) end - it "shows information about an specific Thor::Runner task" do - content = capture(:stdout) { Thor::Runner.start(["help", "list"]) } - expect(content).to match(/List the available thor tasks/) - expect(content).not_to match(/help \[TASK\]/) + it "shows information about a specific Thor::Runner command" do + content = capture(:stdout) { Thor::Runner.start(%w(help list)) } + expect(content).to match(/List the available thor commands/) + expect(content).not_to match(/help \[COMMAND\]/) end it "shows information about a specific Thor class" do - content = capture(:stdout) { Thor::Runner.start(["help", "my_script"]) } + content = capture(:stdout) { Thor::Runner.start(%w(help my_script)) } expect(content).to match(/zoo\s+# zoo around/m) end - it "shows information about an specific task from an specific Thor class" do - content = capture(:stdout) { Thor::Runner.start(["help", "my_script:zoo"]) } + it "shows information about a specific command from a specific Thor class" do + content = capture(:stdout) { Thor::Runner.start(%w(help my_script:zoo)) } expect(content).to match(/zoo around/) - expect(content).not_to match(/help \[TASK\]/) + expect(content).not_to match(/help \[COMMAND\]/) end it "shows information about a specific Thor group class" do - content = capture(:stdout) { Thor::Runner.start(["help", "my_counter"]) } + content = capture(:stdout) { Thor::Runner.start(%w(help my_counter)) } expect(content).to match(/my_counter N/) end - it "raises error if a class/task cannot be found" do - content = capture(:stderr){ Thor::Runner.start(["help", "unknown"]) } - expect(content.strip).to eq('Could not find task "unknown" in "default" namespace.') + it "raises error if a class/command cannot be found" do + content = capture(:stderr) { Thor::Runner.start(%w(help unknown)) } + expect(content.strip).to eq('Could not find command "unknown" in "default" namespace.') end - it "raises error if a class/task cannot be found for a setup without thorfiles" do + it "raises error if a class/command cannot be found for a setup without thorfiles" do when_no_thorfiles_exist do - Thor::Runner.should_receive :exit - content = capture(:stderr){ Thor::Runner.start(["help", "unknown"]) } - expect(content.strip).to eq('Could not find task "unknown".') + expect(Thor::Runner).to receive :exit + content = capture(:stderr) { Thor::Runner.start(%w(help unknown)) } + expect(content.strip).to eq('Could not find command "unknown".') end end end describe "#start" do - it "invokes a task from Thor::Runner" do - ARGV.replace ["list"] + it "invokes a command from Thor::Runner" do + ARGV.replace %w(list) expect(capture(:stdout) { Thor::Runner.start }).to match(/my_counter N/) end - it "invokes a task from a specific Thor class" do - ARGV.replace ["my_script:zoo"] - expect(Thor::Runner.start).to be_true + it "invokes a command from a specific Thor class" do + ARGV.replace %w(my_script:zoo) + expect(Thor::Runner.start).to be true end - it "invokes the default task from a specific Thor class if none is specified" do - ARGV.replace ["my_script"] - expect(Thor::Runner.start).to eq("default task") + it "invokes the default command from a specific Thor class if none is specified" do + ARGV.replace %w(my_script) + expect(Thor::Runner.start).to eq("default command") end - it "forwads arguments to the invoked task" do - ARGV.replace ["my_script:animal", "horse"] - expect(Thor::Runner.start).to eq(["horse"]) + it "forwards arguments to the invoked command" do + ARGV.replace %w(my_script:animal horse) + expect(Thor::Runner.start).to eq(%w(horse)) end - it "invokes tasks through shortcuts" do - ARGV.replace ["my_script", "-T", "horse"] - expect(Thor::Runner.start).to eq(["horse"]) + it "invokes commands through shortcuts" do + ARGV.replace %w(my_script -T horse) + expect(Thor::Runner.start).to eq(%w(horse)) end it "invokes a Thor::Group" do - ARGV.replace ["my_counter", "1", "2", "--third", "3"] - expect(Thor::Runner.start).to eq([1, 2, 3]) + ARGV.replace %w(my_counter 1 2 --third 3) + expect(Thor::Runner.start).to eq([1, 2, 3, nil, nil, nil]) end - it "raises an error if class/task can't be found" do - ARGV.replace ["unknown"] - content = capture(:stderr){ Thor::Runner.start } - expect(content.strip).to eq('Could not find task "unknown" in "default" namespace.') + it "raises an error if class/command can't be found" do + ARGV.replace %w(unknown) + content = capture(:stderr) { Thor::Runner.start } + expect(content.strip).to eq('Could not find command "unknown" in "default" namespace.') end - it "raises an error if class/task can't be found in a setup without thorfiles" do + it "raises an error if class/command can't be found in a setup without thorfiles" do when_no_thorfiles_exist do - ARGV.replace ["unknown"] - Thor::Runner.should_receive :exit - content = capture(:stderr){ Thor::Runner.start } - expect(content.strip).to eq('Could not find task "unknown".') + ARGV.replace %w(unknown) + expect(Thor::Runner).to receive :exit + content = capture(:stderr) { Thor::Runner.start } + expect(content.strip).to eq('Could not find command "unknown".') end end it "does not swallow NoMethodErrors that occur inside the called method" do - ARGV.replace ["my_script:call_unexistent_method"] - expect{ Thor::Runner.start }.to raise_error(NoMethodError) + ARGV.replace %w(my_script:call_unexistent_method) + expect { Thor::Runner.start }.to raise_error(NoMethodError) end it "does not swallow Thor::Group InvocationError" do - ARGV.replace ["whiny_generator"] - expect{ Thor::Runner.start }.to raise_error(ArgumentError, /thor wrong_arity takes 1 argument, but it should not/) + ARGV.replace %w(whiny_generator) + expect { Thor::Runner.start }.to raise_error(ArgumentError, /thor wrong_arity takes 1 argument, but it should not/) end it "does not swallow Thor InvocationError" do - ARGV.replace ["my_script:animal"] + ARGV.replace %w(my_script:animal) content = capture(:stderr) { Thor::Runner.start } - expect(content.strip).to eq('thor animal requires at least 1 argument: "thor my_script:animal TYPE".') + expect(content.strip).to eq('ERROR: "thor animal" was called with no arguments +Usage: "thor my_script:animal TYPE"') end end - describe "tasks" do + describe "commands" do + let(:location) { "#{File.dirname(__FILE__)}/fixtures/command.thor" } before do - @location = "#{File.dirname(__FILE__)}/fixtures/task.thor" @original_yaml = { "random" => { - :location => @location, - :filename => "4a33b894ffce85d7b412fc1b36f88fe0", - :namespaces => ["amazing"] + location: location, + filename: "4a33b894ffce85d7b412fc1b36f88fe0", + namespaces: %w(amazing) } } root_file = File.join(Thor::Util.thor_root, "thor.yml") # Stub load and save to avoid thor.yaml from being overwritten - YAML.stub!(:load_file).and_return(@original_yaml) - File.stub!(:exists?).with(root_file).and_return(true) - File.stub!(:open).with(root_file, "w") + allow(YAML).to receive(:load_file).and_return(@original_yaml) + allow(File).to receive(:exist?).with(root_file).and_return(true) + allow(File).to receive(:open).with(root_file, "w") end describe "list" do - it "gives a list of the available tasks" do - ARGV.replace ["list"] + it "gives a list of the available commands" do + ARGV.replace %w(list) content = capture(:stdout) { Thor::Runner.start } expect(content).to match(/amazing:describe NAME\s+# say that someone is amazing/m) end it "gives a list of the available Thor::Group classes" do - ARGV.replace ["list"] + ARGV.replace %w(list) expect(capture(:stdout) { Thor::Runner.start }).to match(/my_counter N/) end - it "can filter a list of the available tasks by --group" do - ARGV.replace ["list", "--group", "standard"] + it "can filter a list of the available commands by --group" do + ARGV.replace %w(list --group standard) expect(capture(:stdout) { Thor::Runner.start }).to match(/amazing:describe NAME/) ARGV.replace [] expect(capture(:stdout) { Thor::Runner.start }).not_to match(/my_script:animal TYPE/) - ARGV.replace ["list", "--group", "script"] + ARGV.replace %w(list --group script) expect(capture(:stdout) { Thor::Runner.start }).to match(/my_script:animal TYPE/) end - it "can skip all filters to show all tasks using --all" do - ARGV.replace ["list", "--all"] + it "can skip all filters to show all commands using --all" do + ARGV.replace %w(list --all) content = capture(:stdout) { Thor::Runner.start } expect(content).to match(/amazing:describe NAME/) expect(content).to match(/my_script:animal TYPE/) end - it "doesn't list superclass tasks in the subclass" do - ARGV.replace ["list"] + it "doesn't list superclass commands in the subclass" do + ARGV.replace %w(list) expect(capture(:stdout) { Thor::Runner.start }).not_to match(/amazing:help/) end - it "presents tasks in the default namespace with an empty namespace" do - ARGV.replace ["list"] + it "presents commands in the default namespace with an empty namespace" do + ARGV.replace %w(list) expect(capture(:stdout) { Thor::Runner.start }).to match(/^thor :cow\s+# prints 'moo'/m) end - it "runs tasks with an empty namespace from the default namespace" do - ARGV.replace [":task_conflict"] - expect(capture(:stdout) { Thor::Runner.start }).to eq("task\n") + it "runs commands with an empty namespace from the default namespace" do + ARGV.replace %w(:command_conflict) + expect(capture(:stdout) { Thor::Runner.start }).to eq("command\n") end - it "runs groups even when there is a task with the same name" do - ARGV.replace ["task_conflict"] + it "runs groups even when there is a command with the same name" do + ARGV.replace %w(command_conflict) expect(capture(:stdout) { Thor::Runner.start }).to eq("group\n") end - it "runs tasks with no colon in the default namespace" do - ARGV.replace ["cow"] + it "runs commands with no colon in the default namespace" do + ARGV.replace %w(cow) expect(capture(:stdout) { Thor::Runner.start }).to eq("moo\n") end end @@ -192,49 +193,74 @@ def when_no_thorfiles_exist describe "uninstall" do before do path = File.join(Thor::Util.thor_root, @original_yaml["random"][:filename]) - FileUtils.should_receive(:rm_rf).with(path) + expect(FileUtils).to receive(:rm_rf).with(path) end it "uninstalls existing thor modules" do - silence(:stdout) { Thor::Runner.start(["uninstall", "random"]) } + silence(:stdout) { Thor::Runner.start(%w(uninstall random)) } end end describe "installed" do before do - Dir.should_receive(:[]).and_return([]) + expect(Dir).to receive(:[]).and_return([]) end it "displays the modules installed in a pretty way" do - stdout = capture(:stdout) { Thor::Runner.start(["installed"]) } + stdout = capture(:stdout) { Thor::Runner.start(%w(installed)) } expect(stdout).to match(/random\s*amazing/) expect(stdout).to match(/amazing:describe NAME\s+# say that someone is amazing/m) end end describe "install/update" do - before do - FileUtils.stub!(:mkdir_p) - FileUtils.stub!(:touch) - $stdin.stub!(:gets).and_return("Y") + 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 - path = File.join(Thor::Util.thor_root, Digest::MD5.hexdigest(@location + "random")) - File.should_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) + end + silence_warnings do + silence(:stdout) { Thor::Runner.start(%w(update random)) } + end + end - it "updates existing thor files" do - path = File.join(Thor::Util.thor_root, @original_yaml["random"][:filename]) - if File.directory? path - FileUtils.should_receive(:rm_rf).with(path) - else - File.should_receive(:delete).with(path) + it "installs thor files" do + ARGV.replace %W(install #{location}) + silence_warnings do + silence(:stdout) { Thor::Runner.start } + end end - silence(:stdout) { Thor::Runner.start(["update", "random"]) } end - it "installs thor files" do - ARGV.replace ["install", @location] - 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 end diff --git a/spec/script_exit_status_spec.rb b/spec/script_exit_status_spec.rb new file mode 100644 index 000000000..6021b2b45 --- /dev/null +++ b/spec/script_exit_status_spec.rb @@ -0,0 +1,29 @@ +describe "when the Thor class's exit_with_failure? method returns true" do + 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']}" + + full_command= "ruby #{script_path} #{command}" + r,w= IO.pipe + pid= spawn({"RUBYLIB" => ruby_lib}, + full_command, + {out: w, err: [:child, :out]}) + w.close + + _, exit_status= Process.wait2(pid) + r.read + r.close + + exit_status.exitstatus + end + + it "a command that raises a Thor::Error exits with a status of 1" do + expect(thor_command("error")).to eq(1) + end + + it "a command that does not raise a Thor::Error exits with a status of 0" do + expect(thor_command("ok")).to eq(0) + end +end diff --git a/spec/shell/basic_spec.rb b/spec/shell/basic_spec.rb index 820a06e5e..659afd2e4 100644 --- a/spec/shell/basic_spec.rb +++ b/spec/shell/basic_spec.rb @@ -1,6 +1,5 @@ -# encoding: UTF-8 - -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') +# coding: utf-8 +require "helper" describe Thor::Shell::Basic do def shell @@ -17,125 +16,321 @@ def shell end end + describe "#indent" do + it "sets the padding temporarily" do + shell.indent { expect(shell.padding).to eq(1) } + expect(shell.padding).to eq(0) + end + + it "derives padding from original value" do + shell.padding = 6 + shell.indent { expect(shell.padding).to eq(7) } + end + + it "accepts custom indentation amounts" do + shell.indent(6) do + expect(shell.padding).to eq(6) + end + end + + it "increases the padding when nested" do + shell.indent do + expect(shell.padding).to eq(1) + + shell.indent do + expect(shell.padding).to eq(2) + end + end + expect(shell.padding).to eq(0) + end + end + describe "#ask" do it "prints a message to the user and gets the response" do - $stdout.should_receive(:print).with("Should I overwrite it? ") - $stdin.should_receive(:gets).and_return('Sure') + expect(Thor::LineEditor).to receive(:readline).with("Should I overwrite it? ", {}).and_return("Sure") expect(shell.ask("Should I overwrite it?")).to eq("Sure") end - it "prints a message and returns nil if EOF is sent to stdin" do - $stdout.should_receive(:print).with(" ") - $stdin.should_receive(:gets).and_return(nil) + it "prints a message to the user prefixed with the current padding" do + expect(Thor::LineEditor).to receive(:readline).with(" Enter your name: ", {}).and_return("George") + shell.padding = 2 + shell.ask("Enter your name:") + end + + it "prints a message and returns nil if EOF is given as input" do + expect(Thor::LineEditor).to receive(:readline).with(" ", {}).and_return(nil) expect(shell.ask("")).to eq(nil) 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($stdin).to receive(:noecho).and_return("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") + 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") + end - it "prints a message to the user with the available options and determines the correctness of the answer" do - $stdout.should_receive(:print).with('What\'s your favorite Neopolitan flavor? ["strawberry", "chocolate", "vanilla"] ') - $stdin.should_receive(:gets).and_return('chocolate') - expect(shell.ask("What's your favorite Neopolitan flavor?", :limited_to => ["strawberry", "chocolate", "vanilla"])).to eq("chocolate") + 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") end - it "prints a message to the user with the available options and reasks the question after an incorrect repsonse" do - $stdout.should_receive(:print).with('What\'s your favorite Neopolitan flavor? ["strawberry", "chocolate", "vanilla"] ').twice - $stdout.should_receive(:puts).with('Your response must be one of: ["strawberry", "chocolate", "vanilla"]. Please try again.') - $stdin.should_receive(:gets).and_return('moose tracks', 'chocolate') - expect(shell.ask("What's your favorite Neopolitan flavor?", :limited_to => ["strawberry", "chocolate", "vanilla"])).to eq("chocolate") + 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") + 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") + 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") + 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") end end describe "#yes?" do it "asks the user and returns true if the user replies yes" do - $stdout.should_receive(:print).with("Should I overwrite it? ") - $stdin.should_receive(:gets).and_return('y') - expect(shell.yes?("Should I overwrite it?")).to be_true + 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 - $stdout.should_receive(:print).with("Should I overwrite it? ") - $stdin.should_receive(:gets).and_return('n') - expect(shell.yes?("Should I overwrite it?")).not_to be_true + 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(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(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 - $stdout.should_receive(:print).with("Should I overwrite it? ") - $stdin.should_receive(:gets).and_return('n') - expect(shell.no?("Should I overwrite it?")).to be_true + 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(shell.no?("Should I overwrite it?")).to be false + end - $stdout.should_receive(:print).with("Should I overwrite it? ") - $stdin.should_receive(:gets).and_return('Yes') - expect(shell.no?("Should I overwrite it?")).to be_false + 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(shell.no?("Should I overwrite it?")).to be false end end describe "#say" do it "prints a message to the user" do - $stdout.should_receive(:puts).with("Running...") + expect($stdout).to receive(:print).with("Running...\n") shell.say("Running...") end it "prints a message to the user without new line if it ends with a whitespace" do - $stdout.should_receive(:print).with("Running... ") + expect($stdout).to receive(:print).with("Running... ") shell.say("Running... ") end it "does not use a new line with whitespace+newline embedded" do - $stdout.should_receive(:puts).with("It's \nRunning...") + expect($stdout).to receive(:print).with("It's \nRunning...\n") shell.say("It's \nRunning...") end it "prints a message to the user without new line" do - $stdout.should_receive(:print).with("Running...") + expect($stdout).to receive(:print).with("Running...") shell.say("Running...", nil, false) end + + it "coerces everything to a string before printing" do + expect($stdout).to receive(:print).with("this_is_not_a_string\n") + shell.say(:this_is_not_a_string, nil, true) + end + + it "does not print a message if muted" do + expect($stdout).not_to receive(:print) + shell.mute do + shell.say("Running...") + end + end + + 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($stdout).not_to receive(:print) + shell.say("Running...") + end + end + + describe "#say_error" do + it "prints a message to the user" do + expect($stderr).to receive(:print).with("Running...\n") + shell.say_error("Running...") + end + + it "prints a message to the user without new line if it ends with a whitespace" do + expect($stderr).to receive(:print).with("Running... ") + shell.say_error("Running... ") + end + + it "does not use a new line with whitespace+newline embedded" do + expect($stderr).to receive(:print).with("It's \nRunning...\n") + shell.say_error("It's \nRunning...") + end + + it "prints a message to the user without new line" do + expect($stderr).to receive(:print).with("Running...") + shell.say_error("Running...", nil, false) + end + + it "coerces everything to a string before printing" do + expect($stderr).to receive(:print).with("this_is_not_a_string\n") + shell.say_error(:this_is_not_a_string, nil, true) + end + + it "does not print a message if muted" do + expect($stderr).not_to receive(:print) + shell.mute do + shell.say_error("Running...") + end + end + + 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($stderr).not_to receive(:print) + shell.say_error("Running...") + end + end + + describe "#print_wrapped" do + let(:message) do + "Creates a back-up of the given folder by compressing it in a .tar.gz\n"\ + "file and then uploading it to the configured Amazon S3 Bucket.\n\n"\ + "It does not verify the integrity of the generated back-up." + end + + before do + allow(ENV).to receive(:[]).with("THOR_COLUMNS").and_return(80) + end + + context "without indentation" do + subject(:wrap_text) { described_class.new.print_wrapped(message) } + + let(:expected_output) do + "Creates a back-up of the given folder by compressing it in a .tar.gz file and\n"\ + "then uploading it to the configured Amazon S3 Bucket.\n\n"\ + "It does not verify the integrity of the generated back-up.\n" + end + + it "properly wraps the text around the 80th column" do + expect { wrap_text }.to output(expected_output).to_stdout + end + end + + context "with indentation" do + 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"\ + " and then uploading it to the configured Amazon S3 Bucket.\n\n"\ + " It does not verify the integrity of the generated back-up.\n" + end + + it "properly wraps the text around the 80th column" do + expect { wrap_text }.to output(expected_output).to_stdout + end + end end describe "#say_status" do it "prints a message to the user with status" do - $stdout.should_receive(:puts).with(" create ~/.thor/task.thor") - shell.say_status(:create, "~/.thor/task.thor") + expect($stdout).to receive(:print).with(" create ~/.thor/command.thor\n") + shell.say_status(:create, "~/.thor/command.thor") end - it "always use new line" do - $stdout.should_receive(:puts).with(" create ") + it "always uses new line" do + expect($stdout).to receive(:print).with(" create \n") 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 - shell.should_receive(:mute?).and_return(true) - $stdout.should_not_receive(:puts) + expect(shell).to receive(:mute?).and_return(true) + expect($stdout).not_to receive(:print) shell.mute do - shell.say_status(:created, "~/.thor/task.thor") + shell.say_status(:created, "~/.thor/command.thor") end end it "does not print a message if base is set to quiet" do - base = MyCounter.new [1,2] - base.should_receive(:options).and_return(:quiet => true) + base = MyCounter.new [1, 2] + expect(base).to receive(:options).and_return(quiet: true) - $stdout.should_not_receive(:puts) + expect($stdout).not_to receive(:print) shell.base = base - shell.say_status(:created, "~/.thor/task.thor") + shell.say_status(:created, "~/.thor/command.thor") end it "does not print a message if log status is set to false" do - $stdout.should_not_receive(:puts) - shell.say_status(:created, "~/.thor/task.thor", false) + expect($stdout).not_to receive(:print) + shell.say_status(:created, "~/.thor/command.thor", false) end - it "uses padding to set messages left margin" do + it "uses padding to set message's left margin" do shell.padding = 2 - $stdout.should_receive(:puts).with(" create ~/.thor/task.thor") - shell.say_status(:create, "~/.thor/task.thor") + expect($stdout).to receive(:print).with(" create ~/.thor/command.thor\n") + shell.say_status(:create, "~/.thor/command.thor") end end describe "#print_in_columns" do before do - @array = [1234567890] - @array += ('a'..'e').to_a + @array = [1_234_567_890] + @array += ("a".."e").to_a end it "prints in columns" do @@ -162,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 @@ -173,8 +368,7 @@ def shell it "uses maximum terminal width" do @table << ["def", "#456", "Lançam foo bar"] @table << ["ghi", "#789", "بالله عليكم"] - shell.should_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 @@ -185,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 @@ -197,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 @@ -205,7 +399,7 @@ def #456 Lanç... it "prints a table with small numbers and right-aligns them" do table = [ - ["Name", "Number", "Color"], + ["Name", "Number", "Color"], # rubocop: disable Style/WordArray ["Erik", 1, "green"] ] content = capture(:stdout) { shell.print_table(table) } @@ -217,7 +411,7 @@ def #456 Lanç... it "doesn't output extra spaces for right-aligned columns in the last column" do table = [ - ["Name", "Number"], + ["Name", "Number"], # rubocop: disable Style/WordArray ["Erik", 1] ] content = capture(:stdout) { shell.print_table(table) } @@ -229,83 +423,153 @@ def #456 Lanç... it "prints a table with big numbers" do table = [ - ["Name", "Number", "Color"], - ["Erik", 1234567890123, "green"] + ["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 - $stdout.should_receive(:print).with('Overwrite foo? (enter "h" for help) [Ynaqh] ') - $stdin.should_receive(:gets).and_return('n') - shell.file_collision('foo') + 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 - it "returns true if the user choose default option" do - $stdout.stub!(:print) - $stdin.should_receive(:gets).and_return('') - expect(shell.file_collision('foo')).to be_true + it "outputs a new line and returns true if stdin is closed" do + expect($stdout).to receive(:print).with("\n") + expect(Thor::LineEditor).to receive(:readline).and_return(nil) + expect(shell.file_collision("foo")).to be true end - it "returns false if the user choose no" do - $stdout.stub!(:print) - $stdin.should_receive(:gets).and_return('n') - expect(shell.file_collision('foo')).to be_false + it "returns true if the user chooses default option" do + expect(Thor::LineEditor).to receive(:readline).and_return("") + expect(shell.file_collision("foo")).to be true end - it "returns true if the user choose yes" do - $stdout.stub!(:print) - $stdin.should_receive(:gets).and_return('y') - expect(shell.file_collision('foo')).to be_true + it "returns false if the user chooses no" do + expect(Thor::LineEditor).to receive(:readline).and_return("n") + expect(shell.file_collision("foo")).to be false end - it "shows help usage if the user choose help" do - $stdout.stub!(:print) - $stdin.should_receive(:gets).and_return('h') - $stdin.should_receive(:gets).and_return('n') - help = capture(:stdout) { shell.file_collision('foo') } + it "returns true if the user chooses yes" do + expect(Thor::LineEditor).to receive(:readline).and_return("y") + expect(shell.file_collision("foo")).to be true + end + + it "shows help usage if the user chooses help" do + expect(Thor::LineEditor).to receive(:readline).and_return("h", "n") + help = capture(:stdout) { shell.file_collision("foo") } expect(help).to match(/h \- help, show this help/) end - it "quits if the user choose quit" do - $stdout.stub!(:print) - $stdout.should_receive(:puts).with('Aborting...') - $stdin.should_receive(:gets).and_return('q') + it "quits if the user chooses quit" do + expect($stdout).to receive(:print).with("Aborting...\n") + expect(Thor::LineEditor).to receive(:readline).and_return("q") - expect { - shell.file_collision('foo') - }.to raise_error(SystemExit) + expect do + shell.file_collision("foo") + end.to raise_error(SystemExit) end - it "always returns true if the user choose always" do - $stdout.should_receive(:print).with('Overwrite foo? (enter "h" for help) [Ynaqh] ') - $stdin.should_receive(:gets).and_return('a') + 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(shell.file_collision('foo')).to be_true + expect(shell.file_collision("foo")).to be true - $stdout.should_not_receive(:print) - expect(shell.file_collision('foo')).to be_true + expect($stdout).not_to receive(:print) + expect(shell.file_collision("foo")).to be true end describe "when a block is given" do - it "displays diff options to the user" do - $stdout.should_receive(:print).with('Overwrite foo? (enter "h" for help) [Ynaqdh] ') - $stdin.should_receive(:gets).and_return('s') - shell.file_collision('foo'){ } + 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") + shell.file_collision("foo") {} end it "invokes the diff command" do - $stdout.stub!(:print) - $stdin.should_receive(:gets).and_return('d') - $stdin.should_receive(:gets).and_return('n') - shell.should_receive(:system).with(/diff -u/) - capture(:stdout) { shell.file_collision('foo'){ } } + expect(Thor::LineEditor).to receive(:readline).and_return("d") + expect(Thor::LineEditor).to receive(:readline).and_return("n") + expect(shell).to receive(:system).with(/diff -u/) + capture(:stdout) { shell.file_collision("foo") {} } + end + + it "invokes the merge tool" do + allow(shell).to receive(:merge_tool).and_return("meld") + expect(Thor::LineEditor).to receive(:readline).and_return("m") + expect(shell).to receive(:system).with("meld", /foo/, "foo") + capture(:stdout) { shell.file_collision("foo") {} } + end + + it "invokes the merge tool that specified at ENV['THOR_MERGE']" do + allow(ENV).to receive(:[]).with("THOR_MERGE").and_return("meld") + expect(Thor::LineEditor).to receive(:readline).and_return("m") + expect(shell).to receive(:system).with("meld", /foo/, "foo") + capture(:stdout) { shell.file_collision("foo") {} } + end + + it "show warning if user chooses merge but merge tool is not specified" do + allow(shell).to receive(:merge_tool).and_return("") + expect(Thor::LineEditor).to receive(:readline).and_return("m") + expect(Thor::LineEditor).to receive(:readline).and_return("n") + help = capture(:stdout) { shell.file_collision("foo") {} } + expect(help).to match(/Please specify merge tool to `THOR_MERGE` env/) end end end diff --git a/spec/shell/color_spec.rb b/spec/shell/color_spec.rb index 6eee09459..3b40c0b35 100644 --- a/spec/shell/color_spec.rb +++ b/spec/shell/color_spec.rb @@ -1,12 +1,66 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') +require "helper" describe Thor::Shell::Color do def shell @shell ||= Thor::Shell::Color.new end + before do + allow($stdout).to receive(:tty?).and_return(true) + allow(ENV).to receive(:[]).and_return(nil) + allow(ENV).to receive(:[]).with("TERM").and_return("ansi") + allow_any_instance_of(StringIO).to receive(:tty?).and_return(true) + end + + describe "#ask" do + it "sets the color if specified and tty?" do + 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 "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) + 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 + expect(Thor::LineEditor).to receive(:readline).with("\e[32m\e[47m\e[1mIs this green on white? \e[0m", anything).and_return("yes") + shell.ask "Is this green on white?", [:green, :on_white, :bold] + end + + it "supports the legacy color syntax" do + expect(Thor::LineEditor).to receive(:readline).with("\e[1m\e[34mIs this legacy blue? \e[0m", anything).and_return("yes") + shell.ask "Is this legacy blue?", [:blue, true] + end + end + describe "#say" do - it "set the color if specified" do + it "set the color if specified and tty?" do out = capture(:stdout) do shell.say "Wow! Now we have colors!", :green end @@ -14,6 +68,42 @@ def shell expect(out.chomp).to eq("\e[32mWow! Now we have colors!\e[0m") end + it "does not set the color if output is not a tty" do + out = capture(:stdout) do + expect($stdout).to receive(:tty?).and_return(false) + shell.say "Wow! Now we have colors!", :green + end + + expect(out.chomp).to eq("Wow! Now we have colors!") + end + + 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 "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 "does not use a new line even with colors" do out = capture(:stdout) do shell.say "Wow! Now we have colors! ", :green @@ -29,6 +119,14 @@ def shell expect(out.chomp).to eq("\e[32m\e[41m\e[1mWow! Now we have colors *and* background colors\e[0m") end + + it "supports the legacy color syntax" do + out = capture(:stdout) do + shell.say "Wow! This still works?", [:blue, true] + end + + expect(out.chomp).to eq("\e[1m\e[34mWow! This still works?\e[0m") + end end describe "#say_status" do @@ -62,16 +160,65 @@ def shell bold = shell.set_color "hi!", :white, :on_red, :bold expect(bold).to eq("\e[37m\e[41m\e[1mhi!\e[0m") end + + it "does nothing when there are no colors" do + colorless = shell.set_color "hi!", nil + expect(colorless).to eq("hi!") + + colorless = shell.set_color "hi!" + expect(colorless).to eq("hi!") + end + + it "does nothing when stdout is not a tty" do + allow($stdout).to receive(:tty?).and_return(false) + colorless = shell.set_color "hi!", :white + expect(colorless).to eq("hi!") + end + + it "does nothing when the TERM environment variable is set to 'dumb'" do + allow(ENV).to receive(:[]).with("TERM").and_return("dumb") + colorless = shell.set_color "hi!", :white + expect(colorless).to eq("hi!") + end + + 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 describe "when a block is given" do it "invokes the diff command" do - $stdout.stub!(:print) - $stdin.should_receive(:gets).and_return('d') - $stdin.should_receive(:gets).and_return('n') + allow($stdout).to receive(:print) + allow($stdout).to receive(:tty?).and_return(true) + expect(Thor::LineEditor).to receive(:readline).and_return("d", "n") - output = capture(:stdout) { shell.file_collision('spec/fixtures/doc/README'){ "README\nEND\n" } } + output = capture(:stdout) { shell.file_collision("spec/fixtures/doc/README") { "README\nEND\n" } } expect(output).to match(/\e\[31m\- __start__\e\[0m/) expect(output).to match(/^ README/) expect(output).to match(/\e\[32m\+ END\e\[0m/) diff --git a/spec/shell/html_spec.rb b/spec/shell/html_spec.rb index 5ca47fdb8..811c490a5 100644 --- a/spec/shell/html_spec.rb +++ b/spec/shell/html_spec.rb @@ -1,4 +1,4 @@ -require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') +require "helper" describe Thor::Shell::HTML do def shell @@ -6,7 +6,7 @@ def shell end describe "#say" do - it "set the color if specified" do + it "sets the color if specified" do out = capture(:stdout) { shell.say "Wow! Now we have colors!", :green } expect(out.chomp).to eq('Wow! Now we have colors!') end @@ -24,9 +24,18 @@ def shell describe "#say_status" do it "uses color to say status" do - $stdout.should_receive(:puts).with(' conflict README') + expect($stdout).to receive(:print).with(" conflict README\n") shell.say_status :conflict, "README", :red end end + describe "#set_color" do + it "escapes HTML content when using the default colors" do + expect(shell.set_color("", :blue)).to eq "<htmlcontent>" + end + + it "escapes HTML content when not using the default colors" do + expect(shell.set_color("", [:nocolor])).to eq "<htmlcontent>" + end + end end diff --git a/spec/shell_spec.rb b/spec/shell_spec.rb index bc5bd9781..87c038d53 100644 --- a/spec/shell_spec.rb +++ b/spec/shell_spec.rb @@ -1,4 +1,4 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') +require "helper" describe Thor::Shell do def shell @@ -7,29 +7,29 @@ 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 describe "#shell" do it "returns the shell in use" do - expect(MyCounter.new([1,2]).shell).to be_kind_of(Thor::Base.shell) + expect(MyCounter.new([1, 2]).shell).to be_kind_of(Thor::Base.shell) end it "uses $THOR_SHELL" do class Thor::Shell::TestShell < Thor::Shell::Basic; end expect(Thor::Base.shell).to eq(shell.class) - ENV['THOR_SHELL'] = 'TestShell' + ENV["THOR_SHELL"] = "TestShell" Thor::Base.shell = nil expect(Thor::Base.shell).to eq(Thor::Shell::TestShell) - ENV['THOR_SHELL'] = '' + ENV["THOR_SHELL"] = "" Thor::Base.shell = shell.class expect(Thor::Base.shell).to eq(shell.class) end @@ -37,11 +37,10 @@ class Thor::Shell::TestShell < Thor::Shell::Basic; end describe "with_padding" do it "uses padding for inside block outputs" do - base = MyCounter.new([1,2]) + base = MyCounter.new([1, 2]) base.with_padding do expect(capture(:stdout) { base.say_status :padding, "cool" }.strip).to eq("padding cool") end end 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/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index cd6dfd371..000000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,63 +0,0 @@ -$TESTING=true - -require 'simplecov' -SimpleCov.start do - add_group 'Libraries', 'lib' - add_group 'Specs', 'spec' -end - -$:.unshift(File.join(File.dirname(__FILE__), "..", "lib")) -require 'thor' -require 'thor/group' -require 'stringio' - -require 'rdoc' -require 'rspec' -require 'diff/lcs' # You need diff/lcs installed to run specs (but not to run Thor). -require 'fakeweb' # You need fakeweb installed to run specs (but not to run Thor). - -# Set shell to basic -$0 = "thor" -$thor_runner = true -ARGV.clear -Thor::Base.shell = Thor::Shell::Basic - -# Load fixtures -load File.join(File.dirname(__FILE__), "fixtures", "task.thor") -load File.join(File.dirname(__FILE__), "fixtures", "group.thor") -load File.join(File.dirname(__FILE__), "fixtures", "script.thor") -load File.join(File.dirname(__FILE__), "fixtures", "invoke.thor") -load File.join(File.dirname(__FILE__), "fixtures", "enum.thor") - -RSpec.configure do |config| - config.before do - ARGV.replace [] - end - - config.expect_with :rspec do |c| - c.syntax = :expect - end - - def capture(stream) - begin - stream = stream.to_s - eval "$#{stream} = StringIO.new" - yield - result = eval("$#{stream}").string - ensure - eval("$#{stream} = #{stream.upcase}") - end - - result - end - - def source_root - File.join(File.dirname(__FILE__), 'fixtures') - end - - def destination_root - File.join(File.dirname(__FILE__), 'sandbox') - end - - alias :silence :capture -end diff --git a/spec/subcommand_spec.rb b/spec/subcommand_spec.rb new file mode 100644 index 000000000..0546df4ae --- /dev/null +++ b/spec/subcommand_spec.rb @@ -0,0 +1,70 @@ +require "helper" + +describe Thor do + describe "#subcommand" do + it "maps a given subcommand to another Thor subclass" do + barn_help = capture(:stdout) { Scripts::MyDefaults.start(%w(barn)) } + expect(barn_help).to include("barn help [COMMAND] # Describe subcommands or one specific subcommand") + end + + it "passes commands to subcommand classes" do + expect(capture(:stdout) { Scripts::MyDefaults.start(%w(barn open)) }.strip).to eq("Open sesame!") + end + + it "passes arguments to subcommand classes" do + expect(capture(:stdout) { Scripts::MyDefaults.start(%w(barn open shotgun)) }.strip).to eq("That's going to leave a mark.") + end + + it "ignores unknown options (the subcommand class will handle them)" do + expect(capture(:stdout) { Scripts::MyDefaults.start(%w(barn paint blue --coats 4)) }.strip).to eq("4 coats of blue paint") + end + + it "passes parsed options to subcommands" do + output = capture(:stdout) { TestSubcommands::Parent.start(%w(sub print_opt --opt output)) } + expect(output).to eq("output") + end + + it "accepts the help switch and calls the help command on the subcommand" do + output = capture(:stdout) { TestSubcommands::Parent.start(%w(sub print_opt --help)) } + sub_help = capture(:stdout) { TestSubcommands::Parent.start(%w(sub help print_opt)) } + expect(output).to eq(sub_help) + end + + it "accepts the help short switch and calls the help command on the subcommand" do + output = capture(:stdout) { TestSubcommands::Parent.start(%w(sub print_opt -h)) } + sub_help = capture(:stdout) { TestSubcommands::Parent.start(%w(sub help print_opt)) } + expect(output).to eq(sub_help) + end + + it "the help command on the subcommand and after it should result in the same output" do + output = capture(:stdout) { TestSubcommands::Parent.start(%w(sub help)) } + sub_help = capture(:stdout) { TestSubcommands::Parent.start(%w(help sub)) } + expect(output).to eq(sub_help) + end + end + + context "subcommand with an arg" do + module SubcommandTest1 + class Child1 < Thor + desc "foo NAME", "Fooo" + def foo(name) + puts "#{name} was given" + end + end + + class Parent < Thor + desc "child1", "child1 description" + subcommand "child1", Child1 + + def self.exit_on_failure? + false + end + end + end + + it "shows subcommand name and method name" do + sub_help = capture(:stderr) { SubcommandTest1::Parent.start(%w(child1 foo)) } + expect(sub_help).to eq ['ERROR: "thor child1 foo" was called with no arguments', 'Usage: "thor child1 foo NAME"', ""].join("\n") + end + end +end diff --git a/spec/task_spec.rb b/spec/task_spec.rb deleted file mode 100644 index e55a4a61f..000000000 --- a/spec/task_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') - -describe Thor::Task do - def task(options={}) - options.each do |key, value| - options[key] = Thor::Option.parse(key, value) - end - - @task ||= Thor::Task.new(:can_has, "I can has cheezburger", "I can has cheezburger\nLots and lots of it", "can_has", options) - end - - describe "#formatted_usage" do - it "includes namespace within usage" do - object = Struct.new(:namespace, :arguments).new("foo", []) - expect(task(: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(task(: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(task(:bar => :required).formatted_usage(object)).to eq(":foo:can_has --bar=BAR") - end - - it "injects arguments into usage" do - options = {:required => true, :type => :string} - object = Struct.new(:namespace, :arguments).new("foo", [Thor::Argument.new(:bar, options)]) - expect(task(:foo => :required).formatted_usage(object)).to eq("foo:can_has BAR --foo=FOO") - end - end - - describe "#dynamic" do - it "creates a dynamic task with the given name" do - expect(Thor::DynamicTask.new('task').name).to eq('task') - expect(Thor::DynamicTask.new('task').description).to eq('A dynamically-generated task') - expect(Thor::DynamicTask.new('task').usage).to eq('task') - expect(Thor::DynamicTask.new('task').options).to eq({}) - end - - it "does not invoke an existing method" do - mock = mock() - mock.class.should_receive(:handle_no_task_error).with("to_s") - Thor::DynamicTask.new('to_s').run(mock) - end - end - - describe "#dup" do - it "dup options hash" do - task = Thor::Task.new("can_has", nil, nil, nil, :foo => true, :bar => :required) - task.dup.options.delete(:foo) - expect(task.options[:foo]).to be - end - end - - describe "#run" do - it "runs a task by calling a method in the given instance" do - mock = mock() - mock.should_receive(:can_has).and_return {|*args| args } - expect(task.run(mock, [1, 2, 3])).to eq([1, 2, 3]) - end - - it "raises an error if the method to be invoked is private" do - klass = Class.new do - def self.handle_no_task_error(name) - name - end - - private - def can_has - "fail" - end - end - - expect(task.run(klass.new)).to eq("can_has") - end - end -end diff --git a/spec/thor_spec.rb b/spec/thor_spec.rb index 3e9195783..3703331a8 100644 --- a/spec/thor_spec.rb +++ b/spec/thor_spec.rb @@ -1,144 +1,148 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') +require "helper" describe Thor do describe "#method_option" do it "sets options to the next method to be invoked" do - args = ["foo", "bar", "--force"] - arg, options = MyScript.start(args) - expect(options).to eq({ "force" => true }) + args = %w(foo bar --force) + _, options = MyScript.start(args) + expect(options).to eq("force" => true) end describe ":lazy_default" do it "is absent when option is not specified" do - arg, options = MyScript.start(["with_optional"]) + _, options = MyScript.start(%w(with_optional)) expect(options).to eq({}) end it "sets a default that can be overridden for strings" do - arg, options = MyScript.start(["with_optional", "--lazy"]) - expect(options).to eq({ "lazy" => "yes" }) + _, options = MyScript.start(%w(with_optional --lazy)) + expect(options).to eq("lazy" => "yes") - arg, options = MyScript.start(["with_optional", "--lazy", "yesyes!"]) - expect(options).to eq({ "lazy" => "yesyes!" }) + _, options = MyScript.start(%w(with_optional --lazy yesyes!)) + expect(options).to eq("lazy" => "yesyes!") end it "sets a default that can be overridden for numerics" do - arg, options = MyScript.start(["with_optional", "--lazy-numeric"]) - expect(options).to eq({ "lazy_numeric" => 42 }) + _, options = MyScript.start(%w(with_optional --lazy-numeric)) + expect(options).to eq("lazy_numeric" => 42) - arg, options = MyScript.start(["with_optional", "--lazy-numeric", 20000]) - expect(options).to eq({ "lazy_numeric" => 20000 }) + _, options = MyScript.start(%w(with_optional --lazy-numeric 20000)) + expect(options).to eq("lazy_numeric" => 20_000) end it "sets a default that can be overridden for arrays" do - arg, options = MyScript.start(["with_optional", "--lazy-array"]) - expect(options).to eq({ "lazy_array" => %w[eat at joes] }) + _, options = MyScript.start(%w(with_optional --lazy-array)) + expect(options).to eq("lazy_array" => %w(eat at joes)) - arg, options = MyScript.start(["with_optional", "--lazy-array", "hello", "there"]) - expect(options).to eq({ "lazy_array" => %w[hello there] }) + _, options = MyScript.start(%w(with_optional --lazy-array hello there)) + expect(options).to eq("lazy_array" => %w(hello there)) end it "sets a default that can be overridden for hashes" do - arg, options = MyScript.start(["with_optional", "--lazy-hash"]) - expect(options).to eq({ "lazy_hash" => {'swedish' => 'meatballs'} }) + _, options = MyScript.start(%w(with_optional --lazy-hash)) + expect(options).to eq("lazy_hash" => {"swedish" => "meatballs"}) - arg, options = MyScript.start(["with_optional", "--lazy-hash", "polish:sausage"]) - expect(options).to eq({ "lazy_hash" => {'polish' => 'sausage'} }) + _, options = MyScript.start(%w(with_optional --lazy-hash polish:sausage)) + expect(options).to eq("lazy_hash" => {"polish" => "sausage"}) end end describe "when :for is supplied" do - it "updates an already defined task" do - args, options = MyChildScript.start(["animal", "horse", "--other=fish"]) + it "updates an already defined command" do + _, options = MyChildScript.start(%w(animal horse --other=fish)) expect(options[:other]).to eq("fish") end describe "and the target is on the parent class" do - it "updates an already defined task" do - args = ["example_default_task", "my_param", "--new-option=verified"] + it "updates an already defined command" do + args = %w(example_default_command my_param --new-option=verified) options = Scripts::MyScript.start(args) expect(options[:new_option]).to eq("verified") end - it "adds a task to the tasks list if the updated task is on the parent class" do - expect(Scripts::MyScript.tasks["example_default_task"]).to be + it "adds a command to the command list if the updated command is on the parent class" do + expect(Scripts::MyScript.commands["example_default_command"]).to be end - it "clones the parent task" do - expect(Scripts::MyScript.tasks["example_default_task"]).not_to eq(MyChildScript.tasks["example_default_task"]) + it "clones the parent command" do + expect(Scripts::MyScript.commands["example_default_command"]).not_to eq(MyChildScript.commands["example_default_command"]) end end end end - describe "#default_task" do - it "sets a default task" do - expect(MyScript.default_task).to eq("example_default_task") + describe "#default_command" do + it "sets a default command" do + expect(MyScript.default_command).to eq("example_default_command") end - it "invokes the default task if no command is specified" do - expect(MyScript.start([])).to eq("default task") + it "invokes the default command if no command is specified" do + expect(MyScript.start([])).to eq("default command") end - it "invokes the default task if no command is specified even if switches are given" do - expect(MyScript.start(["--with", "option"])).to eq({"with"=>"option"}) + it "invokes the default command if no command is specified even if switches are given" do + expect(MyScript.start(%w(--with option))).to eq("with" => "option") end - it "inherits the default task from parent" do - expect(MyChildScript.default_task).to eq("example_default_task") + it "inherits the default command from parent" do + expect(MyChildScript.default_command).to eq("example_default_command") end end 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 desc "exec", "Run a command" def exec(*args) - return options, args + [options, args] end - desc "boring", "An ordinary task" + desc "boring", "An ordinary command" def boring(*args) - return options, args + [options, args] end end - it "passes remaining args to task when it encounters a non-option" do - expect(my_script.start(%w[exec command --verbose])).to eq [{}, ["command", "--verbose"]] + it "passes remaining args to command when it encounters a non-option" do + expect(my_script.start(%w(exec command --verbose))).to eq [{}, %w(command --verbose)] end - it "passes remaining args to task when it encounters an unknown option" do - expect(my_script.start(%w[exec --foo command --bar])).to eq [{}, ["--foo", "command", "--bar"]] + it "passes remaining args to command when it encounters an unknown option" do + expect(my_script.start(%w(exec --foo command --bar))).to eq [{}, %w(--foo command --bar)] end it "still accepts options that are given before non-options" do - expect(my_script.start(%w[exec --verbose command --foo])).to eq [{"verbose" => true}, ["command", "--foo"]] + expect(my_script.start(%w(exec --verbose command --foo))).to eq [{"verbose" => true}, %w(command --foo)] end it "still accepts options that require a value" do - expect(my_script.start(%w[exec --mode rashly command])).to eq [{"mode" => "rashly"}, ["command"]] + expect(my_script.start(%w(exec --mode rashly command))).to eq [{"mode" => "rashly"}, %w(command)] end - it "still passes everything after -- to task" do - expect(my_script.start(%w[exec -- --verbose])).to eq [{}, ["--verbose"]] + it "still passes everything after -- to command" do + expect(my_script.start(%w(exec -- --verbose))).to eq [{}, %w(--verbose)] end - it "does not affect ordinary tasks" do - expect(my_script.start(%w[boring command --verbose])).to eq [{"verbose" => true}, ["command"]] + it "still passes everything after -- to command, complex" do + expect(my_script.start(%w[exec command --mode z again -- --verbose more])).to eq [{}, %w[command --mode z again -- --verbose more]] end - context "when provided with multiple task names" do + it "does not affect ordinary commands" do + expect(my_script.start(%w(boring command --verbose))).to eq [{"verbose" => true}, %w(command)] + end + + context "when provided with multiple command names" do klass = Class.new(Thor) do stop_on_unknown_option! :foo, :bar end - it "affects all specified tasks" do - expect(klass.stop_on_unknown_option?(mock :name => "foo")).to be_true - expect(klass.stop_on_unknown_option?(mock :name => "bar")).to be_true - expect(klass.stop_on_unknown_option?(mock :name => "baz")).to be_false + 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 end end @@ -147,39 +151,245 @@ def boring(*args) stop_on_unknown_option! :foo stop_on_unknown_option! :bar end - it "affects all specified tasks" do - expect(klass.stop_on_unknown_option?(mock :name => "foo")).to be_true - expect(klass.stop_on_unknown_option?(mock :name => "bar")).to be_true - expect(klass.stop_on_unknown_option?(mock :name => "baz")).to be_false + 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 + end + end + + it "doesn't break new" do + expect(my_script.new).to be_a(Thor) + end + + context "along with check_unknown_options!" do + my_script2 = Class.new(Thor) do + class_option "verbose", type: :boolean + class_option "mode", type: :string + check_unknown_options! + stop_on_unknown_option! :exec + + desc "exec", "Run a command" + def exec(*args) + [options, args] + end + + def self.exit_on_failure? + false + end + end + + it "passes remaining args to command when it encounters a non-option" do + expect(my_script2.start(%w[exec command --verbose])).to eq [{}, %w[command --verbose]] + end + + it "does not accept if first non-option looks like an option, but only refuses that invalid option" do + expect(capture(:stderr) do + my_script2.start(%w[exec --foo command --bar]) + end.strip).to eq("Unknown switches \"--foo\"") + end + + it "still accepts options that are given before non-options" do + expect(my_script2.start(%w[exec --verbose command])).to eq [{"verbose" => true}, %w[command]] + end + + it "still accepts when non-options are given after real options and argument" do + expect(my_script2.start(%w[exec --verbose command --foo])).to eq [{"verbose" => true}, %w[command --foo]] + end + + it "does not accept when non-option looks like an option and is after real options" do + expect(capture(:stderr) do + my_script2.start(%w[exec --verbose --foo]) + end.strip).to eq("Unknown switches \"--foo\"") + end + + it "still accepts options that require a value" do + expect(my_script2.start(%w[exec --mode rashly command])).to eq [{"mode" => "rashly"}, %w[command]] + end + + it "still passes everything after -- to command" do + expect(my_script2.start(%w[exec -- --verbose])).to eq [{}, %w[--verbose]] + end + + it "still passes everything after -- to command, complex" do + expect(my_script2.start(%w[exec command --mode z again -- --verbose more])).to eq [{}, %w[command --mode z again -- --verbose more]] + end + end + end + + describe "#check_unknown_options!" do + my_script = Class.new(Thor) do + class_option "verbose", type: :boolean + class_option "mode", type: :string + check_unknown_options! + + desc "checked", "a command with checked" + def checked(*args) + [options, args] + end + + def self.exit_on_failure? + false + end + end + + it "still accept options and arguments" do + expect(my_script.start(%w[checked command --verbose])).to eq [{"verbose" => true}, %w[command]] + end + + it "still accepts options that are given before arguments" do + expect(my_script.start(%w[checked --verbose command])).to eq [{"verbose" => true}, %w[command]] + end + + it "does not accept if non-option that looks like an option is before the arguments" do + expect(capture(:stderr) do + my_script.start(%w[checked --foo command --bar]) + end.strip).to eq("Unknown switches \"--foo\", \"--bar\"") + end + + it "does not accept if non-option that looks like an option is after an argument" do + expect(capture(:stderr) do + my_script.start(%w[checked command --foo --bar]) + end.strip).to eq("Unknown switches \"--foo\", \"--bar\"") + end + + it "does not accept when non-option that looks like an option is after real options" do + expect(capture(:stderr) do + my_script.start(%w[checked --verbose --foo]) + end.strip).to eq("Unknown switches \"--foo\"") + end + + it "does not accept when non-option that looks like an option is before real options" do + expect(capture(:stderr) do + my_script.start(%w[checked --foo --verbose]) + end.strip).to eq("Unknown switches \"--foo\"") + end + + it "still accepts options that require a value" do + expect(my_script.start(%w[checked --mode rashly command])).to eq [{"mode" => "rashly"}, %w[command]] + end + + it "still passes everything after -- to command" do + expect(my_script.start(%w[checked -- --verbose])).to eq [{}, %w[--verbose]] + end + + it "still passes everything after -- to command, complex" do + expect(my_script.start(%w[checked command --mode z again -- --verbose more])).to eq [{"mode" => "z"}, %w[command again --verbose more]] + end + end + + describe "#disable_required_check!" do + my_script = Class.new(Thor) do + class_option "foo", required: true + + disable_required_check! :boring + + desc "exec", "Run a command" + def exec(*args) + [options, args] + end + + desc "boring", "An ordinary command" + def boring(*args) + [options, args] + end + + def self.exit_on_failure? + false + end + end + + it "does not check the required option in the given command" do + expect(my_script.start(%w(boring command))).to eq [{}, %w(command)] + end + + it "does check the required option of the remaining command" do + content = capture(:stderr) { my_script.start(%w(exec command)) } + expect(content).to eq "No value provided for required options '--foo'\n" + end + + it "does affects help by default" do + expect(my_script.disable_required_check?(double(name: "help"))).to be true + end + + context "when provided with multiple command names" do + klass = Class.new(Thor) do + disable_required_check! :foo, :bar + 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 + end + end + + context "when invoked several times" do + klass = Class.new(Thor) do + disable_required_check! :foo + disable_required_check! :bar + 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 end end end + describe "#command_exists?" do + it "returns true for a command that is defined in the class" do + expect(MyScript.command_exists?("zoo")).to be true + expect(MyScript.command_exists?("name-with-dashes")).to be true + expect(MyScript.command_exists?("animal_prison")).to be true + end + + it "returns false for a command that is not defined in the class" do + expect(MyScript.command_exists?("animal_heaven")).to be false + end + end + describe "#map" do it "calls the alias of a method if one is provided" do - expect(MyScript.start(["-T", "fish"])).to eq(["fish"]) + expect(MyScript.start(%w(-T fish))).to eq(%w(fish)) end - it "calls the alias of a method if several are provided via .map" do - expect(MyScript.start(["-f", "fish"])).to eq(["fish", {}]) - expect(MyScript.start(["--foo", "fish"])).to eq(["fish", {}]) + it "calls the alias of a method if several are provided via #map" do + expect(MyScript.start(%w(-f fish))).to eq(["fish", {}]) + expect(MyScript.start(%w(--foo fish))).to eq(["fish", {}]) end it "inherits all mappings from parent" do - expect(MyChildScript.default_task).to eq("example_default_task") + expect(MyChildScript.default_command).to eq("example_default_command") + end + end + + describe "#package_name" do + it "provides a proper description for a command when the package_name is assigned" do + content = capture(:stdout) { PackageNameScript.start(%w(help)) } + expect(content).to match(/Baboon commands:/m) + end + + # TODO: remove this, might be redundant, just wanted to prove full coverage + it "provides a proper description for a command when the package_name is NOT assigned" do + content = capture(:stdout) { MyScript.start(%w(help)) } + expect(content).to match(/Commands:/m) end end describe "#desc" do - it "provides description for a task" do - content = capture(:stdout) { MyScript.start(["help"]) } + it "provides description for a command" do + content = capture(:stdout) { MyScript.start(%w(help)) } expect(content).to match(/thor my_script:zoo\s+# zoo around/m) end it "provides no namespace if $thor_runner is false" do begin $thor_runner = false - content = capture(:stdout) { MyScript.start(["help"]) } + content = capture(:stdout) { MyScript.start(%w(help)) } expect(content).to match(/thor zoo\s+# zoo around/m) ensure $thor_runner = true @@ -187,18 +397,18 @@ def boring(*args) end describe "when :for is supplied" do - it "overwrites a previous defined task" do - expect(capture(:stdout) { MyChildScript.start(["help"]) }).to match(/animal KIND \s+# fish around/m) + it "overwrites a previous defined command" do + expect(capture(:stdout) { MyChildScript.start(%w(help)) }).to match(/animal KIND \s+# fish around/m) end end describe "when :hide is supplied" do - it "does not show the task in help" do - expect(capture(:stdout) { MyScript.start(["help"]) }).not_to match(/this is hidden/m) + it "does not show the command in help" do + expect(capture(:stdout) { MyScript.start(%w(help)) }).not_to match(/this is hidden/m) end - it "but the task is still invokcable not show the task in help" do - expect(MyScript.start(["hidden", "yesyes"])).to eq(["yesyes"]) + 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 end @@ -211,100 +421,122 @@ def boring(*args) end it "overwrites default options if called on the method scope" do - args = ["zoo", "--force", "--param", "feathers"] + args = %w(zoo --force --param feathers) options = MyChildScript.start(args) - expect(options).to eq({ "force" => true, "param" => "feathers" }) + expect(options).to eq("force" => true, "param" => "feathers") end it "allows default options to be merged with method options" do - args = ["animal", "bird", "--force", "--param", "1.0", "--other", "tweets"] + args = %w(animal bird --force --param 1.0 --other tweets) arg, options = MyChildScript.start(args) - expect(arg).to eq('bird') - expect(options).to eq({ "force"=>true, "param"=>1.0, "other"=>"tweets" }) + expect(arg).to eq("bird") + expect(options).to eq("force" => true, "param" => 1.0, "other" => "tweets") + 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(["zoo"])).to eq(true) + expect(MyScript.start(%w(zoo))).to eq(true) end it "calls a single-param method when a single param is passed" do - expect(MyScript.start(["animal", "fish"])).to eq(["fish"]) + expect(MyScript.start(%w(animal fish))).to eq(%w(fish)) end it "does not set options in attributes" do - expect(MyScript.start(["with_optional", "--all"])).to eq([nil, { "all" => true }, []]) + expect(MyScript.start(%w(with_optional --all))).to eq([nil, {"all" => true}, []]) end - it "raises an error if a required param is not provided" do - expect(capture(:stderr) { MyScript.start(["animal"]) }.strip).to eq('thor animal requires at least 1 argument: "thor my_script:animal TYPE".') + it "raises an error if the wrong number of params are provided" do + arity_asserter = lambda do |args, msg| + stderr = capture(:stderr) { Scripts::Arities.start(args) } + expect(stderr.strip).to eq(msg) + end + arity_asserter.call %w(zero_args one), 'ERROR: "thor zero_args" was called with arguments ["one"] +Usage: "thor scripts:arities:zero_args"' + arity_asserter.call %w(one_arg), 'ERROR: "thor one_arg" was called with no arguments +Usage: "thor scripts:arities:one_arg ARG"' + arity_asserter.call %w(one_arg one two), 'ERROR: "thor one_arg" was called with arguments ["one", "two"] +Usage: "thor scripts:arities:one_arg ARG"' + arity_asserter.call %w(one_arg one two), 'ERROR: "thor one_arg" was called with arguments ["one", "two"] +Usage: "thor scripts:arities:one_arg ARG"' + arity_asserter.call %w(two_args one), 'ERROR: "thor two_args" was called with arguments ["one"] +Usage: "thor scripts:arities:two_args ARG1 ARG2"' + arity_asserter.call %w(optional_arg one two), 'ERROR: "thor optional_arg" was called with arguments ["one", "two"] +Usage: "thor scripts:arities:optional_arg [ARG]"' + arity_asserter.call %w(multiple_usages), 'ERROR: "thor multiple_usages" was called with no arguments +Usage: "thor scripts:arities:multiple_usages ARG --foo" + "thor scripts:arities:multiple_usages ARG --bar"' end - it "raises an error if the invoked task does not exist" do - expect(capture(:stderr) { Amazing.start(["animal"]) }.strip).to eq('Could not find task "animal" in "amazing" namespace.') + it "raises an error if the invoked command does not exist" do + expect(capture(:stderr) { Amazing.start(%w(animal)) }.strip).to eq('Could not find command "animal" in "amazing" namespace.') end it "calls method_missing if an unknown method is passed in" do - expect(MyScript.start(["unk", "hello"])).to eq([:unk, ["hello"]]) + expect(MyScript.start(%w(unk hello))).to eq([:unk, %w(hello)]) end it "does not call a private method no matter what" do - expect(capture(:stderr) { MyScript.start(["what"]) }.strip).to eq('Could not find task "what" in "my_script" namespace.') + expect(capture(:stderr) { MyScript.start(%w(what)) }.strip).to eq('Could not find command "what" in "my_script" namespace.') end - it "uses task default options" do - options = MyChildScript.start(["animal", "fish"]).last - expect(options).to eq({ "other" => "method default" }) + it "uses command default options" do + options = MyChildScript.start(%w(animal fish)).last + expect(options).to eq("other" => "method default") end - it "raises when an exception happens within the task call" do - expect{ MyScript.start(["call_myself_with_wrong_arity"]) }.to raise_error(ArgumentError) + it "raises when an exception happens within the command call" do + expect { MyScript.start(%w(call_myself_with_wrong_arity)) }.to raise_error(ArgumentError) end context "when the user enters an unambiguous substring of a command" do it "invokes a command" do - expect(MyScript.start(["z"])).to eq(MyScript.start(["zoo"])) + expect(MyScript.start(%w(z))).to eq(MyScript.start(%w(zoo))) end - it "invokes a command, even when there's an alias the resolves to the same command" do - expect(MyScript.start(["hi"])).to eq(MyScript.start(["hidden"])) + it "invokes a command, even when there's an alias it resolves to the same command" do + expect(MyScript.start(%w(hi arg))).to eq(MyScript.start(%w(hidden arg))) end it "invokes an alias" do - expect(MyScript.start(["animal_pri"])).to eq(MyScript.start(["zoo"])) + expect(MyScript.start(%w(animal_pri))).to eq(MyScript.start(%w(zoo))) end end context "when the user enters an ambiguous substring of a command" do - it "raises an exception that explains the ambiguity" do - expect{ MyScript.start(["call"]) }.to raise_error(ArgumentError, 'Ambiguous task call matches [call_myself_with_wrong_arity, call_unexistent_method]') + 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) end it "raises an exception when there is an alias" do - expect{ MyScript.start(["f"]) }.to raise_error(ArgumentError, 'Ambiguous task f matches [foo, fu]') + shell = Thor::Base.shell.new + expect(shell).to receive(:error).with("Ambiguous command f matches [foo, fu]") + MyScript.start(%w(f), shell: shell) end end - - end - - describe "#subcommand" do - it "maps a given subcommand to another Thor subclass" do - barn_help = capture(:stdout) { Scripts::MyDefaults.start(["barn"]) } - expect(barn_help).to include("barn help [COMMAND] # Describe subcommands or one specific subcommand") - end - - it "passes commands to subcommand classes" do - expect(capture(:stdout) { Scripts::MyDefaults.start(["barn", "open"]) }.strip).to eq("Open sesame!") - end - - it "passes arguments to subcommand classes" do - expect(capture(:stdout) { Scripts::MyDefaults.start(["barn", "open", "shotgun"]) }.strip).to eq("That's going to leave a mark.") - end - - it "ignores unknown options (the subcommand class will handle them)" do - expect(capture(:stdout) { Scripts::MyDefaults.start(["barn", "paint", "blue", "--coats", "4"])}.strip).to eq("4 coats of blue paint") - end end describe "#help" do @@ -318,24 +550,24 @@ def shell end it "provides useful help info for the help method itself" do - expect(@content).to match(/help \[TASK\]\s+# Describe available tasks/) + expect(@content).to match(/help \[COMMAND\]\s+# Describe available commands/) end it "provides useful help info for a method with params" do expect(@content).to match(/animal TYPE\s+# horse around/) end - it "uses the maximum terminal size to show tasks" do - @shell.should_receive(:terminal_width).and_return(80) + it "uses the maximum terminal size to show commands" do + expect(Thor::Shell::Terminal).to receive(:terminal_width).and_return(80) content = capture(:stdout) { MyScript.help(shell) } expect(content).to match(/aaa\.\.\.$/) end - it "provides description for tasks from classes in the same namespace" do + it "provides description for commands from classes in the same namespace" do expect(@content).to match(/baz\s+# do some bazing/) end - it "shows superclass tasks" do + it "shows superclass commands" do content = capture(:stdout) { MyChildScript.help(shell) } expect(content).to match(/foo BAR \s+# do some fooing/) end @@ -350,11 +582,31 @@ 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 task" do - it "provides full help info when talking about a specific task" do - expect(capture(:stdout) { MyScript.task_help(shell, "foo") }).to eq(<<-END) + describe "for a specific command" do + it "provides full help info when talking about a specific command" do + expect(capture(:stdout) { MyScript.command_help(shell, "foo") }).to eq(<<-END) Usage: thor my_script:foo BAR @@ -367,18 +619,31 @@ def shell END end - it "raises an error if the task can't be found" do - expect { - MyScript.task_help(shell, "unknown") - }.to raise_error(Thor::UndefinedTaskError, 'Could not find task "unknown" in "my_script" namespace.') + it "provides full help info when talking about a specific command with multiple usages" do + expect(capture(:stdout) { MyScript.command_help(shell, "baz") }).to eq(<<-END) +Usage: + thor my_script:baz THING + thor my_script:baz --all + +Options: + [--all=ALL] # Do bazing for all the things + +super cool +END + end + + it "raises an error if the command can't be found" do + expect do + MyScript.command_help(shell, "unknown") + end.to raise_error(Thor::UndefinedCommandError, 'Could not find command "unknown" in "my_script" namespace.') end it "normalizes names before claiming they don't exist" do - expect(capture(:stdout) { MyScript.task_help(shell, "name-with-dashes") }).to match(/thor my_script:name-with-dashes/) + expect(capture(:stdout) { MyScript.command_help(shell, "name-with-dashes") }).to match(/thor my_script:name-with-dashes/) end it "uses the long description if it exists" do - expect(capture(:stdout) { MyScript.task_help(shell, "long_description") }).to eq(<<-HELP) + expect(capture(:stdout) { MyScript.command_help(shell, "long_description") }).to eq(<<-HELP) Usage: thor my_script:long_description @@ -389,54 +654,128 @@ def shell HELP end - it "doesn't assign the long description to the next task without one" do - expect(capture(:stdout) { - MyScript.task_help(shell, "name_with_dashes") - }).not_to match(/so very long/i) + 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 it "calls the class method" do - expect(capture(:stdout) { MyScript.start(["help"]) }).to match(/Tasks:/) + expect(capture(:stdout) { MyScript.start(%w(help)) }).to match(/Commands:/) end it "calls the class method" do - expect(capture(:stdout) { MyScript.start(["help", "foo"]) }).to match(/Usage:/) + expect(capture(:stdout) { MyScript.start(%w(help foo)) }).to match(/Usage:/) + end + end + + context "with required class_options" do + let(:klass) do + Class.new(Thor) do + class_option :foo, required: true + + desc "bar", "do something" + def bar; end + end end + + it "shows the command help" do + content = capture(:stdout) { klass.start(%w(help)) } + expect(content).to match(/Commands:/) + end + end + end + + describe "subcommands" do + it "triggers a subcommand help when passed --help" do + parent = Class.new(Thor) + child = Class.new(Thor) + parent.desc "child", "child subcommand" + parent.subcommand "child", child + parent.desc "dummy", "dummy" + expect(child).to receive(:help).with(anything, anything) + parent.start ["child", "--help"] end end - describe "when creating tasks" do + describe "when creating commands" do it "prints a warning if a public method is created without description or usage" do - expect(capture(:stdout) { + expect(capture(:stdout) do klass = Class.new(Thor) klass.class_eval "def hello_from_thor; end" - }).to match(/\[WARNING\] Attempted to create task "hello_from_thor" without usage or description/) + end).to match(/\[WARNING\] Attempted to create command "hello_from_thor" without usage or description/) end - it "does not print if overwriting a previous task" do - expect(capture(:stdout) { + it "does not print if overwriting a previous command" do + expect(capture(:stdout) do klass = Class.new(Thor) klass.class_eval "def help; end" - }).to be_empty + end).to be_empty end 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.upcase! if options[:loud] + name = name.upcase if options[:loud] "Hi #{name}" end end - expect(klass.start(["hi", "jose"])).to eq("Hi jose") - expect(klass.start(["hi", "jose", "--loud"])).to eq("Hi JOSE") - expect(klass.start(["hi", "--loud", "jose"])).to eq("Hi JOSE") + expect(klass.start(%w(hi jose))).to eq("Hi jose") + expect(klass.start(%w(hi jose --loud))).to eq("Hi JOSE") + 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 @@ -447,8 +786,8 @@ def unknown(*args) end end - expect(klass.start(["unknown", "foo", "--bar", "baz", "bat", "--bam"])).to eq(["foo", "--bar", "baz", "bat", "--bam"]) - expect(klass.start(["unknown", "--bar", "baz"])).to eq(["--bar", "baz"]) + expect(klass.start(%w(unknown foo --bar baz bat --bam))).to eq(%w(foo --bar baz bat --bam)) + expect(klass.start(%w(unknown --bar baz))).to eq(%w(--bar baz)) end it "does not pass through unknown options with strict args" do @@ -461,8 +800,8 @@ def unknown(*args) end end - expect(klass.start(["unknown", "--bar", "baz"])).to eq([]) - expect(klass.start(["unknown", "foo", "--bar", "baz"])).to eq(["foo"]) + expect(klass.start(%w(unknown --bar baz))).to eq([]) + expect(klass.start(%w(unknown foo --bar baz))).to eq(%w(foo)) end it "strict args works in the inheritance chain" do @@ -477,12 +816,63 @@ def unknown(*args) end end - expect(klass.start(["unknown", "--bar", "baz"])).to eq([]) - expect(klass.start(["unknown", "foo", "--bar", "baz"])).to eq(["foo"]) + expect(klass.start(%w(unknown --bar baz))).to eq([]) + expect(klass.start(%w(unknown foo --bar baz))).to eq(%w(foo)) end - it "send as a task name" do - expect(MyScript.start(["send"])).to eq(true) + it "issues a deprecation warning on incompatible types by default" do + expect do + Class.new(Thor) do + option "bar", type: :numeric, default: "foo" + end + end.to output(/^Deprecation warning/).to_stderr + end + + it "allows incompatible types if allow_incompatible_default_type! is called" do + expect do + Class.new(Thor) do + allow_incompatible_default_type! + + option "bar", type: :numeric, default: "foo" + end + end.not_to output.to_stderr + end + + 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 + end + end.not_to output.to_stderr + end + + it "checks the default type when check_default_type! is called" do + expect do + Class.new(Thor) do + check_default_type! + + option "bar", type: :numeric, default: "foo" + end + end.to raise_error(ArgumentError, "Expected numeric default value for '--bar'; got \"foo\" (string)") + end + + it "send as a command name" do + expect(MyScript.start(%w(send))).to eq(true) end end + + context "without an exit_on_failure? method" do + my_script = Class.new(Thor) do + desc "no arg", "do nothing" + def no_arg + end + end + + it "outputs a deprecation warning on error" do + expect do + my_script.start(%w[no_arg one]) + end.to output(/^Deprecation.*exit_on_failure/).to_stderr + end + end + end diff --git a/spec/util_spec.rb b/spec/util_spec.rb index b4b591cb3..54cc051bf 100644 --- a/spec/util_spec.rb +++ b/spec/util_spec.rb @@ -1,4 +1,4 @@ -require File.expand_path(File.dirname(__FILE__) + '/spec_helper') +require "helper" module Thor::Util def self.clear_user_home! @@ -9,28 +9,28 @@ def self.clear_user_home! describe Thor::Util do describe "#find_by_namespace" do it "returns 'default' if no namespace is given" do - expect(Thor::Util.find_by_namespace('')).to eq(Scripts::MyDefaults) + expect(Thor::Util.find_by_namespace("")).to eq(Scripts::MyDefaults) end it "adds 'default' if namespace starts with :" do - expect(Thor::Util.find_by_namespace(':child')).to eq(Scripts::ChildDefault) + expect(Thor::Util.find_by_namespace(":child")).to eq(Scripts::ChildDefault) end it "returns nil if the namespace can't be found" do - expect(Thor::Util.find_by_namespace('thor:core_ext:ordered_hash')).to be_nil + expect(Thor::Util.find_by_namespace("thor:core_ext:hash_with_indifferent_access")).to be nil end it "returns a class if it matches the namespace" do - expect(Thor::Util.find_by_namespace('app:broken:counter')).to eq(BrokenCounter) + expect(Thor::Util.find_by_namespace("app:broken:counter")).to eq(BrokenCounter) end it "matches classes default namespace" do - expect(Thor::Util.find_by_namespace('scripts:my_script')).to eq(Scripts::MyScript) + expect(Thor::Util.find_by_namespace("scripts:my_script")).to eq(Scripts::MyScript) end end describe "#namespace_from_thor_class" do - it "replaces constant nesting with task namespacing" do + it "replaces constant nesting with command namespacing" do expect(Thor::Util.namespace_from_thor_class("Foo::Bar::Baz")).to eq("foo:bar:baz") end @@ -39,7 +39,7 @@ def self.clear_user_home! end it "accepts class and module objects" do - expect(Thor::Util.namespace_from_thor_class(Thor::CoreExt::OrderedHash)).to eq("thor:core_ext:ordered_hash") + expect(Thor::Util.namespace_from_thor_class(Thor::CoreExt::HashWithIndifferentAccess)).to eq("thor:core_ext:hash_with_indifferent_access") expect(Thor::Util.namespace_from_thor_class(Thor::Util)).to eq("thor:util") end @@ -87,27 +87,40 @@ def self.clear_user_home! end end - describe "#find_class_and_task_by_namespace" do + describe "#find_class_and_command_by_namespace" do it "returns a Thor::Group class if full namespace matches" do - expect(Thor::Util.find_class_and_task_by_namespace("my_counter")).to eq([MyCounter, nil]) + expect(Thor::Util.find_class_and_command_by_namespace("my_counter")).to eq([MyCounter, nil]) end it "returns a Thor class if full namespace matches" do - expect(Thor::Util.find_class_and_task_by_namespace("thor")).to eq([Thor, nil]) + expect(Thor::Util.find_class_and_command_by_namespace("thor")).to eq([Thor, nil]) end - it "returns a Thor class and the task name" do - expect(Thor::Util.find_class_and_task_by_namespace("thor:help")).to eq([Thor, "help"]) + it "returns a Thor class and the command name" do + expect(Thor::Util.find_class_and_command_by_namespace("thor:help")).to eq([Thor, "help"]) end - it "falls back in the namespace:task look up even if a full namespace does not match" do + it "falls back in the namespace:command look up even if a full namespace does not match" do Thor.const_set(:Help, Module.new) - expect(Thor::Util.find_class_and_task_by_namespace("thor:help")).to eq([Thor, "help"]) + expect(Thor::Util.find_class_and_command_by_namespace("thor:help")).to eq([Thor, "help"]) Thor.send :remove_const, :Help end it "falls back on the default namespace class if nothing else matches" do - expect(Thor::Util.find_class_and_task_by_namespace("test")).to eq([Scripts::MyDefaults, "test"]) + 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 + + it "returns correct Thor class and the command name with hypen when shared namespaces" do + expect(Thor::Util.find_class_and_command_by_namespace("fruits:rotten-apple")).to eq([Apple, "rotten-apple"]) + end + + it "returns correct Thor class and the associated alias command name when shared namespaces" do + expect(Thor::Util.find_class_and_command_by_namespace("fruits:ra")).to eq([Apple, "ra"]) end end @@ -120,56 +133,56 @@ def self.clear_user_home! describe "#user_home" do before do - ENV.stub!(:[]) + allow(ENV).to receive(:[]) Thor::Util.clear_user_home! end - it "returns the user path if none variable is set on the environment" do + it "returns the user path if no variable is set on the environment" do expect(Thor::Util.user_home).to eq(File.expand_path("~")) end - it "returns the *unix system path if file cannot be expanded and separator does not exist" do - File.should_receive(:expand_path).with("~").and_raise(RuntimeError) + it "returns the *nix system path if file cannot be expanded and separator does not exist" do + expect(File).to receive(:expand_path).with("~").and_raise(RuntimeError) previous_value = File::ALT_SEPARATOR - capture(:stderr){ File.const_set(:ALT_SEPARATOR, false) } + capture(:stderr) { File.const_set(:ALT_SEPARATOR, false) } expect(Thor::Util.user_home).to eq("/") - capture(:stderr){ File.const_set(:ALT_SEPARATOR, previous_value) } + capture(:stderr) { File.const_set(:ALT_SEPARATOR, previous_value) } end it "returns the windows system path if file cannot be expanded and a separator exists" do - File.should_receive(:expand_path).with("~").and_raise(RuntimeError) + expect(File).to receive(:expand_path).with("~").and_raise(RuntimeError) previous_value = File::ALT_SEPARATOR - capture(:stderr){ File.const_set(:ALT_SEPARATOR, true) } + capture(:stderr) { File.const_set(:ALT_SEPARATOR, true) } expect(Thor::Util.user_home).to eq("C:/") - capture(:stderr){ File.const_set(:ALT_SEPARATOR, previous_value) } + capture(:stderr) { File.const_set(:ALT_SEPARATOR, previous_value) } end it "returns HOME/.thor if set" do - ENV.stub!(:[]).with("HOME").and_return("/home/user/") + allow(ENV).to receive(:[]).with("HOME").and_return("/home/user/") expect(Thor::Util.user_home).to eq("/home/user/") end it "returns path with HOMEDRIVE and HOMEPATH if set" do - ENV.stub!(:[]).with("HOMEDRIVE").and_return("D:/") - ENV.stub!(:[]).with("HOMEPATH").and_return("Documents and Settings/James") + allow(ENV).to receive(:[]).with("HOMEDRIVE").and_return("D:/") + allow(ENV).to receive(:[]).with("HOMEPATH").and_return("Documents and Settings/James") expect(Thor::Util.user_home).to eq("D:/Documents and Settings/James") end it "returns APPDATA/.thor if set" do - ENV.stub!(:[]).with("APPDATA").and_return("/home/user/") + allow(ENV).to receive(:[]).with("APPDATA").and_return("/home/user/") expect(Thor::Util.user_home).to eq("/home/user/") end end describe "#thor_root_glob" do before do - ENV.stub!(:[]) + allow(ENV).to receive(:[]) Thor::Util.clear_user_home! end it "escapes globs in path" do - ENV.stub!(:[]).with("HOME").and_return("/home/user{1}/") - Dir.should_receive(:[]).with("/home/user\\{1\\}/.thor/*").and_return([]) + allow(ENV).to receive(:[]).with("HOME").and_return("/home/user{1}/") + expect(Dir).to receive(:[]).with('/home/user\\{1\\}/.thor/*').and_return([]) expect(Thor::Util.thor_root_glob).to eq([]) end end @@ -177,20 +190,20 @@ def self.clear_user_home! describe "#globs_for" do it "escapes globs in path" do expect(Thor::Util.globs_for("/home/apps{1}")).to eq([ - "/home/apps\\{1\\}/Thorfile", - "/home/apps\\{1\\}/*.thor", - "/home/apps\\{1\\}/tasks/*.thor", - "/home/apps\\{1\\}/lib/tasks/*.thor" + '/home/apps\\{1\\}/Thorfile', + '/home/apps\\{1\\}/*.thor', + '/home/apps\\{1\\}/tasks/*.thor', + '/home/apps\\{1\\}/lib/tasks/**/*.thor' ]) end end describe "#escape_globs" do it "escapes ? * { } [ ] glob characters" do - expect(Thor::Util.escape_globs("apps?")).to eq("apps\\?") - expect(Thor::Util.escape_globs("apps*")).to eq("apps\\*") - expect(Thor::Util.escape_globs("apps {1}")).to eq("apps \\{1\\}") - expect(Thor::Util.escape_globs("apps [1]")).to eq("apps \\[1\\]") + expect(Thor::Util.escape_globs("apps?")).to eq('apps\\?') + expect(Thor::Util.escape_globs("apps*")).to eq('apps\\*') + expect(Thor::Util.escape_globs("apps {1}")).to eq('apps \\{1\\}') + expect(Thor::Util.escape_globs("apps [1]")).to eq('apps \\[1\\]') end end end diff --git a/thor.gemspec b/thor.gemspec index 55c3cc32b..6f26e4e35 100644 --- a/thor.gemspec +++ b/thor.gemspec @@ -1,20 +1,33 @@ -# -*- encoding: utf-8 -*- -require File.expand_path('../lib/thor/version', __FILE__) +# coding: utf-8 +lib = File.expand_path("../lib/", __FILE__) +$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) +require "thor/version" -Gem::Specification.new do |s| - s.authors = ['Yehuda Katz', 'José Valim'] - s.description = %q{A scripting framework that replaces rake, sake and rubigen} - s.email = 'ruby-thor@googlegroups.com' - s.executables = `git ls-files -- bin/*`.split("\n").map{|f| File.basename(f)} - s.extra_rdoc_files = ['CHANGELOG.rdoc', 'LICENSE.md', 'README.md', 'Thorfile'] - s.files = `git ls-files`.split("\n") - s.homepage = 'http://whatisthor.com/' - s.licenses = ['MIT'] - s.name = 'thor' - s.rdoc_options = ['--charset=UTF-8'] - s.require_paths = ['lib'] - s.required_rubygems_version = Gem::Requirement.new('>= 1.3.6') - s.summary = s.description - s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - s.version = Thor::VERSION +Gem::Specification.new do |spec| + spec.name = "thor" + spec.version = Thor::VERSION + spec.licenses = %w(MIT) + spec.authors = ["Yehuda Katz", "José Valim"] + spec.email = "ruby-thor@googlegroups.com" + spec.homepage = "http://whatisthor.com/" + spec.description = "Thor is a toolkit for building powerful command-line interfaces." + spec.summary = spec.description + + spec.metadata = { + "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/rails/thor/tree/v#{Thor::VERSION}", + "wiki_uri" => "https://github.com/rails/thor/wiki", + "rubygems_mfa_required" => "true", + } + + spec.required_ruby_version = ">= 2.6.0" + spec.required_rubygems_version = ">= 1.3.5" + + 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