#![allow(clippy::mutable_key_type)]
mod task_pool;
use std::collections::{HashMap, HashSet};
use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::thread::JoinHandle;
use crossbeam_channel::{Receiver, Sender, select};
use lsp_server::{Connection, ErrorCode, Message, Notification, Request, RequestId, Response};
use lsp_types::notification::{
DidChangeConfiguration, DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument,
Notification as NotificationTrait, PublishDiagnostics,
};
use lsp_types::request::{
CodeActionRequest, DocumentHighlightRequest, DocumentSymbolRequest, Formatting, GotoDefinition,
HoverRequest, PrepareRenameRequest, RangeFormatting, References, Rename,
Request as RequestTrait,
};
use lsp_types::{
CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams,
CodeActionProviderCapability, CodeActionResponse, Diagnostic as LspDiagnostic,
DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams,
DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentFormattingParams,
DocumentHighlight, DocumentHighlightKind, DocumentHighlightParams,
DocumentRangeFormattingParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams,
HoverProviderCapability, InitializeResult, Location, MarkupContent, MarkupKind, NumberOrString,
OneOf, Position, PrepareRenameResponse, PublishDiagnosticsParams, Range, ReferenceParams,
RenameOptions, RenameParams, ServerCapabilities, ServerInfo, SymbolKind as LspSymbolKind,
TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Uri,
WorkspaceEdit,
};
use rowan::{NodeOrToken, SyntaxToken, TextRange, TextSize, TokenAtOffset};
use salsa::Database as _;
use serde::Deserialize;
use smol_str::SmolStr;
use crate::ast::{AssignmentExpr, AstNode as _, BinaryExpr, FunctionExpr};
use crate::config::{Config, FormatConfig, IndexConfig, LintConfig};
use crate::file_discovery::collect_r_files;
use crate::formatter::{FormatStyle, format_node, format_range, format_with_style};
use crate::incremental::{Analysis, IncrementalDatabase, SourceFile};
use crate::linter::{Diagnostic, Severity};
use crate::parser::{diff_edit, map_range_through_edit, parse};
use crate::rindex::build::{BuildOptions, build_index};
use crate::rindex::cache::{Cache, resolve_cache_root};
use crate::rindex::discover::referenced_in_source;
use crate::rindex::libpaths::LibrarySearch;
use crate::rindex::provider::{
CompositeProvider, IndexedProvider, package_indexed, resolve_origin,
};
use crate::rindex::schema::{Formal, SymbolEntry, SymbolKind};
use crate::semantic::{BindingId, BindingKind, PackageOrigin, SemanticModel};
use crate::syntax::{NodePtr, RLanguage, SyntaxKind, SyntaxNode};
use crate::text::LineIndex;
use task_pool::{Spawner, TaskPool, read_pool_size};
type DynError = Box<dyn std::error::Error + Sync + Send>;
pub fn run() -> Result<(), DynError> {
let (connection, io_threads) = Connection::stdio();
let (id, params) = connection.initialize_start()?;
let editor_settings = params
.get("initializationOptions")
.map(EditorSettings::from_client_value)
.unwrap_or_default();
let workspace_roots = workspace_roots_from_params(¶ms);
let init_result = InitializeResult {
capabilities: server_capabilities(),
server_info: Some(ServerInfo {
name: "arity".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}),
};
connection.initialize_finish(id, serde_json::to_value(init_result)?)?;
main_loop(connection, editor_settings, workspace_roots)?;
io_threads.join()?;
Ok(())
}
fn workspace_roots_from_params(params: &serde_json::Value) -> Vec<PathBuf> {
let from_uri = |s: &str| s.parse::<Uri>().ok().and_then(|u| uri::to_path(&u));
let mut roots: Vec<PathBuf> = params
.get("workspaceFolders")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|folder| folder.get("uri").and_then(|u| u.as_str()))
.filter_map(from_uri)
.collect();
if roots.is_empty()
&& let Some(path) = params
.get("rootUri")
.and_then(|u| u.as_str())
.and_then(from_uri)
{
roots.push(path);
}
roots
}
fn server_capabilities() -> ServerCapabilities {
ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
document_formatting_provider: Some(OneOf::Left(true)),
document_range_formatting_provider: Some(OneOf::Left(true)),
code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
definition_provider: Some(OneOf::Left(true)),
references_provider: Some(OneOf::Left(true)),
document_highlight_provider: Some(OneOf::Left(true)),
document_symbol_provider: Some(OneOf::Left(true)),
rename_provider: Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: Default::default(),
})),
..Default::default()
}
}
fn main_loop(
connection: Connection,
editor_settings: EditorSettings,
workspace_roots: Vec<PathBuf>,
) -> Result<(), DynError> {
let (out_tx, out_rx) = crossbeam_channel::unbounded::<Outbound>();
let (lint_tx, lint_rx) = crossbeam_channel::unbounded::<LintMsg>();
let (read_tx, read_rx) = crossbeam_channel::unbounded::<ReadJob>();
let read_pool = TaskPool::new("arity-lsp-read", read_pool_size());
let lint_handle = spawn_lint_thread(lint_rx, read_rx, out_tx, read_pool.spawner());
if !workspace_roots.is_empty() {
let _ = lint_tx.send(LintMsg::SeedWorkspace {
roots: workspace_roots,
});
}
let mut state = GlobalState::new(
connection.sender.clone(),
lint_tx,
read_tx,
read_pool.spawner(),
editor_settings,
);
loop {
select! {
recv(connection.receiver) -> msg => {
let Ok(msg) = msg else { break };
match msg {
Message::Request(req) => {
if connection.handle_shutdown(&req)? {
break;
}
state.on_request(req);
}
Message::Notification(not) => state.on_notification(not),
Message::Response(_) => {}
}
}
recv(out_rx) -> ob => {
let Ok(ob) = ob else { break };
state.on_outbound(ob);
}
}
}
drop(state); let _ = lint_handle.join();
Ok(())
}
#[derive(Debug, Clone)]
struct Document {
text: String,
version: i32,
}
#[derive(Debug, Clone)]
struct ResolvedSettings {
style: FormatStyle,
lint: LintConfig,
index: IndexConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct EditorSettings {
line_width: Option<u32>,
indent_width: Option<u32>,
}
impl EditorSettings {
fn from_client_value(value: &serde_json::Value) -> Self {
let section = value
.get("arity")
.filter(|v| v.is_object())
.unwrap_or(value);
serde_json::from_value(section.clone()).unwrap_or_default()
}
fn to_format_style(&self) -> FormatStyle {
let mut config = FormatConfig::default();
if let Some(width) = self.line_width {
config.line_width = width;
}
if let Some(width) = self.indent_width {
config.indent_width = width;
}
match config.validate(None) {
Ok(()) => FormatStyle::from(&config),
Err(_) => FormatStyle::default(),
}
}
}
fn resolve_format_style(
config: &Config,
config_present: bool,
editor: &EditorSettings,
) -> FormatStyle {
if config_present {
FormatStyle::from(&config.format)
} else {
editor.to_format_style()
}
}
struct LintRequest {
uri: Uri,
path: PathBuf,
text: String,
version: i32,
lint_config: LintConfig,
index_config: IndexConfig,
}
enum LintMsg {
Request(Box<LintRequest>),
SeedWorkspace {
roots: Vec<PathBuf>,
},
}
enum ReadJob {
Format {
id: RequestId,
path: PathBuf,
text: String,
style: FormatStyle,
sender: Sender<Message>,
},
FormatRange {
id: RequestId,
path: PathBuf,
text: String,
range: Range,
style: FormatStyle,
sender: Sender<Message>,
},
Hover {
id: RequestId,
path: PathBuf,
text: String,
position: Position,
sender: Sender<Message>,
},
Definition {
id: RequestId,
path: PathBuf,
uri: Uri,
text: String,
position: Position,
sender: Sender<Message>,
},
References {
id: RequestId,
path: PathBuf,
uri: Uri,
text: String,
position: Position,
include_declaration: bool,
sender: Sender<Message>,
},
}
enum Outbound {
Diagnostics {
uri: Uri,
version: i32,
diags: Vec<LspDiagnostic>,
findings: Arc<Vec<Diagnostic>>,
},
RelintAll,
}
struct GlobalState {
documents: HashMap<Uri, Document>,
findings: HashMap<Uri, (i32, Arc<Vec<Diagnostic>>)>,
rename_anchors: HashMap<Uri, RenameAnchor>,
config_cache: HashMap<PathBuf, ResolvedSettings>,
editor_settings: EditorSettings,
sender: Sender<Message>,
lint_tx: Sender<LintMsg>,
read_tx: Sender<ReadJob>,
read_spawner: Spawner,
}
impl GlobalState {
fn new(
sender: Sender<Message>,
lint_tx: Sender<LintMsg>,
read_tx: Sender<ReadJob>,
read_spawner: Spawner,
editor_settings: EditorSettings,
) -> Self {
Self {
documents: HashMap::new(),
findings: HashMap::new(),
rename_anchors: HashMap::new(),
config_cache: HashMap::new(),
editor_settings,
sender,
lint_tx,
read_tx,
read_spawner,
}
}
fn on_request(&mut self, req: Request) {
match req.method.as_str() {
Formatting::METHOD => self.on_formatting(req),
RangeFormatting::METHOD => self.on_range_formatting(req),
CodeActionRequest::METHOD => self.on_code_action(req),
HoverRequest::METHOD => self.on_hover(req),
GotoDefinition::METHOD => self.on_definition(req),
References::METHOD => self.on_references(req),
DocumentHighlightRequest::METHOD => self.on_document_highlight(req),
DocumentSymbolRequest::METHOD => self.on_document_symbol(req),
PrepareRenameRequest::METHOD => self.on_prepare_rename(req),
Rename::METHOD => self.on_rename(req),
_ => {
let resp = Response::new_err(
req.id,
ErrorCode::MethodNotFound as i32,
format!("unhandled method: {}", req.method),
);
let _ = self.sender.send(Message::Response(resp));
}
}
}
fn on_formatting(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) = req.extract::<DocumentFormattingParams>(Formatting::METHOD) else {
self.respond_err(id, "invalid formatting params");
return;
};
let uri = params.text_document.uri;
let Some(text) = self.documents.get(&uri).map(|d| d.text.clone()) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let Ok(settings) = self.resolve_settings(&uri) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let path = uri::to_path(&uri).unwrap_or_else(|| PathBuf::from("untitled.R"));
self.dispatch_read(ReadJob::Format {
id,
path,
text,
style: settings.style,
sender: self.sender.clone(),
});
}
fn on_range_formatting(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) = req.extract::<DocumentRangeFormattingParams>(RangeFormatting::METHOD)
else {
self.respond_err(id, "invalid range formatting params");
return;
};
let uri = params.text_document.uri;
let Some(text) = self.documents.get(&uri).map(|d| d.text.clone()) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let Ok(settings) = self.resolve_settings(&uri) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let path = uri::to_path(&uri).unwrap_or_else(|| PathBuf::from("untitled.R"));
self.dispatch_read(ReadJob::FormatRange {
id,
path,
text,
range: params.range,
style: settings.style,
sender: self.sender.clone(),
});
}
fn on_code_action(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) = req.extract::<CodeActionParams>(CodeActionRequest::METHOD) else {
self.respond_err(id, "invalid code action params");
return;
};
let uri = params.text_document.uri;
let Some((text, version)) = self
.documents
.get(&uri)
.map(|d| (d.text.clone(), d.version))
else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let range = params.range;
let sender = self.sender.clone();
if let Some((cached_version, findings)) = self.findings.get(&uri)
&& *cached_version == version
{
let findings = Arc::clone(findings);
self.read_spawner.spawn(move || {
let actions = code_actions_from_findings(&findings, &text, &uri, range);
let _ = sender.send(Message::Response(Response::new_ok(id, actions)));
});
return;
}
let path = uri::to_path(&uri).unwrap_or_else(|| PathBuf::from("untitled.R"));
let lint = self
.resolve_settings(&uri)
.map(|s| s.lint)
.unwrap_or_default();
self.read_spawner.spawn(move || {
let actions = compute_code_actions(&text, &path, &lint, &uri, range);
let _ = sender.send(Message::Response(Response::new_ok(id, actions)));
});
}
fn on_hover(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) = req.extract::<HoverParams>(HoverRequest::METHOD) else {
self.respond_err(id, "invalid hover params");
return;
};
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let Some(text) = self.documents.get(&uri).map(|d| d.text.clone()) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let path = uri::to_path(&uri).unwrap_or_else(|| PathBuf::from("untitled.R"));
self.dispatch_read(ReadJob::Hover {
id,
path,
text,
position,
sender: self.sender.clone(),
});
}
fn on_definition(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) = req.extract::<GotoDefinitionParams>(GotoDefinition::METHOD) else {
self.respond_err(id, "invalid definition params");
return;
};
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let Some(text) = self.documents.get(&uri).map(|d| d.text.clone()) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let path = uri::to_path(&uri).unwrap_or_else(|| PathBuf::from("untitled.R"));
self.dispatch_read(ReadJob::Definition {
id,
path,
uri,
text,
position,
sender: self.sender.clone(),
});
}
fn on_references(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) = req.extract::<ReferenceParams>(References::METHOD) else {
self.respond_err(id, "invalid references params");
return;
};
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let include_declaration = params.context.include_declaration;
let Some(text) = self.documents.get(&uri).map(|d| d.text.clone()) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let path = uri::to_path(&uri).unwrap_or_else(|| PathBuf::from("untitled.R"));
self.dispatch_read(ReadJob::References {
id,
path,
uri,
text,
position,
include_declaration,
sender: self.sender.clone(),
});
}
fn on_document_highlight(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) =
req.extract::<DocumentHighlightParams>(DocumentHighlightRequest::METHOD)
else {
self.respond_err(id, "invalid documentHighlight params");
return;
};
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let Some(text) = self.documents.get(&uri).map(|d| d.text.clone()) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let sender = self.sender.clone();
self.read_spawner.spawn(move || {
let line_index = LineIndex::new(&text);
let offset = line_index.position_to_byte(position).min(text.len());
let result = compute_document_highlights(&text, offset).map(|highlights| {
highlights
.into_iter()
.map(|(range, kind)| DocumentHighlight {
range: text_range_to_lsp_range(&line_index, range),
kind: Some(kind),
})
.collect::<Vec<_>>()
});
let _ = sender.send(Message::Response(Response::new_ok(id, result)));
});
}
fn on_document_symbol(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) = req.extract::<DocumentSymbolParams>(DocumentSymbolRequest::METHOD)
else {
self.respond_err(id, "invalid documentSymbol params");
return;
};
let uri = params.text_document.uri;
let Some(text) = self.documents.get(&uri).map(|d| d.text.clone()) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let sender = self.sender.clone();
self.read_spawner.spawn(move || {
let symbols = compute_document_symbols(&text);
let response = DocumentSymbolResponse::Nested(symbols);
let _ = sender.send(Message::Response(Response::new_ok(id, response)));
});
}
fn on_prepare_rename(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) =
req.extract::<TextDocumentPositionParams>(PrepareRenameRequest::METHOD)
else {
self.respond_err(id, "invalid prepareRename params");
return;
};
let uri = params.text_document.uri;
let Some(text) = self.documents.get(&uri).map(|d| d.text.clone()) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let line_index = LineIndex::new(&text);
let offset = line_index.position_to_byte(params.position).min(text.len());
match compute_prepare_rename(&text, offset) {
Some(prepared) => {
self.rename_anchors.insert(uri, prepared.anchor);
let response = PrepareRenameResponse::RangeWithPlaceholder {
range: prepared.range,
placeholder: prepared.placeholder,
};
self.respond_ok(id, serde_json::to_value(response).unwrap_or_default());
}
None => {
self.rename_anchors.remove(&uri);
self.respond_ok(id, serde_json::Value::Null);
}
}
}
fn on_rename(&mut self, req: Request) {
let id = req.id.clone();
let Ok((_, params)) = req.extract::<RenameParams>(Rename::METHOD) else {
self.respond_err(id, "invalid rename params");
return;
};
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let new_name = params.new_name;
let Some(text) = self.documents.get(&uri).map(|d| d.text.clone()) else {
self.respond_ok(id, serde_json::Value::Null);
return;
};
let anchored = self
.rename_anchors
.get(&uri)
.and_then(|anchor| compute_rename_with_anchor(&text, anchor, &new_name));
let edits = anchored.or_else(|| {
let line_index = LineIndex::new(&text);
let offset = line_index.position_to_byte(position).min(text.len());
compute_rename(&text, offset, &new_name)
});
self.rename_anchors.remove(&uri);
match edits {
Some(edits) if !edits.is_empty() => {
let workspace_edit = WorkspaceEdit {
changes: Some(HashMap::from([(uri, edits)])),
..Default::default()
};
self.respond_ok(id, serde_json::to_value(workspace_edit).unwrap_or_default());
}
_ => self.respond_err(id, "rename is not available here"),
}
}
fn dispatch_read(&self, job: ReadJob) {
if let Err(crossbeam_channel::SendError(job)) = self.read_tx.send(job) {
let (id, sender) = match job {
ReadJob::Format { id, sender, .. } => (id, sender),
ReadJob::FormatRange { id, sender, .. } => (id, sender),
ReadJob::Hover { id, sender, .. } => (id, sender),
ReadJob::Definition { id, sender, .. } => (id, sender),
ReadJob::References { id, sender, .. } => (id, sender),
};
let _ = sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
}
}
fn on_notification(&mut self, not: Notification) {
match not.method.as_str() {
DidOpenTextDocument::METHOD => {
if let Ok(params) =
not.extract::<DidOpenTextDocumentParams>(DidOpenTextDocument::METHOD)
{
let uri = params.text_document.uri;
self.documents.insert(
uri.clone(),
Document {
text: params.text_document.text,
version: params.text_document.version,
},
);
self.send_lint(uri);
}
}
DidChangeTextDocument::METHOD => {
if let Ok(mut params) =
not.extract::<DidChangeTextDocumentParams>(DidChangeTextDocument::METHOD)
&& let Some(change) = params.content_changes.pop()
{
let uri = params.text_document.uri;
self.documents.insert(
uri.clone(),
Document {
text: change.text,
version: params.text_document.version,
},
);
self.send_lint(uri);
}
}
DidCloseTextDocument::METHOD => {
if let Ok(params) =
not.extract::<DidCloseTextDocumentParams>(DidCloseTextDocument::METHOD)
{
let uri = params.text_document.uri;
self.documents.remove(&uri);
self.findings.remove(&uri);
self.rename_anchors.remove(&uri);
self.publish(uri, Vec::new(), None);
}
}
DidChangeConfiguration::METHOD => {
if let Ok(params) =
not.extract::<DidChangeConfigurationParams>(DidChangeConfiguration::METHOD)
{
let updated = EditorSettings::from_client_value(¶ms.settings);
if updated != self.editor_settings {
self.editor_settings = updated;
self.config_cache.clear();
}
}
}
_ => {}
}
}
fn on_outbound(&mut self, ob: Outbound) {
match ob {
Outbound::Diagnostics {
uri,
version,
diags,
findings,
} => {
if matches!(self.documents.get(&uri), Some(d) if d.version == version) {
self.findings.insert(uri.clone(), (version, findings));
self.publish(uri, diags, Some(version));
}
}
Outbound::RelintAll => {
let uris: Vec<Uri> = self.documents.keys().cloned().collect();
for uri in uris {
self.send_lint(uri);
}
}
}
}
fn send_lint(&mut self, uri: Uri) {
let Some(doc) = self.documents.get(&uri) else {
return;
};
let text = doc.text.clone();
let version = doc.version;
let path = uri::to_path(&uri).unwrap_or_else(|| PathBuf::from("untitled.R"));
let (lint_config, index_config) = match self.resolve_settings(&uri) {
Ok(s) => (s.lint, s.index),
Err(_) => (LintConfig::default(), IndexConfig::default()),
};
let _ = self.lint_tx.send(LintMsg::Request(Box::new(LintRequest {
uri,
path,
text,
version,
lint_config,
index_config,
})));
}
fn resolve_settings(&mut self, uri: &Uri) -> Result<ResolvedSettings, ConfigResolveError> {
let path = uri::to_path(uri).ok_or(ConfigResolveError::NonFileUri)?;
let anchor = path
.parent()
.ok_or(ConfigResolveError::NoParentDirectory)?
.to_path_buf();
if let Some(s) = self.config_cache.get(&anchor) {
return Ok(s.clone());
}
let (config, source) = Config::resolve(None, false, &anchor)
.map_err(|err| ConfigResolveError::Config(err.to_string()))?;
let resolved = ResolvedSettings {
style: resolve_format_style(&config, source.is_some(), &self.editor_settings),
lint: config.lint,
index: config.index,
};
self.config_cache.insert(anchor, resolved.clone());
Ok(resolved)
}
fn publish(&self, uri: Uri, diagnostics: Vec<LspDiagnostic>, version: Option<i32>) {
let params = PublishDiagnosticsParams {
uri,
diagnostics,
version,
};
let not = Notification::new(PublishDiagnostics::METHOD.to_string(), params);
let _ = self.sender.send(Message::Notification(not));
}
fn respond_ok(&self, id: RequestId, value: serde_json::Value) {
let _ = self
.sender
.send(Message::Response(Response::new_ok(id, value)));
}
fn respond_err(&self, id: RequestId, message: &str) {
let resp = Response::new_err(id, ErrorCode::InvalidParams as i32, message.to_string());
let _ = self.sender.send(Message::Response(resp));
}
}
fn spawn_lint_thread(
lint_rx: Receiver<LintMsg>,
read_rx: Receiver<ReadJob>,
out_tx: Sender<Outbound>,
read_spawner: Spawner,
) -> JoinHandle<()> {
let (build_tx, build_rx) = crossbeam_channel::unbounded::<IndexedProvider>();
let (done_tx, done_rx) = crossbeam_channel::unbounded::<AnalyzeDone>();
std::thread::Builder::new()
.name("arity-lint".to_string())
.spawn(move || {
let mut worker = LintWorker {
db: IncrementalDatabase::default(),
index_loaded: HashSet::new(),
index_attempts: HashSet::new(),
out_tx,
build_tx,
done_tx,
inflight: None,
pending: HashMap::new(),
read_spawner,
index_pool: TaskPool::new("arity-index", 1),
};
worker.run(&lint_rx, &read_rx, &build_rx, &done_rx);
})
.expect("spawn lint thread")
}
struct AnalyzeDone {
uri: Uri,
version: i32,
}
struct InflightAnalyze {
uri: Uri,
version: i32,
}
#[derive(Debug, PartialEq, Eq)]
enum DispatchAction {
Wait,
Start(Uri),
SupersedeAndStart(Uri),
}
fn decide(inflight: Option<(&Uri, i32)>, pending: &HashMap<Uri, i32>) -> DispatchAction {
match inflight {
None => match pending.keys().next() {
Some(uri) => DispatchAction::Start(uri.clone()),
None => DispatchAction::Wait,
},
Some((uri, version)) => {
if pending.get(uri).is_some_and(|&v| v > version) {
DispatchAction::SupersedeAndStart(uri.clone())
} else {
DispatchAction::Wait
}
}
}
}
struct LintWorker {
db: IncrementalDatabase,
index_loaded: HashSet<PathBuf>,
index_attempts: HashSet<SmolStr>,
out_tx: Sender<Outbound>,
build_tx: Sender<IndexedProvider>,
done_tx: Sender<AnalyzeDone>,
inflight: Option<InflightAnalyze>,
pending: HashMap<Uri, LintRequest>,
read_spawner: Spawner,
index_pool: TaskPool,
}
impl LintWorker {
fn run(
&mut self,
lint_rx: &Receiver<LintMsg>,
read_rx: &Receiver<ReadJob>,
build_rx: &Receiver<IndexedProvider>,
done_rx: &Receiver<AnalyzeDone>,
) {
loop {
select! {
recv(lint_rx) -> msg => {
let Ok(msg) = msg else { break };
self.handle_lint_msg(msg);
while let Ok(m) = lint_rx.try_recv() {
self.handle_lint_msg(m);
}
self.try_dispatch();
}
recv(done_rx) -> done => {
let Ok(done) = done else { continue };
if matches!(&self.inflight, Some(f) if f.uri == done.uri && f.version == done.version) {
self.inflight = None;
}
self.try_dispatch();
}
recv(read_rx) -> job => {
let Ok(job) = job else { continue };
let snapshot = self.db.snapshot();
self.read_spawner.spawn(move || run_read(snapshot, job));
}
recv(build_rx) -> built => {
let Ok(indexed) = built else { continue };
self.db.set_library_index(indexed);
let _ = self.out_tx.send(Outbound::RelintAll);
}
}
}
}
fn handle_lint_msg(&mut self, msg: LintMsg) {
match msg {
LintMsg::Request(req) => self.enqueue(*req),
LintMsg::SeedWorkspace { roots } => self.seed_workspace(roots),
}
}
fn seed_workspace(&mut self, roots: Vec<PathBuf>) {
let discovered = collect_r_files(&roots).unwrap_or_default();
let mut files: Vec<SourceFile> = self
.db
.workspace()
.map(|ws| ws.members(&self.db).to_vec())
.unwrap_or_default();
for path in discovered {
if let Ok(text) = std::fs::read_to_string(&path) {
files.push(self.db.upsert_file(&path, text));
}
}
self.db.set_workspace_members(files, roots);
}
fn enqueue(&mut self, req: LintRequest) {
match self.pending.get(&req.uri) {
Some(existing) if existing.version >= req.version => {}
_ => {
self.pending.insert(req.uri.clone(), req);
}
}
}
fn try_dispatch(&mut self) {
loop {
let versions: HashMap<Uri, i32> = self
.pending
.iter()
.map(|(uri, req)| (uri.clone(), req.version))
.collect();
let inflight = self.inflight.as_ref().map(|f| (&f.uri, f.version));
let uri = match decide(inflight, &versions) {
DispatchAction::Wait => return,
DispatchAction::Start(uri) => uri,
DispatchAction::SupersedeAndStart(uri) => {
self.db.trigger_cancellation();
self.inflight = None;
uri
}
};
let Some(req) = self.pending.remove(&uri) else {
return;
};
if self.start(req) {
return;
}
}
}
fn start(&mut self, req: LintRequest) -> bool {
let anchor = req
.path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
self.ensure_index(&anchor, &req.index_config);
let active = self.db.upsert_file(&req.path, req.text.clone());
let already_member = self
.db
.workspace()
.is_some_and(|ws| ws.members(&self.db).contains(&active));
if !already_member {
crate::linter::check::seed_workspace_for(&mut self.db, &req.path, active);
}
let prepared = match crate::linter::check::prepare_document_in_project(
&mut self.db,
&req.path,
active,
&req.lint_config,
) {
Ok(Some(prepared)) => prepared,
Ok(None) | Err(_) => {
self.publish_empty(&req);
return false;
}
};
if req.index_config.auto_build {
self.maybe_build(&anchor, &req.index_config, &req.text);
}
let snapshot = self.db.snapshot();
let out_tx = self.out_tx.clone();
let done_tx = self.done_tx.clone();
let uri = req.uri.clone();
let version = req.version;
let text = req.text;
self.inflight = Some(InflightAnalyze {
uri: uri.clone(),
version,
});
let fallback = CompositeProvider::base_only();
self.read_spawner.spawn(move || {
let result = salsa::Cancelled::catch(AssertUnwindSafe(|| {
crate::linter::check::analyze_prepared(&snapshot, &prepared, &fallback)
}));
if let Ok(diagnostics) = result {
let line_index = LineIndex::new(&text);
let diags: Vec<LspDiagnostic> = diagnostics
.iter()
.map(|d| to_lsp_diagnostic(d, &line_index))
.collect();
let _ = out_tx.send(Outbound::Diagnostics {
uri: uri.clone(),
version,
diags,
findings: Arc::new(diagnostics),
});
}
drop(snapshot);
let _ = done_tx.send(AnalyzeDone { uri, version });
});
true
}
fn publish_empty(&self, req: &LintRequest) {
let _ = self.out_tx.send(Outbound::Diagnostics {
uri: req.uri.clone(),
version: req.version,
diags: Vec::new(),
findings: Arc::new(Vec::new()),
});
}
fn ensure_index(&mut self, anchor: &Path, cfg: &IndexConfig) {
if self.index_loaded.contains(anchor) {
return;
}
let indexed = match resolve_cache_root(None, cfg.cache_dir.as_deref()) {
Ok(root) => IndexedProvider::from_cache(&Cache::new(root)),
Err(_) => IndexedProvider::empty(),
};
self.db.set_library_index(indexed);
self.index_loaded.insert(anchor.to_path_buf());
}
fn maybe_build(&mut self, anchor: &Path, cfg: &IndexConfig, source: &str) {
let current = self.db.library_data();
let empty = IndexedProvider::empty();
let indexed = current.as_deref().unwrap_or(&empty);
let to_build = packages_to_build(&mut self.index_attempts, indexed, source);
if to_build.is_empty() {
return;
}
let Ok(cache_root) = resolve_cache_root(None, cfg.cache_dir.as_deref()) else {
return;
};
let cfg = cfg.clone();
let anchor = anchor.to_path_buf();
let build_tx = self.build_tx.clone();
self.index_pool.spawn(move || {
let now = now_unix_secs();
let cache = Cache::new(cache_root);
let search = LibrarySearch::discover(Some(&anchor), &cfg.library_paths);
let report = build_index(
&to_build,
&cache,
&search,
BuildOptions {
help: cfg.help,
force: false,
},
now,
);
if report.newly_indexed().next().is_some() {
let _ = build_tx.send(IndexedProvider::from_cache(&cache));
}
});
}
}
fn packages_to_build(
attempts: &mut HashSet<SmolStr>,
indexed: &IndexedProvider,
source: &str,
) -> Vec<SmolStr> {
referenced_in_source(source)
.into_iter()
.filter(|pkg| !package_indexed(indexed, pkg) && attempts.insert(pkg.clone()))
.collect()
}
fn now_unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn run_read(snapshot: Analysis, job: ReadJob) {
match job {
ReadJob::Format {
id,
path,
text,
style,
sender,
} => {
let result = format_edits_via_db(&snapshot, &path, &text, style);
let _ = sender.send(Message::Response(Response::new_ok(id, result)));
}
ReadJob::FormatRange {
id,
path,
text,
range,
style,
sender,
} => {
let result = format_range_edits_via_db(&snapshot, &path, &text, range, style);
let _ = sender.send(Message::Response(Response::new_ok(id, result)));
}
ReadJob::Hover {
id,
path,
text,
position,
sender,
} => {
let result = hover_via_db(&snapshot, &path, &text, position);
let _ = sender.send(Message::Response(Response::new_ok(id, result)));
}
ReadJob::Definition {
id,
path,
uri,
text,
position,
sender,
} => {
let result = definition_via_db(&snapshot, &path, &uri, &text, position);
let _ = sender.send(Message::Response(Response::new_ok(id, result)));
}
ReadJob::References {
id,
path,
uri,
text,
position,
include_declaration,
sender,
} => {
let result =
references_via_db(&snapshot, &path, &uri, &text, position, include_declaration);
let _ = sender.send(Message::Response(Response::new_ok(id, result)));
}
}
}
fn format_edits_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
style: FormatStyle,
) -> Option<Vec<TextEdit>> {
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
if !snapshot.parse_diagnostics(file).is_empty() {
return Some(None);
}
let root = snapshot.parsed_tree(file);
let formatted = format_node(&root, style, text.ends_with('\n')).ok();
Some(formatted.map(|formatted| edits_for_formatted(text, formatted)))
}));
match cached {
Ok(Some(edits)) => edits,
Ok(None) | Err(_) => compute_format_edits(text, style),
}
}
fn format_range_edits_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
range: Range,
style: FormatStyle,
) -> Option<Vec<TextEdit>> {
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
if !snapshot.parse_diagnostics(file).is_empty() {
return Some(None);
}
let root = snapshot.parsed_tree(file);
let line_index = LineIndex::new(text);
let text_range = lsp_range_to_text_range(&line_index, range);
let edits = match format_range(&root, text_range, style) {
Ok(Some(formatted)) => Some(range_edits(&line_index, text, formatted)),
Ok(None) => Some(Vec::new()),
Err(_) => None,
};
Some(edits)
}));
match cached {
Ok(Some(edits)) => edits,
Ok(None) | Err(_) => compute_format_range_edits(text, range, style),
}
}
fn hover_via_db(snapshot: &Analysis, path: &Path, text: &str, position: Position) -> Option<Hover> {
let line_index = LineIndex::new(text);
let offset = line_index.position_to_byte(position).min(text.len());
let index = snapshot.library_data().unwrap_or_default();
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
let root = snapshot.parsed_tree(file);
Some(hover_from_node(&root, &line_index, offset, &index))
}));
match cached {
Ok(Some(hover)) => hover,
Ok(None) | Err(_) => {
let root = parse(text).cst;
hover_from_node(&root, &line_index, offset, &index)
}
}
}
fn definition_via_db(
snapshot: &Analysis,
path: &Path,
uri: &Uri,
text: &str,
position: Position,
) -> Option<GotoDefinitionResponse> {
let line_index = LineIndex::new(text);
let offset = TextSize::new(line_index.position_to_byte(position).min(text.len()) as u32);
let root = parse(text).cst;
let model = SemanticModel::build(&root);
if let Some(def_range) = definition_local_range(&root, &model, offset) {
let location = Location {
uri: uri.clone(),
range: text_range_to_lsp_range(&line_index, def_range),
};
return Some(GotoDefinitionResponse::Scalar(location));
}
let token = pick_name_token(&root, offset)?;
if token.kind() != SyntaxKind::IDENT
|| matches!(
symbol_query_at(&root, offset),
Some(SymbolQuery::Namespaced { .. })
)
{
return None;
}
let name = SmolStr::new(token.text());
let locations = salsa::Cancelled::catch(AssertUnwindSafe(|| {
snapshot
.workspace_def_sites(&name)
.into_iter()
.filter(|(def_path, _)| def_path != path)
.filter_map(|(def_path, range)| {
let file = snapshot.lookup_file(&def_path)?;
let target_uri = uri::from_path(&def_path)?;
let target_index = LineIndex::new(snapshot.file_text(file));
Some(Location {
uri: target_uri,
range: text_range_to_lsp_range(&target_index, range),
})
})
.collect::<Vec<_>>()
}))
.unwrap_or_default();
match locations.len() {
0 => None,
1 => Some(GotoDefinitionResponse::Scalar(
locations.into_iter().next()?,
)),
_ => Some(GotoDefinitionResponse::Array(locations)),
}
}
fn references_via_db(
snapshot: &Analysis,
path: &Path,
uri: &Uri,
text: &str,
position: Position,
include_declaration: bool,
) -> Option<Vec<Location>> {
let line_index = LineIndex::new(text);
let offset = TextSize::new(line_index.position_to_byte(position).min(text.len()) as u32);
let root = parse(text).cst;
let model = SemanticModel::build(&root);
if let Some((target, occ)) = local_occurrences(&root, &model, offset) {
let mut locations: Vec<Location> = occ
.reads
.iter()
.map(|range| Location {
uri: uri.clone(),
range: text_range_to_lsp_range(&line_index, *range),
})
.collect();
if include_declaration {
locations.push(Location {
uri: uri.clone(),
range: text_range_to_lsp_range(&line_index, occ.def),
});
}
if model.binding_is_file_scope(target.binding) {
let cross = salsa::Cancelled::catch(AssertUnwindSafe(|| {
snapshot
.workspace_read_sites(&target.name)
.into_iter()
.filter(|(read_path, _)| read_path != path)
.filter_map(|(read_path, range)| location_in(snapshot, &read_path, range))
.collect::<Vec<_>>()
}))
.unwrap_or_default();
locations.extend(cross);
}
return (!locations.is_empty()).then_some(locations);
}
let token = pick_name_token(&root, offset)?;
if token.kind() != SyntaxKind::IDENT
|| matches!(
symbol_query_at(&root, offset),
Some(SymbolQuery::Namespaced { .. })
)
{
return None;
}
let name = SmolStr::new(token.text());
let locations = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let mut locs: Vec<Location> = snapshot
.workspace_read_sites(&name)
.into_iter()
.filter_map(|(read_path, range)| location_in(snapshot, &read_path, range))
.collect();
if include_declaration {
locs.extend(
snapshot
.workspace_def_sites(&name)
.into_iter()
.filter_map(|(def_path, range)| location_in(snapshot, &def_path, range)),
);
}
locs
}))
.unwrap_or_default();
(!locations.is_empty()).then_some(locations)
}
fn location_in(snapshot: &Analysis, path: &Path, range: TextRange) -> Option<Location> {
let file = snapshot.lookup_file(path)?;
let target_uri = uri::from_path(path)?;
let target_index = LineIndex::new(snapshot.file_text(file));
Some(Location {
uri: target_uri,
range: text_range_to_lsp_range(&target_index, range),
})
}
pub fn compute_code_actions(
text: &str,
path: &std::path::Path,
lint: &LintConfig,
uri: &Uri,
range: Range,
) -> CodeActionResponse {
let diagnostics = crate::linter::check_document(path, text, lint).unwrap_or_default();
code_actions_from_findings(&diagnostics, text, uri, range)
}
fn code_actions_from_findings(
findings: &[Diagnostic],
text: &str,
uri: &Uri,
range: Range,
) -> CodeActionResponse {
let line_index = LineIndex::new(text);
findings
.iter()
.filter_map(|d| {
let fix = d.fix.as_ref()?;
let diag_range = Range {
start: line_index.byte_to_position(u32::from(d.range.start()) as usize),
end: line_index.byte_to_position(u32::from(d.range.end()) as usize),
};
if !ranges_overlap(diag_range, range) {
return None;
}
let edit = TextEdit {
range: Range {
start: line_index.byte_to_position(fix.start),
end: line_index.byte_to_position(fix.end),
},
new_text: fix.content.clone(),
};
let mut changes = HashMap::new();
changes.insert(uri.clone(), vec![edit]);
Some(CodeActionOrCommand::CodeAction(CodeAction {
title: fix.description.clone(),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![to_lsp_diagnostic(d, &line_index)]),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}))
})
.collect()
}
fn ranges_overlap(a: Range, b: Range) -> bool {
!(position_lt(a.end, b.start) || position_lt(b.end, a.start))
}
fn position_lt(a: Position, b: Position) -> bool {
(a.line, a.character) < (b.line, b.character)
}
#[derive(Debug)]
enum ConfigResolveError {
NonFileUri,
NoParentDirectory,
Config(String),
}
impl std::fmt::Display for ConfigResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NonFileUri => write!(f, "URI is not a file:// URI"),
Self::NoParentDirectory => write!(f, "file has no parent directory"),
Self::Config(msg) => f.write_str(msg),
}
}
}
pub fn compute_format_edits(text: &str, style: FormatStyle) -> Option<Vec<TextEdit>> {
let formatted = format_with_style(text, style).ok()?;
Some(edits_for_formatted(text, formatted))
}
pub fn compute_format_range_edits(
text: &str,
range: Range,
style: FormatStyle,
) -> Option<Vec<TextEdit>> {
let parsed = parse(text);
if !parsed.diagnostics.is_empty() {
return None;
}
let line_index = LineIndex::new(text);
let text_range = lsp_range_to_text_range(&line_index, range);
match format_range(&parsed.cst, text_range, style).ok()? {
Some(formatted) => Some(range_edits(&line_index, text, formatted)),
None => Some(Vec::new()),
}
}
fn text_range_to_lsp_range(line_index: &LineIndex, range: TextRange) -> Range {
Range {
start: line_index.byte_to_position(u32::from(range.start()) as usize),
end: line_index.byte_to_position(u32::from(range.end()) as usize),
}
}
fn lsp_range_to_text_range(line_index: &LineIndex, range: Range) -> TextRange {
let start = line_index.position_to_byte(range.start);
let end = line_index.position_to_byte(range.end);
TextRange::new(
TextSize::new(start as u32),
TextSize::new(start.max(end) as u32),
)
}
fn range_edits(
line_index: &LineIndex,
text: &str,
formatted: crate::formatter::RangeFormatted,
) -> Vec<TextEdit> {
let start = usize::from(formatted.range.start());
let end = usize::from(formatted.range.end());
if text.get(start..end) == Some(formatted.text.as_str()) {
return Vec::new();
}
vec![TextEdit {
range: Range {
start: line_index.byte_to_position(start),
end: line_index.byte_to_position(end),
},
new_text: formatted.text,
}]
}
fn edits_for_formatted(text: &str, formatted: String) -> Vec<TextEdit> {
if formatted == text {
return Vec::new();
}
let line_index = LineIndex::new(text);
let end = line_index.byte_to_position(text.len());
vec![TextEdit {
range: Range {
start: Position::new(0, 0),
end,
},
new_text: formatted,
}]
}
fn to_lsp_diagnostic(d: &Diagnostic, idx: &LineIndex) -> LspDiagnostic {
let start = idx.byte_to_position(u32::from(d.range.start()) as usize);
let end = idx.byte_to_position(u32::from(d.range.end()) as usize);
let severity = match d.severity {
Severity::Error => DiagnosticSeverity::ERROR,
Severity::Warning => DiagnosticSeverity::WARNING,
Severity::Info => DiagnosticSeverity::INFORMATION,
Severity::Hint => DiagnosticSeverity::HINT,
};
LspDiagnostic {
range: Range { start, end },
severity: Some(severity),
code: Some(NumberOrString::String(d.rule.to_string())),
source: Some("arity".to_string()),
message: d.message.body.clone(),
..Default::default()
}
}
enum SymbolQuery {
Namespaced {
package: SmolStr,
name: SmolStr,
range: TextRange,
},
Bare {
name: SmolStr,
range: TextRange,
},
}
#[derive(Debug, Clone)]
pub struct RenameAnchor {
node_ptr: NodePtr,
offset_in_node: u32,
text: String,
}
#[derive(Debug, Clone)]
pub struct PreparedRename {
pub range: Range,
pub placeholder: String,
pub anchor: RenameAnchor,
}
struct LocalTarget {
binding: BindingId,
range: TextRange,
name: SmolStr,
}
fn resolve_local_target(
root: &SyntaxNode,
model: &SemanticModel,
offset: TextSize,
) -> Option<LocalTarget> {
let token = pick_name_token(root, offset)?;
if token.kind() != SyntaxKind::IDENT {
return None;
}
let range = token.text_range();
let name = SmolStr::new(token.text());
if let Some(ident) = model.idents().iter().find(|i| i.range == range) {
let binding = model.resolve_local(ident)?;
return Some(LocalTarget {
binding,
range,
name,
});
}
let idx = model.bindings().iter().position(|b| b.def_range == range)?;
Some(LocalTarget {
binding: BindingId::from_index(idx),
range,
name,
})
}
pub fn compute_prepare_rename(text: &str, offset: usize) -> Option<PreparedRename> {
let parsed = parse(text);
if !parsed.diagnostics.is_empty() {
return None;
}
let root = parsed.cst;
let model = SemanticModel::build(&root);
let off = TextSize::new(offset.min(text.len()) as u32);
let target = resolve_local_target(&root, &model, off)?;
let token = pick_name_token(&root, off)?;
let node = token.parent()?;
let offset_in_node = u32::from(target.range.start()) - u32::from(node.text_range().start());
let line_index = LineIndex::new(text);
Some(PreparedRename {
range: Range {
start: line_index.byte_to_position(usize::from(target.range.start())),
end: line_index.byte_to_position(usize::from(target.range.end())),
},
placeholder: target.name.to_string(),
anchor: RenameAnchor {
node_ptr: NodePtr::from_node(&node),
offset_in_node,
text: text.to_string(),
},
})
}
pub fn compute_rename(text: &str, offset: usize, new_name: &str) -> Option<Vec<TextEdit>> {
if !is_syntactic_r_name(new_name) {
return None;
}
let parsed = parse(text);
if !parsed.diagnostics.is_empty() {
return None;
}
let root = parsed.cst;
let model = SemanticModel::build(&root);
let off = TextSize::new(offset.min(text.len()) as u32);
let target = resolve_local_target(&root, &model, off)?;
let line_index = LineIndex::new(text);
let edits = rename_edits(&model, &target, new_name, &line_index);
(!edits.is_empty()).then_some(edits)
}
pub fn compute_definition(text: &str, offset: usize) -> Option<TextRange> {
let root = parse(text).cst;
let model = SemanticModel::build(&root);
let off = TextSize::new(offset.min(text.len()) as u32);
definition_local_range(&root, &model, off)
}
pub fn compute_references(
text: &str,
offset: usize,
include_declaration: bool,
) -> Option<Vec<TextRange>> {
let root = parse(text).cst;
let model = SemanticModel::build(&root);
let off = TextSize::new(offset.min(text.len()) as u32);
let (_, occ) = local_occurrences(&root, &model, off)?;
let mut ranges = occ.reads;
if include_declaration {
ranges.push(occ.def);
}
ranges.sort_by_key(|range| range.start());
ranges.dedup();
Some(ranges)
}
pub fn compute_document_highlights(
text: &str,
offset: usize,
) -> Option<Vec<(TextRange, DocumentHighlightKind)>> {
let root = parse(text).cst;
let model = SemanticModel::build(&root);
let off = TextSize::new(offset.min(text.len()) as u32);
let (_, occ) = local_occurrences(&root, &model, off)?;
let mut highlights: Vec<(TextRange, DocumentHighlightKind)> =
Vec::with_capacity(occ.reads.len() + 1);
highlights.push((occ.def, DocumentHighlightKind::WRITE));
highlights.extend(
occ.reads
.into_iter()
.map(|range| (range, DocumentHighlightKind::READ)),
);
highlights.sort_by_key(|(range, _)| range.start());
Some(highlights)
}
pub fn compute_document_symbols(text: &str) -> Vec<DocumentSymbol> {
let root = parse(text).cst;
let model = SemanticModel::build(&root);
let bindings: HashMap<TextRange, SmolStr> = model
.bindings()
.iter()
.filter(|b| matches!(b.kind, BindingKind::Local | BindingKind::Implicit))
.map(|b| (b.def_range, b.name.clone()))
.collect();
let line_index = LineIndex::new(text);
let mut symbols = Vec::new();
collect_document_symbols(&root, &bindings, &line_index, &mut symbols);
symbols
}
fn collect_document_symbols(
node: &SyntaxNode,
bindings: &HashMap<TextRange, SmolStr>,
line_index: &LineIndex,
out: &mut Vec<DocumentSymbol>,
) {
for child in node.children() {
match document_symbol_for(&child, bindings, line_index) {
Some(symbol) => out.push(symbol),
None => collect_document_symbols(&child, bindings, line_index, out),
}
}
}
#[expect(deprecated, reason = "DocumentSymbol::deprecated is a required field")]
fn document_symbol_for(
node: &SyntaxNode,
bindings: &HashMap<TextRange, SmolStr>,
line_index: &LineIndex,
) -> Option<DocumentSymbol> {
let assign = AssignmentExpr::cast(node.clone())?;
let name_token = assign.target_name_token()?;
let name = bindings.get(&name_token.text_range())?;
let value = assign.value_element();
let is_function =
matches!(&value, Some(NodeOrToken::Node(n)) if FunctionExpr::can_cast(n.kind()));
let mut children = Vec::new();
if let Some(NodeOrToken::Node(value_node)) = &value {
collect_document_symbols(value_node, bindings, line_index, &mut children);
}
Some(DocumentSymbol {
name: name.to_string(),
detail: None,
kind: if is_function {
LspSymbolKind::FUNCTION
} else {
LspSymbolKind::VARIABLE
},
tags: None,
deprecated: None,
range: text_range_to_lsp_range(line_index, node.text_range()),
selection_range: text_range_to_lsp_range(line_index, name_token.text_range()),
children: (!children.is_empty()).then_some(children),
})
}
fn definition_local_range(
root: &SyntaxNode,
model: &SemanticModel,
offset: TextSize,
) -> Option<TextRange> {
let target = resolve_local_target(root, model, offset)?;
Some(model.binding(target.binding).def_range)
}
struct LocalOccurrences {
def: TextRange,
reads: Vec<TextRange>,
}
fn local_occurrences(
root: &SyntaxNode,
model: &SemanticModel,
offset: TextSize,
) -> Option<(LocalTarget, LocalOccurrences)> {
let target = resolve_local_target(root, model, offset)?;
let mut reads: Vec<TextRange> = model
.idents()
.iter()
.filter(|ident| {
ident.name == target.name && model.resolve_local(ident) == Some(target.binding)
})
.map(|ident| ident.range)
.collect();
reads.sort_by_key(|range| range.start());
reads.dedup();
let def = model.binding(target.binding).def_range;
Some((target, LocalOccurrences { def, reads }))
}
pub fn compute_rename_with_anchor(
current_text: &str,
anchor: &RenameAnchor,
new_name: &str,
) -> Option<Vec<TextEdit>> {
let offset = rename_cursor_offset(current_text, anchor)?;
compute_rename(current_text, offset, new_name)
}
fn rename_cursor_offset(current_text: &str, anchor: &RenameAnchor) -> Option<usize> {
let root = parse(current_text).cst;
let node = if current_text == anchor.text {
anchor.node_ptr.try_to_node(&root)?
} else {
let edit = diff_edit(&anchor.text, current_text);
let mapped = map_range_through_edit(anchor.node_ptr.text_range(), &edit)?;
anchor.node_ptr.with_range(mapped).try_to_node(&root)?
};
Some(usize::from(node.text_range().start()) + anchor.offset_in_node as usize)
}
fn rename_edits(
model: &SemanticModel,
target: &LocalTarget,
new_name: &str,
line_index: &LineIndex,
) -> Vec<TextEdit> {
let mut ranges: Vec<TextRange> = vec![model.binding(target.binding).def_range];
for ident in model.idents() {
if ident.name == target.name && model.resolve_local(ident) == Some(target.binding) {
ranges.push(ident.range);
}
}
ranges.sort_by_key(|range| range.start());
ranges.dedup();
ranges
.into_iter()
.map(|range| TextEdit {
range: Range {
start: line_index.byte_to_position(usize::from(range.start())),
end: line_index.byte_to_position(usize::from(range.end())),
},
new_text: new_name.to_string(),
})
.collect()
}
fn is_syntactic_r_name(name: &str) -> bool {
let Some(first) = name.chars().next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '.') {
return false;
}
if first == '.' && matches!(name.as_bytes().get(1), Some(b) if b.is_ascii_digit()) {
return false;
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_')
{
return false;
}
!is_reserved_word(name)
}
fn is_reserved_word(name: &str) -> bool {
matches!(
name,
"if" | "else"
| "repeat"
| "while"
| "function"
| "for"
| "in"
| "next"
| "break"
| "TRUE"
| "FALSE"
| "NULL"
| "Inf"
| "NaN"
| "NA"
| "NA_integer_"
| "NA_real_"
| "NA_character_"
| "NA_complex_"
)
}
pub fn compute_hover(text: &str, offset: usize, indexed: &IndexedProvider) -> Option<Hover> {
let root = parse(text).cst;
let line_index = LineIndex::new(text);
hover_from_node(&root, &line_index, offset.min(text.len()), indexed)
}
fn hover_from_node(
root: &SyntaxNode,
line_index: &LineIndex,
offset: usize,
indexed: &IndexedProvider,
) -> Option<Hover> {
let offset = TextSize::new(offset as u32);
let query = symbol_query_at(root, offset)?;
let (package, entry, range) = resolve_query(query, root, indexed)?;
let lsp_range = Range {
start: line_index.byte_to_position(u32::from(range.start()) as usize),
end: line_index.byte_to_position(u32::from(range.end()) as usize),
};
Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: render_hover_markdown(&package, entry),
}),
range: Some(lsp_range),
})
}
fn symbol_query_at(root: &SyntaxNode, offset: TextSize) -> Option<SymbolQuery> {
let token = pick_name_token(root, offset)?;
for ancestor in token.parent_ancestors() {
if ancestor.kind() == SyntaxKind::BINARY_EXPR
&& let Some(access) = BinaryExpr::cast(ancestor).and_then(|b| b.namespace_access())
&& access.name_token == token
{
return Some(SymbolQuery::Namespaced {
package: access.package,
name: access.name,
range: token.text_range(),
});
}
}
Some(SymbolQuery::Bare {
name: SmolStr::new(token.text()),
range: token.text_range(),
})
}
fn pick_name_token(root: &SyntaxNode, offset: TextSize) -> Option<SyntaxToken<RLanguage>> {
let is_name = |k: SyntaxKind| matches!(k, SyntaxKind::IDENT | SyntaxKind::USER_OP);
match root.token_at_offset(offset) {
TokenAtOffset::None => None,
TokenAtOffset::Single(t) => is_name(t.kind()).then_some(t),
TokenAtOffset::Between(left, right) => {
if is_name(right.kind()) {
Some(right)
} else if is_name(left.kind()) {
Some(left)
} else {
None
}
}
}
}
fn resolve_query<'p>(
query: SymbolQuery,
root: &SyntaxNode,
indexed: &'p IndexedProvider,
) -> Option<(SmolStr, &'p SymbolEntry, TextRange)> {
match query {
SymbolQuery::Namespaced {
package,
name,
range,
} => {
let entry = indexed.lookup(&package, &name)?;
Some((package, entry, range))
}
SymbolQuery::Bare { name, range } => {
let model = SemanticModel::build(root);
let package = match resolve_origin(indexed, &name, model.loaded_packages()) {
PackageOrigin::Resolved(p) => p,
PackageOrigin::Ambiguous(mut v) => v.pop()?,
PackageOrigin::Unknown => return None,
};
let entry = indexed.lookup(&package, &name)?;
Some((package, entry, range))
}
}
}
fn render_hover_markdown(package: &str, entry: &SymbolEntry) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let usage = entry.help.as_ref().and_then(|h| h.usage.as_deref());
let signature = usage.map(str::to_string).or_else(|| {
entry.formals.as_ref().map(|formals| {
let args = formals
.iter()
.map(format_formal)
.collect::<Vec<_>>()
.join(", ");
format!("{}({})", entry.name, args)
})
});
if let Some(signature) = signature {
let _ = write!(out, "```r\n{signature}\n```\n");
}
let kind = match entry.kind {
SymbolKind::Function => "function",
SymbolKind::Data => "data",
SymbolKind::Other => "object",
};
let _ = write!(out, "`{package}::{}` · {kind}", entry.name);
if let Some(help) = &entry.help {
if let Some(title) = &help.title {
let _ = write!(out, "\n\n**{title}**");
}
if let Some(description) = &help.description {
let _ = write!(out, "\n\n{description}");
}
if !help.arguments.is_empty() {
out.push_str("\n\n**Arguments**\n");
for arg in &help.arguments {
let _ = write!(out, "\n- `{}` — {}", arg.name, arg.description);
}
}
}
out
}
fn format_formal(formal: &Formal) -> String {
match &formal.default {
Some(default) => format!("{} = {}", formal.name, default),
None => formal.name.to_string(),
}
}
mod uri {
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use lsp_types::Uri;
pub fn to_path(uri: &Uri) -> Option<PathBuf> {
let scheme = uri.scheme()?;
if !scheme.as_str().eq_ignore_ascii_case("file") {
return None;
}
let decoded = uri
.path()
.as_estr()
.decode()
.into_string_lossy()
.into_owned();
Some(from_uri_path(&decoded))
}
#[cfg(windows)]
fn from_uri_path(p: &str) -> PathBuf {
PathBuf::from(p.strip_prefix('/').unwrap_or(p).replace('/', "\\"))
}
#[cfg(not(windows))]
fn from_uri_path(p: &str) -> PathBuf {
PathBuf::from(p)
}
pub fn from_path(path: &Path) -> Option<Uri> {
let s = path.to_str()?;
let mut out = String::from("file://");
encode_into(&to_uri_path(s), &mut out);
Uri::from_str(&out).ok()
}
#[cfg(windows)]
fn to_uri_path(s: &str) -> String {
format!("/{}", s.replace('\\', "/"))
}
#[cfg(not(windows))]
fn to_uri_path(s: &str) -> String {
s.to_string()
}
fn encode_into(s: &str, out: &mut String) {
for &b in s.as_bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/' | b':') {
out.push(b as char);
} else {
out.push('%');
out.push(hex(b >> 4));
out.push(hex(b & 0x0f));
}
}
}
fn hex(n: u8) -> char {
char::from(if n < 10 { b'0' + n } else { b'A' + (n - 10) })
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn pos(line: u32, character: u32) -> Position {
Position { line, character }
}
fn full_line_0() -> Range {
Range {
start: pos(0, 0),
end: pos(0, 100),
}
}
fn test_path() -> &'static Path {
if cfg!(windows) {
Path::new(r"C:\tmp\t.R")
} else {
Path::new("/tmp/t.R")
}
}
fn test_uri() -> Uri {
uri::from_path(test_path()).expect("valid file uri")
}
fn uri_named(name: &str) -> Uri {
let path = if cfg!(windows) {
PathBuf::from(format!(r"C:\tmp\{name}"))
} else {
PathBuf::from(format!("/tmp/{name}"))
};
uri::from_path(&path).expect("valid file uri")
}
#[test]
fn decide_idle_starts_a_pending_uri() {
let a = uri_named("a.R");
let pending = HashMap::from([(a.clone(), 1)]);
assert_eq!(decide(None, &pending), DispatchAction::Start(a));
}
#[test]
fn decide_idle_empty_queue_waits() {
let pending: HashMap<Uri, i32> = HashMap::new();
assert_eq!(decide(None, &pending), DispatchAction::Wait);
}
#[test]
fn decide_supersedes_same_uri_newer_version() {
let a = uri_named("a.R");
let pending = HashMap::from([(a.clone(), 2)]);
assert_eq!(
decide(Some((&a, 1)), &pending),
DispatchAction::SupersedeAndStart(a)
);
}
#[test]
fn decide_waits_when_pending_same_uri_not_newer() {
let a = uri_named("a.R");
let pending = HashMap::from([(a.clone(), 1)]);
assert_eq!(decide(Some((&a, 1)), &pending), DispatchAction::Wait);
}
#[test]
fn decide_never_cancels_a_different_uri() {
let a = uri_named("a.R");
let pending = HashMap::from([(uri_named("b.R"), 5), (uri_named("c.R"), 9)]);
assert_eq!(decide(Some((&a, 1)), &pending), DispatchAction::Wait);
}
#[test]
fn decide_relint_all_drains_one_uri_at_a_time() {
let (a, b, c) = (uri_named("a.R"), uri_named("b.R"), uri_named("c.R"));
let mut pending = HashMap::from([(a.clone(), 1), (b.clone(), 1), (c.clone(), 1)]);
let DispatchAction::Start(first) = decide(None, &pending) else {
panic!("expected Start");
};
assert!(pending.contains_key(&first));
pending.remove(&first);
let action = decide(Some((&first, 1)), &pending);
assert_eq!(action, DispatchAction::Wait);
let mut started = vec![first];
while !pending.is_empty() {
let DispatchAction::Start(next) = decide(None, &pending) else {
panic!("expected Start");
};
pending.remove(&next);
started.push(next);
}
started.sort_by_key(|u| u.as_str().to_string());
assert_eq!(started, {
let mut all = vec![a, b, c];
all.sort_by_key(|u| u.as_str().to_string());
all
});
}
#[test]
fn editor_settings_parse_bare_camel_case_object() {
let value = serde_json::json!({ "lineWidth": 100, "indentWidth": 4 });
let settings = EditorSettings::from_client_value(&value);
assert_eq!(settings.line_width, Some(100));
assert_eq!(settings.indent_width, Some(4));
}
#[test]
fn editor_settings_parse_namespaced_under_arity() {
let value = serde_json::json!({
"arity": { "lineWidth": 120 },
"editor": { "tabSize": 8 },
});
let settings = EditorSettings::from_client_value(&value);
assert_eq!(settings.line_width, Some(120));
assert_eq!(settings.indent_width, None);
}
#[test]
fn editor_settings_ignore_unknown_and_malformed() {
let unknown = serde_json::json!({ "bogus": true });
assert_eq!(
EditorSettings::from_client_value(&unknown),
EditorSettings::default()
);
let malformed = serde_json::json!("not an object");
assert_eq!(
EditorSettings::from_client_value(&malformed),
EditorSettings::default()
);
}
#[test]
fn editor_settings_to_style_layers_over_defaults() {
let settings = EditorSettings {
line_width: Some(100),
indent_width: None,
};
let style = settings.to_format_style();
assert_eq!(style.line_width, 100);
assert_eq!(style.indent_width, FormatStyle::default().indent_width);
}
#[test]
fn editor_settings_out_of_range_fall_back_to_defaults() {
let settings = EditorSettings {
line_width: Some(0),
indent_width: Some(4),
};
assert_eq!(settings.to_format_style(), FormatStyle::default());
}
#[test]
fn config_file_wins_over_editor_settings() {
let mut config = Config::default();
config.format.line_width = 70;
let editor = EditorSettings {
line_width: Some(120),
indent_width: Some(8),
};
let style = resolve_format_style(&config, true, &editor);
assert_eq!(style.line_width, 70);
assert_eq!(style.indent_width, FormatStyle::default().indent_width);
let fallback = resolve_format_style(&Config::default(), false, &editor);
assert_eq!(fallback.line_width, 120);
assert_eq!(fallback.indent_width, 8);
}
#[test]
fn uri_path_round_trips() {
let uri = test_uri();
assert_eq!(uri::to_path(&uri).as_deref(), Some(test_path()));
}
#[test]
fn code_action_offers_quickfix_for_diagnostic_in_range() {
let src = "if (x = 1) print(x)\n";
let actions = compute_code_actions(
src,
test_path(),
&LintConfig::default(),
&test_uri(),
full_line_0(),
);
let CodeActionOrCommand::CodeAction(action) = actions
.iter()
.find(|a| matches!(a, CodeActionOrCommand::CodeAction(a) if a.title.contains("==")))
.expect("an `=` → `==` quick-fix")
else {
unreachable!()
};
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
let changes = action
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.expect("workspace edit with changes");
let edits = changes.get(&test_uri()).expect("edits for our uri");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "==");
assert_eq!(edits[0].range.start.line, 0);
}
#[test]
fn code_action_empty_when_range_misses_diagnostics() {
let src = "if (x = 1) print(x)\n";
let far = Range {
start: pos(5, 0),
end: pos(5, 0),
};
let actions =
compute_code_actions(src, test_path(), &LintConfig::default(), &test_uri(), far);
assert!(actions.is_empty(), "expected no actions, got {actions:?}");
}
fn indexed_dplyr() -> IndexedProvider {
use crate::rindex::schema::{PackageIndex, SCHEMA_VERSION, SymbolEntry, SymbolKind};
let idx = PackageIndex {
schema_version: SCHEMA_VERSION,
package: "dplyr".into(),
version: "1.0".into(),
lib_path: "/lib".into(),
r_version: None,
harvested_at: 0,
symbols: vec![SymbolEntry {
name: "across".into(),
kind: SymbolKind::Function,
exported: true,
formals: None,
help: None,
}],
};
IndexedProvider::from_indices([idx])
}
#[test]
fn packages_to_build_skips_indexed_and_dedups_attempts() {
let mut attempts = HashSet::new();
let indexed = indexed_dplyr();
let src = "library(dplyr)\nlibrary(stats)\nlibrary(notarealpkg)\n";
let first = packages_to_build(&mut attempts, &indexed, src);
assert_eq!(first, vec![SmolStr::new("notarealpkg")]);
let second = packages_to_build(&mut attempts, &indexed, src);
assert!(second.is_empty(), "expected no re-attempt, got {second:?}");
}
fn documented_dplyr() -> IndexedProvider {
use crate::rindex::schema::{Formal, HelpArg, HelpDoc, PackageIndex, SCHEMA_VERSION};
let idx = PackageIndex {
schema_version: SCHEMA_VERSION,
package: "dplyr".into(),
version: "1.0".into(),
lib_path: "/lib".into(),
r_version: None,
harvested_at: 0,
symbols: vec![SymbolEntry {
name: "across".into(),
kind: SymbolKind::Function,
exported: true,
formals: Some(vec![
Formal {
name: ".cols".into(),
default: Some("everything()".into()),
},
Formal {
name: ".fns".into(),
default: None,
},
]),
help: Some(HelpDoc {
title: Some("Apply a function across columns".into()),
description: Some("Apply one or more functions to a set of columns.".into()),
usage: Some("across(.cols, .fns)".into()),
arguments: vec![HelpArg {
name: ".cols".into(),
description: "Columns to transform.".into(),
}],
}),
}],
};
IndexedProvider::from_indices([idx])
}
fn offset_of(src: &str, needle: &str) -> usize {
src.find(needle).expect("needle present") + 1
}
fn hover_markdown(src: &str, needle: &str, indexed: &IndexedProvider) -> Option<String> {
compute_hover(src, offset_of(src, needle), indexed).map(|h| match h.contents {
HoverContents::Markup(m) => m.value,
other => panic!("expected markup, got {other:?}"),
})
}
#[test]
fn hover_resolves_bare_name_via_attached_package() {
let provider = documented_dplyr();
let src = "library(dplyr)\nacross(a, mean)\n";
let md = hover_markdown(src, "across(a", &provider).expect("hover for across");
assert!(md.contains("across(.cols, .fns)"), "signature: {md}");
assert!(md.contains("dplyr::across"), "origin: {md}");
assert!(
md.contains("Apply a function across columns"),
"title: {md}"
);
assert!(md.contains("`.cols`"), "arguments: {md}");
}
#[test]
fn hover_resolves_namespaced_without_library() {
let provider = documented_dplyr();
let src = "dplyr::across(a)\n";
let md = hover_markdown(src, "across", &provider).expect("hover for dplyr::across");
assert!(md.contains("dplyr::across"));
}
#[test]
fn hover_none_for_unknown_and_non_name() {
let provider = documented_dplyr();
assert!(compute_hover("bogus()\n", 1, &provider).is_none());
let src = "across (a)\n";
assert!(compute_hover(src, offset_of(src, " (a"), &provider).is_none());
}
#[test]
fn format_via_db_matches_compute_and_falls_back() {
use crate::incremental::IncrementalDatabase;
let style = FormatStyle::default();
let path = test_path();
let buffer = "x<-f(1 )\n";
let expected = compute_format_edits(buffer, style);
assert!(
matches!(&expected, Some(edits) if !edits.is_empty()),
"fixture must require reformatting"
);
let mut db = IncrementalDatabase::default();
db.upsert_file(path, buffer.to_string());
let snapshot = db.snapshot();
assert_eq!(
format_edits_via_db(&snapshot, path, buffer, style),
expected,
"cached-tree format must match the re-parse path"
);
let mut stale = IncrementalDatabase::default();
stale.upsert_file(path, "y <- 1\n".to_string());
assert_eq!(
format_edits_via_db(&stale.snapshot(), path, buffer, style),
expected,
"version skew must fall back to the buffer text"
);
let empty = IncrementalDatabase::default();
assert_eq!(
format_edits_via_db(&empty.snapshot(), path, buffer, style),
expected,
"untracked path must fall back to the buffer text"
);
}
#[test]
fn hover_via_db_matches_compute() {
use crate::incremental::IncrementalDatabase;
let path = test_path();
let src = "library(dplyr)\nacross(a, mean)\n";
let position = pos(1, 0);
let mut db = IncrementalDatabase::default();
db.set_library_index(documented_dplyr());
db.upsert_file(path, src.to_string());
let hover =
hover_via_db(&db.snapshot(), path, src, position).expect("hover for across via db");
let md = match hover.contents {
HoverContents::Markup(m) => m.value,
other => panic!("expected markup, got {other:?}"),
};
assert!(md.contains("dplyr::across"), "origin: {md}");
let mut empty = IncrementalDatabase::default();
empty.set_library_index(documented_dplyr());
assert!(
hover_via_db(&empty.snapshot(), path, src, position).is_some(),
"fallback hover should resolve too"
);
}
#[test]
fn workspace_roots_parses_folders_then_root_uri() {
let uri = test_uri();
let want = vec![test_path().to_path_buf()];
let params = serde_json::json!({
"workspaceFolders": [{ "uri": uri.as_str(), "name": "w" }],
});
assert_eq!(workspace_roots_from_params(¶ms), want);
let params = serde_json::json!({ "rootUri": uri.as_str() });
assert_eq!(workspace_roots_from_params(¶ms), want);
assert!(workspace_roots_from_params(&serde_json::json!({})).is_empty());
}
}