Skip to content
Draft
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
5 changes: 5 additions & 0 deletions doc/R.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1549,6 +1549,7 @@ features in your R.nvim config. Below are the default values:
document_highlight = true, -- enable the document highlight provider
document_symbol = true, -- enable the document symbol provider
workspace_symbol = true, -- enable the workspace symbol provider
rename = true, -- enable the rename provider
doc_width = 0,
fun_data_1 = { "select", "rename", "mutate", "filter" },
fun_data_2 = { ggplot = { "aes" }, with = { "*" } },
Expand All @@ -1566,6 +1567,7 @@ Meaning of options:
- `signature`: Enable the signature help provider

- `implementation`: Enable the implementation provider

- `definition`: Enable the definition provider

- `use_git_files`: Use `git ls-files` to find R files when indexing the
Expand All @@ -1582,6 +1584,9 @@ Meaning of options:

- `workspace_symbol`: Enable the workspace symbol provider

- `rename`: Enable the rename provider. This allows you to rename variables and
functions across your R project (see |R.nvim-rename|).

Note: The definition and references providers also recognize symbols defined
in `{targets}` pipelines. Target-defining calls (e.g., `tar_target()`,
`tar_file()`) where the first argument is an unquoted identifier are indexed
Expand Down
4 changes: 4 additions & 0 deletions lua/r/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ local hooks = require("r.hooks")
---Enable the document highlight provider
---@field document_highlight? boolean
---
---Enable the rename provider
---@field rename? boolean
---
---Text width of documentation window displayed when
---an item is selected
---@field doc_width? integer
Expand Down Expand Up @@ -508,6 +511,7 @@ local config = {
document_symbol = true,
workspace_symbol = true,
document_highlight = true,
rename = true,
doc_width = 0,
fun_data_1 = { "select", "rename", "mutate", "filter" },
fun_data_2 = { ggplot = { "aes" }, with = { "*" } },
Expand Down
10 changes: 7 additions & 3 deletions lua/r/lsp/highlight.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ local scope = require("r.lsp.scope")
-- R grammar: all assignments are binary_operator with named fields lhs/rhs
-- Walk up to the nearest assignment ancestor so that compound targets like
-- x[1] <- 1, x$y <- 1, or names(x) <- ... are correctly classified as writes.
local assign_target = { ["<-"] = "lhs", ["<<-"] = "lhs", ["="] = "lhs", ["->"] = "rhs", ["->>"] = "rhs" }
local assign_target =
{ ["<-"] = "lhs", ["<<-"] = "lhs", ["="] = "lhs", ["->"] = "rhs", ["->>"] = "rhs" }
local function highlight_kind(node, bufnr)
local cur = node
while true do
Expand Down Expand Up @@ -50,7 +51,10 @@ function M.document_highlight(req_id, line, col, bufnr)

local highlights = {}
for _, node in query:iter_captures(root, bufnr) do
if vim.treesitter.get_node_text(node, bufnr) == word then
if
not utils.is_argument_name_node(node)
and vim.treesitter.get_node_text(node, bufnr) == word
then
local sr, sc = node:start()
local _, ec = node:end_()

Expand All @@ -60,7 +64,7 @@ function M.document_highlight(req_id, line, col, bufnr)
if usage_scope then
local resolved = scope.resolve_symbol(word, usage_scope)
include = resolved ~= nil
and utils.is_same_definition(resolved, target_definition)
and utils.is_same_r_variable(resolved, target_definition, bufnr)
end
else
-- Symbol not resolved in scope: highlight all buffer occurrences
Expand Down
19 changes: 19 additions & 0 deletions lua/r/lsp/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,25 @@ M.references = function(req_id, line, col, bufnr)
require("r.lsp.references").find_references(req_id, line, col, bufnr)
end

---Rename all references to the symbol under cursor
---@param req_id string
---@param line integer LSP line (0-indexed)
---@param col integer LSP character (0-indexed)
---@param bufnr integer Source buffer (resolved from textDocument URI)
---@param new_name string Replacement identifier
M.rename = function(req_id, line, col, bufnr, new_name)
bufnr = get_r_bufnr(bufnr)
if vim.bo[bufnr].filetype ~= "r" then
local lang = "other"
vim.api.nvim_buf_call(bufnr, function() lang = get_lang(line) end)
if lang ~= "r" then
M.send_msg({ code = "N" .. req_id })
return
end
end
require("r.lsp.rename").rename_symbol(req_id, line, col, bufnr, new_name)
end

---Find implementations of the symbol under cursor
---@param req_id string
---@param line integer LSP line (0-indexed)
Expand Down
155 changes: 80 additions & 75 deletions lua/r/lsp/references.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ local workspace = require("r.lsp.workspace")
local utils = require("r.lsp.utils")
local scope = require("r.lsp.scope")

--- Find all workspace references without scope filtering (fallback)
--- Collect all workspace references without scope filtering (fallback)
---@param symbol string Symbol name
---@param req_id string LSP request ID
---@param bufnr integer Source buffer number
local function find_all_workspace_references(symbol, req_id, bufnr)
---@return table[] locations List of {file, line, col, end_col}
local function collect_workspace_references(symbol, bufnr)
local ast = require("r.lsp.ast")

-- Prepare workspace
utils.prepare_workspace()

local all_refs = {}
Expand All @@ -37,103 +36,97 @@ local function find_all_workspace_references(symbol, req_id, bufnr)
local file = vim.api.nvim_buf_get_name(bufnr)

for _, node in query:iter_captures(root, bufnr) do
local text = vim.treesitter.get_node_text(node, bufnr)
if text == symbol then
local start_row, start_col = node:start()
local _, end_col = node:end_()
table.insert(all_refs, {
file = file,
line = start_row,
col = start_col,
end_col = end_col,
})
if not utils.is_argument_name_node(node) then
local text = vim.treesitter.get_node_text(node, bufnr)
if text == symbol then
local start_row, start_col = node:start()
local _, end_col = node:end_()
table.insert(all_refs, {
file = file,
line = start_row,
col = start_col,
end_col = end_col,
})
end
end
end
end
end

all_refs = utils.deduplicate_locations(all_refs)

if #all_refs > 0 then
utils.send_response("R", req_id, { locations = all_refs })
else
utils.send_null(req_id)
end
return utils.deduplicate_locations(all_refs)
end

--- Find all references to a symbol across workspace (scope-aware)
---@param req_id string LSP request ID
--- Find all locations for a symbol across workspace (scope-aware).
--- Returns the location list and the word, so callers (references, rename)
--- can reuse the result without duplicating the search logic.
---@param line integer 0-indexed row from LSP params
---@param col integer 0-indexed column from LSP params
---@param bufnr integer Source buffer number
function M.find_references(req_id, line, col, bufnr)
---@return {locations: table[], word: string}|nil
function M.find_locations(line, col, bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local row = line

-- Get keyword from LSP position params
local word, err = utils.get_word_at_bufpos(bufnr, row, col)
if err or not word then
utils.send_null(req_id)
return
end
if err or not word then return nil end

local current_scope = scope.get_scope_at_position(bufnr, row, col)
if not current_scope then
-- No scope found, fallback to workspace search
find_all_workspace_references(word, req_id, bufnr)
return
local locs = collect_workspace_references(word, bufnr)
return { locations = locs, word = word, resolved = false }
end

local target_definition = scope.resolve_symbol(word, current_scope)
if not target_definition then
-- Symbol not resolved in scope, fallback to workspace search
-- This handles cases like add(2,3) where add is defined in other files
find_all_workspace_references(word, req_id, bufnr)
return
local locs = collect_workspace_references(word, bufnr)
return { locations = locs, word = word, resolved = false }
end

utils.prepare_workspace()

local all_refs = {}

local query = require("r.lsp.queries").get("references")
if not query then
utils.send_null(req_id)
return
end
if not query then return { locations = {}, word = word } end

local ast = require("r.lsp.ast")
local parser, root = ast.get_parser_and_root(bufnr)
local file = vim.api.nvim_buf_get_name(bufnr)

if parser and root then
for _, node in query:iter_captures(root, bufnr) do
local text = vim.treesitter.get_node_text(node, bufnr)
if text == word then
local start_row, start_col = node:start()
local _, end_col = node:end_()

local usage_scope =
scope.get_scope_at_position(bufnr, start_row, start_col)
if usage_scope then
local resolved = scope.resolve_symbol(word, usage_scope)
if
resolved and utils.is_same_definition(resolved, target_definition)
then
table.insert(all_refs, {
file = file,
line = start_row,
col = start_col,
end_col = end_col,
})
if not utils.is_argument_name_node(node) then
local text = vim.treesitter.get_node_text(node, bufnr)
if text == word then
local start_row, start_col = node:start()
local _, end_col = node:end_()

local usage_scope =
scope.get_scope_at_position(bufnr, start_row, start_col)
if usage_scope then
local resolved = scope.resolve_symbol(word, usage_scope)
if
resolved
and utils.is_same_r_variable(
resolved,
target_definition,
bufnr
)
then
table.insert(all_refs, {
file = file,
line = start_row,
col = start_col,
end_col = end_col,
})
end
end
end
end
end
end

-- For public (file-level) symbols, also search workspace files for usages
-- Cross-file references can only see public symbols
if target_definition.visibility == "public" then
local workspace_locations = workspace.get_definitions(word)
local buffer = require("r.lsp.buffer")
Expand Down Expand Up @@ -173,19 +166,22 @@ function M.find_references(req_id, line, col, bufnr)

if temp_root then
for _, node in query:iter_captures(temp_root, temp_bufnr) do
local text = vim.treesitter.get_node_text(node, temp_bufnr)
if text == word then
local in_function =
ast.find_ancestor(node, "function_definition")
if not in_function then
local start_row, start_col = node:start()
local _, end_col = node:end_()
table.insert(all_refs, {
file = filepath,
line = start_row,
col = start_col,
end_col = end_col,
})
if not utils.is_argument_name_node(node) then
local text =
vim.treesitter.get_node_text(node, temp_bufnr)
if text == word then
local in_function =
ast.find_ancestor(node, "function_definition")
if not in_function then
local start_row, start_col = node:start()
local _, end_col = node:end_()
table.insert(all_refs, {
file = filepath,
line = start_row,
col = start_col,
end_col = end_col,
})
end
end
end
end
Expand All @@ -198,12 +194,21 @@ function M.find_references(req_id, line, col, bufnr)
end

all_refs = utils.deduplicate_locations(all_refs)
return { locations = all_refs, word = word, resolved = true }
end

if #all_refs > 0 then
utils.send_response("R", req_id, { locations = all_refs })
else
--- Find all references to a symbol across workspace (scope-aware)
---@param req_id string LSP request ID
---@param line integer 0-indexed row from LSP params
---@param col integer 0-indexed column from LSP params
---@param bufnr integer Source buffer number
function M.find_references(req_id, line, col, bufnr)
local result = M.find_locations(line, col, bufnr)
if not result or #result.locations == 0 then
utils.send_null(req_id)
return
end
utils.send_response("R", req_id, { locations = result.locations })
end

return M
49 changes: 49 additions & 0 deletions lua/r/lsp/rename.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
--- LSP rename for R.nvim
--- Provides textDocument/rename functionality

local M = {}

local utils = require("r.lsp.utils")

--- Rename all references to the symbol at the given position
---@param req_id string LSP request ID
---@param line integer 0-indexed row from LSP params
---@param col integer 0-indexed column from LSP params
---@param bufnr integer Source buffer number
---@param new_name string The replacement identifier
function M.rename_symbol(req_id, line, col, bufnr, new_name)
local result = require("r.lsp.references").find_locations(line, col, bufnr)
if not result or #result.locations == 0 then
utils.send_null(req_id)
return
end

-- If the symbol wasn't resolved in the local scope and has no definition
-- anywhere in the project, it belongs to an external package. Renaming it
-- locally would silently produce broken code, so refuse.
if not result.resolved then
local workspace = require("r.lsp.workspace")
if #workspace.get_definitions(result.word) == 0 then
utils.send_null(req_id)
return
end
end

-- Group edits by file URI into WorkspaceEdit.changes format
local changes = {}
for _, loc in ipairs(result.locations) do
local uri = "file://" .. loc.file
if not changes[uri] then changes[uri] = {} end
table.insert(changes[uri], {
range = {
start = { line = loc.line, character = loc.col },
["end"] = { line = loc.line, character = loc.end_col },
},
newText = new_name,
})
end

utils.send_response("X", req_id, { changes = changes })
end

return M
Loading
Loading