2

Is it possible to configure vim to store commands in your command history without abbreviations expanded?

For instance I have the following command (to make it easy to switch back and forth between header and source files)

cnoreabbrev <expr> cv 'edit %:r.cpp'

However, if I type :cv<cr> and then type up I see

:edit %:r.cpp

in the command window rather than

:cv

Is there a way to configure the command history (either globally or on a per-abbreviation basis) to not be stored in expanded form.

2 Answers 2

3

I don't know how reliable it is, but I came up with this:

fu! s:cnorea(lhs, rhs) abort
    let s:counter = get(s:, 'counter', 0) + 1
    fu! s:cnorea_{s:counter}() abort closure
        call timer_start(0, {
                          \  -> execute('if histget(":", -1) ==# '.string(a:rhs)
                          \ .'|              call histdel(":", -1)
                          \   |          endif
                          \   |          call histadd(":", '.string(a:lhs).')')
                          \ })
        return a:rhs
    endfu

    exe printf('cnorea <expr> %s
             \                   getcmdtype() ==# ":" && getcmdpos() ==# %d
             \                   ?    <sid>cnorea_%d()
             \                   :    %s'
             \ , a:lhs, strlen(a:lhs)+1, s:counter, string(a:lhs))
endfu

call s:cnorea('cv', 'e %:r.cpp')

It seems to work as you want: it expands cv into e %:r.cpp, and logs cv in the command-line history. However, it relies on a timer, so if you want to test it, you first need to check that :echo has('timers') returns 1.

You could define as many similar abbreviations as you want, by invoking s:cnorea(), passing to it the {lhs} and {rhs} of your abbreviations. Examples:

call s:cnorea('abcd', 'echo "hello"')
call s:cnorea('xyz', 'echo "world"')

It works like this:

call s:cnorea('cv', 'e %:r.cpp')

Invokes s:cnorea(), passing the strings 'cv' and 'e %:r.cpp' as arguments. The latter does 2 things:

  1. define the function s:cnorea_1()
  2. define the abbreviation:

    cnorea <expr> cv getcmdtype() ==# ":" && getcmdpos() ==# 3 ? <SID>cnorea_1('cv', 'e %:r.cpp') : 'cv'

Like your original abbreviation, this one uses an expression as its {rhs}. The expression tests whether you're on a regular Ex command line (getcmdtype() ==# ':'), and whether you've typed cv at the beginning of the line.

If any of these 2 conditions isn't satisfied, the expression returns cv, which negates the expansion. Otherwise, it's expanded into the output of another function s:cnorea_1(), which is defined like this:

fu! s:cnorea_1() abort closure
    call timer_start(0, {
                      \  -> execute('if histget(":", -1) ==# '.string(a:rhs)
                      \ .'|              call histdel(":", -1)
                      \   |          endif
                      \   |          call histadd(":", '.string(a:lhs).')')
                      \ })
    return a:rhs
endfu

It returns your the expansion of your abbreviation, but before that, it calls a timer which in turn will call histdel() to delete :e %:r.cpp from the history, and histadd() to add cv in the history.

A timer allows you to defer the execution of a function or a funcref. From the perspective of the timer, the latter is called a callback. Here, the timer doesn't call a function, but a lambda expression, which is a special kind of funcref (see :h Funcref).

The syntax of a lambda expression is as follows:

{ args -> expr }

Example:

:echo { x,y -> x+y }(1,2)
   3

It returns 3, because 3 is the image of (1,2) under the function f: x,y -> x+y.

The lambda expression used in the timer is:

  {
\  -> execute('if histget(":", -1) ==# '.string(a:rhs)
\ .'|              call histdel(":", -1)
\   |          endif
\   |          call histadd(":", '.string(a:lhs).')')
\ }

The purpose of this lambda expression/callback is to execute some Ex commands. But Ex commands are not expressions, so you can't write them directly. You have to wrap them inside the function execute(), which, as any function, is an expression. It takes as argument an Ex command (or a sequence of Ex commands separated by bars) and returns its output.

It doesn't need any argument, so there's nothing before the arrow ->. It's just a routine which will execute:

if histget(":", -1) ==# 'e %:r.cpp'
    call histdel(":", -1)
endif
call histadd(":", 'cv')

This checks whether the last entry in the command-line history is e %:r.cpp (it should be, it's just a precaution), removes it if this is the case, then adds cv.


To summarize, when you execute :cv at the beginning of the command-line, it should be expanded into :e %r.cpp, but in the process, a timer is called to replace :e %:r.cpp with :cv in the history.

On my machine, although the callback is invoked after 0 ms, it's not instantaneous. Therefore, if you execute :cv, Vim should expand it into :e %:r.cpp, then replace it in the history. If on your machine, the callback is instantaneous, you should increase its waiting time, by replacing the 1st argument passed to timer_start() with another arbitrary value (as an example, with 1, for 1ms).

Also, it's not completely reliable, because if you expand your abbreviation, without executing it immediately:

:cv
    hit <space> to expand `cv`
:e %:r.cpp 
    during a few seconds, edit the command-line, maybe to add another command
    hit <Enter>

... then the timer will be called too soon, and in your history, you'll get both entries:

:cv
:e %:r.cpp

Again, you could increase the waiting time (1000 ms, 5000 ms, ...), but it's a hack.


A few other notes to better explain the code.

The counter is needed to make the functions used by every abbreviation unique:

let s:counter = get(s:, 'counter', 0) + 1

It's evaluated inside the function name, through an element of syntax called curly braces name (see :h curly-braces-names):

fu! s:cnorea_{s:counter}() abort closure
"            └─────────┤
"                      └ curly braces names

This means that the 1st time you call s:cnorea(), it will define the function s:cnorea_1() for your 1st abbreviation. The 2nd time, it will define the function s:cnorea_2() for your 2nd abbreviation, and so on.


You don't need to pass the arguments from s:cnorea() to s:cnorea_1(), because of the closure attribute (see :h closure). Thanks to this, inside s:cnorea_1(), you can refer to variables which were defined in the scope of the outer function (s:cnorea()). So, even though a:lhs and a:rhs are in the scope of the arguments of s:cnorea(), s:cnorea_1() can still access them.


The abbreviation is installed by executing the output of a printf() invocation. You don't need it, you could install it without printf():

But, printf() allows you to separate the code from the expressions it contains. Compare:

exe ' cnorea <expr> '.a:lhs
  \.'                       getcmdtype() ==# ":" && getcmdpos() ==# '.(strlen(a:lhs)+1)
  \.'                       ?    <sid>cnorea_'.s:counter.'()'
  \.'                       :    '.string(a:lhs)

vs:

exe printf('cnorea <expr> %s
         \                   getcmdtype() ==# ":" && getcmdpos() ==# %d
         \                   ?    <sid>cnorea_%d()
         \                   :    %s'
         \ , a:lhs, strlen(a:lhs)+1, s:counter, string(a:lhs))

You can use whichever version you find more readable (if any).

2
  • strlen(a:lhs)+1 doesn't that break on multibyte characters? Aug 31, 2017 at 6:34
  • @ChristianBrabandt Initially I thought that multibyte characters would break too, so I used strchars(). But I don't think so anymore, because getcmdpos() returns the position of the cursor in term of byte count, not in term of character count. However, you're right, there was a problem with multibyte characters, because I think they aren't allowed in a function name (at least I can't define a function whose name contains à - E124, E193). So, inside the inner function name, I replaced the lhs of the abbreviation with a simple counter.
    – user852573
    Aug 31, 2017 at 10:56
1

So, user-defined commands must begin with an uppercase letter. This namespacing rule prevents user-defined commands from trampling built-in vim functionality.

I think what I really wanted to do in the above example is define my own command that mimics the look and feel of a built-in command. (begins with a lowercase letter and appears literally in the command history).

One way to achieve this is to define a user-defined command that's uppercase (like CV) and have cv be an abbreviation for that. However, the body of the uppercase command checks if it's being called in command position and expands into the lowercase command (cv) if it isn't.

Here's an example showing how to do that for my hj and cv abbreviations (which just take you to a header or source file in the same directory)

command! HJ edit %:r.h
cnoreabbrev <expr> hj getcmdtype() !=# ':' ? 'hj' : 'HJ'
command! CV edit %:r.cpp
cnoreabbrev <expr> cv getcmdtype() !=# ':' ? 'cv' : 'CV'

In your history :hj will still show up as :HJ when you press up, but the effect isn't nearly as jarring as expanding into :edit %:r.h.

getcmdtype expands into a string indicating the context that the command is being invoked in.

More information on writing commands with "defensive" expansions is here:

Resolve path to current file

using

command! CV edit %:r.cpp
cnoreabbrev <expr> cv CV

is tempting, but a really bad idea. If you type /cv to search for "cv", then the abbreviation will get expanded and your search will become /CV. This is probably not what you wanted.

The linked answer also goes into some detail about detecting the "cursor position" when expanding command abbreviations, but I haven't found that necessary (yet).

EDIT:

The getcmdpos() ==# 3 condition (or whatever 1 + the length of the abbreviation is) is actually very important.

hj and cv are uncommon enough that they don't often appear as "words" on their own. But if they ever do appear (say you make a foo.hj) file by mistake, then foo.hj will get expanded into foo.HJ.

The getcmdtype() !=# ':' check is not as strict as one might think.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.