rustledger-lsp 0.21.0

Language Server Protocol implementation for Beancount
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
//! Execute command handler for custom editor commands.
//!
//! Provides commands:
//! - rledger.insertDate: Insert today's date
//! - rledger.sortTransactions: Sort transactions by date
//! - rledger.alignAmounts: Align amounts in a region

use lsp_types::{
    ExecuteCommandParams, MessageType, ShowMessageParams, TextEdit, Uri, WorkspaceEdit,
};
use rustledger_core::Directive;
use rustledger_parser::ParseResult;
use std::collections::HashMap;

use super::formatting::format_document;
use super::utils::{LineIndex, PositionEncoding};

/// Argument key clients can pass to suppress informational no-op
/// notifications (the "already sorted" / "no transactions to sort"
/// toasts that interactive users see). Format-on-save hooks and
/// chained automation pass `{"silent": true}` to opt out; the default
/// (no argument or `silent: false`) preserves toasts so command-
/// palette users get feedback when nothing happened.
const SILENT_ARG_KEY: &str = "silent";

fn is_silent_invocation(arguments: &[serde_json::Value]) -> bool {
    arguments
        .iter()
        .filter_map(serde_json::Value::as_object)
        .any(|obj| {
            obj.get(SILENT_ARG_KEY)
                .and_then(serde_json::Value::as_bool)
                .unwrap_or(false)
        })
}

/// Available commands.
///
/// `rledger.noop` is a deliberate placeholder used by code-lens titles
/// that exist purely to be RENDERED (e.g. the `Balance: X USD (checking…)`
/// placeholder set by `handlers::code_lens` on balance lenses, and the
/// `⚠ Balance: X USD (see diagnostic)` callout set by the resolve path
/// on mismatched assertions — see issue #1245). We advertise it here so
/// strict clients that only forward server-advertised commands will
/// route clicks back to the server cleanly, where `handle_execute_command`
/// silently no-ops.
pub const COMMANDS: &[&str] = &[
    "rledger.insertDate",
    "rledger.sortTransactions",
    "rledger.alignAmounts",
    "rledger.showAccountBalance",
    "rledger.noop",
];

/// Result of an executeCommand handler.
///
/// `response` is the JSON-RPC response body (returned to the caller).
/// `show_message` is an optional `window/showMessage` notification the
/// dispatcher should emit alongside the response. Commands like
/// `rledger.alignAmounts` use this to surface "Cannot align: file has
/// parse errors" feedback that clients (VS Code etc.) would otherwise
/// drop on the floor since executeCommand has no spec-defined way to
/// surface human-readable errors.
#[derive(Debug, Default)]
pub struct ExecuteCommandResponse {
    /// JSON value returned to the client as the `workspace/executeCommand`
    /// response (often a `WorkspaceEdit` to apply, sometimes a small JSON
    /// payload like an inserted-date result).
    pub response: Option<serde_json::Value>,
    /// Optional `window/showMessage` notification the dispatcher sends
    /// alongside the response — used for human-readable feedback that
    /// the executeCommand response shape can't carry.
    pub show_message: Option<ShowMessageParams>,
}

impl ExecuteCommandResponse {
    fn json(value: serde_json::Value) -> Self {
        Self {
            response: Some(value),
            show_message: None,
        }
    }

    fn none() -> Self {
        Self::default()
    }

    fn warn(message: impl Into<String>) -> Self {
        Self {
            response: None,
            show_message: Some(ShowMessageParams {
                typ: MessageType::WARNING,
                message: message.into(),
            }),
        }
    }

    /// Info-level notification — used for no-op acknowledgments where
    /// the user explicitly invoked a command and nothing happened
    /// (e.g. "already sorted", "no transactions to sort"). Distinct
    /// from `warn` so editor themes can style the two differently;
    /// VS Code's `window.showInformationMessage` is non-modal whereas
    /// `showWarningMessage` is more attention-grabbing.
    fn info(message: impl Into<String>) -> Self {
        Self {
            response: None,
            show_message: Some(ShowMessageParams {
                typ: MessageType::INFO,
                message: message.into(),
            }),
        }
    }
}

