Wrapping selected text in Vim, easy... no?

A common want when editing code or prose, né, necessity is to wrap some selected text with something else - it could be an opening / closing tag combo, or some kind of logging method or even some kind of formatting function. Here are some common examples:

  1. Wrap with delimiter characters
    e.g. Hi! to "Hi!"
  2. Wrap with a method
    e.g. "a string" to wrapped("a string")
  3. Wrap with a tag
    e.g. Boo! to <div>Boo!</div>
  4. Wrap with a method and do some pre/post cleanup
    e.g. <?php echo $var ?> to <?php Output::text($var) ?>

The common way of approaching the first three scenarios is by using a plugin such as surround, sandwich or UltiSnips - but how about 4? In my particular case I also wanted to support wrapping with different methods on the Output object and map them to different keys. So using plugins didn’t quite cut the hotdog relish. Time to roll up the sleeves.

Break it down (James Brown)

Ok, let’s decompose this into the following rough components:

  1. Capture the selected text into a variable
  2. Clear the text
  3. Do any clean up around the text
  4. Re-insert the text from the variable
  5. Map that all to a key or command

Capture the selected text into a variable

This sounds easy right? Just yank it into a register and copy that… somehow… is there already a built in method for that? Pretty sure there isn’t so you end up with something like:

function! GetVisualSelection()
  " Get from the start of the visual selection, '<
  let [line_start, column_start] = getpos("'<")[1:2]
  " To the end of the visual selection, '>
  let [line_end, column_end] = getpos("'>")[1:2]
  let lines = getline(line_start, line_end)
  if len(lines) == 0
    return ''
  endif
  " Take into account the selection setting
  let lines[-1] = lines[-1][: column_end - (&selection == 'inclusive' ? 1 : 2)]
  let lines[0] = lines[0][column_start - 1:]
  return join(lines, "\n")
endfunction

Insert text at current cursor

Let’s skip 2 and 3 (they are actually trivial, yay) and let’s ponder inserting text - again that should be built-in but you can’t simply put in something like exe "normal i" . text as that’s not fool proof depending on what text is. For this you’ll probably want something like:

function! InsertText(text)
  let cur_line_num = line('.')
  let cur_col_num = col('.')
  let orig_line = getline('.')
  let modified_line =
      \ strpart(orig_line, 0, cur_col_num - 1)
      \ . a:text
      \ . strpart(orig_line, cur_col_num - 1)
  " Replace the current line with the modified line.
  call setline(cur_line_num, modified_line)
  " Place cursor on the last character of the inserted text.
  call cursor(cur_line_num, cur_col_num + strlen(a:text))
endfunction

Putting it all together

Let’s see what a full approach might look like:

function! GetVisualSelection()
  " Get from the start of the visual selection, '<
  let [line_start, column_start] = getpos("'<")[1:2]
  " To the end of the visual selection, '>
  let [line_end, column_end] = getpos("'>")[1:2]
  let lines = getline(line_start, line_end)
  if len(lines) == 0
    return ''
  endif
  " Take into account the selection setting
  let lines[-1] = lines[-1][: column_end - (&selection == 'inclusive' ? 1 : 2)]
  let lines[0] = lines[0][column_start - 1:]
  return join(lines, "\n")
endfunction

function! InsertText(text)
  let cur_line_num = line('.')
  let cur_col_num = col('.')
  let orig_line = getline('.')
  let modified_line =
      \ strpart(orig_line, 0, cur_col_num - 1)
      \ . a:text
      \ . strpart(orig_line, cur_col_num - 1)
  " Replace the current line with the modified line.
  call setline(cur_line_num, modified_line)
  " Place cursor on the last character of the inserted text.
  call cursor(cur_line_num, cur_col_num + strlen(a:text))
endfunction

function! WrapIt(wrapper)
  let sel = GetVisualSelection()
  " Create the full end text we want
  let text = '?php ' . a:wrapper . '(' . sel . ') ?'
  " Delete everything inside the opening and closing angled brackets
  normal di<
  call InsertText(text)
endfunction

vmap <localleader>wt <esc>:call WrapIt('Output::text')<cr>
vmap <localleader>wi <esc>:call WrapIt('Output::id')<cr>
vmap <localleader>wa <esc>:call WrapIt('Output::attrib')<cr>
vmap <localleader>wh <esc>:call WrapIt('Output::html')<cr>

Personally I have the WrapIt function and the mappings in a project scoped vimrc but the GetVisualSelection and InsertText functions are ripe for putting in a global autoload functions files.

So with that in place I can select some text using, say, viw and hit ,it (my localleader is a comma) et voilà - text wrapped and cleaned up!

// @Category
// @Tags
// @Size
// @
26/07/2021