Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 119 additions & 2 deletions lua/peekstack/core/location.lua
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ local function from_lsp_location(loc, provider)
return nil
end
local uri = loc.uri or loc.targetUri
local range = loc.range or loc.targetRange
-- A LocationLink's targetRange spans the whole symbol (e.g. a function body);
-- targetSelectionRange is the name to land on and to compare against the cursor.
local range = loc.range or loc.targetSelectionRange or loc.targetRange
if not uri or not range then
return nil
end
Expand All @@ -168,7 +170,6 @@ local function from_lsp_location(loc, provider)
text = loc.text,
kind = loc.kind,
provider = provider,
origin = loc.originSelectionRange,
}
end

Expand Down Expand Up @@ -224,6 +225,122 @@ function M.list_from_lsp(result, provider)
return items
end

---Find the loaded buffer for a URI, matching equivalent paths (symlinks,
---differently escaped URIs) so unsaved buffer text wins over the file on disk.
---@param uri string
---@return integer?
local function loaded_buf_for_uri(uri)
local fname = fs.uri_to_fname(uri)
local real = fname and resolve_realpath(fname)
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(bufnr) then
if vim.uri_from_bufnr(bufnr) == uri then
return bufnr
end
local name = vim.api.nvim_buf_get_name(bufnr)
if real and name ~= "" and resolve_realpath(name) == real then
return bufnr
end
end
end
return nil
end

---Return a reader for the 0-based lines of a document, preferring the loaded
---buffer (which the LSP server sees) over the file on disk.
---@param uri string
---@return fun(row: integer): string?
local function line_reader(uri)
local bufnr = loaded_buf_for_uri(uri)
if bufnr then
return function(row)
return vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1]
end
end

local lines
return function(row)
if not lines then
lines = {}
local fname = fs.uri_to_fname(uri)
local file = fname and io.open(fname, "rb")
if file then
for line in file:lines() do
-- Match the buffer text Neovim would show: no CR from CRLF files.
lines[#lines + 1] = line:gsub("\r$", "")
end
file:close()
if lines[1] then
lines[1] = lines[1]:gsub("^\239\187\191", "")
end
end
end
return lines[row + 1]
end
end

---Byte and LSP columns only differ on lines containing non-ASCII bytes.
---@param line string?
---@return boolean
local function is_multibyte(line)
return line ~= nil and line:find("[\128-\255]") ~= nil
end

---@param read_line fun(row: integer): string?
---@param pos { line: integer, character: integer }
---@param encoding string
---@return { line: integer, character: integer }
local function position_to_byte(read_line, pos, encoding)
local line = read_line(pos.line)
if not is_multibyte(line) then
return { line = pos.line, character = pos.character }
end
return { line = pos.line, character = vim.str_byteindex(line, encoding, pos.character, false) }
end

---Convert a byte-column position in a loaded buffer into the column unit of
---an LSP client's offset encoding.
---@param bufnr integer
---@param pos { line: integer, character: integer } 0-based line, byte column
---@param encoding? string client offset encoding (defaults to "utf-16")
---@return { line: integer, character: integer }
function M.position_to_lsp(bufnr, pos, encoding)
encoding = encoding or "utf-16"
if encoding == "utf-8" then
return { line = pos.line, character = pos.character }
end
local line = vim.api.nvim_buf_get_lines(bufnr, pos.line, pos.line + 1, false)[1]
if not is_multibyte(line) then
return { line = pos.line, character = pos.character }
end
return { line = pos.line, character = vim.str_utfindex(line, encoding, pos.character, false) }
end

---Convert location ranges reported in an LSP client's offset encoding into
---byte columns, the unit used for every internal location.
---@param locations PeekstackLocation[]
---@param encoding? string client offset encoding (defaults to "utf-16")
function M.ranges_to_byte(locations, encoding)
encoding = encoding or "utf-16"
if encoding == "utf-8" then
return
end
---@type table<string, fun(row: integer): string?>
local readers = {}
for _, loc in ipairs(locations) do
local reader = readers[loc.uri]
if not reader then
reader = line_reader(loc.uri)
readers[loc.uri] = reader
end
local range = normalize_range(loc.range)
loc.range = {
start = position_to_byte(reader, range.start, encoding),
["end"] = position_to_byte(reader, range["end"], encoding),
}
end
end

---@param diags table[]?
---@param provider? string
---@return PeekstackLocation[]
Expand Down
13 changes: 8 additions & 5 deletions lua/peekstack/providers/lsp.lua
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,7 @@ local function request(ctx, method, provider, params_modifier, result_mapper, cb
for _, client in ipairs(clients) do
local params = {
textDocument = vim.lsp.util.make_text_document_params(bufnr),
position = {
line = ctx.position.line,
character = ctx.position.character,
},
position = location.position_to_lsp(bufnr, ctx.position, client.offset_encoding),
}
if params_modifier then
params_modifier(params)
Expand All @@ -146,7 +143,13 @@ local function request(ctx, method, provider, params_modifier, result_mapper, cb
return
end
if not err and result then
local ok, locs = pcall(mapper, result, provider, ctx)
local ok, locs = pcall(function()
local mapped = mapper(result, provider, ctx)
if type(mapped) == "table" then
location.ranges_to_byte(mapped, client.offset_encoding)
end
return mapped
end)
if ok and type(locs) == "table" then
vim.list_extend(all_locations, locs)
end
Expand Down
5 changes: 3 additions & 2 deletions lua/peekstack/types.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
-- Type definitions for Peekstack
---0-based lines and 0-based byte columns. LSP providers convert from and to
---each client's offset encoding at the request boundary.
---@class PeekstackRange
---@field start { line: integer, character: integer }
---@field ["end"] { line: integer, character: integer }
Expand All @@ -9,7 +11,6 @@
---@field text? string
---@field kind? integer
---@field provider string
---@field origin? PeekstackRange

---@class PeekstackDiagnosticExtmarks
---@field bufnr integer
Expand Down Expand Up @@ -156,7 +157,7 @@
---@field popup_id integer?
---@field buffer_mode "copy"|"source"|nil
---@field line_offset integer
---@field position { line: integer, character: integer }
---@field position { line: integer, character: integer } 0-based line and byte column in `bufnr`
---@field root_winid integer
---@field from_popup boolean

Expand Down
18 changes: 18 additions & 0 deletions tests/location_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ describe("location", function()
assert.equals(3, result.range.start.line)
end)

it("prefers targetSelectionRange over targetRange for a LocationLink", function()
local loc = {
targetUri = "file:///tmp/bar.lua",
targetRange = {
start = { line = 3, character = 0 },
["end"] = { line = 9, character = 3 },
},
targetSelectionRange = {
start = { line = 3, character = 9 },
["end"] = { line = 3, character = 14 },
},
}
local result = location.normalize(loc, "lsp.definition")
assert.is_not_nil(result)
assert.same({ line = 3, character = 9 }, result.range.start)
assert.same({ line = 3, character = 14 }, result.range["end"])
end)

it("normalizes a filename-based location", function()
local loc = {
filename = "/tmp/test.lua",
Expand Down
Loading
Loading