Skip to content

Commit

Permalink
Add Ruby linter with Steep (#4671)
Browse files Browse the repository at this point in the history
* 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 soutaro/steep#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 `==#`
  • Loading branch information
lloeki authored Jun 25, 2024
1 parent f4bc3c2 commit e09520e
Show file tree
Hide file tree
Showing 12 changed files with 343 additions and 0 deletions.
172 changes: 172 additions & 0 deletions ale_linters/ruby/steep.vim
Original file line number Diff line number Diff line change
@@ -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',
\})
1 change: 1 addition & 0 deletions doc/ale-supported-languages-and-tools.txt
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ Notes:
* `solargraph`
* `sorbet`
* `standardrb`
* `steep`
* `syntax_tree`
* Rust
* `cargo`!!
Expand Down
1 change: 1 addition & 0 deletions supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
100 changes: 100 additions & 0 deletions test/handler/test_steep_handler.vader
Original file line number Diff line number Diff line change
@@ -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, [])

69 changes: 69 additions & 0 deletions test/linter/test_ruby_steep.vader
Original file line number Diff line number Diff line change
@@ -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'
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.

0 comments on commit e09520e

Please sign in to comment.