Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 /^(?<file>(?:\w:)?[^:]+):(?<line>[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'
Expand Down
2 changes: 1 addition & 1 deletion lib/overcommit/hook/pre_commit/yaml_syntax.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion spec/overcommit/default_configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion spec/overcommit/hook/pre_commit/yaml_syntax_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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' => /(?<file>.+):/ }.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
190 changes: 190 additions & 0 deletions spec/overcommit/hook_loader/vale_spec.rb
Original file line number Diff line number Diff line change
@@ -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' => /^(?<file>(?:\w:)?[^:]+):(?<line>\d+):\d+: \[(?<type>\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