diff options
author | Loic Nageleisen <loic.nageleisen@gmail.com> | 2024-06-25 10:17:27 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-06-25 17:17:27 +0900 |
commit | e09520e2d7d7af09e82da7e845541b67d15d8d1d (patch) | |
tree | e7ada9cee19223205f1886b91ff107a572f026e9 | |
parent | f4bc3c2711a004daf749cdba24e20144896f441d (diff) | |
download | ale-e09520e2d7d7af09e82da7e845541b67d15d8d1d.zip |
Add Ruby linter with Steep (#4671)
* Add Ruby linter with Steep
Fixes #3254
* Run steep instead of using language server
LSP presents a few issues and this works around those.
* Work around Steep path issue
See https://github.com/soutaro/steep/pull/975
* Add simple tests for steep
* Add steep to supported tools
* Pass linter
* Add a comment regarding Steep's column counting
* Make lnum an integer
* Add Steep handler test
* Fix separator for Windows
* Escape Windows path separators for substitute()
* Use ALEInfo (I) group
* Use fnameescape instead of quotes
* Skip linting for files not under steep root
* Add and pass tests covering proper steep root lookup
* Fix separator discrepancy
* Use strict operators (match case)
* Fix ordering
* Use `is#` instead of `==#`
-rw-r--r-- | ale_linters/ruby/steep.vim | 172 | ||||
-rw-r--r-- | doc/ale-supported-languages-and-tools.txt | 1 | ||||
-rw-r--r-- | supported-tools.md | 1 | ||||
-rw-r--r-- | test/handler/test_steep_handler.vader | 100 | ||||
-rw-r--r-- | test/linter/test_ruby_steep.vader | 69 | ||||
-rw-r--r-- | test/test-files/ruby/nested/dummy.rb | 0 | ||||
-rw-r--r-- | test/test-files/ruby/nested/foo/Steepfile | 0 | ||||
-rw-r--r-- | test/test-files/ruby/nested/foo/dummy.rb | 0 | ||||
-rw-r--r-- | test/test-files/ruby/nested/foo/one/dummy.rb | 0 | ||||
-rw-r--r-- | test/test-files/ruby/nested/foo/two/Steepfile | 0 | ||||
-rw-r--r-- | test/test-files/ruby/nested/foo/two/dummmy.rb | 0 | ||||
-rw-r--r-- | test/test-files/ruby/nested/foo/two/three/dummy.rb | 0 |
12 files changed, 343 insertions, 0 deletions
diff --git a/ale_linters/ruby/steep.vim b/ale_linters/ruby/steep.vim new file mode 100644 index 00000000..4fd52620 --- /dev/null +++ b/ale_linters/ruby/steep.vim @@ -0,0 +1,172 @@ +call ale#Set('ruby_steep_executable', 'steep') +call ale#Set('ruby_steep_options', '') + +" Find the nearest dir containing a Steepfile +function! ale_linters#ruby#steep#FindRoot(buffer) abort + for l:name in ['Steepfile'] + let l:dir = fnamemodify( + \ ale#path#FindNearestFile(a:buffer, l:name), + \ ':h' + \) + + if l:dir isnot# '.' && isdirectory(l:dir) + return l:dir + endif + endfor + + return '' +endfunction + +" Rename path relative to root +function! ale_linters#ruby#steep#RelativeToRoot(buffer, path) abort + let l:separator = has('win32') ? '\' : '/' + let l:steep_root = ale_linters#ruby#steep#FindRoot(a:buffer) + + " path isn't under root + if l:steep_root is# '' + return '' + endif + + let l:steep_root_prefix = l:steep_root . l:separator + + " win32 path separators get interpreted by substitute, escape them + if has('win32') + let l:steep_root_pat = substitute(l:steep_root_prefix, '\\', '\\\\', 'g') + else + let l:steep_root_pat = l:steep_root_prefix + endif + + return substitute(a:path, l:steep_root_pat, '', '') +endfunction + +function! ale_linters#ruby#steep#GetCommand(buffer) abort + let l:executable = ale#Var(a:buffer, 'ruby_steep_executable') + + " steep check needs to apply some config from the file path so: + " - steep check can't use stdin (no path) + " - steep check can't use %t (path outside of project) + " => we can only use %s + + " somehow :ALEInfo shows that ALE still appends '< %t' to the command + " => luckily steep check ignores stdin + + " somehow steep has a problem with absolute path to file but a path + " relative to Steepfile directory works: + " see https://github.com/soutaro/steep/pull/975 + " => change to Steepfile directory and remove leading path + + let l:buffer_filename = fnamemodify(bufname(a:buffer), ':p') + let l:buffer_filename = fnameescape(l:buffer_filename) + + let l:relative = ale_linters#ruby#steep#RelativeToRoot(a:buffer, l:buffer_filename) + + " if file is not under steep root, steep can't type check + if l:relative is# '' + " don't execute + return '' + endif + + return ale#ruby#EscapeExecutable(l:executable, 'steep') + \ . ' check ' + \ . ale#Var(a:buffer, 'ruby_steep_options') + \ . ' ' . fnameescape(l:relative) +endfunction + +function! ale_linters#ruby#steep#GetType(severity) abort + if a:severity is? 'information' + \|| a:severity is? 'hint' + return 'I' + endif + + if a:severity is? 'warning' + return 'W' + endif + + return 'E' +endfunction + +" Handle output from steep +function! ale_linters#ruby#steep#HandleOutput(buffer, lines) abort + let l:output = [] + + let l:in = 0 + let l:item = {} + + for l:line in a:lines + " Look for first line of a message block + " If not in-message (l:in == 0) that's expected + " If in-message (l:in > 0) that's less expected but let's recover + let l:match = matchlist(l:line, '^\([^:]*\):\([0-9]*\):\([0-9]*\): \[\([^]]*\)\] \(.*\)') + + if len(l:match) > 0 + " Something is lingering: recover by pushing what is there + if len(l:item) > 0 + call add(l:output, l:item) + let l:item = {} + endif + + let l:filename = l:match[1] + + " Steep's reported column is offset by 1 (zero-indexed?) + let l:item = { + \ 'lnum': l:match[2] + 0, + \ 'col': l:match[3] + 1, + \ 'type': ale_linters#ruby#steep#GetType(l:match[4]), + \ 'text': l:match[5], + \} + + " Done with this line, mark being in-message and go on with next line + let l:in = 1 + continue + endif + + " We're past the first line of a message block + if l:in > 0 + " Look for code in subsequent lines of the message block + if l:line =~# '^│ Diagnostic ID:' + let l:match = matchlist(l:line, '^│ Diagnostic ID: \(.*\)') + + if len(l:match) > 0 + let l:item.code = l:match[1] + endif + + " Done with the line + continue + endif + + " Look for last line of the message block + if l:line =~# '^└' + " Done with the line, mark looking for underline and go on with the next line + let l:in = 2 + continue + endif + + " Look for underline right after last line + if l:in == 2 + let l:match = matchlist(l:line, '\([~][~]*\)') + + if len(l:match) > 0 + let l:item.end_col = l:item['col'] + len(l:match[1]) - 1 + endif + + call add(l:output, l:item) + + " Done with the line, mark looking for first line and go on with the next line + let l:in = 0 + let l:item = {} + continue + endif + endif + endfor + + return l:output +endfunction + +call ale#linter#Define('ruby', { +\ 'name': 'steep', +\ 'executable': {b -> ale#Var(b, 'ruby_steep_executable')}, +\ 'language': 'ruby', +\ 'command': function('ale_linters#ruby#steep#GetCommand'), +\ 'project_root': function('ale_linters#ruby#steep#FindRoot'), +\ 'callback': 'ale_linters#ruby#steep#HandleOutput', +\}) diff --git a/doc/ale-supported-languages-and-tools.txt b/doc/ale-supported-languages-and-tools.txt index 2e4b4ab0..505cac86 100644 --- a/doc/ale-supported-languages-and-tools.txt +++ b/doc/ale-supported-languages-and-tools.txt @@ -572,6 +572,7 @@ Notes: * `solargraph` * `sorbet` * `standardrb` + * `steep` * `syntax_tree` * Rust * `cargo`!! diff --git a/supported-tools.md b/supported-tools.md index 56a5a6c9..3d687614 100644 --- a/supported-tools.md +++ b/supported-tools.md @@ -581,6 +581,7 @@ formatting. * [solargraph](https://solargraph.org) * [sorbet](https://github.com/sorbet/sorbet) * [standardrb](https://github.com/testdouble/standard) + * [steep](https://github.com/soutaro/steep) * [syntax_tree](https://github.com/ruby-syntax-tree/syntax_tree) * Rust * [cargo](https://github.com/rust-lang/cargo) :floppy_disk: (see `:help ale-integration-rust` for configuration instructions) diff --git a/test/handler/test_steep_handler.vader b/test/handler/test_steep_handler.vader new file mode 100644 index 00000000..98875229 --- /dev/null +++ b/test/handler/test_steep_handler.vader @@ -0,0 +1,100 @@ +Before: + runtime ale_linters/ruby/steep.vim + +After: + call ale#linter#Reset() + +Execute(The steep handler should parse lines correctly): + AssertEqual + \ [ + \ { + \ 'lnum': 400, + \ 'col': 18, + \ 'end_col': 45, + \ 'text': 'Method parameters are incompatible with declaration `(untyped, untyped, *untyped, **untyped) { () -> untyped } -> untyped`', + \ 'code': 'Ruby::MethodArityMismatch', + \ 'type': 'E', + \ }, + \ { + \ 'lnum': 20, + \ 'col': 9, + \ 'end_col': 17, + \ 'text': 'Cannot find implementation of method `::Frobz::FooBarBaz#method_name`', + \ 'code': 'Ruby::MethodDefinitionMissing', + \ 'type': 'W', + \ }, + \ { + \ 'lnum': 30, + \ 'col': 9, + \ 'end_col': 17, + \ 'text': 'Cannot find implementation of method `::Frobz::FooBarBaz#method_name`', + \ 'code': 'Ruby::MethodDefinitionMissing', + \ 'type': 'I', + \ }, + \ { + \ 'lnum': 40, + \ 'col': 9, + \ 'end_col': 17, + \ 'text': 'Cannot find implementation of method `::Frobz::FooBarBaz#method_name`', + \ 'code': 'Ruby::MethodDefinitionMissing', + \ 'type': 'I', + \ }, + \ ], + \ ale_linters#ruby#steep#HandleOutput(347, [ + \ '# Type checking files:', + \ '', + \ '...............................................................................................................................F..........F.F...F.', + \ '', + \ 'lib/frobz/foobar_baz.rb:400:17: [error] Method parameters are incompatible with declaration `(untyped, untyped, *untyped, **untyped) { () -> untyped } -> untyped`', + \ '│ Diagnostic ID: Ruby::MethodArityMismatch', + \ '│', + \ '└ def frobz(obj, suffix, *args, &block)', + \ ' ~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + \ '', + \ 'lib/frobz/foobar_baz.rb:20:8: [warning] Cannot find implementation of method `::Frobz::FooBarBaz#method_name`', + \ '│ Diagnostic ID: Ruby::MethodDefinitionMissing', + \ '│', + \ '└ class FooBarBaz', + \ ' ~~~~~~~~~', + \ '', + \ 'lib/frobz/foobar_baz.rb:30:8: [information] Cannot find implementation of method `::Frobz::FooBarBaz#method_name`', + \ '│ Diagnostic ID: Ruby::MethodDefinitionMissing', + \ '│', + \ '└ class FooBarBaz', + \ ' ~~~~~~~~~', + \ '', + \ 'lib/frobz/foobar_baz.rb:40:8: [hint] Cannot find implementation of method `::Frobz::FooBarBaz#method_name`', + \ '│ Diagnostic ID: Ruby::MethodDefinitionMissing', + \ '│', + \ '└ class FooBarBaz', + \ ' ~~~~~~~~~', + \ '', + \ 'Detected 4 problems from 1 file', + \ ]) + +Execute(The steep handler should handle when files are checked and no offenses are found): + AssertEqual + \ [], + \ ale_linters#ruby#steep#HandleOutput(347, [ + \ '# Type checking files:', + \ '', + \ '.............................................................................................................................................', + \ '', + \ 'No type error detected. 🧉', + \ ]) + +Execute(The steep handler should handle when no files are checked): + AssertEqual + \ [], + \ ale_linters#ruby#steep#HandleOutput(347, [ + \ '# Type checking files:', + \ '', + \ '', + \ '', + \ 'No type error detected. 🧉', + \ ]) + +Execute(The steep handler should handle empty output): + AssertEqual [], ale_linters#ruby#steep#HandleOutput(347, ['']) + AssertEqual [], ale_linters#ruby#steep#HandleOutput(347, []) + diff --git a/test/linter/test_ruby_steep.vader b/test/linter/test_ruby_steep.vader new file mode 100644 index 00000000..59ab1e73 --- /dev/null +++ b/test/linter/test_ruby_steep.vader @@ -0,0 +1,69 @@ +" Author: Loic Nageleisen <https://github.com/lloeki> +" Description: Tests for steep linter. +Before: + call ale#assert#SetUpLinterTest('ruby', 'steep') + + let g:ale_ruby_steep_executable = 'steep' + +After: + call ale#assert#TearDownLinterTest() + +Execute(Executable should default to steep): + call ale#test#SetFilename('../test-files/ruby/nested/foo/dummy.rb') + AssertLinter 'steep', ale#Escape('steep') + \ . ' check ' + \ . ' dummy.rb' + +Execute(Should be able to set a custom executable): + let g:ale_ruby_steep_executable = 'bin/steep' + + call ale#test#SetFilename('../test-files/ruby/nested/foo/dummy.rb') + AssertLinter 'bin/steep' , ale#Escape('bin/steep') + \ . ' check ' + \ . ' dummy.rb' + +Execute(Setting bundle appends 'exec steep'): + let g:ale_ruby_steep_executable = 'path to/bundle' + + call ale#test#SetFilename('../test-files/ruby/nested/foo/dummy.rb') + AssertLinter 'path to/bundle', ale#Escape('path to/bundle') + \ . ' exec steep' + \ . ' check ' + \ . ' dummy.rb' + +Execute(should accept options): + let g:ale_ruby_steep_options = '--severity-level=hint' + + call ale#test#SetFilename('../test-files/ruby/nested/foo/dummy.rb') + AssertLinter 'steep', ale#Escape('steep') + \ . ' check' + \ . ' --severity-level=hint' + \ . ' dummy.rb' + +Execute(Should not lint files out of steep root): + call ale#test#SetFilename('../test-files/ruby/nested/dummy.rb') + AssertLinter 'steep', '' + +Execute(Should lint files at top steep root): + call ale#test#SetFilename('../test-files/ruby/nested/foo/dummy.rb') + AssertLinter 'steep', ale#Escape('steep') + \ . ' check ' + \ . ' dummy.rb' + +Execute(Should lint files below top steep root): + call ale#test#SetFilename('../test-files/ruby/nested/foo/one/dummy.rb') + AssertLinter 'steep', ale#Escape('steep') + \ . ' check ' + \ . ' one' . (has('win32') ? '\' : '/') . 'dummy.rb' + +Execute(Should lint files at nested steep root): + call ale#test#SetFilename('../test-files/ruby/nested/foo/two/dummy.rb') + AssertLinter 'steep', ale#Escape('steep') + \ . ' check ' + \ . ' dummy.rb' + +Execute(Should lint files below nested steep root): + call ale#test#SetFilename('../test-files/ruby/nested/foo/two/three/dummy.rb') + AssertLinter 'steep', ale#Escape('steep') + \ . ' check ' + \ . ' three' . (has('win32') ? '\' : '/') . 'dummy.rb' diff --git a/test/test-files/ruby/nested/dummy.rb b/test/test-files/ruby/nested/dummy.rb new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/test/test-files/ruby/nested/dummy.rb diff --git a/test/test-files/ruby/nested/foo/Steepfile b/test/test-files/ruby/nested/foo/Steepfile new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/test/test-files/ruby/nested/foo/Steepfile diff --git a/test/test-files/ruby/nested/foo/dummy.rb b/test/test-files/ruby/nested/foo/dummy.rb new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/test/test-files/ruby/nested/foo/dummy.rb diff --git a/test/test-files/ruby/nested/foo/one/dummy.rb b/test/test-files/ruby/nested/foo/one/dummy.rb new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/test/test-files/ruby/nested/foo/one/dummy.rb diff --git a/test/test-files/ruby/nested/foo/two/Steepfile b/test/test-files/ruby/nested/foo/two/Steepfile new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/test/test-files/ruby/nested/foo/two/Steepfile diff --git a/test/test-files/ruby/nested/foo/two/dummmy.rb b/test/test-files/ruby/nested/foo/two/dummmy.rb new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/test/test-files/ruby/nested/foo/two/dummmy.rb diff --git a/test/test-files/ruby/nested/foo/two/three/dummy.rb b/test/test-files/ruby/nested/foo/two/three/dummy.rb new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/test/test-files/ruby/nested/foo/two/three/dummy.rb |