Converting Vim Macros to Functions

From ‘one-off’ to ‘actually I need to do this again…’

Context

My often-talked-about video workflow has a bunch of useful features. For example, if I’ve uploaded a video to YouTube via the web interface (usually on a headless machine via xpra) I have a script which will grab the video id so that the metadata can be updated.

Unfortunately that script is rather simple and only scans back so far — around the past 100 videos or so. So today when I was updating my Mongolian Great Khan videos, of which there are 71, it missed a few.

There were less than two dozen video ids missing, which was enough that I could grab them from the video URLs rather than rewriting the script to be a bit smarter.

That said, I am going to have to do that anyway when I get to clearing out my Apex videos backlog, so it would have been time well-invested; but I wanted to get the Mongolia videos out so I could go out for a walk in the sun!

Vim Macros

This is something I’ve done on occasion before, and I had a macro recorded to strip out the id from a pasted URL. It went something like:

<80>kh/h^Mdt=<80>kD^[A"^[<80>a<80>kuA,<80>kd^[<80>a

Note that <80> is a specifier for a keyboard key, as far as I can tell. So it’s roughly:

“Hit <home>; jump to ‘h’ (in https); delete to =; hit another key…

to go from something like "id": "https://www.youtube.com/watch?v=dzJrSh_ByUw" to "id": "dzJrSh_ByUw".

Macros persist across sessions as they stored in the viminfo file. Unfortunately, I couldn’t remember to which register I had recorded the macro, so I had a look with :registers

Some registers, with register "i highlighted

You can see some of the other macros I’ve been using. The eagle-eyed among you will also notice that the <80>s in that image on the "i line are in a different colour to other lines. That is because I accidentally hit qi (start recording new macro i) instead of @i (apply macro i). Whoops! I then wanted to recover the macro, and zeraphel in #vim gave me the tip of using let @i = (to assign to register i) or writing the text and "iy (into register i, yank the following text). But that doesn’t preserve the “this keyboard key” entries.

Hmm.

Writing Functions Instead

It was then suggested by zeraphel and romainl that I might consider writing a function instead, for things which are more than one-offs.

This is no tutorial on vimscript! I’ll leave that to Learn Vimscript the Hard Way, which is much better. This is a note of some of the features I used, as I ended up with a few tabs open while looking things up,

(tabs closed not shown!)

and this is more about how they are used and any gotchas than a reference guide.

Defining Functions

User functions need to be defined with a capital letter, because Reasons™.

Using Normal-mode Commands

Like you can use the command :normal (or :norm for short; insert-Cheers-reference-here), you can use that in vimscript. I [ab]used this to write macro-like functions, which though inelegant got the job done. For example norm A, to insert a comma at the end of the line; norm o"id": "TOREPLACE" to open a new line and insert a placeholder JSON key-value pair.

Checking For A String in Another String

You can get the position of a substring in another using stridx("string", "substring").

I used this as a sanity check:

function InsertYTID()
  " Assumes YT URL is on clipboard (ie "+)
  " Check for this:
  let l:ytbegin = "https://www.youtube.com/watch?v="        
  let l:sane = stridx(@+, l:ytbegin)

stridx() returns -1 if the substring isn’t found within the string.

Clipboard Buffers

Some other things in the sanity check above:

  • l: prefix for local variables
  • @+ to access the contents of one of the two clipboard registers- @* accesses the one for selected text, @+ for copy(+paste) clipboard.

A pitfall was that the vim I had installed from Arch was vim-minimal, which lacked X11 clipboard support. Installing the gvim package sorted that.

if syntax

vimscript has conditionals, though a potential pitfall is that -1 (and I assume other negative values) are truthy. Since that was the value I would get when the YouTube URL prefix was not present in the clipboard string, I tested for that explicitly:

if l:sane == -1  
    echom "You must have a YT url on the clipboard!"
elseif l:sane != -1
    let ytid = ExtractYTID(@+)

which brings me to…

Function Arguments

To get at a function argument, you name it in the function definition (eg function MyFunction(argument1)) and get at it using a: prefix.

String Replacements / Substitutions

There is a substitution function, which I used to strip out the gubbins I didn’t need from the YT URL:

function ExtractYTID(url)
  let out = substitute(a:url, "https://www.youtube.com/watch?v=", "", "")
  return out
endfunction

it is of the form substitute({expr}, {pat}, {sub}, {flags}). See :help substitute:

substitute({expr}, {pat}, {sub}, {flags})       *substitute()*

    The result is a String, which is a copy of {expr}, in which
    the first match of {pat} is replaced with {sub}.  This works
    like the ":substitute" command (without any flags).  But the
    matching with {pat} is always done like the 'magic' option is
    set and 'cpoptions' is empty (to make scripts portable).
    'ignorecase' is still relevant.  'smartcase' is not used.
    See |string-match| for how {pat} is used.
    And a "~" in {sub} is not replaced with the previous {sub}.
    Note that some codes in {sub} have a special meaning
    |sub-replace-special|.  For example, to replace something with
    "\n" (two characters), use "\\\\n" or '\\n'.
    When {pat} does not match in {expr}, {expr} is returned
    unmodified.
    When {flags} is "g", all matches of {pat} in {expr} are
    replaced.  Otherwise {flags} should be "".
    Example: >
        :let &path = substitute(&path, ",\\=[^,]*$", "", "")
    This removes the last component of the 'path' option. 
        :echo substitute("testing", ".*", "\\U\\0", "")
    results in "TESTING".

Lifted from this answer on SO.

Note that I didn’t have to assign the result to a variable, I could have returned directly: return substitute(...).

Calling Functions From Other Functions

Just use call:

function InsertYTID()
  " Assumes YT URL is on clipboard (ie "+)
  " Check for this:
  let l:ytbegin = "https://www.youtube.com/watch?v="        
  let l:sane = stridx(@+, l:ytbegin)

  if l:sane == -1  
    echom "You must have a YT url on the clipboard!"
  elseif l:sane != -1
    let ytid = ExtractYTID(@+)
    call InsertIdToReplaceAfterScheduled()
    "call YTInsertURLAndTrim()
    " replace "" with the quote-wrapped YT id
    s/TOREPLACE/\=ytid/
  endif
endfunction

Timesaver: Map Function to Command

To save you (me) typing :call InsertYTID() repeatedly (modulo tab-completion), you can map a call to a command:

command InsertYTID call InsertYTID()

and then you can simply :InsertYTID, or whatever is enough to disambiguate it from another command. If you flipped the command around to be YTIDInsert, then :YTI may be sufficient!

Tell us what's on your mind