neovim

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

gen_lsp.lua (20622B)


      1 #!/usr/bin/env -S nvim -l
      2 -- Generates lua-ls annotations for lsp.
      3 
      4 local USAGE = [[
      5 Generates lua-ls annotations for lsp.
      6 
      7 Also updates types in runtime/lua/vim/lsp/protocol.lua
      8 
      9 Usage:
     10  src/gen/gen_lsp.lua [options]
     11 
     12 Options:
     13  --version <version>  LSP version to use (default: 3.18)
     14  --out <out>          Output file (default: runtime/lua/vim/lsp/_meta/protocol.lua)
     15  --help               Print this help message
     16 ]]
     17 
     18 --- The LSP protocol JSON data (it's partial, non-exhaustive).
     19 --- https://raw.githubusercontent.com/microsoft/language-server-protocol/gh-pages/_specifications/lsp/3.18/metaModel/metaModel.schema.json
     20 --- @class vim._gen_lsp.Protocol
     21 --- @field requests vim._gen_lsp.Request[]
     22 --- @field notifications vim._gen_lsp.Notification[]
     23 --- @field structures vim._gen_lsp.Structure[]
     24 --- @field enumerations vim._gen_lsp.Enumeration[]
     25 --- @field typeAliases vim._gen_lsp.TypeAlias[]
     26 
     27 --- @class vim._gen_lsp.Notification
     28 --- @field deprecated? string
     29 --- @field documentation? string
     30 --- @field messageDirection string
     31 --- @field clientCapability? string
     32 --- @field serverCapability? string
     33 --- @field method vim.lsp.protocol.Method
     34 --- @field params? any
     35 --- @field proposed? boolean
     36 --- @field registrationMethod? string
     37 --- @field registrationOptions? any
     38 --- @field since? string
     39 
     40 --- @class vim._gen_lsp.Request : vim._gen_lsp.Notification
     41 --- @field errorData? any
     42 --- @field partialResult? any
     43 --- @field result any
     44 
     45 --- @class vim._gen_lsp.Structure translated to @class
     46 --- @field deprecated? string
     47 --- @field documentation? string
     48 --- @field extends? { kind: string, name: string }[]
     49 --- @field mixins? { kind: string, name: string }[]
     50 --- @field name string
     51 --- @field properties? vim._gen_lsp.Property[]  members, translated to @field
     52 --- @field proposed? boolean
     53 --- @field since? string
     54 
     55 --- @class vim._gen_lsp.StructureLiteral translated to anonymous @class.
     56 --- @field deprecated? string
     57 --- @field description? string
     58 --- @field properties vim._gen_lsp.Property[]
     59 --- @field proposed? boolean
     60 --- @field since? string
     61 
     62 --- @class vim._gen_lsp.Property translated to @field
     63 --- @field deprecated? string
     64 --- @field documentation? string
     65 --- @field name string
     66 --- @field optional? boolean
     67 --- @field proposed? boolean
     68 --- @field since? string
     69 --- @field type { kind: string, name: string }
     70 
     71 --- @class vim._gen_lsp.Enumeration translated to @enum
     72 --- @field deprecated string?
     73 --- @field documentation string?
     74 --- @field name string?
     75 --- @field proposed boolean?
     76 --- @field since string?
     77 --- @field suportsCustomValues boolean?
     78 --- @field values { name: string, value: string, documentation?: string, since?: string }[]
     79 
     80 --- @class vim._gen_lsp.TypeAlias translated to @alias
     81 --- @field deprecated? string?
     82 --- @field documentation? string
     83 --- @field name string
     84 --- @field proposed? boolean
     85 --- @field since? string
     86 --- @field type vim._gen_lsp.Type
     87 
     88 --- @class vim._gen_lsp.Type
     89 --- @field kind string a common field for all Types.
     90 --- @field name? string for ReferenceType, BaseType
     91 --- @field element? any for ArrayType
     92 --- @field items? vim._gen_lsp.Type[] for OrType, AndType
     93 --- @field key? vim._gen_lsp.Type for MapType
     94 --- @field value? string|vim._gen_lsp.Type for StringLiteralType, MapType, StructureLiteralType
     95 
     96 --- @param fname string
     97 --- @param text string
     98 local function tofile(fname, text)
     99  local f = assert(io.open(fname, 'w'), ('failed to open: %s'):format(fname))
    100  f:write(text)
    101  f:close()
    102  print('Written to:', fname)
    103 end
    104 
    105 ---@param opt vim._gen_lsp.opt
    106 ---@return vim._gen_lsp.Protocol
    107 local function read_json(opt)
    108  local uri = 'https://raw.githubusercontent.com/microsoft/language-server-protocol/gh-pages/_specifications/lsp/'
    109    .. opt.version
    110    .. '/metaModel/metaModel.json'
    111  print('Reading ' .. uri)
    112 
    113  local res = vim.system({ 'curl', '--no-progress-meter', uri, '-o', '-' }):wait()
    114  if res.code ~= 0 or (res.stdout or ''):len() < 999 then
    115    print(('URL failed: %s'):format(uri))
    116    vim.print(res)
    117    error(res.stdout)
    118  end
    119  return vim.json.decode(res.stdout)
    120 end
    121 
    122 --- Gets the Lua symbol for a given fully-qualified LSP method name.
    123 --- @param s string
    124 --- @return string
    125 local function to_luaname(s)
    126  -- "$/" prefix is special: https://microsoft.github.io/language-server-protocol/specification/#dollarRequests
    127  return (s:gsub('^%$', 'dollar'):gsub('/', '_'))
    128 end
    129 
    130 --- @param a vim._gen_lsp.Notification
    131 --- @param b vim._gen_lsp.Notification
    132 --- @return boolean
    133 local function compare_method(a, b)
    134  return to_luaname(a.method) < to_luaname(b.method)
    135 end
    136 
    137 ---@param protocol vim._gen_lsp.Protocol
    138 local function write_to_vim_protocol(protocol)
    139  local all = {} --- @type (vim._gen_lsp.Request|vim._gen_lsp.Notification)[]
    140  vim.list_extend(all, protocol.notifications)
    141  vim.list_extend(all, protocol.requests)
    142 
    143  table.sort(all, compare_method)
    144  table.sort(protocol.requests, compare_method)
    145  table.sort(protocol.notifications, compare_method)
    146 
    147  local output = { '-- Generated by gen_lsp.lua, keep at end of file.' }
    148 
    149  do -- methods
    150    for _, dir in ipairs({ 'clientToServer', 'serverToClient' }) do
    151      local dir1 = dir:sub(1, 1):upper() .. dir:sub(2)
    152      local alias = ('vim.lsp.protocol.Method.%s'):format(dir1)
    153      for _, b in ipairs({
    154        { title = 'Request', methods = protocol.requests },
    155        { title = 'Notification', methods = protocol.notifications },
    156      }) do
    157        output[#output + 1] = ('--- LSP %s (direction: %s)'):format(b.title, dir)
    158        output[#output + 1] = ('--- @alias %s.%s'):format(alias, b.title)
    159        for _, item in ipairs(b.methods) do
    160          if item.messageDirection == dir or item.messageDirection == 'both' then
    161            output[#output + 1] = ("--- | '%s',"):format(item.method)
    162          end
    163        end
    164        output[#output + 1] = ''
    165      end
    166 
    167      vim.list_extend(output, {
    168        ('--- LSP Message (direction: %s).'):format(dir),
    169        ('--- @alias %s'):format(alias),
    170        ('--- | %s.Request'):format(alias),
    171        ('--- | %s.Notification'):format(alias),
    172        '',
    173      })
    174    end
    175 
    176    vim.list_extend(output, {
    177      '--- @alias vim.lsp.protocol.Method',
    178      '--- | vim.lsp.protocol.Method.ClientToServer',
    179      '--- | vim.lsp.protocol.Method.ServerToClient',
    180      '',
    181      '-- Generated by gen_lsp.lua, keep at end of file.',
    182      '--- @deprecated Use `vim.lsp.protocol.Method` instead.',
    183      '--- @enum vim.lsp.protocol.Methods',
    184      '--- @see https://microsoft.github.io/language-server-protocol/specification/#metaModel',
    185      '--- LSP method names.',
    186      'protocol.Methods = {',
    187    })
    188 
    189    for _, item in ipairs(all) do
    190      if item.method then
    191        if item.documentation then
    192          local document = vim.split(item.documentation, '\n?\n', { trimempty = true })
    193          for _, docstring in ipairs(document) do
    194            output[#output + 1] = '  --- ' .. docstring
    195          end
    196        end
    197        output[#output + 1] = ("  %s = '%s',"):format(to_luaname(item.method), item.method)
    198      end
    199    end
    200    output[#output + 1] = '}'
    201  end
    202 
    203  do -- registrationMethods
    204    local found = {} --- @type table<string, boolean>
    205    vim.list_extend(output, {
    206      '',
    207      '-- Generated by gen_lsp.lua, keep at end of file.',
    208      '--- LSP registration methods',
    209      '---@alias vim.lsp.protocol.Method.Registration',
    210    })
    211    for _, item in ipairs(all) do
    212      if item.registrationMethod and not found[item.registrationMethod] then
    213        vim.list_extend(output, {
    214          ("--- | '%s'"):format(item.registrationMethod or item.method),
    215        })
    216        found[item.registrationMethod or item.method] = true
    217      end
    218    end
    219  end
    220 
    221  do -- capabilities
    222    vim.list_extend(output, {
    223      '',
    224      '-- stylua: ignore start',
    225      '-- Generated by gen_lsp.lua, keep at end of file.',
    226      '--- Maps method names to the required client capability',
    227      '---TODO: also has workspace/* items because spec lacks a top-level "workspaceProvider"',
    228      'protocol._provider_to_client_registration = {',
    229    })
    230 
    231    local providers = {} --- @type table<string, string>
    232    for _, item in ipairs(all) do
    233      local base_provider = item.serverCapability and item.serverCapability:match('^[^%.]+')
    234      if item.registrationOptions and not providers[base_provider] and item.clientCapability then
    235        if item.clientCapability == item.serverCapability then
    236          base_provider = nil
    237        end
    238        local key = base_provider or item.method
    239        providers[key] = item.clientCapability
    240      end
    241    end
    242 
    243    ---@type { provider: string, path : string }[]
    244    local found_entries = {}
    245    for key, value in pairs(providers) do
    246      found_entries[#found_entries + 1] = { provider = key, path = value }
    247    end
    248    table.sort(found_entries, function(a, b)
    249      return a.provider < b.provider
    250    end)
    251    for _, entry in ipairs(found_entries) do
    252      output[#output + 1] = ("  ['%s'] = { %s },"):format(
    253        entry.provider,
    254        "'" .. entry.path:gsub('%.', "', '") .. "'"
    255      )
    256    end
    257 
    258    output[#output + 1] = '}'
    259    output[#output + 1] = '-- stylua: ignore end'
    260 
    261    vim.list_extend(output, {
    262      '',
    263      '-- stylua: ignore start',
    264      '-- Generated by gen_lsp.lua, keep at end of file.',
    265      '--- Maps method names to the required server capability',
    266      '-- A server capability equal to the method means there is no related server capability',
    267      'protocol._request_name_to_server_capability = {',
    268    })
    269 
    270    for _, item in ipairs(all) do
    271      output[#output + 1] = ("  ['%s'] = { %s },"):format(
    272        item.method,
    273        "'" .. (item.serverCapability or item.method):gsub('%.', "', '") .. "'"
    274      )
    275    end
    276 
    277    ---@type table<string, string[]>
    278    local registration_capability = {}
    279    for _, item in ipairs(all) do
    280      if item.serverCapability then
    281        if item.registrationMethod and item.registrationMethod ~= item.method then
    282          local registrationMethod = item.registrationMethod
    283          assert(registrationMethod, 'registrationMethod is nil')
    284          if not registration_capability[item.registrationMethod] then
    285            registration_capability[registrationMethod] = {}
    286          end
    287          table.insert(registration_capability[registrationMethod], item.serverCapability)
    288        end
    289      end
    290    end
    291 
    292    for registrationMethod, capabilities in pairs(registration_capability) do
    293      output[#output + 1] = ("  ['%s'] = { '%s' },"):format(
    294        registrationMethod,
    295        vim.iter(capabilities):fold(capabilities[1], function(acc, v)
    296          return #v < #acc and v or acc
    297        end)
    298      )
    299    end
    300 
    301    output[#output + 1] = '}'
    302    output[#output + 1] = '-- stylua: ignore end'
    303 
    304    vim.list_extend(output, {
    305      '',
    306      '-- stylua: ignore start',
    307      '-- Generated by gen_lsp.lua, keep at end of file.',
    308      'protocol._method_supports_dynamic_registration = {',
    309    })
    310 
    311    --- These methods have no registrationOptions but can still be registered
    312    --- TODO: remove if resolved upstream: https://github.com/microsoft/language-server-protocol/issues/2218
    313    local methods_with_no_registration_options = {
    314      ['workspace/didChangeWorkspaceFolders'] = true,
    315    }
    316 
    317    for _, item in ipairs(all) do
    318      if
    319        item.registrationMethod
    320        or item.registrationOptions
    321        or methods_with_no_registration_options[item.method]
    322      then
    323        output[#output + 1] = ("  ['%s'] = %s,"):format(item.method, true)
    324      end
    325    end
    326 
    327    output[#output + 1] = '}'
    328    output[#output + 1] = '-- stylua: ignore end'
    329 
    330    vim.list_extend(output, {
    331      '',
    332      '-- stylua: ignore start',
    333      '-- Generated by gen_lsp.lua, keep at end of file.',
    334      'protocol._method_supports_static_registration = {',
    335    })
    336 
    337    for _, item in ipairs(all) do
    338      if
    339        item.registrationOptions
    340        and (item.serverCapability and not item.serverCapability:find('%.'))
    341      then
    342        output[#output + 1] = ("  ['%s'] = %s,"):format(item.method, true)
    343      end
    344    end
    345 
    346    output[#output + 1] = '}'
    347    output[#output + 1] = '-- stylua: ignore end'
    348 
    349    vim.list_extend(output, {
    350      '',
    351      '-- stylua: ignore start',
    352      '-- Generated by gen_lsp.lua, keep at end of file.',
    353      '-- These methods have no registration options but can still be registered dynamically.',
    354      'protocol._methods_with_no_registration_options = {',
    355    })
    356    for key, v in pairs(methods_with_no_registration_options) do
    357      output[#output + 1] = ("  ['%s'] = %s ,"):format(key, v)
    358    end
    359    output[#output + 1] = '}'
    360    output[#output + 1] = '-- stylua: ignore end'
    361  end
    362 
    363  output[#output + 1] = ''
    364  output[#output + 1] = 'return protocol'
    365 
    366  local fname = './runtime/lua/vim/lsp/protocol.lua'
    367  local bufnr = vim.fn.bufadd(fname)
    368  vim.fn.bufload(bufnr)
    369  vim.api.nvim_set_current_buf(bufnr)
    370  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
    371  local index = vim.iter(ipairs(lines)):find(function(key, item)
    372    return vim.startswith(item, '-- Generated by') and key or nil
    373  end)
    374  index = index and index - 1 or vim.api.nvim_buf_line_count(bufnr) - 1
    375  vim.api.nvim_buf_set_lines(bufnr, index, -1, true, output)
    376  vim.cmd.write()
    377 end
    378 
    379 --- @param doc string
    380 local function process_documentation(doc)
    381  doc = doc:gsub('\n', '\n---')
    382  -- Remove <200b> (zero-width space) unicode characters: e.g., `**/<200b>*`
    383  doc = doc:gsub('\226\128\139', '')
    384  -- Escape annotations that are not recognized by lua-ls
    385  doc = doc:gsub('%^---@sample', '---\\@sample')
    386  return '---' .. doc
    387 end
    388 
    389 local simple_types = {
    390  string = true,
    391  boolean = true,
    392  integer = true,
    393  uinteger = true,
    394  decimal = true,
    395 }
    396 
    397 local anonymous_num = 0
    398 
    399 --- @type string[]
    400 local anonym_classes = {}
    401 
    402 --- @param type vim._gen_lsp.Type
    403 --- @param prefix? string Optional prefix associated with the this type, made of (nested) field name.
    404 ---              Used to generate class name for structure literal types.
    405 --- @return string
    406 local function parse_type(type, prefix)
    407  if type.kind == 'reference' or type.kind == 'base' then
    408    if type.kind == 'base' and type.name == 'string' and prefix == 'method' then
    409      return 'vim.lsp.protocol.Method'
    410    end
    411    if simple_types[type.name] then
    412      return type.name
    413    end
    414    return 'lsp.' .. type.name
    415  elseif type.kind == 'array' then
    416    local parsed_items = parse_type(type.element, prefix)
    417    if type.element.items and #type.element.items > 1 then
    418      parsed_items = '(' .. parsed_items .. ')'
    419    end
    420    return parsed_items .. '[]'
    421  elseif type.kind == 'or' then
    422    local types = {} --- @type string[]
    423    for _, item in ipairs(type.items) do
    424      types[#types + 1] = parse_type(item, prefix)
    425    end
    426    return table.concat(types, '|')
    427  elseif type.kind == 'stringLiteral' then
    428    return '"' .. type.value .. '"'
    429  elseif type.kind == 'map' then
    430    local key = assert(type.key)
    431    local value = type.value --[[ @as vim._gen_lsp.Type ]]
    432    return ('table<%s, %s>'):format(parse_type(key, prefix), parse_type(value, prefix))
    433  elseif type.kind == 'literal' then
    434    -- can I use ---@param disabled? {reason: string}
    435    -- use | to continue the inline class to be able to add docs
    436    -- https://github.com/LuaLS/lua-language-server/issues/2128
    437    anonymous_num = anonymous_num + 1
    438    local anonymous_classname = 'lsp._anonym' .. anonymous_num
    439    if prefix then
    440      anonymous_classname = anonymous_classname .. '.' .. prefix
    441    end
    442 
    443    local anonym = { '---@class ' .. anonymous_classname }
    444    if anonymous_num > 1 then
    445      table.insert(anonym, 1, '')
    446    end
    447 
    448    ---@type vim._gen_lsp.StructureLiteral
    449    local structural_literal = assert(type.value) --[[ @as vim._gen_lsp.StructureLiteral ]]
    450    for _, field in ipairs(structural_literal.properties) do
    451      anonym[#anonym + 1] = '---'
    452      if field.documentation then
    453        anonym[#anonym + 1] = process_documentation(field.documentation)
    454      end
    455      anonym[#anonym + 1] = ('---@field %s%s %s'):format(
    456        field.name,
    457        (field.optional and '?' or ''),
    458        parse_type(field.type, prefix .. '.' .. field.name)
    459      )
    460    end
    461    for _, line in ipairs(anonym) do
    462      if line then
    463        anonym_classes[#anonym_classes + 1] = line
    464      end
    465    end
    466    return anonymous_classname
    467  elseif type.kind == 'tuple' then
    468    local types = {} --- @type string[]
    469    for _, value in ipairs(type.items) do
    470      types[#types + 1] = parse_type(value, prefix)
    471    end
    472    return '[' .. table.concat(types, ', ') .. ']'
    473  end
    474 
    475  vim.print('WARNING: Unknown type ', type)
    476  return ''
    477 end
    478 
    479 --- @param protocol vim._gen_lsp.Protocol
    480 --- @param version string
    481 --- @param output_file string
    482 local function write_to_meta_protocol(protocol, version, output_file)
    483  local output = {
    484    '--' .. '[[',
    485    'THIS FILE IS GENERATED by src/gen/gen_lsp.lua',
    486    'DO NOT EDIT MANUALLY',
    487    '',
    488    'Based on LSP protocol ' .. version,
    489    '',
    490    'Regenerate:',
    491    ([=[nvim -l src/gen/gen_lsp.lua --version %s]=]):format(version),
    492    '--' .. ']]',
    493    '',
    494    '---@meta',
    495    "error('Cannot require a meta file')",
    496    '',
    497    '---@alias lsp.null vim.NIL',
    498    '---@alias uinteger integer',
    499    '---@alias decimal number',
    500    '---@alias lsp.DocumentUri string',
    501    '---@alias lsp.URI string',
    502    '',
    503  }
    504 
    505  for _, structure in ipairs(protocol.structures) do
    506    if structure.documentation then
    507      output[#output + 1] = process_documentation(structure.documentation)
    508    end
    509    local class_string = ('---@class lsp.%s'):format(structure.name)
    510    if structure.extends or structure.mixins then
    511      local inherits_from = table.concat(
    512        vim.list_extend(
    513          vim.tbl_map(parse_type, structure.extends or {}),
    514          vim.tbl_map(parse_type, structure.mixins or {})
    515        ),
    516        ', '
    517      )
    518      class_string = class_string .. ': ' .. inherits_from
    519    end
    520    output[#output + 1] = class_string
    521 
    522    for _, field in ipairs(structure.properties or {}) do
    523      output[#output + 1] = '---' -- Insert a single newline between @fields (and after @class)
    524      if field.documentation then
    525        output[#output + 1] = process_documentation(field.documentation)
    526      end
    527      output[#output + 1] = ('---@field %s%s %s'):format(
    528        field.name,
    529        (field.optional and '?' or ''),
    530        parse_type(field.type, field.name)
    531      )
    532    end
    533    output[#output + 1] = ''
    534  end
    535 
    536  for _, enum in ipairs(protocol.enumerations) do
    537    if enum.documentation then
    538      output[#output + 1] = process_documentation(enum.documentation)
    539    end
    540    output[#output + 1] = '---@alias lsp.' .. enum.name
    541    for _, value in ipairs(enum.values) do
    542      local value1 = (type(value.value) == 'string' and ('"%s"'):format(value.value) or value.value)
    543      output[#output + 1] = ('---| %s # %s'):format(value1, value.name)
    544    end
    545    output[#output + 1] = ''
    546  end
    547 
    548  for _, alias in ipairs(protocol.typeAliases) do
    549    if alias.documentation then
    550      output[#output + 1] = process_documentation(alias.documentation)
    551    end
    552 
    553    local alias_type --- @type string
    554 
    555    if alias.type.kind == 'or' then
    556      local alias_types = {} --- @type string[]
    557      for _, item in ipairs(alias.type.items) do
    558        alias_types[#alias_types + 1] = parse_type(item, alias.name)
    559      end
    560      alias_type = table.concat(alias_types, '|')
    561    else
    562      alias_type = parse_type(alias.type, alias.name)
    563    end
    564    output[#output + 1] = ('---@alias lsp.%s %s'):format(alias.name, alias_type)
    565    output[#output + 1] = ''
    566  end
    567 
    568  -- anonymous classes
    569  vim.list_extend(output, anonym_classes)
    570 
    571  tofile(output_file, table.concat(output, '\n') .. '\n')
    572 end
    573 
    574 ---@class vim._gen_lsp.opt
    575 ---@field output_file string
    576 ---@field version string
    577 
    578 --- @return vim._gen_lsp.opt
    579 local function parse_args()
    580  ---@type vim._gen_lsp.opt
    581  local opt = {
    582    output_file = 'runtime/lua/vim/lsp/_meta/protocol.lua',
    583    version = '3.18',
    584  }
    585 
    586  local i = 1
    587  while i <= #_G.arg do
    588    local cur_arg = _G.arg[i]
    589    if cur_arg == '--out' then
    590      opt.output_file = assert(_G.arg[i + 1], '--out <outfile> needed')
    591      i = i + 1
    592    elseif cur_arg == '--version' then
    593      opt.version = assert(_G.arg[i + 1], '--version <version> needed')
    594      i = i + 1
    595    elseif cur_arg == '--help' or cur_arg == '-h' then
    596      print(USAGE)
    597      os.exit(0)
    598    elseif vim.startswith(cur_arg, '-') then
    599      print('Unrecognized option:', cur_arg, '\n')
    600      os.exit(1)
    601    end
    602    i = i + 1
    603  end
    604 
    605  return opt
    606 end
    607 
    608 local function main()
    609  local opt = parse_args()
    610  local protocol = read_json(opt)
    611  write_to_vim_protocol(protocol)
    612  write_to_meta_protocol(protocol, opt.version, opt.output_file)
    613 end
    614 
    615 main()