/// Handle an execute command request.
pub fn handle_execute_command(
    params: &ExecuteCommandParams,
    source: &str,
    parse_result: &ParseResult,
    uri: &Uri,
    encoding: PositionEncoding,
) -> ExecuteCommandResponse {
    match params.command.as_str() {
        "rledger.insertDate" => {
            ExecuteCommandResponse::json(handle_insert_date().unwrap_or(serde_json::Value::Null))
        }
        "rledger.sortTransactions" => {
            let silent = is_silent_invocation(&params.arguments);
            handle_sort_transactions(source, parse_result, uri, silent, encoding)
        }
        "rledger.alignAmounts" => handle_align_amounts(source, parse_result, uri, encoding),
        "rledger.showAccountBalance" => {
            handle_show_account_balance(&params.arguments, parse_result)
        }
        // Intentional no-op: backs the placeholder / "(see diagnostic)"
        // titles that code-lens emits for balance assertions (#1245).
        // The lens exists to be displayed; clicking it has nothing to
        // do. Explicit arm (rather than falling through to the
        // unknown-command warning) so a future contributor doesn't
        // think the lens code is buggy when they grep for the command.
        "rledger.noop" => ExecuteCommandResponse::none(),
        _ => {
            tracing::warn!("Unknown command: {}", params.command);
            ExecuteCommandResponse::none()
        }
    }
}

/// Insert today's date at cursor.
fn handle_insert_date() -> Option<serde_json::Value> {
    let today = jiff::Zoned::now().date().to_string();
    Some(serde_json::json!({
        "text": today
    }))
}

/// Sort all transactions by date.
///
/// `silent` distinguishes interactive invocations (command palette,
/// default) from chained automation (format-on-save hooks that pass
/// `{"silent": true}` as the first argument). Both paths return the
/// same JSON response on success; on a no-op (`<2 transactions` or
/// already-sorted), interactive callers get an info-level
/// `window/showMessage` so they know the command did fire and just
/// had nothing to do, while silent callers get a fully empty response
/// to avoid spamming a toast on every save.
fn handle_sort_transactions(
    source: &str,
    parse_result: &ParseResult,
    uri: &Uri,
    silent: bool,
    encoding: PositionEncoding,
) -> ExecuteCommandResponse {
    let mut transactions: Vec<(rustledger_core::NaiveDate, usize, usize, String)> = Vec::new();

    for spanned in &parse_result.directives {
        if let Directive::Transaction(txn) = &spanned.value {
            let start = spanned.span.start;
            let end = spanned.span.end;
            let text = source[start..end].to_string();
            transactions.push((txn.date, start, end, text));
        }
    }

    if transactions.len() < 2 {
        // Interactive callers (command palette, default) see an info
        // toast so they know the command fired and just had nothing
        // actionable. Chained automation (format-on-save) passes
        // `silent: true` to suppress the toast.
        if silent {
            return ExecuteCommandResponse::none();
        }
        return ExecuteCommandResponse::info(
            "No transactions to sort (the file has fewer than 2 transactions).",
        );
    }

    let mut sorted = transactions.clone();
    sorted.sort_by_key(|(date, start, _, _)| (*date, *start));

    if transactions == sorted {
        // Same interactive vs silent split as the <2 case above.
        if silent {
            return ExecuteCommandResponse::none();
        }
        return ExecuteCommandResponse::info("Transactions are already sorted by date.");
    }

    let Some(first_start) = transactions.iter().map(|(_, s, _, _)| *s).min() else {
        return ExecuteCommandResponse::none();
    };
    let Some(last_end) = transactions.iter().map(|(_, _, e, _)| *e).max() else {
        return ExecuteCommandResponse::none();
    };

    let sorted_text: String = sorted
        .iter()
        .map(|(_, _, _, text)| text.as_str())
        .collect::<Vec<_>>()
        .join("\n\n");

    let line_index = LineIndex::new(source, encoding);
    let (start_line, start_col) = line_index.offset_to_position(first_start);
    let (end_line, end_col) = line_index.offset_to_position(last_end);

    let edit = TextEdit {
        range: lsp_types::Range {
            start: lsp_types::Position::new(start_line, start_col),
            end: lsp_types::Position::new(end_line, end_col),
        },
        new_text: sorted_text,
    };

    #[allow(clippy::mutable_key_type)]
    let mut changes = HashMap::new();
    changes.insert(uri.clone(), vec![edit]);

    let workspace_edit = WorkspaceEdit {
        changes: Some(changes),
        document_changes: None,
        change_annotations: None,
    };

    match serde_json::to_value(workspace_edit) {
        Ok(v) => ExecuteCommandResponse::json(v),
        Err(_) => ExecuteCommandResponse::none(),
    }
}

