#![allow(clippy::mutable_key_type)]
mod code_action;
mod completion_resolve;
mod folding;
mod hover;
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, DidChangeWatchedFiles, DidCloseTextDocument,
DidOpenTextDocument, Notification as _, PublishDiagnostics,
};
use lsp_types::request::{
CodeActionRequest, Completion, DocumentDiagnosticRequest, DocumentHighlightRequest,
DocumentSymbolRequest, FoldingRangeRequest, Formatting, GotoDefinition, HoverRequest,
PrepareRenameRequest, RangeFormatting, References, RegisterCapability, Rename, Request as _,
ResolveCompletionItem, WorkspaceDiagnosticRefresh, WorkspaceSymbolRequest,
};
use lsp_types::{
CodeActionParams, CodeActionProviderCapability, CompletionItem, CompletionItemKind,
CompletionList, CompletionOptions, CompletionParams, CompletionResponse, Diagnostic,
DiagnosticOptions, DiagnosticServerCapabilities, DiagnosticSeverity,
DidChangeConfigurationParams, DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
DidChangeWatchedFilesRegistrationOptions, DidCloseTextDocumentParams,
DidOpenTextDocumentParams, DocumentDiagnosticParams, DocumentDiagnosticReport,
DocumentDiagnosticReportResult, DocumentFormattingParams, DocumentHighlight,
DocumentHighlightKind, DocumentHighlightParams, DocumentRangeFormattingParams, DocumentSymbol,
DocumentSymbolParams, DocumentSymbolResponse, FileChangeType, FileSystemWatcher, FoldingRange,
FoldingRangeParams, FoldingRangeProviderCapability, FullDocumentDiagnosticReport, GlobPattern,
GotoDefinitionParams, GotoDefinitionResponse, HoverParams, HoverProviderCapability,
InsertTextFormat, Location, NumberOrString, OneOf, Position, PrepareRenameResponse,
PublishDiagnosticsParams, Range, ReferenceParams, Registration, RegistrationParams,
RelatedFullDocumentDiagnosticReport, RelatedUnchangedDocumentDiagnosticReport, RenameOptions,
RenameParams, ServerCapabilities, SymbolKind, TextDocumentContentChangeEvent,
TextDocumentPositionParams, TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit,
UnchangedDocumentDiagnosticReport, Uri, WorkspaceEdit, WorkspaceSymbol, WorkspaceSymbolParams,
WorkspaceSymbolResponse,
};
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::config::{Config, LintConfig};
use crate::file_discovery::{ExcludeFilter, FileKind, collect_lint_files, file_kind_or_tex};
use crate::formatter::{
FormatStyle, WrapMode, format_node_range_with_signatures, format_node_with_signatures,
format_with_style_flavored,
};
use crate::incremental::{Analysis, IncrementalDatabase};
use crate::linter::{RuleSelection, Severity, lint_document};
use crate::parser::{parse, parse_with_flavor};
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,
)),
diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions {
identifier: Some("badness".to_owned()),
inter_file_dependencies: true,
workspace_diagnostics: false,
work_done_progress_options: Default::default(),
})),
document_formatting_provider: Some(OneOf::Left(true)),
document_range_formatting_provider: Some(OneOf::Left(true)),
document_symbol_provider: Some(OneOf::Left(true)),
workspace_symbol_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)),
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(true),
..Default::default()
}),
..Default::default()
}
}
struct Document {
text: String,
version: i32,
}
struct GlobalState {
documents: HashMap<Uri, Document>,
editor_settings: EditorSettings,
config_cache: HashMap<PathBuf, ResolvedSettings>,
supports_pull_diagnostics: bool,
supports_diagnostic_refresh: bool,
supports_dynamic_watchers: bool,
next_request_id: i32,
}
#[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
}
}
#[derive(Debug, Clone)]
struct ResolvedSettings {
style: FormatStyle,
wrap_override: Option<WrapMode>,
config_present: bool,
lint: LintConfig,
exclude: ExcludeFilter,
}
impl ResolvedSettings {
fn from_config(config: &Config, present: bool, editor: &EditorSettings) -> Self {
if present {
Self {
style: FormatStyle::from(&config.format),
wrap_override: config.format.wrap.map(Into::into),
config_present: true,
lint: config.lint.clone(),
exclude: ExcludeFilter::none(),
}
} else {
Self::from_editor(editor)
}
}
fn from_editor(editor: &EditorSettings) -> Self {
Self {
style: editor.to_format_style(),
wrap_override: None,
config_present: false,
lint: LintConfig::default(),
exclude: ExcludeFilter::none(),
}
}
fn rule_selection(&self) -> RuleSelection {
RuleSelection::resolve(self.lint.select.as_deref(), &self.lint.ignore).0
}
}
impl GlobalState {
fn resolve_settings(&mut self, uri: &Uri) -> ResolvedSettings {
let Some(anchor) = uri_to_fs_path(uri).and_then(|p| p.parent().map(Path::to_path_buf))
else {
return ResolvedSettings::from_editor(&self.editor_settings);
};
if let Some(cached) = self.config_cache.get(&anchor) {
return cached.clone();
}
let resolved = match Config::resolve(None, false, &anchor) {
Ok((config, source)) => {
let present = source.is_some();
let mut resolved =
ResolvedSettings::from_config(&config, present, &self.editor_settings);
if present {
let root = source.as_deref().and_then(Path::parent).unwrap_or(&anchor);
if let Ok(filter) = ExcludeFilter::new(root, &config.exclude_patterns(&[])) {
resolved.exclude = filter;
}
}
resolved
}
Err(_) => return ResolvedSettings::from_editor(&self.editor_settings),
};
self.config_cache.insert(anchor, resolved.clone());
resolved
}
}
enum WorkerJob {
Edit {
uri: Uri,
path: PathBuf,
text: String,
version: i32,
kind: FileKind,
rules: RuleSelection,
exclude: ExcludeFilter,
},
Close { path: PathBuf },
WatchedChange { path: PathBuf, deleted: bool },
Format {
id: RequestId,
path: PathBuf,
text: String,
style: FormatStyle,
kind: FileKind,
},
RangeFormat {
id: RequestId,
path: PathBuf,
text: String,
style: FormatStyle,
kind: FileKind,
range: Range,
},
Symbols {
id: RequestId,
path: PathBuf,
text: String,
kind: FileKind,
},
WorkspaceSymbols { id: RequestId, query: String },
FoldingRange {
id: RequestId,
path: PathBuf,
text: String,
kind: FileKind,
},
Completion {
id: RequestId,
uri: Uri,
text: String,
position: Position,
},
ResolveCompletion {
id: RequestId,
item: Box<CompletionItem>,
},
Hover {
id: RequestId,
path: PathBuf,
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,
},
DocumentHighlight {
id: RequestId,
path: PathBuf,
text: String,
position: Position,
},
PrepareRename {
id: RequestId,
path: PathBuf,
text: String,
position: Position,
},
Rename {
id: RequestId,
path: PathBuf,
text: String,
position: Position,
new_name: String,
},
Diagnostic {
id: RequestId,
path: PathBuf,
text: String,
kind: FileKind,
previous_result_id: Option<String>,
rules: RuleSelection,
},
CodeAction {
id: RequestId,
uri: Uri,
path: PathBuf,
text: String,
kind: FileKind,
range: Range,
rules: RuleSelection,
},
}
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 client_diagnostic_support(init_params: &serde_json::Value) -> (bool, bool) {
let caps = init_params.get("capabilities");
let supports_pull = caps
.and_then(|c| c.get("textDocument"))
.and_then(|t| t.get("diagnostic"))
.is_some();
let supports_refresh = caps
.and_then(|c| c.get("workspace"))
.and_then(|w| w.get("diagnostic"))
.and_then(|d| d.get("refreshSupport"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
(supports_pull, supports_refresh)
}
fn client_watched_files_support(init_params: &serde_json::Value) -> bool {
init_params
.get("capabilities")
.and_then(|c| c.get("workspace"))
.and_then(|w| w.get("didChangeWatchedFiles"))
.and_then(|d| d.get("dynamicRegistration"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
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 (supports_pull_diagnostics, supports_diagnostic_refresh) =
client_diagnostic_support(&init_params);
let supports_dynamic_watchers = client_watched_files_support(&init_params);
let mut state = GlobalState {
documents: HashMap::new(),
editor_settings,
config_cache: HashMap::new(),
supports_pull_diagnostics,
supports_diagnostic_refresh,
supports_dynamic_watchers,
next_request_id: 1,
};
register_file_watchers(&connection, &mut state);
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, &mut state, &job_tx, req)
}
RangeFormatting::METHOD => {
on_range_formatting(&connection, &mut state, &job_tx, req)
}
DocumentSymbolRequest::METHOD => {
on_document_symbol(&connection, &state, &job_tx, req)
}
WorkspaceSymbolRequest::METHOD => {
on_workspace_symbol(&connection, &job_tx, req)
}
Completion::METHOD => on_completion(&connection, &state, &job_tx, req),
ResolveCompletionItem::METHOD => {
on_completion_resolve(&connection, &job_tx, req)
}
HoverRequest::METHOD => on_hover(&connection, &state, &job_tx, req),
GotoDefinition::METHOD => {
on_goto_definition(&connection, &state, &job_tx, req)
}
References::METHOD => on_references(&connection, &state, &job_tx, req),
DocumentHighlightRequest::METHOD => {
on_document_highlight(&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)
}
CodeActionRequest::METHOD => {
on_code_action(&connection, &mut state, &job_tx, req)
}
DocumentDiagnosticRequest::METHOD => {
on_document_diagnostic(&connection, &mut 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, &mut 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 resolved = state.resolve_settings(&uri);
let _ = job_tx.send(WorkerJob::Edit {
path,
uri,
text: doc.text,
version: doc.version,
kind,
rules: resolved.rule_selection(),
exclude: resolved.exclude,
});
}
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 resolved = state.resolve_settings(&uri);
let _ = job_tx.send(WorkerJob::Edit {
path,
uri,
text,
version,
kind,
rules: resolved.rule_selection(),
exclude: resolved.exclude,
});
}
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),
});
if !state.supports_pull_diagnostics {
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);
state.config_cache.clear();
}
}
DidChangeWatchedFiles::METHOD => {
if let Ok(params) =
not.extract::<DidChangeWatchedFilesParams>(DidChangeWatchedFiles::METHOD)
{
on_watched_files_change(connection, state, job_tx, params);
}
}
_ => {}
}
}
const WATCHED_FILES_REGISTRATION_ID: &str = "badness-watched-files";
fn register_file_watchers(connection: &Connection, state: &mut GlobalState) {
if !state.supports_dynamic_watchers {
return;
}
let options = DidChangeWatchedFilesRegistrationOptions {
watchers: vec![
FileSystemWatcher {
glob_pattern: GlobPattern::String("**/*.{tex,bib}".to_owned()),
kind: None,
},
FileSystemWatcher {
glob_pattern: GlobPattern::String("**/badness.toml".to_owned()),
kind: None,
},
],
};
let registration = Registration {
id: WATCHED_FILES_REGISTRATION_ID.to_owned(),
method: DidChangeWatchedFiles::METHOD.to_owned(),
register_options: serde_json::to_value(options).ok(),
};
let params = RegistrationParams {
registrations: vec![registration],
};
let Ok(params) = serde_json::to_value(params) else {
return;
};
let id = state.next_request_id;
state.next_request_id += 1;
let _ = connection.sender.send(Message::Request(Request {
id: RequestId::from(id),
method: RegisterCapability::METHOD.to_owned(),
params,
}));
}
fn on_watched_files_change(
connection: &Connection,
state: &mut GlobalState,
job_tx: &Sender<WorkerJob>,
params: DidChangeWatchedFilesParams,
) {
let mut config_changed = false;
for event in params.changes {
let path = uri_to_path(&event.uri);
if state.documents.keys().any(|open| uri_to_path(open) == path) {
continue;
}
if path.file_name().is_some_and(|name| name == "badness.toml") {
config_changed = true;
} else {
let _ = job_tx.send(WorkerJob::WatchedChange {
path,
deleted: event.typ == FileChangeType::DELETED,
});
}
}
if config_changed {
state.config_cache.clear();
relint_all_open(connection, state, job_tx);
}
}
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: &mut 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;
if !state.documents.contains_key(&uri) {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
}
let resolved = state.resolve_settings(&uri);
let mut style = resolved.style;
if !resolved.config_present && params.options.tab_size > 0 {
style.indent_width = params.options.tab_size as usize;
}
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
style.wrap = resolved.wrap_override.unwrap_or(kind.default_wrap());
let text = state.documents[&uri].text.clone();
let _ = job_tx.send(WorkerJob::Format {
id,
path,
text,
style,
kind,
});
}
fn on_range_formatting(
connection: &Connection,
state: &mut GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<DocumentRangeFormattingParams>(RangeFormatting::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid range formatting params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document.uri;
if !state.documents.contains_key(&uri) {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
}
let resolved = state.resolve_settings(&uri);
let mut style = resolved.style;
if !resolved.config_present && params.options.tab_size > 0 {
style.indent_width = params.options.tab_size as usize;
}
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
style.wrap = resolved.wrap_override.unwrap_or(kind.default_wrap());
let text = state.documents[&uri].text.clone();
let _ = job_tx.send(WorkerJob::RangeFormat {
id,
path,
text,
style,
kind,
range: params.range,
});
}
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_workspace_symbol(connection: &Connection, job_tx: &Sender<WorkerJob>, req: Request) {
let id = req.id.clone();
let params = match req.extract::<WorkspaceSymbolParams>(WorkspaceSymbolRequest::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid workspace/symbol params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let _ = job_tx.send(WorkerJob::WorkspaceSymbols {
id,
query: params.query,
});
}
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_document_diagnostic(
connection: &Connection,
state: &mut GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<DocumentDiagnosticParams>(DocumentDiagnosticRequest::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid diagnostic params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document.uri;
if !state.supports_pull_diagnostics {
reply_empty_diagnostic_report(connection, id);
return;
}
let Some(doc) = state.documents.get(&uri) else {
reply_empty_diagnostic_report(connection, id);
return;
};
let text = doc.text.clone();
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
let rules = state.resolve_settings(&uri).rule_selection();
let _ = job_tx.send(WorkerJob::Diagnostic {
id,
path,
text,
kind,
previous_result_id: params.previous_result_id,
rules,
});
}
fn reply_empty_diagnostic_report(connection: &Connection, id: RequestId) {
let report = DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(
RelatedFullDocumentDiagnosticReport::default(),
));
let value = serde_json::to_value(report).unwrap_or(serde_json::Value::Null);
let _ = connection
.sender
.send(Message::Response(Response::new_ok(id, value)));
}
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_completion_resolve(connection: &Connection, job_tx: &Sender<WorkerJob>, req: Request) {
let id = req.id.clone();
let item = match req.extract::<CompletionItem>(ResolveCompletionItem::METHOD) {
Ok((_, item)) => item,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid completion item".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let _ = job_tx.send(WorkerJob::ResolveCompletion {
id,
item: Box::new(item),
});
}
fn on_hover(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<HoverParams>(HoverRequest::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid hover 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;
};
let _ = job_tx.send(WorkerJob::Hover {
id,
path,
text: doc.text.clone(),
position,
});
}
fn on_code_action(
connection: &Connection,
state: &mut GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<CodeActionParams>(CodeActionRequest::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid codeAction params".to_owned(),
);
let _ = connection.sender.send(Message::Response(resp));
return;
}
};
let uri = params.text_document.uri;
let range = params.range;
let Some(doc) = state.documents.get(&uri) else {
let _ = connection.sender.send(Message::Response(Response::new_ok(
id,
serde_json::Value::Null,
)));
return;
};
let text = doc.text.clone();
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
let rules = state.resolve_settings(&uri).rule_selection();
let _ = job_tx.send(WorkerJob::CodeAction {
id,
uri,
path,
text,
kind,
range,
rules,
});
}
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_document_highlight(
connection: &Connection,
state: &GlobalState,
job_tx: &Sender<WorkerJob>,
req: Request,
) {
let id = req.id.clone();
let params = match req.extract::<DocumentHighlightParams>(DocumentHighlightRequest::METHOD) {
Ok((_, params)) => params,
Err(_) => {
let resp = Response::new_err(
id,
ErrorCode::InvalidParams as i32,
"invalid document-highlight 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;
};
let _ = job_tx.send(WorkerJob::DocumentHighlight {
id,
path,
text: doc.text.clone(),
position,
});
}
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: &mut GlobalState,
job_tx: &Sender<WorkerJob>,
outbound: Outbound,
) {
match outbound {
Outbound::Diagnostics {
uri,
version,
diags,
} => {
if state.supports_pull_diagnostics {
return;
}
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 => relint_all_open(connection, state, job_tx),
}
}
fn relint_all_open(connection: &Connection, state: &mut GlobalState, job_tx: &Sender<WorkerJob>) {
if state.supports_pull_diagnostics {
if state.supports_diagnostic_refresh {
let id = state.next_request_id;
state.next_request_id += 1;
let _ = connection.sender.send(Message::Request(Request {
id: RequestId::from(id),
method: WorkspaceDiagnosticRefresh::METHOD.to_owned(),
params: serde_json::Value::Null,
}));
}
return;
}
let snapshot: Vec<(Uri, String, i32)> = state
.documents
.iter()
.map(|(uri, doc)| (uri.clone(), doc.text.clone(), doc.version))
.collect();
for (uri, text, version) in snapshot {
let path = uri_to_path(&uri);
let kind = file_kind_for(&path);
let resolved = state.resolve_settings(&uri);
let _ = job_tx.send(WorkerJob::Edit {
uri,
path,
text,
version,
kind,
rules: resolved.rule_selection(),
exclude: resolved.exclude,
});
}
}
struct AnalyzeDone {
uri: Uri,
version: i32,
}
struct InflightAnalyze {
uri: Uri,
version: i32,
}
struct AnalyzeRequest {
uri: Uri,
path: PathBuf,
version: i32,
kind: FileKind,
rules: RuleSelection,
}
#[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,
rules,
exclude,
} => {
self.db.upsert_file(&path, text);
if self.seed_dir(&path, &exclude) {
let _ = self.out_tx.send(Outbound::RelintAll);
}
self.enqueue(AnalyzeRequest {
uri,
path,
version,
kind,
rules,
});
}
WorkerJob::Close { path } => {
self.db.remove_file(&path);
}
WorkerJob::WatchedChange { path, deleted } => {
if self.apply_watched_change(&path, deleted) {
let _ = self.out_tx.send(Outbound::RelintAll);
}
}
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::RangeFormat {
id,
path,
text,
style,
kind,
range,
} => {
let snapshot = self.db.snapshot();
let out_tx = self.out_tx.clone();
self.read_spawner.spawn(move || {
run_range_format(&snapshot, id, &path, &text, style, kind, range, &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::WorkspaceSymbols { id, query } => {
let snapshot = self.db.snapshot();
let members = self.project_members();
let out_tx = self.out_tx.clone();
self.read_spawner
.spawn(move || run_workspace_symbols(&snapshot, id, &query, members, &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::ResolveCompletion { id, item } => {
let snapshot = self.db.snapshot();
let members = self.project_members();
let out_tx = self.out_tx.clone();
self.read_spawner
.spawn(move || run_completion_resolve(&snapshot, id, *item, members, &out_tx));
}
WorkerJob::Hover {
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_hover(&snapshot, id, &path, &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::DocumentHighlight {
id,
path,
text,
position,
} => {
let snapshot = self.db.snapshot();
let out_tx = self.out_tx.clone();
self.read_spawner.spawn(move || {
run_document_highlight(&snapshot, id, &path, &text, position, &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,
)
});
}
WorkerJob::Diagnostic {
id,
path,
text,
kind,
previous_result_id,
rules,
} => {
let snapshot = self.db.snapshot();
let members = self.project_members();
let out_tx = self.out_tx.clone();
self.read_spawner.spawn(move || {
run_document_diagnostic(
&snapshot,
id,
&path,
&text,
kind,
members,
previous_result_id,
&rules,
&out_tx,
)
});
}
WorkerJob::CodeAction {
id,
uri,
path,
text,
kind,
range,
rules,
} => {
let snapshot = self.db.snapshot();
let members = self.project_members();
let out_tx = self.out_tx.clone();
self.read_spawner.spawn(move || {
run_code_action(
&snapshot, id, &uri, &path, &text, kind, range, members, &rules, &out_tx,
)
});
}
}
}
fn seed_dir(&mut self, path: &Path, exclude: &ExcludeFilter) -> 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], exclude) 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 apply_watched_change(&mut self, path: &Path, deleted: bool) -> bool {
let tracked = self.db.lookup_file(path);
let in_seeded_dir = path
.parent()
.is_some_and(|dir| self.seeded_dirs.contains(dir));
if tracked.is_none() && !in_seeded_dir {
return false; }
if deleted {
return self.db.remove_file(path).is_some();
}
let Ok(text) = std::fs::read_to_string(path) else {
return false; };
if tracked.is_some_and(|file| self.db.file_text(file) == text) {
return false;
}
self.db.upsert_file(path, text);
true
}
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,
rules,
} = 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, &rules)
}
FileKind::Bib => analyze_bib(&snapshot, &path, &rules),
}));
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>,
rules: &RuleSelection,
) -> 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)) {
if rules.is_active(d.rule) {
diags.push(lint_to_lsp(&idx, &text, d));
}
}
Some(diags)
}
fn analyze_bib(snapshot: &Analysis, path: &Path, rules: &RuleSelection) -> 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) {
if rules.is_active(d.rule) {
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()
}
}
#[allow(clippy::too_many_arguments)]
fn run_document_diagnostic(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
kind: FileKind,
members: Vec<ProjectMember>,
previous_result_id: Option<String>,
rules: &RuleSelection,
out_tx: &Sender<Outbound>,
) {
let items = compute_diagnostics(snapshot, path, text, kind, members, rules);
let result_id = result_id_for(&items);
let report = if previous_result_id.as_deref() == Some(result_id.as_str()) {
DocumentDiagnosticReport::Unchanged(RelatedUnchangedDocumentDiagnosticReport {
related_documents: None,
unchanged_document_diagnostic_report: UnchangedDocumentDiagnosticReport { result_id },
})
} else {
DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
related_documents: None,
full_document_diagnostic_report: FullDocumentDiagnosticReport {
result_id: Some(result_id),
items,
},
})
};
let value = serde_json::to_value(DocumentDiagnosticReportResult::Report(report))
.unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, value)));
}
fn compute_diagnostics(
snapshot: &Analysis,
path: &Path,
text: &str,
kind: FileKind,
members: Vec<ProjectMember>,
rules: &RuleSelection,
) -> Vec<Diagnostic> {
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
analyze_tex(snapshot, path, members, rules)
}
FileKind::Bib => analyze_bib(snapshot, path, rules),
}));
match cached {
Ok(Some(items)) => items,
Ok(None) | Err(_) => fallback_diagnostics(path, text, kind, rules),
}
}
fn fallback_diagnostics(
path: &Path,
text: &str,
kind: FileKind,
rules: &RuleSelection,
) -> Vec<Diagnostic> {
let idx = LineIndex::new(text);
let mut diags: Vec<Diagnostic> = Vec::new();
match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
let parsed = parse_with_flavor(text, kind.lex_config());
for err in &parsed.errors {
diags.push(Diagnostic {
range: byte_range_to_lsp(&idx, text, err.start, err.end),
severity: Some(DiagnosticSeverity::ERROR),
source: Some("badness".to_owned()),
message: err.message.clone(),
..Default::default()
});
}
let root = parsed.syntax();
let model = SemanticModel::build(&root);
for d in lint_document(path, &root, &model, None, None) {
if rules.is_active(d.rule) {
diags.push(lint_to_lsp(&idx, text, d));
}
}
}
FileKind::Bib => {
let parsed = bib_parse(text);
for err in &parsed.errors {
diags.push(Diagnostic {
range: byte_range_to_lsp(&idx, text, err.start, err.end),
severity: Some(DiagnosticSeverity::ERROR),
source: Some("badness".to_owned()),
message: err.message.clone(),
..Default::default()
});
}
let root = parsed.syntax();
let model = BibModel::build(&root);
for d in crate::bib::linter::lint_document(path, &root, &model) {
if rules.is_active(d.rule) {
diags.push(lint_to_lsp(&idx, text, d));
}
}
}
}
diags
}
#[allow(clippy::too_many_arguments)]
fn run_code_action(
snapshot: &Analysis,
id: RequestId,
uri: &Uri,
path: &Path,
text: &str,
kind: FileKind,
range: Range,
members: Vec<ProjectMember>,
rules: &RuleSelection,
out_tx: &Sender<Outbound>,
) {
let findings = compute_lint_findings(snapshot, path, text, kind, members, rules);
let actions = code_action::code_actions_for_range(&findings, text, uri, range);
let value = serde_json::to_value(actions).unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, value)));
}
fn compute_lint_findings(
snapshot: &Analysis,
path: &Path,
text: &str,
kind: FileKind,
members: Vec<ProjectMember>,
rules: &RuleSelection,
) -> Vec<crate::linter::Diagnostic> {
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
lint_findings(snapshot, path, kind, members, rules)
}));
match cached {
Ok(Some(items)) => items,
Ok(None) | Err(_) => fallback_lint_findings(path, text, kind, rules),
}
}
fn lint_findings(
snapshot: &Analysis,
path: &Path,
kind: FileKind,
members: Vec<ProjectMember>,
rules: &RuleSelection,
) -> Option<Vec<crate::linter::Diagnostic>> {
let file = snapshot.lookup_file(path)?;
let findings = match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
let lint_path = snapshot.file_path(file).to_path_buf();
let root = snapshot.parsed_tree(file);
let model = snapshot.semantic_model(file);
let (resolution, citations) = snapshot.resolve_project(members);
lint_document(&lint_path, &root, model, Some(resolution), Some(citations))
}
FileKind::Bib => {
let root = snapshot.parsed_bib_tree(file);
let model = snapshot.bib_semantic_model(file);
crate::bib::linter::lint_document(path, &root, model)
}
};
Some(retain_active(findings, rules))
}
fn fallback_lint_findings(
path: &Path,
text: &str,
kind: FileKind,
rules: &RuleSelection,
) -> Vec<crate::linter::Diagnostic> {
let findings = match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
let parsed = parse_with_flavor(text, kind.lex_config());
let root = parsed.syntax();
let model = SemanticModel::build(&root);
lint_document(path, &root, &model, None, None)
}
FileKind::Bib => {
let parsed = bib_parse(text);
let root = parsed.syntax();
let model = BibModel::build(&root);
crate::bib::linter::lint_document(path, &root, &model)
}
};
retain_active(findings, rules)
}
fn retain_active(
mut findings: Vec<crate::linter::Diagnostic>,
rules: &RuleSelection,
) -> Vec<crate::linter::Diagnostic> {
findings.retain(|d| rules.is_active(d.rule));
findings
}
fn result_id_for(items: &[Diagnostic]) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
serde_json::to_vec(items)
.unwrap_or_default()
.hash(&mut hasher);
hasher.finish().to_string()
}
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,
})
}
#[allow(clippy::too_many_arguments)]
fn run_range_format(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
style: FormatStyle,
kind: FileKind,
range: Range,
out_tx: &Sender<Outbound>,
) {
let result = match compute_range_format(snapshot, path, text, style, kind, range) {
Some(edits) => serde_json::to_value(edits).unwrap_or(serde_json::Value::Null),
None => serde_json::Value::Null,
};
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
fn compute_range_format(
snapshot: &Analysis,
path: &Path,
text: &str,
style: FormatStyle,
kind: FileKind,
sel_range: Range,
) -> Option<Vec<TextEdit>> {
match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {}
FileKind::Bib => return None,
}
let idx = LineIndex::new(text);
let start = idx.offset_at(text, sel_range.start.line, sel_range.start.character);
let end = idx.offset_at(text, sel_range.end.line, sel_range.end.character);
let (lo, hi) = (start.min(end), start.max(end));
let sel = TextRange::new(
TextSize::new(lo.min(u32::MAX as usize) as u32),
TextSize::new(hi.min(u32::MAX as usize) as u32),
);
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 sigs = snapshot.scope_signatures(members_of(snapshot), file);
Some(Some(range_edits_for_root(
&root, text, &idx, sel, style, sigs,
)))
}));
match cached {
Ok(Some(Some(edits))) => edits,
Ok(Some(None)) => None,
Ok(None) | Err(_) => {
let parsed = parse_with_flavor(text, kind.lex_config());
if !parsed.errors.is_empty() {
return None;
}
range_edits_for_root(
&parsed.syntax(),
text,
&idx,
sel,
style,
&SignatureDb::default(),
)
}
}
}
fn range_edits_for_root(
root: &SyntaxNode,
text: &str,
idx: &LineIndex,
sel: TextRange,
style: FormatStyle,
external: &SignatureDb,
) -> Option<Vec<TextEdit>> {
let block_range = expand_to_top_level_blocks(root, sel)?;
let fragment = format_node_range_with_signatures(root, style, external, block_range).ok()?;
let base = usize::from(block_range.start());
let end = usize::from(block_range.end());
if fragment == text[base..end] {
return Some(Vec::new());
}
Some(diff_to_edits(idx, text, block_range, &fragment))
}
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, kind)
}
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,
kind: FileKind,
) -> 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_with_flavor(text, kind.lex_config()).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_workspace_symbols(
snapshot: &Analysis,
id: RequestId,
query: &str,
members: Vec<ProjectMember>,
out_tx: &Sender<Outbound>,
) {
let needle = query.to_ascii_lowercase();
let mut symbols = Vec::new();
for member in members {
if member.kind == FileKind::Bib {
continue;
}
let collected = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let text = snapshot.file_text(member.file);
let idx = LineIndex::new(text);
let items = outline(&snapshot.parsed_tree(member.file));
let container = member.path.file_stem().and_then(|s| s.to_str());
let mut file_symbols = Vec::new();
collect_workspace_symbols(
&items,
&member.path,
&idx,
text,
&needle,
container,
&mut file_symbols,
);
file_symbols
}));
if let Ok(mut file_symbols) = collected {
symbols.append(&mut file_symbols);
}
}
let result = serde_json::to_value(WorkspaceSymbolResponse::Nested(symbols))
.unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
fn collect_workspace_symbols(
items: &[OutlineItem],
path: &Path,
idx: &LineIndex,
text: &str,
needle: &str,
container: Option<&str>,
out: &mut Vec<WorkspaceSymbol>,
) {
for item in items {
let matches = needle.is_empty() || item.name.to_ascii_lowercase().contains(needle);
if matches && let Some(location) = location_for(path, idx, text, item.selection_range) {
out.push(WorkspaceSymbol {
name: item.name.clone(),
kind: outline_symbol_kind(item.kind),
tags: None,
container_name: container.map(str::to_owned),
location: OneOf::Left(location),
data: None,
});
}
collect_workspace_symbols(&item.children, path, idx, text, needle, container, out);
}
}
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)
}
}
}
fn outline_symbol_kind(kind: OutlineSymbol) -> SymbolKind {
match kind {
OutlineSymbol::Section => SymbolKind::MODULE,
OutlineSymbol::Float => SymbolKind::OBJECT,
OutlineSymbol::Theorem => SymbolKind::CLASS,
OutlineSymbol::Label => SymbolKind::CONSTANT,
OutlineSymbol::Macro => SymbolKind::FUNCTION,
OutlineSymbol::Environment => SymbolKind::INTERFACE,
}
}
#[allow(deprecated)] fn to_document_symbol(item: &OutlineItem, idx: &LineIndex, text: &str) -> DocumentSymbol {
let kind = outline_symbol_kind(item.kind);
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 run_completion_resolve(
snapshot: &Analysis,
id: RequestId,
item: CompletionItem,
members: Vec<ProjectMember>,
out_tx: &Sender<Outbound>,
) {
let resolved = salsa::Cancelled::catch(AssertUnwindSafe(|| {
completion_resolve::resolve(snapshot, item.clone(), members)
}))
.unwrap_or(item);
let result = serde_json::to_value(resolved).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 {
data: completion_resolve::CompletionResolveData::Citation {
lint_path: lint_path.to_path_buf(),
key: key.to_string(),
}
.into_value(),
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_hover(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
position: Position,
members: Vec<ProjectMember>,
out_tx: &Sender<Outbound>,
) {
let result = hover::compute_hover(snapshot, path, text, position, members)
.and_then(|hover| serde_json::to_value(hover).ok())
.unwrap_or(serde_json::Value::Null);
let _ = out_tx.send(Outbound::Response(Response::new_ok(id, result)));
}
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_document_highlight(
snapshot: &Analysis,
id: RequestId,
path: &Path,
text: &str,
position: Position,
out_tx: &Sender<Outbound>,
) {
let highlights = compute_document_highlight(snapshot, path, text, position);
let result = serde_json::to_value(highlights).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_document_highlight(
snapshot: &Analysis,
path: &Path,
text: &str,
position: Position,
) -> Vec<DocumentHighlight> {
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 {
return Vec::new();
}
let collect = |model: &SemanticModel| -> Vec<DocumentHighlight> {
let Some(target) = rename_target_under_cursor(model, offset) else {
return Vec::new();
};
let highlight = |range: TextRange, kind: DocumentHighlightKind| DocumentHighlight {
range: lsp_range(&idx, text, range),
kind: Some(kind),
};
match &target.target {
CursorTarget::Labels(_) => {
let name = &target.placeholder;
let defs = model
.labels()
.iter()
.filter(|l| &l.name == name)
.map(|l| highlight(l.key_range, DocumentHighlightKind::WRITE));
let uses = model
.refs()
.iter()
.filter(|r| &r.name == name)
.map(|r| highlight(r.key_range, DocumentHighlightKind::READ));
defs.chain(uses).collect()
}
CursorTarget::Citations(_) => {
let name = &target.placeholder;
model
.citations()
.iter()
.filter(|c| &c.name == name)
.map(|c| highlight(c.key_range, DocumentHighlightKind::READ))
.collect()
}
}
};
match snapshot.lookup_file(path) {
Some(file) if snapshot.file_text(file) == text => {
collect(snapshot.semantic_model(file))
}
_ => {
let root = SyntaxNode::new_root(parse(text).green);
collect(&SemanticModel::build(&root))
}
}
}));
computed.unwrap_or_default()
}
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(),
_ => {
let file = uri_to_fs_path(uri);
crate::completion::candidates(ctx, sigs, model)
.into_iter()
.map(|candidate| candidate_to_item(candidate, file.as_deref()))
.collect()
}
}
}
fn candidate_to_item(candidate: CompletionCandidate, file: Option<&Path>) -> CompletionItem {
let kind = match candidate.kind {
CandidateKind::Command => CompletionItemKind::FUNCTION,
CandidateKind::Environment => CompletionItemKind::CLASS,
CandidateKind::Label => CompletionItemKind::REFERENCE,
};
let data = file.and_then(|file| {
let payload = match candidate.kind {
CandidateKind::Command => completion_resolve::CompletionResolveData::Command {
name: candidate.label.clone(),
file: file.to_path_buf(),
},
CandidateKind::Environment => completion_resolve::CompletionResolveData::Environment {
name: candidate.label.clone(),
file: file.to_path_buf(),
},
CandidateKind::Label => return None,
};
payload.into_value()
});
CompletionItem {
label: candidate.label,
kind: Some(kind),
insert_text: candidate.insert_text,
insert_text_format: candidate.snippet.then_some(InsertTextFormat::SNIPPET),
data,
..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),
}
}
fn expand_to_top_level_blocks(root: &SyntaxNode, sel: TextRange) -> Option<TextRange> {
let mut acc: Option<TextRange> = None;
for child in root.children() {
let r = child.text_range();
let hit = if sel.is_empty() {
r.contains_inclusive(sel.start())
} else {
sel.start() < r.end() && r.start() < sel.end()
};
if hit {
acc = Some(acc.map_or(r, |a| a.cover(r)));
}
}
acc
}
fn diff_to_edits(
idx: &LineIndex,
text: &str,
block_range: TextRange,
fragment: &str,
) -> Vec<TextEdit> {
let base = usize::from(block_range.start());
let end = usize::from(block_range.end());
let original = &text[base..end];
let a: Vec<&str> = original.split_inclusive('\n').collect();
let b: Vec<&str> = fragment.split_inclusive('\n').collect();
let (n, m) = (a.len(), b.len());
if n.saturating_mul(m) > 4_000_000 {
return vec![TextEdit {
range: byte_range_to_lsp(idx, text, base, end),
new_text: fragment.to_owned(),
}];
}
let mut lcs = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
lcs[i][j] = if a[i] == b[j] {
lcs[i + 1][j + 1] + 1
} else {
lcs[i + 1][j].max(lcs[i][j + 1])
};
}
}
let mut edits = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut a_off = base; let mut del_start = base;
let mut del_end = base;
let mut ins = String::new();
let mut in_hunk = false;
while i < n || j < m {
if i < n && j < m && a[i] == b[j] {
if in_hunk {
edits.push(TextEdit {
range: byte_range_to_lsp(idx, text, del_start, del_end),
new_text: std::mem::take(&mut ins),
});
in_hunk = false;
}
a_off += a[i].len();
i += 1;
j += 1;
} else if j == m || (i < n && lcs[i + 1][j] >= lcs[i][j + 1]) {
if !in_hunk {
del_start = a_off;
in_hunk = true;
}
a_off += a[i].len();
del_end = a_off;
i += 1;
} else {
if !in_hunk {
del_start = a_off;
del_end = a_off;
in_hunk = true;
}
ins.push_str(b[j]);
j += 1;
}
}
if in_hunk {
edits.push(TextEdit {
range: byte_range_to_lsp(idx, text, del_start, del_end),
new_text: ins,
});
}
edits
}
#[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 state_with_editor(editor: EditorSettings) -> GlobalState {
GlobalState {
documents: HashMap::new(),
editor_settings: editor,
config_cache: HashMap::new(),
supports_pull_diagnostics: false,
supports_diagnostic_refresh: false,
supports_dynamic_watchers: false,
next_request_id: 1,
}
}
fn file_uri_in(dir: &Path) -> Uri {
path_to_uri(&dir.join("main.tex")).expect("file uri")
}
#[test]
fn resolve_settings_prefers_file_config_over_editor() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("badness.toml"),
"[format]\nline-width = 100\nindent-width = 8\n",
)
.expect("write config");
let mut state = state_with_editor(EditorSettings {
line_width: Some(40),
indent_width: Some(3),
});
let resolved = state.resolve_settings(&file_uri_in(dir.path()));
assert!(resolved.config_present);
assert_eq!(resolved.style.line_width, 100);
assert_eq!(resolved.style.indent_width, 8);
}
#[test]
fn resolve_settings_falls_back_to_editor_without_config() {
let dir = tempfile::tempdir().expect("tempdir");
let mut state = state_with_editor(EditorSettings {
line_width: Some(40),
indent_width: None,
});
let resolved = state.resolve_settings(&file_uri_in(dir.path()));
assert!(!resolved.config_present);
assert_eq!(resolved.style.line_width, 40);
assert_eq!(
resolved.style.indent_width,
FormatStyle::default().indent_width
);
assert!(resolved.wrap_override.is_none());
}
#[test]
fn resolve_settings_wrap_override_from_config() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("badness.toml"),
"[format]\nwrap = \"preserve\"\n",
)
.expect("write config");
let mut state = state_with_editor(EditorSettings::default());
let resolved = state.resolve_settings(&file_uri_in(dir.path()));
assert_eq!(resolved.wrap_override, Some(WrapMode::Preserve));
}
#[test]
fn resolve_settings_applies_lint_selection() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("badness.toml"),
"[lint]\nselect = [\"duplicate-label\"]\n",
)
.expect("write config");
let mut state = state_with_editor(EditorSettings::default());
let rules = state
.resolve_settings(&file_uri_in(dir.path()))
.rule_selection();
assert!(rules.is_active("duplicate-label"));
assert!(!rules.is_active("deprecated-command"));
assert!(rules.is_active("parse"));
}
#[test]
fn resolve_settings_builds_exclude_filter_for_sibling_discovery() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("badness.toml"), "exclude = [\"vendor/\"]\n")
.expect("write config");
std::fs::write(dir.path().join("main.tex"), "").expect("write main");
std::fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
std::fs::write(dir.path().join("vendor").join("lib.tex"), "").expect("write lib");
let mut state = state_with_editor(EditorSettings::default());
let resolved = state.resolve_settings(&file_uri_in(dir.path()));
let files =
collect_lint_files(&[dir.path().to_path_buf()], &resolved.exclude).expect("collect");
let names: Vec<_> = files
.iter()
.map(|(p, _)| p.strip_prefix(dir.path()).unwrap_or(p).to_path_buf())
.collect();
assert!(names.contains(&PathBuf::from("main.tex")));
assert!(
!names.iter().any(|p| p.starts_with("vendor")),
"excluded sibling should be pruned, got {names:?}"
);
}
#[test]
fn resolve_settings_without_config_excludes_nothing() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("main.tex"), "").expect("write main");
std::fs::create_dir(dir.path().join("vendor")).expect("mkdir vendor");
std::fs::write(dir.path().join("vendor").join("lib.tex"), "").expect("write lib");
let mut state = state_with_editor(EditorSettings::default());
let resolved = state.resolve_settings(&file_uri_in(dir.path()));
assert!(!resolved.config_present);
let files =
collect_lint_files(&[dir.path().to_path_buf()], &resolved.exclude).expect("collect");
let names: Vec<_> = files
.iter()
.map(|(p, _)| p.strip_prefix(dir.path()).unwrap_or(p).to_path_buf())
.collect();
assert!(names.contains(&PathBuf::from("main.tex")));
assert!(names.contains(&PathBuf::from("vendor/lib.tex")));
}
#[test]
fn resolve_settings_caches_by_anchor_until_cleared() {
let dir = tempfile::tempdir().expect("tempdir");
let mut state = state_with_editor(EditorSettings {
line_width: Some(40),
indent_width: None,
});
let uri = file_uri_in(dir.path());
assert_eq!(state.resolve_settings(&uri).style.line_width, 40);
state.editor_settings.line_width = Some(72);
assert_eq!(state.resolve_settings(&uri).style.line_width, 40);
state.config_cache.clear();
assert_eq!(state.resolve_settings(&uri).style.line_width, 72);
}
#[test]
fn resolve_settings_untitled_uses_editor_fallback_uncached() {
let mut state = state_with_editor(EditorSettings {
line_width: Some(55),
indent_width: None,
});
let resolved = state.resolve_settings(&uri("untitled:Untitled-1"));
assert!(!resolved.config_present);
assert_eq!(resolved.style.line_width, 55);
assert!(state.config_cache.is_empty());
}
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));
}
}