Skip to content

Instantly share code, notes, and snippets.

@lacygoill
Last active December 21, 2021 23:57
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save lacygoill/88437bcbbe90d1d2e2787695e1c1c6a9 to your computer and use it in GitHub Desktop.
Save lacygoill/88437bcbbe90d1d2e2787695e1c1c6a9 to your computer and use it in GitHub Desktop.
New events when you enter/leave a command line in Vim

In patch 8.0.1206, Vim has added 2 new events: CmdlineEnter and CmdlineLeave. They are fired every time you enter or leave a command line.

Syntax

There are 7 types of command lines, including one for normal Ex commands, debug mode commands, search commands (with a distinction between forward and backward), expressions, and inputs (more info at :h cmdwin-char). Each of them is associated with a symbol among this set : > / ? = @ -.

You can limit the effect of an autocmd listening to CmdlineEnter / CmdlineLeave to one or several types of command lines by:

  • writing their symbols in the pattern field
  • comparing the value of the expression expand('<afile>') to a regex matching several symbols, inside the executed command

Short Examples

au CmdlineEnter @ call feedkeys('my default input has ', 'in')

This autocmd populates the input field with the text my default input.

After sourcing it, if you execute the following command, without modifying the default input:

echo strchars(input('')).' characters'

You should get the message:

my default input has 21 characters

The next autocmd populates the expression command line with the current date, on the condition that you leave it empty, and that the current line contains the word date:

au CmdlineLeave = if getline('.') =~# 'date' && empty(getcmdline())
               \|     call feedkeys(strftime('%d-%m %H:%M'), 'in')
               \| endif

I'm not sure it's a good idea to use this kind of code, as it may interfere with your config / plugins, any time you have a mapping or abbreviation in insert mode which presses C-r =. It would be nice to be able to programmatically detect if C-r = has been pressed during the expansion of a mapping or an abbreviation, to limit the effect of the autocmd to a command line which has been entered manually.

I guess you could manually set a flag in your config and test it in your autocmd, but it wouldn't be possible in third-party plugins.

Longer Examples

CmdlineEnter and CmdlineLeave could be used in slightly more complex scenarios. Here are 5 of them:

  • fix a typo
  • lazy load a plugin when the command line is used for the 1st time
  • chain commands which are frequently used one after the other
  • enable 'hlsearch' after a search, and disable it after moving the cursor
  • display a message after an arbitrary command has been executed to remind you of something

Fix a typo

When you copy a line of Vimscript in a buffer to put it on the command line, sometimes you may copy the newline at the end, which may be translated into a literal carriage return on the command line. If this happens, the command fails.

Another typo, which I make sometimes due to my keyboard layout, is to type a z instead of an open parenthesis. So, instead of executing :h matchstr(), sometimes I execute :h matchstrz) which raises an error.

You could maybe fix these issues with abbreviations, using the <expr> argument, or install a wrapper mapping around <CR> to inspect the command line before it's executed and change it if needed:

cno <expr> <cr> <sid>ccr()

fu! s:ccr() abort
    if getcmdline() =~# '\r$'
        return "\<bs>\<cr>"
    endif
    return "\<cr>"
endfu

… but here's a solution using the new CmdlineLeave event:

augroup my_cmdline
    au!
    au CmdlineLeave : if getcmdline() =~# '\r$'
                   \|     call s:fix_typo('cr')
                   \| endif

    au CmdlineLeave : if getcmdline() =~# '\v\C^h%[elp]\s+\S+z\)\s*$'
                   \|     call s:fix_typo('z')
                   \| endif
augroup END

fu! s:fix_typo(label) abort
    let cmdline = getcmdline()
    let keys = {
             \   'cr': "\<bs>\<cr>",
             \   'z' : "\<bs>\<bs>()\<cr>",
             \ }[a:label]
    call timer_start(0, {-> feedkeys(':'.cmdline.keys, 'in')})
endfu

2 questions:

  • a) Why the timer?
  • b) Why saving the command line in a variable, instead of invoking getcmdline() directly inside the callback?

