use std::time::Duration;
use badness::formatter::{FormatStyle, format_with_style};
use lsp_server::{Connection, Message, Notification, Request, RequestId, Response};
use lsp_types::{
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
DocumentFormattingParams, FormattingOptions, InitializeParams, InitializeResult,
InitializedParams, Position, PublishDiagnosticsParams, Range, TextDocumentContentChangeEvent,
TextDocumentIdentifier, TextDocumentItem, TextEdit, Uri, VersionedTextDocumentIdentifier,
};
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.text_document_sync.is_some());
send_notification(
&client,
"initialized",
serde_json::to_value(InitializedParams {}).unwrap(),
);
(client, server_thread)
}
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 = recv_response(client);
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 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);
}