neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

luacats_parser.lua (15470B)


      1 local luacats_grammar = require('gen.luacats_grammar')
      2 
      3 --- @class nvim.luacats.parser.param : nvim.luacats.Param
      4 
      5 --- @class nvim.luacats.parser.return
      6 --- @field name string
      7 --- @field type string
      8 --- @field desc string
      9 
     10 --- @class nvim.luacats.parser.note
     11 --- @field desc string
     12 
     13 --- @class nvim.luacats.parser.brief
     14 --- @field kind 'brief'
     15 --- @field desc string
     16 
     17 --- @class nvim.luacats.parser.alias
     18 --- @field kind 'alias'
     19 --- @field type string[]
     20 --- @field desc string
     21 
     22 --- @class nvim.luacats.parser.fun
     23 --- @field name string
     24 --- @field params nvim.luacats.parser.param[]
     25 --- @field overloads string[]
     26 --- @field returns nvim.luacats.parser.return[]
     27 --- @field desc string
     28 --- @field access? 'private'|'package'|'protected'
     29 --- @field class? string
     30 --- @field module? string
     31 --- @field modvar? string
     32 --- @field classvar? string
     33 --- @field deprecated? true
     34 --- @field async? true
     35 --- @field since? string
     36 --- @field attrs? string[]
     37 --- @field nodoc? true
     38 --- @field generics? table<string,string>
     39 --- @field table? true
     40 --- @field notes? nvim.luacats.parser.note[]
     41 --- @field see? nvim.luacats.parser.note[]
     42 
     43 --- @class nvim.luacats.parser.field : nvim.luacats.Field
     44 --- @field classvar? string
     45 --- @field nodoc? true
     46 
     47 --- @class nvim.luacats.parser.class : nvim.luacats.Class
     48 --- @field desc? string
     49 --- @field nodoc? true
     50 --- @field inlinedoc? true
     51 --- @field fields nvim.luacats.parser.field[]
     52 --- @field notes? string[]
     53 
     54 --- @class nvim.luacats.parser.State
     55 --- @field doc_lines? string[]
     56 --- @field cur_obj? nvim.luacats.parser.obj
     57 --- @field last_doc_item? nvim.luacats.parser.param|nvim.luacats.parser.return|nvim.luacats.parser.note
     58 --- @field last_doc_item_indent? integer
     59 
     60 --- @alias nvim.luacats.parser.obj
     61 --- | nvim.luacats.parser.class
     62 --- | nvim.luacats.parser.fun
     63 --- | nvim.luacats.parser.brief
     64 --- | nvim.luacats.parser.alias
     65 
     66 -- Remove this when we document classes properly
     67 --- Some doc lines have the form:
     68 ---   param name some.complex.type (table) description
     69 --- if so then transform the line to remove the complex type:
     70 ---   param name (table) description
     71 --- @param line string
     72 local function use_type_alt(line)
     73  for _, type in ipairs({ 'table', 'function' }) do
     74    line = line:gsub('@param%s+([a-zA-Z_?]+)%s+.*%((' .. type .. ')%)', '@param %1 %2')
     75    line = line:gsub('@param%s+([a-zA-Z_?]+)%s+.*%((' .. type .. '|nil)%)', '@param %1 %2')
     76    line = line:gsub('@param%s+([a-zA-Z_?]+)%s+.*%((' .. type .. '%?)%)', '@param %1 %2')
     77 
     78    line = line:gsub('@return%s+.*%((' .. type .. ')%)', '@return %1')
     79    line = line:gsub('@return%s+.*%((' .. type .. '|nil)%)', '@return %1')
     80    line = line:gsub('@return%s+.*%((' .. type .. '%?)%)', '@return %1')
     81  end
     82  return line
     83 end
     84 
     85 --- If we collected any `---` lines. Add them to the existing (or new) object
     86 --- Used for function/class descriptions and multiline param descriptions.
     87 --- @param state nvim.luacats.parser.State
     88 local function add_doc_lines_to_obj(state)
     89  if state.doc_lines then
     90    state.cur_obj = state.cur_obj or {}
     91    local cur_obj = assert(state.cur_obj)
     92    local txt = table.concat(state.doc_lines, '\n')
     93    if cur_obj.desc then
     94      cur_obj.desc = cur_obj.desc .. '\n' .. txt
     95    else
     96      cur_obj.desc = txt
     97    end
     98    state.doc_lines = nil
     99  end
    100 end
    101 
    102 --- @param line string
    103 --- @param state nvim.luacats.parser.State
    104 local function process_doc_line(line, state)
    105  line = line:sub(4):gsub('^%s+@', '@')
    106  line = use_type_alt(line)
    107 
    108  local parsed = luacats_grammar:match(line)
    109 
    110  if not parsed then
    111    if line:match('^ ') then
    112      line = line:sub(2)
    113    end
    114 
    115    if state.last_doc_item then
    116      if not state.last_doc_item_indent then
    117        state.last_doc_item_indent = #line:match('^%s*') + 1
    118      end
    119      state.last_doc_item.desc = (state.last_doc_item.desc or '')
    120        .. '\n'
    121        .. line:sub(state.last_doc_item_indent or 1)
    122    else
    123      state.doc_lines = state.doc_lines or {}
    124      table.insert(state.doc_lines, line)
    125    end
    126    return
    127  end
    128 
    129  state.last_doc_item_indent = nil
    130  state.last_doc_item = nil
    131  state.cur_obj = state.cur_obj or {}
    132  local cur_obj = assert(state.cur_obj)
    133 
    134  local kind = parsed.kind
    135 
    136  if kind == 'brief' then
    137    state.cur_obj = {
    138      kind = 'brief',
    139      desc = parsed.desc,
    140    }
    141  elseif kind == 'class' then
    142    --- @cast parsed nvim.luacats.Class
    143    cur_obj.kind = 'class'
    144    cur_obj.name = parsed.name
    145    cur_obj.parent = parsed.parent
    146    cur_obj.access = parsed.access
    147    cur_obj.desc = state.doc_lines and table.concat(state.doc_lines, '\n') or nil
    148    state.doc_lines = nil
    149    cur_obj.fields = {}
    150  elseif kind == 'field' then
    151    --- @cast parsed nvim.luacats.Field
    152    if parsed.desc == '' then
    153      parsed.desc = nil
    154    end
    155    parsed.desc = parsed.desc or state.doc_lines and table.concat(state.doc_lines, '\n') or nil
    156    if parsed.desc then
    157      parsed.desc = vim.trim(parsed.desc)
    158    end
    159    table.insert(cur_obj.fields, parsed)
    160    state.doc_lines = nil
    161  elseif kind == 'operator' then
    162    parsed.desc = parsed.desc or state.doc_lines and table.concat(state.doc_lines, '\n') or nil
    163    if parsed.desc then
    164      parsed.desc = vim.trim(parsed.desc)
    165    end
    166    table.insert(cur_obj.fields, parsed)
    167    state.doc_lines = nil
    168  elseif kind == 'param' then
    169    state.last_doc_item_indent = nil
    170    cur_obj.params = cur_obj.params or {}
    171    if vim.endswith(parsed.name, '?') then
    172      parsed.name = parsed.name:sub(1, -2)
    173      parsed.type = parsed.type .. '?'
    174    end
    175    state.last_doc_item = {
    176      name = parsed.name,
    177      type = parsed.type,
    178      desc = parsed.desc,
    179    }
    180    table.insert(cur_obj.params, state.last_doc_item)
    181  elseif kind == 'return' then
    182    cur_obj.returns = cur_obj.returns or {}
    183    for _, t in ipairs(parsed) do
    184      table.insert(cur_obj.returns, {
    185        name = t.name,
    186        type = t.type,
    187        desc = parsed.desc,
    188      })
    189    end
    190    state.last_doc_item_indent = nil
    191    state.last_doc_item = cur_obj.returns[#cur_obj.returns]
    192  elseif kind == 'private' then
    193    cur_obj.access = 'private'
    194  elseif kind == 'package' then
    195    cur_obj.access = 'package'
    196  elseif kind == 'protected' then
    197    cur_obj.access = 'protected'
    198  elseif kind == 'deprecated' then
    199    cur_obj.deprecated = true
    200  elseif kind == 'inlinedoc' then
    201    cur_obj.inlinedoc = true
    202  elseif kind == 'nodoc' then
    203    cur_obj.nodoc = true
    204  elseif kind == 'since' then
    205    cur_obj.since = parsed.desc
    206  elseif kind == 'see' then
    207    cur_obj.see = cur_obj.see or {}
    208    table.insert(cur_obj.see, { desc = parsed.desc })
    209  elseif kind == 'note' then
    210    state.last_doc_item_indent = nil
    211    state.last_doc_item = {
    212      desc = parsed.desc,
    213    }
    214    cur_obj.notes = cur_obj.notes or {}
    215    table.insert(cur_obj.notes, state.last_doc_item)
    216  elseif kind == 'type' then
    217    cur_obj.desc = parsed.desc
    218    parsed.desc = nil
    219    parsed.kind = nil
    220    cur_obj.type = parsed
    221  elseif kind == 'alias' then
    222    state.cur_obj = {
    223      kind = 'alias',
    224      desc = parsed.desc,
    225    }
    226  elseif kind == 'enum' then
    227    -- TODO
    228    state.doc_lines = nil
    229  elseif kind == 'async' then
    230    cur_obj.async = true
    231  elseif kind == 'overload' then
    232    cur_obj.overloads = cur_obj.overloads or {}
    233    table.insert(cur_obj.overloads, parsed.type)
    234  elseif
    235    vim.tbl_contains({
    236      'diagnostic',
    237      'cast',
    238      'overload',
    239      'meta',
    240    }, kind)
    241  then
    242    -- Ignore
    243    return
    244  elseif kind == 'generic' then
    245    cur_obj.generics = cur_obj.generics or {}
    246    cur_obj.generics[parsed.name] = parsed.type or 'any'
    247  else
    248    error('Unhandled' .. vim.inspect(parsed))
    249  end
    250 end
    251 
    252 --- @param fun nvim.luacats.parser.fun
    253 --- @return nvim.luacats.parser.field
    254 local function fun2field(fun)
    255  local parts = { 'fun(' }
    256 
    257  local params = {} ---@type string[]
    258  for _, p in ipairs(fun.params or {}) do
    259    params[#params + 1] = string.format('%s: %s', p.name, p.type)
    260  end
    261  parts[#parts + 1] = table.concat(params, ', ')
    262  parts[#parts + 1] = ')'
    263  if fun.returns then
    264    parts[#parts + 1] = ': '
    265    local tys = {} --- @type string[]
    266    for _, p in ipairs(fun.returns) do
    267      tys[#tys + 1] = p.type
    268    end
    269    parts[#parts + 1] = table.concat(tys, ', ')
    270  end
    271 
    272  return {
    273    kind = 'field',
    274    name = fun.name,
    275    type = table.concat(parts, ''),
    276    access = fun.access,
    277    desc = fun.desc,
    278    nodoc = fun.nodoc,
    279    classvar = fun.classvar,
    280  }
    281 end
    282 
    283 --- Function to normalize known form for declaring functions and normalize into a more standard
    284 --- form.
    285 --- @param line string
    286 --- @return string
    287 local function filter_decl(line)
    288  -- M.fun = vim._memoize(function(...)
    289  --   ->
    290  -- function M.fun(...)
    291  line = line:gsub('^local (.+) = memoize%([^,]+, function%((.*)%)$', 'local function %1(%2)')
    292  line = line:gsub('^(.+) = memoize%([^,]+, function%((.*)%)$', 'function %1(%2)')
    293  return line
    294 end
    295 
    296 --- @param line string
    297 --- @param state nvim.luacats.parser.State
    298 --- @param classes table<string,nvim.luacats.parser.class>
    299 --- @param classvars table<string,string>
    300 --- @param has_indent boolean
    301 local function process_lua_line(line, state, classes, classvars, has_indent)
    302  line = filter_decl(line)
    303 
    304  if state.cur_obj and state.cur_obj.kind == 'class' then
    305    local nm = line:match('^local%s+([a-zA-Z0-9_]+)%s*=')
    306    if nm then
    307      classvars[nm] = state.cur_obj.name
    308    end
    309    return
    310  end
    311 
    312  do
    313    local parent_tbl, sep, fun_or_meth_nm =
    314      line:match('^function%s+([a-zA-Z0-9_]+)([.:])([a-zA-Z0-9_]+)%s*%(')
    315    if parent_tbl then
    316      -- Have a decl. Ensure cur_obj
    317      state.cur_obj = state.cur_obj or {}
    318      local cur_obj = assert(state.cur_obj)
    319 
    320      -- Match `Class:foo` methods for defined classes
    321      local class = classvars[parent_tbl]
    322      if class then
    323        --- @cast cur_obj nvim.luacats.parser.fun
    324        cur_obj.name = fun_or_meth_nm
    325        cur_obj.class = class
    326        cur_obj.classvar = parent_tbl
    327        -- Add self param to methods
    328        if sep == ':' then
    329          cur_obj.params = cur_obj.params or {}
    330          table.insert(cur_obj.params, 1, {
    331            name = 'self',
    332            type = class,
    333          })
    334        end
    335 
    336        -- Add method as the field to the class
    337        table.insert(classes[class].fields, fun2field(cur_obj))
    338        return
    339      end
    340 
    341      -- Match `M.foo`
    342      if cur_obj and parent_tbl == cur_obj.modvar then
    343        cur_obj.name = fun_or_meth_nm
    344        return
    345      end
    346    end
    347  end
    348 
    349  do
    350    -- Handle: `function A.B.C.foo(...)`
    351    local fn_nm = line:match('^function%s+([.a-zA-Z0-9_]+)%s*%(')
    352    if fn_nm then
    353      state.cur_obj = state.cur_obj or {}
    354      state.cur_obj.name = fn_nm
    355      return
    356    end
    357  end
    358 
    359  do
    360    -- Handle: `M.foo = {...}` where `M` is the modvar
    361    local parent_tbl, tbl_nm = line:match('([a-zA-Z_]+)%.([a-zA-Z0-9_]+)%s*=')
    362    if state.cur_obj and parent_tbl and parent_tbl == state.cur_obj.modvar then
    363      state.cur_obj.name = tbl_nm
    364      state.cur_obj.table = true
    365      return
    366    end
    367  end
    368 
    369  do
    370    -- Handle: `foo = {...}`
    371    local tbl_nm = line:match('^([a-zA-Z0-9_]+)%s*=')
    372    if tbl_nm and not has_indent then
    373      state.cur_obj = state.cur_obj or {}
    374      state.cur_obj.name = tbl_nm
    375      state.cur_obj.table = true
    376      return
    377    end
    378  end
    379 
    380  do
    381    -- Handle: `vim.foo = {...}`
    382    local tbl_nm = line:match('^(vim%.[a-zA-Z0-9_]+)%s*=')
    383    if state.cur_obj and tbl_nm and not has_indent then
    384      state.cur_obj.name = tbl_nm
    385      state.cur_obj.table = true
    386      return
    387    end
    388  end
    389 
    390  if state.cur_obj then
    391    if line:find('^%s*%-%- luacheck:') then
    392      state.cur_obj = nil
    393    elseif line:find('^%s*local%s+') then
    394      state.cur_obj = nil
    395    elseif line:find('^%s*return%s+') then
    396      state.cur_obj = nil
    397    elseif line:find('^%s*[a-zA-Z_.]+%(%s+') then
    398      state.cur_obj = nil
    399    end
    400  end
    401 end
    402 
    403 --- Determine the table name used to export functions of a module
    404 --- Usually this is `M`.
    405 --- @param str string
    406 --- @return string?
    407 local function determine_modvar(str)
    408  local modvar --- @type string?
    409  for line in vim.gsplit(str, '\n') do
    410    do
    411      --- @type string?
    412      local m = line:match('^return%s+([a-zA-Z_]+)')
    413      if m then
    414        modvar = m
    415      end
    416    end
    417    do
    418      --- @type string?
    419      local m = line:match('^return%s+setmetatable%(([a-zA-Z_]+),')
    420      if m then
    421        modvar = m
    422      end
    423    end
    424  end
    425  return modvar
    426 end
    427 
    428 --- @param obj nvim.luacats.parser.obj
    429 --- @param funs nvim.luacats.parser.fun[]
    430 --- @param classes table<string,nvim.luacats.parser.class>
    431 --- @param briefs string[]
    432 --- @param uncommitted nvim.luacats.parser.obj[]
    433 local function commit_obj(obj, classes, funs, briefs, uncommitted)
    434  local commit = false
    435  if obj.kind == 'class' then
    436    --- @cast obj nvim.luacats.parser.class
    437    if not classes[obj.name] then
    438      classes[obj.name] = obj
    439      commit = true
    440    end
    441  elseif obj.kind == 'alias' then
    442    -- Just pretend
    443    commit = true
    444  elseif obj.kind == 'brief' then
    445    --- @cast obj nvim.luacats.parser.brief`
    446    briefs[#briefs + 1] = obj.desc
    447    commit = true
    448  else
    449    --- @cast obj nvim.luacats.parser.fun`
    450    if obj.name then
    451      funs[#funs + 1] = obj
    452      commit = true
    453    end
    454  end
    455  if not commit then
    456    table.insert(uncommitted, obj)
    457  end
    458  return commit
    459 end
    460 
    461 --- @param filename string
    462 --- @param uncommitted nvim.luacats.parser.obj[]
    463 -- luacheck: no unused
    464 local function dump_uncommitted(filename, uncommitted)
    465  local out_path = 'luacats-uncommited/' .. filename:gsub('/', '%%') .. '.txt'
    466  if #uncommitted > 0 then
    467    print(string.format('Could not commit %d objects in %s', #uncommitted, filename))
    468    vim.fn.mkdir(vim.fs.dirname(out_path), 'p')
    469    local f = assert(io.open(out_path, 'w'))
    470    for i, x in ipairs(uncommitted) do
    471      f:write(i)
    472      f:write(': ')
    473      f:write(vim.inspect(x))
    474      f:write('\n')
    475    end
    476    f:close()
    477  else
    478    vim.fn.delete(out_path)
    479  end
    480 end
    481 
    482 local M = {}
    483 
    484 function M.parse_str(str, filename)
    485  local funs = {} --- @type nvim.luacats.parser.fun[]
    486  local classes = {} --- @type table<string,nvim.luacats.parser.class>
    487  local briefs = {} --- @type string[]
    488 
    489  local mod_return = determine_modvar(str)
    490 
    491  --- @type string
    492  local module = filename:match('.*/lua/([a-z_][a-z0-9_/]+)%.lua') or filename
    493  module = module:gsub('/', '.')
    494 
    495  local classvars = {} --- @type table<string,string>
    496 
    497  local state = {} --- @type nvim.luacats.parser.State
    498 
    499  -- Keep track of any partial objects we don't commit
    500  local uncommitted = {} --- @type nvim.luacats.parser.obj[]
    501 
    502  for line in vim.gsplit(str, '\n') do
    503    local has_indent = line:match('^%s+') ~= nil
    504    line = vim.trim(line)
    505    if vim.startswith(line, '---') then
    506      process_doc_line(line, state)
    507    else
    508      add_doc_lines_to_obj(state)
    509 
    510      if state.cur_obj then
    511        state.cur_obj.modvar = mod_return
    512        state.cur_obj.module = module
    513      end
    514 
    515      process_lua_line(line, state, classes, classvars, has_indent)
    516 
    517      -- Commit the object
    518      local cur_obj = state.cur_obj
    519      if cur_obj then
    520        if not commit_obj(cur_obj, classes, funs, briefs, uncommitted) then
    521          --- @diagnostic disable-next-line:inject-field
    522          cur_obj.line = line
    523        end
    524      end
    525 
    526      state = {}
    527    end
    528  end
    529 
    530  -- dump_uncommitted(filename, uncommitted)
    531 
    532  return classes, funs, briefs, uncommitted
    533 end
    534 
    535 --- @param filename string
    536 function M.parse(filename)
    537  local f = assert(io.open(filename, 'r'))
    538  local txt = f:read('*all')
    539  f:close()
    540 
    541  return M.parse_str(txt, filename)
    542 end
    543 
    544 return M