mirror of
				https://github.com/neovim/neovim.git
				synced 2025-10-26 12:27:24 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			538 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Lua
		
	
	
	
	
	
			
		
		
	
	
			538 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Lua
		
	
	
	
	
	
| local luacats_grammar = require('scripts.luacats_grammar')
 | |
| 
 | |
| --- @class nvim.luacats.parser.param
 | |
| --- @field name string
 | |
| --- @field type string
 | |
| --- @field desc string
 | |
| 
 | |
| --- @class nvim.luacats.parser.return
 | |
| --- @field name string
 | |
| --- @field type string
 | |
| --- @field desc string
 | |
| 
 | |
| --- @class nvim.luacats.parser.note
 | |
| --- @field desc string
 | |
| 
 | |
| --- @class nvim.luacats.parser.brief
 | |
| --- @field kind 'brief'
 | |
| --- @field desc string
 | |
| 
 | |
| --- @class nvim.luacats.parser.alias
 | |
| --- @field kind 'alias'
 | |
| --- @field type string[]
 | |
| --- @field desc string
 | |
| 
 | |
| --- @class nvim.luacats.parser.fun
 | |
| --- @field name string
 | |
| --- @field params nvim.luacats.parser.param[]
 | |
| --- @field returns nvim.luacats.parser.return[]
 | |
| --- @field desc string
 | |
| --- @field access? 'private'|'package'|'protected'
 | |
| --- @field class? string
 | |
| --- @field module? string
 | |
| --- @field modvar? string
 | |
| --- @field classvar? string
 | |
| --- @field deprecated? true
 | |
| --- @field since? string
 | |
| --- @field attrs? string[]
 | |
| --- @field nodoc? true
 | |
| --- @field generics? table<string,string>
 | |
| --- @field table? true
 | |
| --- @field notes? nvim.luacats.parser.note[]
 | |
| --- @field see? nvim.luacats.parser.note[]
 | |
| 
 | |
| --- @class nvim.luacats.parser.field
 | |
| --- @field name string
 | |
| --- @field type string
 | |
| --- @field desc string
 | |
| --- @field access? 'private'|'package'|'protected'
 | |
| 
 | |
| --- @class nvim.luacats.parser.class
 | |
| --- @field kind 'class'
 | |
| --- @field parent? string
 | |
| --- @field name string
 | |
| --- @field desc string
 | |
| --- @field nodoc? true
 | |
| --- @field inlinedoc? true
 | |
| --- @field access? 'private'|'package'|'protected'
 | |
| --- @field fields nvim.luacats.parser.field[]
 | |
| --- @field notes? string[]
 | |
| 
 | |
| --- @class nvim.luacats.parser.State
 | |
| --- @field doc_lines? string[]
 | |
| --- @field cur_obj? nvim.luacats.parser.obj
 | |
| --- @field last_doc_item? nvim.luacats.parser.param|nvim.luacats.parser.return|nvim.luacats.parser.note
 | |
| --- @field last_doc_item_indent? integer
 | |
| 
 | |
| --- @alias nvim.luacats.parser.obj
 | |
| --- | nvim.luacats.parser.class
 | |
| --- | nvim.luacats.parser.fun
 | |
| --- | nvim.luacats.parser.brief
 | |
| --- | nvim.luacats.parser.alias
 | |
| 
 | |
| -- Remove this when we document classes properly
 | |
| --- Some doc lines have the form:
 | |
| ---   param name some.complex.type (table) description
 | |
| --- if so then transform the line to remove the complex type:
 | |
| ---   param name (table) description
 | |
| --- @param line string
 | |
| local function use_type_alt(line)
 | |
|   for _, type in ipairs({ 'table', 'function' }) do
 | |
|     line = line:gsub('@param%s+([a-zA-Z_?]+)%s+.*%((' .. type .. ')%)', '@param %1 %2')
 | |
|     line = line:gsub('@param%s+([a-zA-Z_?]+)%s+.*%((' .. type .. '|nil)%)', '@param %1 %2')
 | |