/// Align amounts in the document by delegating to the *canonical*
/// document formatter ([`format_document`]) and then keeping only
/// the edits that actually touch a posting line.
///
/// `format_document` runs the same `rustledger_parser::format::format_source`
/// pipeline as `rledger format`, so the column widths this command
/// resolves agree with the on-disk output. The previous bespoke logic
/// here ran a regex-style line scanner with a "max-existing-column"
/// alignment heuristic that matched neither the LSP `textDocument/
/// formatting` request nor the CLI — the duplicate-code-path class
/// issue #1142 warned about.
///
/// **Filter rationale.** The command name promises *alignment*. A
/// canonical-format edit list also includes blank-line collapse,
/// comment normalization, and trailing-whitespace strip — applying
/// those under an "Align Amounts" command label silently mutates the
/// buffer in ways the user did not invoke this command for. We
/// classify each edit by whether the source line at the edit's start
/// is a posting line (indented, beginning with an optional posting
/// flag and then an account name); only those survive. If nothing
/// survives, the user gets a clear "No amounts to align" message
/// instead of a misleading canonical-reformat WorkspaceEdit.
///
/// **Parse-error semantics.** On a file with parse errors,
/// `format_document` returns `None` and this command surfaces a
/// dedicated "cannot align" message rather than running the surface-
/// cleanup fallback that `handle_formatting` uses.
fn handle_align_amounts(
    source: &str,
    parse_result: &ParseResult,
    uri: &Uri,
    encoding: PositionEncoding,
) -> ExecuteCommandResponse {
    let Some(edits) = format_document(source, parse_result, encoding) else {
        // Parse errors: surface via window/showMessage so the user
        // actually sees the failure. executeCommand responses are
        // discarded by VS Code etc.; showMessage is the spec-mandated
        // way to display a textual error.
        if !parse_result.errors.is_empty() {
            return ExecuteCommandResponse::warn(format!(
                "Cannot align amounts: source has {} parse error(s); fix them first",
                parse_result.errors.len()
            ));
        }
        return ExecuteCommandResponse::warn("No amounts to align");
    };

    let posting_edits: Vec<_> = edits
        .into_iter()
        .filter(|e| edit_touches_posting_line(source, e))
        .collect();
    if posting_edits.is_empty() {
        return ExecuteCommandResponse::warn("No amounts to align");
    }

    #[allow(clippy::mutable_key_type)]
    let mut changes = HashMap::new();
    changes.insert(uri.clone(), posting_edits);

    let workspace_edit = WorkspaceEdit {
        changes: Some(changes),
        document_changes: None,
        change_annotations: None,
    };

    match serde_json::to_value(workspace_edit) {
        Ok(v) => ExecuteCommandResponse::json(v),
        Err(_) => ExecuteCommandResponse::none(),
    }
}

