feat(lua): vim.keycode() gets structured parse result #38636

Problems:
1. Can't get individual parts of a key-chord separately: modifiers, key.
2. Can't separate a key-combo into individual key-chords.

Solution:
Enhance `vim.keycode()` to optionally return a structured parse result
as a list of key-chords:
- `key_raw` the key-chord (problem 2)
- `mod` the modifiers of `key_raw` (problem 1)
- `key_orig` the key part of the key-chord, only here if differing from `key`
  (this doesn't solve any of the above mentioned problems, but it may provide
  useful and it's (in terms of code) free)
- `key` a normalized version of `key_orig` (solving problem 1), example(the
  first is `key_orig` and second is `key`): `lt` and `<`, `Bar` and `|` (in
  `<C-Bar>`)
This commit is contained in:
altermo
2026-07-18 18:20:41 +02:00
committed by GitHub
parent 952e59347e
commit a2dfa195b2
14 changed files with 263 additions and 35 deletions

View File

@@ -1271,7 +1271,7 @@ vim.inspect() *vim.inspect()*
• https://github.com/kikito/inspect.lua
• https://github.com/mpeterv/vinspect
vim.keycode({str}) *vim.keycode()*
vim.keycode({str}, {info}) *vim.keycode()*
Translates keycodes.
Example: >lua
@@ -1280,10 +1280,20 @@ vim.keycode({str}) *vim.keycode()*
<
Parameters: ~
• {str} (`string`) String to be converted.
• {str} (`string`) String to be converted.
• {info} (`boolean?`) Also returns a normalized detailed info about the
key as a second return value.
Return: ~
Return (multiple): ~
(`string`)
(`table?`) List of dicts where each dict represents a key chord, and
has fields:
• {key} (`string`) The key chord normalized without modifiers.
• {key_orig} (`string?`) Non-normalized version of key chord, only
present when it differs from {key}. Example: `lt` and `<`.
• {key_raw} (`string`) The original key chord with modifiers.
• {mod} (`('M'|'T'|'C'|'S'|'2'|'3'|'4'|'D')[]`) A list of single
character modifiers of the key.
See also: ~
• |nvim_replace_termcodes()|

View File

@@ -313,6 +313,8 @@ LUA
• |vim.pack.get()| output includes revision of a pending update.
• |vim.pack.get()| can fetch new updates before computing the output.
• |vim.o| now accepts table style values for assignment.
• |vim.pos| and |vim.range| can now convert between mark positions.
• |vim.keycode()| returns structured info as return value 2.
OPTIONS

View File

@@ -1260,6 +1260,23 @@ function vim.print(...)
return vim._print(false, ...)
end
--- @class vim.keycode.info
--- @inlinedoc
---
--- The key chord normalized without modifiers.
--- @field key string
---
--- Non-normalized version of key chord,
--- only present when it differs from {key}.
--- Example: `lt` and `<`.
--- @field key_orig string?
---
--- The original key chord with modifiers.
--- @field key_raw string
---
--- A list of single character modifiers of the key.
--- @field mod ('M'|'T'|'C'|'S'|'2'|'3'|'4'|'D')[]
--- Translates keycodes.
---
--- Example:
@@ -1269,11 +1286,21 @@ end
--- vim.g.mapleader = k'<bs>'
--- ```
---
---
--- @param str string String to be converted.
--- @param info boolean? Also returns a normalized
--- detailed info about the key as a second return value.
--- @return string
--- @return vim.keycode.info[]? List of dicts where
--- each dict represents a key chord, and has fields:
--- @see |nvim_replace_termcodes()|
function vim.keycode(str)
return vim.api.nvim_replace_termcodes(str, true, true, true)
function vim.keycode(str, info)
if info then
local keycode = vim.keycode(str)
return keycode, vim._keyarg(keycode)
else
return vim.api.nvim_replace_termcodes(str, true, true, true)
end
end
--- @param server_addr string

View File

@@ -2391,7 +2391,7 @@ static int command_line_handle_key(CommandLineState *s)
end:
// put the character in the command line
if (IS_SPECIAL(s->c) || mod_mask != 0) {
put_on_cmdline(get_special_key_name(s->c, mod_mask), -1, true);
put_on_cmdline(get_special_key_name(s->c, mod_mask, NULL), -1, true);
} else {
int j = utf_char2bytes(s->c, IObuff);
IObuff[j] = NUL; // exclude composing chars

View File

@@ -1904,7 +1904,7 @@ static void insert_special(int c, int allow_modmask, int ctrlv)
allow_modmask = true;
}
if (IS_SPECIAL(c) || (mod_mask && allow_modmask)) {
char *p = get_special_key_name(c, mod_mask);
char *p = get_special_key_name(c, mod_mask, NULL);
int len = (int)strlen(p);
c = (uint8_t)p[len - 1];
if (len > 2) {

View File

@@ -28,12 +28,7 @@
#include "keycodes.c.generated.h"
// Some useful tables.
static const struct modmasktable {
uint16_t mod_mask; ///< Bit-mask for particular key modifier.
uint16_t mod_flag; ///< Bit(s) for particular key modifier.
char name; ///< Single letter name of modifier.
} mod_mask_table[] = {
const struct modmasktable mod_mask_table[] = {
{ MOD_MASK_ALT, MOD_MASK_ALT, 'M' },
{ MOD_MASK_META, MOD_MASK_META, 'T' },
{ MOD_MASK_CTRL, MOD_MASK_CTRL, 'C' },
@@ -260,9 +255,10 @@ int handle_x_keys(const int key)
}
/// @return a string which contains the name of the given key when the given modifiers are down.
char *get_special_key_name(int c, int modifiers)
char *get_special_key_name(int c, int modifiers, struct keycode_data *data)
{
static char string[MAX_KEY_NAME_LEN + 1];
bool is_ascii_ctrl = false;
string[0] = '<';
int idx = 1;
@@ -293,31 +289,38 @@ char *get_special_key_name(int c, int modifiers)
// extract modifiers.
if (c > 0
&& utf_char2len(c) == 1) {
if (table_idx < 0
&& (!vim_isprintc(c) || (c & 0x7f) == ' ')
&& (c & 0x80)) {
c &= 0x7f;
modifiers |= MOD_MASK_ALT;
// try again, to find the un-alted key in the special key table
table_idx = find_special_key_in_table(c);
}
if (table_idx < 0 && !vim_isprintc(c) && c < ' ') {
c += '@';
modifiers |= MOD_MASK_CTRL;
is_ascii_ctrl = true;
}
}
if (data) {
data->modifiers = 0;
}
// translate the modifier into a string
for (int i = 0; mod_mask_table[i].name != 'A'; i++) {
if ((modifiers & mod_mask_table[i].mod_mask)
== mod_mask_table[i].mod_flag) {
string[idx++] = mod_mask_table[i].name;
string[idx++] = '-';
if (data) {
data->modifiers |= mod_mask_table[i].mod_flag;
}
}
}
if (table_idx < 0) { // unknown special key, may output t_xx
if (IS_SPECIAL(c)) {
if (data) {
data->key_orig = (String){ NULL, 0 };
data->key = (String){ &string[idx], 4 };
}
string[idx++] = 't';
string[idx++] = '_';
string[idx++] = (char)(uint8_t)KEY2TERMCAP0(c);
@@ -326,11 +329,37 @@ char *get_special_key_name(int c, int modifiers)
// Not a special key, only modifiers, output directly.
int len = utf_char2len(c);
if (len == 1 && vim_isprintc(c)) {
if (data) {
if ('A' <= c && c <= 'Z') {
data->_key_mem = (char)c + 32;
data->key = (String){ &data->_key_mem, 1 };
if (!is_ascii_ctrl) {
data->modifiers |= MOD_MASK_SHIFT;
}
data->key_orig = (String){ &string[idx], (size_t)len };
} else {
data->key_orig = (String){ NULL, 0 };
data->key = (String){ &string[idx], (size_t)len };
}
}
string[idx++] = (char)(uint8_t)c;
} else if (len > 1) {
if (data) {
data->key = (String){ &string[idx], (size_t)len };
data->key_orig = (String){ NULL, 0 };
}
idx += utf_char2bytes(c, string + idx);
} else {
char *s = transchar(c);
if (data) {
data->_key_mem = (char)c;
data->key = (String){ &data->_key_mem, 1 };
data->key_orig = (String){ &string[idx], strlen(s) };
}
while (*s) {
string[idx++] = *s++;
}
@@ -338,6 +367,18 @@ char *get_special_key_name(int c, int modifiers)
}
} else { // use name of special key
const String *s = &key_names_table[table_idx].name;
if (data) {
if (0 <= c && c <= 0x7f) {
data->_key_mem = (char)c;
data->key = (String){ &data->_key_mem, 1 };
data->key_orig = *s;
} else {
data->key = *s;
data->key_orig = (String){ NULL, 0 };
}
}
if ((int)s->size + idx + 2 <= MAX_KEY_NAME_LEN) {
STRCPY(string + idx, s->data);
idx += (int)s->size;

View File

@@ -1,7 +1,9 @@
#pragma once
#include "nvim/api/private/defs.h"
#include "nvim/ascii_defs.h"
#include "nvim/eval/typval_defs.h" // IWYU pragma: keep
#include "nvim/message_defs.h"
// Keycode definitions for special keys.
//
@@ -504,4 +506,10 @@ enum {
FSK_SIMPLIFY = 0x08, ///< simplify <C-H>, etc.
};
extern const struct modmasktable {
uint16_t mod_mask; ///< Bit-mask for particular key modifier.
uint16_t mod_flag; ///< Bit(s) for particular key modifier.
char name; ///< Single letter name of modifier.
} mod_mask_table[];
#include "keycodes.h.generated.h"

View File

@@ -31,6 +31,7 @@
#include "nvim/ex_eval.h"
#include "nvim/fold.h"
#include "nvim/globals.h"
#include "nvim/keycodes.h"
#include "nvim/lua/base64.h"
#include "nvim/lua/converter.h"
#include "nvim/lua/spell.h"
@@ -41,6 +42,7 @@
#include "nvim/mbyte_defs.h"
#include "nvim/memline.h"
#include "nvim/memory.h"
#include "nvim/message_defs.h"
#include "nvim/pos_defs.h"
#include "nvim/regexp.h"
#include "nvim/regexp_defs.h"
@@ -678,6 +680,51 @@ static int nlua_with(lua_State *L)
return rets;
}
// Parses internal representation of key into detailed dict about each key chord.
static int nlua_keyarg(lua_State *L)
{
luaL_argcheck(L, lua_isstring(L, 1), 1, "string expected");
char *escaped = vim_strsave_escape_ks((char *)lua_tostring(L, 1));
const char *p = escaped;
lua_newtable(L);
struct keycode_data data;
while (*p != NUL) {
const char *key = str2special(&p, true, true, &data);
lua_createtable(L, 0, 3);
lua_newtable(L);
for (int i = 0; mod_mask_table[i].name != 'A'; i++) {
if ((data.modifiers & mod_mask_table[i].mod_mask) == mod_mask_table[i].mod_flag) {
lua_pushlstring(L, &mod_mask_table[i].name, 1);
lua_rawseti(L, -2, (int)lua_objlen(L, -2) + 1);
}
}
lua_setfield(L, -2, "mod");
lua_pushlstring(L, data.key.data, data.key.size);
lua_setfield(L, -2, "key");
lua_pushstring(L, key);
lua_setfield(L, -2, "key_raw");
if (data.key_orig.size != 0) {
lua_pushlstring(L, data.key_orig.data, data.key_orig.size);
lua_setfield(L, -2, "key_orig");
}
lua_rawseti(L, -2, (int)lua_objlen(L, -2) + 1);
}
xfree(escaped);
return 1;
}
// Access to internal functions. For use in runtime/
static void nlua_state_add_internal(lua_State *const lstate)
{
@@ -695,6 +742,9 @@ static void nlua_state_add_internal(lua_State *const lstate)
lua_pushcfunction(lstate, &nlua_with);
lua_setfield(lstate, -2, "_with_c");
lua_pushcfunction(lstate, &nlua_keyarg);
lua_setfield(lstate, -2, "_keyarg");
}
void nlua_state_add_stdlib(lua_State *const lstate, bool is_thread)

View File

@@ -1223,7 +1223,7 @@ static char *translate_mapping(const char *const str_in, const char *const cpo_v
str += 2;
}
if (IS_SPECIAL(c) || modifiers) { // special key
ga_concat(&ga, get_special_key_name(c, modifiers));
ga_concat(&ga, get_special_key_name(c, modifiers, NULL));
continue; // for (str)
}
}
@@ -1948,7 +1948,7 @@ int put_escstr(FILE *fd, const char *strstart, int what)
str += 2;
}
if (IS_SPECIAL(c) || modifiers) { // special key
if (fputs(get_special_key_name(c, modifiers), fd) < 0) {
if (fputs(get_special_key_name(c, modifiers, NULL), fd) < 0) {
return FAIL;
}
continue;

View File

@@ -1986,7 +1986,7 @@ int msg_outtrans_special(const char *strstart, bool from, int maxlen)
text = "<Space>";
str++;
} else {
text = str2special(&str, from, false);
text = str2special(&str, from, false, NULL);
}
if (text[0] != NUL && text[1] == NUL) {
// single-byte character or illegal byte
@@ -2024,7 +2024,7 @@ char *str2special_save(const char *const str, const bool replace_spaces,
const char *p = str;
while (*p != NUL) {
ga_concat(&ga, str2special(&p, replace_spaces, replace_others));
ga_concat(&ga, str2special(&p, replace_spaces, replace_others, NULL));
}
ga_append(&ga, NUL);
return (char *)ga.ga_data;
@@ -2049,14 +2049,14 @@ char *str2special_arena(const char *const str, const bool replace_spaces,
const char *p = str;
size_t len = 0;
while (*p) {
len += strlen(str2special(&p, replace_spaces, replace_others));
len += strlen(str2special(&p, replace_spaces, replace_others, NULL));
}
char *buf = arena_alloc(arena, len + 1, false);
size_t pos = 0;
p = str;
while (*p) {
const char *s = str2special(&p, replace_spaces, replace_others);
const char *s = str2special(&p, replace_spaces, replace_others, NULL);
size_t s_len = strlen(s);
memcpy(buf + pos, s, s_len);
pos += s_len;
@@ -2078,8 +2078,8 @@ char *str2special_arena(const char *const str, const bool replace_spaces,
/// for the second time.
/// On illegal byte return a string with only that byte.
const char *str2special(const char **const sp, const bool replace_spaces,
const TriState replace_others)
FUNC_ATTR_NONNULL_ALL FUNC_ATTR_WARN_UNUSED_RESULT FUNC_ATTR_NONNULL_RET
const TriState replace_others, struct keycode_data *data)
FUNC_ATTR_NONNULL_ARG(1) FUNC_ATTR_WARN_UNUSED_RESULT FUNC_ATTR_NONNULL_RET
{
static char buf[7];
@@ -2088,6 +2088,12 @@ const char *str2special(const char **const sp, const bool replace_spaces,
// string if it is a multi-byte character.
const char *const p = mb_unescape(sp);
if (p != NULL) {
if (data) {
data->modifiers = 0;
data->key = (String){ (char *)p, strlen(p) };
data->key_orig = (String){ NULL, 0 };
}
return p;
}
}
@@ -2134,8 +2140,22 @@ const char *str2special(const char **const sp, const bool replace_spaces,
|| (replace_spaces && c == ' ')
|| (replace_others != kFalse && c == '<')
|| (replace_others == kTrue && (c == '|' || c == '\\'))) {
return get_special_key_name(c, modifiers);
return get_special_key_name(c, modifiers, data);
}
if (data) {
data->modifiers = 0;
if ('A' <= c && c <= 'Z') {
data->_key_mem = (char)c + 32;
data->key = (String){ &data->_key_mem, 1 };
data->modifiers |= MOD_MASK_SHIFT;
data->key_orig = (String){ buf, 1 };
} else {
data->key = (String){ buf, 1 };
data->key_orig = (String){ NULL, 0 };
}
}
buf[0] = (char)c;
buf[1] = NUL;
return buf;
@@ -2150,7 +2170,7 @@ void str2specialbuf(const char *sp, char *buf, size_t len)
FUNC_ATTR_NONNULL_ALL
{
while (*sp) {
const char *s = str2special(&sp, false, false);
const char *s = str2special(&sp, false, false, NULL);
const size_t s_len = strlen(s);
if (len <= s_len) {
break;

View File

@@ -29,3 +29,10 @@ typedef struct msg_hist {
bool append; ///< Message should be appended to previous entry, as opposed
///< to on a new line (|ui-messages|->msg_show->append).
} MessageHistoryEntry;
struct keycode_data {
int modifiers; ///< Bitmask of modifiers.
char _key_mem; ///< Storage for {key} to point to.
String key; ///< A "normalized" version of key.
String key_orig; ///< Alternative version of {key}. Empty string means unset.
};

View File

@@ -4795,7 +4795,7 @@ static int put_set(FILE *fd, char *cmd, OptIndex opt_idx, void *varp)
OptInt wc;
if (wc_use_keyname(varp, &wc)) {
// print 'wildchar' and 'wildcharm' as a key name
if (fputs(get_special_key_name((int)wc, 0), fd) < 0) {
if (fputs(get_special_key_name((int)wc, 0, NULL), fd) < 0) {
return FAIL;
}
} else if (fprintf(fd, "%" PRId64, value_num) < 0) {
@@ -6372,7 +6372,7 @@ static void option_value2string(vimoption_T *opt, int opt_flags)
OptInt wc = 0;
if (wc_use_keyname(varp, &wc)) {
xstrlcpy(NameBuff, get_special_key_name((int)wc, 0), sizeof(NameBuff));
xstrlcpy(NameBuff, get_special_key_name((int)wc, 0, NULL), sizeof(NameBuff));
} else if (wc != 0) {
xstrlcpy(NameBuff, transchar((int)wc), sizeof(NameBuff));
} else {

View File

@@ -93,7 +93,7 @@ getkey:
}
#ifdef NVIM_LOG_DEBUG
char *keyname = key == K_EVENT ? "K_EVENT" : get_special_key_name(key, mod_mask);
char *keyname = key == K_EVENT ? "K_EVENT" : get_special_key_name(key, mod_mask, NULL);
DLOG("input: %s", keyname);
#endif

View File

@@ -0,0 +1,63 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local eq = t.eq
local exec_lua = n.exec_lua
local clear = n.clear
describe('vim.keycode() detailed info', function()
before_each(clear)
local function parse(key, simplify)
local parsed = exec_lua([[return select(2,vim.keycode(...,true))]], key)
return vim.tbl_map(function(tbl)
if simplify == 1 then
return { tbl.mod, tbl.key }
elseif simplify == 2 then
return { tbl.mod, tbl.key, tbl.key_raw }
end
return tbl
end, parsed)
end
it('works', function()
eq({ { mod = { 'M' }, key = 'f', key_raw = '<M-f>' } }, parse('<A-f>'))
eq({ { { 'M' }, 'Home' } }, parse('<A-Home>', 1))
eq({ { { 'M' }, 'f' }, { {}, 'b' }, { {}, 'c' } }, parse('<A-f>b<char-99>', 1))
eq({ { { 'M' }, '>' } }, parse('<A->>', 1))
eq({ { { 'M' }, 't_>>' } }, parse('<A-t_>>>', 1))
end)
it('normalizes', function()
-- stylua: ignore start
eq({ { {}, '\\', '<Bslash>' } }, parse('\\', 2))
eq({ { { 'M' }, '\\', '<M-Bslash>' } }, parse('<A-\\>', 2))
eq({ { { 'C' }, '\\', '<C-\\>' } }, parse('<C-\\>', 2))
eq({ { {}, '|', '<Bar>' } }, parse('|', 2))
eq({ { { 'M' }, '|', '<M-Bar>' } }, parse('<A-|>', 2))
eq({ { { 'C' }, '|', '<C-Bar>' } }, parse('<C-|>', 2))
eq({ { {}, '\127', '\127' } }, parse('\127', 2))
eq({ { { 'M' }, '\127', '<M-^?>' } }, parse('<A-\127>', 2))
eq({ { { 'C' }, '\127', '<C-^?>' } }, parse('<C-\127>', 2))
eq({ { {}, '<', '<lt>' } }, parse('<', 2))
eq({ { {}, '\t', '<Tab>' } }, parse('\t', 2))
eq({ { {}, '\r', '<CR>' } }, parse('\r', 2))
eq({ { {}, '\n', '<NL>' } }, parse('\n', 2))
eq({ { {}, '\27', '<Esc>' } }, parse('\27', 2))
eq({ { {}, ' ', '<Space>' } }, parse(' ', 2))
eq({ { { 'S' }, 'a', 'A' } }, parse('A', 2))
eq({ { { 'C' }, 'a', '<C-A>' } }, parse('<C-a>', 2))
-- stylua: ignore end
eq({ { mod = {}, key = '<', key_raw = '<lt>', key_orig = 'lt' } }, parse('<'))
end)
it('handles utf8-chars', function()
eq({ { {}, 'ö' }, { { 'M' }, 'ó' }, { {}, 'ú' }, { {}, 'ü' } }, parse('ö<A-ó>úü', 1))
eq({ { {}, 'a' }, { {}, '\226\129\129' }, { {}, 'b' } }, parse('a\226\129\129b', 1))
eq({ { {}, 'a' }, { {}, '\242\129\129\129' }, { {}, 'b' } }, parse('a\242\129\129\129b', 1))
end)
end)