diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa79c50..db3837b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## main (unreleased) +* Add a configurable `Vale` pre-commit hook to check spelling and style in text and source files (#758). + * Honor Git commit-message cleanup settings when handling comment-prefixed titles (#736). * Add configurable line parsing for command hooks (#750). diff --git a/README.md b/README.md index 8105b070..8f1b91b9 100644 --- a/README.md +++ b/README.md @@ -602,6 +602,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](#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) @@ -612,6 +613,32 @@ issue](https://github.com/sds/overcommit/issues/238) for more details. * [YardCoverage](lib/overcommit/hook/pre_commit/yard_coverage.rb) * [YarnCheck](lib/overcommit/hook/pre_commit/yarn_check.rb) +#### Vale + +[Vale](https://vale.sh/) checks spelling and style in text, markup, and source +files. Install Vale using its [installation instructions](https://vale.sh/docs/install), +then add a `.vale.ini` and any styles your project needs. Enable the hook in +`.overcommit.yml`: + +```yaml +PreCommit: + Vale: + enabled: true +``` + +Vale uses a [line-aware command hook](#adding-existing-line-aware-commands) +configured in [config/default.yml](config/default.yml), so no Ruby plugin is +needed. It receives matching staged files and runs `vale --output=line`. +Use `include` and `exclude` to customize the file types and paths checked; +some formats require additional tools or Vale configuration. + +The default line output does not include severity, so all diagnostics from a +failed Vale command are treated as errors. `problem_on_unmodified_line` controls +how diagnostics on unchanged lines are handled. To distinguish warnings, use a +[custom Vale output template](https://github.com/vale-cli/vale/issues/350) +and configure `flags`, `message_pattern`, and `warning_message_type_pattern` +as described in the line-aware command documentation. + ### PrePush `pre-push` hooks are run during `git push`, after remote refs have been updated diff --git a/config/default.yml b/config/default.yml index 8eda0b68..f78c9ffb 100644 --- a/config/default.yml +++ b/config/default.yml @@ -949,6 +949,57 @@ PreCommit: install_command: 'gem install travis' include: '.travis.yml' + Vale: + enabled: false + description: 'Check spelling and style with Vale' + required_executable: 'vale' + install_command: 'brew install vale' + message_pattern: !ruby/regexp /^(?(?:\w:)?[^:]+):(?[0-9]+):/ + flags: ['--output=line'] + include: + # Text, markup, and source formats supported by Vale. Some formats require + # additional software/configuration; use 'exclude' to disable them. + - '**/*.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/pre_commit/yaml_syntax.rb b/lib/overcommit/hook/pre_commit/yaml_syntax.rb index 83ff6789..bdeda295 100644 --- a/lib/overcommit/hook/pre_commit/yaml_syntax.rb +++ b/lib/overcommit/hook/pre_commit/yaml_syntax.rb @@ -7,7 +7,7 @@ def run messages = [] applicable_files.each do |file| - YAML.load_file(file, aliases: true) + YAML.load_file(file, permitted_classes: [Regexp, Symbol], aliases: true) rescue ArgumentError begin YAML.load_file(file) diff --git a/spec/overcommit/default_configuration_spec.rb b/spec/overcommit/default_configuration_spec.rb index 706af872..f505d140 100644 --- a/spec/overcommit/default_configuration_spec.rb +++ b/spec/overcommit/default_configuration_spec.rb @@ -5,7 +5,8 @@ describe 'default configuration' do default_config = begin - YAML.load_file(Overcommit::ConfigurationLoader::DEFAULT_CONFIG_PATH, aliases: true).to_hash + YAML.load_file(Overcommit::ConfigurationLoader::DEFAULT_CONFIG_PATH, + permitted_classes: [Regexp, Symbol], aliases: true).to_hash rescue ArgumentError YAML.load_file(Overcommit::ConfigurationLoader::DEFAULT_CONFIG_PATH).to_hash end diff --git a/spec/overcommit/hook/pre_commit/yaml_syntax_spec.rb b/spec/overcommit/hook/pre_commit/yaml_syntax_spec.rb index a6f61f2c..a15b0c68 100644 --- a/spec/overcommit/hook/pre_commit/yaml_syntax_spec.rb +++ b/spec/overcommit/hook/pre_commit/yaml_syntax_spec.rb @@ -22,10 +22,26 @@ context 'when YAML file has errors' do before do - YAML.stub(:load_file).with(staged_file, { aliases: true }).and_raise(ArgumentError) + YAML.stub(:load_file). + with(staged_file, { permitted_classes: [Regexp, Symbol], aliases: true }). + and_raise(ArgumentError) YAML.stub(:load_file).with(staged_file).and_raise(ArgumentError) end it { should fail_hook } end + + it 'accepts the regular expressions used by line-aware command hooks' do + directory do + File.write(staged_file, { 'message_pattern' => /(?.+):/ }.to_yaml) + expect(subject).to pass + end + end + + it 'continues to reject arbitrary Ruby objects' do + directory do + File.write(staged_file, "--- !ruby/object:Object {}\n") + expect(subject).to fail_hook + end + end end diff --git a/spec/overcommit/hook_loader/vale_spec.rb b/spec/overcommit/hook_loader/vale_spec.rb new file mode 100644 index 00000000..f4056b09 --- /dev/null +++ b/spec/overcommit/hook_loader/vale_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Vale pre-commit command hook' do + subject(:hook) { loader.load_hooks.find { |loaded| loaded.name == 'Vale' } } + + let(:loader) do + Overcommit::HookLoader::PluginHookLoader.new(config, context, Overcommit::Logger.silent) + end + let(:config) do + Overcommit::ConfigurationLoader.default_configuration.merge( + Overcommit::Configuration.new('PreCommit' => { 'Vale' => options }) + ) + end + let(:options) { { 'enabled' => true } } + let(:context) { Overcommit::HookContext.create('pre-commit', config, [], StringIO.new) } + let(:stdout) { "README.md:2:7:Test.Spelling:Use 'Overcommit'.\n" } + let(:result) { double(success?: false, stdout: stdout, stderr: '') } + + around do |example| + repo { example.run } + end + + after do + if Overcommit::Hook::PreCommit.const_defined?(:Vale, false) + Overcommit::Hook::PreCommit.send(:remove_const, :Vale) + end + end + + it 'is disabled by default' do + default_config = Overcommit::ConfigurationLoader.default_configuration + default_loader = Overcommit::HookLoader::PluginHookLoader.new( + default_config, context, Overcommit::Logger.silent + ) + expect(default_loader.load_hooks.map(&:name)).not_to include('Vale') + end + + it 'can be enabled using only configuration' do + expect(config.enabled_ad_hoc_hooks(context)).to include('Vale') + expect(config.enabled_builtin_hooks(context)).not_to include('Vale') + expect(hook.command).to eq(%w[vale --output=line]) + expect(hook.description).to eq('Check spelling and style with Vale') + end + + it 'selects staged text, markup, and source files, respecting exclusions' do + options['exclude'] = 'vendor/**' + paths = %w[README.md docs/guide.adoc index.html notes.txt src/main.rb vendor/old.md image.png] + allow(context).to receive(:modified_files). + and_return(paths.map { |path| File.expand_path(path) }) + expect(hook.applicable_files).to eq( + paths.first(5).map { |path| File.expand_path(path) }.sort + ) + end + + it 'allows projects to override the file selection' do + options['include'] = '**/*.custom' + allow(context).to receive(:modified_files). + and_return(%w[README.md prose.custom].map { |path| File.expand_path(path) }) + expect(hook.applicable_files).to eq([File.expand_path('prose.custom')]) + end + + it 'skips when no staged files match' do + allow(context).to receive(:modified_files).and_return([File.expand_path('image.png')]) + expect(hook.run?).to eq(false) + end + + it 'reports how to install Vale when the executable is missing' do + allow(hook).to receive(:in_path?).with('vale').and_return(false) + expect(hook).not_to receive(:execute) + status, output = hook.run_and_transform + expect(status).to eq(:fail) + expect(output).to include("'vale' is not installed", 'brew install vale') + end + + context 'when running Vale' do + before do + allow(context).to receive(:modified_files).and_return([File.expand_path('README.md')]) + allow(context).to receive(:modified_lines_in_file).with('README.md').and_return([2]) + allow(hook).to receive(:in_path?).with('vale').and_return(true) + allow(hook).to receive(:execute). + with(%w[vale --output=line], args: [File.expand_path('README.md')]).and_return(result) + end + + it 'passes on a successful exit' do + allow(result).to receive_messages(success?: true, stdout: '') + expect(hook.run_and_transform.first).to eq(:pass) + end + + it 'extracts file and line and preserves the complete Vale diagnostic' do + expect(hook.run).to eq([ + Overcommit::Hook::Message.new(:error, 'README.md', 2, stdout.chomp) + ]) + end + + it 'fails on diagnostics on modified lines' do + status, output = hook.run_and_transform + expect(status).to eq(:fail) + expect(output).to include('Errors on modified lines:', stdout.chomp) + end + + it 'preserves spaces, Windows drive letters, and backslashes in paths' do + path = 'C:\\project docs\\README.md' + allow(result).to receive(:stdout).and_return("#{path}:2:7:Test.Spelling:Fix this.\n") + expect(hook.run.first.file).to eq(path) + expect(hook.run.first.line).to eq(2) + end + + it 'extracts all diagnostics, including messages containing colons' do + second = "docs/guide.md:8:1:Test.Style:Prefer this: example.\n" + allow(result).to receive(:stdout).and_return(stdout + second) + expect(hook.run.map { |message| [message.file, message.line, message.content] }).to eq([ + ['README.md', 2, stdout.chomp], ['docs/guide.md', 8, second.chomp] + ]) + end + + it 'fails when Vale exits unsuccessfully without stdout, preserving stderr' do + allow(result).to receive_messages(stdout: '', stderr: 'E100: missing .vale.ini') + expect(hook.run_and_transform).to eq([:fail, 'E100: missing .vale.ini']) + end + + it 'fails when Vale exits unsuccessfully without any output' do + allow(result).to receive(:stdout).and_return('') + expect(hook.run_and_transform.first).to eq(:fail) + end + + it 'reports unexpected stdout instead of discarding it' do + allow(result).to receive(:stdout).and_return('unexpected Vale output') + expect { hook.run }.to raise_error(Overcommit::Exceptions::MessageProcessingError) + end + + it 'treats diagnostics without severity as errors even if the message mentions a warning' do + allow(result).to receive(:stdout). + and_return("README.md:2:7:Test.Style:A warning: fix this.\n") + expect(hook.run.first.type).to eq(:error) + end + + context 'with diagnostics on unmodified lines' do + let(:stdout) { "README.md:1:1:Test.Style:Old problem.\n" } + + it 'fails by default' do + expect(hook.run_and_transform.first).to eq(:fail) + end + + context 'when configured to ignore them' do + let(:options) { super().merge('problem_on_unmodified_line' => 'ignore') } + + it 'passes and omits the diagnostic' do + expect(hook.run_and_transform).to eq([:pass, '']) + end + end + + context 'when configured to warn' do + let(:options) { super().merge('problem_on_unmodified_line' => 'warn') } + + it 'warns' do + expect(hook.run_and_transform.first).to eq(:warn) + end + end + end + end + + it 'supports a custom output template that includes severity' do + options.merge!( + 'flags' => ['--output=./vale.tmpl'], + 'message_pattern' => /^(?(?:\w:)?[^:]+):(?\d+):\d+: \[(?\w+)\]/, + 'warning_message_type_pattern' => 'warning' + ) + allow(hook).to receive(:execute).with(%w[vale --output=./vale.tmpl], args: []). + and_return(double(success?: false, + stdout: "README.md:2:7: [warning] Test.Style:Fix this.\n")) + expect(hook.run.first.type).to eq(:warning) + end + + it 'runs a real subprocess with the default flags and matching staged file paths' do + File.write('guide with spaces.md', "first line\nsecond line\n") + File.write('image.png', 'image') + Overcommit::Utils.execute(['git', 'add', 'guide with spaces.md', 'image.png']) + options['command'] = [RbConfig.ruby, '-e', <<~RUBY, '--'] + abort 'wrong arguments' unless ARGV == ['--output=line', File.expand_path('guide with spaces.md')] + puts ARGV.last + ":2:1:Test.Style:Fix this." + exit 1 + RUBY + allow(hook).to receive(:in_path?).with('vale').and_return(true) + status, output = hook.run_and_transform + expect(status).to eq(:fail) + expect(output).to include('Errors on modified lines:', + 'guide with spaces.md:2:1:Test.Style:Fix this.') + end +end