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

Adding R language support for function call split/join #172

Merged
merged 17 commits into from
Dec 1, 2020
Merged
Show file tree
Hide file tree
Changes from 12 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
171 changes: 171 additions & 0 deletions autoload/sj/r.vim
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
" Only real syntax that's interesting is cParen and cConditional
let s:skip = sj#SkipSyntax(['rComment'])

" function! sj#r#SplitFuncall()
"
" Split the R function call if the cursor lies within the arguments of a
" function call
"
function! sj#r#SplitFuncall()
if !s:IsValidSelection("va(")
return 0
endif

call sj#PushCursor()
let items = s:ParseJsonFromMotion("va(\<esc>vi(")
let items = map(items, {k, v -> v . (k+1 < len(items) ? "," : "")})

let r_indent_align_args = get(g:, 'r_indent_align_args', 1)
if r_indent_align_args && len(items)
let items[0] = "(" . items[0]
let items[-1] = items[-1] . ")"
let lines = items
else
let lines = ["("] + items + [")"]
endif

call sj#PopCursor()
call s:ReplaceMotionPreserveCursor('va(', lines)

return 1
endfunction

" function! sj#r#JoinFuncall()
"
" Join an R function call if the cursor lies within the arguments of a
" function call
"
function! sj#r#JoinFuncall()
if !s:IsValidSelection("va(")
return 0
endif

call sj#PushCursor()
let items = s:ParseJsonFromMotion("va(\<esc>vi(")

" clean up unwanted spaces around parens which can occur during nested joins
let text = join(items, ", ")
let text = substitute(text, '(\s', '(', 'g')
let text = substitute(text, '\s)', ')', 'g')

call sj#PopCursor()
call s:ReplaceMotionPreserveCursor("va(", ["(" . text . ")"])

return 1
endfunction

" function! s:DoMotion(motion)
"
" Perform a normal-mode motion command
"
function s:DoMotion(motion)
call sj#PushCursor()
execute "silent normal! " . a:motion . "\<esc>"
execute "silent normal! \<esc>"
call sj#PopCursor()
endfunction
AndrewRadev marked this conversation as resolved.
Show resolved Hide resolved

" function! s:MoveCursor(lines, cols)
"
" Reposition cursor given relative lines offset and columns from the start of
" the line
"
function! s:MoveCursor(lines, cols)
let y = a:lines > 0 ? a:lines . 'j^' : a:lines < 0 ? a:lines . 'k^' : ''
let x = a:cols > 0 ? a:cols . 'l' : a:cols < 0 ? a:cols . 'h' : ''
let motion = y . x
if len(motion)
execute 'silent normal! ' . motion
endif
endfunction

" function! s:ParseJsonObject(text)
"
" Wrapper around sj#argparser#js#Construct to simply parse a given string
"
function! s:ParseJsonObject(text)
let parser = sj#argparser#js#Construct(0, len(a:text), a:text)
call parser.Process()
return parser.args
endfunction

" function! s:ParseJsonFromMotion(motion)
"
" Parse a json object from the visual selection of a given normal-mode motion
" string
"
function! s:ParseJsonFromMotion(motion)
let text = sj#GetMotion(a:motion)
return s:ParseJsonObject(text)
endfunction

" function! s:IsValidSelection(motion)
"
" Test whether a visual selection contains more than a single character after
" performing the given normal-mode motion string
"
function! s:IsValidSelection(motion)
call s:DoMotion(a:motion)
return getpos("'<") != getpos("'>")
endfunction
AndrewRadev marked this conversation as resolved.
Show resolved Hide resolved

" function! s:ReplaceMotionPreserveCursor(motion, rep) {{{2
"
" Replace the normal mode "motion" selection with a list of replacement lines,
" "rep", separated by line breaks, Assuming the non-whitespace content of
" "motion" is identical to the non-whitespace content of the joined lines of
" "rep", the cursor will be repositioned to the resulting location of the
" current character under the cursor.
"
function! s:ReplaceMotionPreserveCursor(motion, rep)
" default to interpretting all lines of text as originally from text to replace
let rep = a:rep