a) You can't send the keys right now, because the command hasn't been executed yet; from :h CmdlineLeave:

Before leaving the command line.

But it seems you can't modify the command either. Maybe it's locked, I don't know. Anyway, you can re-execute a new (fixed) command with the timer.

b) When the callback will be processed, the old command line will be lost, so you must save it immediately.

Lazy load when command line is used

Suppose you have a lot of abbreviations in command line mode, and you want to delay their installation until you press : for the 1st time. As an example, here's a function which installs abbreviations to automatically uppercase some lowercase characters of the first word on the command line when it matches the name of a custom command, so that you can type its full name in lowercase:

fu! s:cmdline_auto_uppercase() abort
    let commands = getcompletion('[A-Z]?*', 'command')
    for cmd in commands
        let lcmd  = tolower(cmd)
        exe printf('
        \            cnorea <expr> %s
        \               getcmdtype() == '':'' && getcmdline() =~# ''\v^%(%(tab<Bar>vert%[ical])\s+)?%s$''
        \               ?   %s
        \               :   %s
        \          ', lcmd, lcmd, string(cmd), string(tolower(cmd))
        \         )
    endfor
endfu

Depending on the number of plugins/custom commands you have, calling this function could install a lot of abbreviations. To delay their installation until they are really needed (that is until the command line is entered), you could temporarily remap : and install an autocmd listening to CursorHold, so that the 1st time you press :, or &updatetime ms has elapsed without you doing anything, the function is called:

augroup my_lazy_loaded_cmdline
    au!
    au CursorHold * call s:cmdline_auto_uppercase() | au! my_lazy_loaded_cmdline
augroup END

nno <expr> : <sid>lazy_loaded_cmdline()

fu! s:lazy_loaded_cmdline() abort
    sil! nunmap :
    call s:cmdline_auto_uppercase()
    return ':'
endfu

But with the CmdlineEnter event, you can simply install one autocmd:

augroup my_lazy_loaded_cmdline
    au!
    au CmdlineEnter : call s:cmdline_auto_uppercase()
                   \| exe 'au! my_lazy_loaded_cmdline'
                   \| aug! my_lazy_loaded_cmdline
augroup END

It's a fire-once autocmd, meaning that it removes itself the first time it's executed.

Chain commands

Some commands are often executed consecutively. For example, after executing :ls to list the loaded buffers, you may execute :b to give the focus to one of them, providing a number, or a part of its name as an argument to the command.

To automate the process, you could install a wrapper mapping around <CR> to inspect the command line before it's executed, and react accordingly. The detail of this method is explained in this gist.

It works very well, but using CmdlineLeave gives a few benefits.

There's no need to implement all the code inside a single function, it can be split across several files. Also, there's less risk of interference between your mapping and a plugin. For example, a plugin may type <CR> in a recursive mapping, assuming the user didn't remap this key. Because of this, I had an issue with a plugin similar to vim-mucomplete.

Although, it's still possible that a conflict arises between 2 plugins competing to remap <CR>. Example: vim-slash vs vim-searchindex.

So, instead of remapping <CR>, you could rewrite the previous gist using CmdlineLeave:

augroup my_cmdline
    au!
    au CmdlineLeave : call s:cmdline_chain()
augroup END

