#![allow(clippy::mutable_key_type)]
mod folding;
mod task_pool;
use std::collections::{HashMap, HashSet};
use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use std::thread::JoinHandle;
use crossbeam_channel::{Receiver, Sender, select, unbounded};
use lsp_server::{Connection, ErrorCode, Message, Notification, Request, RequestId, Response};
use lsp_types::notification::{
DidChangeConfiguration, DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument,
Notification as _, PublishDiagnostics,
};
use lsp_types::request::{
Completion, DocumentSymbolRequest, FoldingRangeRequest, Formatting, GotoDefinition,
PrepareRenameRequest, References, Rename, Request as _,
};
use lsp_types::{
CompletionItem, CompletionItemKind, CompletionList, CompletionOptions, CompletionParams,
CompletionResponse, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams,
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
DocumentFormattingParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
FoldingRange, FoldingRangeParams, FoldingRangeProviderCapability, FormattingOptions,
GotoDefinitionParams, GotoDefinitionResponse, InsertTextFormat, Location, NumberOrString,
OneOf, Position, PrepareRenameResponse, PublishDiagnosticsParams, Range, ReferenceParams,
RenameOptions, RenameParams, ServerCapabilities, SymbolKind, TextDocumentContentChangeEvent,
TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, Uri,
WorkspaceEdit,
};
use rowan::{TextRange, TextSize};
use salsa::Database as _;
use serde::Deserialize;
use smol_str::SmolStr;
use crate::bib::completion::{
BibCandidateKind, BibCompletionCandidate, bib_candidates, classify_bib_context,
};
use crate::bib::outline::{BibOutlineItem, outline as bib_outline};
use crate::bib::semantic::Model as BibModel;
use crate::bib::{
format_node as bib_format_node, format_with_style as bib_format_with_style, parse as bib_parse,
};
use crate::completion::{CandidateKind, CompletionCandidate, CompletionContext, FileArgKind};
use crate::file_discovery::{ExcludeFilter, FileKind, collect_lint_files, file_kind_or_tex};
use crate::formatter::{FormatStyle, format_node_with_signatures, format_with_style_flavored};
use crate::incremental::{Analysis, IncrementalDatabase};
use crate::linter::{Severity, lint_document};
use crate::parser::parse;
use crate::project::{ProjectMember, ResolvedCitations, ResolvedLabels};
use crate::semantic::{OutlineItem, OutlineSymbol, SemanticModel, SignatureDb, outline};
use crate::syntax::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();
serve(connection)?;
io_threads.join()?;
Ok(())
}
pub fn serve(connection: Connection) -> Result<(), DynError> {
let capabilities = serde_json::to_value(server_capabilities())?;
let init_params = connection.initialize(capabilities)?;
main_loop(connection, init_params)
}
fn server_capabilities() -> ServerCapabilities {
ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::INCREMENTAL,
)),
document_formatting_provider: Some(OneOf::Left(true)),
document_symbol_provider: Some(OneOf::Left(true)),
definition_provider: Some(OneOf::Left(true)),
references_provider: Some(OneOf::Left(true)),
rename_provider: Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: Default::default(),
})),
folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec![
"\\".to_owned(),
"{".to_owned(),
"/".to_owned(),
"@".to_owned(),
]),
resolve_provider: Some(false),
..Default::default()
}),
..Default::default()
}
}
struct Document {
text: String,
version: i32,
}
struct GlobalState {
documents: HashMap<Uri, Document>,
editor_settings: EditorSettings,
}
#[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("badness")
.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 style = FormatStyle::default();
if let Some(width) = self.line_width {
style.line_width = width as usize;
}
if let Some(width) = self.indent_width {
style.indent_width = width as usize;
}
style
}
}
fn resolve_style(settings: &EditorSettings, options: &FormattingOptions) -> FormatStyle {
let mut style = settings.to_format_style();
if options.tab_size > 0 {
style.indent_width = options.tab_size as usize;
}
style
}
enum WorkerJob {
Edit {
uri: Uri,
path: PathBuf,
text: String,
version: i32,
kind: FileKind,
},
Close { path: PathBuf },
Format {
id: RequestId,
path: PathBuf,
text: String,
style: FormatStyle,
kind: FileKind,
},
Symbols {
id: RequestId,
path: PathBuf,
text: String,
kind: FileKind,
},
FoldingRange {
id: RequestId,
path: PathBuf,
text: String,
kind: FileKind,
},
Completion {
id: RequestId,
uri: Uri,
text: String,
position: Position,
},
GotoDefinition {
id: RequestId,
path: PathBuf,
text: String,
position: Position,
},
References {
id: RequestId,
path: PathBuf,
text: String,
position: Position,
include_declaration: bool,
},
PrepareRename {
id: RequestId,
path: PathBuf,
text: String,
position: Position,
},
Rename {
id: RequestId,
path: PathBuf,
text: String,
position: Position,
new_name: String,
},
}
enum Outbound {
Diagnostics {
uri: Uri,
version: i32,
diags: Vec<Diagnostic>,
},
Response(Response),
RelintAll,
}
fn uri_to_path(uri: &Uri) -> PathBuf {
uri_to_fs_path(uri).unwrap_or_else(|| PathBuf::from(uri.as_str()))
}
fn file_kind_for(path: &Path) -> FileKind {
file_kind_or_tex(path)
}
fn members_of(snapshot: &Analysis) -> Vec<ProjectMember> {
snapshot
.tracked_files()
.into_iter()
.map(|(path, file)| {
let kind = file_kind_for(&path);
ProjectMember { file, path, kind }
})
.collect()
}
fn main_loop(connection: Connection, init_params: serde_json::Value) -> Result<(), DynError> {
let editor_settings = init_params
.get("initializationOptions")
.map(EditorSettings::from_client_value)
.unwrap_or_default();
let mut state = GlobalState {
documents: HashMap::new(),
editor_settings,
};
let read_pool = TaskPool::new("badness-lsp-read", read_pool_size());
let (job_tx, job_rx) = unbounded::<WorkerJob>();
let (out_tx, out_rx) = unbounded::<Outbound>();
let worker = spawn_worker(job_rx, out_tx, read_pool.spawner());
loop {
select! {
recv(connection.receiver) -> msg => {
let Ok(msg) = msg else { break };
match msg {
Message::Request(req) => {
if connection.handle_shutdown(&req)? {
break;
}
match req.method.as_str() {
Formatting::METHOD => on_formatting(&connection, &state, &job_tx, req),
DocumentSymbolRequest::METHOD => {
on_document_symbol(&connection, &state, &job_tx, req)
}
Completion::METHOD => on_completion(&connection, &state, &job_tx, req),
GotoDefinition::METHOD => {
on_goto_definition(&connection, &state, &job_tx, req)
}
References::METHOD => on_references(&connection, &state, &job_tx, req),
PrepareRenameRequest::METHOD => {
on_prepare_rename(&connection, &state, &job_tx, req)
}
Rename::METHOD => on_rename(&connection, &state, &job_tx, req),
FoldingRangeRequest::METHOD => {
on_folding_range(&connection, &state, &job_tx, req)
}
_ => respond_unhandled(&connection, req),
}
}
Message::Notification(not) => {
on_notification(&connection, &mut state, &job_tx, not);
}
Message::Response(_) => {}
}
}
recv(out_rx) -> outbound => {
let Ok(outbound) = outbound else { continue };
forward_outbound(&connection, &state, &job_tx, outbound);
}
}
}
drop(job_tx);
let _ = worker.join();
Ok(())
}
fn on_notification(
connection: &Connection,
state: &mut GlobalState,
job_tx: &Sender<WorkerJob>,
not: Notification,
) {
match not.method.as_str() {
DidOpenTextDocument::METHOD => {
let Ok(params) = not.extract::<DidOpenTextDocumentParams>(DidOpenTextDocument::METHOD)
else {
return;
};
let doc = params.text_document;
let uri = doc.uri;
state.documents.insert(
uri.clone(),
Document {
text: doc.text.clone(),
version: doc.version,
},
);
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
let _ = job_tx.send(WorkerJob::Edit {
path,
uri,
text: doc.text,
version: doc.version,
kind,
});
}
DidChangeTextDocument::METHOD => {
let Ok(params) =
not.extract::<DidChangeTextDocumentParams>(DidChangeTextDocument::METHOD)
else {
return;
};
let uri = params.text_document.uri;
let version = params.text_document.version;
let Some(doc) = state.documents.get_mut(&uri) else {
return;
};
apply_content_changes(&mut doc.text, params.content_changes);
doc.version = version;
let text = doc.text.clone();
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
let _ = job_tx.send(WorkerJob::Edit {
path,
uri,
text,
version,
kind,
});
}
DidCloseTextDocument::METHOD => {
let Ok(params) =
not.extract::<DidCloseTextDocumentParams>(DidCloseTextDocument::METHOD)
else {
return;
};
let uri = params.text_document.uri;
state.documents.remove(&uri);
let _ = job_tx.send(WorkerJob::Close {
path: uri_to_path(&uri),
});
send_diagnostics(connection, uri, Vec::new(), None);
}
DidChangeConfiguration::METHOD => {
if let Ok(params) =
not.extract::<DidChangeConfigurationParams>(DidChangeConfiguration::METHOD)
{
state.editor_settings = EditorSettings::from_client_value(¶ms.settings);
}
}
_ => {}
}
}
fn apply_content_changes(text: &mut String, changes: Vec<TextDocumentContentChangeEvent>) {
for change in changes {
match change.range {
None => *text = change.text,
Some(range) => {
let idx = LineIndex::new(text);
let start = idx.offset_at(text, range.start.line, range.start.character);
let end = idx.offset_at(text, range.end.line, range.end.character);
let (start, end) = (start.min(end), start.max(end));
text.replace_range(start..end, &change.text);
}
}
}
}
fn on_formatting(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<DocumentFormattingParams>(Formatting::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid formatting params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document.uri;
let Some(doc) = state.documents.get(&uri) else {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
};
let mut style = resolve_style(&state.editor_settings, ¶ms.options);
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
style.wrap = kind.default_wrap();
let _ = job_tx.send(WorkerJob::Format {
id,
path,
text: doc.text.clone(),
style,
kind,
});
}
fn on_document_symbol(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<DocumentSymbolParams>(DocumentSymbolRequest::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid documentSymbol params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document.uri;
let Some(doc) = state.documents.get(&uri) else {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
};
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
let _ = job_tx.send(WorkerJob::Symbols {
id,
path,
text: doc.text.clone(),
kind,
});
}
fn on_folding_range(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<FoldingRangeParams>(FoldingRangeRequest::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid foldingRange params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document.uri;
let Some(doc) = state.documents.get(&uri) else {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
};
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
let _ = job_tx.send(WorkerJob::FoldingRange {
id,
path,
text: doc.text.clone(),
kind,
});
}
fn on_completion(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<CompletionParams>(Completion::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid completion params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let Some(doc) = state.documents.get(&uri) else {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
};
let _ = job_tx.send(WorkerJob::Completion {
id,
uri,
text: doc.text.clone(),
position,
});
}
fn on_goto_definition(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<GotoDefinitionParams>(GotoDefinition::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid definition params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
let path = uri_to_path(&uri);
let Some(doc) = state.documents.get(&uri) else {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
};
if file_kind_for(&path) == FileKind::Bib {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
}
let _ = job_tx.send(WorkerJob::GotoDefinition {
id,
path,
text: doc.text.clone(),
position,
});
}
fn on_references(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<ReferenceParams>(References::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid references params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
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 path = uri_to_path(&uri);
let Some(doc) = state.documents.get(&uri) else {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
};
let _ = job_tx.send(WorkerJob::References {
id,
path,
text: doc.text.clone(),
position,
include_declaration,
});
}
fn on_prepare_rename(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<TextDocumentPositionParams>(PrepareRenameRequest::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid prepareRename params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document.uri;
let position = params.position;
let path = uri_to_path(&uri);
let Some(doc) = state.documents.get(&uri) else {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
};
let _ = job_tx.send(WorkerJob::PrepareRename {
id,
path,
text: doc.text.clone(),
position,
});
}
fn on_rename(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<RenameParams>(Rename::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid rename params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let new_name = params.new_name;
let path = uri_to_path(&uri);
let Some(doc) = state.documents.get(&uri) else {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
};
let _ = job_tx.send(WorkerJob::Rename {
id,
path,
text: doc.text.clone(),
position,
new_name,
});
}
fn forward_outbound(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
outbound: Outbound,
) {
match outbound {
Outbound::Diagnostics {
uri,
version,
diags,
} => {
if state
.documents
.get(&uri)
.is_some_and(|doc| doc.version == version)
{
send_diagnostics(connection, uri, diags, Some(version));
}
}
Outbound::Response(resp) => {
let _ = connection.sender.send(Message::Response(resp));
}
Outbound::RelintAll => {
for (uri, doc) in &state.documents {
let path = uri_to_path(uri);
let kind = file_kind_for(&path);
let _ = job_tx.send(WorkerJob::Edit {
uri: uri.clone(),
path,
text: doc.text.clone(),
version: doc.version,
kind,
});
}
}
}
}
struct AnalyzeDone {
uri: Uri,
version: i32,
}
struct InflightAnalyze {
uri: Uri,
version: i32,
}
struct AnalyzeRequest {
uri: Uri,
path: PathBuf,
version: i32,
kind: FileKind,
}
#[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
}
}
}
}
fn spawn_worker(
job_rx: Receiver<WorkerJob>,
out_tx: Sender<Outbound>,
read_spawner: Spawner,
) -> JoinHandle<()> {
let (done_tx, done_rx) = unbounded::<AnalyzeDone>();
std::thread::Builder::new()
.name("badness-lsp-worker".to_owned())
.spawn(move || {
let mut worker = Worker {
db: IncrementalDatabase::default(),
out_tx,
done_tx,
read_spawner,
inflight: None,
pending: HashMap::new(),
seeded_dirs: HashSet::new(),
};
worker.run(&job_rx, &done_rx);
})
.expect("spawn LSP worker thread")
}
struct Worker {
db: IncrementalDatabase,
out_tx: Sender<Outbound>,
done_tx: Sender<AnalyzeDone>,
read_spawner: Spawner,
inflight: Option<InflightAnalyze>,
pending: HashMap<Uri, AnalyzeRequest>,
seeded_dirs: HashSet<PathBuf>,
}
impl Worker {
fn run(&mut self, job_rx: &Receiver<WorkerJob>, done_rx: &Receiver<AnalyzeDone>) {
loop {
select! {
recv(job_rx) -> job => {
let Ok(job) = job else { break }; self.handle_job(job);
while let Ok(j) = job_rx.try_recv() {
self.handle_job(j);
}
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();
}
}
}
}
fn handle_job(&mut self, job: WorkerJob) {
match job {
WorkerJob::Edit {
uri,
path,
text,
version,
kind,
} => {
self.db.upsert_file(&path, text);
if self.seed_dir(&path) {
let _ = self.out_tx.send(Outbound::RelintAll);
}
self.enqueue(AnalyzeRequest {
uri,
path,
version,
kind,
});
}
WorkerJob::Close { path } => {
self.db.remove_file(&path);
}
WorkerJob::Format {
id,
path,
text,
style,
kind,
} => {
let snapshot = self.db.snapshot();
let out_tx = self.out_tx.clone();
self.read_spawner
.spawn(move || run_format(&snapshot, id, &path, &text, style, kind, &out_tx));
}
WorkerJob::Symbols {
id,
path,
text,
kind,
} => {
let snapshot = self.db.snapshot();
let out_tx = self.out_tx.clone();
self.read_spawner
.spawn(move || run_symbols(&snapshot, id, &path, &text, kind, &out_tx));
}
WorkerJob::FoldingRange {
id,
path,
text,
kind,
} => {
let snapshot = self.db.snapshot();
let out_tx = self.out_tx.clone();
self.read_spawner
.spawn(move || run_folding(&snapshot, id, &path, &text, kind, &out_tx));
}
WorkerJob::Completion {
id,
uri,
text,
position,
} => {
let snapshot = self.db.snapshot();
let members = self.project_members();
let out_tx = self.out_tx.clone();
self.read_spawner.spawn(move || {
run_completion(&snapshot, id, &uri, &text, position, members, &out_tx)
});
}
WorkerJob::GotoDefinition {
id,
path,
text,
position,
} => {
let snapshot = self.db.snapshot();
let members = self.project_members();
let out_tx = self.out_tx.clone();
self.read_spawner.spawn(move || {
run_goto_definition(&snapshot, id, &path, &text, position, members, &out_tx)
});
}
WorkerJob::References {
id,
path,
text,
position,
include_declaration,
} => {
let snapshot = self.db.snapshot();
let members = self.project_members();
let out_tx = self.out_tx.clone();
self.read_spawner.spawn(move || {
run_references(
&snapshot,
id,
&path,
&text,
position,
members,
include_declaration,
&out_tx,
)
});
}
WorkerJob::PrepareRename {
id,
path,
text,
position,
} => {
let snapshot = self.db.snapshot();
let out_tx = self.out_tx.clone();
self.read_spawner.spawn(move || {
run_prepare_rename(&snapshot, id, &path, &text, position, &out_tx)
});
}
WorkerJob::Rename {
id,
path,
text,
position,
new_name,
} => {
let snapshot = self.db.snapshot();
let members = self.project_members();
let out_tx = self.out_tx.clone();
self.read_spawner.spawn(move || {
run_rename(
&snapshot, id, &path, &text, position, &new_name, members, &out_tx,
)
});
}
}
}
fn seed_dir(&mut self, path: &Path) -> bool {
if !path.is_file() {
return false;
}
let Some(dir) = path.parent() else {
return false;
};
if dir.parent().is_none() {
return false;
}
let dir = dir.to_path_buf();
if !self.seeded_dirs.insert(dir.clone()) {
return false; }
let Ok(files) = collect_lint_files(&[dir], &ExcludeFilter::none()) else {
return false;
};
let mut grew = false;
for (sibling, _kind) in files {
if self.db.lookup_file(&sibling).is_some() {
continue; }
if let Ok(text) = std::fs::read_to_string(&sibling) {
self.db.upsert_file(&sibling, text);
grew = true;
}
}
grew
}
fn project_members(&self) -> Vec<ProjectMember> {
self.db
.tracked_files()
.into_iter()
.map(|(path, file)| {
let kind = file_kind_for(&path);
ProjectMember { file, path, kind }
})
.collect()
}
fn enqueue(&mut self, req: AnalyzeRequest) {
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) {
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;
};
self.start_analyze(req);
}
fn start_analyze(&mut self, req: AnalyzeRequest) {
let snapshot = self.db.snapshot();
let members = self.project_members();
let out_tx = self.out_tx.clone();
let done_tx = self.done_tx.clone();
let AnalyzeRequest {
uri,
path,
version,
kind,
} = req;
self.inflight = Some(InflightAnalyze {
uri: uri.clone(),
version,
});
self.read_spawner.spawn(move || {
let result = salsa::Cancelled::catch(AssertUnwindSafe(|| match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
analyze_tex(&snapshot, &path, members)
}
FileKind::Bib => analyze_bib(&snapshot, &path),
}));
if let Ok(Some(diags)) = result {
let _ = out_tx.send(Outbound::Diagnostics {
uri: uri.clone(),
version,
diags,
});
}
drop(snapshot);
let _ = done_tx.send(AnalyzeDone { uri, version });
});
}
}
fn analyze_tex(
snapshot: &Analysis,
path: &Path,
members: Vec<ProjectMember>,
) -> Option<Vec<Diagnostic>> {
let file = snapshot.lookup_file(path)?;
let text = snapshot.file_text(file).to_owned();
let lint_path = snapshot.file_path(file).to_path_buf();
let idx = LineIndex::new(&text);
let mut diags: Vec<Diagnostic> = snapshot
.parse_diagnostics(file)
.iter()
.map(|d| Diagnostic {
range: byte_range_to_lsp(&idx, &text, d.start, d.end),
severity: Some(DiagnosticSeverity::ERROR),
source: Some("badness".to_owned()),
message: d.message.clone(),
..Default::default()
})
.collect();
let root = snapshot.parsed_tree(file);
let model = snapshot.semantic_model(file);
let (resolution, citations) = snapshot.resolve_project(members);
for d in lint_document(&lint_path, &root, model, Some(resolution), Some(citations)) {
diags.push(lint_to_lsp(&idx, &text, d));
}
Some(diags)
}
fn analyze_bib(snapshot: &Analysis, path: &Path) -> Option<Vec<Diagnostic>> {
let file = snapshot.lookup_file(path)?;
let text = snapshot.file_text(file).to_owned();
let idx = LineIndex::new(&text);
let mut diags: Vec<Diagnostic> = snapshot
.bib_parse_diagnostics(file)
.iter()
.map(|d| Diagnostic {
range: byte_range_to_lsp(&idx, &text, d.start, d.end),
severity: Some(DiagnosticSeverity::ERROR),
source: Some("badness".to_owned()),
message: d.message.clone(),
..Default::default()
})
.collect();
let root = snapshot.parsed_bib_tree(file);
let model = snapshot.bib_semantic_model(file);
for d in crate::bib::linter::lint_document(path, &root, model) {
diags.push(lint_to_lsp(&idx, &text, d));
}
Some(diags)
}
fn lint_to_lsp(idx: &LineIndex, text: &str, d: crate::linter::Diagnostic) -> Diagnostic {
Diagnostic {
range: byte_range_to_lsp(idx, text, d.start, d.end),
severity: Some(severity_to_lsp(d.severity)),
code: Some(NumberOrString::String(d.rule.to_owned())),
source: Some("badness".to_owned()),
message: d.message,
..Default::default()
}
}
fn run_format(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
style: FormatStyle,
kind: FileKind,
out_tx: &Sender<Outbound>,
) {
let result = match compute_format(snapshot, path, text, style, kind) {
Some(edit) => serde_json::to_value(vec![edit]).unwrap_or(serde_json::Value::Null),
None => serde_json::Value::Null,
};
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
fn compute_format(
snapshot: &Analysis,
path: &Path,
text: &str,
style: FormatStyle,
kind: FileKind,
) -> Option<TextEdit> {
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
if !snapshot.parse_diagnostics(file).is_empty() {
return Some(None);
}
let root = snapshot.parsed_tree(file);
let sigs = snapshot.scope_signatures(members_of(snapshot), file);
Some(format_node_with_signatures(&root, style, sigs).ok())
}
FileKind::Bib => {
if !snapshot.bib_parse_diagnostics(file).is_empty() {
return Some(None);
}
let root = snapshot.parsed_bib_tree(file);
Some(bib_format_node(&root, style).ok())
}
}
}));
let formatted = match cached {
Ok(Some(opt)) => opt,
Ok(None) | Err(_) => match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
format_with_style_flavored(text, style, kind.lex_config()).ok()
}
FileKind::Bib => bib_format_with_style(text, style).ok(),
},
}?;
if formatted == text {
return None;
}
let idx = LineIndex::new(text);
let (end_line, end_col) = idx.utf16_position(text, text.len());
Some(TextEdit {
range: Range {
start: Position::new(0, 0),
end: Position::new(end_line, end_col),
},
new_text: formatted,
})
}
fn run_symbols(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
kind: FileKind,
out_tx: &Sender<Outbound>,
) {
let symbols = match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
compute_symbols(snapshot, path, text)
}
FileKind::Bib => compute_bib_symbols(snapshot, path, text),
};
let result = serde_json::to_value(DocumentSymbolResponse::Nested(symbols))
.unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
fn compute_symbols(snapshot: &Analysis, path: &Path, text: &str) -> Vec<DocumentSymbol> {
let idx = LineIndex::new(text);
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
Some(outline(&snapshot.parsed_tree(file)))
}));
let items = match cached {
Ok(Some(items)) => items,
Ok(None) | Err(_) => outline(&SyntaxNode::new_root(parse(text).green)),
};
items
.iter()
.map(|item| to_document_symbol(item, &idx, text))
.collect()
}
fn compute_bib_symbols(snapshot: &Analysis, path: &Path, text: &str) -> Vec<DocumentSymbol> {
let idx = LineIndex::new(text);
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
Some(bib_outline(snapshot.bib_semantic_model(file)))
}));
let items = match cached {
Ok(Some(items)) => items,
Ok(None) | Err(_) => bib_outline(&BibModel::build(&bib_parse(text).syntax())),
};
items
.iter()
.map(|item| bib_to_document_symbol(item, &idx, text))
.collect()
}
fn run_folding(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
kind: FileKind,
out_tx: &Sender<Outbound>,
) {
let ranges = compute_folding(snapshot, path, text, kind);
let result = serde_json::to_value(ranges).unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
fn compute_folding(
snapshot: &Analysis,
path: &Path,
text: &str,
kind: FileKind,
) -> Vec<FoldingRange> {
if kind == FileKind::Bib {
return Vec::new();
}
let idx = LineIndex::new(text);
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
Some(folding::folding_ranges(
&snapshot.parsed_tree(file),
&idx,
text,
))
}));
match cached {
Ok(Some(ranges)) => ranges,
Ok(None) | Err(_) => {
folding::folding_ranges(&SyntaxNode::new_root(parse(text).green), &idx, text)
}
}
}
#[allow(deprecated)] fn to_document_symbol(item: &OutlineItem, idx: &LineIndex, text: &str) -> DocumentSymbol {
let kind = match item.kind {
OutlineSymbol::Section => SymbolKind::MODULE,
OutlineSymbol::Float => SymbolKind::OBJECT,
OutlineSymbol::Theorem => SymbolKind::CLASS,
OutlineSymbol::Label => SymbolKind::CONSTANT,
};
let range = item.range;
let selection = item.selection_range;
let children: Vec<DocumentSymbol> = item
.children
.iter()
.map(|child| to_document_symbol(child, idx, text))
.collect();
DocumentSymbol {
name: item.name.clone(),
detail: None,
kind,
tags: None,
deprecated: None,
range: byte_range_to_lsp(idx, text, range.start().into(), range.end().into()),
selection_range: byte_range_to_lsp(
idx,
text,
selection.start().into(),
selection.end().into(),
),
children: (!children.is_empty()).then_some(children),
}
}
#[allow(deprecated)] fn bib_to_document_symbol(item: &BibOutlineItem, idx: &LineIndex, text: &str) -> DocumentSymbol {
let range = item.range;
let selection = item.selection_range;
DocumentSymbol {
name: item.name.clone(),
detail: Some(item.detail.clone()),
kind: SymbolKind::CONSTANT,
tags: None,
deprecated: None,
range: byte_range_to_lsp(idx, text, range.start().into(), range.end().into()),
selection_range: byte_range_to_lsp(
idx,
text,
selection.start().into(),
selection.end().into(),
),
children: None,
}
}
fn run_completion(
snapshot: &Analysis,
id: RequestId,
uri: &Uri,
text: &str,
position: Position,
members: Vec<ProjectMember>,
out_tx: &Sender<Outbound>,
) {
let path = uri_to_path(uri);
let items = compute_completion(snapshot, uri, &path, text, position, members);
let result = serde_json::to_value(CompletionResponse::List(CompletionList {
is_incomplete: true,
items,
}))
.unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
fn compute_completion(
snapshot: &Analysis,
uri: &Uri,
path: &Path,
text: &str,
position: Position,
members: Vec<ProjectMember>,
) -> Vec<CompletionItem> {
let idx = LineIndex::new(text);
let offset = idx.offset_at(text, position.line, position.character);
if file_kind_for(path) == FileKind::Bib {
return compute_bib_completion(text, offset);
}
compute_tex_completion(snapshot, uri, path, text, offset, members)
}
fn compute_bib_completion(text: &str, offset: usize) -> Vec<CompletionItem> {
let root = bib_parse(text).syntax();
let ctx = classify_bib_context(&root, offset);
let model = BibModel::build(&root);
bib_candidates(&ctx, &model)
.into_iter()
.map(bib_candidate_to_item)
.collect()
}
enum TexCompletion {
Items(Vec<CompletionItem>),
Cite { prefix: String, lint_path: PathBuf },
}
fn compute_tex_completion(
snapshot: &Analysis,
uri: &Uri,
path: &Path,
text: &str,
offset: usize,
members: Vec<ProjectMember>,
) -> Vec<CompletionItem> {
let resolved = salsa::Cancelled::catch(AssertUnwindSafe(|| {
if let Some(file) = snapshot.lookup_file(path)
&& snapshot.file_text(file) == text
{
let root = snapshot.parsed_tree(file);
let ctx = crate::completion::classify_context(&root, offset);
return match ctx {
CompletionContext::CitationKey { prefix } => TexCompletion::Cite {
prefix,
lint_path: snapshot.file_path(file).to_path_buf(),
},
_ => TexCompletion::Items(build_completion_items(
&ctx,
snapshot.scope_signatures(members.clone(), file),
snapshot.semantic_model(file),
uri,
)),
};
}
reparse_tex_completion(text, offset, uri, path)
}))
.unwrap_or_else(|_| reparse_tex_completion(text, offset, uri, path));
match resolved {
TexCompletion::Items(items) => items,
TexCompletion::Cite { prefix, lint_path } => {
salsa::Cancelled::catch(AssertUnwindSafe(|| {
let (_, citations) = snapshot.resolve_project(members);
cite_completion_items(snapshot, citations, &lint_path, &prefix)
}))
.unwrap_or_default()
}
}
}
fn reparse_tex_completion(text: &str, offset: usize, uri: &Uri, path: &Path) -> TexCompletion {
let root = SyntaxNode::new_root(parse(text).green);
let ctx = crate::completion::classify_context(&root, offset);
match ctx {
CompletionContext::CitationKey { prefix } => TexCompletion::Cite {
prefix,
lint_path: path.to_path_buf(),
},
_ => {
let sigs = crate::semantic::scan_definitions(&root);
let model = SemanticModel::build(&root);
TexCompletion::Items(build_completion_items(&ctx, &sigs, &model, uri))
}
}
}
fn cite_completion_items(
snapshot: &Analysis,
citations: &ResolvedCitations,
lint_path: &Path,
prefix: &str,
) -> Vec<CompletionItem> {
let prefix = prefix.to_lowercase();
let mut keys: Vec<SmolStr> = Vec::new();
for bib_path in citations.bib_definers(lint_path) {
let Some(file) = snapshot.lookup_file(bib_path) else {
continue;
};
for entry in snapshot.bib_semantic_model(file).entries() {
if entry.key.to_lowercase().starts_with(&prefix) {
keys.push(entry.key.clone());
}
}
}
keys.sort();
keys.dedup();
keys.into_iter()
.map(|key| CompletionItem {
label: key.to_string(),
kind: Some(CompletionItemKind::REFERENCE),
..Default::default()
})
.collect()
}
fn bib_candidate_to_item(candidate: BibCompletionCandidate) -> CompletionItem {
let kind = match candidate.kind {
BibCandidateKind::EntryType => CompletionItemKind::STRUCT,
BibCandidateKind::FieldName => CompletionItemKind::FIELD,
BibCandidateKind::StringMacro => CompletionItemKind::CONSTANT,
};
CompletionItem {
label: candidate.label,
kind: Some(kind),
..Default::default()
}
}
fn run_goto_definition(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
position: Position,
members: Vec<ProjectMember>,
out_tx: &Sender<Outbound>,
) {
let locations = compute_goto_definition(snapshot, path, text, position, members);
let result = serde_json::to_value(GotoDefinitionResponse::Array(locations))
.unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
#[allow(clippy::too_many_arguments)]
fn run_references(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
position: Position,
members: Vec<ProjectMember>,
include_declaration: bool,
out_tx: &Sender<Outbound>,
) {
let locations =
compute_references(snapshot, path, text, position, members, include_declaration);
let result = serde_json::to_value(locations).unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
fn run_prepare_rename(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
position: Position,
out_tx: &Sender<Outbound>,
) {
let result = compute_prepare_rename(snapshot, path, text, position)
.map(|(range, placeholder)| {
serde_json::to_value(PrepareRenameResponse::RangeWithPlaceholder { range, placeholder })
.unwrap_or(serde_json::Value::Null)
})
.unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
#[allow(clippy::too_many_arguments)]
fn run_rename(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
position: Position,
new_name: &str,
members: Vec<ProjectMember>,
out_tx: &Sender<Outbound>,
) {
let result = compute_rename(snapshot, path, text, position, new_name, members)
.and_then(|edit| serde_json::to_value(edit).ok())
.unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
#[derive(Debug)]
enum CursorTarget {
Labels(Vec<SmolStr>),
Citations(Vec<SmolStr>),
}
#[derive(Debug)]
struct RenameTarget {
target: CursorTarget,
span: TextRange,
placeholder: SmolStr,
}
fn compute_goto_definition(
snapshot: &Analysis,
path: &Path,
text: &str,
position: Position,
members: Vec<ProjectMember>,
) -> Vec<Location> {
let idx = LineIndex::new(text);
let offset = idx.offset_at(text, position.line, position.character);
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let (target, lint_path) = match snapshot.lookup_file(path) {
Some(file) if snapshot.file_text(file) == text => (
reference_under_cursor(snapshot.semantic_model(file), offset),
snapshot.file_path(file).to_path_buf(),
),
_ => {
let root = SyntaxNode::new_root(parse(text).green);
let model = SemanticModel::build(&root);
(reference_under_cursor(&model, offset), path.to_path_buf())
}
};
let Some(target) = target else {
return Vec::new();
};
let (resolution, citations) = snapshot.resolve_project(members);
match target {
CursorTarget::Labels(names) => {
resolve_label_locations(snapshot, resolution, &lint_path, &names)
}
CursorTarget::Citations(names) => {
resolve_citation_locations(snapshot, citations, &lint_path, &names)
}
}
}));
cached.unwrap_or_default()
}
fn reference_under_cursor(model: &SemanticModel, offset: usize) -> Option<CursorTarget> {
let at = TextSize::new(offset as u32);
let label_names: Vec<SmolStr> = model
.refs()
.iter()
.filter(|r| r.range.contains_inclusive(at))
.map(|r| r.name.clone())
.collect();
if !label_names.is_empty() {
return Some(CursorTarget::Labels(label_names));
}
let cite_names: Vec<SmolStr> = model
.citations()
.iter()
.filter(|c| c.range.contains_inclusive(at))
.map(|c| c.name.clone())
.collect();
(!cite_names.is_empty()).then_some(CursorTarget::Citations(cite_names))
}
fn compute_references(
snapshot: &Analysis,
path: &Path,
text: &str,
position: Position,
members: Vec<ProjectMember>,
include_declaration: bool,
) -> Vec<Location> {
let idx = LineIndex::new(text);
let offset = idx.offset_at(text, position.line, position.character);
let computed = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let (resolution, citations) = snapshot.resolve_project(members);
if file_kind_for(path) == FileKind::Bib {
let Some((key, key_range)) = bib_entry_under_cursor(snapshot, path, text, offset)
else {
return Vec::new();
};
let origin = snapshot
.lookup_file(path)
.map(|file| snapshot.file_path(file).to_path_buf())
.unwrap_or_else(|| path.to_path_buf());
let decl = if include_declaration {
location_for(&origin, &idx, text, key_range)
} else {
None
};
return reference_citation_locations(
snapshot,
citations,
&origin,
FileKind::Bib,
&[key],
include_declaration,
decl,
);
}
let (target, origin) = match snapshot.lookup_file(path) {
Some(file) if snapshot.file_text(file) == text => (
references_target_under_cursor(snapshot.semantic_model(file), offset),
snapshot.file_path(file).to_path_buf(),
),
_ => {
let root = SyntaxNode::new_root(parse(text).green);
let model = SemanticModel::build(&root);
(
references_target_under_cursor(&model, offset),
path.to_path_buf(),
)
}
};
let Some(target) = target else {
return Vec::new();
};
match target {
CursorTarget::Labels(names) => reference_label_locations(
snapshot,
resolution,
&origin,
&names,
include_declaration,
),
CursorTarget::Citations(names) => reference_citation_locations(
snapshot,
citations,
&origin,
FileKind::Tex,
&names,
include_declaration,
None,
),
}
}));
computed.unwrap_or_default()
}
fn references_target_under_cursor(model: &SemanticModel, offset: usize) -> Option<CursorTarget> {
if let Some(target) = reference_under_cursor(model, offset) {
return Some(target);
}
let at = TextSize::new(offset as u32);
let label_names: Vec<SmolStr> = model
.labels()
.iter()
.filter(|l| l.range.contains_inclusive(at))
.map(|l| l.name.clone())
.collect();
(!label_names.is_empty()).then_some(CursorTarget::Labels(label_names))
}
fn rename_target_under_cursor(model: &SemanticModel, offset: usize) -> Option<RenameTarget> {
let at = TextSize::new(offset as u32);
if let Some(r) = model
.refs()
.iter()
.find(|r| r.key_range.contains_inclusive(at))
{
return Some(RenameTarget {
target: CursorTarget::Labels(vec![r.name.clone()]),
span: r.key_range,
placeholder: r.name.clone(),
});
}
if let Some(c) = model
.citations()
.iter()
.find(|c| c.key_range.contains_inclusive(at))
{
return Some(RenameTarget {
target: CursorTarget::Citations(vec![c.name.clone()]),
span: c.key_range,
placeholder: c.name.clone(),
});
}
let label = model
.labels()
.iter()
.find(|l| l.key_range.contains_inclusive(at))?;
Some(RenameTarget {
target: CursorTarget::Labels(vec![label.name.clone()]),
span: label.key_range,
placeholder: label.name.clone(),
})
}
fn compute_prepare_rename(
snapshot: &Analysis,
path: &Path,
text: &str,
position: Position,
) -> Option<(Range, String)> {
let idx = LineIndex::new(text);
let offset = idx.offset_at(text, position.line, position.character);
let computed = salsa::Cancelled::catch(AssertUnwindSafe(|| {
if file_kind_for(path) == FileKind::Bib {
let (key, key_range) = bib_entry_under_cursor(snapshot, path, text, offset)?;
return Some((lsp_range(&idx, text, key_range), key.to_string()));
}
let target = match snapshot.lookup_file(path) {
Some(file) if snapshot.file_text(file) == text => {
rename_target_under_cursor(snapshot.semantic_model(file), offset)
}
_ => {
let root = SyntaxNode::new_root(parse(text).green);
let model = SemanticModel::build(&root);
rename_target_under_cursor(&model, offset)
}
}?;
Some((
lsp_range(&idx, text, target.span),
target.placeholder.to_string(),
))
}));
computed.ok().flatten()
}
fn compute_rename(
snapshot: &Analysis,
path: &Path,
text: &str,
position: Position,
new_name: &str,
members: Vec<ProjectMember>,
) -> Option<WorkspaceEdit> {
if !is_valid_key(new_name) {
return None;
}
let idx = LineIndex::new(text);
let offset = idx.offset_at(text, position.line, position.character);
let changes = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let (resolution, citations) = snapshot.resolve_project(members);
if file_kind_for(path) == FileKind::Bib {
let Some((key, _)) = bib_entry_under_cursor(snapshot, path, text, offset) else {
return HashMap::new();
};
let origin = snapshot
.lookup_file(path)
.map(|file| snapshot.file_path(file).to_path_buf())
.unwrap_or_else(|| path.to_path_buf());
return rename_citation_edits(
snapshot,
citations,
&origin,
FileKind::Bib,
&[key],
new_name,
);
}
let (target, origin) = match snapshot.lookup_file(path) {
Some(file) if snapshot.file_text(file) == text => (
rename_target_under_cursor(snapshot.semantic_model(file), offset),
snapshot.file_path(file).to_path_buf(),
),
_ => {
let root = SyntaxNode::new_root(parse(text).green);
let model = SemanticModel::build(&root);
(
rename_target_under_cursor(&model, offset),
path.to_path_buf(),
)
}
};
let Some(target) = target else {
return HashMap::new();
};
match target.target {
CursorTarget::Labels(names) => {
rename_label_edits(snapshot, resolution, &origin, &names, new_name)
}
CursorTarget::Citations(names) => rename_citation_edits(
snapshot,
citations,
&origin,
FileKind::Tex,
&names,
new_name,
),
}
}))
.unwrap_or_default();
finalize_rename(changes)
}
fn bib_entry_under_cursor(
snapshot: &Analysis,
path: &Path,
text: &str,
offset: usize,
) -> Option<(SmolStr, TextRange)> {
let at = TextSize::new(offset as u32);
let find = |model: &BibModel| {
model
.entries()
.iter()
.find(|e| e.key_range.contains_inclusive(at))
.map(|e| (e.key.clone(), e.key_range))
};
match snapshot.lookup_file(path) {
Some(file) if snapshot.file_text(file) == text => find(snapshot.bib_semantic_model(file)),
_ => find(&BibModel::build(&bib_parse(text).syntax())),
}
}
fn reference_label_locations(
snapshot: &Analysis,
resolution: &ResolvedLabels,
origin: &Path,
names: &[SmolStr],
include_declaration: bool,
) -> Vec<Location> {
let mut locations = Vec::new();
for member in resolution.namespace_members(origin) {
let Some(file) = snapshot.lookup_file(member) else {
continue;
};
let text = snapshot.file_text(file);
let idx = LineIndex::new(text);
let model = snapshot.semantic_model(file);
for r in model.refs() {
if names.contains(&r.name) {
locations.push(location_for(member, &idx, text, r.range));
}
}
if include_declaration {
for label in model.labels() {
if names.contains(&label.name) {
locations.push(location_for(member, &idx, text, label.range));
}
}
}
}
dedup_locations(locations)
}
#[allow(clippy::too_many_arguments)]
fn reference_citation_locations(
snapshot: &Analysis,
citations: &ResolvedCitations,
origin: &Path,
kind: FileKind,
names: &[SmolStr],
include_declaration: bool,
decl_for_bib: Option<Location>,
) -> Vec<Location> {
let members = if kind == FileKind::Bib {
citations.bib_citers(origin)
} else {
citations.namespace_members(origin)
};
let mut locations = Vec::new();
for member in members {
let Some(file) = snapshot.lookup_file(member) else {
continue;
};
let text = snapshot.file_text(file);
let idx = LineIndex::new(text);
for c in snapshot.semantic_model(file).citations() {
if names.iter().any(|n| n.eq_ignore_ascii_case(&c.name)) {
locations.push(location_for(member, &idx, text, c.range));
}
}
}
let mut locations = dedup_locations(locations);
if include_declaration {
match kind {
FileKind::Bib => locations.extend(decl_for_bib),
_ => locations.extend(resolve_citation_locations(
snapshot, citations, origin, names,
)),
}
}
locations
}
fn resolve_label_locations(
snapshot: &Analysis,
resolution: &ResolvedLabels,
lint_path: &Path,
names: &[SmolStr],
) -> Vec<Location> {
let mut locations = Vec::new();
for name in names {
for def_path in resolution.definers(lint_path, name) {
let Some(file) = snapshot.lookup_file(def_path) else {
continue;
};
let text = snapshot.file_text(file);
let idx = LineIndex::new(text);
for label in snapshot.semantic_model(file).labels() {
if &label.name == name {
locations.push(location_for(def_path, &idx, text, label.range));
}
}
}
}
dedup_locations(locations)
}
fn resolve_citation_locations(
snapshot: &Analysis,
citations: &ResolvedCitations,
lint_path: &Path,
names: &[SmolStr],
) -> Vec<Location> {
let mut locations = Vec::new();
for bib_path in citations.bib_definers(lint_path) {
let Some(file) = snapshot.lookup_file(bib_path) else {
continue;
};
let text = snapshot.file_text(file);
let idx = LineIndex::new(text);
for entry in snapshot.bib_semantic_model(file).entries() {
if names.iter().any(|n| n.eq_ignore_ascii_case(&entry.key)) {
locations.push(location_for(bib_path, &idx, text, entry.key_range));
}
}
}
dedup_locations(locations)
}
fn location_for(path: &Path, idx: &LineIndex, text: &str, range: TextRange) -> Option<Location> {
Some(Location {
uri: path_to_uri(path)?,
range: byte_range_to_lsp(
idx,
text,
usize::from(range.start()),
usize::from(range.end()),
),
})
}
fn dedup_locations(locations: Vec<Option<Location>>) -> Vec<Location> {
let mut seen = HashSet::new();
locations
.into_iter()
.flatten()
.filter(|loc| seen.insert((loc.uri.as_str().to_owned(), loc.range.start, loc.range.end)))
.collect()
}
fn lsp_range(idx: &LineIndex, text: &str, range: TextRange) -> Range {
byte_range_to_lsp(
idx,
text,
usize::from(range.start()),
usize::from(range.end()),
)
}
fn rename_label_edits(
snapshot: &Analysis,
resolution: &ResolvedLabels,
origin: &Path,
names: &[SmolStr],
new_name: &str,
) -> HashMap<Uri, Vec<TextEdit>> {
let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
for member in resolution.namespace_members(origin) {
let Some(file) = snapshot.lookup_file(member) else {
continue;
};
let Some(uri) = path_to_uri(member) else {
continue;
};
let text = snapshot.file_text(file);
let idx = LineIndex::new(text);
let model = snapshot.semantic_model(file);
for r in model.refs() {
if names.contains(&r.name) {
push_edit(&mut changes, &uri, &idx, text, r.key_range, new_name);
}
}
for label in model.labels() {
if names.contains(&label.name) {
push_edit(&mut changes, &uri, &idx, text, label.key_range, new_name);
}
}
}
changes
}
fn rename_citation_edits(
snapshot: &Analysis,
citations: &ResolvedCitations,
origin: &Path,
kind: FileKind,
names: &[SmolStr],
new_name: &str,
) -> HashMap<Uri, Vec<TextEdit>> {
let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
let tex_members = if kind == FileKind::Bib {
citations.bib_citers(origin)
} else {
citations.namespace_members(origin)
};
for member in tex_members {
let Some(file) = snapshot.lookup_file(member) else {
continue;
};
let Some(uri) = path_to_uri(member) else {
continue;
};
let text = snapshot.file_text(file);
let idx = LineIndex::new(text);
for c in snapshot.semantic_model(file).citations() {
if names.iter().any(|n| n.eq_ignore_ascii_case(&c.name)) {
push_edit(&mut changes, &uri, &idx, text, c.key_range, new_name);
}
}
}
match kind {
FileKind::Bib => push_bib_entry_edits(snapshot, &mut changes, origin, names, new_name),
_ => {
for bib_path in citations.bib_definers(origin) {
push_bib_entry_edits(snapshot, &mut changes, bib_path, names, new_name);
}
}
}
changes
}
fn push_bib_entry_edits(
snapshot: &Analysis,
changes: &mut HashMap<Uri, Vec<TextEdit>>,
bib_path: &Path,
names: &[SmolStr],
new_name: &str,
) {
let Some(file) = snapshot.lookup_file(bib_path) else {
return;
};
let Some(uri) = path_to_uri(bib_path) else {
return;
};
let text = snapshot.file_text(file);
let idx = LineIndex::new(text);
for entry in snapshot.bib_semantic_model(file).entries() {
if names.iter().any(|n| n.eq_ignore_ascii_case(&entry.key)) {
push_edit(changes, &uri, &idx, text, entry.key_range, new_name);
}
}
}
fn push_edit(
changes: &mut HashMap<Uri, Vec<TextEdit>>,
uri: &Uri,
idx: &LineIndex,
text: &str,
range: TextRange,
new_name: &str,
) {
changes.entry(uri.clone()).or_default().push(TextEdit {
range: lsp_range(idx, text, range),
new_text: new_name.to_owned(),
});
}
fn finalize_rename(mut changes: HashMap<Uri, Vec<TextEdit>>) -> Option<WorkspaceEdit> {
changes.retain(|_, edits| {
edits.sort_by_key(|edit| (edit.range.start, edit.range.end));
edits.dedup();
!edits.is_empty()
});
(!changes.is_empty()).then(|| WorkspaceEdit {
changes: Some(changes),
..Default::default()
})
}
fn is_valid_key(new_name: &str) -> bool {
!new_name.trim().is_empty()
&& !new_name.chars().any(|c| {
matches!(
c,
'{' | '}' | '%' | '\\' | ',' | '#' | '~' | '$' | '^' | '&' | '\n' | '\r'
)
})
}
fn build_completion_items(
ctx: &CompletionContext,
sigs: &SignatureDb,
model: &SemanticModel,
uri: &Uri,
) -> Vec<CompletionItem> {
match ctx {
CompletionContext::FilePath { prefix, kind } => file_completion_items(uri, prefix, *kind),
CompletionContext::None => Vec::new(),
_ => crate::completion::candidates(ctx, sigs, model)
.into_iter()
.map(candidate_to_item)
.collect(),
}
}
fn candidate_to_item(candidate: CompletionCandidate) -> CompletionItem {
let kind = match candidate.kind {
CandidateKind::Command => CompletionItemKind::FUNCTION,
CandidateKind::Environment => CompletionItemKind::CLASS,
CandidateKind::Label => CompletionItemKind::REFERENCE,
};
CompletionItem {
label: candidate.label,
kind: Some(kind),
insert_text: candidate.insert_text,
insert_text_format: candidate.snippet.then_some(InsertTextFormat::SNIPPET),
..Default::default()
}
}
fn file_completion_items(uri: &Uri, prefix: &str, kind: FileArgKind) -> Vec<CompletionItem> {
let Some(doc_path) = uri_to_fs_path(uri) else {
return Vec::new();
};
let Some(doc_dir) = doc_path.parent() else {
return Vec::new();
};
let (dir_part, file_prefix) = match prefix.rfind('/') {
Some(slash) => (&prefix[..=slash], &prefix[slash + 1..]),
None => ("", prefix),
};
let Ok(entries) = std::fs::read_dir(doc_dir.join(dir_part)) else {
return Vec::new();
};
let mut items = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || !name.starts_with(file_prefix) {
continue;
}
let is_dir = entry.file_type().is_ok_and(|t| t.is_dir());
if is_dir {
items.push(CompletionItem {
label: name,
kind: Some(CompletionItemKind::FOLDER),
..Default::default()
});
} else if has_extension(&name, kind.extensions()) {
items.push(CompletionItem {
label: name,
kind: Some(CompletionItemKind::FILE),
..Default::default()
});
}
}
items
}
fn has_extension(name: &str, exts: &[&str]) -> bool {
match name.rsplit_once('.') {
Some((_, ext)) => {
let ext = ext.to_ascii_lowercase();
exts.contains(&ext.as_str())
}
None => false,
}
}
fn uri_to_fs_path(uri: &Uri) -> Option<PathBuf> {
let rest = uri.as_str().strip_prefix("file://")?;
let path = match rest.strip_prefix('/') {
Some(_) => rest,
None => rest.split_once('/').map(|(_, p)| p)?,
};
let path = percent_decode(path);
let path = strip_drive_letter_slash(&path);
Some(PathBuf::from(path))
}
fn strip_drive_letter_slash(path: &str) -> &str {
let bytes = path.as_bytes();
if let [b'/', drive, b':', rest @ ..] = bytes
&& drive.is_ascii_alphabetic()
&& matches!(rest, [] | [b'/', ..] | [b'\\', ..])
{
&path[1..]
} else {
path
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let (Some(hi), Some(lo)) = (
(bytes[i + 1] as char).to_digit(16),
(bytes[i + 2] as char).to_digit(16),
)
{
out.push((hi * 16 + lo) as u8);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn path_to_uri(path: &Path) -> Option<Uri> {
let mut s = path.display().to_string().replace('\\', "/");
if !s.starts_with('/') {
s.insert(0, '/');
}
format!("file://{}", percent_encode_path(&s)).parse().ok()
}
fn percent_encode_path(s: &str) -> String {
let mut out = String::with_capacity(s.len());
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(
char::from_digit((b >> 4) as u32, 16)
.unwrap()
.to_ascii_uppercase(),
);
out.push(
char::from_digit((b & 0xf) as u32, 16)
.unwrap()
.to_ascii_uppercase(),
);
}
}
out
}
fn send_diagnostics(
connection: &Connection,
uri: Uri,
diagnostics: Vec<Diagnostic>,
version: Option<i32>,
) {
let params = PublishDiagnosticsParams {
uri,
diagnostics,
version,
};
let not = Notification::new(PublishDiagnostics::METHOD.to_owned(), params);
let _ = connection.sender.send(Message::Notification(not));
}
fn respond_unhandled(connection: &Connection, req: Request) {
let resp = Response::new_err(
req.id,
ErrorCode::MethodNotFound as i32,
format!("unhandled request: {}", req.method),
);
let _ = connection.sender.send(Message::Response(resp));
}
fn severity_to_lsp(severity: Severity) -> DiagnosticSeverity {
match severity {
Severity::Error => DiagnosticSeverity::ERROR,
Severity::Warning => DiagnosticSeverity::WARNING,
Severity::Info => DiagnosticSeverity::INFORMATION,
Severity::Hint => DiagnosticSeverity::HINT,
}
}
fn byte_range_to_lsp(idx: &LineIndex, text: &str, start: usize, end: usize) -> Range {
let (sl, sc) = idx.utf16_position(text, start);
let (el, ec) = idx.utf16_position(text, end);
Range {
start: Position::new(sl, sc),
end: Position::new(el, ec),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn uri(s: &str) -> Uri {
s.parse().unwrap()
}
#[test]
fn uri_to_fs_path_handles_unix_and_windows() {
assert_eq!(
uri_to_fs_path(&uri("file:///tmp/dir/main.tex")),
Some(PathBuf::from("/tmp/dir/main.tex"))
);
assert_eq!(
uri_to_fs_path(&uri("file:///C:/Users/me/main.tex")),
Some(PathBuf::from("C:/Users/me/main.tex"))
);
assert_eq!(uri_to_fs_path(&uri("untitled:Untitled-1")), None);
}
#[test]
fn strip_drive_letter_slash_only_strips_real_drives() {
assert_eq!(strip_drive_letter_slash("/C:/dir"), "C:/dir");
assert_eq!(strip_drive_letter_slash("/c:"), "c:");
assert_eq!(strip_drive_letter_slash("/C:\\dir"), "C:\\dir");
assert_eq!(strip_drive_letter_slash("/tmp/dir"), "/tmp/dir");
assert_eq!(strip_drive_letter_slash("/ab:/dir"), "/ab:/dir");
}
#[test]
fn decide_starts_when_idle() {
let mut pending = HashMap::new();
pending.insert(uri("file:///a.tex"), 1);
assert_eq!(
decide(None, &pending),
DispatchAction::Start(uri("file:///a.tex"))
);
}
#[test]
fn decide_waits_when_idle_and_empty() {
assert_eq!(decide(None, &HashMap::new()), DispatchAction::Wait);
}
#[test]
fn decide_supersedes_only_on_newer_same_uri() {
let a = uri("file:///a.tex");
let mut pending = HashMap::new();
pending.insert(a.clone(), 5);
assert_eq!(
decide(Some((&a, 3)), &pending),
DispatchAction::SupersedeAndStart(a.clone())
);
assert_eq!(decide(Some((&a, 5)), &pending), DispatchAction::Wait);
}
#[test]
fn decide_never_cancels_inflight_for_a_different_uri() {
let a = uri("file:///a.tex");
let b = uri("file:///b.tex");
let mut pending = HashMap::new();
pending.insert(b, 9);
assert_eq!(decide(Some((&a, 1)), &pending), DispatchAction::Wait);
}
#[test]
fn apply_content_changes_splices_ranged_edit() {
let mut text = "hello world\n".to_owned();
let change = TextDocumentContentChangeEvent {
range: Some(Range {
start: Position::new(0, 6),
end: Position::new(0, 11),
}),
range_length: None,
text: "there".to_owned(),
};
apply_content_changes(&mut text, vec![change]);
assert_eq!(text, "hello there\n");
}
#[test]
fn apply_content_changes_full_replace_on_no_range() {
let mut text = "old".to_owned();
let change = TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "new".to_owned(),
};
apply_content_changes(&mut text, vec![change]);
assert_eq!(text, "new");
}
#[test]
fn editor_settings_namespaced_and_bare() {
let bare = serde_json::json!({ "lineWidth": 100, "indentWidth": 4 });
let s = EditorSettings::from_client_value(&bare);
assert_eq!(s.line_width, Some(100));
assert_eq!(s.indent_width, Some(4));
let style = s.to_format_style();
assert_eq!(style.line_width, 100);
assert_eq!(style.indent_width, 4);
let namespaced = serde_json::json!({ "badness": { "lineWidth": 72 } });
let s = EditorSettings::from_client_value(&namespaced);
assert_eq!(s.line_width, Some(72));
assert_eq!(s.indent_width, None);
}
fn offset_of(text: &str, needle: &str) -> usize {
text.find(needle).expect("needle present")
}
#[test]
fn reference_under_cursor_finds_ref_and_cite() {
let text = "\\label{a}\n\\ref{a}\n\\cite{k}\n";
let model = SemanticModel::build(&SyntaxNode::new_root(parse(text).green));
let at_ref = offset_of(text, "\\ref{a}") + 5; match reference_under_cursor(&model, at_ref) {
Some(CursorTarget::Labels(names)) => assert_eq!(names, vec![SmolStr::new("a")]),
other => panic!("expected a label target, got {other:?}"),
}
let at_cite = offset_of(text, "\\cite{k}") + 6; match reference_under_cursor(&model, at_cite) {
Some(CursorTarget::Citations(names)) => assert_eq!(names, vec![SmolStr::new("k")]),
other => panic!("expected a citation target, got {other:?}"),
}
let at_label = offset_of(text, "\\label{a}") + 1;
assert!(reference_under_cursor(&model, at_label).is_none());
}
#[test]
fn reference_under_cursor_splits_cref_list() {
let text = "\\cref{a,b,c}\n";
let model = SemanticModel::build(&SyntaxNode::new_root(parse(text).green));
let at = offset_of(text, "\\cref") + 2;
match reference_under_cursor(&model, at) {
Some(CursorTarget::Labels(names)) => assert_eq!(
names,
vec![SmolStr::new("a"), SmolStr::new("b"), SmolStr::new("c")]
),
other => panic!("expected a label target, got {other:?}"),
}
}
#[test]
fn path_to_uri_round_trips_through_uri_to_fs_path() {
let p = PathBuf::from("/tmp/my dir/main.tex");
let u = path_to_uri(&p).expect("a file path forms a URI");
assert!(u.as_str().contains("%20"), "got {}", u.as_str());
assert_eq!(uri_to_fs_path(&u), Some(p));
}
}