" do motion and get bounds & text
call s:DoMotion(a:motion)
let ini = split(sj#GetByPosition(getpos("'<"), getpos(".")), "\n")
let ini = map(ini, {k, v -> sj#Ltrim(v)})

" do replacement
let body = join(a:rep, "\n")
call sj#ReplaceMotion(a:motion, body)

" go back to start of selection
silent normal! `<

" try to reconcile initial selection against replacement lines
let [cursory, cursorx, leading_ws] = [0, 0, 0]
while len(ini) && len(rep)
let i = stridx(ini[0], rep[0])
let j = stridx(rep[0], ini[0])
if i >= 0
" if an entire line of the replacement text found in initial then we'll
" need our cursor to move to the next line if more lines are insered
let ini[0] = sj#Ltrim(ini[0][i+len(rep[0]):])
let cursorx += i + len(rep[0])
let ini = len(ini[0]) ? ini : ini[1:]
let rep = rep[1:]
if len(ini)
let cursory += 1
let cursorx = 0
endif
elseif j >= 0
" if an entire line of the initial is found in the replacement then
" we'll need our cursor to move rightward through length of the initial
let rep[0] = rep[0][j+len(ini[0]):]
let leading_ws = len(rep[0])
let rep[0] = sj#Ltrim(rep[0])
let leading_ws = leading_ws - len(rep[0])
let cursorx += j + len(ini[0])
let ini = ini[1:]
let cursorx += (len(ini) && len(ini[0]) ? leading_ws : 0)
else
let ini = []
endif
endwhile

call s:MoveCursor(cursory, max([cursorx-1, 0]))
call sj#PushCursor()
endfunction
Copy link
Owner

Choose a reason for hiding this comment

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

It's an interesting approach, and it does seem to work reliably for these cases. Still, I've left it as a private/script-local function, because it's not used anywhere else, and I'd really like to have multiple use cases to decide what a sensible interface for it could be.

For instance, neither the mask and inserts are being used right now. Is an array of booleans a useful interface for mask? How are inserts going to be figured out, since they're not used at all right now? I would avoid these extra parameters until they're actually used in practice.

But as a private function, this is fine -- if and when I end up using it for something else (or get a PR using it), it might be a good point to move it to the shared sj.vim file.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, apologies for letting those slip through. I had used them at one point in the implementation and had forgotten to go back through and clean up the interfaces.

The intention was that mask could be used to indicate when a part of the replacement draws its origin from the original text. For some splits/joins the content of the code changes, so it's necessary to mask portions out from being used to try to align the cursor. inserts was used in conjunction with this to specify how many characters need to be inserted between lines to properly align the cursor. An example might be:

let x = len(a) ? "true" : "false"

" after split, becomes:
if len(a)
  let x = "true"
else 
  let x = "false"
endif

I never settled on something that I was particularly happy with to accommodate more comprehensive rearrangements and ended up scrapping the attempts.

I'll clear these vestigial bits out and leave it for another exercise to try to handle non-whitespace or non-sequential rearrangments.



11 changes: 11 additions & 0 deletions ftplugin/r/splitjoin.vim
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
if !exists('b:splitjoin_split_callbacks')
let b:splitjoin_split_callbacks = [
\ 'sj#r#SplitFuncall'
\ ]
endif

if !exists('b:splitjoin_join_callbacks')
let b:splitjoin_join_callbacks = [
\ 'sj#r#JoinFuncall'
\ ]
endif
86 changes: 86 additions & 0 deletions spec/plugin/r_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
require 'spec_helper'

describe "R" do
let(:filename) { 'test.r' }

before :each do
vim.set(:expandtab)
vim.set(:shiftwidth, 2)
end

after :each do
vim.command('silent! unlet g:r_indent_align_args')
end

specify "function calls with align_args = 0" do
vim.command('let g:r_indent_align_args = 0')

set_file_contents 'print(1, a = 2, 3)'
vim.search('1,')
split

assert_file_contents <<~EOF
print(
1,
a = 2,
3
)
EOF

join

assert_file_contents 'print(1, a = 2, 3)'
end
Comment on lines +15 to +33
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Currently this test is failing. I tried debugging it, but haven't been able to reproduce the failure. It seems to be an issue with the trailing )'s indentation, which my setup indents to the same indentation level as the print line, but the test suite is indenting to the same indentation as the arguments.

I have a few settings set on my machine that might be causing issues. The default R language indentkeys does not contain 0), meaning new lines starting with ) aren't automatically reevaluated for indentation. Using the default indentkeys, I believe the expected output should be as follows, but I'm still trying to hunt down where I might have masked this setting to be able to reproduce it.

print(
  1,
  a = 2,
  3
  )

I believe this is an issue with my system settings not reflecting the unit test environment and not an issue with the code. I'm happy to help dig into it more, but it would be very helpful if you wouldn't mind adjusting the expected result to reflect whatever the default indentation produces.

Copy link
Owner

Choose a reason for hiding this comment

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

The problem has to do with R indentation differences between Vim versions -- the build uses 8.0.1453, which seems to indent the closing brackets like this. I've vendored R support, so that the build has a consistent version.


specify "function calls with align_args = 1" do
vim.command('let g:r_indent_align_args = 1')

set_file_contents 'print(1, a = 2, 3)'
vim.search('1,')
split

assert_file_contents <<~EOF
print(1,
a = 2,
3)
EOF

join

assert_file_contents 'print(1, a = 2, 3)'
end

specify "function calls with nested calls" do
vim.command('let g:r_indent_align_args = 1')
set_file_contents 'print(1, c(1, 2, 3), 3)'

# On start of nested function
vim.search('c(')
split

assert_file_contents <<~EOF
print(1,
c(1, 2, 3),
3)
EOF

join

assert_file_contents 'print(1, c(1, 2, 3), 3)'

# Inside nested function
vim.search('c(')
vim.search('1,')
split

assert_file_contents <<~EOF
print(1, c(1,
2,
3), 3)
EOF

join

assert_file_contents 'print(1, c(1, 2, 3), 3)'
end
end