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