/// Heuristic for whether a `TextEdit` modifies a posting line — i.e.,
/// a line whose alignment the `rledger.alignAmounts` command is
/// supposed to touch.
///
/// Rule: an indented line is a posting iff its first non-whitespace
/// character is NOT
///
/// - `;` or `%` (comment),
/// - a lowercase letter (Beancount metadata keys are lowercase
///   identifiers).
///
/// That single test covers:
///
/// - ASCII postings (`Assets:Cash 100 USD`): first char `A` is
///   not lowercase → posting.
/// - Cyrillic / Greek postings: first char is `\p{Lu}` (uppercase),
///   not lowercase → posting.
/// - CJK / Hebrew / Arabic / Devanagari postings: first char is
///   `\p{Lo}` (letter, other), neither uppercase nor lowercase →
///   posting. The previous `char::is_uppercase` form rejected
///   these because is_uppercase is `\p{Lu}` only; the lexer's
///   ACCOUNT regex is the wider `[\p{Lu}\p{Lo}\p{Lt}]…`.
/// - Posting flags (`!`, `*`, `#`, `?`, `&`, `PSTCURM`, and any
///   single-uppercase-letter currency-as-flag): the flag is not
///   lowercase either, so the line is accepted without having to
///   hardcode the flag set.
/// - Metadata sub-lines (`  key: value`): lowercase `k` → rejected.
/// - Comments (`  ; comment`): `;` matched → rejected.
/// - Top-level directives and blanks: rejected by the indent /
///   emptiness checks above the discriminator.
fn edit_touches_posting_line(source: &str, edit: &TextEdit) -> bool {
    let line_idx = edit.range.start.line as usize;
    let Some(line) = source.lines().nth(line_idx) else {
        return false;
    };
    let trimmed = line.trim_start();
    if trimmed.is_empty() || line.len() == trimmed.len() {
        return false; // blank line or top-level directive
    }
    let Some(first) = trimmed.chars().next() else {
        return false;
    };
    if first == ';' || first == '%' {
        return false;
    }
    !first.is_lowercase()
}