|     line = line:gsub('@param%s+([a-zA-Z_?]+)%s+.*%((' .. type .. '%?)%)', '@param %1 %2')
 | |
| 
 | |
|     line = line:gsub('@return%s+.*%((' .. type .. ')%)', '@return %1')
 | |
|     line = line:gsub('@return%s+.*%((' .. type .. '|nil)%)', '@return %1')
 | |
|     line = line:gsub('@return%s+.*%((' .. type .. '%?)%)', '@return %1')
 | |
|   end
 | |
|   return line
 | |
| end
 | |
| 
 | |
| --- If we collected any `---` lines. Add them to the existing (or new) object
 | |
| --- Used for function/class descriptions and multiline param descriptions.
 | |
| --- @param state nvim.luacats.parser.State
 | |
| local function add_doc_lines_to_obj(state)
 | |
|   if state.doc_lines then
 | |
|     state.cur_obj = state.cur_obj or {}
 | |
|     local cur_obj = assert(state.cur_obj)
 | |
|     local txt = table.concat(state.doc_lines, '\n')
 | |
|     if cur_obj.desc then
 | |
|       cur_obj.desc = cur_obj.desc .. '\n' .. txt
 | |
|     else
 | |
|       cur_obj.desc = txt
 | |
|     end
 | |
|     state.doc_lines = nil
 | |
|   end
 | |
| end
 | |
| 
 | |
| --- @param line string
 | |
| --- @param state nvim.luacats.parser.State
 | |
| local function process_doc_line(line, state)
 | |
|   line = line:sub(4):gsub('^%s+@', '@')
 | |
|   line = use_type_alt(line)
 | |
| 
 | |
|   local parsed = luacats_grammar:match(line)
 | |
| 
 | |
|   if not parsed then
 | |
|     if line:match('^ ') then
 | |
|       line = line:sub(2)
 | |
|     end
 | |
| 
 | |
|     if state.last_doc_item then
 | |
|       if not state.last_doc_item_indent then
 | |
|         state.last_doc_item_indent = #line:match('^%s*') + 1
 | |
|       end
 | |
|       state.last_doc_item.desc = (state.last_doc_item.desc or '')
 | |
|         .. '\n'
 | |
|         .. line:sub(state.last_doc_item_indent or 1)
 | |
|     else
 | |
|       state.doc_lines = state.doc_lines or {}
 | |
|       table.insert(state.doc_lines, line)
 | |
|     end
 | |
|     return
 | |
|   end
 | |
| 
 | |
|   state.last_doc_item_indent = nil
 | |
|   state.last_doc_item = nil
 | |
|   state.cur_obj = state.cur_obj or {}
 | |
|   local cur_obj = assert(state.cur_obj)
 | |
| 
 | |
|   local kind = parsed.kind
 | |
| 
 | |
|   if kind == 'brief' then
 | |
|     state.cur_obj = {
 | |
|       kind = 'brief',
 | |
|       desc = parsed.desc,
 | |
|     }
 | |
|   elseif kind == 'class' then
 | |
|     --- @cast parsed nvim.luacats.Class
 | |
|     cur_obj.kind = 'class'
 | |
|     cur_obj.name = parsed.name
 | |
|     cur_obj.parent = parsed.parent
 | |
|     cur_obj.access = parsed.access
 | |
|     cur_obj.desc = state.doc_lines and table.concat(state.doc_lines, '\n') or nil
 | |
|     state.doc_lines = nil
 | |
|     cur_obj.fields = {}
 | |
|   elseif kind == 'field' then
 | |
|     --- @cast parsed nvim.luacats.Field
 | |
|     parsed.desc = parsed.desc or state.doc_lines and table.concat(state.doc_lines, '\n') or nil
 | |
|     if parsed.desc then
 | |
|       parsed.desc = vim.trim(parsed.desc)
 | |
|     end
 | |
|     table.insert(cur_obj.fields, parsed)
 | |
|     state.doc_lines = nil
 | |
|   elseif kind == 'operator' then
 | |