fu! s:cmdline_chain() abort
    let cmdline = getcmdline()
    let pat2cmd = {
                \  '(g|v).*(#|nu%[mber])' : [ ''         , 0 ],
                \  '(ls|files|buffers)!?' : [ 'b '       , 0 ],
                \  'chi%[story]'          : [ 'sil col ' , 1 ],
                \  'lhi%[story]'          : [ 'sil lol ' , 1 ],
                \  'marks'                : [ 'norm! `'  , 1 ],
                \  'old%[files]'          : [ 'e #<'     , 1 ],
                \  'undol%[ist]'          : [ 'u '       , 1 ],
                \  'changes'              : [ "norm! g;\<s-left>"     , 1 ],
                \  'ju%[mps]'             : [ "norm! \<c-o>\<s-left>" , 1 ],
                \ }
    for [pat, cmd ] in items(pat2cmd)
        let [ keys, nomore ] = cmd
        if cmdline =~# '\v\C^'.pat.'$'
            if nomore
                let more_save = &more | set nomore
                call timer_start(0, {-> execute('set '.(more_save ? '' : 'no').'more')})
            endif
            return feedkeys(':'.keys, 'in')
        endif
    endfor
    if cmdline =~# '\v\C^(dli|il)%[ist]\s+'
        call feedkeys(':'.cmdline[0].'j  '.split(cmdline, ' ')[1]."\<s-left>\<left>", 'in')
    elseif cmdline =~# '\v\C^(cli|lli)'
        call feedkeys(':sil '.repeat(cmdline[0], 2).' ', 'in')
    endif
endfu

Inside the function, the dictionary pat2cmd binds some patterns to some lists. Each pattern is matched against the contents of the command line, and each list describes the next command to automatically execute if there's a match. A list contains 2 items: a command and a boolean flag. When the flag is on, it means that you want the previous command to be executed while 'more' is disabled. IOW, you don't want a long listing to be paused when it fills the screen, and Vim to display the message -- More --.

WARNING:

Both methods, remapping <CR> or installing an autocmd listening to CmdlineLeave may cause an issue while in Ex mode (and debug mode?). As an example, consider this code written in a minimal vimrc:

cno <expr> <cr> CCR()

fu! CCR()

    return "\<cr>"
endfu

If you start Vim with just a minimum of initializations:

$ vim -Nu /tmp/vimrc /tmp/vimrc

Then press gQ to enter Ex mode (while maintaining line editing commands and completion functions), <CR> to execute an empty command, then :vi to get back to normal mode, you'll get the error E501. I don't know what it means, but I know it has something to do with the empty line inside the function. The point is that both methods may interfere in an unexpected way.

Bottom line: don't write an empty line in a function processed when CmdlineLeave is triggered.

Toggle 'hlsearch'

If you want to see all the matches of the last search pattern highlighted after executing a search command, until your cursor has moved, there are several ways of doing it. One way consists, again, in remapping <CR>:

cno <expr>          <cr>             <sid>wrap_cr()
nno <expr> <silent> n                <sid>wrap_n(1)
nno <expr> <silent> N                <sid>wrap_n(0)
nno        <silent> <plug>(my_nohls) :<c-u>call <sid>set_nohls()<cr>

fu! s:wrap_cr() abort
    if getcmdtype() =~# '[/?]'
        call s:set_hls()
        call feedkeys("\<plug>(my_nohls)", 'i')
    endif
    return "\<cr>"
endfu

fu! s:wrap_n(fwd) abort
    call s:set_hls()
    call feedkeys("\<plug>(my_nohls)", 'i')
    return a:fwd ? 'n' : 'N'
endfu

fu! s:set_hls() abort
    sil! au! my_search
    set hls
endfu

fu! s:set_nohls() abort
    augroup my_search
        au!
        au CursorMoved * set nohls | au! my_search
    augroup END
endfu

And again, CmdlineLeave is an alternative to this mapping:

augroup my_cmdline
    au!
    au CmdlineLeave /,\? call s:set_hls()
                   \|    call timer_start(0, {-> s:set_nohls()})
augroup END

nno <expr> <silent> n <sid>wrap_n(1)
nno <expr> <silent> N <sid>wrap_n(0)

fu! s:wrap_n(fwd) abort
    call s:set_hls()
    call timer_start(0, {-> s:set_nohls()})
    return a:fwd ? 'n' : 'N'
endfu

fu! s:set_hls() abort
    sil! au! my_search
    set hls
endfu

fu! s:set_nohls() abort
    augroup my_search
        au!
        au CursorMoved * set nohls | au! my_search
    augroup END
endfu

Reminder

