---Debug event format module. ---Resolves {0} and {N} and {N.key...} placeholders against wrapped varargs. --- ---## Model --- ---All varargs are wrapped into `wrapped = {...}`. Every placeholder indexes into this. --- ---### Syntax: {0}, {N}, or {N.key.sub...} --- {0} -> vim.inspect(all data) --- {N} -> wrapped[N] --- {N.key} -> wrapped[N].key --- {N.1.key} -> wrapped[N][1].key (numeric into string) --- {N.["key"]} -> wrapped[N]["key"] (bracket = always string key) --- {N.["a.b"]} -> wrapped[N]["a.b"] (key containing dots) --- Mixed bare and bracket: {N.a.["x.y"].z} works as expected --- ---### Grammar (key segments after {N.}) --- key_segment := bare_key | '[' quoted_string ']' --- bare_key := [^.[]+ (alphanumeric, underscores, etc.) --- quoted_string := '"' [^"]* '"' | "'" [^']* "'" --- Separator '.' between segments. No whitespace between segments. --- ---### Examples --- dbg('{0}', {a=1, b='bee'}) -> '{ a = 1, b = "bee" }' --- dbg('{1}', 'hello') -> 'hello' --- dbg('{2}', 'a', 'b') -> 'b' --- dbg('{1.name}', {name='hi'}) -> 'hi' --- dbg('{1.1}', {{'nested'}}) -> 'nested' --- dbg('{1.1.name}', {{name='nested'}}) -> 'nested' --- dbg('{2.1.jan.name}', {'x', {jan={name='jan'}}}) -> 'jan' --- dbg('{2.1.jan.age}', {'x', {age=39, {jan={name='jan',age=11}}}}) -> '11' --- dbg('{1.["a.b"]}', { {['a.b'] = 'dots'} }) -> 'dots' --- dbg('{1.["1"]}', { {['1'] = 'str', [1] = 'int'} }) -> 'str' (string key wins) --- dbg('{1.["function"]}', { {['function'] = 'kw'} }) -> 'kw' --- dbg('{1.["hello world"]}', { {['hello world'] = 'hw'} }) -> 'hw' --- dbg('{1.[""]}', { {[''] = 'empty'} }) -> 'empty' --- ---### Rules --- - {0} returns vim.inspect of all data; cannot be followed by keys --- - First key must be numeric (0 or the index into wrapped) --- - Subsequent bare keys are strings unless they parse as integers --- - Bracket keys (["key"]) are ALWAYS string keys — never parsed as numbers --- - This means {1.["2"]} accesses string key "2", while {1.2} accesses integer key 2 --- - Indices out of range produce an error --- - Any nil result (missing index or missing chained key) produces an error --- - Table values are rendered via vim.inspect --- ---### Error Suggestions --- - Index off by 1-3: suggests correct index (e.g., {3} with 2 items suggests {2}) --- - Wrong key name: suggests closest key using Levenshtein distance (threshold 2) --- ---Parses a key expression into segments. ---Grammar: --- expression := key_segment ('.' key_segment)* --- key_segment := bare_key | '[' quoted_string ']' --- bare_key := [^.[]+ --- quoted_string := '"' [^"]* '"' | "'" [^']* "'" ---Returns array of { key = string|number, is_string = boolean }. ---Bracket keys are always strings; bare keys are strings unless numeric. ---@param inner string ---@return table local function parse_keys(inner) local keys = {} local pos = 1 while pos <= #inner do if inner:sub(pos, pos) == '.' then pos = pos + 1 end if pos > #inner then break end local c = inner:sub(pos, pos) if c == '[' then pos = pos + 1 if pos > #inner then error('parse error: unclosed bracket in {' .. inner .. '}') end local quote = inner:sub(pos, pos) if quote ~= '"' and quote ~= "'" then error('parse error: expected quote in {' .. inner .. '}') end pos = pos + 1 local key_start = pos local quote_end = inner:find(quote, pos, true) if not quote_end then error('parse error: unclosed bracket in {' .. inner .. '}') end local key = inner:sub(key_start, quote_end - 1) pos = quote_end + 1 if pos > #inner or inner:sub(pos, pos) ~= ']' then error('parse error: expected ] in {' .. inner .. '}') end pos = pos + 1 keys[#keys + 1] = { key = key, is_string = true } else local key_start = pos while pos <= #inner and inner:sub(pos, pos) ~= '.' and inner:sub(pos, pos) ~= '[' do pos = pos + 1 end local key = inner:sub(key_start, pos - 1) if key == '' then error('parse error: empty key in {' .. inner .. '}') end keys[#keys + 1] = { key = tonumber(key) or key, is_string = false } end end return keys end ---Computes Levenshtein edit distance between two strings. ---Uses Wagner-Fischer dynamic programming algorithm. ---@param a string first string ---@param b string second string ---@return number edit distance (0 = identical) local function levenshtein(a, b) local m, n = #a, #b if m == 0 then return n end if n == 0 then return m end local prev = {} local curr = {} for j = 0, n do prev[j] = j end for i = 1, m do curr[0] = i for j = 1, n do local cost = a:sub(i, i) == b:sub(j, j) and 0 or 1 curr[j] = math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) end prev, curr = curr, prev end return prev[n] end ---Suggests a similar key from a table based on Levenshtein distance. ---Only suggests keys within threshold distance (<= 2). ---@param tbl table the table to search ---@param wrong_key any the key that was not found ---@return string? suggested key or nil if none close enough local function suggest_key(tbl, wrong_key) if type(wrong_key) ~= 'string' then return nil end local best_key, best_dist = nil, math.huge for k in pairs(tbl) do if type(k) == 'string' then local d = levenshtein(k, wrong_key) if d < best_dist then best_key, best_dist = k, d end end end if best_dist <= 2 then return best_key end return nil end ---@param format string format string with placeholders ---@param data table varargs list packed as {...} ---@return string formatted message ---@see vim.inspect local function format_message(format, data) vim.validate('format', format, 'string') vim.validate('data', data, 'table') local data_len = #data local result = {} local pos = 1 while pos <= #format do local startbrace, endbrace = format:find('%b{}', pos) if not startbrace then table.insert(result, format:sub(pos)) break end if startbrace > pos then table.insert(result, format:sub(pos, startbrace - 1)) end local match = format:sub(startbrace, endbrace) local inner = match:match('^{%s*(.-)%s*}$') local replacement if not inner or inner == '' then error('Empty {} is not allowed. Use {N} or {N.key...} syntax.') else local keys = parse_keys(inner) local first_key_entry = keys[1] local first_key = first_key_entry.key if first_key_entry.is_string or type(first_key) ~= 'number' then error( 'First key in {' .. inner .. '} must be a numeric index (like {1} or {1.key}). ' .. 'Named keys like {' .. inner .. '} are not supported. Use {1.' .. inner .. '} instead.' ) end local idx = first_key if idx == 0 then if #keys > 1 then error('{0} cannot be followed by keys like {0.key}. Use {0} alone or {N.key} instead.') end if vim.tbl_count(data) == 0 then error('{0} cannot be used on empty data.') end replacement = vim.inspect(data) elseif idx > data_len or idx < 1 then local suggestion = nil if idx > data_len and idx <= data_len + 3 then suggestion = ' (did you mean {' .. (idx - 1) .. '}?)' end error( 'Index ' .. idx .. ' is out of range (have ' .. data_len .. ' items)' .. (suggestion or '') ) else local current = data[idx] local failed_key = nil local parent_table = nil for i = 2, #keys do if current == nil then break end local k = keys[i] local access_key = k.is_string and k.key or tonumber(k.key) or k.key if type(current) == 'table' then local key_exists = false for ek in pairs(current) do if ek == access_key then key_exists = true break end end if not key_exists then failed_key = access_key parent_table = current end end current = current[access_key] end if current == nil then local msg = 'Key not found while resolving {' .. inner .. '}' if failed_key and parent_table then local suggestion = suggest_key(parent_table, failed_key) if suggestion then msg = msg .. " (did you mean '" .. suggestion .. "'?)" end end error(msg) end replacement = type(current) == 'table' and vim.inspect(current) or tostring(current) end end table.insert(result, replacement) pos = endbrace + 1 end return table.concat(result) end return setmetatable( { levenshtein = levenshtein }, { __call = function(_, ...) return format_message(...) end } )