rustledger_lsp/handlers/utils.rs
1//! Shared utility functions for LSP handlers.
2//!
3//! This module contains common utilities used across multiple handlers,
4//! including position conversion, word extraction, and type checking.
5
6use lsp_types::{Position, Range};
7use rustledger_parser::ParseResult;
8
9/// Trim a directive span's end offset back over trailing whitespace.
10///
11/// A directive's parser span runs up to the *start of the next directive*, so
12/// it swallows any trailing blank lines. Mapping that raw end to a `Position`
13/// makes ranges (folding, document symbols, …) overshoot into the following
14/// directive. This returns the offset just past the directive's last
15/// non-whitespace byte, so the range ends at the directive's real content.
16pub(crate) fn trim_span_end(source: &str, end: usize) -> usize {
17 let clamped = end.min(source.len());
18 source
19 .get(..clamped)
20 .map_or(clamped, |s| s.trim_end().len())
21}
22
23/// Format a count with a noun, pluralizing with a trailing `s` unless the count
24/// is exactly 1 (so hover/code-lens show "1 posting", not "1 postings"). Only
25/// handles regular `+s` plurals, which is all the LSP surface needs
26/// (transaction, posting, amount).
27pub(crate) fn count_noun(count: usize, singular: &str) -> String {
28 if count == 1 {
29 format!("1 {singular}")
30 } else {
31 format!("{count} {singular}s")
32 }
33}
34
35/// Whether two LSP `Range`s overlap, using LSP half-open semantics
36/// (`Range.end` is EXCLUSIVE per the spec).
37///
38/// Two ranges where `a.end == b.start` are adjacent, NOT overlapping
39/// — hence the `<=` (rather than strict `<`). A zero-width range
40/// (cursor position) at exactly `r.end` is past `r`, not inside it;
41/// a zero-width range at exactly `r.start` IS inside `r`.
42///
43/// Single canonical implementation. Previously two private copies
44/// existed (`handlers/import.rs` and `handlers/code_actions.rs`) with
45/// opposite semantics — one strict-`<`, one `<=`. Hoisting here
46/// removes the inconsistency.
47#[must_use]
48pub fn ranges_overlap(a: Range, b: Range) -> bool {
49 !(a.end <= b.start || b.end <= a.start)
50}
51
52/// LSP position-encoding negotiated with the client at initialization.
53///
54/// Per LSP 3.17, a client advertises which encodings it accepts via
55/// `InitializeParams.capabilities.general.positionEncodings`. The
56/// server replies with the encoding it will use; clients that don't
57/// negotiate get the default (UTF-16). Modern editors (VS Code,
58/// neovim, helix, zed) negotiate UTF-8 because that's cheaper than
59/// re-encoding every diagnostic position.
60///
61/// All position emission paths in the LSP layer should consult this
62/// type — emitting LSP positions in a different encoding than the
63/// negotiated one will misalign on non-ASCII content. Today most
64/// handlers in this crate emit byte (UTF-8) offsets via
65/// [`LineIndex::offset_to_position`]; that works under UTF-8
66/// negotiation but is wrong for UTF-16-only clients. See
67/// `server.rs::run` for the negotiation site and `MainLoopState`
68/// for the storage.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum PositionEncoding {
71 /// UTF-8 byte offsets. The native unit of the underlying source
72 /// representation; preferred by modern editors.
73 Utf8,
74 /// UTF-16 code units. The LSP 3.17 default for clients that
75 /// don't negotiate.
76 Utf16,
77}
78
79impl PositionEncoding {
80 /// Decide the negotiated encoding from a `ServerCapabilities`-
81 /// shaped value. `Some(UTF8)` → UTF-8; `None` → UTF-16 (the
82 /// LSP default).
83 #[must_use]
84 pub fn from_negotiated(negotiated: Option<&lsp_types::PositionEncodingKind>) -> Self {
85 match negotiated {
86 Some(kind) if *kind == lsp_types::PositionEncodingKind::UTF8 => Self::Utf8,
87 _ => Self::Utf16,
88 }
89 }
90}
91
92/// A line index for efficient offset-to-position conversion, encoding-aware.
93///
94/// Borrows the source via `&'a str` — the index does NOT allocate a copy.
95/// Construction is O(n) for the `line_starts` table walk; subsequent
96/// lookups are O(log lines) for line resolution. Column resolution
97/// under UTF-16 encoding is also O(log n) via a lazily-built
98/// [`ropey::Rope`] that's only paid for under UTF-16 negotiation;
99/// under UTF-8 encoding the column math is constant time after line
100/// resolution (the column equals the byte delta from line start).
101///
102/// **Architecture (round-18).** Previous iterations alternated
103/// between full-rope-only (O(log n) lookups but O(n) construction
104/// for both encodings), no-rope-only (O(line_length) UTF-16 lookups
105/// with no rope cost), and owned-Arc-source (gratuitous O(n) clone).
106/// This design picks the right cost for each path: UTF-8 paths pay
107/// no rope cost; UTF-16 paths pay one rope construction per index
108/// and get O(log n) lookups.
109///
110/// **Line-break semantics.** Only `\n` is treated as a line break,
111/// matching the LSP-spec-implied convention that `Position.line` is
112/// indexed by `\n` boundaries. Bare `\r` (legacy macOS line endings)
113/// is NOT a line break. CRLF is recognized via the `\r` being part
114/// of the preceding line content (trimmed in `position_to_offset`
115/// and `line_text`).
116///
117/// # Example
118///
119/// ```ignore
120/// let index = LineIndex::new(source, PositionEncoding::Utf16);
121/// let (line, col) = index.offset_to_position(offset);
122/// ```
123#[derive(Debug)]
124pub struct LineIndex<'a> {
125 /// Borrowed source — the index lives only as long as the source.
126 source: &'a str,
127 /// Byte offset of the start of each line (including line 0 at offset 0).
128 line_starts: Vec<usize>,
129 /// Negotiated LSP position encoding.
130 encoding: PositionEncoding,
131 /// Lazily-built rope used ONLY for UTF-16 column conversions.
132 /// `None` until the first UTF-16-encoded lookup; `None` forever
133 /// under [`PositionEncoding::Utf8`]. Single-threaded interior
134 /// mutability via [`std::cell::OnceCell`] — handlers build a
135 /// LineIndex per request, no cross-thread sharing.
136 utf16_rope: std::cell::OnceCell<ropey::Rope>,
137}
138
139impl<'a> LineIndex<'a> {
140 /// Build a line index from source text.
141 ///
142 /// O(n) for the line-starts table walk. The UTF-16 rope is NOT
143 /// built here — it's deferred to the first UTF-16 column lookup
144 /// via `utf16_rope`'s [`std::cell::OnceCell`]. Under UTF-8
145 /// encoding the rope is never built at all.
146 ///
147 /// `encoding` is the LSP position encoding negotiated with the
148 /// client (see [`PositionEncoding`]). Pass
149 /// [`PositionEncoding::Utf16`] for the LSP spec default, or the
150 /// value stored on the main-loop state for the negotiated wire
151 /// encoding.
152 pub fn new(source: &'a str, encoding: PositionEncoding) -> Self {
153 let mut line_starts = vec![0]; // Line 0 starts at offset 0
154
155 for (i, ch) in source.char_indices() {
156 if ch == '\n' {
157 line_starts.push(i + 1); // Next line starts after the newline
158 }
159 }
160
161 Self {
162 source,
163 line_starts,
164 encoding,
165 utf16_rope: std::cell::OnceCell::new(),
166 }
167 }
168
169 /// Get or build the UTF-16 rope on demand.
170 fn rope(&self) -> &ropey::Rope {
171 self.utf16_rope
172 .get_or_init(|| ropey::Rope::from_str(self.source))
173 }
174
175 /// Locate the line containing `byte` via binary search over
176 /// `line_starts`. Saturating-sub on the `Err` branch handles a
177 /// byte that falls strictly between line starts (the common
178 /// case).
179 fn byte_to_line(&self, byte: usize) -> usize {
180 match self.line_starts.binary_search(&byte) {
181 Ok(line) => line,
182 Err(line) => line.saturating_sub(1),
183 }
184 }
185
186 /// Convert a byte offset to a (line, column) position (0-based).
187 ///
188 /// `column` is in the negotiated encoding — UTF-8 byte offsets
189 /// under [`PositionEncoding::Utf8`], UTF-16 code units under
190 /// [`PositionEncoding::Utf16`]. UTF-8 is O(log lines) total;
191 /// UTF-16 is O(log lines) line resolution + O(log n) rope
192 /// conversion (after a one-time O(n) rope construction).
193 pub fn offset_to_position(&self, offset: usize) -> (u32, u32) {
194 let offset = offset.min(self.source.len());
195 let line = self.byte_to_line(offset);
196 let line_start = self.line_starts[line];
197 let col: u32 = match self.encoding {
198 PositionEncoding::Utf8 => (offset - line_start) as u32,
199 PositionEncoding::Utf16 => {
200 let rope = self.rope();
201 let char_at = rope.byte_to_char(offset);
202 let line_start_char = rope.byte_to_char(line_start);
203 (rope.char_to_utf16_cu(char_at) - rope.char_to_utf16_cu(line_start_char)) as u32
204 }
205 };
206 (line as u32, col)
207 }
208
209 /// Convert a (line, column) position to a byte offset.
210 ///
211 /// `col` is interpreted in the negotiated encoding (UTF-8 bytes
212 /// vs. UTF-16 code units). Returns `None` when:
213 /// - `line` is past the last line in the source, OR
214 /// - `col` overshoots the line's content in the negotiated
215 /// encoding, OR
216 /// - `col` lands inside a surrogate pair (UTF-16) or off a
217 /// UTF-8 char boundary (UTF-8).
218 ///
219 /// The strict-overshoot contract is symmetric across encodings
220 /// so callers can rely on `None` as a uniform "malformed client
221 /// position" signal regardless of negotiation.
222 pub fn position_to_offset(&self, line: u32, col: u32) -> Option<usize> {
223 let line_usize = line as usize;
224 if line_usize >= self.line_starts.len() {
225 return None;
226 }
227 let line_start = self.line_starts[line_usize];
228 let line_end_raw = self
229 .line_starts
230 .get(line_usize + 1)
231 .copied()
232 .unwrap_or(self.source.len());
233 // Exclude only the trailing '\n' from the addressable-content
234 // range. The '\r' under CRLF is treated as line content (it
235 // sits inside the byte range `source.split('\n')` yields for
236 // the line), so handlers can address positions immediately
237 // before the `\n`. Strict `\n`-only stripping mirrors the
238 // `\n`-only line-break policy enforced in `line_starts`.
239 let line_text_end = {
240 let bytes = self.source.as_bytes();
241 if line_end_raw > line_start && bytes.get(line_end_raw - 1) == Some(&b'\n') {
242 line_end_raw - 1
243 } else {
244 line_end_raw
245 }
246 };
247 match self.encoding {
248 PositionEncoding::Utf8 => {
249 let offset = line_start.checked_add(col as usize)?;
250 if offset > line_text_end {
251 return None;
252 }
253 if offset < self.source.len() && !self.source.is_char_boundary(offset) {
254 return None;
255 }
256 Some(offset)
257 }
258 PositionEncoding::Utf16 => {
259 // Route through ropey for O(log n) lookup. The rope
260 // helper translates a UTF-16 code-unit offset into a
261 // byte offset; we still validate the result lies
262 // within the addressed line's content (strict
263 // overshoot returns None).
264 let rope = self.rope();
265 let line_start_char = rope.byte_to_char(line_start);
266 let line_start_utf16 = rope.char_to_utf16_cu(line_start_char);
267 let line_text_end_char = rope.byte_to_char(line_text_end);
268 let line_text_end_utf16 = rope.char_to_utf16_cu(line_text_end_char);
269 let target_utf16 = line_start_utf16.checked_add(col as usize)?;
270 if target_utf16 > line_text_end_utf16 {
271 return None;
272 }
273 let char_idx = rope.utf16_cu_to_char(target_utf16);
274 // Round-trip check: if `col` landed in a surrogate
275 // pair, utf16_cu_to_char snaps to the surrounding
276 // char, and re-converting gives a different code-
277 // unit count. That's the malformed-input signal.
278 if rope.char_to_utf16_cu(char_idx) != target_utf16 {
279 return None;
280 }
281 Some(rope.char_to_byte(char_idx))
282 }
283 }
284 }
285
286 /// Get the number of lines in the source.
287 pub fn line_count(&self) -> usize {
288 self.line_starts.len()
289 }
290
291 /// Byte offset of the start of `line` in the source. Returns
292 /// `None` if `line` is past the last line.
293 #[must_use]
294 pub fn line_start_byte(&self, line: u32) -> Option<usize> {
295 self.line_starts.get(line as usize).copied()
296 }
297
298 /// Convert a `(line, byte_offset_within_line)` pair to an LSP
299 /// `Position` in the negotiated encoding.
300 ///
301 /// The common pattern this serves: a handler calls
302 /// `line.find(needle)` to locate a substring, getting a BYTE
303 /// offset within the line. Emitting that offset directly as a
304 /// `Position::character` is wrong under UTF-16 negotiation on
305 /// non-ASCII content (round-19 reviewer-flagged hazard across
306 /// references / rename / document_highlight / linked_editing /
307 /// call_hierarchy / type_hierarchy / selection_range). This
308 /// helper routes through the index's encoding-aware
309 /// `offset_to_position` so the emitted `Position.character` is
310 /// correct under both negotiations.
311 ///
312 /// Returns `None` if:
313 /// - `line` is past the last line, OR
314 /// - `byte_in_line` would overflow `usize` when added to the
315 /// line start, OR
316 /// - `byte_in_line` strictly overshoots the line's addressable
317 /// content (i.e. exceeds `line_text(line).len()`). The
318 /// strict-overshoot reject mirrors `position_to_offset`'s
319 /// contract: silently routing into the next line via
320 /// `offset_to_position`'s `min(source.len())` clamp would
321 /// hide caller bugs where the byte offset came from a stale
322 /// cached value rather than a fresh `line.find()`.
323 #[must_use]
324 pub fn byte_in_line_to_position(&self, line: u32, byte_in_line: usize) -> Option<Position> {
325 let line_start = self.line_start_byte(line)?;
326 let line_text = self.line_text(line)?;
327 if byte_in_line > line_text.len() {
328 return None;
329 }
330 let abs_byte = line_start.checked_add(byte_in_line)?;
331 let (l, c) = self.offset_to_position(abs_byte);
332 Some(Position::new(l, c))
333 }
334
335 /// Negotiated LSP position encoding the index was built with.
336 #[must_use]
337 pub fn encoding(&self) -> PositionEncoding {
338 self.encoding
339 }
340
341 /// Get the text of a single line (0-indexed), excluding the
342 /// terminating newline. Returns None if `line` is out of bounds.
343 ///
344 /// Borrows from the index's source — same lifetime as the
345 /// LineIndex itself.
346 pub fn line_text(&self, line: u32) -> Option<&'a str> {
347 let line = line as usize;
348 let start = *self.line_starts.get(line)?;
349 let end = self
350 .line_starts
351 .get(line + 1)
352 .copied()
353 .unwrap_or(self.source.len());
354 // `start..end` includes any trailing `\n` (and `\r` if CRLF);
355 // strip both so the returned slice mirrors `str::lines()`.
356 Some(
357 self.source
358 .get(start..end)?
359 .trim_end_matches('\n')
360 .trim_end_matches('\r'),
361 )
362 }
363}
364
365/// Get the word at a given column position in a line, interpreting
366/// `col` in the negotiated LSP position encoding.
367///
368/// Returns the word and its start/end columns in the **same encoding
369/// as `col`** — so callers can splice the returned `(start, end)`
370/// directly into LSP `Position` / `Range` values without further
371/// conversion. Words include alphanumeric characters, colons,
372/// hyphens, and underscores.
373///
374/// Pre-round-17 this helper treated `col` as a raw char index, which
375/// is neither UTF-8 bytes nor UTF-16 code units. The result misfired
376/// on any non-ASCII line under EITHER negotiated encoding, breaking
377/// rename / references / document-highlight / linked-editing on
378/// Cyrillic / CJK / emoji content.
379pub fn get_word_at_position(
380 line: &str,
381 col: usize,
382 encoding: PositionEncoding,
383) -> Option<(String, usize, usize)> {
384 // Walk the line accumulating (byte_offset, char_count, units_seen)
385 // tuples so we can map `col` (in the negotiated encoding) to a
386 // char index. Then find the word boundary at that char index and
387 // map the boundary back to the same encoding so the returned
388 // columns are wire-ready.
389 let chars: Vec<char> = line.chars().collect();
390
391 // Mapping from char index → encoded col, in O(line length).
392 let encoded_col_at_char = |char_idx: usize| -> usize {
393 chars
394 .iter()
395 .take(char_idx)
396 .map(|c| match encoding {
397 PositionEncoding::Utf8 => c.len_utf8(),
398 PositionEncoding::Utf16 => c.len_utf16(),
399 })
400 .sum()
401 };
402
403 // Mapping `col` → char index. Returns None if `col` lands inside
404 // a multi-byte char (UTF-8) or surrogate pair (UTF-16).
405 let mut acc = 0usize;
406 let mut cursor_char_idx = 0usize;
407 for (i, c) in chars.iter().enumerate() {
408 if acc == col {
409 cursor_char_idx = i;
410 break;
411 }
412 let u = match encoding {
413 PositionEncoding::Utf8 => c.len_utf8(),
414 PositionEncoding::Utf16 => c.len_utf16(),
415 };
416 if acc + u > col {
417 // `col` lands inside the char `c`.
418 return None;
419 }
420 acc += u;
421 cursor_char_idx = i + 1;
422 }
423 if acc < col {
424 // `col` past end of line.
425 return None;
426 }
427
428 let mut start = cursor_char_idx;
429 while start > 0 && is_word_char(chars[start - 1]) {
430 start -= 1;
431 }
432 let mut end = cursor_char_idx;
433 while end < chars.len() && is_word_char(chars[end]) {
434 end += 1;
435 }
436 if start == end {
437 return None;
438 }
439
440 let word: String = chars[start..end].iter().collect();
441 Some((word, encoded_col_at_char(start), encoded_col_at_char(end)))
442}
443
444/// Get the word at a position in a source document, interpreting
445/// `position.character` in the negotiated encoding.
446///
447/// Convenience wrapper that extracts the addressed line and delegates
448/// to [`get_word_at_position`]. Returns only the word text (not its
449/// columns) since most callers (hover, goto-definition) don't need
450/// the column values.
451pub fn get_word_at_source_position(
452 source: &str,
453 position: Position,
454 encoding: PositionEncoding,
455) -> Option<String> {
456 let line = source.lines().nth(position.line as usize)?;
457 let (word, _, _) = get_word_at_position(line, position.character as usize, encoding)?;
458 Some(word)
459}
460
461/// Check if a character is part of a word (for Beancount identifiers).
462pub fn is_word_char(c: char) -> bool {
463 c.is_alphanumeric() || c == ':' || c == '-' || c == '_'
464}
465
466/// Check if a string looks like an account name.
467///
468/// Account names start with a standard account type and contain colons.
469pub fn is_account_like(s: &str) -> bool {
470 s.contains(':')
471 && rustledger_core::ACCOUNT_TYPES
472 .iter()
473 .any(|t| s.starts_with(t))
474}
475
476/// Check if a string is a standard root account type.
477#[must_use]
478pub fn is_account_type(s: &str) -> bool {
479 rustledger_core::ACCOUNT_TYPES.contains(&s)
480}
481
482/// Check if a string looks like a currency (simple format check).
483///
484/// Currencies are typically 2-5 uppercase letters/digits (e.g., USD, EUR, BTC).
485pub fn is_currency_like_simple(s: &str) -> bool {
486 s.len() >= 2
487 && s.len() <= 5
488 && s.chars()
489 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
490}
491
492/// Spans of the actual *declared* currency token in each
493/// `Commodity` directive — exactly one per Commodity directive,
494/// namely the first `Currency` token within that directive's
495/// source span.
496///
497/// Used to disambiguate "declaration" from "use" in the LSP
498/// references and document-highlight handlers. A naive
499/// "occurrence span is contained within a Commodity directive
500/// span" check is wrong because Commodity directives can carry
501/// metadata whose values tokenize as `Currency` or `Amount`
502/// (e.g. `2024-01-01 commodity USD\n alias: EUR` — `EUR` here
503/// is a metadata reference, not a declaration). The first
504/// currency within each Commodity span is unambiguously the
505/// declared one because the parser is strictly forward-advancing
506/// and the declared currency is parsed before the indented
507/// metadata block.
508///
509/// Returns a `HashSet` so callers can ask "is this occurrence a
510/// declaration?" in O(1).
511#[must_use]
512pub fn commodity_declaration_spans(
513 parse_result: &ParseResult,
514) -> std::collections::HashSet<rustledger_parser::Span> {
515 parse_result
516 .directives
517 .iter()
518 .filter_map(|d| {
519 if !matches!(&d.value, rustledger_core::Directive::Commodity(_)) {
520 return None;
521 }
522 parse_result
523 .currency_occurrences
524 .iter()
525 .find(|o| o.span.start >= d.span.start && o.span.end <= d.span.end)
526 .map(|o| o.span)
527 })
528 .collect()
529}
530
531/// The spans of every `Open` directive's declared account token.
532///
533/// Account references in beancount come from six directive kinds
534/// (`open` / `close` / `balance` / `pad` / `note` / `document`)
535/// PLUS posting accounts in transactions PLUS ACCOUNT-typed
536/// metadata values. Of those, the two "declaration-like"
537/// directives — the ones that establish or end the account's
538/// lifecycle — are `open` and `close`. Both surface as `WRITE`
539/// in document-highlight and both honor the
540/// `Find References > Include Declaration` toggle. The remaining
541/// four directive shapes + posting accounts + ACCOUNT-typed
542/// metadata are all references (`READ`).
543///
544/// **Pre-#1262-phase-5.5 behavior, retained.** The legacy
545/// substring-search implementation marked both `Open` AND `Close`
546/// as `WRITE`. The phase-5.5 rewrite preserves that policy; only
547/// the underlying mechanism changed from per-directive substring
548/// search to per-token CST classification.
549///
550/// **Why we walk the CST instead of the typed-AST `directives`.**
551/// A directive that parses *syntactically* but whose typed
552/// conversion errors — most commonly an `open` with an invalid
553/// booking method (`InvalidBookingMethod`) — is dropped from
554/// `parse_result.directives`, but its `ACCOUNT` token is still
555/// present in `parse_result.account_occurrences` (the lexer's
556/// classification is independent of typed-AST validity, per the
557/// `account_occurrences` rustdoc). If we walked `directives` we
558/// would silently re-classify the failed-Open's account as a
559/// reference, breaking the `include_declaration: false` filter
560/// exactly when the user is debugging a broken directive. The
561/// CST walk sees the `OPEN_DIRECTIVE` node regardless of typed
562/// conversion success.
563///
564/// **Performance.** O(number of CST nodes) traversal, no
565/// quadratic walk over `account_occurrences`. The previous
566/// implementation was O(N_opens × N_occurrences) because it
567/// re-scanned the full occurrences list for each Open directive.
568///
569/// Returns a `HashSet` so callers can ask "is this occurrence a
570/// declaration?" in O(1).
571#[must_use]
572pub fn account_declaration_spans(
573 parse_result: &ParseResult,
574) -> std::collections::HashSet<rustledger_parser::Span> {
575 use rustledger_parser::SyntaxKind;
576 let bom_offset: usize = if parse_result.has_leading_bom { 3 } else { 0 };
577 let mut declarations = std::collections::HashSet::new();
578
579 for node in parse_result.syntax_node().descendants() {
580 let kind = node.kind();
581 if kind != SyntaxKind::OPEN_DIRECTIVE && kind != SyntaxKind::CLOSE_DIRECTIVE {
582 continue;
583 }
584 // Skip directives wrapped by error-recovery. The first
585 // ACCOUNT token inside an ERROR_NODE is also excluded
586 // from `account_occurrences` (per its rustdoc), so adding
587 // such a span here would not match any occurrence anyway
588 // — but the cleaner contract is "declarations come from
589 // recognized directives only", which the parent-ancestor
590 // check enforces.
591 if node
592 .ancestors()
593 .skip(1)
594 .any(|a| a.kind() == SyntaxKind::ERROR_NODE)
595 {
596 continue;
597 }
598 let Some(account_token) = node
599 .descendants_with_tokens()
600 .filter_map(|n| n.into_token())
601 .find(|t| t.kind() == SyntaxKind::ACCOUNT)
602 else {
603 continue;
604 };
605 let range = account_token.text_range();
606 let start = u32::from(range.start()) as usize + bom_offset;
607 let end = u32::from(range.end()) as usize + bom_offset;
608 declarations.insert(rustledger_parser::Span::new(start, end));
609 }
610
611 declarations
612}
613
614/// Check if a string looks like a currency, validating against known currencies.
615///
616/// Validates the format (uppercase-and-digits, 2-24 chars) and then
617/// confirms the string actually appears as a parsed `Currency` token
618/// in the document by looking it up in `parse_result.currency_occurrences`.
619///
620/// The previous implementation manually walked the AST testing each
621/// position that can carry a currency (Commodity.currency,
622/// Open.currencies, Balance.amount, Posting.units / cost / price,
623/// Price directive). That had two problems:
624///
625/// 1. Any position the walk forgot — or any future directive type
626/// that carries a currency — would silently be excluded, and
627/// rename / references / document-highlight would refuse to fire
628/// on a real currency only mentioned there.
629/// 2. Code duplication: the parser already records every `Currency`
630/// token in `currency_occurrences`; the walk was a parallel and
631/// necessarily-incomplete reimplementation.
632///
633/// Consulting the parser's index makes the check exact by
634/// construction and shrinks the function from ~50 lines to ~5.
635pub fn is_currency_like(s: &str, parse_result: &ParseResult) -> bool {
636 if !s.chars().all(|c| c.is_uppercase() || c.is_numeric()) || s.len() < 2 || s.len() > 24 {
637 return false;
638 }
639 parse_result
640 .currency_occurrences
641 .iter()
642 .any(|occ| occ.value == s)
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 #[test]
650 fn test_count_noun_pluralization() {
651 assert_eq!(count_noun(0, "posting"), "0 postings");
652 assert_eq!(count_noun(1, "posting"), "1 posting");
653 assert_eq!(count_noun(2, "posting"), "2 postings");
654 assert_eq!(count_noun(1, "transaction"), "1 transaction");
655 assert_eq!(count_noun(3, "amount"), "3 amounts");
656 }
657
658 #[test]
659 fn test_line_index_basic() {
660 let source = "line1\nline2\nline3";
661 let index = LineIndex::new(source, PositionEncoding::Utf8);
662
663 // Same tests as byte_offset_to_position
664 assert_eq!(index.offset_to_position(0), (0, 0));
665 assert_eq!(index.offset_to_position(5), (0, 5));
666 assert_eq!(index.offset_to_position(6), (1, 0));
667 assert_eq!(index.offset_to_position(10), (1, 4));
668 assert_eq!(index.offset_to_position(12), (2, 0));
669 assert_eq!(index.offset_to_position(17), (2, 5));
670
671 // Line count
672 assert_eq!(index.line_count(), 3);
673 }
674
675 #[test]
676 fn test_line_index_empty() {
677 let index = LineIndex::new("", PositionEncoding::Utf8);
678 assert_eq!(index.offset_to_position(0), (0, 0));
679 assert_eq!(index.line_count(), 1);
680 }
681
682 #[test]
683 fn test_line_index_single_line() {
684 let index = LineIndex::new("hello world", PositionEncoding::Utf8);
685 assert_eq!(index.offset_to_position(0), (0, 0));
686 assert_eq!(index.offset_to_position(5), (0, 5));
687 assert_eq!(index.offset_to_position(11), (0, 11));
688 assert_eq!(index.line_count(), 1);
689 }
690
691 #[test]
692 fn test_line_index_trailing_newline() {
693 let source = "line1\nline2\n";
694 let index = LineIndex::new(source, PositionEncoding::Utf8);
695 assert_eq!(index.offset_to_position(11), (1, 5));
696 assert_eq!(index.offset_to_position(12), (2, 0)); // Empty line 3
697 assert_eq!(index.line_count(), 3);
698 }
699
700 #[test]
701 fn test_line_index_position_to_offset() {
702 let source = "line1\nline2\nline3";
703 let index = LineIndex::new(source, PositionEncoding::Utf8);
704
705 assert_eq!(index.position_to_offset(0, 0), Some(0));
706 assert_eq!(index.position_to_offset(0, 5), Some(5));
707 assert_eq!(index.position_to_offset(1, 0), Some(6));
708 assert_eq!(index.position_to_offset(1, 4), Some(10));
709 assert_eq!(index.position_to_offset(2, 0), Some(12));
710
711 // Out of bounds
712 assert_eq!(index.position_to_offset(3, 0), None);
713 assert_eq!(index.position_to_offset(0, 100), None);
714 }
715
716 /// Cross-check the indexed UTF-8 column math against a simple
717 /// inline walk over the source. Both should land on the same
718 /// (line, col) for every byte offset in a typical beancount
719 /// fixture.
720 #[test]
721 fn test_line_index_utf8_matches_inline_walk() {
722 let source = "2024-01-01 open Assets:Bank USD\n2024-01-15 * \"Coffee\"\n Assets:Bank -5.00 USD\n Expenses:Food\n";
723 let index = LineIndex::new(source, PositionEncoding::Utf8);
724
725 for offset in 0..source.len() {
726 let mut line = 0u32;
727 let mut col = 0u32;
728 for (i, ch) in source.char_indices() {
729 if i >= offset {
730 break;
731 }
732 if ch == '\n' {
733 line += 1;
734 col = 0;
735 } else {
736 col += 1;
737 }
738 }
739 let indexed = index.offset_to_position(offset);
740 assert_eq!((line, col), indexed, "Mismatch at offset {}", offset);
741 }
742 }
743
744 /// `position_to_offset` returns `None` symmetrically across
745 /// encodings on column overshoot. Pins the round-17 fix for the
746 /// reviewer-flagged divergence (UTF-8 was strict, UTF-16 silently
747 /// clamped via ropey).
748 #[test]
749 fn test_line_index_position_to_offset_overshoot_symmetric() {
750 let source = "line1\nline2\nline3";
751
752 let utf8 = LineIndex::new(source, PositionEncoding::Utf8);
753 assert_eq!(utf8.position_to_offset(0, 5), Some(5));
754 assert_eq!(utf8.position_to_offset(0, 6), None);
755 assert_eq!(utf8.position_to_offset(0, 100), None);
756
757 let utf16 = LineIndex::new(source, PositionEncoding::Utf16);
758 assert_eq!(utf16.position_to_offset(0, 5), Some(5));
759 assert_eq!(utf16.position_to_offset(0, 6), None);
760 assert_eq!(utf16.position_to_offset(0, 100), None);
761 }
762
763 /// Non-BMP scalars (emoji) take TWO UTF-16 code units (surrogate
764 /// pair) but FOUR UTF-8 bytes. The encoding-aware `LineIndex`
765 /// must emit different `character` values for the two encodings —
766 /// this test pins both directions.
767 #[test]
768 fn test_line_index_utf16_columns() {
769 // "💰" = U+1F4B0, 4 UTF-8 bytes, 2 UTF-16 code units.
770 let source = "💰 USD";
771 let after_emoji_byte = '💰'.len_utf8();
772
773 let utf8 = LineIndex::new(source, PositionEncoding::Utf8);
774 assert_eq!(utf8.offset_to_position(after_emoji_byte), (0, 4));
775
776 let utf16 = LineIndex::new(source, PositionEncoding::Utf16);
777 assert_eq!(utf16.offset_to_position(after_emoji_byte), (0, 2));
778
779 // Inverse: a UTF-16 col=2 maps back to byte offset 4.
780 assert_eq!(utf16.position_to_offset(0, 2), Some(after_emoji_byte));
781 // ASCII content past the emoji: byte 8 = UTF-16 col 6.
782 assert_eq!(utf16.offset_to_position(8), (0, 6));
783 }
784
785 /// Bare CR is NOT a line break — only `\n` is. Source `"a\rb"`
786 /// is one line; the LineIndex emits column 2 (UTF-8) / column 2
787 /// (UTF-16) for byte 2. Pre-round-18 ropey-based impls treated
788 /// bare CR as a line break, which diverges from the LSP spec's
789 /// implicit `\n`-only convention. Pinning this prevents an
790 /// accidental ropey-revert from silently shifting positions in
791 /// legacy-Mac files.
792 #[test]
793 fn test_line_index_bare_cr_not_a_line_break() {
794 let source = "a\rb";
795 let index = LineIndex::new(source, PositionEncoding::Utf8);
796 assert_eq!(index.line_count(), 1);
797 // Byte 2 (the 'b') is on line 0 col 2 — NOT line 1 col 0.
798 assert_eq!(index.offset_to_position(2), (0, 2));
799 // CRLF is still recognized as ONE line break (the '\n'), and
800 // the '\r' is trimmed from the line's visible content range.
801 let crlf = LineIndex::new("a\r\nb", PositionEncoding::Utf8);
802 assert_eq!(crlf.line_count(), 2);
803 assert_eq!(crlf.offset_to_position(3), (1, 0));
804 }
805
806 #[test]
807 fn test_line_index_basic_offsets() {
808 let source = "line1\nline2\nline3";
809 let index = LineIndex::new(source, PositionEncoding::Utf8);
810 assert_eq!(index.offset_to_position(0), (0, 0));
811 assert_eq!(index.offset_to_position(5), (0, 5));
812 assert_eq!(index.offset_to_position(6), (1, 0));
813 assert_eq!(index.offset_to_position(10), (1, 4));
814 }
815
816 /// `byte_in_line_to_position` must reject byte offsets that
817 /// strictly overshoot the addressed line's content. Pre-round-20
818 /// it silently routed overshoots through `offset_to_position`'s
819 /// `min(source.len())` clamp, emitting a Position pointing into
820 /// the NEXT line — masking caller bugs (stale cached offsets,
821 /// off-by-one in needle arithmetic) as wrong-line edits.
822 #[test]
823 fn test_byte_in_line_to_position_strict_overshoot() {
824 let source = "abc\ndefgh\nij";
825 let index = LineIndex::new(source, PositionEncoding::Utf8);
826
827 // In-range on line 0 ("abc", len=3): 0..=3 succeed.
828 assert_eq!(
829 index.byte_in_line_to_position(0, 0),
830 Some(Position::new(0, 0))
831 );
832 assert_eq!(
833 index.byte_in_line_to_position(0, 3),
834 Some(Position::new(0, 3))
835 );
836 // Overshoot line 0: 4 would have addressed line 1 col 0 under
837 // the old clamp; strict reject is None.
838 assert_eq!(index.byte_in_line_to_position(0, 4), None);
839 assert_eq!(index.byte_in_line_to_position(0, 100), None);
840
841 // In-range on line 1 ("defgh", len=5): 0..=5 succeed.
842 assert_eq!(
843 index.byte_in_line_to_position(1, 5),
844 Some(Position::new(1, 5))
845 );
846 assert_eq!(index.byte_in_line_to_position(1, 6), None);
847
848 // Past-last-line still None via line_text.
849 assert_eq!(index.byte_in_line_to_position(99, 0), None);
850 }
851
852 #[test]
853 fn test_get_word_at_position() {
854 let line = " Assets:Bank -100.00 USD";
855
856 // At "Assets:Bank"
857 let result = get_word_at_position(line, 5, PositionEncoding::Utf8);
858 assert!(result.is_some());
859 let (word, start, end) = result.unwrap();
860 assert_eq!(word, "Assets:Bank");
861 assert_eq!(start, 2);
862 assert_eq!(end, 13);
863
864 // At "USD"
865 let result = get_word_at_position(line, 24, PositionEncoding::Utf8);
866 assert!(result.is_some());
867 let (word, _, _) = result.unwrap();
868 assert_eq!(word, "USD");
869 }
870
871 /// `get_word_at_position` returns columns in the same encoding as
872 /// the input `col`. On a Cyrillic account, UTF-16 col=12 lands on
873 /// the space before `USD`; the word `USD` starts at UTF-16 col=13
874 /// (one Cyrillic char = 1 UTF-16 unit but 2 UTF-8 bytes, so the
875 /// UTF-8 column for the same byte is larger). Pins both encodings.
876 #[test]
877 fn test_get_word_at_position_encoding_aware() {
878 let line = "Активы:Банк USD";
879 // "Активы:Банк " is 12 chars / 12 UTF-16 units / 22 UTF-8
880 // bytes (each Cyrillic char is 2 UTF-8 bytes / 1 UTF-16
881 // unit). "USD" starts at char 12.
882
883 let (word, s, e) = get_word_at_position(line, 12, PositionEncoding::Utf16)
884 .expect("word at UTF-16 col 12 should resolve");
885 assert_eq!(word, "USD");
886 assert_eq!((s, e), (12, 15));
887
888 let (word, s, e) = get_word_at_position(line, 22, PositionEncoding::Utf8)
889 .expect("word at UTF-8 col 22 should resolve");
890 assert_eq!(word, "USD");
891 assert_eq!((s, e), (22, 25));
892
893 // A col that lands inside a multi-byte char under UTF-8 returns None.
894 // Byte 1 is in the middle of "А" (2 bytes).
895 assert!(get_word_at_position(line, 1, PositionEncoding::Utf8).is_none());
896 }
897
898 #[test]
899 fn test_is_account_like() {
900 assert!(is_account_like("Assets:Bank"));
901 assert!(is_account_like("Expenses:Food:Groceries"));
902 assert!(!is_account_like("USD"));
903 assert!(!is_account_like("Bank"));
904 assert!(!is_account_like("Random:Thing"));
905 }
906
907 #[test]
908 fn test_is_account_type() {
909 assert!(is_account_type("Assets"));
910 assert!(is_account_type("Liabilities"));
911 assert!(is_account_type("Income"));
912 assert!(!is_account_type("Bank"));
913 assert!(!is_account_type("assets"));
914 }
915
916 #[test]
917 fn test_is_currency_like_simple() {
918 assert!(is_currency_like_simple("USD"));
919 assert!(is_currency_like_simple("EUR"));
920 assert!(is_currency_like_simple("BTC"));
921 assert!(!is_currency_like_simple("usd"));
922 assert!(!is_currency_like_simple("U"));
923 assert!(!is_currency_like_simple("TOOLONGCURRENCY"));
924 }
925
926 /// `is_currency_like` validates format AND confirms the string
927 /// actually appears as a parsed `Currency` token in the
928 /// document. This test pins both behaviors.
929 ///
930 /// Includes a coverage case for the latent gap the previous
931 /// manual-AST-walk implementation had: a currency mentioned
932 /// only in a `Price` directive returns true. (Whether the old
933 /// walk happened to cover `Price` doesn't matter for the new
934 /// implementation — it queries `currency_occurrences`, which
935 /// is exhaustive by construction.)
936 #[test]
937 fn test_is_currency_like() {
938 use rustledger_parser::parse;
939
940 let source = r#"2024-01-01 commodity USD
9412024-01-01 open Assets:Bank USD
9422024-01-15 * "Coffee"
943 Assets:Bank -5.00 USD
944 Expenses:Food 5.00 USD
9452024-01-20 price GBP 1.27 USD
946"#;
947 let parse_result = parse(source);
948
949 // Format check: must be uppercase/digits, length 2-24.
950 assert!(
951 !is_currency_like("usd", &parse_result),
952 "lowercase rejected"
953 );
954 assert!(!is_currency_like("U", &parse_result), "too short rejected");
955
956 // Format-valid but not present in document.
957 assert!(
958 !is_currency_like("XYZ", &parse_result),
959 "unknown currency rejected"
960 );
961
962 // Format-valid and present as Currency token.
963 assert!(is_currency_like("USD", &parse_result));
964
965 // Currency that appears ONLY in a Price directive (the
966 // latent gap of the previous manual AST walk if it had
967 // missed Price). `currency_occurrences` is exhaustive.
968 assert!(is_currency_like("GBP", &parse_result));
969 }
970
971 #[test]
972 fn test_is_word_char() {
973 assert!(is_word_char('a'));
974 assert!(is_word_char('Z'));
975 assert!(is_word_char('0'));
976 assert!(is_word_char(':'));
977 assert!(is_word_char('-'));
978 assert!(is_word_char('_'));
979 assert!(!is_word_char(' '));
980 assert!(!is_word_char('"'));
981 }
982
983 /// `account_declaration_spans` includes both `Open` and `Close`
984 /// header accounts (lifecycle boundaries) and excludes balance /
985 /// pad / note / document / posting / ACCOUNT-typed metadata.
986 #[test]
987 fn account_declaration_spans_covers_open_and_close() {
988 use rustledger_parser::parse;
989 let source = "\
9902024-01-01 open Assets:Bank USD
9912024-06-15 * \"Coffee\"
992 Assets:Bank -5.00 USD
9932024-08-01 balance Assets:Bank 95.00 USD
9942024-12-31 close Assets:Bank
995";
996 let result = parse(source);
997 assert!(result.errors.is_empty(), "{:?}", result.errors);
998 let decls = account_declaration_spans(&result);
999
1000 // Two declarations expected: Open header (line 0) and
1001 // Close header (line 4). The posting and balance are
1002 // references, not declarations.
1003 assert_eq!(decls.len(), 2, "got {decls:?}");
1004
1005 // Match against `account_occurrences`: occurrences whose
1006 // span is in `decls` are declarations.
1007 let decl_occurrences: Vec<&rustledger_parser::Spanned<rustledger_core::Account>> = result
1008 .account_occurrences
1009 .iter()
1010 .filter(|o| decls.contains(&o.span))
1011 .collect();
1012 assert_eq!(decl_occurrences.len(), 2);
1013 // First decl = source byte offset of Open's Assets:Bank
1014 // (line 0, col 16 == byte 16); Second = Close's
1015 // (line 4, col 17). The exact byte math is the contract.
1016 let mut starts: Vec<usize> = decl_occurrences.iter().map(|o| o.span.start).collect();
1017 starts.sort_unstable();
1018 assert_eq!(
1019 starts[0], 16,
1020 "Open's Assets:Bank starts at byte 16 of the source",
1021 );
1022 // Line 4 starts after "2024-12-31 close " preceded by lines
1023 // 0..3. Sanity-check the offset against the source bytes
1024 // rather than hardcoding.
1025 let close_offset = source.find("close Assets:Bank").unwrap() + "close ".len();
1026 assert_eq!(starts[1], close_offset);
1027 }
1028
1029 /// An `Open` directive whose typed-AST conversion fails
1030 /// (`InvalidBookingMethod` here) is dropped from
1031 /// `parse_result.directives`, but its ACCOUNT token IS in
1032 /// `account_occurrences` and the CST node is intact (not
1033 /// inside an `ERROR_NODE`). The CST walk must still classify
1034 /// it as a declaration. The previous typed-AST walk silently
1035 /// regressed in this case — `include_declaration: false`
1036 /// stopped filtering the open, exactly when the user is
1037 /// debugging a broken directive.
1038 #[test]
1039 fn account_declaration_spans_handles_failed_open_conversion() {
1040 use rustledger_core::Directive;
1041 use rustledger_parser::parse;
1042 // Invalid booking method - parser emits
1043 // `InvalidBookingMethod`, drops the Open from
1044 // `directives`, but keeps the CST node + ACCOUNT token.
1045 let source = "2024-01-01 open Assets:Bank USD \"GARBAGE\"\n";
1046 let result = parse(source);
1047 // The directive was dropped (no Open in typed AST):
1048 assert!(
1049 !result
1050 .directives
1051 .iter()
1052 .any(|d| matches!(&d.value, Directive::Open(_))),
1053 "expected the Open to be dropped from directives, got {:?}",
1054 result.directives
1055 );
1056 // But the ACCOUNT token IS in occurrences:
1057 let has_account = result
1058 .account_occurrences
1059 .iter()
1060 .any(|o| o.value.as_str() == "Assets:Bank");
1061 assert!(has_account, "{:?}", result.account_occurrences);
1062 // And the CST walk classifies it as a declaration.
1063 let decls = account_declaration_spans(&result);
1064 assert_eq!(
1065 decls.len(),
1066 1,
1067 "expected the failed-Open's ACCOUNT to still be a declaration; got {decls:?}",
1068 );
1069 }
1070
1071 /// An ACCOUNT-typed metadata value inside an `Open` directive
1072 /// (e.g. `payee_account: Assets:Other`) tokenizes as ACCOUNT
1073 /// at the lexer level, BUT it is not the directive header
1074 /// account. The helper takes the FIRST ACCOUNT token in the
1075 /// directive's source span, which is always the header
1076 /// (parser is forward-advancing; the metadata block is parsed
1077 /// after the header). This test pins that contract.
1078 #[test]
1079 fn account_declaration_spans_skips_metadata_account_value() {
1080 use rustledger_parser::parse;
1081 let source = "\
10822024-01-01 open Assets:Bank USD
1083 payee_account: Assets:Other
1084";
1085 let result = parse(source);
1086 assert!(result.errors.is_empty(), "{:?}", result.errors);
1087 let decls = account_declaration_spans(&result);
1088 assert_eq!(decls.len(), 1, "got {decls:?}");
1089
1090 // The single declaration is the header (Assets:Bank), NOT
1091 // the metadata value (Assets:Other). Find the occurrence
1092 // whose span is in `decls` and assert its value.
1093 let decl = result
1094 .account_occurrences
1095 .iter()
1096 .find(|o| decls.contains(&o.span))
1097 .expect("at least one ACCOUNT occurrence is a declaration");
1098 assert_eq!(
1099 decl.value.as_str(),
1100 "Assets:Bank",
1101 "the declared account must be the directive header, not the metadata value",
1102 );
1103 }
1104
1105 /// A bare posting account is never a declaration. Counter-test
1106 /// to make sure the helper isn't trivially marking every
1107 /// ACCOUNT token as a declaration.
1108 #[test]
1109 fn account_declaration_spans_excludes_posting_account() {
1110 use rustledger_parser::parse;
1111 let source = "\
11122024-01-01 open Assets:Bank USD
11132024-06-15 * \"Coffee\"
1114 Assets:Bank -5.00 USD
1115 Expenses:Food
1116";
1117 let result = parse(source);
1118 assert!(result.errors.is_empty(), "{:?}", result.errors);
1119 let decls = account_declaration_spans(&result);
1120 // Only one declaration (the Open header).
1121 assert_eq!(decls.len(), 1, "got {decls:?}");
1122
1123 // Neither the Assets:Bank posting nor the Expenses:Food
1124 // posting is in `decls`.
1125 let posting_occurrences: Vec<_> = result
1126 .account_occurrences
1127 .iter()
1128 .filter(|o| !decls.contains(&o.span))
1129 .collect();
1130 assert_eq!(
1131 posting_occurrences.len(),
1132 2,
1133 "expected 2 non-declaration occurrences (the two postings); got {posting_occurrences:?}",
1134 );
1135 }
1136}