Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Ruby linter with Steep #4671

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 == ''
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 == ''
" 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a complex handler function. Add a test for this too. There's a directory for testing functions for handling results.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I will!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should finally be able to get back to complete this within a few weeks.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"a few weeks" heh. On it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@w0rp I have rebased and added the handler test.

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 @@ -580,6 +580,7 @@ formatting.
* [rufo](https://github.com/ruby-formatter/rufo)
* [solargraph](https://solargraph.org)
* [sorbet](https://github.com/sorbet/sorbet)
* [steep](https://github.com/soutaro/steep)
* [standardrb](https://github.com/testdouble/standard)
* [syntax_tree](https://github.com/ruby-syntax-tree/syntax_tree)
* Rust
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.