|     parsed.desc = parsed.desc or state.doc_lines and table.concat(state.doc_lines, '\n') or nil
 | |
|     if parsed.desc then
 | |
|       parsed.desc = vim.trim(parsed.desc)
 | |
|     end
 | |
|     table.insert(cur_obj.fields, parsed)
 | |
|     state.doc_lines = nil
 | |
|   elseif kind == 'param' then
 | |
|     state.last_doc_item_indent = nil
 | |
|     cur_obj.params = cur_obj.params or {}
 | |
|     if vim.endswith(parsed.name, '?') then
 | |
|       parsed.name = parsed.name:sub(1, -2)
 | |
|       parsed.type = parsed.type .. '?'
 | |
|     end
 | |
|     state.last_doc_item = {
 | |
|       name = parsed.name,
 | |
|       type = parsed.type,
 | |
|       desc = parsed.desc,
 | |
|     }
 | |
|     table.insert(cur_obj.params, state.last_doc_item)
 | |
|   elseif kind == 'return' then
 | |
|     cur_obj.returns = cur_obj.returns or {}
 | |
|     for _, t in ipairs(parsed) do
 | |
|       table.insert(cur_obj.returns, {
 | |
|         name = t.name,
 | |
|         type = t.type,
 | |
|         desc = parsed.desc,
 | |
|       })
 | |
|     end
 | |
|     state.last_doc_item_indent = nil
 | |
