#![allow(clippy::mutable_key_type)]
use std::time::Duration;
use badness::formatter::{FormatStyle, format_with_style};
use lsp_server::{Connection, Message, Notification, Request, RequestId, Response};
use lsp_types::{
ClientCapabilities, CodeActionContext, CodeActionOrCommand, CodeActionParams,
CodeActionProviderCapability, CompletionItem, CompletionItemKind, CompletionParams,
CompletionResponse, DiagnosticClientCapabilities, DiagnosticWorkspaceClientCapabilities,
DidChangeTextDocumentParams, DidChangeWatchedFilesClientCapabilities,
DidChangeWatchedFilesParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
DocumentDiagnosticParams, DocumentDiagnosticReport, DocumentDiagnosticReportResult,
DocumentFormattingParams, DocumentHighlight, DocumentHighlightKind, DocumentHighlightParams,
DocumentRangeFormattingParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
FileChangeType, FileEvent, FoldingRange, FoldingRangeKind, FoldingRangeParams,
FoldingRangeProviderCapability, FormattingOptions, GotoDefinitionParams,
GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability,
InitializeParams, InitializeResult, InitializedParams, InsertTextFormat, Location,
NumberOrString, OneOf, PartialResultParams, Position, PrepareRenameResponse,
PublishDiagnosticsParams, Range, ReferenceContext, ReferenceParams, RegistrationParams,
RenameOptions, RenameParams, SymbolKind, TextDocumentClientCapabilities,
TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem,
TextDocumentPositionParams, TextEdit, Uri, VersionedTextDocumentIdentifier,
WorkDoneProgressParams, WorkspaceClientCapabilities, WorkspaceEdit, WorkspaceSymbolParams,
WorkspaceSymbolResponse,
};
fn path_to_file_uri(path: &std::path::Path) -> Uri {
let mut s = path.display().to_string().replace('\\', "/");
if !s.starts_with('/') {
s.insert(0, '/');
}
format!("file://{s}")
.parse()
.expect("path should form a valid file:// URI")
}
fn recv(client: &Connection) -> Message {
client
.receiver
.recv_timeout(Duration::from_secs(5))
.expect("timed out waiting for a server message")
}
fn recv_response(client: &Connection) -> Response {
match recv(client) {
Message::Response(resp) => resp,
other => panic!("expected a response, got {other:?}"),
}
}
fn recv_diagnostics(client: &Connection) -> PublishDiagnosticsParams {
match recv(client) {
Message::Notification(not) if not.method == "textDocument/publishDiagnostics" => {
serde_json::from_value(not.params).expect("valid PublishDiagnosticsParams")
}
other => panic!("expected publishDiagnostics, got {other:?}"),
}
}
fn send_request(client: &Connection, id: i32, method: &str, params: serde_json::Value) {
client
.sender
.send(Message::Request(Request {
id: RequestId::from(id),
method: method.to_owned(),
params,
}))
.unwrap();
}
fn send_notification(client: &Connection, method: &str, params: serde_json::Value) {
client
.sender
.send(Message::Notification(Notification {
method: method.to_owned(),
params,
}))
.unwrap();
}
fn start_server(
init_options: Option<serde_json::Value>,
) -> (Connection, std::thread::JoinHandle<()>) {
let (server, client) = Connection::memory();
let server_thread = std::thread::spawn(move || badness::lsp::serve(server).unwrap());
let params = InitializeParams {
initialization_options: init_options,
..Default::default()
};
send_request(
&client,
1,
"initialize",
serde_json::to_value(params).unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(1));
let init: InitializeResult = serde_json::from_value(resp.result.unwrap()).unwrap();
assert!(
init.capabilities.document_formatting_provider.is_some(),
"server must advertise documentFormattingProvider"
);
assert!(
init.capabilities
.document_range_formatting_provider
.is_some(),
"server must advertise documentRangeFormattingProvider"
);
assert!(
matches!(
init.capabilities.document_symbol_provider,
Some(OneOf::Left(true))
),
"server must advertise documentSymbolProvider"
);
assert!(
matches!(
init.capabilities.workspace_symbol_provider,
Some(OneOf::Left(true))
),
"server must advertise workspaceSymbolProvider"
);
assert!(
init.capabilities.completion_provider.is_some(),
"server must advertise completionProvider"
);
assert!(
matches!(
init.capabilities.definition_provider,
Some(OneOf::Left(true))
),
"server must advertise definitionProvider"
);
assert!(
matches!(
init.capabilities.references_provider,
Some(OneOf::Left(true))
),
"server must advertise referencesProvider"
);
assert!(
matches!(
init.capabilities.rename_provider,
Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
..
}))
),
"server must advertise renameProvider with prepare support"
);
assert!(
matches!(
init.capabilities.folding_range_provider,
Some(FoldingRangeProviderCapability::Simple(true))
),
"server must advertise foldingRangeProvider"
);
assert!(
matches!(
init.capabilities.hover_provider,
Some(HoverProviderCapability::Simple(true))
),
"server must advertise hoverProvider"
);
assert!(
matches!(
init.capabilities.code_action_provider,
Some(CodeActionProviderCapability::Simple(true))
),
"server must advertise codeActionProvider"
);
assert!(init.capabilities.text_document_sync.is_some());
assert!(
init.capabilities.diagnostic_provider.is_some(),
"server must advertise diagnosticProvider (pull diagnostics)"
);
send_notification(
&client,
"initialized",
serde_json::to_value(InitializedParams {}).unwrap(),
);
(client, server_thread)
}
fn start_server_pull() -> (Connection, std::thread::JoinHandle<()>) {
let (server, client) = Connection::memory();
let server_thread = std::thread::spawn(move || badness::lsp::serve(server).unwrap());
let params = InitializeParams {
capabilities: ClientCapabilities {
text_document: Some(TextDocumentClientCapabilities {
diagnostic: Some(DiagnosticClientCapabilities::default()),
..Default::default()
}),
workspace: Some(WorkspaceClientCapabilities {
diagnostic: Some(DiagnosticWorkspaceClientCapabilities {
refresh_support: Some(true),
}),
..Default::default()
}),
..Default::default()
},
..Default::default()
};
send_request(
&client,
1,
"initialize",
serde_json::to_value(params).unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(1));
let init: InitializeResult = serde_json::from_value(resp.result.unwrap()).unwrap();
assert!(
init.capabilities.diagnostic_provider.is_some(),
"server must advertise diagnosticProvider (pull diagnostics)"
);
send_notification(
&client,
"initialized",
serde_json::to_value(InitializedParams {}).unwrap(),
);
(client, server_thread)
}
fn pull_diagnostic(client: &Connection, id: i32, uri: &Uri, previous_result_id: Option<String>) {
send_request(
client,
id,
"textDocument/diagnostic",
serde_json::to_value(DocumentDiagnosticParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
identifier: None,
previous_result_id,
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
);
}
fn recv_document_diagnostic_report(client: &Connection, id: i32) -> DocumentDiagnosticReport {
loop {
match recv(client) {
Message::Response(resp) => {
assert_eq!(resp.id, RequestId::from(id));
let result: DocumentDiagnosticReportResult =
serde_json::from_value(resp.result.unwrap()).unwrap();
match result {
DocumentDiagnosticReportResult::Report(report) => return report,
DocumentDiagnosticReportResult::Partial(_) => {
panic!("server returned a partial report; none was requested")
}
}
}
Message::Notification(not) if not.method == "textDocument/publishDiagnostics" => {
panic!("pull-mode client must not receive a publishDiagnostics push")
}
Message::Notification(_) => continue,
Message::Request(req) => {
client
.sender
.send(Message::Response(Response::new_ok(
req.id,
serde_json::Value::Null,
)))
.unwrap();
}
}
}
}
fn report_items(report: &DocumentDiagnosticReport) -> Option<&[lsp_types::Diagnostic]> {
match report {
DocumentDiagnosticReport::Full(full) => Some(&full.full_document_diagnostic_report.items),
DocumentDiagnosticReport::Unchanged(_) => None,
}
}
fn report_result_id(report: &DocumentDiagnosticReport) -> Option<String> {
match report {
DocumentDiagnosticReport::Full(full) => {
full.full_document_diagnostic_report.result_id.clone()
}
DocumentDiagnosticReport::Unchanged(unchanged) => Some(
unchanged
.unchanged_document_diagnostic_report
.result_id
.clone(),
),
}
}
fn did_open(client: &Connection, uri: &Uri, version: i32, text: &str) {
send_notification(
client,
"textDocument/didOpen",
serde_json::to_value(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "latex".to_owned(),
version,
text: text.to_owned(),
},
})
.unwrap(),
);
}
fn shutdown(client: &Connection, server_thread: std::thread::JoinHandle<()>) {
send_request(client, 99, "shutdown", serde_json::Value::Null);
let resp = loop {
match recv(client) {
Message::Response(resp) => break resp,
Message::Notification(_) => continue,
other => panic!("expected the shutdown response, got {other:?}"),
}
};
assert_eq!(resp.id, RequestId::from(99));
send_notification(client, "exit", serde_json::Value::Null);
server_thread.join().expect("server thread panicked");
}
#[test]
fn lsp_formatting_and_diagnostics_transcript() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///test.tex".parse().unwrap();
let broken = "\\begin{itemize}\n\\item a\n";
did_open(&client, &uri, 1, broken);
let diags = recv_diagnostics(&client);
assert_eq!(diags.uri, uri);
assert!(
!diags.diagnostics.is_empty(),
"an unclosed environment must produce at least one diagnostic"
);
let messy = "\\section{Hi} \n\n\n\ntext. ";
send_notification(
&client,
"textDocument/didChange",
serde_json::to_value(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: messy.to_owned(),
}],
})
.unwrap(),
);
let diags = recv_diagnostics(&client);
assert!(
diags.diagnostics.is_empty(),
"a valid document must clear diagnostics, got {:?}",
diags.diagnostics
);
send_request(
&client,
2,
"textDocument/formatting",
serde_json::to_value(DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
options: FormattingOptions {
tab_size: 2,
insert_spaces: true,
..Default::default()
},
work_done_progress_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(2));
let edits: Vec<TextEdit> = serde_json::from_value(resp.result.unwrap()).unwrap();
assert_eq!(edits.len(), 1, "expected one whole-document edit");
let expected = format_with_style(
messy,
FormatStyle {
line_width: 80,
indent_width: 2,
..FormatStyle::default()
},
)
.unwrap();
assert_eq!(edits[0].new_text, expected);
shutdown(&client, server_thread);
}
#[test]
fn lsp_range_formatting_formats_only_the_selected_block() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///range.tex".parse().unwrap();
let doc = "first paragraph.\n\nsecond paragraph.\n";
did_open(&client, &uri, 1, doc);
let diags = recv_diagnostics(&client);
assert!(diags.diagnostics.is_empty(), "clean doc → no diagnostics");
let select_first = Range {
start: Position::new(0, 2),
end: Position::new(0, 5),
};
let range_params = |range: Range| {
serde_json::to_value(DocumentRangeFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
range,
options: FormattingOptions {
tab_size: 2,
insert_spaces: true,
..Default::default()
},
work_done_progress_params: Default::default(),
})
.unwrap()
};
send_request(
&client,
2,
"textDocument/rangeFormatting",
range_params(select_first),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(2));
let edits: Vec<TextEdit> = serde_json::from_value(resp.result.unwrap()).unwrap();
let formatted = apply_edits(doc, &edits);
assert_eq!(
formatted, "first paragraph.\n\nsecond paragraph.\n",
"only the selected block is formatted; the second paragraph is untouched"
);
send_notification(
&client,
"textDocument/didChange",
serde_json::to_value(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: formatted.clone(),
}],
})
.unwrap(),
);
let diags = recv_diagnostics(&client);
assert!(diags.diagnostics.is_empty());
send_request(
&client,
3,
"textDocument/rangeFormatting",
range_params(select_first),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(3));
let edits: Vec<TextEdit> = serde_json::from_value(resp.result.unwrap()).unwrap();
assert!(
edits.is_empty(),
"an already-formatted selection yields no edits, got {edits:?}"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_range_formatting_reindents_multiline_environment() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///range_env.tex".parse().unwrap();
let doc = "\\begin{itemize}\n\\item one\n\\item two\n\\end{itemize}\n\nsecond paragraph.\n";
did_open(&client, &uri, 1, doc);
let diags = recv_diagnostics(&client);
assert!(diags.diagnostics.is_empty(), "clean doc → no diagnostics");
send_request(
&client,
2,
"textDocument/rangeFormatting",
serde_json::to_value(DocumentRangeFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
range: Range {
start: Position::new(1, 3),
end: Position::new(1, 3),
},
options: FormattingOptions {
tab_size: 2,
insert_spaces: true,
..Default::default()
},
work_done_progress_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(2));
let edits: Vec<TextEdit> = serde_json::from_value(resp.result.unwrap()).unwrap();
let formatted = apply_edits(doc, &edits);
assert_eq!(
formatted,
"\\begin{itemize}\n \\item one\n \\item two\n\\end{itemize}\n\nsecond paragraph.\n",
"the environment body is reindented; the trailing paragraph is untouched"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_document_symbol_outline() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///outline.tex".parse().unwrap();
let doc = "\\section{Intro}\n\
\\begin{figure}\n\
\\label{fig:one}\n\
\\end{figure}\n\
\\begin{theorem}\n\
x\n\
\\end{theorem}\n";
did_open(&client, &uri, 1, doc);
let diags = recv_diagnostics(&client);
assert!(diags.diagnostics.is_empty(), "clean doc → no diagnostics");
send_request(
&client,
2,
"textDocument/documentSymbol",
serde_json::to_value(DocumentSymbolParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(2));
let response: DocumentSymbolResponse =
serde_json::from_value(resp.result.unwrap()).expect("a documentSymbol response");
let DocumentSymbolResponse::Nested(symbols) = response else {
panic!("expected a nested documentSymbol response");
};
assert_eq!(symbols.len(), 1);
let section = &symbols[0];
assert_eq!(section.name, "Intro");
assert_eq!(section.kind, SymbolKind::MODULE);
let children = section.children.as_deref().unwrap_or_default();
let names: Vec<&str> = children.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["figure", "theorem"]);
let figure = &children[0];
assert_eq!(figure.kind, SymbolKind::OBJECT);
let figure_kids: &[DocumentSymbol] = figure.children.as_deref().unwrap_or_default();
assert_eq!(figure_kids.len(), 1);
assert_eq!(figure_kids[0].name, "fig:one");
assert_eq!(figure_kids[0].kind, SymbolKind::CONSTANT);
assert_eq!(children[1].kind, SymbolKind::CLASS);
shutdown(&client, server_thread);
}
#[test]
fn lsp_document_symbol_dtx_documented_macros() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///pkg.dtx".parse().unwrap();
let doc = "\\section{Implementation}\n\
% \\DescribeMacro{\\foo}\n\
% \\begin{macro}{\\bar}\n\
% \\begin{macrocode}\n\
\\def\\bar{b}\n\
% \\end{macrocode}\n\
% \\end{macro}\n";
did_open(&client, &uri, 1, doc);
let _ = recv_diagnostics(&client);
send_request(
&client,
2,
"textDocument/documentSymbol",
serde_json::to_value(DocumentSymbolParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(2));
let response: DocumentSymbolResponse =
serde_json::from_value(resp.result.unwrap()).expect("a documentSymbol response");
let DocumentSymbolResponse::Nested(symbols) = response else {
panic!("expected a nested documentSymbol response");
};
assert_eq!(symbols.len(), 1);
let section = &symbols[0];
assert_eq!(section.name, "Implementation");
assert_eq!(section.kind, SymbolKind::MODULE);
let children = section.children.as_deref().unwrap_or_default();
let names: Vec<&str> = children.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["\\foo", "\\bar"]);
assert!(children.iter().all(|c| c.kind == SymbolKind::FUNCTION));
shutdown(&client, server_thread);
}
#[test]
fn lsp_folding_ranges() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///fold.tex".parse().unwrap();
let doc = "\\section{Intro}\n\
% a\n\
% b\n\
% c\n\
\\begin{itemize}\n\
\\item x\n\
\\end{itemize}\n";
did_open(&client, &uri, 1, doc);
let diags = recv_diagnostics(&client);
assert!(diags.diagnostics.is_empty(), "clean doc → no diagnostics");
send_request(
&client,
2,
"textDocument/foldingRange",
serde_json::to_value(FoldingRangeParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(2));
let ranges: Vec<FoldingRange> =
serde_json::from_value(resp.result.unwrap()).expect("a foldingRange response");
let triples: Vec<(u32, u32, Option<FoldingRangeKind>)> = ranges
.iter()
.map(|r| (r.start_line, r.end_line, r.kind.clone()))
.collect();
assert!(
triples.contains(&(0, 6, None)),
"section fold, got {triples:?}"
);
assert!(
triples.contains(&(1, 3, Some(FoldingRangeKind::Comment))),
"comment fold, got {triples:?}"
);
assert!(
triples.contains(&(4, 6, None)),
"itemize fold, got {triples:?}"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_code_action_quickfix() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///ca.tex".parse().unwrap();
let doc = "\\bf hi\n";
did_open(&client, &uri, 1, doc);
let diags = recv_diagnostics(&client);
assert!(
diags.diagnostics.iter().any(|d| d.message.contains("\\bf")),
"deprecated-command should flag \\bf, got {:?}",
diags.diagnostics
);
let on_bf = Range {
start: Position::new(0, 0),
end: Position::new(0, 3),
};
send_request(
&client,
2,
"textDocument/codeAction",
serde_json::to_value(CodeActionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
range: on_bf,
context: CodeActionContext::default(),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(2));
let actions: Vec<CodeActionOrCommand> =
serde_json::from_value(resp.result.unwrap()).expect("a codeAction response");
let CodeActionOrCommand::CodeAction(action) = actions
.iter()
.find(|a| matches!(a, CodeActionOrCommand::CodeAction(a) if a.title.contains("bfseries")))
.expect("a `\\bf` → `\\bfseries` quick-fix")
else {
unreachable!()
};
let edits = action
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.and_then(|c| c.get(&uri))
.expect("a single-file edit on the document");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "\\bfseries");
assert_eq!(edits[0].range.start, Position::new(0, 0));
assert_eq!(edits[0].range.end, Position::new(0, 3));
let off_bf = Range {
start: Position::new(0, 5),
end: Position::new(0, 5),
};
send_request(
&client,
3,
"textDocument/codeAction",
serde_json::to_value(CodeActionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
range: off_bf,
context: CodeActionContext::default(),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(3));
let actions: Vec<CodeActionOrCommand> =
serde_json::from_value(resp.result.unwrap()).expect("a codeAction response");
assert!(
actions.is_empty(),
"a range off the command yields no quick-fix, got {actions:?}"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_bib_diagnostics_formatting_and_symbols() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///refs.bib".parse().unwrap();
let doc = "@article{k, title={A}}\n@misc{k, title={B}}\n";
did_open(&client, &uri, 1, doc);
let diags = recv_diagnostics(&client);
assert_eq!(diags.uri, uri);
assert!(
diags.diagnostics.iter().any(|d| d.code
== Some(lsp_types::NumberOrString::String(
"duplicate-key".to_owned()
))),
"a duplicate cite key must produce a duplicate-key diagnostic, got {:?}",
diags.diagnostics
);
send_request(
&client,
2,
"textDocument/formatting",
serde_json::to_value(DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
options: FormattingOptions {
tab_size: 2,
insert_spaces: true,
..Default::default()
},
work_done_progress_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(2));
let edits: Vec<TextEdit> = serde_json::from_value(resp.result.unwrap()).unwrap();
assert_eq!(edits.len(), 1, "expected one whole-document edit");
let expected = badness::bib::format_with_style(
doc,
FormatStyle {
line_width: 80,
indent_width: 2,
..FormatStyle::default()
},
)
.unwrap();
assert_eq!(edits[0].new_text, expected);
send_request(
&client,
3,
"textDocument/documentSymbol",
serde_json::to_value(DocumentSymbolParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(3));
let DocumentSymbolResponse::Nested(symbols) =
serde_json::from_value(resp.result.unwrap()).expect("a documentSymbol response")
else {
panic!("expected a nested documentSymbol response");
};
assert_eq!(symbols.len(), 2, "two entries → two flat symbols");
assert!(symbols.iter().all(|s| s.name == "k"));
assert!(symbols.iter().all(|s| s.kind == SymbolKind::CONSTANT));
assert!(symbols.iter().all(|s| s.children.is_none()));
let details: Vec<&str> = symbols
.iter()
.map(|s| s.detail.as_deref().unwrap_or_default())
.collect();
assert_eq!(details, vec!["article", "misc"]);
shutdown(&client, server_thread);
}
#[test]
fn incremental_did_change_splices_buffer() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///inc.tex".parse().unwrap();
did_open(&client, &uri, 1, "\\section{Hi}\nworld\n");
let diags = recv_diagnostics(&client);
assert!(diags.diagnostics.is_empty());
send_notification(
&client,
"textDocument/didChange",
serde_json::to_value(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: Some(Range {
start: Position::new(1, 0),
end: Position::new(1, 5),
}),
range_length: None,
text: "\\begin{itemize}".to_owned(),
}],
})
.unwrap(),
);
let diags = recv_diagnostics(&client);
assert!(
!diags.diagnostics.is_empty(),
"the spliced unclosed environment must produce a diagnostic"
);
send_request(
&client,
2,
"textDocument/formatting",
serde_json::to_value(DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
options: FormattingOptions {
tab_size: 2,
insert_spaces: true,
..Default::default()
},
work_done_progress_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(2));
assert!(
resp.result.is_none() || resp.result == Some(serde_json::Value::Null),
"formatter must refuse the now-broken spliced buffer, got {:?}",
resp.result
);
shutdown(&client, server_thread);
}
#[test]
fn line_width_from_initialization_options() {
let (client, server_thread) = start_server(Some(serde_json::json!({ "lineWidth": 20 })));
let uri: Uri = "file:///wrap.tex".parse().unwrap();
let para = "alpha beta gamma delta epsilon zeta eta theta\n";
did_open(&client, &uri, 1, para);
let diags = recv_diagnostics(&client);
assert!(diags.diagnostics.is_empty());
send_request(
&client,
2,
"textDocument/formatting",
serde_json::to_value(DocumentFormattingParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
options: FormattingOptions {
tab_size: 0,
insert_spaces: true,
..Default::default()
},
work_done_progress_params: Default::default(),
})
.unwrap(),
);
let resp = recv_response(&client);
let edits: Vec<TextEdit> = serde_json::from_value(resp.result.unwrap()).unwrap();
assert_eq!(edits.len(), 1, "the narrow width must reflow the paragraph");
let expected = format_with_style(
para,
FormatStyle {
line_width: 20,
..FormatStyle::default()
},
)
.unwrap();
assert_eq!(edits[0].new_text, expected);
let default_out = format_with_style(para, FormatStyle::default()).unwrap();
assert_ne!(
expected, default_out,
"the test paragraph must format differently at width 20 vs 80"
);
shutdown(&client, server_thread);
}
#[test]
fn did_close_clears_and_allows_reopen() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///close.tex".parse().unwrap();
did_open(&client, &uri, 1, "\\begin{itemize}\n");
let diags = recv_diagnostics(&client);
assert!(!diags.diagnostics.is_empty(), "unclosed env → diagnostic");
send_notification(
&client,
"textDocument/didClose",
serde_json::to_value(DidCloseTextDocumentParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
})
.unwrap(),
);
let diags = recv_diagnostics(&client);
assert!(diags.diagnostics.is_empty(), "close must clear diagnostics");
did_open(&client, &uri, 1, "\\section{Hi}\n");
let diags = recv_diagnostics(&client);
assert_eq!(diags.uri, uri);
assert!(
diags.diagnostics.is_empty(),
"reopened clean doc must parse cleanly, got {:?}",
diags.diagnostics
);
shutdown(&client, server_thread);
}
fn complete(client: &Connection, id: i32, uri: &Uri, position: Position) -> Vec<CompletionItem> {
send_request(
client,
id,
"textDocument/completion",
serde_json::to_value(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.unwrap(),
);
let resp = recv_response(client);
assert_eq!(resp.id, RequestId::from(id));
match serde_json::from_value::<CompletionResponse>(resp.result.unwrap()).unwrap() {
CompletionResponse::Array(items) => items,
CompletionResponse::List(list) => list.items,
}
}
fn labels(items: &[CompletionItem]) -> Vec<&str> {
items.iter().map(|i| i.label.as_str()).collect()
}
fn hover_markdown(client: &Connection, id: i32, uri: &Uri, position: Position) -> Option<String> {
send_request(
client,
id,
"textDocument/hover",
serde_json::to_value(HoverParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
})
.unwrap(),
);
let resp = recv_response(client);
assert_eq!(resp.id, RequestId::from(id));
let result = resp.result.unwrap();
if result.is_null() {
return None;
}
let hover: Hover = serde_json::from_value(result).unwrap();
match hover.contents {
HoverContents::Markup(m) => Some(m.value),
other => panic!("expected markup hover, got {other:?}"),
}
}
#[test]
fn lsp_hover_command_signature_and_null() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///hover.tex".parse().unwrap();
let doc = "\\section{Intro}\n\nPlain words here.\n";
did_open(&client, &uri, 1, doc);
let diags = recv_diagnostics(&client);
assert!(diags.diagnostics.is_empty(), "{:?}", diags.diagnostics);
let md = hover_markdown(&client, 2, &uri, Position::new(0, 3)).expect("hover for \\section");
assert!(md.contains("\\section"), "prototype: {md}");
assert!(md.contains("sectioning level"), "facts: {md}");
assert!(
hover_markdown(&client, 3, &uri, Position::new(2, 2)).is_none(),
"prose hover should be null"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_completion_commands_environments_and_refs() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///complete.tex".parse().unwrap();
let doc = "\\section{Intro}\n\
\\label{sec:intro}\n\
\\ref{sec:i}\n\
\\begin{itemize}\n\
\\item x\n\
\\end{itemize}\n\
\\sub\n";
did_open(&client, &uri, 1, doc);
let diags = recv_diagnostics(&client);
assert!(
diags.diagnostics.is_empty(),
"clean doc → no diagnostics, got {:?}",
diags.diagnostics
);
let cmds = complete(&client, 2, &uri, Position::new(6, 4));
let names = labels(&cmds);
assert!(names.contains(&"subsection"), "{names:?}");
assert!(names.contains(&"subsubsection"), "{names:?}");
assert!(
cmds.iter()
.all(|i| i.kind == Some(CompletionItemKind::FUNCTION)),
"command items are FUNCTION"
);
let envs = complete(&client, 3, &uri, Position::new(3, 9));
let itemize = envs
.iter()
.find(|i| i.label == "itemize")
.expect("itemize env candidate");
assert_eq!(itemize.insert_text_format, Some(InsertTextFormat::SNIPPET));
assert_eq!(
itemize.insert_text.as_deref(),
Some("itemize}\n\t$0\n\\end{itemize}")
);
let refs = complete(&client, 4, &uri, Position::new(2, 10));
assert_eq!(labels(&refs), vec!["sec:intro"]);
assert_eq!(refs[0].kind, Some(CompletionItemKind::REFERENCE));
shutdown(&client, server_thread);
}
#[test]
fn lsp_completion_file_paths() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("intro.tex"), "x").unwrap();
std::fs::write(dir.path().join("logo.png"), "x").unwrap();
std::fs::write(dir.path().join("notes.txt"), "x").unwrap();
std::fs::create_dir(dir.path().join("chapters")).unwrap();
let uri = path_to_file_uri(&dir.path().join("main.tex"));
let (client, server_thread) = start_server(None);
did_open(&client, &uri, 1, "\\input{}\n");
let _ = recv_diagnostics(&client);
let inputs = complete(&client, 2, &uri, Position::new(0, 7));
let names = labels(&inputs);
assert!(names.contains(&"intro.tex"), "{names:?}");
assert!(names.contains(&"chapters"), "{names:?}");
assert!(!names.contains(&"logo.png"), "{names:?}");
assert!(!names.contains(&"notes.txt"), "{names:?}");
let intro = inputs.iter().find(|i| i.label == "intro.tex").unwrap();
assert_eq!(intro.kind, Some(CompletionItemKind::FILE));
let chapters = inputs.iter().find(|i| i.label == "chapters").unwrap();
assert_eq!(chapters.kind, Some(CompletionItemKind::FOLDER));
send_notification(
&client,
"textDocument/didChange",
serde_json::to_value(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: 2,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "\\includegraphics{}\n".to_owned(),
}],
})
.unwrap(),
);
let _ = recv_diagnostics(&client);
let graphics = complete(&client, 3, &uri, Position::new(0, 17));
let names = labels(&graphics);
assert!(names.contains(&"logo.png"), "{names:?}");
assert!(names.contains(&"chapters"), "{names:?}");
assert!(!names.contains(&"intro.tex"), "{names:?}");
shutdown(&client, server_thread);
}
fn rule_codes(diags: &PublishDiagnosticsParams) -> Vec<String> {
diags
.diagnostics
.iter()
.filter_map(|d| match &d.code {
Some(NumberOrString::String(code)) => Some(code.clone()),
_ => None,
})
.collect()
}
#[test]
fn lsp_cross_file_resolution_clears_diagnostics() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("part.tex"), "\\label{sec:intro}\n").unwrap();
std::fs::write(
dir.path().join("refs.bib"),
"@article{knuth1984, title={The TeXbook}}\n",
)
.unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\documentclass{article}\n\
\\addbibresource{refs.bib}\n\
\\begin{document}\n\
\\input{part}\n\
\\ref{sec:intro}\n\
\\cite{knuth1984}\n\
\\end{document}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let uri = path_to_file_uri(&main_path);
did_open(&client, &uri, 1, main);
let diags = recv_diagnostics(&client);
assert_eq!(diags.uri, uri);
assert!(
diags.diagnostics.is_empty(),
"cross-file label + citation must resolve, got {:?}",
diags.diagnostics
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_cross_file_undefined_ref_and_citation_fire() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("part.tex"), "\\label{sec:intro}\n").unwrap();
std::fs::write(
dir.path().join("refs.bib"),
"@article{knuth1984, title={The TeXbook}}\n",
)
.unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\documentclass{article}\n\
\\addbibresource{refs.bib}\n\
\\begin{document}\n\
\\input{part}\n\
\\ref{sec:missing}\n\
\\cite{lamport1986}\n\
\\end{document}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let uri = path_to_file_uri(&main_path);
did_open(&client, &uri, 1, main);
let diags = recv_diagnostics(&client);
assert_eq!(diags.uri, uri);
let codes = rule_codes(&diags);
assert!(
codes.iter().any(|c| c == "undefined-ref"),
"expected undefined-ref, got {codes:?}"
);
assert!(
codes.iter().any(|c| c == "undefined-citation"),
"expected undefined-citation, got {codes:?}"
);
shutdown(&client, server_thread);
}
fn definition(client: &Connection, id: i32, uri: &Uri, position: Position) -> Vec<Location> {
send_request(
client,
id,
"textDocument/definition",
serde_json::to_value(GotoDefinitionParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.unwrap(),
);
let resp = loop {
match recv(client) {
Message::Response(resp) => break resp,
Message::Notification(_) => continue,
other => panic!("expected a response, got {other:?}"),
}
};
assert_eq!(resp.id, RequestId::from(id));
match serde_json::from_value::<GotoDefinitionResponse>(resp.result.unwrap()).unwrap() {
GotoDefinitionResponse::Array(locs) => locs,
GotoDefinitionResponse::Scalar(loc) => vec![loc],
GotoDefinitionResponse::Link(_) => panic!("unexpected LocationLink response"),
}
}
#[test]
fn lsp_definition_same_file_ref_to_label() {
let (client, server_thread) = start_server(None);
let abs = std::path::absolute("def.tex").expect("absolute path");
let uri = path_to_file_uri(&abs);
let doc = "\\label{sec:intro}\n\\ref{sec:intro}\n";
did_open(&client, &uri, 1, doc);
let _ = recv_diagnostics(&client);
let locs = definition(&client, 2, &uri, Position::new(1, 6));
assert_eq!(locs.len(), 1, "one definition, got {locs:?}");
assert_eq!(locs[0].uri, uri);
assert_eq!(locs[0].range.start, Position::new(0, 0));
let none = definition(&client, 3, &uri, Position::new(0, 0));
assert!(none.is_empty(), "the `\\label` site is not a reference");
shutdown(&client, server_thread);
}
#[test]
fn lsp_definition_cross_file_ref_and_cite() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("part.tex"), "\\label{sec:intro}\n").unwrap();
std::fs::write(
dir.path().join("refs.bib"),
"@article{knuth1984, title={The TeXbook}}\n",
)
.unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\documentclass{article}\n\
\\addbibresource{refs.bib}\n\
\\begin{document}\n\
\\input{part}\n\
\\ref{sec:intro}\n\
\\cite{knuth1984}\n\
\\end{document}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let uri = path_to_file_uri(&main_path);
did_open(&client, &uri, 1, main);
let _ = recv_diagnostics(&client);
let ref_locs = definition(&client, 2, &uri, Position::new(4, 6));
assert_eq!(ref_locs.len(), 1, "one label definition, got {ref_locs:?}");
assert_eq!(
ref_locs[0].uri,
path_to_file_uri(&dir.path().join("part.tex"))
);
assert_eq!(ref_locs[0].range.start, Position::new(0, 0));
let cite_locs = definition(&client, 3, &uri, Position::new(5, 8));
assert_eq!(cite_locs.len(), 1, "one bib entry, got {cite_locs:?}");
assert_eq!(
cite_locs[0].uri,
path_to_file_uri(&dir.path().join("refs.bib"))
);
assert_eq!(cite_locs[0].range.start.line, 0);
shutdown(&client, server_thread);
}
fn workspace_symbols(client: &Connection, id: i32, query: &str) -> Vec<(String, SymbolKind, Uri)> {
send_request(
client,
id,
"workspace/symbol",
serde_json::to_value(WorkspaceSymbolParams {
query: query.to_owned(),
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.unwrap(),
);
let resp = loop {
match recv(client) {
Message::Response(resp) => break resp,
Message::Notification(_) => continue,
other => panic!("expected a response, got {other:?}"),
}
};
assert_eq!(resp.id, RequestId::from(id));
match serde_json::from_value::<WorkspaceSymbolResponse>(resp.result.unwrap())
.expect("a workspace/symbol response")
{
WorkspaceSymbolResponse::Nested(symbols) => symbols
.into_iter()
.map(|s| {
let uri = match s.location {
OneOf::Left(loc) => loc.uri,
OneOf::Right(loc) => loc.uri,
};
(s.name, s.kind, uri)
})
.collect(),
WorkspaceSymbolResponse::Flat(symbols) => symbols
.into_iter()
.map(|s| (s.name, s.kind, s.location.uri))
.collect(),
}
}
#[test]
fn lsp_workspace_symbol_cross_file() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("part.tex"),
"\\section{Chapter One}\n\\label{sec:intro}\n",
)
.unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\documentclass{article}\n\
\\begin{document}\n\
\\input{part}\n\
\\end{document}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let uri = path_to_file_uri(&main_path);
did_open(&client, &uri, 1, main);
let _ = recv_diagnostics(&client);
let part_uri = path_to_file_uri(&dir.path().join("part.tex"));
let secs = workspace_symbols(&client, 2, "Chapter");
assert_eq!(secs.len(), 1, "one section match, got {secs:?}");
assert_eq!(secs[0].0, "Chapter One");
assert_eq!(secs[0].1, SymbolKind::MODULE);
assert_eq!(
secs[0].2, part_uri,
"the section lives in the included file"
);
let labels = workspace_symbols(&client, 3, "sec:intro");
assert_eq!(labels.len(), 1, "one label match, got {labels:?}");
assert_eq!(labels[0].0, "sec:intro");
assert_eq!(labels[0].1, SymbolKind::CONSTANT);
let lower = workspace_symbols(&client, 4, "chapter one");
assert_eq!(lower.len(), 1, "case-insensitive match, got {lower:?}");
let none = workspace_symbols(&client, 5, "zzz-no-such-symbol");
assert!(none.is_empty(), "no matches → empty, got {none:?}");
shutdown(&client, server_thread);
}
fn references(
client: &Connection,
id: i32,
uri: &Uri,
position: Position,
include_declaration: bool,
) -> Vec<Location> {
send_request(
client,
id,
"textDocument/references",
serde_json::to_value(ReferenceParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: ReferenceContext {
include_declaration,
},
})
.unwrap(),
);
let resp = loop {
match recv(client) {
Message::Response(resp) => break resp,
Message::Notification(_) => continue,
other => panic!("expected a response, got {other:?}"),
}
};
assert_eq!(resp.id, RequestId::from(id));
serde_json::from_value::<Vec<Location>>(resp.result.unwrap()).unwrap()
}
fn sorted_starts(mut locs: Vec<Location>) -> Vec<Position> {
locs.sort_by_key(|l| (l.range.start.line, l.range.start.character));
locs.into_iter().map(|l| l.range.start).collect()
}
#[test]
fn lsp_references_same_file_label_uses() {
let (client, server_thread) = start_server(None);
let abs = std::path::absolute("def.tex").expect("absolute path");
let uri = path_to_file_uri(&abs);
let doc = "\\label{sec:intro}\n\\ref{sec:intro}\n\\ref{sec:intro}\n";
did_open(&client, &uri, 1, doc);
let _ = recv_diagnostics(&client);
let uses = references(&client, 2, &uri, Position::new(1, 6), false);
assert_eq!(
sorted_starts(uses),
vec![Position::new(1, 0), Position::new(2, 0)],
"both \\ref uses, no \\label"
);
let with_decl = references(&client, 3, &uri, Position::new(1, 6), true);
assert_eq!(
sorted_starts(with_decl),
vec![
Position::new(0, 0),
Position::new(1, 0),
Position::new(2, 0),
],
"the \\label declaration is included"
);
let from_def = references(&client, 4, &uri, Position::new(0, 8), false);
assert_eq!(
sorted_starts(from_def),
vec![Position::new(1, 0), Position::new(2, 0)],
"find-references works from the definition site"
);
let none = references(&client, 5, &uri, Position::new(3, 0), true);
assert!(none.is_empty(), "no reference under an empty position");
shutdown(&client, server_thread);
}
fn document_highlight(
client: &Connection,
id: i32,
uri: &Uri,
position: Position,
) -> Vec<DocumentHighlight> {
send_request(
client,
id,
"textDocument/documentHighlight",
serde_json::to_value(DocumentHighlightParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
})
.unwrap(),
);
let resp = loop {
match recv(client) {
Message::Response(resp) => break resp,
Message::Notification(_) => continue,
other => panic!("expected a response, got {other:?}"),
}
};
assert_eq!(resp.id, RequestId::from(id));
match resp.result {
Some(serde_json::Value::Null) | None => Vec::new(),
Some(v) => serde_json::from_value(v).unwrap(),
}
}
fn highlight_starts(
mut hl: Vec<DocumentHighlight>,
) -> Vec<(Position, Option<DocumentHighlightKind>)> {
hl.sort_by_key(|h| (h.range.start.line, h.range.start.character));
hl.into_iter().map(|h| (h.range.start, h.kind)).collect()
}
#[test]
fn lsp_document_highlight_label_and_refs() {
let (client, server_thread) = start_server(None);
let abs = std::path::absolute("def.tex").expect("absolute path");
let uri = path_to_file_uri(&abs);
let doc = "\\label{sec:intro}\n\\ref{sec:intro}\n\\ref{sec:intro}\n";
did_open(&client, &uri, 1, doc);
let _ = recv_diagnostics(&client);
let expected = vec![
(Position::new(0, 7), Some(DocumentHighlightKind::WRITE)),
(Position::new(1, 5), Some(DocumentHighlightKind::READ)),
(Position::new(2, 5), Some(DocumentHighlightKind::READ)),
];
let from_ref = document_highlight(&client, 2, &uri, Position::new(1, 6));
assert_eq!(
highlight_starts(from_ref),
expected,
"the \\label definition and both \\ref uses, by key span"
);
let from_def = document_highlight(&client, 3, &uri, Position::new(0, 8));
assert_eq!(
highlight_starts(from_def),
expected,
"highlight resolves identically from the definition site"
);
let on_word = document_highlight(&client, 4, &uri, Position::new(1, 1));
assert!(on_word.is_empty(), "cursor on the command word, not a key");
let none = document_highlight(&client, 5, &uri, Position::new(3, 0));
assert!(none.is_empty(), "no key under an empty position");
shutdown(&client, server_thread);
}
#[test]
fn lsp_document_highlight_cref_isolates_each_key() {
let (client, server_thread) = start_server(None);
let abs = std::path::absolute("def.tex").expect("absolute path");
let uri = path_to_file_uri(&abs);
let doc = "\\cref{a,b}\n\\ref{a}\n\\label{a}\n\\label{b}\n";
did_open(&client, &uri, 1, doc);
let _ = recv_diagnostics(&client);
let on_a = document_highlight(&client, 2, &uri, Position::new(0, 6));
assert_eq!(
highlight_starts(on_a),
vec![
(Position::new(0, 6), Some(DocumentHighlightKind::READ)), (Position::new(1, 5), Some(DocumentHighlightKind::READ)), (Position::new(2, 7), Some(DocumentHighlightKind::WRITE)), ],
"only the `a` key family, the sibling `b` excluded"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_document_highlight_citation_keys() {
let (client, server_thread) = start_server(None);
let abs = std::path::absolute("def.tex").expect("absolute path");
let uri = path_to_file_uri(&abs);
let doc = "\\cite{foo}\n\\cite{foo}\n\\cite{bar}\n";
did_open(&client, &uri, 1, doc);
let _ = recv_diagnostics(&client);
let on_foo = document_highlight(&client, 2, &uri, Position::new(0, 7));
assert_eq!(
highlight_starts(on_foo),
vec![
(Position::new(0, 6), Some(DocumentHighlightKind::READ)),
(Position::new(1, 6), Some(DocumentHighlightKind::READ)),
],
"both \\cite{{foo}} keys, the \\cite{{bar}} excluded"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_references_cross_file_cite_from_tex_and_bib() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("refs.bib"),
"@article{knuth1984, title={The TeXbook}}\n",
)
.unwrap();
std::fs::write(dir.path().join("part.tex"), "\\cite{knuth1984}\n").unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\documentclass{article}\n\
\\addbibresource{refs.bib}\n\
\\begin{document}\n\
\\input{part}\n\
\\cite{knuth1984}\n\
\\end{document}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let main_uri = path_to_file_uri(&main_path);
did_open(&client, &main_uri, 1, main);
let _ = recv_diagnostics(&client);
let part_uri = path_to_file_uri(&dir.path().join("part.tex"));
let bib_uri = path_to_file_uri(&dir.path().join("refs.bib"));
let uses = references(&client, 2, &main_uri, Position::new(4, 8), false);
let mut found: Vec<(Uri, u32)> = uses
.iter()
.map(|l| (l.uri.clone(), l.range.start.line))
.collect();
found.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then(a.1.cmp(&b.1)));
assert_eq!(
found,
vec![(main_uri.clone(), 4), (part_uri.clone(), 0)],
"both \\cite sites, declaration excluded, got {uses:?}"
);
let with_decl = references(&client, 3, &main_uri, Position::new(4, 8), true);
assert!(
with_decl.iter().any(|l| l.uri == bib_uri),
"the bib entry is included, got {with_decl:?}"
);
assert_eq!(
with_decl.len(),
3,
"two cites + one entry, got {with_decl:?}"
);
did_open(
&client,
&bib_uri,
1,
"@article{knuth1984, title={The TeXbook}}\n",
);
let _ = recv_diagnostics(&client);
let from_bib = references(&client, 4, &bib_uri, Position::new(0, 12), false);
let mut bib_found: Vec<(Uri, u32)> = from_bib
.iter()
.map(|l| (l.uri.clone(), l.range.start.line))
.collect();
bib_found.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()).then(a.1.cmp(&b.1)));
assert_eq!(
bib_found,
vec![(main_uri.clone(), 4), (part_uri.clone(), 0)],
"cite uses resolved from the bib entry, got {from_bib:?}"
);
shutdown(&client, server_thread);
}
fn prepare_rename(
client: &Connection,
id: i32,
uri: &Uri,
position: Position,
) -> serde_json::Value {
send_request(
client,
id,
"textDocument/prepareRename",
serde_json::to_value(TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position,
})
.unwrap(),
);
let resp = loop {
match recv(client) {
Message::Response(resp) => break resp,
Message::Notification(_) => continue,
other => panic!("expected a response, got {other:?}"),
}
};
assert_eq!(resp.id, RequestId::from(id));
resp.result.unwrap()
}
fn rename(
client: &Connection,
id: i32,
uri: &Uri,
position: Position,
new_name: &str,
) -> std::collections::HashMap<Uri, Vec<TextEdit>> {
send_request(
client,
id,
"textDocument/rename",
serde_json::to_value(RenameParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position,
},
new_name: new_name.to_owned(),
work_done_progress_params: WorkDoneProgressParams::default(),
})
.unwrap(),
);
let resp = loop {
match recv(client) {
Message::Response(resp) => break resp,
Message::Notification(_) => continue,
other => panic!("expected a response, got {other:?}"),
}
};
assert_eq!(resp.id, RequestId::from(id));
match resp.result.unwrap() {
serde_json::Value::Null => std::collections::HashMap::new(),
value => serde_json::from_value::<WorkspaceEdit>(value)
.unwrap()
.changes
.unwrap_or_default(),
}
}
fn apply_edits(text: &str, edits: &[TextEdit]) -> String {
let index = badness::text::LineIndex::new(text);
let mut spans: Vec<(usize, usize, &str)> = edits
.iter()
.map(|edit| {
let start = index.offset_at(text, edit.range.start.line, edit.range.start.character);
let end = index.offset_at(text, edit.range.end.line, edit.range.end.character);
(start, end, edit.new_text.as_str())
})
.collect();
spans.sort_by_key(|(start, _, _)| *start);
let mut out = text.to_owned();
for (start, end, new_text) in spans.into_iter().rev() {
out.replace_range(start..end, new_text);
}
out
}
#[test]
fn lsp_prepare_rename_anchors_to_key_token() {
let (client, server_thread) = start_server(None);
let abs = std::path::absolute("def.tex").expect("absolute path");
let uri = path_to_file_uri(&abs);
let doc = "\\label{sec:intro}\n\\ref{sec:intro}\n";
did_open(&client, &uri, 1, doc);
let _ = recv_diagnostics(&client);
let prepared = prepare_rename(&client, 2, &uri, Position::new(1, 6));
let response: PrepareRenameResponse = serde_json::from_value(prepared).unwrap();
match response {
PrepareRenameResponse::RangeWithPlaceholder { range, placeholder } => {
assert_eq!(range, Range::new(Position::new(1, 5), Position::new(1, 14)));
assert_eq!(placeholder, "sec:intro");
}
other => panic!("expected RangeWithPlaceholder, got {other:?}"),
}
let on_command = prepare_rename(&client, 3, &uri, Position::new(1, 2));
assert!(on_command.is_null(), "the command word is not renameable");
let on_nothing = prepare_rename(&client, 4, &uri, Position::new(2, 0));
assert!(on_nothing.is_null(), "no key under an empty position");
shutdown(&client, server_thread);
}
#[test]
fn lsp_rename_label_rewrites_def_and_uses_cross_file() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("part.tex"),
"\\label{sec:intro}\n\\cref{sec:intro,other}\n",
)
.unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\documentclass{article}\n\
\\begin{document}\n\
\\input{part}\n\
\\ref{sec:intro}\n\
\\end{document}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let main_uri = path_to_file_uri(&main_path);
did_open(&client, &main_uri, 1, main);
let _ = recv_diagnostics(&client);
let part_uri = path_to_file_uri(&dir.path().join("part.tex"));
let changes = rename(&client, 2, &main_uri, Position::new(3, 6), "sec:overview");
assert_eq!(changes.len(), 2, "edits span both files, got {changes:?}");
let part_src = "\\label{sec:intro}\n\\cref{sec:intro,other}\n";
let part_out = apply_edits(part_src, &changes[&part_uri]);
assert_eq!(
part_out, "\\label{sec:overview}\n\\cref{sec:overview,other}\n",
"definition + list key renamed, sibling key untouched"
);
let main_out = apply_edits(main, &changes[&main_uri]);
assert!(
main_out.contains("\\ref{sec:overview}"),
"the \\ref use is renamed, got {main_out:?}"
);
let declined = rename(&client, 3, &main_uri, Position::new(3, 6), "bad}name");
assert!(
declined.is_empty(),
"a syntactically unsafe key is declined"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_rename_cite_key_rewrites_entry_and_uses() {
let dir = tempfile::tempdir().expect("temp dir");
let bib_src = "@article{Knuth1984, title={The TeXbook}}\n";
std::fs::write(dir.path().join("refs.bib"), bib_src).unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\documentclass{article}\n\
\\addbibresource{refs.bib}\n\
\\begin{document}\n\
\\cite{knuth1984}\n\
\\end{document}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let main_uri = path_to_file_uri(&main_path);
did_open(&client, &main_uri, 1, main);
let _ = recv_diagnostics(&client);
let bib_uri = path_to_file_uri(&dir.path().join("refs.bib"));
let changes = rename(&client, 2, &main_uri, Position::new(3, 8), "knuth-texbook");
assert_eq!(changes.len(), 2, "edits span the .tex and the .bib");
let bib_out = apply_edits(bib_src, &changes[&bib_uri]);
assert_eq!(
bib_out, "@article{knuth-texbook, title={The TeXbook}}\n",
"the @entry key is rewritten despite the case mismatch"
);
let main_out = apply_edits(main, &changes[&main_uri]);
assert!(
main_out.contains("\\cite{knuth-texbook}"),
"the \\cite use is rewritten, got {main_out:?}"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_lone_fragment_has_no_cross_file_diagnostics() {
let dir = tempfile::tempdir().expect("temp dir");
let frag_path = dir.path().join("frag.tex");
let frag = "\\section{Loose}\n\\ref{nowhere}\n";
std::fs::write(&frag_path, frag).unwrap();
let (client, server_thread) = start_server(None);
let uri = path_to_file_uri(&frag_path);
did_open(&client, &uri, 1, frag);
let diags = recv_diagnostics(&client);
assert_eq!(diags.uri, uri);
assert!(
rule_codes(&diags).iter().all(|c| c != "undefined-ref"),
"a rootless fragment must not flag undefined-ref, got {:?}",
diags.diagnostics
);
shutdown(&client, server_thread);
}
fn complete_draining(
client: &Connection,
id: i32,
uri: &Uri,
position: Position,
) -> Vec<CompletionItem> {
send_request(
client,
id,
"textDocument/completion",
serde_json::to_value(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position,
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
context: None,
})
.unwrap(),
);
let resp = loop {
match recv(client) {
Message::Response(resp) => break resp,
Message::Notification(_) => continue,
other => panic!("expected a response, got {other:?}"),
}
};
assert_eq!(resp.id, RequestId::from(id));
match serde_json::from_value::<CompletionResponse>(resp.result.unwrap()).unwrap() {
CompletionResponse::Array(items) => items,
CompletionResponse::List(list) => list.items,
}
}
#[test]
fn lsp_bib_completion_entry_types() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///refs.bib".parse().unwrap();
did_open(&client, &uri, 1, "@art\n");
let items = complete_draining(&client, 2, &uri, Position::new(0, 4));
let names = labels(&items);
assert!(names.contains(&"article"), "{names:?}");
let article = items.iter().find(|i| i.label == "article").unwrap();
assert_eq!(article.kind, Some(CompletionItemKind::STRUCT));
shutdown(&client, server_thread);
}
#[test]
fn lsp_bib_completion_field_names() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///fields.bib".parse().unwrap();
did_open(&client, &uri, 1, "@article{k,\n au\n}\n");
let items = complete_draining(&client, 2, &uri, Position::new(1, 4));
let names = labels(&items);
assert!(names.contains(&"author"), "{names:?}");
let author = items.iter().find(|i| i.label == "author").unwrap();
assert_eq!(author.kind, Some(CompletionItemKind::FIELD));
shutdown(&client, server_thread);
}
#[test]
fn lsp_bib_completion_string_macros() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///strings.bib".parse().unwrap();
let doc = "@string{els = {Elsevier}}\n@article{k, publisher = e}\n";
did_open(&client, &uri, 1, doc);
let items = complete_draining(&client, 2, &uri, Position::new(1, 25));
let names = labels(&items);
assert!(names.contains(&"els"), "{names:?}");
let els = items.iter().find(|i| i.label == "els").unwrap();
assert_eq!(els.kind, Some(CompletionItemKind::CONSTANT));
shutdown(&client, server_thread);
}
#[test]
fn lsp_cite_completion_cross_file() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("refs.bib"),
"@article{knuth1984, title={The TeXbook}}\n",
)
.unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\addbibresource{refs.bib}\n\\cite{kn}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let uri = path_to_file_uri(&main_path);
did_open(&client, &uri, 1, main);
let items = complete_draining(&client, 2, &uri, Position::new(1, 8));
let names = labels(&items);
assert!(names.contains(&"knuth1984"), "{names:?}");
let key = items.iter().find(|i| i.label == "knuth1984").unwrap();
assert_eq!(key.kind, Some(CompletionItemKind::REFERENCE));
shutdown(&client, server_thread);
}
fn did_change_full(client: &Connection, uri: &Uri, v: i32, text: &str) {
send_notification(
client,
"textDocument/didChange",
serde_json::to_value(DidChangeTextDocumentParams {
text_document: VersionedTextDocumentIdentifier {
uri: uri.clone(),
version: v,
},
content_changes: vec![TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: text.to_owned(),
}],
})
.unwrap(),
);
}
#[test]
fn lsp_pull_diagnostics_suppress_push_and_report_full() {
let (client, server_thread) = start_server_pull();
let uri: Uri = "file:///pull.tex".parse().unwrap();
did_open(&client, &uri, 1, "\\begin{itemize}\n\\item a\n");
pull_diagnostic(&client, 10, &uri, None);
let report = recv_document_diagnostic_report(&client, 10);
let items = report_items(&report).expect("a broken document yields a full report");
assert!(
!items.is_empty(),
"an unclosed environment must produce at least one pulled diagnostic"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_pull_diagnostics_current_right_after_change() {
let (client, server_thread) = start_server_pull();
let uri: Uri = "file:///pull_change.tex".parse().unwrap();
did_open(&client, &uri, 1, "\\begin{itemize}\n\\item a\n");
did_change_full(&client, &uri, 2, "\\section{Hi}\n\ntext.\n");
pull_diagnostic(&client, 11, &uri, None);
let report = recv_document_diagnostic_report(&client, 11);
let items = report_items(&report).expect("expected a full report");
assert!(
items.is_empty(),
"the pull must reflect the fixed buffer, got {items:?}"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_pull_diagnostics_unchanged_result_id() {
let (client, server_thread) = start_server_pull();
let uri: Uri = "file:///pull_unchanged.tex".parse().unwrap();
did_open(&client, &uri, 1, "\\section{Hi}\n\ntext.\n");
pull_diagnostic(&client, 12, &uri, None);
let first = recv_document_diagnostic_report(&client, 12);
assert!(matches!(first, DocumentDiagnosticReport::Full(_)));
let result_id = report_result_id(&first).expect("full report carries a result_id");
pull_diagnostic(&client, 13, &uri, Some(result_id.clone()));
let second = recv_document_diagnostic_report(&client, 13);
assert!(
matches!(second, DocumentDiagnosticReport::Unchanged(_)),
"an unchanged document must report `unchanged`, got {second:?}"
);
assert_eq!(report_result_id(&second), Some(result_id));
shutdown(&client, server_thread);
}
#[test]
fn lsp_push_client_still_receives_pushes() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///push.tex".parse().unwrap();
did_open(&client, &uri, 1, "\\begin{itemize}\n\\item a\n");
let diags = recv_diagnostics(&client);
assert_eq!(diags.uri, uri);
assert!(
!diags.diagnostics.is_empty(),
"push-mode client must still receive pushed diagnostics"
);
shutdown(&client, server_thread);
}
fn start_server_watching() -> (Connection, std::thread::JoinHandle<()>, RegistrationParams) {
let (server, client) = Connection::memory();
let server_thread = std::thread::spawn(move || badness::lsp::serve(server).unwrap());
let params = InitializeParams {
capabilities: ClientCapabilities {
workspace: Some(WorkspaceClientCapabilities {
did_change_watched_files: Some(DidChangeWatchedFilesClientCapabilities {
dynamic_registration: Some(true),
relative_pattern_support: None,
}),
..Default::default()
}),
..Default::default()
},
..Default::default()
};
send_request(
&client,
1,
"initialize",
serde_json::to_value(params).unwrap(),
);
let resp = recv_response(&client);
assert_eq!(resp.id, RequestId::from(1));
send_notification(
&client,
"initialized",
serde_json::to_value(InitializedParams {}).unwrap(),
);
let reg = match recv(&client) {
Message::Request(req) if req.method == "client/registerCapability" => {
client
.sender
.send(Message::Response(Response::new_ok(
req.id,
serde_json::Value::Null,
)))
.unwrap();
serde_json::from_value::<RegistrationParams>(req.params)
.expect("valid RegistrationParams")
}
other => panic!("expected client/registerCapability, got {other:?}"),
};
(client, server_thread, reg)
}
fn did_change_watched_files(client: &Connection, events: &[(Uri, FileChangeType)]) {
let changes = events
.iter()
.map(|(uri, typ)| FileEvent::new(uri.clone(), *typ))
.collect();
send_notification(
client,
"workspace/didChangeWatchedFiles",
serde_json::to_value(DidChangeWatchedFilesParams { changes }).unwrap(),
);
}
fn recv_diagnostics_matching(
client: &Connection,
uri: &Uri,
want: impl Fn(&[String]) -> bool,
) -> PublishDiagnosticsParams {
loop {
match recv(client) {
Message::Notification(not) if not.method == "textDocument/publishDiagnostics" => {
let diags: PublishDiagnosticsParams =
serde_json::from_value(not.params).expect("valid PublishDiagnosticsParams");
if &diags.uri == uri && want(&rule_codes(&diags)) {
return diags;
}
}
Message::Notification(_) => {}
Message::Request(req) => {
client
.sender
.send(Message::Response(Response::new_ok(
req.id,
serde_json::Value::Null,
)))
.unwrap();
}
Message::Response(_) => {}
}
}
}
#[test]
fn lsp_registers_file_watchers_on_initialized() {
let (client, server_thread, reg) = start_server_watching();
assert_eq!(reg.registrations.len(), 1, "one registration: {reg:?}");
let r = ®.registrations[0];
assert_eq!(r.method, "workspace/didChangeWatchedFiles");
let opts = r.register_options.as_ref().expect("watcher options");
let globs: Vec<String> = opts["watchers"]
.as_array()
.expect("watchers array")
.iter()
.map(|w| w["globPattern"].as_str().expect("string glob").to_owned())
.collect();
assert!(
globs.contains(&"**/*.{tex,bib}".to_owned()),
"expected the tex/bib glob, got {globs:?}"
);
assert!(
globs.contains(&"**/badness.toml".to_owned()),
"expected the config glob, got {globs:?}"
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_no_watcher_registration_without_capability() {
let (client, server_thread) = start_server(None);
let uri: Uri = "file:///nowatch.tex".parse().unwrap();
did_open(&client, &uri, 1, "\\bf hi\n");
let diags = recv_diagnostics(&client); assert_eq!(diags.uri, uri);
shutdown(&client, server_thread);
}
#[test]
fn lsp_watched_tex_change_reanalyzes_open_doc() {
let dir = tempfile::tempdir().expect("temp dir");
let part_path = dir.path().join("part.tex");
std::fs::write(&part_path, "\\label{sec:intro}\n").unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\documentclass{article}\n\
\\begin{document}\n\
\\input{part}\n\
\\ref{sec:intro}\n\
\\end{document}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let uri = path_to_file_uri(&main_path);
did_open(&client, &uri, 1, main);
recv_diagnostics_matching(&client, &uri, |codes| {
!codes.iter().any(|c| c == "undefined-ref")
});
std::fs::write(&part_path, "% the label is gone now\n").unwrap();
did_change_watched_files(
&client,
&[(path_to_file_uri(&part_path), FileChangeType::CHANGED)],
);
let diags = recv_diagnostics_matching(&client, &uri, |codes| {
codes.iter().any(|c| c == "undefined-ref")
});
assert!(
rule_codes(&diags).iter().any(|c| c == "undefined-ref"),
"expected undefined-ref after the label was removed on disk, got {:?}",
diags.diagnostics
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_watched_bib_change_reanalyzes_open_doc() {
let dir = tempfile::tempdir().expect("temp dir");
let bib_path = dir.path().join("refs.bib");
std::fs::write(&bib_path, "@article{knuth1984, title={The TeXbook}}\n").unwrap();
let main_path = dir.path().join("main.tex");
let main = "\\documentclass{article}\n\
\\addbibresource{refs.bib}\n\
\\begin{document}\n\
\\cite{knuth1984}\n\
\\end{document}\n";
std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let uri = path_to_file_uri(&main_path);
did_open(&client, &uri, 1, main);
recv_diagnostics_matching(&client, &uri, |codes| {
!codes.iter().any(|c| c == "undefined-citation")
});
std::fs::write(&bib_path, "@article{lamport1986, title={LaTeX}}\n").unwrap();
did_change_watched_files(
&client,
&[(path_to_file_uri(&bib_path), FileChangeType::CHANGED)],
);
let diags = recv_diagnostics_matching(&client, &uri, |codes| {
codes.iter().any(|c| c == "undefined-citation")
});
assert!(
rule_codes(&diags).iter().any(|c| c == "undefined-citation"),
"expected undefined-citation after the key was removed on disk, got {:?}",
diags.diagnostics
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_watched_config_change_reanalyzes_open_doc() {
let dir = tempfile::tempdir().expect("temp dir");
let main_path = dir.path().join("main.tex");
let main = "\\bf hi\n"; std::fs::write(&main_path, main).unwrap();
let (client, server_thread) = start_server(None);
let uri = path_to_file_uri(&main_path);
did_open(&client, &uri, 1, main);
let diags = recv_diagnostics_matching(&client, &uri, |codes| {
codes.iter().any(|c| c == "deprecated-command")
});
assert!(
rule_codes(&diags).iter().any(|c| c == "deprecated-command"),
"expected deprecated-command before the config ignores it, got {:?}",
diags.diagnostics
);
let config_path = dir.path().join("badness.toml");
std::fs::write(&config_path, "[lint]\nignore = [\"deprecated-command\"]\n").unwrap();
did_change_watched_files(
&client,
&[(path_to_file_uri(&config_path), FileChangeType::CREATED)],
);
let diags = recv_diagnostics_matching(&client, &uri, |codes| {
!codes.iter().any(|c| c == "deprecated-command")
});
assert!(
!rule_codes(&diags).iter().any(|c| c == "deprecated-command"),
"deprecated-command must stop firing once the config ignores it, got {:?}",
diags.diagnostics
);
shutdown(&client, server_thread);
}
#[test]
fn lsp_watched_change_for_open_buffer_is_ignored() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("open.tex");
std::fs::write(&path, "\\bf on disk\n").unwrap(); let uri = path_to_file_uri(&path);
let (client, server_thread) = start_server(None);
did_open(&client, &uri, 1, "clean buffer\n");
let diags = recv_diagnostics(&client);
assert_eq!(diags.uri, uri);
assert!(
!rule_codes(&diags).iter().any(|c| c == "deprecated-command"),
"the clean buffer must not report the disk's deprecated command, got {:?}",
diags.diagnostics
);
did_change_watched_files(&client, &[(uri.clone(), FileChangeType::CHANGED)]);
did_change_full(&client, &uri, 2, "still clean\n");
let diags = recv_diagnostics_matching(&client, &uri, |_| true);
assert!(
!rule_codes(&diags).iter().any(|c| c == "deprecated-command"),
"an open buffer's diagnostics must track the overlay, not disk, got {:?}",
diags.diagnostics
);
shutdown(&client, server_thread);
}