[vim/vim] map-<expr> doesn't replay properly in mappings and macros (#7971)

101 views
Skip to first unread message

Dani Dickstein

unread,
Mar 15, 2021, 10:59:56 PM3/15/21
to vim/vim, Subscribed

This bug was originally reported at neovim/neovim#14133, but I'm reporting it here too since it affects Vim as well.

  1. Create a new buffer with the following vimscript, then save and source the file:
function! X()
  let line = line('.')
  let col = col('.')
  echomsg "Pressed x at (".l:line.", ".l:col.")"
  return "x"
endfunction

nnoremap <expr> x X()
  1. Place your cursor at the start of the last line and record the macro fpx.
  2. Observe that in :messages you see "Pressed x at (8, 8)."

Failure inside macro

  1. Undo the change and restore the cursor position.
  2. Replay the macro.
  3. Observe that the message "Pressed x at (8, 1)" appears.

Failure inside mapping

  1. Undo the change and restore the cursor position.
  2. Execute nmap ! fpx
  3. Press !.
  4. Observe that the message "Pressed x at (8, 1)" appears.

Environment (please complete the following information):

  • Vim version 8.2.2600
  • OS: macOS 11.1
  • Terminal: iTerm2 3.4.4


You are receiving this because you are subscribed to this thread.
Reply to this email directly, view it on GitHub, or unsubscribe.

lacygoill

unread,
Mar 15, 2021, 11:59:51 PM3/15/21
to vim/vim, Subscribed

Relevant todo item:

Using an insert mode expression mapping, cursor is not in the expected
position. (ZyX, 2010 Aug 29)

After sourcing this script:

let g:pos = []
fu Func()
    call add(g:pos, col('.'))
    return 'a'
endfu
ino <expr> a Func()
norm aaaaa
echom pos
echo ''

g:pos contains this list:

[1, 1, 1, 1]

While one would expect:

[1, 1, 2, 3]

As a workaround, <Ignore> can be prepended to the sequence returned by Func():

let g:pos = []
fu Func()
    call add(g:pos, col('.'))
    return "\<ignore>a"
endfu
ino <expr> a Func()
norm aaaaa
echom pos
echo ''
[1, 1, 2, 3]

lacygoill

unread,
Mar 16, 2021, 1:36:31 AM3/16/21
to vim/vim, Subscribed

While one would expect:

[1, 1, 2, 3]

Ah no, one would expect:

[1, 2, 3, 4]

As a workaround, can be prepended to the sequence returned by Func():

No, <Ignore> should be appended, not prepended:

let g:pos = []
fu Func
()
    let g:pos += [col('.')]
    return "a\<ignore>"
endfu
ino <expr> a Func()
norm aaaaa
echom pos
echo ''
[1, 2, 3, 4]

lacygoill

unread,
Mar 16, 2021, 2:08:44 AM3/16/21
to vim/vim, Subscribed

Although, in the original example, <Ignore> can't help:

vim9script
def g:Func(): string
    echom 'Pressed x at col: ' .. col('.')
    return "\<ignore>x"
enddef
nno <expr> x g:Func()
setline(1, 'aaa p aaa')
norm fpx
Pressed x at col: 1

I think that's because :norm writes fpx in the typeahead buffer, then inside the latter, Vim remaps the key x into <Ignore>x. To get this sequence, Func() has to be evaluated; but when it is, the cursor has not been moved yet; fp has not been executed. I think the OP expects their X() function to be evaluated after fp has been executed. This is the case during an interactive usage; but not in a non-interactive one (like a macro or a mapping).

This is confirmed using feedkeys():

vim9script
def g:Func(): string
    echom 'Pressed x at col: ' .. col('.')
    return 'x'
enddef
nno <expr> x g:Func()
setline(1, 'aaa p aaa')
feedkeys('fpx')
Pressed x at col: 1

Notice that the column was 1 when Func() was evaluated. That's because fp was still in the typeahead when x was remapped.

Now, watch this:

vim9script
def g:Func(): string
    echom 'Pressed x at col: ' .. col('.')
    return 'x'
enddef
nno <expr> x g:Func()
setline(1, 'aaa p aaa')
feedkeys('fp', 'x')
feedkeys('x')
Pressed x at col: 5

This time, the column is 5, because we've asked feedkeys() to execute the contents of the typeahead buffer (via the second optional flag argument x), before adding the key x.

So, is that really a bug? It seems it's working as intended.

lacygoill

unread,
Mar 16, 2021, 2:10:28 AM3/16/21
to vim/vim, Subscribed

