nixos/lua-lsp/script/core/hover/description.lua

526 lines
15 KiB
Lua

local vm = require 'vm'
local ws = require 'workspace'
local markdown = require 'provider.markdown'
local config = require 'config'
local lang = require 'language'
local util = require 'utility'
local guide = require 'parser.guide'
local rpath = require 'workspace.require-path'
local furi = require 'file-uri'
local wssymbol = require 'core.workspace-symbol'
local function collectRequire(mode, literal, uri)
local result, searchers
if mode == 'require' then
result, searchers = rpath.findUrisByRequireName(uri, literal)
elseif mode == 'dofile'
or mode == 'loadfile' then
result = ws.findUrisByFilePath(literal)
end
if result and #result > 0 then
local shows = {}
for i, uri in ipairs(result) do
local searcher = searchers and searchers[uri]
local path = ws.getRelativePath(uri)
if vm.isMetaFile(uri) then
shows[i] = ('* [[meta]](%s)'):format(uri)
elseif searcher then
searcher = searcher:gsub('^[/\\]+', '')
shows[i] = ('* [%s](%s) %s'):format(path, uri, lang.script('HOVER_USE_LUA_PATH', searcher))
else
shows[i] = ('* [%s](%s)'):format(path, uri)
end
end
table.sort(shows)
local md = markdown()
md:add('md', table.concat(shows, '\n'))
return md
end
end
local function asStringInRequire(source, literal)
local parent = source.parent
if parent and parent.type == 'callargs' then
local call = parent.parent
local func = call.node
local libName = vm.getLibraryName(func)
if not libName then
return
end
if libName == 'require'
or libName == 'dofile'
or libName == 'loadfile' then
return collectRequire(libName, literal, guide.getUri(source))
end
end
end
local function asStringView(source, literal)
-- 内部包含转义符?
if not source[2] then
return
end
local rawLen = source.finish - source.start - 2 * #source[2]
if config.get(guide.getUri(source), 'Lua.hover.viewString')
and (source[2] == '"' or source[2] == "'")
and rawLen > #literal then
local view = literal
local max = config.get(guide.getUri(source), 'Lua.hover.viewStringMax')
if #view > max then
view = view:sub(1, max) .. '...'
end
local md = markdown()
md:add('txt', view)
return md
end
end
local function asString(source)
local literal = guide.getLiteral(source)
if type(literal) ~= 'string' then
return nil
end
return asStringInRequire(source, literal)
or asStringView(source, literal)
end
---@param comment string
---@param suri uri
---@return string?
local function normalizeComment(comment, suri)
if not comment then
return nil
end
if comment:sub(1, 1) == '-' then
comment = comment:sub(2)
end
if comment:sub(1, 1) == '@' then
return nil
end
comment = comment:gsub('(%[.-%]%()(.-)(%))', function (left, path, right)
local scheme = furi.split(path)
if scheme
-- strange way to check `C:/xxx.lua`
and #scheme > 1 then
return
end
local absPath = ws.getAbsolutePath(suri:gsub('/[^/]+$', ''), path)
if not absPath then
return
end
local uri = furi.encode(absPath)
return left .. uri .. right
end)
return comment
end
local function getBindComment(source)
local uri = guide.getUri(source)
local lines = {}
for _, docComment in ipairs(source.bindComments) do
lines[#lines+1] = normalizeComment(docComment.comment.text, uri)
end
if not lines or #lines == 0 then
return nil
end
return table.concat(lines, '\n')
end
---@async
local function packSee(see)
local name = see.name[1]
local buf = {}
local target
for _, symbol in ipairs(wssymbol(name, guide.getUri(see))) do
if symbol.name == name then
target = symbol.source
break
end
end
if target then
local row, col = guide.rowColOf(target.start)
buf[#buf+1] = ('[%s](%s#%d#%d)'):format(name, guide.getUri(target), row + 1, col)
else
buf[#buf+1] = ('~%s~'):format(name)
end
if see.comment then
buf[#buf+1] = ' '
buf[#buf+1] = see.comment.text
end
return table.concat(buf)
end
---@async
local function lookUpDocSees(lines, docGroup)
local sees = {}
for _, doc in ipairs(docGroup) do
if doc.type == 'doc.see' then
sees[#sees+1] = doc
end
end
if #sees == 0 then
return
end
if #sees == 1 then
lines[#lines+1] = ('See: %s'):format(packSee(sees[1]))
return
end
lines[#lines+1] = 'See:'
for _, see in ipairs(sees) do
lines[#lines+1] = (' * %s'):format(packSee(see))
end
end
---@async
local function lookUpDocComments(source)
local docGroup = source.bindDocs
if not docGroup then
return
end
if source.type == 'setlocal'
or source.type == 'getlocal' then
source = source.node
end
if source.parent.type == 'funcargs' then
return
end
local uri = guide.getUri(source)
local lines = {}
for _, doc in ipairs(docGroup) do
if doc.type == 'doc.comment' then
lines[#lines+1] = normalizeComment(doc.comment.text, uri)
elseif doc.type == 'doc.type'
or doc.type == 'doc.public'
or doc.type == 'doc.protected'
or doc.type == 'doc.private' then
if doc.comment then
lines[#lines+1] = normalizeComment(doc.comment.text, uri)
end
elseif doc.type == 'doc.class' then
for _, docComment in ipairs(doc.bindComments) do
lines[#lines+1] = normalizeComment(docComment.comment.text, uri)
end
end
end
if source.comment then
lines[#lines+1] = normalizeComment(source.comment.text, uri)
end
lookUpDocSees(lines, docGroup)
if not lines or #lines == 0 then
return nil
end
return table.concat(lines, '\n')
end
local function tryDocClassComment(source)
for _, def in ipairs(vm.getDefs(source)) do
if def.type == 'doc.class'
or def.type == 'doc.alias'
or def.type == 'doc.enum' then
local comment = getBindComment(def)
if comment then
return comment
end
end
end
end
local function tryDocModule(source)
if not source.module then
return
end
return collectRequire('require', source.module, guide.getUri(source))
end
local function buildEnumChunk(docType, name, uri)
if not docType then
return nil
end
local enums = {}
local types = {}
local lines = {}
for _, tp in ipairs(vm.getDefs(docType)) do
types[#types+1] = vm.getInfer(tp):view(guide.getUri(docType))
if tp.type == 'doc.type.string'
or tp.type == 'doc.type.integer'
or tp.type == 'doc.type.boolean'
or tp.type == 'doc.type.code' then
enums[#enums+1] = tp
end
local comment = tryDocClassComment(tp)
if comment then
for line in util.eachLine(comment) do
lines[#lines+1] = ('-- %s'):format(line)
end
end
end
if #enums == 0 then
return nil
end
lines[#lines+1] = ('%s:'):format(name)
for _, enum in ipairs(enums) do
local enumDes = (' %s %s'):format(
(enum.default and '->')
or (enum.additional and '+>')
or ' |',
vm.viewObject(enum, uri)
)
if enum.comment then
local first = true
local len = #enumDes
for comm in enum.comment:gmatch '[^\r\n]+' do
if first then
first = false
enumDes = ('%s -- %s'):format(enumDes, comm)
else
enumDes = ('%s\n%s -- %s'):format(enumDes, (' '):rep(len), comm)
end
end
end
lines[#lines+1] = enumDes
end
return table.concat(lines, '\n')
end
local function getBindEnums(source, docGroup)
if source.type ~= 'function' then
return
end
local uri = guide.getUri(source)
local mark = {}
local chunks = {}
local returnIndex = 0
for _, doc in ipairs(docGroup) do
if doc.type == 'doc.param' then
local name = doc.param[1]
if name == '...' then
name = '...(param)'
end
if mark[name] then
goto CONTINUE
end
mark[name] = true
chunks[#chunks+1] = buildEnumChunk(doc.extends, name, uri)
elseif doc.type == 'doc.return' then
for _, rtn in ipairs(doc.returns) do
returnIndex = returnIndex + 1
local name = rtn.name and rtn.name[1] or ('return #%d'):format(returnIndex)
if name == '...' then
name = '...(return)'
end
if mark[name] then
goto CONTINUE
end
mark[name] = true
chunks[#chunks+1] = buildEnumChunk(rtn, name, uri)
end
end
::CONTINUE::
end
if #chunks == 0 then
return nil
end
return table.concat(chunks, '\n\n')
end
local function tryDocFieldComment(source)
if source.type ~= 'doc.field' then
return
end
if source.comment then
return normalizeComment(source.comment.text, guide.getUri(source))
end
if source.bindGroup then
return getBindComment(source)
end
end
local function getFunctionComment(source)
local docGroup = source.bindDocs
if not docGroup then
return
end
local hasReturnComment = false
for _, doc in ipairs(source.bindDocs) do
if doc.type == 'doc.return' and doc.comment then
hasReturnComment = true
break
end
end
local uri = guide.getUri(source)
local md = markdown()
for _, doc in ipairs(docGroup) do
if doc.type == 'doc.comment' then
local comment = normalizeComment(doc.comment.text, uri)
md:add('md', comment)
elseif doc.type == 'doc.param' then
if doc.comment then
md:add('md', ('@*param* `%s` — %s'):format(
doc.param[1],
doc.comment.text
))
end
elseif doc.type == 'doc.return' then
if hasReturnComment then
local name = {}
for _, rtn in ipairs(doc.returns) do
if rtn.name then
name[#name+1] = rtn.name[1]
end
end
if doc.comment then
if #name == 0 then
md:add('md', ('@*return* — %s'):format(doc.comment.text))
else
md:add('md', ('@*return* `%s` — %s'):format(table.concat(name, ','), doc.comment.text))
end
else
if #name == 0 then
md:add('md', '@*return*')
else
md:add('md', ('@*return* `%s`'):format(table.concat(name, ',')))
end
end
end
elseif doc.type == 'doc.overload' then
md:splitLine()
end
end
local enums = getBindEnums(source, docGroup)
md:add('lua', enums)
local comment = md:string()
if comment == '' then
return nil
end
return comment
end
---@async
local function tryDocComment(source)
local md = markdown()
if source.value and source.value.type == 'function' then
source = source.value
end
if source.type == 'function' then
local comment = getFunctionComment(source)
md:add('md', comment)
source = source.parent
end
local comment = lookUpDocComments(source)
md:add('md', comment)
if source.type == 'doc.alias' then
local enums = buildEnumChunk(source, source.alias[1], guide.getUri(source))
md:add('lua', enums)
end
if source.type == 'doc.enum' then
local enums = buildEnumChunk(source, source.enum[1], guide.getUri(source))
md:add('lua', enums)
end
local result = md:string()
if result == '' then
return nil
end
return result
end
---@async
local function tryDocOverloadToComment(source)
if source.type ~= 'doc.type.function' then
return
end
local doc = source.parent
if doc.type ~= 'doc.overload'
or not doc.bindSource then
return
end
local md = tryDocComment(doc.bindSource)
if md then
return md
end
end
local function tyrDocParamComment(source)
if source.type == 'setlocal'
or source.type == 'getlocal' then
source = source.node
end
if source.type ~= 'local' then
return
end
if source.parent.type ~= 'funcargs' then
return
end
if not source.bindDocs then
return
end
for i = #source.bindDocs, 1, -1 do
local doc = source.bindDocs[i]
if doc.type == 'doc.param'
and doc.param[1] == source[1]
and doc.comment then
return doc.comment.text
end
end
end
---@param source parser.object
local function tryDocEnum(source)
if source.type ~= 'doc.enum' then
return
end
local tbl = source.bindSource
if not tbl then
return
end
local md = markdown()
md:add('lua', '{')
for _, field in ipairs(tbl) do
if field.type == 'tablefield'
or field.type == 'tableindex' then
if not field.value then
goto CONTINUE
end
local key = guide.getKeyName(field)
if not key then
goto CONTINUE
end
if field.value.type == 'integer'
or field.value.type == 'string' then
md:add('lua', (' %s: %s = %s,'):format(key, field.value.type, field.value[1]))
end
if field.value.type == 'binary'
or field.value.type == 'unary' then
local number = vm.getNumber(field.value)
if number then
md:add('lua', (' %s: %s = %s,'):format(key, math.tointeger(number) and 'integer' or 'number', number))
end
end
::CONTINUE::
end
end
md:add('lua', '}')
return md:string()
end
---@async
return function (source)
if source.type == 'string' then
return asString(source)
end
if source.type == 'field' then
source = source.parent
end
return tryDocOverloadToComment(source)
or tryDocFieldComment(source)
or tyrDocParamComment(source)
or tryDocComment(source)
or tryDocClassComment(source)
or tryDocModule(source)
or tryDocEnum(source)
end