Suppose you want to display a message after some commands are executed, maybe to remind you that an alternative exists for the command you've just typed. Without CmdlineLeave, timer, lambda expression, execute(), you could achieve your goal with the following code:

let s:overlooked_commands = [
                          \   { 'old': 'vsplit', 'new': 'C-w v' },
                          \   { 'old': 'split' , 'new': 'C-w s' },
                          \   { 'old': 'q!'    , 'new': 'ZQ'    },
                          \   { 'old': 'x'     , 'new': 'ZZ'    },
                          \ ]

nno <silent> <plug>(my_reminder) :<c-u>call <sid>reminder(input(''))<cr>

fu! s:reminder(cmd) abort
    redraw
    echohl WarningMsg | echo '['.a:cmd.'] was equivalent' | echohl NONE
endfu

fu! s:remember(list) abort
    for cmd in a:list
        let old = cmd.old
        let sold = string(old)
        exe printf('
        \            cnorea <expr> %s  getcmdtype() ==# ":" && getcmdline() ==# %s
        \                              ?   %s.feedkeys("<plug>(my_reminder)".%s."<cr>")[1]
        \                              :   %s
        \          ', old, sold, sold, string(cmd.new), sold
        \         )
    endfor
endfu

call s:remember(s:overlooked_commands)
unlet! s:overlooked_commands

To use it, you would need to populate the list of dictionaries s:overlooked_commands with the old commands you're currently using, and their new counterparts you forget. The old command must be the literal command that you type (example: :vsplit), while the new one can be any text you want to read to remind you of an alternative (example: C-w v).

Two remarks:

  1. Do NOT give the i flag to feedkeys(). You want to feed your keys to the typeahead buffer AFTER the command (:sp, :x, …) has been executed. With i, you would feed them before the Enter which executes the command. So, instead of doing this:

         :vs Enter <plug>(future_reminder)C-w v Enter
    

You would do this:

        :vs <plug>(future_reminder)C-w v Enter Enter

IOW, <plug>(…) would be literally inserted on the command line, whereas you wanted it to be typed in normal mode, to call a function. As a result, it would just be interpreted as a filename.

  1. No need to put a backslash in front of <plug> or <cr>, because :cnorea (like all abbreviations/mappings commands) automatically translates these special characters. However, you do need to use double quotes (you could also double the single quotes). Not to allow the translation, :cnorea doesn't care about the kind of quotes you use (it probably translates these special characters in a table regardless of the quotes / backslash used) , but because you're inside a string surrounded by single quotes.

With a recent Vim version which supports CmdlineLeave, the whole code could be rewritten like this:

let s:overlooked_commands = [
                          \   { 'old': 'vs\%[plit]', 'new': 'C-w v', 'regex': 1 },
                          \   { 'old': 'sp\%[lit]' , 'new': 'C-w s', 'regex': 1 },
                          \   { 'old': 'q!'        , 'new': 'ZQ'   , 'regex': 0 },
                          \   { 'old': 'x'         , 'new': 'ZZ'   , 'regex': 0 },
                          \ ]

fu! s:remember(list) abort
    augroup remember_overlooked_commands
        au!
        for cmd in a:list
            exe printf('
            \            au CmdlineLeave :
            \                  if getcmdline() %s %s
            \                |     call timer_start(0, {-> execute("echohl WarningMsg
            \                |                                      echo %s
            \                |                                      echohl NONE", "")})
            \                | endif
            \          ', cmd.regex ? '=~#' : '==#',
            \                 string(cmd.regex ? '^'.cmd.old.'$' : cmd.old),
            \                 string('['.cmd.new .'] was equivalent')
            \         )
        endfor
    augroup END
endfu

call s:remember(s:overlooked_commands)
unlet! s:overlooked_commands

This time, you are not limited to literal commands, you can use regexes too, provided that you set the 3rd flag to 1 in the dictionary.

This means that you can display a message not only when a command matching a literal text has just been executed, but any command matching an arbitrary regex.

@techntools
Copy link

Helpful. Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment