#[path = "lsp_protocol/harness.rs"]
mod harness;
#[path = "lsp_protocol/quirks.rs"]
mod quirks;
use std::time::{Duration, Instant};
use harness::{LspTestClient, test_uri};
use lsp_types::request::{CodeLensRequest, CodeLensResolve, SemanticTokensFullRequest};
use lsp_types::{CodeLensParams, SemanticTokensParams, TextDocumentIdentifier};
#[test]
fn harness_smoke_initialize_and_codelens() {
let mut client = LspTestClient::spawn();
client.initialize();
let uri = test_uri("smoke.beancount");
client.open_document(&uri, "2024-01-01 open Assets:Bank USD\n");
let lenses: Option<Vec<lsp_types::CodeLens>> =
client.request::<CodeLensRequest>(CodeLensParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
});
let lenses = lenses.expect("codeLens returned Some on a non-empty document");
assert!(
!lenses.is_empty(),
"open directive should produce at least one lens"
);
}
#[test]
fn issue_1253_balance_lens_ships_eagerly_resolved_with_cancel_belt_and_braces() {
let mut client = LspTestClient::spawn();
client.initialize();
let uri = test_uri("issue_1253.beancount");
client.open_document(
&uri,
"2012-01-01 open Assets:Bank\n\
2012-01-01 open Income:Employment\n\
\n\
2012-02-01 * \"Salary\"\n \
Assets:Bank 1000 USD\n \
Income:Employment\n\
\n\
2012-02-02 balance Assets:Bank 1000 USD\n",
);
let id = client.next_request_id();
let req = lsp_server::Request {
id: id.clone(),
method: <CodeLensRequest as lsp_types::request::Request>::METHOD.to_string(),
params: serde_json::to_value(CodeLensParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
};
client.raw_send_request(req).expect("send codeLens request");
quirks::nvim_cancel_race(&client, &id);
let mut diagnostic_payloads: Vec<lsp_types::PublishDiagnosticsParams> = Vec::new();
let deadline = Instant::now() + Duration::from_secs(10);
let resp = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let msg = client
.recv_with_timeout(remaining)
.expect("timed out waiting for codeLens response");
match msg {
lsp_server::Message::Response(r) if r.id == id => break r,
lsp_server::Message::Notification(n)
if n.method == "textDocument/publishDiagnostics" =>
{
let p: lsp_types::PublishDiagnosticsParams =
serde_json::from_value(n.params).unwrap();
diagnostic_payloads.push(p);
}
_ => {}
}
};
let bad: Vec<_> = diagnostic_payloads
.iter()
.filter(|p| !p.diagnostics.is_empty())
.collect();
assert!(
bad.is_empty(),
"valid balance assertion must not produce any diagnostic; \
the user reported #1253's lens looking like an error, but \
the underlying validator must not flag it. captured payloads: \
{bad:?}"
);
let result = resp
.result
.expect("server returned a result, not an error, for cancelled-but-completed codeLens");
let lenses: Option<Vec<lsp_types::CodeLens>> = serde_json::from_value(result).unwrap();
let lenses = lenses.expect("lenses should be Some on a non-empty document");
let balance_lens = lenses
.iter()
.find(|l| {
l.command
.as_ref()
.is_some_and(|c| c.title.contains("Balance:"))
})
.expect("balance lens emitted");
let cmd = balance_lens
.command
.as_ref()
.expect("balance lens carries a command (no placeholder, no resolve)");
assert!(
cmd.title.contains('✓'),
"issue #1253: passing assertion must ship with the real ✓ \
title on the initial response, not a `(checking…)` \
placeholder. got {:?}",
cmd.title
);
assert!(
!cmd.title.contains("checking"),
"issue #1253: title must not contain the `(checking…)` \
placeholder; that's the stuck-state symptom. got {:?}",
cmd.title
);
assert!(
balance_lens.data.is_none(),
"issue #1253: balance lens must not carry a resolve-data \
payload; the round-trip is what nvim could race against. \
got data = {:?}",
balance_lens.data
);
}
#[test]
fn semantic_tokens_round_trip_through_async_dispatch() {
let mut client = LspTestClient::spawn();
client.initialize();
let uri = test_uri("semtok.beancount");
client.open_document(
&uri,
"2024-01-01 open Assets:Cash USD\n\
2024-02-01 * \"Coffee\"\n \
Assets:Cash -5.00 USD\n \
Expenses:Food\n",
);
let tokens: Option<lsp_types::SemanticTokensResult> = client
.request::<SemanticTokensFullRequest>(SemanticTokensParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
});
let tokens = tokens.expect("semanticTokens/full returns Some on a parsed document");
let data_len = match tokens {
lsp_types::SemanticTokensResult::Tokens(t) => t.data.len(),
lsp_types::SemanticTokensResult::Partial(p) => p.data.len(),
};
assert!(
data_len > 0,
"non-empty document must produce at least one semantic token \
delta entry"
);
}
#[test]
fn unknown_method_returns_method_not_found_error() {
let mut client = LspTestClient::spawn();
client.initialize();
let id = client.next_request_id();
client
.raw_send_request(lsp_server::Request {
id: id.clone(),
method: "textDocument/doesNotExist".to_string(),
params: serde_json::json!({}),
})
.expect("send bogus request");
let resp = client.expect_response(&id);
let err = resp
.error
.expect("server returned an error, not a result, for an unknown method");
assert_eq!(
err.code,
lsp_server::ErrorCode::MethodNotFound as i32,
"unknown method must map to JSON-RPC -32601 MethodNotFound; \
got code {} with message {:?}",
err.code,
err.message
);
}
#[test]
fn code_lens_resolve_round_trip_through_async_dispatch() {
let mut client = LspTestClient::spawn();
client.initialize();
let synthetic_lens = lsp_types::CodeLens {
range: lsp_types::Range {
start: lsp_types::Position::new(0, 0),
end: lsp_types::Position::new(0, 0),
},
command: None,
data: None,
};
let resolved: lsp_types::CodeLens = client.request::<CodeLensResolve>(synthetic_lens);
let cmd = resolved
.command
.expect("defensive fallback must populate command on a command:None lens");
assert_eq!(
cmd.command, "rledger.noop",
"fallback command must be rledger.noop so strict clients \
render something benign instead of nvim's literal \
'Unresolved lens'. got {:?}",
cmd.command
);
}
#[test]
fn issue_1264_no_balance_lens_without_matching_diagnostic() {
let mut client = LspTestClient::spawn();
client.initialize();
let uri = test_uri("issue_1264.beancount");
let source = "option \"operating_currency\" \"USD\"\n\
\n\
2012-01-01 open Assets:Bank\n\
2012-01-01 open Equity:Transfer\n\
2012-01-01 open Expenses:Food\n\
2012-01-01 open Income:Employment\n\
\n\
plugin \"beancount_reds_plugins.effective_date.effective_date\" \"{\n\
'Assets': {'earlier': 'Equity:Transfer', 'later': 'Equity:Transfer'},\n\
}\"\n\
\n\
2012-02-01 * \"Salary\"\n \
Assets:Bank 1000 USD\n \
Income:Employment\n\
\n\
2012-02-02 balance Assets:Bank 1000 USD\n\
\n\
2012-02-03 * \"Delayed food purchase\"\n \
Expenses:Food 100 USD\n \
Assets:Bank -100 USD\n \
effective_date: 2012-02-05\n\
\n\
2012-02-03 balance Assets:Bank 1000 USD\n\
2012-02-04 balance Assets:Bank 1000 USD\n\
2012-02-05 balance Assets:Bank 1000 USD\n\
2012-02-06 balance Assets:Bank 900 USD\n";
client.open_document(&uri, source);
let id = client.next_request_id();
let req = lsp_server::Request {
id: id.clone(),
method: <CodeLensRequest as lsp_types::request::Request>::METHOD.to_string(),
params: serde_json::to_value(CodeLensParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
};
client.raw_send_request(req).expect("send codeLens request");
let mut diagnostic_payloads: Vec<lsp_types::PublishDiagnosticsParams> = Vec::new();
let deadline = Instant::now() + Duration::from_secs(15);
let resp = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let msg = client
.recv_with_timeout(remaining)
.expect("timed out waiting for codeLens response");
match msg {
lsp_server::Message::Response(r) if r.id == id => break r,
lsp_server::Message::Notification(n)
if n.method == "textDocument/publishDiagnostics" =>
{
let p: lsp_types::PublishDiagnosticsParams =
serde_json::from_value(n.params).unwrap();
diagnostic_payloads.push(p);
}
_ => {}
}
};
let result = resp.result.expect("codeLens returned a result");
let lenses: Option<Vec<lsp_types::CodeLens>> = serde_json::from_value(result).unwrap();
let lenses = lenses.expect("lenses emitted on a non-empty document");
let latest_payload = diagnostic_payloads
.iter()
.rev()
.find(|p| p.uri.as_str() == uri)
.unwrap_or_else(|| {
panic!(
"no publishDiagnostics arrived for {uri}; captured \
payloads: {:?}",
diagnostic_payloads
.iter()
.map(|p| p.uri.as_str())
.collect::<Vec<_>>()
)
});
let error_lines: std::collections::HashSet<u32> = latest_payload
.diagnostics
.iter()
.filter(|d| d.severity == Some(lsp_types::DiagnosticSeverity::ERROR))
.map(|d| d.range.start.line)
.collect();
let dead_links: Vec<_> = lenses
.iter()
.filter(|l| {
let title = l.command.as_ref().map(|c| c.title.as_str()).unwrap_or("");
title.contains("Balance:") && title.contains("see diagnostic")
})
.filter(|l| !error_lines.contains(&l.range.start.line))
.collect();
assert!(
dead_links.is_empty(),
"issue #1264: balance lens(es) carry `(see diagnostic)` but no \
ERROR diagnostic exists at the same line(s). This is exactly \
the dead-link UX the issue reported. error lines: {error_lines:?}, \
dead-link lenses: {dead_links:?}"
);
}
#[test]
fn real_balance_failure_round_trips_to_warning_lens() {
let mut client = LspTestClient::spawn();
client.initialize();
let uri = test_uri("real_balance_failure.beancount");
let source = "2024-01-01 open Assets:Bank USD\n\
2024-01-01 open Income:Salary\n\
2024-01-15 * \"Deposit\"\n \
Assets:Bank 50.00 USD\n \
Income:Salary\n\
2024-01-31 balance Assets:Bank 100 USD\n";
client.open_document(&uri, source);
let id = client.next_request_id();
let req = lsp_server::Request {
id: id.clone(),
method: <CodeLensRequest as lsp_types::request::Request>::METHOD.to_string(),
params: serde_json::to_value(CodeLensParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
};
client.raw_send_request(req).expect("send codeLens request");
let mut diagnostic_payloads: Vec<lsp_types::PublishDiagnosticsParams> = Vec::new();
let deadline = Instant::now() + Duration::from_secs(15);
let resp = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let msg = client
.recv_with_timeout(remaining)
.expect("timed out waiting for codeLens response");
match msg {
lsp_server::Message::Response(r) if r.id == id => break r,
lsp_server::Message::Notification(n)
if n.method == "textDocument/publishDiagnostics" =>
{
let p: lsp_types::PublishDiagnosticsParams =
serde_json::from_value(n.params).unwrap();
diagnostic_payloads.push(p);
}
_ => {}
}
};
let result = resp.result.expect("codeLens returned a result");
let lenses: Option<Vec<lsp_types::CodeLens>> = serde_json::from_value(result).unwrap();
let lenses = lenses.expect("lenses emitted on a non-empty document");
let latest_payload = diagnostic_payloads
.iter()
.rev()
.find(|p| p.uri.as_str() == uri)
.unwrap_or_else(|| {
panic!(
"no publishDiagnostics arrived for {uri}; captured \
payloads: {:?}",
diagnostic_payloads
.iter()
.map(|p| p.uri.as_str())
.collect::<Vec<_>>()
)
});
let balance_error = latest_payload.diagnostics.iter().find(|d| {
d.severity == Some(lsp_types::DiagnosticSeverity::ERROR)
&& matches!(
&d.code,
Some(lsp_types::NumberOrString::String(s)) if s == "E2001",
)
});
let balance_error = balance_error.unwrap_or_else(|| {
panic!(
"test premise: validator must emit an E2001 for `balance \
Assets:Bank 100 USD` against a 50 USD deposit. captured \
diagnostics: {:?}",
latest_payload.diagnostics
)
});
let balance_lens = lenses
.iter()
.find(|l| {
l.range.start.line == balance_error.range.start.line
&& l.command
.as_ref()
.is_some_and(|c| c.title.contains("Balance:"))
})
.unwrap_or_else(|| {
panic!(
"no balance lens emitted at line {} (where the E2001 \
lives). lenses: {:?}",
balance_error.range.start.line, lenses
)
});
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
cmd.title.contains('âš ') && cmd.title.contains("see diagnostic"),
"real validator failure (E2001) MUST surface as âš on the \
balance lens. got {:?}",
cmd.title
);
}
#[cfg(unix)]
#[test]
fn multi_file_balance_lens_reflects_cross_file_aggregation() {
let tmp = tempfile::tempdir().expect("tempdir");
let journal_path = tmp.path().join("journal.beancount");
let bank_path = tmp.path().join("bank.beancount");
let credit_card_path = tmp.path().join("credit_card.beancount");
std::fs::write(
&bank_path,
"2024-01-01 open Assets:Bank:Checking USD\n\
2024-01-01 open Income:Salary\n\
2024-01-15 * \"Paycheck\"\n \
Assets:Bank:Checking 5000 USD\n \
Income:Salary\n\
2024-01-21 balance Assets:Bank:Checking 4950 USD\n",
)
.expect("write bank.beancount");
std::fs::write(
&credit_card_path,
"2024-01-01 open Liabilities:Credit-Card\n\
2024-01-20 * \"Pay off credit card\"\n \
Assets:Bank:Checking -50 USD\n \
Liabilities:Credit-Card\n",
)
.expect("write credit_card.beancount");
std::fs::write(
&journal_path,
format!(
"include \"{}\"\ninclude \"{}\"\n",
bank_path.display(),
credit_card_path.display()
),
)
.expect("write journal.beancount");
let mut client = LspTestClient::spawn_with_journal(Some(journal_path));
client.initialize();
let bank_uri = format!("file://{}", bank_path.display());
let source = std::fs::read_to_string(&bank_path).expect("read bank");
client.open_document(&bank_uri, &source);
let id = client.next_request_id();
let req = lsp_server::Request {
id: id.clone(),
method: <CodeLensRequest as lsp_types::request::Request>::METHOD.to_string(),
params: serde_json::to_value(CodeLensParams {
text_document: TextDocumentIdentifier {
uri: bank_uri.parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
};
client.raw_send_request(req).expect("send codeLens request");
let mut diagnostic_payloads: Vec<lsp_types::PublishDiagnosticsParams> = Vec::new();
let deadline = Instant::now() + Duration::from_secs(15);
let resp = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let msg = client
.recv_with_timeout(remaining)
.expect("timed out waiting for codeLens response");
match msg {
lsp_server::Message::Response(r) if r.id == id => break r,
lsp_server::Message::Notification(n)
if n.method == "textDocument/publishDiagnostics" =>
{
let p: lsp_types::PublishDiagnosticsParams =
serde_json::from_value(n.params).unwrap();
diagnostic_payloads.push(p);
}
_ => {}
}
};
let result = resp.result.expect("codeLens returned a result");
let lenses: Option<Vec<lsp_types::CodeLens>> = serde_json::from_value(result).unwrap();
let lenses = lenses.expect("lenses emitted");
let bank_diags = diagnostic_payloads
.iter()
.rev()
.find(|p| p.uri.as_str() == bank_uri)
.unwrap_or_else(|| {
panic!(
"no publishDiagnostics for {bank_uri}; captured: {:?}",
diagnostic_payloads
.iter()
.map(|p| p.uri.as_str())
.collect::<Vec<_>>()
)
});
let unexpected_balance_error = bank_diags.diagnostics.iter().find(|d| {
d.severity == Some(lsp_types::DiagnosticSeverity::ERROR)
&& matches!(
&d.code,
Some(lsp_types::NumberOrString::String(s)) if s == "E2001",
)
});
assert!(
unexpected_balance_error.is_none(),
"multi-file validator should have aggregated the -50 USD from \
credit_card.beancount; got an unexpected E2001 on bank.beancount: {:?}",
unexpected_balance_error,
);
let balance_lens = lenses
.iter()
.find(|l| {
l.command
.as_ref()
.is_some_and(|c| c.title.contains("Balance:"))
})
.expect("balance lens emitted");
let cmd = balance_lens.command.as_ref().expect("ships resolved");
assert!(
cmd.title.contains('✓') && cmd.title.contains("4950"),
"multi-file aggregation makes the assertion hold; lens must \
reflect the validator's ✓ verdict. got {:?}",
cmd.title
);
}
#[test]
fn scratch_file_not_in_journal_uses_single_file_mode() {
let tmp = tempfile::tempdir().expect("tempdir");
let journal_path = tmp.path().join("journal.beancount");
std::fs::write(
&journal_path,
"2024-01-01 open Assets:Bank USD\n\
2024-01-01 open Income:Salary\n\
2024-01-15 * \"Paycheck\"\n \
Assets:Bank 100 USD\n \
Income:Salary\n",
)
.expect("write journal");
let mut client = LspTestClient::spawn_with_journal(Some(journal_path));
client.initialize();
let scratch_uri = test_uri("scratch_not_in_journal.beancount");
client.open_document(
&scratch_uri,
"2024-01-01 open Assets:Bank USD\n\
2024-01-01 open Income:Other\n\
2024-02-01 * \"Scratch deposit\"\n \
Assets:Bank 1000 USD\n \
Income:Other\n\
2024-02-02 balance Assets:Bank 1000 USD\n",
);
let lenses: Option<Vec<lsp_types::CodeLens>> =
client.request::<CodeLensRequest>(CodeLensParams {
text_document: TextDocumentIdentifier {
uri: scratch_uri.parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
});
let lenses = lenses.expect("scratch file has directives, lenses emitted");
let balance_lens = lenses
.iter()
.find(|l| {
l.command
.as_ref()
.is_some_and(|c| c.title.contains("Balance:"))
})
.expect("balance lens emitted on the scratch file");
let cmd = balance_lens
.command
.as_ref()
.expect("balance lens carries a command");
assert!(
cmd.title.contains('✓'),
"scratch file's balance is valid against ITS OWN postings; \
the contains_file gate must keep the journal's snapshot \
from leaking into the scratch lens. got {:?}",
cmd.title
);
}
#[cfg(unix)]
#[test]
fn completion_resolve_returns_single_item_and_uses_journal() {
let tmp = tempfile::tempdir().expect("tempdir");
let journal_path = tmp.path().join("main.beancount");
std::fs::write(
&journal_path,
"2024-01-01 open Assets:Bank:Checking USD\n\
2024-01-01 open Income:Salary\n\
2024-01-15 * \"Paycheck\"\n \
Assets:Bank:Checking 5000 USD\n \
Income:Salary\n\
2024-02-20 * \"Rent\"\n \
Assets:Bank:Checking -1500 USD\n \
Expenses:Rent\n",
)
.expect("write journal");
let mut client = LspTestClient::spawn_with_journal(Some(journal_path.clone()));
client.initialize();
let buf_uri = format!("file://{}", tmp.path().join("__buf__.beancount").display());
client.open_document(&buf_uri, "2024-03-01 * \"x\"\n Assets:Bank:Checking\n");
let item = serde_json::json!({
"label": "Assets:Bank:Checking",
"detail": "Account",
"data": { "uri": buf_uri },
});
let id = client.next_request_id();
let req = lsp_server::Request {
id: id.clone(),
method: <lsp_types::request::ResolveCompletionItem as lsp_types::request::Request>::METHOD
.to_string(),
params: item,
};
client.raw_send_request(req).expect("send resolve");
let resp = client.expect_response(&id);
let result = resp.result.expect("resolve returned a result");
assert!(
result.is_object(),
"completionItem/resolve must return a single CompletionItem object, got: {result}"
);
let resolved: lsp_types::CompletionItem =
serde_json::from_value(result).expect("deserialize CompletionItem");
let detail = resolved.detail.clone().expect("detail summary set");
assert!(
detail.contains("3500") && detail.contains("2 txns"),
"detail should summarize the journal balance/count; got: {detail}"
);
let doc = match resolved.documentation {
Some(lsp_types::Documentation::MarkupContent(m)) => m.value,
other => panic!("expected markdown documentation, got {other:?}"),
};
assert!(
doc.contains("2 transactions"),
"resolve should aggregate over the journal (2 txns); got:\n{doc}"
);
assert!(
doc.contains("3500"),
"resolve should show the journal balance 5000-1500=3500; got:\n{doc}"
);
}
#[cfg(unix)]
#[test]
fn included_file_validation_errors_are_published() {
let tmp = tempfile::tempdir().expect("tempdir");
let journal_path = tmp.path().join("journal.beancount");
let good_path = tmp.path().join("good.beancount");
let bad_path = tmp.path().join("bad.beancount");
std::fs::write(
&good_path,
"2024-01-01 open Assets:Cash USD\n2024-01-01 open Expenses:Food USD\n",
)
.expect("write good");
std::fs::write(
&bad_path,
"2024-02-01 * \"unbalanced in include\"\n \
Assets:Cash -5 USD\n \
Expenses:Food 3 USD\n",
)
.expect("write bad");
std::fs::write(
&journal_path,
format!(
"include \"{}\"\ninclude \"{}\"\n",
good_path.display(),
bad_path.display()
),
)
.expect("write journal");
let mut client = LspTestClient::spawn_with_journal(Some(journal_path));
client.initialize();
let good_uri = format!("file://{}", good_path.display());
let good_src = std::fs::read_to_string(&good_path).expect("read good");
client.open_document(&good_uri, &good_src);
let bad_uri = format!("file://{}", bad_path.display());
let deadline = Instant::now() + Duration::from_secs(15);
let mut seen: Vec<String> = Vec::new();
let bad_diags = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break None;
}
let Some(msg) = client.recv_with_timeout(remaining) else {
break None;
};
if let lsp_server::Message::Notification(n) = msg
&& n.method == "textDocument/publishDiagnostics"
{
let p: lsp_types::PublishDiagnosticsParams =
serde_json::from_value(n.params).expect("valid publishDiagnostics");
seen.push(p.uri.as_str().to_string());
if p.uri.as_str() == bad_uri && !p.diagnostics.is_empty() {
break Some(p);
}
}
};
let bad_diags = bad_diags.unwrap_or_else(|| {
panic!("no non-empty publishDiagnostics for the unopened included file {bad_uri}; saw URIs: {seen:?}")
});
let has_unbalanced = bad_diags.diagnostics.iter().any(|d| {
d.severity == Some(lsp_types::DiagnosticSeverity::ERROR)
&& matches!(&d.code, Some(lsp_types::NumberOrString::String(s)) if s == "E3001")
});
assert!(
has_unbalanced,
"expected an E3001 (unbalanced) diagnostic for the unopened included file {bad_uri}; got {:?}",
bad_diags.diagnostics
);
}
#[cfg(unix)]
#[test]
fn included_file_diagnostics_are_cleared_when_fixed() {
use lsp_types::{DidChangeWatchedFilesParams, FileChangeType, FileEvent};
let tmp = tempfile::tempdir().expect("tempdir");
let journal_path = tmp.path().join("journal.beancount");
let good_path = tmp.path().join("good.beancount");
let bad_path = tmp.path().join("bad.beancount");
std::fs::write(
&good_path,
"2024-01-01 open Assets:Cash USD\n2024-01-01 open Expenses:Food USD\n",
)
.expect("write good");
std::fs::write(
&bad_path,
"2024-02-01 * \"x\"\n Assets:Cash -5 USD\n Expenses:Food 3 USD\n",
)
.expect("write bad");
std::fs::write(
&journal_path,
format!(
"include \"{}\"\ninclude \"{}\"\n",
good_path.display(),
bad_path.display()
),
)
.expect("write journal");
let mut client = LspTestClient::spawn_with_journal(Some(journal_path));
client.initialize();
let good_uri = format!("file://{}", good_path.display());
client.open_document(
&good_uri,
&std::fs::read_to_string(&good_path).expect("read good"),
);
let bad_uri = format!("file://{}", bad_path.display());
let drain = |client: &mut LspTestClient, want_empty: bool| -> bool {
let deadline = Instant::now() + Duration::from_secs(15);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return false;
}
let Some(msg) = client.recv_with_timeout(remaining) else {
return false;
};
if let lsp_server::Message::Notification(n) = msg
&& n.method == "textDocument/publishDiagnostics"
{
let p: lsp_types::PublishDiagnosticsParams =
serde_json::from_value(n.params).expect("valid publishDiagnostics");
if p.uri.as_str() == bad_uri && p.diagnostics.is_empty() == want_empty {
return true;
}
}
}
};
assert!(
drain(&mut client, false),
"expected a non-empty diagnostic for the unopened included file first"
);
std::fs::write(
&bad_path,
"2024-02-01 * \"x\"\n Assets:Cash -5 USD\n Expenses:Food 5 USD\n",
)
.expect("rewrite bad balanced");
client.notify::<lsp_types::notification::DidChangeWatchedFiles>(DidChangeWatchedFilesParams {
changes: vec![FileEvent {
uri: bad_uri.parse().unwrap(),
typ: FileChangeType::CHANGED,
}],
});
assert!(
drain(&mut client, true),
"expected bad.beancount diagnostics to be cleared (empty publish) after the fix"
);
}
#[test]
fn async_request_invalidated_by_edit_still_gets_a_response() {
let mut client = LspTestClient::spawn();
client.initialize();
let uri = test_uri("async_stale.beancount");
let mut big = String::new();
for i in 0..4000 {
big.push_str(&format!("2024-01-01 open Assets:A{i} USD\n"));
}
client.open_document(&uri, &big);
let id = client.next_request_id();
let req = lsp_server::Request {
id: id.clone(),
method: <SemanticTokensFullRequest as lsp_types::request::Request>::METHOD.to_string(),
params: serde_json::to_value(SemanticTokensParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
})
.unwrap(),
};
client
.raw_send_request(req)
.expect("send semanticTokens/full");
client.notify::<lsp_types::notification::DidChangeTextDocument>(
lsp_types::DidChangeTextDocumentParams {
text_document: lsp_types::VersionedTextDocumentIdentifier {
uri: uri.parse().unwrap(),
version: 2,
},
content_changes: vec![lsp_types::TextDocumentContentChangeEvent {
range: None,
range_length: None,
text: "2024-01-01 open Assets:Edited USD\n".to_string(),
}],
},
);
let deadline = Instant::now() + Duration::from_secs(15);
let resp = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(
!remaining.is_zero(),
"timed out waiting for a response to the async request — it was likely dropped (the bug)"
);
let msg = client
.recv_with_timeout(remaining)
.expect("no response for the async request — it was dropped (the bug)");
if let lsp_server::Message::Response(r) = msg
&& r.id == id
{
break r;
}
};
if let Some(err) = resp.error {
assert_eq!(
err.code, -32801,
"a stale async result must be reported as ContentModified, got {err:?}"
);
}
}
#[cfg(unix)]
#[test]
fn workspace_symbol_finds_symbols_in_unopened_included_files() {
use lsp_types::request::WorkspaceSymbolRequest;
let tmp = tempfile::tempdir().expect("tempdir");
let journal_path = tmp.path().join("journal.beancount");
let main_path = tmp.path().join("main.beancount");
let inc_path = tmp.path().join("inc.beancount");
std::fs::write(&main_path, "2024-01-01 open Assets:Bank USD\n").expect("write main");
std::fs::write(&inc_path, "2024-01-01 open Expenses:CrossFileOnly USD\n").expect("write inc");
std::fs::write(
&journal_path,
format!(
"include \"{}\"\ninclude \"{}\"\n",
main_path.display(),
inc_path.display()
),
)
.expect("write journal");
let mut client = LspTestClient::spawn_with_journal(Some(journal_path));
client.initialize();
let main_uri = format!("file://{}", main_path.display());
client.open_document(
&main_uri,
&std::fs::read_to_string(&main_path).expect("read main"),
);
let resp: Option<lsp_types::WorkspaceSymbolResponse> = client
.request::<WorkspaceSymbolRequest>(lsp_types::WorkspaceSymbolParams {
query: "CrossFileOnly".to_string(),
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
});
let symbols = match resp {
Some(lsp_types::WorkspaceSymbolResponse::Flat(v)) => v,
other => panic!("expected a flat workspace-symbol response, got {other:?}"),
};
assert!(
symbols.iter().any(|s| s.name == "Expenses:CrossFileOnly"),
"workspace/symbol must find an account from an unopened included file; got: {:?}",
symbols.iter().map(|s| &s.name).collect::<Vec<_>>()
);
}
#[cfg(unix)]
#[test]
#[allow(clippy::mutable_key_type)] fn rename_account_spans_included_files() {
use lsp_types::request::Rename;
let tmp = tempfile::tempdir().expect("tempdir");
let journal_path = tmp.path().join("journal.beancount");
let main_path = tmp.path().join("main.beancount");
let inc_path = tmp.path().join("inc.beancount");
std::fs::write(&main_path, "2024-01-01 open Assets:Bank USD\n").expect("write main");
std::fs::write(
&inc_path,
"2024-01-01 open Expenses:Food USD\n\
2024-02-01 * \"x\"\n Assets:Bank -5 USD\n Expenses:Food 5 USD\n",
)
.expect("write inc");
std::fs::write(
&journal_path,
format!(
"include \"{}\"\ninclude \"{}\"\n",
main_path.display(),
inc_path.display()
),
)
.expect("write journal");
let mut client = LspTestClient::spawn_with_journal(Some(journal_path));
client.initialize();
let main_uri = format!("file://{}", main_path.display());
client.open_document(
&main_uri,
&std::fs::read_to_string(&main_path).expect("read main"),
);
let edit: Option<lsp_types::WorkspaceEdit> =
client.request::<Rename>(lsp_types::RenameParams {
text_document_position: lsp_types::TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: main_uri.parse().unwrap(),
},
position: lsp_types::Position::new(0, 16),
},
new_name: "Assets:Checking".to_string(),
work_done_progress_params: Default::default(),
});
let changes = edit
.expect("rename returned an edit")
.changes
.expect("workspace edit has per-file changes");
let inc_uri = format!("file://{}", inc_path.display());
let edited_uris: Vec<&str> = changes.keys().map(|u| u.as_str()).collect();
assert!(
changes.keys().any(|u| u.as_str() == main_uri),
"rename must edit the open file; edited: {edited_uris:?}"
);
assert!(
changes.keys().any(|u| u.as_str() == inc_uri),
"rename must also edit the included file (cross-file usage); edited: {edited_uris:?}"
);
}
#[cfg(unix)]
#[test]
fn references_span_included_files() {
use lsp_types::request::References;
let tmp = tempfile::tempdir().expect("tempdir");
let journal_path = tmp.path().join("journal.beancount");
let main_path = tmp.path().join("main.beancount");
let inc_path = tmp.path().join("inc.beancount");
std::fs::write(&main_path, "2024-01-01 open Assets:Bank USD\n").expect("write main");
std::fs::write(
&inc_path,
"2024-01-01 open Expenses:Food USD\n\
2024-02-01 * \"x\"\n Assets:Bank -5 USD\n Expenses:Food 5 USD\n",
)
.expect("write inc");
std::fs::write(
&journal_path,
format!(
"include \"{}\"\ninclude \"{}\"\n",
main_path.display(),
inc_path.display()
),
)
.expect("write journal");
let mut client = LspTestClient::spawn_with_journal(Some(journal_path));
client.initialize();
let main_uri = format!("file://{}", main_path.display());
client.open_document(
&main_uri,
&std::fs::read_to_string(&main_path).expect("read main"),
);
let locations: Option<Vec<lsp_types::Location>> =
client.request::<References>(lsp_types::ReferenceParams {
text_document_position: lsp_types::TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: main_uri.parse().unwrap(),
},
position: lsp_types::Position::new(0, 16),
},
context: lsp_types::ReferenceContext {
include_declaration: true,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
});
let locations = locations.unwrap_or_default();
let inc_uri = format!("file://{}", inc_path.display());
let uris: Vec<&str> = locations.iter().map(|l| l.uri.as_str()).collect();
assert!(
locations.iter().any(|l| l.uri.as_str() == main_uri),
"references must include the open file's open directive; got: {uris:?}"
);
assert!(
locations.iter().any(|l| l.uri.as_str() == inc_uri),
"references must include the usage in the included file; got: {uris:?}"
);
}