|     state.last_doc_item = cur_obj.returns[#cur_obj.returns]
 | |
|   elseif kind == 'private' then
 | |
|     cur_obj.access = 'private'
 | |
|   elseif kind == 'package' then
 | |
|     cur_obj.access = 'package'
 | |
|   elseif kind == 'protected' then
 | |
|     cur_obj.access = 'protected'
 | |
|   elseif kind == 'deprecated' then
 | |
|     cur_obj.deprecated = true
 | |
|   elseif kind == 'inlinedoc' then
 | |
|     cur_obj.inlinedoc = true
 | |
|   elseif kind == 'nodoc' then
 | |
|     cur_obj.nodoc = true
 | |
|   elseif kind == 'since' then
 | |
|     cur_obj.since = parsed.desc
 | |
|   elseif kind == 'see' then
 | |
|     cur_obj.see = cur_obj.see or {}
 | |
|     table.insert(cur_obj.see, { desc = parsed.desc })
 | |
|   elseif kind == 'note' then
 | |
|     state.last_doc_item_indent = nil
 | |
|     state.last_doc_item = {
 | |
|       desc = parsed.desc,
 | |
|     }
 | |
|     cur_obj.notes = cur_obj.notes or {}
 | |
|     table.insert(cur_obj.notes, state.last_doc_item)
 | |
|   elseif kind == 'type' then
 | |
|     cur_obj.desc = parsed.desc
 | |
|     parsed.desc = nil
 | |
|     parsed.kind = nil
 | |
|     cur_obj.type = parsed
 | |
|   elseif kind == 'alias' then
 | |
|     state.cur_obj = {
 | |
|       kind = 'alias',
 | |
|       desc = parsed.desc,
 | |
|     }
 | |
|   elseif kind == 'enum' then
 | |
|     -- TODO
 | |
|     state.doc_lines = nil
 | |
|   elseif
 | |
|     vim.tbl_contains({
 | |
|       'diagnostic',
 | |
|       'cast',
 | |
|       'overload',
 | |
|       'meta',
 | |
|     }, kind)
 | |
|   then
 | |
|     -- Ignore
 | |
|     return
 | |
|   elseif kind == 'generic' then
 | |
|     cur_obj.generics = cur_obj.generics or {}
 | |
|     cur_obj.generics[parsed.name] = parsed.type or 'any'
 | |
|   else
 | |
|     error('Unhandled' .. vim.inspect(parsed))
 | |
|   end
 | |
| end
 | |
| 
 | |
| --- @param fun nvim.luacats.parser.fun
 | |
| --- @return nvim.luacats.parser.field
 | |
| local function fun2field(fun)
 | |
|   local parts = { 'fun(' }
 | |
|   for _, p in ipairs(fun.params or {}) do
 | |
|     parts[#parts + 1] = string.format('%s: %s', p.name, p.type)
 | |
|   end
 | |
|   parts[#parts + 1] = ')'
 | |
|   if fun.returns then
 | |
|     parts[#parts + 1] = ': '
 | |
|     local tys = {} --- @type string[]
 | |
|     for _, p in ipairs(fun.returns) do
 | |
|       tys[#tys + 1] = p.type
 | |
|     end
 | |
|     parts[#parts + 1] = table.concat(tys, ', ')
 | |
|   end
 | |
| 
 | |
|   return {
 | |
|     name = fun.name,
 | |
|     type = table.concat(parts, ''),
 | |
|     access = fun.access,
 | |
|     desc = fun.desc,
 | |
|   }
 | |
| end
 | |
| 
 | |
| --- Function to normalize known form for declaring functions and normalize into a more standard
 | |
| --- form.
 | |
| --- @param line string
 | |
| --- @return string
 | |
| local function filter_decl(line)
 | |
|   -- M.fun = vim._memoize(function(...)
 | |
|   --   ->
 | |
|   -- function M.fun(...)
 | |
|   line = line:gsub('^local (.+) = memoize%([^,]+, function%((.*)%)$', 'local function %1(%2)')
 | |
|   line = line:gsub('^(.+) = memoize%([^,]+, function%((.*)%)$', 'function %1(%2)')
 | |
|   return line
 | |
| end
 | |
| 
 | |
| --- @param line string
 | |
| --- @param state nvim.luacats.parser.State
 | |
| --- @param classes table<string,nvim.luacats.parser.class>
 | |
| --- @param classvars table<string,string>
 | |
| --- @param has_indent boolean
 | |
| local function process_lua_line(line, state, classes, classvars, has_indent)
 | |
|   line = filter_decl(line)
 | |
| 
 | |
|   if state.cur_obj and state.cur_obj.kind == 'class' then
 | |
|     local nm = line:match('^local%s+([a-zA-Z0-9_]+)%s*=')
 | |
|     if nm then
 | |
|       classvars[nm] = state.cur_obj.name
 | |
|     end
 | |
|     return
 | |
|   end
 | |
| 
 | |
|   do
 | |
|     local parent_tbl, sep, fun_or_meth_nm =
 | |
|       line:match('^function%s+([a-zA-Z0-9_]+)([.:])([a-zA-Z0-9_]+)%s*%(')
 | |
|     if parent_tbl then
 | |
|       -- Have a decl. Ensure cur_obj
 | |
|       state.cur_obj = state.cur_obj or {}
 | |
|       local cur_obj = assert(state.cur_obj)
 | |
| 
 | |
|       -- Match `Class:foo` methods for defined classes
 | |
|       local class = classvars[parent_tbl]
 | |
|       if class then
 | |
|         --- @cast cur_obj nvim.luacats.parser.fun
 | |
|         cur_obj.name = fun_or_meth_nm
 | |
|         cur_obj.class = class
 | |
|         cur_obj.classvar = parent_tbl
 | |
|         -- Add self param to methods
 | |
|         if sep == ':' then
 | |
|           cur_obj.params = cur_obj.params or {}
 | |
|           table.insert(cur_obj.params, 1, {
 | |
|             name = 'self',
 | |
|             type = class,
 | |
|           })
 | |
|         end
 | |
| 
 | |
|         -- Add method as the field to the class
 | |
|         table.insert(classes[class].fields, fun2field(cur_obj))
 | |
|         return
 | |
|       end
 | |
| 
 | |
|       -- Match `M.foo`
 | |
|       if cur_obj and parent_tbl == cur_obj.modvar then
 | |
|         cur_obj.name = fun_or_meth_nm
 | |
|         return
 | |
|       end
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   do
 | |
|     -- Handle: `function A.B.C.foo(...)`
 | |
|     local fn_nm = line:match('^function%s+([.a-zA-Z0-9_]+)%s*%(')
 | |
|     if fn_nm then
 | |
|       state.cur_obj = state.cur_obj or {}
 | |
|       state.cur_obj.name = fn_nm
 | |
|       return
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   do
 | |
|     -- Handle: `M.foo = {...}` where `M` is the modvar
 | |
|     local parent_tbl, tbl_nm = line:match('([a-zA-Z_]+)%.([a-zA-Z0-9_]+)%s*=')
 | |
|     if state.cur_obj and parent_tbl and parent_tbl == state.cur_obj.modvar then
 | |
|       state.cur_obj.name = tbl_nm
 | |
|       state.cur_obj.table = true
 | |
|       return
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   do
 | |
|     -- Handle: `foo = {...}`
 | |
|     local tbl_nm = line:match('^([a-zA-Z0-9_]+)%s*=')
 | |
|     if tbl_nm and not has_indent then
 | |
|       state.cur_obj = state.cur_obj or {}
 | |
|       state.cur_obj.name = tbl_nm
 | |
|       state.cur_obj.table = true
 | |
|       return
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   do
 | |
|     -- Handle: `vim.foo = {...}`
 | |
|     local tbl_nm = line:match('^(vim%.[a-zA-Z0-9_]+)%s*=')
 | |
|     if state.cur_obj and tbl_nm and not has_indent then
 | |
|       state.cur_obj.name = tbl_nm
 | |
|       state.cur_obj.table = true
 | |
|       return
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   if state.cur_obj then
 | |
|     if line:find('^%s*%-%- luacheck:') then
 | |
|       state.cur_obj = nil
 | |
|     elseif line:find('^%s*local%s+') then
 | |
|       state.cur_obj = nil
 | |
|     elseif line:find('^%s*return%s+') then
 | |
|       state.cur_obj = nil
 | |
|     elseif line:find('^%s*[a-zA-Z_.]+%(%s+') then
 | |
|       state.cur_obj = nil
 | |
|     end
 | |
|   end
 | |
| end
 | |
| 
 | |
| --- Determine the table name used to export functions of a module
 | |
| --- Usually this is `M`.
 | |
| --- @param str string
 | |
| --- @return string?
 | |
| local function determine_modvar(str)
 | |
|   local modvar --- @type string?
 | |
|   for line in vim.gsplit(str, '\n') do
 | |
|     do
 | |
|       --- @type string?
 | |
|       local m = line:match('^return%s+([a-zA-Z_]+)')
 | |
|       if m then
 | |
|         modvar = m
 | |
|       end
 | |
|     end
 | |
|     do
 | |
|       --- @type string?
 | |
|       local m = line:match('^return%s+setmetatable%(([a-zA-Z_]+),')
 | |
|       if m then
 | |
|         modvar = m
 | |
|       end
 | |
|     end
 | |
|   end
 | |
|   return modvar
 | |
| end
 | |
| 
 | |
| --- @param obj nvim.luacats.parser.obj
 | |
| --- @param funs nvim.luacats.parser.fun[]
 | |
| --- @param classes table<string,nvim.luacats.parser.class>
 | |
| --- @param briefs string[]
 | |
| --- @param uncommitted nvim.luacats.parser.obj[]
 | |
| local function commit_obj(obj, classes, funs, briefs, uncommitted)
 | |
|   local commit = false
 | |
|   if obj.kind == 'class' then
 | |
|     --- @cast obj nvim.luacats.parser.class
 | |
|     if not classes[obj.name] then
 | |
|       classes[obj.name] = obj
 | |
|       commit = true
 | |
|     end
 | |
|   elseif obj.kind == 'alias' then
 | |
|     -- Just pretend
 | |
|     commit = true
 | |
|   elseif obj.kind == 'brief' then
 | |
|     --- @cast obj nvim.luacats.parser.brief`
 | |
|     briefs[#briefs + 1] = obj.desc
 | |
|     commit = true
 | |
|   else
 | |
|     --- @cast obj nvim.luacats.parser.fun`
 | |
|     if obj.name then
 | |
|       funs[#funs + 1] = obj
 | |
|       commit = true
 | |
|     end
 | |
|   end
 | |
|   if not commit then
 | |
|     table.insert(uncommitted, obj)
 | |
|   end
 | |
|   return commit
 | |
| end
 | |
| 
 | |
| --- @param filename string
 | |
| --- @param uncommitted nvim.luacats.parser.obj[]
 | |
| -- luacheck: no unused
 | |
| local function dump_uncommitted(filename, uncommitted)
 | |
|   local out_path = 'luacats-uncommited/' .. filename:gsub('/', '%%') .. '.txt'
 | |
|   if #uncommitted > 0 then
 | |
|     print(string.format('Could not commit %d objects in %s', #uncommitted, filename))
 | |
|     vim.fn.mkdir(assert(vim.fs.dirname(out_path)), 'p')
 | |
|     local f = assert(io.open(out_path, 'w'))
 | |
|     for i, x in ipairs(uncommitted) do
 | |
|       f:write(i)
 | |
|       f:write(': ')
 | |
|       f:write(vim.inspect(x))
 | |
|       f:write('\n')
 | |
|     end
 | |
|     f:close()
 | |
|   else
 | |
|     vim.fn.delete(out_path)
 | |
|   end
 | |
| end
 | |
| 
 | |
| local M = {}
 | |
| 
 | |
| function M.parse_str(str, filename)
 | |
|   local funs = {} --- @type nvim.luacats.parser.fun[]
 | |
|   local classes = {} --- @type table<string,nvim.luacats.parser.class>
 | |
|   local briefs = {} --- @type string[]
 | |
| 
 | |
|   local mod_return = determine_modvar(str)
 | |
| 
 | |
|   --- @type string
 | |
|   local module = filename:match('.*/lua/([a-z_][a-z0-9_/]+)%.lua') or filename
 | |
|   module = module:gsub('/', '.')
 | |
| 
 | |
|   local classvars = {} --- @type table<string,string>
 | |
| 
 | |
|   local state = {} --- @type nvim.luacats.parser.State
 | |
| 
 | |
|   -- Keep track of any partial objects we don't commit
 | |
|   local uncommitted = {} --- @type nvim.luacats.parser.obj[]
 | |
| 
 | |
|   for line in vim.gsplit(str, '\n') do
 | |
|     local has_indent = line:match('^%s+') ~= nil
 | |
|     line = vim.trim(line)
 | |
|     if vim.startswith(line, '---') then
 | |
|       process_doc_line(line, state)
 | |
|     else
 | |
|       add_doc_lines_to_obj(state)
 | |
| 
 | |
|       if state.cur_obj then
 | |
|         state.cur_obj.modvar = mod_return
 | |
|         state.cur_obj.module = module
 | |
|       end
 | |
| 
 | |
|       process_lua_line(line, state, classes, classvars, has_indent)
 | |
| 
 | |
|       -- Commit the object
 | |
|       local cur_obj = state.cur_obj
 | |
|       if cur_obj then
 | |
|         if not commit_obj(cur_obj, classes, funs, briefs, uncommitted) then
 | |
|           --- @diagnostic disable-next-line:inject-field
 | |
|           cur_obj.line = line
 | |
|         end
 | |
|       end
 | |
| 
 | |
|       state = {}
 | |
|     end
 | |
|   end
 | |
| 
 | |
|   -- dump_uncommitted(filename, uncommitted)
 | |
| 
 | |
|   return classes, funs, briefs, uncommitted
 | |
| end
 | |
| 
 | |
| --- @param filename string
 | |
| function M.parse(filename)
 | |
|   local f = assert(io.open(filename, 'r'))
 | |
|   local txt = f:read('*all')
 | |
|   f:close()
 | |
| 
 | |
|   return M.parse_str(txt, filename)
 | |
| end
 | |
| 
 | |
| return M
 | 
