diff --git a/CHANGELOG.md b/CHANGELOG.md index f27e6208..26a3746b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## master (unreleased) * Add `--disable-pending-cops` as default flag to `RuboCop` pre-commit hook to ignore non-existent cops. Requires RuboCop `0.82.0` or newer. +* Add "ad-hoc" line-aware command hooks. +* Add `Vale` pre-commit hook to check spelling and style in text and source files. ## 0.58.0 diff --git a/README.md b/README.md index 36126a2c..13ac99e5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ In addition to supporting a wide variety of hooks that can be used across multiple repositories, you can also define hooks specific to a repository which are stored in source control. You can also easily -[add your existing hook scripts](#adding-existing-git-hooks) without writing +[add your existing hook scripts](#adding-existing-git-hooks) or +[use some of your existing line-oriented analysis tools](#adding-existing-line-aware-commands) without writing any Ruby code. * [Requirements](#requirements) @@ -41,6 +42,7 @@ any Ruby code. * [PreRebase](#prerebase) * [Repo-Specific Hooks](#repo-specific-hooks) * [Adding Existing Git Hooks](#adding-existing-git-hooks) + * [Adding Existing Line-Aware Commands](#adding-existing-line-aware-commands) * [Security](#security) * [Contributing](#contributing) * [Community](#community) @@ -240,6 +242,7 @@ Option | Description `install_command` | Command the user can run to install the `required_executable` (or alternately the specified `required_libraries`). This is intended for documentation purposes, as Overcommit does not install software on your behalf since there are too many edge cases where such behavior would result in incorrectly configured installations (e.g. installing a Python package in the global package space instead of in a virtual environment). `skip_file_checkout` | Whether to skip this hook for file checkouts (e.g. `git checkout some-ref -- file`). Only applicable to `PostCheckout` hooks. `skip_if` | Array of arguments to be executed to determine whether or not the hook should run. For example, setting this to a value of `['bash', '-c', '! which my-executable']` would allow you to skip running this hook if `my-executable` was not in the bin path. +[...]`message_`[...] | *[Line-aware command hooks](#adding-existing-line-aware-commands) only.* In addition to the built-in configuration options, each hook can expose its own unique configuration options. The `AuthorEmail` hook, for example, allows @@ -564,6 +567,7 @@ issue](https://github.com/sds/overcommit/issues/238) for more details. * [TrailingWhitespace](lib/overcommit/hook/pre_commit/trailing_whitespace.rb) * [TravisLint](lib/overcommit/hook/pre_commit/travis_lint.rb) * [TsLint](lib/overcommit/hook/pre_commit/ts_lint.rb) +* Vale * [Vint](lib/overcommit/hook/pre_commit/vint.rb) * [W3cCss](lib/overcommit/hook/pre_commit/w3c_css.rb) * [W3cHtml](lib/overcommit/hook/pre_commit/w3c_html.rb) @@ -671,6 +675,70 @@ of hook, see the [git-hooks documentation][GHD]. [GHD]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks +### Adding Existing Line-Aware Commands + +Or in other words "low-code error format support." + +If you use tools that analyze files and report their findings line-by-line, +and that Overcommit does not yet support, you may be able to integrate them +with Overcommit without writing any Ruby code in a similar way as +[for existing Git hooks](#adding-existing-git-hooks). + +These special line-aware command hooks behave and are configured the same way +as the Git ones, except only file arguments get passed to them. +Also to enable them and for optimal use, one must configure them as explained +below, so that, using the command output: +- differentiating between warnings and errors becomes possible +- modified lines can be detected and acted upon as defined by + the `problem_on_unmodified_line`, `requires_files`, `include` and `exclude` + [hook options](#hook-options) + +**Warning**: Only the command's standard output stream is considered for now, +*not* its standard error stream. + +To differentiate between warning and error messages, +the `warning_message_type_pattern` option may be specified: +the `type` field of the `message_pattern` regexp below must then include +the `warning_message_type_pattern` option's text. + +The `message_pattern` option specifies the format of the command's messages. +It is mandatory, must be a [(Ruby) regexp][RubyRE], and must define at least +a `file` [named capture group][RubyRENCG]. +The only other allowed ones are `line` and `type`, which when specified +enable detection of modified lines and warnings respectively. + +**Tip**: The following value for this option is often adequate: +```ruby +/^(?(?:\w:)?[^:]+):(?\d+):[^ ]* (?[^ ]+)/ +``` +It generalizes the quasi-standard [GNU/Emacs-style error format][GNUEerrf], +adding the most frequently used warning syntax to it. + +For example: + +```yaml +PreCommit: + CustomScript: + enabled: true + command: './bin/custom-script' + message_pattern: !ruby/regexp /^(?[^:]+):(?[0-9]+):(?[^ ]+)/ + warning_message_type_pattern: warning +``` + +**Tip**: To get the syntax of the regexps right, a Ruby interpreter like `irb` +can help: + +```ruby +require('yaml'); puts YAML.dump(/MY-REGEXP/) +``` + +Then copy the output line text as the YAML option's value, thereby +omitting the `---` prefix. + +[RubyRE]: https://ruby-doc.org/core-2.4.1/Regexp.html +[RubyRENCG]: https://ruby-doc.org/core-2.4.1/Regexp.html#class-Regexp-label-Capturing +[GNUEerrf]: https://www.gnu.org/prep/standards/standards.html#Errors + ## Security While Overcommit can make managing Git hooks easier and more convenient, diff --git a/config/default.yml b/config/default.yml index 51189348..66707911 100644 --- a/config/default.yml +++ b/config/default.yml @@ -842,6 +842,58 @@ PreCommit: install_command: 'gem install travis' include: '.travis.yml' + Vale: + enabled: false + command: "vale" + message_pattern: !ruby/regexp /^(?[^:]+):(?[0-9]+):/ + flags: + - '--output=line' + include: + # All known extensions for all supported formats are included + # (see https://docs.errata.ai/vale/scoping#formats), even + # the non-built-in ones that require additional software and/or + # configuration: they can be disabled easily using 'exclude': + - '**/*.adoc' + - '**/*.bsh' + - '**/*.c' + - '**/*.cc' + - '**/*.cpp' + - '**/*.cs' + - '**/*.css' + - '**/*.csx' + - '**/*.cxx' + - '**/*.dita' + - '**/*.java' + - '**/*.js' + - '**/*.go' + - '**/*.h' + - '**/*.hpp' + - '**/*.hs' + - '**/*.html' + - '**/*.less' + - '**/*.lua' + - '**/*.md' + - '**/*.php' + - '**/*.pl' + - '**/*.pm' + - '**/*.pod' + - '**/*.py' + - '**/*.py3' + - '**/*.pyi' + - '**/*.pyw' + - '**/*.R' + - '**/*.r' + - '**/*.rb' + - '**/*.rpy' + - '**/*.rst' + - '**/*.sass' + - '**/*.sbt' + - '**/*.scala' + - '**/*.swift' + - '**/*.txt' + - '**/*.xhtml' + - '**/*.xml' + Vint: enabled: false description: 'Analyze with Vint' diff --git a/lib/overcommit/hook_loader/base.rb b/lib/overcommit/hook_loader/base.rb index 9173e9f1..612b436f 100644 --- a/lib/overcommit/hook_loader/base.rb +++ b/lib/overcommit/hook_loader/base.rb @@ -22,6 +22,40 @@ def load_hooks private + def is_hook_line_aware(hook_config) + hook_config['message_pattern'] + end + + def create_line_aware_command_hook_class(hook_base) # rubocop:disable Metrics/MethodLength + Class.new(hook_base) do + def line_aware_config + { + message_pattern: @config['message_pattern'], + warning_message_type_pattern: @config['warning_message_type_pattern'] + } + end + + def run + result = execute(command, args: applicable_files) + + return :pass if result.success? + + extract_messages(line_aware_config, result) + end + + def extract_messages(line_aware_config, result) + warning_message_type_pattern = line_aware_config[:warning_message_type_pattern] + Overcommit::Utils::MessagesUtils.extract_messages( + result.stdout.split("\n"), + line_aware_config[:message_pattern], + Overcommit::Utils::MessagesUtils.create_type_categorizer( + warning_message_type_pattern + ) + ) + end + end + end + attr_reader :log # Load and return a {Hook} from a CamelCase hook name. diff --git a/lib/overcommit/hook_loader/plugin_hook_loader.rb b/lib/overcommit/hook_loader/plugin_hook_loader.rb index d168a0de..dfa2e88c 100644 --- a/lib/overcommit/hook_loader/plugin_hook_loader.rb +++ b/lib/overcommit/hook_loader/plugin_hook_loader.rb @@ -74,16 +74,12 @@ def check_for_modified_plugins raise Overcommit::Exceptions::InvalidHookSignature end - def create_ad_hoc_hook(hook_name) - hook_module = Overcommit::Hook.const_get(@context.hook_class_name) - hook_base = hook_module.const_get('Base') - + def create_git_hook_class(hook_base) # Implement a simple class that executes the command and returns pass/fail # based on the exit status - hook_class = Class.new(hook_base) do + Class.new(hook_base) do def run result = @context.execute_hook(command) - if result.success? :pass else @@ -91,6 +87,24 @@ def run end end end + end + + def create_ad_hoc_hook(hook_name) + hook_module = Overcommit::Hook.const_get(@context.hook_class_name) + hook_base = hook_module.const_get('Base') + + hook_config = @config.for_hook(hook_name, @context.hook_class_name) + hook_class = + if is_hook_line_aware(hook_config) + create_line_aware_command_hook_class(hook_base) + else + create_git_hook_class(hook_base) + end + + # Only to avoid warnings in unit tests...: + if hook_module.const_defined?(hook_name) + return hook_module.const_get(hook_name).new(@config, @context) + end hook_module.const_set(hook_name, hook_class).new(@config, @context) rescue LoadError, NameError => e diff --git a/lib/overcommit/utils/messages_utils.rb b/lib/overcommit/utils/messages_utils.rb index c92a6010..a7baecb2 100644 --- a/lib/overcommit/utils/messages_utils.rb +++ b/lib/overcommit/utils/messages_utils.rb @@ -37,6 +37,14 @@ def extract_messages(output_messages, regex, type_categorizer = nil) end end + def create_type_categorizer(warning_pattern) + return nil if warning_pattern.nil? + + lambda do |type| + type.include?(warning_pattern) ? :warning : :error + end + end + private def extract_file(match, message) diff --git a/spec/overcommit/hook_loader/plugin_hook_loader_spec.rb b/spec/overcommit/hook_loader/plugin_hook_loader_spec.rb new file mode 100644 index 00000000..3dca7319 --- /dev/null +++ b/spec/overcommit/hook_loader/plugin_hook_loader_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'ad-hoc pre-commit hook' do + subject do + hook_loader = Overcommit::HookLoader::PluginHookLoader.new( + config, + context, + Overcommit::Logger.new(STDOUT) + ) + hooks = hook_loader.load_hooks + hooks.find { |h| h.name == hook_name } + end + let(:config) do + config = Overcommit::Configuration.new( + YAML.safe_load(config_contents, [Regexp]), { + validate: false + } + ) + Overcommit::ConfigurationLoader.default_configuration.merge(config) + end + let(:context) do + empty_stdin = File.open(File::NULL) # pre-commit hooks don't take input + context = Overcommit::HookContext.create('pre-commit', config, applicable_files, empty_stdin) + context + end + + around do |example| + repo do + example.run + end + end + + describe 'if not line-aware' do + let(:config_contents) do + <<-'YML' + PreCommit: + FooGitHook: + enabled: true + command: "foocmd" + YML + end + let(:hook_name) { 'FooGitHook' } + let(:applicable_files) { nil } + + before do + context.stub(:execute_hook).with(%w[foocmd]). + and_return(result) + end + + context 'when command succeeds' do + let(:result) do + double(success?: true, stdout: '') + end + + it { should pass } + end + + context 'when command fails' do + let(:result) do + double(success?: false, stdout: '', stderr: '') + end + + it { should fail_hook } + end + end + + describe 'if line-aware' do + let(:config_contents) do + <<-'YML' + PreCommit: + FooLint: + enabled: true + command: ["foo", "lint"] + message_pattern: !ruby/regexp /^(?[^:]+):(?[0-9]+):(?[^ ]+)/ + warning_message_type_pattern: warning + flags: + - "--format=emacs" + include: '**/*.foo' + FooLintRecommendedPattern: + enabled: true + command: ["foo", "lint"] + message_pattern: !ruby/regexp /^(?(?:\w:)?[^:]+):(?\d+):[^ ]* (?[^ ]+)/ + warning_message_type_pattern: warning + flags: + - "--format=emacs" + include: '**/*.foo' + FooLintRecommendedPatternNoWarnings: + enabled: true + command: ["foo", "lint"] + message_pattern: !ruby/regexp /^(?(?:\w:)?[^:]+):(?\d+):[^ ]* (?[^ ]+)/ + flags: + - "--format=emacs" + include: '**/*.foo' + YML + end + let(:hook_name) { 'FooLint' } + let(:applicable_files) { %w[file.foo] } + + before do + subject.stub(:applicable_files).and_return(applicable_files) + subject.stub(:execute).with(%w[foo lint --format=emacs], args: applicable_files). + and_return(result) + end + + context 'when command succeeds' do + let(:result) do + double(success?: true, stdout: '') + end + + it { should pass } + end + + context 'when command fails with empty stdout' do + let(:result) do + double(success?: false, stdout: '', stderr: '') + end + + it { should pass } + end + + context 'when command fails with some warning message' do + let(:result) do + double( + success?: false, + stdout: "A:1:warning...\n", + stderr: '' + ) + end + + it { should warn } + end + + context 'when command fails with some error message' do + let(:result) do + double( + success?: false, + stdout: "A:1:???\n", + stderr: '' + ) + end + + it { should fail_hook } + end + + describe '(using recommended pattern)' do + let(:hook_name) { 'FooLintRecommendedPattern' } + + context 'when command fails with some warning message' do + let(:result) do + double( + success?: false, + stdout: <<-MSG, +B:1: warning: ??? + MSG + stderr: '' + ) + end + + it { should warn } + end + + context 'when command fails with some error message' do + let(:result) do + double( + success?: false, + stdout: <<-MSG, +A:1:80: error + MSG + stderr: '' + ) + end + + it { should fail_hook } + end + end + + describe '(using recommended pattern w/o warnings)' do + let(:hook_name) { 'FooLintRecommendedPatternNoWarnings' } + + context 'when command fails with some messages' do + let(:result) do + double( + success?: false, + stdout: <<-MSG, +A:1:80: error +B:1: warning: ??? + MSG + stderr: '' + ) + end + + it { should fail_hook } + end + end + end +end