In your case, one workaround is to force Vim to execute fp before X() is evaluated by first executing a no-op, like CTRL-\_CTRL-N:

vim9script
def g:Func(): string
    echom 'Pressed x at col: ' .. col('.')
    return 'x'
enddef
nno <expr> x g:Func()
setline(1, 'aaa p aaa')
exe "norm fp\<c-\>\<c-n>x"
Pressed x at col: 5

That is, during the recording, don't press this:

f p x

Press this:

f p CTRL-\ CTRL-N x

Dani Dickstein

unread,
Mar 16, 2021, 5:24:36 AM3/16/21
to vim/vim, Subscribed

@lacygoill thanks for the thorough reply! I have a couple questions.

  1. Why does a no-op force Vim to execute fp before evaluating X()? I see that it works, but I don't understand why.
  2. If I try changing the example to consume and print[1] the type-ahead as follows:
    set nocompatible
    
    function! X()
      call feedkeys(":echomsg 'Typeahead: ","ni")
      call feedkeys("'\<CR>", "nx")
      echomsg "Pressed x at col ".col('.')
      return "x"
    endfunction
    
    nnoremap <expr> x X()
    nmap ! fpxFr
    
    then I see "Fr" appear (check :messages) but column 1 is still displayed. This surprises me because based on your answer above I would have expected "fp" to still be in the typeahead buffer if it hasn't executed yet.

[1]: I can't find a good way to inspect current type-ahead -- only ways to save, restore, and execute it. The closest I can find is input() but that blocks on the user's input.

lacygoill

unread,
Mar 16, 2021, 6:25:55 AM3/16/21
to vim/vim, Subscribed

Why does a no-op force Vim to execute fp before evaluating X()? I see that it works, but I don't understand why.

I'm not a dev, so I don't know. I've just noticed that fp didn't seem to have been executed when X() was evaluated, and I remembered a similar issue which I've documented here. Basically, it boiled down to this: when executing the contents of a register which changes the current mode, some keys might be executed in the wrong mode. The solution which I found was to execute a no-op to force Vim to change the current mode when I expected it would already have been automatically done. Your issue sounded similar, so I applied the same solution:

the current mode should have been changed
=> it did not
=> execute a no-op to make Vim change it now

the keys should have been executed
=> they were not
=> execute a no-op to make Vim execute them now

then I see "Fr" appear (check :messages) but column 1 is still displayed. This surprises me because based on your answer above I would have expected "fp" to still be in the typeahead buffer if it hasn't executed yet.

I agree, it looks unexpected. But maybe fp has been removed from the typeahead because Vim has noticed that these keys could not be remapped further (you have no f mapping), and that they form a complete and valid command (f expects an argument, here provided by p). And maybe the keys which are removed from the typeahead are not immediately executed; there might be some kind of overhead which requires a little more time. This time might be small enough that it cannot be perceived during an interactive use, but could be noticeable in a non-interactive one (like a mapping or a macro), because all the keys are typed almost instantaneously.

That's all speculation on my part. You'll have to wait for a dev to comment to get more reliable information.

[1]: I can't find a good way to inspect current type-ahead -- only ways to save, restore, and execute it. The closest I can find is input() but that blocks on the user's input.

You could add a breakpoint at the start of the function:

breakadd func X

Then, as you said, you can invoke input('') which will consume the typeahead, and put it on the command line. Obviously, this breaks the original sequence, but for a one-time debugging session, I guess it's good enough to let you peek at the typeahead's contents.

Andy Massimino

unread,
Mar 16, 2021, 7:10:41 PM3/16/21
to vim/vim, Subscribed

There are a couple of passages in vim's :help map-<expr> which sort of sideways mention exactly what @lacygoill is saying:

Be very careful about side effects!  The expression is evaluated while
obtaining characters, you may very well make the command dysfunctional.

...

If something changed that requires Vim to
go through the main loop (e.g. to update the display), return "\<Ignore>".
This is similar to "nothing" but makes Vim return from the loop that waits for input.

But neither are so explicit, and the problem remains. If a person is writing an <expr> map which needs line/col, they'll be bitten eventually when someone tries to create a map or macro on it. Unless they add a "sequence point" like this

nnoremap <expr> <plug>(my-x) X()
nmap x <ignore><plug>(my-x)
nmap <space> fpx

Bram Moolenaar

unread,
Mar 17, 2021, 8:28:52 AM3/17/21
to vim/vim, Subscribed

Closed #7971 via 18b7d86.

Reply all
Reply to author
Forward
0 new messages