/// Show account balance.
fn handle_show_account_balance(
    arguments: &[serde_json::Value],
    parse_result: &ParseResult,
) -> ExecuteCommandResponse {
    let Some(account) = arguments.first().and_then(|a| a.as_str()) else {
        return ExecuteCommandResponse::warn(
            "rledger.showAccountBalance: account argument missing",
        );
    };

    let mut balances: HashMap<String, rustledger_core::Decimal> = HashMap::new();
    for spanned in &parse_result.directives {
        if let Directive::Transaction(txn) = &spanned.value {
            for posting in &txn.postings {
                if posting.account.as_ref() == account
                    && let Some(units) = &posting.units
                    && let Some(number) = units.number()
                {
                    let currency = units.currency().unwrap_or("???").to_string();
                    *balances.entry(currency).or_default() += number;
                }
            }
        }
    }

    if balances.is_empty() {
        return ExecuteCommandResponse::warn(format!(
            "No transactions found for account '{account}'"
        ));
    }

    let balance_str: String = balances
        .iter()
        .map(|(currency, amount)| format!("{amount} {currency}"))
        .collect::<Vec<_>>()
        .join(", ");

    ExecuteCommandResponse::json(serde_json::json!({
        "account": account,
        "balance": balance_str,
        "balances": balances
    }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use rustledger_parser::parse;

    #[test]
    fn test_insert_date() {
        let result = handle_insert_date();
        assert!(result.is_some());

        let value = result.unwrap();
        let text = value.get("text").and_then(|v| v.as_str()).unwrap();
        // Should be in YYYY-MM-DD format
        assert_eq!(text.len(), 10);
        assert!(text.chars().nth(4) == Some('-'));
        assert!(text.chars().nth(7) == Some('-'));
    }

    #[test]
    fn test_show_account_balance() {
        let source = r#"2024-01-01 open Assets:Bank USD
2024-01-15 * "Deposit"
  Assets:Bank  100.00 USD
  Income:Salary
2024-01-20 * "Coffee"
  Assets:Bank  -5.00 USD
  Expenses:Food
"#;
        let result = parse(source);

        let args = vec![serde_json::json!("Assets:Bank")];
        let response = handle_show_account_balance(&args, &result);
        let value = response.response.expect("expected a JSON response");
        let balance_str = value.get("balance").and_then(|v| v.as_str()).unwrap();
        assert!(balance_str.contains("95")); // 100 - 5 = 95
        assert!(balance_str.contains("USD"));
    }

    /// Unknown account: response.is_none(), feedback surfaces via
    /// window/showMessage so editors that don't subscribe to the
    /// executeCommand return value still see the warning.
    #[test]
    fn show_account_balance_unknown_account_surfaces_show_message() {
        let source = "2024-01-01 open Assets:Bank USD\n";
        let result = parse(source);
        let args = vec![serde_json::json!("Assets:Missing")];
        let response = handle_show_account_balance(&args, &result);
        assert!(response.response.is_none());
        let msg = response.show_message.expect("expected showMessage");
        assert!(msg.message.contains("No transactions"), "{msg:?}");
    }

    /// Missing first argument: previously a silent None; now a
    /// showMessage so misbehaving clients (and human users invoking
    /// the command without an argument) see the diagnostic.
    #[test]
    fn show_account_balance_missing_arg_surfaces_show_message() {
        let result = parse("");
        let response = handle_show_account_balance(&[], &result);
        assert!(response.response.is_none());
        let msg = response.show_message.expect("expected showMessage");
        assert!(msg.message.contains("account argument"), "{msg:?}");
    }

    /// First argument present but not a string (e.g., null or an
    /// object from a buggy client) also surfaces the same diagnostic.
    #[test]
    fn show_account_balance_wrong_type_arg_surfaces_show_message() {
        let result = parse("");
        let args = vec![serde_json::json!(null)];
        let response = handle_show_account_balance(&args, &result);
        assert!(response.response.is_none());
        let msg = response.show_message.expect("expected showMessage");
        assert!(msg.message.contains("account argument"), "{msg:?}");

        let args = vec![serde_json::json!({"oops": "object"})];
        let response = handle_show_account_balance(&args, &result);
        assert!(response.response.is_none());
        assert!(response.show_message.is_some());
    }

    /// `handle_sort_transactions` no-op behavior is split on the
    /// `silent` flag: interactive callers (command palette default,
    /// `silent=false`) get an info-level `showMessage` so they know
    /// the command fired; chained automation (format-on-save hooks
    /// passing `silent=true`) gets a fully empty response so toasts
    /// don't pop on every save. These tests pin BOTH halves of the
    /// contract — a future revert in either direction surfaces here.
    #[test]
    fn sort_transactions_empty_file_silent_mode_is_silent() {
        use lsp_types::Uri;
        let result = parse("");
        let uri: Uri = "file:///test.beancount".parse().unwrap();
        let response = handle_sort_transactions("", &result, &uri, true, PositionEncoding::Utf16);
        assert!(response.response.is_none(), "expected silent response");
        assert!(
            response.show_message.is_none(),
            "expected no showMessage in silent mode, got {:?}",
            response.show_message
        );
    }

    #[test]
    fn sort_transactions_empty_file_interactive_shows_info() {
        use lsp_types::Uri;
        let result = parse("");
        let uri: Uri = "file:///test.beancount".parse().unwrap();
        let response = handle_sort_transactions("", &result, &uri, false, PositionEncoding::Utf16);
        assert!(response.response.is_none());
        let msg = response.show_message.expect("expected info showMessage");
        assert_eq!(msg.typ, MessageType::INFO);
        assert!(
            msg.message.contains("No transactions to sort"),
            "expected 'No transactions to sort' info, got {msg:?}"
        );
    }

    #[test]
    fn sort_transactions_single_transaction_silent_mode_is_silent() {
        use lsp_types::Uri;
        let source = "2024-01-01 * \"Solo\"\n  Assets:Bank  -5.00 USD\n  Expenses:Food\n";
        let result = parse(source);
        let uri: Uri = "file:///test.beancount".parse().unwrap();
        let response =
            handle_sort_transactions(source, &result, &uri, true, PositionEncoding::Utf16);
        assert!(response.response.is_none());
        assert!(response.show_message.is_none());
    }

    #[test]
    fn sort_transactions_single_transaction_interactive_shows_info() {
        use lsp_types::Uri;
        let source = "2024-01-01 * \"Solo\"\n  Assets:Bank  -5.00 USD\n  Expenses:Food\n";
        let result = parse(source);
        let uri: Uri = "file:///test.beancount".parse().unwrap();
        let response =
            handle_sort_transactions(source, &result, &uri, false, PositionEncoding::Utf16);
        assert!(response.response.is_none());
        let msg = response.show_message.expect("expected info showMessage");
        assert_eq!(msg.typ, MessageType::INFO);
    }

    #[test]
    fn sort_transactions_already_sorted_silent_mode_is_silent() {
        use lsp_types::Uri;
        let source = "2024-01-01 * \"A\"\n  Assets:Bank  -1.00 USD\n  Expenses:Food\n\n2024-02-01 * \"B\"\n  Assets:Bank  -2.00 USD\n  Expenses:Food\n";
        let result = parse(source);
        let uri: Uri = "file:///test.beancount".parse().unwrap();
        let response =
            handle_sort_transactions(source, &result, &uri, true, PositionEncoding::Utf16);
        assert!(
            response.response.is_none(),
            "already-sorted in silent mode should be silent, got {:?}",
            response.response
        );
        assert!(response.show_message.is_none());
    }

    #[test]
    fn sort_transactions_already_sorted_interactive_shows_info() {
        use lsp_types::Uri;
        let source = "2024-01-01 * \"A\"\n  Assets:Bank  -1.00 USD\n  Expenses:Food\n\n2024-02-01 * \"B\"\n  Assets:Bank  -2.00 USD\n  Expenses:Food\n";
        let result = parse(source);
        let uri: Uri = "file:///test.beancount".parse().unwrap();
        let response =
            handle_sort_transactions(source, &result, &uri, false, PositionEncoding::Utf16);
        assert!(response.response.is_none());
        let msg = response.show_message.expect("expected info showMessage");
        assert_eq!(msg.typ, MessageType::INFO);
        assert!(
            msg.message.contains("already sorted"),
            "expected 'already sorted' info, got {msg:?}"
        );
    }

    #[test]
    fn sort_transactions_out_of_order_produces_edit() {
        use lsp_types::Uri;
        // B before A by date: actually needs sorting.
        let source = "2024-02-01 * \"B\"\n  Assets:Bank  -2.00 USD\n  Expenses:Food\n\n2024-01-01 * \"A\"\n  Assets:Bank  -1.00 USD\n  Expenses:Food\n";
        let result = parse(source);
        let uri: Uri = "file:///test.beancount".parse().unwrap();
        let response =
            handle_sort_transactions(source, &result, &uri, false, PositionEncoding::Utf16);
        assert!(
            response.response.is_some(),
            "out-of-order should produce a WorkspaceEdit"
        );
    }

    /// `is_silent_invocation` extracts the `silent` flag from the
    /// command's `arguments` array. Default (no args) is interactive;
    /// `{"silent": true}` opts out; anything else is interactive.
    #[test]
    fn silent_invocation_arg_parser() {
        assert!(!is_silent_invocation(&[]));
        assert!(!is_silent_invocation(&[serde_json::json!({})]));
        assert!(!is_silent_invocation(&[
            serde_json::json!({"silent": false})
        ]));
        assert!(is_silent_invocation(&[serde_json::json!({"silent": true})]));
        // Non-object argument: ignored.
        assert!(!is_silent_invocation(&[serde_json::json!("silent")]));
        assert!(!is_silent_invocation(&[serde_json::json!(true)]));
    }

    #[test]
    fn test_align_amounts_produces_canonical_alignment() {
        // Goes beyond a shape-only smoke test: applies the emitted edits
        // to the source and asserts the amounts line up at the file-wide
        // auto column (the same geometry `rledger format` uses on disk).
        // Pins the contract that `rledger.alignAmounts`,
        // `textDocument/formatting`, and `rledger format` agree on the
        // canonical alignment — now a *document-wide* property, not a
        // fixed column. The two postings have different-length accounts,
        // so the widest prefix (Assets:Bank:Checking) drives the column;
        // a per-line formatter would align each to its own number and
        // fail the cross-line assertion below.
        use lsp_types::Uri;

        let misaligned =
            "2024-01-15 * \"Coffee\"\n  Assets:Bank:Checking -5.00 USD\n  Expenses:Food 5.00 USD\n";
        let result = parse(misaligned);
        let uri: Uri = "file:///test.beancount".parse().unwrap();
        let response = handle_align_amounts(misaligned, &result, &uri, PositionEncoding::Utf16);
        let out = response
            .response
            .expect("align should return a JSON response");

        let changes = out.get("changes").and_then(|v| v.as_object()).unwrap();
        let edits = changes.values().next().unwrap().as_array().unwrap();
        assert!(!edits.is_empty(), "misaligned input must produce edits");

        // The number field begins two columns past the widest account
        // prefix; the widest number (`-5.00`) fills the field exactly, so
        // it starts right at that column.
        let expected_num_col = "  Assets:Bank:Checking".chars().count() + 2;
        let applied = apply_lsp_text_edits(misaligned, edits);
        let bank_line = applied
            .lines()
            .find(|l| l.contains("Assets:Bank:Checking"))
            .expect("Assets:Bank:Checking line should still exist after edit");
        let food_line = applied
            .lines()
            .find(|l| l.contains("Expenses:Food"))
            .expect("Expenses:Food line should still exist after edit");
        let dash_pos = bank_line.find("-5.00").expect("amount survived the edit");
        assert_eq!(
            dash_pos, expected_num_col,
            "widest-number amount should start at the file-wide column \
             ({expected_num_col}); got line {bank_line:?}"
        );
        // Cross-line: both currencies must land at the same column — the
        // load-bearing property a per-line formatter would break.
        assert_eq!(
            bank_line.find("USD"),
            food_line.find("USD"),
            "currencies must align across postings; got {bank_line:?} / {food_line:?}"
        );

        // No-op shape: a canonically-aligned source returns no JSON
        // response and a showMessage notification ("No amounts to
        // align") instead.
        let aligned = "2024-01-15 open Assets:Bank USD\n";
        let aligned_parsed = parse(aligned);
        let response2 =
            handle_align_amounts(aligned, &aligned_parsed, &uri, PositionEncoding::Utf16);
        assert!(
            response2.response.is_none(),
            "no-op input should not return a workspace edit, got {:?}",
            response2.response
        );
        let msg = response2
            .show_message
            .expect("no-op input should surface a showMessage notification");
        assert!(
            msg.message.contains("No amounts to align"),
            "expected 'no amounts' message, got {msg:?}"
        );
    }

    /// Apply a JSON array of LSP `TextEdit` objects to `source`,
    /// returning the resulting text. Translates each `(line, character)`
    /// LSP position to a byte offset, treating `character` as UTF-16 code
    /// units per the LSP 3.17 default — matching what `minimal_diff_edit`
    /// produces on the server side.
    fn apply_lsp_text_edits(source: &str, edits: &[serde_json::Value]) -> String {
        let mut typed: Vec<(u32, u32, u32, u32, String)> = edits
            .iter()
            .map(|e| {
                let r = e.get("range").unwrap();
                let s = r.get("start").unwrap();
                let n = r.get("end").unwrap();
                (
                    s.get("line").and_then(|v| v.as_u64()).unwrap() as u32,
                    s.get("character").and_then(|v| v.as_u64()).unwrap() as u32,
                    n.get("line").and_then(|v| v.as_u64()).unwrap() as u32,
                    n.get("character").and_then(|v| v.as_u64()).unwrap() as u32,
                    e.get("newText")
                        .and_then(|v| v.as_str())
                        .unwrap()
                        .to_string(),
                )
            })
            .collect();
        // Apply from the end so earlier edits' offsets don't shift.
        typed.sort_by_key(|t| std::cmp::Reverse((t.0, t.1)));

        let mut out = source.to_string();
        for (sl, sc, el, ec, new_text) in typed {
            // Build a fresh LineIndex for the mutating buffer on every
            // edit so byte offsets reflect the current state. Test-
            // only path; production handlers build the index once per
            // request and apply edits client-side.
            let idx = crate::handlers::utils::LineIndex::new(
                &out,
                crate::handlers::utils::PositionEncoding::Utf16,
            );
            let start = idx.position_to_offset(sl, sc).expect("start in bounds");
            let end = idx.position_to_offset(el, ec).expect("end in bounds");
            out.replace_range(start..end, &new_text);
        }
        out
    }
}