Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/std/dbg/format.lua

Raw
---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 }
)