rustledger_parser/logos_lexer.rs
1//! SIMD-accelerated lexer using Logos.
2//!
3//! This module provides a fast tokenizer for Beancount syntax using the Logos crate,
4//! which generates a DFA-based lexer with SIMD optimizations where available.
5
6use logos::Logos;
7use std::fmt;
8use std::ops::Range;
9
10// The leading-BOM strip happens at the `parse()` entry boundary (see
11// `crate::bom::strip_leading`). By the time the lexer runs, the source
12// is BOM-free at byte 0 by construction. Any U+FEFF byte the lexer
13// encounters is therefore mid-file and unrecognized — logos's default
14// error path emits a `Token::Error` for it, and the parser's existing
15// error classifier (which searches `error_text` for U+FEFF) surfaces
16// the dedicated `ParseErrorKind::BomInDirectiveBody` diagnostic.
17//
18// No BOM-aware lexer callback, no `Token::Bom` variant, and no
19// BOM regex in the Token enum — but the `Err(()) => ...` arm in
20// `tokenize` DOES contain one mid-file-BOM special case: it preserves
21// `at_line_start` and advances `last_newline_end` past leading BOM
22// bytes in the error span, so indented content on the same logical
23// line still emits an `Indent` token. That logic lives in the
24// `apply_err_layout_transparency` helper below and is unit-tested
25// directly (including the multi-BOM coalesced-Err case that logos
26// doesn't produce from real input today but might in the future).
27
28/// A span in the source code (byte offsets).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct Span {
31 /// Start byte offset (inclusive).
32 pub start: usize,
33 /// End byte offset (exclusive).
34 pub end: usize,
35}
36
37impl From<Range<usize>> for Span {
38 fn from(range: Range<usize>) -> Self {
39 Self {
40 start: range.start,
41 end: range.end,
42 }
43 }
44}
45
46impl From<Span> for Range<usize> {
47 fn from(span: Span) -> Self {
48 span.start..span.end
49 }
50}
51
52/// Token types produced by the Logos lexer.
53///
54/// Horizontal whitespace is emitted as a first-class [`Token::Whitespace`]
55/// token (was previously skipped via `#[logos(skip r"[ \t]+")]`). The
56/// existing [`tokenize`] entry point filters whitespace out for
57/// backward-compat with the AST-style parser; the new
58/// [`tokenize_lossless`] entry point keeps them so the CST can
59/// reconstruct source byte-for-byte. Both paths share the same Logos
60/// implementation — there is exactly one tokenization pass per file.
61#[derive(Logos, Debug, Clone, PartialEq, Eq)]
62pub enum Token<'src> {
63 /// Horizontal whitespace (`[ \t]+`). Significant for the CST and
64 /// for the existing indent post-processing in [`tokenize`]; both
65 /// callers handle this variant.
66 #[regex(r"[ \t]+")]
67 Whitespace(&'src str),
68 // ===== Literals =====
69 /// A date in YYYY-MM-DD, YYYY-M-D, YYYY/MM/DD, or YYYY/M/D format.
70 /// Single-digit month and day are accepted (e.g., 2024-1-5).
71 #[regex(r"\d{4}[-/]\d{1,2}[-/]\d{1,2}")]
72 Date(&'src str),
73
74 /// A number with optional thousands separators and decimals.
75 /// Examples: 123, 1,234.56, 1234.5678, 1. (trailing decimal)
76 /// Negative numbers are handled as unary minus (`-` token + number)
77 /// to allow subtraction expressions like `3-2` to parse correctly.
78 /// Python beancount v3 requires an integer part before the decimal point.
79 /// Leading decimals like `.50` are rejected per the beancount v3 spec.
80 #[regex(r"(\d{1,3}(,\d{3})*|\d+)(\.\d*)?")]
81 Number(&'src str),
82
83 /// A double-quoted string (handles escape sequences).
84 /// The slice includes the quotes.
85 #[regex(r#""([^"\\]|\\.)*""#)]
86 String(&'src str),
87
88 /// An account name like Assets:Bank:Checking, Капитал:Retained-Earnings,
89 /// or 资产:银行:支票.
90 ///
91 /// The first component starts with an uppercase letter (`\p{Lu}`), a
92 /// letter without case like CJK ideographs (`\p{Lo}`), or a titlecase
93 /// letter (`\p{Lt}`). Sub-components may also start with a digit.
94 /// Subsequent characters can be any Unicode letter, digit, hyphen, or ANY
95 /// non-ASCII character.
96 ///
97 /// The non-ASCII allowance (#1930) matches what beancount actually accepts,
98 /// which is broader than letters: `Assets:CORP✨` (`So`), `Assets:CORP½`
99 /// (`No`) and `Assets:CORP→` all load there, and a committed fixture in
100 /// fava-portfolio-returns uses one — so rejecting them meant refusing real
101 /// files. Determined by probing beancount rather than reading its grammar.
102 ///
103 /// Restricted to NON-ASCII on purpose, and that is the safety argument:
104 /// every character with syntactic meaning in beancount (`@ # ^ { } " ; , *
105 /// ! ~ ( ) :`) is ASCII, so widening here cannot let an account name
106 /// swallow a price annotation, tag or cost brace. beancount agrees on that
107 /// boundary — it rejects `Assets:CORP@x`, `CORP#x`, `CORP_x` and `CORP.x`.
108 ///
109 /// The COMPONENT START is deliberately untouched by #1930 — but note we do
110 /// NOT match beancount there. It requires ASCII uppercase or a digit; we
111 /// also allow `\p{Lo}`/`\p{Lt}`, so `Assets:日本` loads here and is
112 /// rejected by beancount. That divergence predates #1930 and is left alone
113 /// on purpose: tightening it would break CJK ledgers that work today. We
114 /// do agree on rejecting a lowercase or symbol start (`Assets:corp✨`,
115 /// `Assets:✨x`).
116 ///
117 /// KNOWN SHARP EDGE: the non-ASCII range includes Unicode whitespace,
118 /// control and line-separator characters — NBSP (U+00A0), LINE SEPARATOR
119 /// (U+2028), NEL (U+0085), ZWSP (U+200B) and friends — so `Assets:A<NBSP>B`
120 /// lexes as ONE account whose name is visually indistinguishable from
121 /// `Assets:A B`. That is worth knowing about, and it is deliberate:
122 /// beancount accepts every one of those inside an account name (verified
123 /// individually), so excluding them would trade a visual-ambiguity hazard
124 /// for the concrete bug #1930 exists to fix — rejecting files beancount
125 /// loads. If this is ever revisited it should be as a lint over account
126 /// names, not a lexer restriction, so the file still parses.
127 ///
128 /// Note: The beancount v3 spec restricts the first character to ASCII
129 /// `[A-Z]`, but this is an artifact of the C flex lexer's poor Unicode
130 /// support, not a meaningful language design choice (see
131 /// beancount/beancount#161, #398, #733).
132 ///
133 /// The account type prefix is validated later against options (`name_assets`, etc.).
134 #[regex(
135 r"[\p{Lu}\p{Lo}\p{Lt}][\p{L}0-9\-\x{80}-\x{10FFFF}]*(:([\p{Lu}\p{Lo}\p{Lt}0-9][\p{L}0-9\-\x{80}-\x{10FFFF}]*)+)+"
136 )]
137 Account(&'src str),
138
139 /// A currency/commodity code like USD, EUR, AAPL, BTC, or single-char tickers like T, V, F.
140 /// Uppercase letters, can contain digits, apostrophes, dots, underscores, hyphens.
141 /// Single-character currencies (e.g., T for AT&T, V for Visa) are valid NYSE/NASDAQ tickers.
142 /// Note: Single-char currencies are disambiguated from transaction flags in the parser.
143 /// Also supports `/` prefix for options/futures contracts (e.g., `/ESM24`, `/LOX21_211204_P100.25`).
144 /// The `/` prefix requires an uppercase letter first to avoid matching `/1.14` as currency.
145 /// Priority 3 ensures Currency wins over Flag for single uppercase letters.
146 #[regex(r"/[A-Z][A-Z0-9'._-]*|[A-Z][A-Z0-9'._-]*", priority = 3)]
147 Currency(&'src str),
148
149 /// A tag like #tag-name.
150 #[regex(r"#[a-zA-Z0-9-_/.]+")]
151 Tag(&'src str),
152
153 /// A link like ^link-name.
154 #[regex(r"\^[a-zA-Z0-9-_/.]+")]
155 Link(&'src str),
156
157 // ===== Keywords =====
158 // Using #[token] for exact matches (higher priority than regex)
159 /// The `txn` keyword for transactions.
160 #[token("txn")]
161 Txn,
162 /// The `balance` directive keyword.
163 #[token("balance")]
164 Balance,
165 /// The `open` directive keyword.
166 #[token("open")]
167 Open,
168 /// The `close` directive keyword.
169 #[token("close")]
170 Close,
171 /// The `commodity` directive keyword.
172 #[token("commodity")]
173 Commodity,
174 /// The `pad` directive keyword.
175 #[token("pad")]
176 Pad,
177 /// The `event` directive keyword.
178 #[token("event")]
179 Event,
180 /// The `query` directive keyword.
181 #[token("query")]
182 Query,
183 /// The `note` directive keyword.
184 #[token("note")]
185 Note,
186 /// The `document` directive keyword.
187 #[token("document")]
188 Document,
189 /// The `price` directive keyword.
190 #[token("price")]
191 Price,
192 /// The `custom` directive keyword.
193 #[token("custom")]
194 Custom,
195 /// The `option` directive keyword.
196 #[token("option")]
197 Option_,
198 /// The `include` directive keyword.
199 #[token("include")]
200 Include,
201 /// The `plugin` directive keyword.
202 #[token("plugin")]
203 Plugin,
204 /// The `pushtag` directive keyword.
205 #[token("pushtag")]
206 Pushtag,
207 /// The `poptag` directive keyword.
208 #[token("poptag")]
209 Poptag,
210 /// The `pushmeta` directive keyword.
211 #[token("pushmeta")]
212 Pushmeta,
213 /// The `popmeta` directive keyword.
214 #[token("popmeta")]
215 Popmeta,
216 /// The `TRUE` boolean literal (also True, true).
217 #[token("TRUE")]
218 #[token("True")]
219 #[token("true")]
220 True,
221 /// The `FALSE` boolean literal (also False, false).
222 #[token("FALSE")]
223 #[token("False")]
224 #[token("false")]
225 False,
226 /// The `NULL` literal.
227 #[token("NULL")]
228 Null,
229
230 // ===== Punctuation =====
231 // Order matters: longer tokens first
232 /// Double left brace `{{` for cost specifications (legacy total cost).
233 #[token("{{")]
234 LDoubleBrace,
235 /// Double right brace `}}` for cost specifications.
236 #[token("}}")]
237 RDoubleBrace,
238 /// Left brace with hash `{#` for total cost (new syntax).
239 #[token("{#")]
240 LBraceHash,
241 /// Left brace `{` for cost specifications.
242 #[token("{")]
243 LBrace,
244 /// Right brace `}` for cost specifications.
245 #[token("}")]
246 RBrace,
247 /// Left parenthesis `(` for expressions.
248 #[token("(")]
249 LParen,
250 /// Right parenthesis `)` for expressions.
251 #[token(")")]
252 RParen,
253 /// Double at-sign `@@` for total cost.
254 #[token("@@")]
255 AtAt,
256 /// At-sign `@` for unit cost.
257 #[token("@")]
258 At,
259 /// Colon `:` separator.
260 #[token(":")]
261 Colon,
262 /// Comma `,` separator.
263 #[token(",")]
264 Comma,
265 /// Tilde `~` for tolerance.
266 #[token("~")]
267 Tilde,
268 /// Pipe `|` for deprecated payee/narration separator.
269 #[token("|")]
270 Pipe,
271 /// Plus `+` operator.
272 #[token("+")]
273 Plus,
274 /// Minus `-` operator.
275 #[token("-")]
276 Minus,
277 /// Star `*` for cleared transactions and multiplication.
278 #[token("*")]
279 Star,
280 /// Slash `/` for division.
281 #[token("/")]
282 Slash,
283
284 // ===== Transaction Flags =====
285 /// Pending flag `!` for incomplete transactions.
286 #[token("!")]
287 Pending,
288
289 /// Other transaction flags: P S T C U R M ? &
290 /// Note: # and % are handled as comments when followed by space
291 #[regex(r"[PSTCURM?&]")]
292 Flag(&'src str),
293
294 // ===== Structural =====
295 /// Newline (significant in Beancount for directive boundaries).
296 #[regex(r"\r?\n")]
297 Newline,
298
299 /// A comment starting with semicolon.
300 /// The slice includes the semicolon.
301 #[regex(r";[^\n\r]*", allow_greedy = true)]
302 Comment(&'src str),
303
304 /// Hash token `#` used as separator in cost specs: `{per_unit # total currency}`
305 /// Note: In Python beancount, `#` is only a comment at the START of a line.
306 /// Mid-line `# text` is NOT a comment - it's either a cost separator or syntax error.
307 /// Start-of-line hash comments are handled in post-processing (tokenize function).
308 #[token("#")]
309 Hash,
310
311 /// A percent comment (ledger-style).
312 /// Python beancount accepts % as a comment character for ledger compatibility.
313 #[regex(r"%[^\n\r]*", allow_greedy = true)]
314 PercentComment(&'src str),
315
316 /// Shebang line at start of file (e.g., #!/usr/bin/env bean-web).
317 /// Treated as a comment-like directive to skip.
318 #[regex(r"#![^\n\r]*", allow_greedy = true)]
319 Shebang(&'src str),
320
321 /// Emacs org-mode directive (e.g., "#+STARTUP: showall").
322 /// These are Emacs configuration lines that should be skipped.
323 #[regex(r"#\+[^\n\r]*", allow_greedy = true)]
324 EmacsDirective(&'src str),
325
326 /// A metadata key (identifier followed by colon).
327 /// Examples: filename:, lineno:, custom-key:, nameOnCard:
328 /// The slice includes the trailing colon. Keys must start with a lowercase ASCII letter
329 /// per the beancount v3 spec. Keys starting with uppercase are rejected.
330 ///
331 /// At least TWO characters before the colon (`+`, not `*`) — beancount's
332 /// key rule is a lowercase letter followed by one or more further
333 /// characters, so a bare `k:` does not lex as a key there at all and the
334 /// file fails to load. We accepted it (#1955).
335 ///
336 /// Only the LENGTH diverged. Every other part of this rule already matched:
337 /// `kk`, `k1`, `k-` and `k_` are accepted by both tools, and an uppercase
338 /// start like `A:` is rejected by both. So this is deliberately a minimal
339 /// `*` -> `+` rather than a rewrite of the character classes.
340 #[regex(r"[a-z][a-zA-Z0-9_-]+:")]
341 MetaKey(&'src str),
342
343 /// Indentation token (inserted by post-processing, not by Logos).
344 /// Contains the number of leading spaces.
345 /// This is a placeholder - actual indentation detection happens in [`tokenize`].
346 Indent(usize),
347
348 /// Deep indentation (3+ spaces) - used for posting-level metadata.
349 DeepIndent(usize),
350
351 /// Error token for unrecognized input.
352 /// Contains the invalid source text for better error messages.
353 Error(&'src str),
354}
355
356impl Token<'_> {
357 /// Returns true if this is a transaction flag (* or !).
358 /// Single-character currencies (e.g., T, P, C) can also be used as flags.
359 pub const fn is_txn_flag(&self) -> bool {
360 match self {
361 Self::Star | Self::Pending | Self::Flag(_) | Self::Hash => true,
362 // Single-char currencies can be used as transaction flags
363 Self::Currency(s) => s.len() == 1,
364 _ => false,
365 }
366 }
367
368 /// Returns true if this is a keyword that starts a directive.
369 pub const fn is_directive_keyword(&self) -> bool {
370 matches!(
371 self,
372 Self::Txn
373 | Self::Balance
374 | Self::Open
375 | Self::Close
376 | Self::Commodity
377 | Self::Pad
378 | Self::Event
379 | Self::Query
380 | Self::Note
381 | Self::Document
382 | Self::Price
383 | Self::Custom
384 | Self::Option_
385 | Self::Include
386 | Self::Plugin
387 | Self::Pushtag
388 | Self::Poptag
389 | Self::Pushmeta
390 | Self::Popmeta
391 )
392 }
393}
394
395impl fmt::Display for Token<'_> {
396 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397 match self {
398 Self::Date(s) => write!(f, "{s}"),
399 Self::Number(s) => write!(f, "{s}"),
400 Self::String(s) => write!(f, "{s}"),
401 Self::Account(s) => write!(f, "{s}"),
402 Self::Currency(s) => write!(f, "{s}"),
403 Self::Tag(s) => write!(f, "{s}"),
404 Self::Link(s) => write!(f, "{s}"),
405 Self::Txn => write!(f, "txn"),
406 Self::Balance => write!(f, "balance"),
407 Self::Open => write!(f, "open"),
408 Self::Close => write!(f, "close"),
409 Self::Commodity => write!(f, "commodity"),
410 Self::Pad => write!(f, "pad"),
411 Self::Event => write!(f, "event"),
412 Self::Query => write!(f, "query"),
413 Self::Note => write!(f, "note"),
414 Self::Document => write!(f, "document"),
415 Self::Price => write!(f, "price"),
416 Self::Custom => write!(f, "custom"),
417 Self::Option_ => write!(f, "option"),
418 Self::Include => write!(f, "include"),
419 Self::Plugin => write!(f, "plugin"),
420 Self::Pushtag => write!(f, "pushtag"),
421 Self::Poptag => write!(f, "poptag"),
422 Self::Pushmeta => write!(f, "pushmeta"),
423 Self::Popmeta => write!(f, "popmeta"),
424 Self::True => write!(f, "TRUE"),
425 Self::False => write!(f, "FALSE"),
426 Self::Null => write!(f, "NULL"),
427 Self::LDoubleBrace => write!(f, "{{{{"),
428 Self::RDoubleBrace => write!(f, "}}}}"),
429 Self::LBraceHash => write!(f, "{{#"),
430 Self::LBrace => write!(f, "{{"),
431 Self::RBrace => write!(f, "}}"),
432 Self::LParen => write!(f, "("),
433 Self::RParen => write!(f, ")"),
434 Self::AtAt => write!(f, "@@"),
435 Self::At => write!(f, "@"),
436 Self::Colon => write!(f, ":"),
437 Self::Comma => write!(f, ","),
438 Self::Tilde => write!(f, "~"),
439 Self::Pipe => write!(f, "|"),
440 Self::Plus => write!(f, "+"),
441 Self::Minus => write!(f, "-"),
442 Self::Star => write!(f, "*"),
443 Self::Slash => write!(f, "/"),
444 Self::Pending => write!(f, "!"),
445 Self::Flag(s) => write!(f, "{s}"),
446 Self::Whitespace(s) => write!(f, "{s}"),
447 Self::Newline => write!(f, "\\n"),
448 Self::Comment(s) => write!(f, "{s}"),
449 Self::Hash => write!(f, "#"),
450 Self::PercentComment(s) => write!(f, "{s}"),
451 Self::Shebang(s) => write!(f, "{s}"),
452 Self::EmacsDirective(s) => write!(f, "{s}"),
453 Self::MetaKey(s) => write!(f, "{s}"),
454 Self::Indent(n) => write!(f, "<indent:{n}>"),
455 Self::DeepIndent(n) => write!(f, "<deep-indent:{n}>"),
456 Self::Error(s) => {
457 // Strip any embedded U+FEFF bytes (a mid-file BOM
458 // captured into a lexer error span) so diagnostics
459 // rendering this token stay human-readable. LSP problem
460 // panels, CLI stderr, and GitHub-rendered bug reports
461 // all silently drop or strip literal BOM bytes — the
462 // `<BOM>` placeholder makes the failure mode visible.
463 //
464 // Streamed (rather than `s.replace(...)`) so this
465 // Display impl is zero-allocation. LSP problem panels
466 // re-render diagnostics on every keystroke during
467 // interactive editing; a `String` allocation per
468 // render showed up in flame graphs for files with
469 // many BOM-containing Token::Error tokens. The fast
470 // path (no BOM in `s`) is one `f.write_str(s)` call.
471 if s.contains(crate::bom::BOM_CHAR) {
472 let mut chunks = s.split(crate::bom::BOM_CHAR);
473 // Interleave chunks with "<BOM>" between them.
474 // `split` yields N+1 chunks for N matches, so the
475 // first chunk is emitted as-is and each subsequent
476 // chunk gets a `<BOM>` prefix. Final output is
477 // chunk0 + "<BOM>" + chunk1 + "<BOM>" + chunkN —
478 // matching the (allocating) `s.replace(...)`
479 // behavior exactly.
480 if let Some(first) = chunks.next() {
481 f.write_str(first)?;
482 }
483 for chunk in chunks {
484 f.write_str("<BOM>")?;
485 f.write_str(chunk)?;
486 }
487 Ok(())
488 } else {
489 f.write_str(s)
490 }
491 }
492 }
493 }
494}
495
496/// Apply mid-file BOM layout-transparency rules to lexer-state from
497/// inside the `Err` arm of `tokenize`.
498///
499/// A mid-file BOM (U+FEFF) is layout-transparent: it produces an
500/// error diagnostic via the parser's classifier, but must NOT clobber
501/// `at_line_start` or move `last_newline_end` past the BOM, otherwise
502/// the next token on the same logical line (e.g. an indented posting
503/// from a concatenated Windows file) loses its indent classification
504/// and the parser mistypes it. Leading-BOM is handled at the
505/// `crate::parse` boundary and never reaches this code path; only
506/// mid-file BOMs that survived the strip do.
507///
508/// We use `trim_start_matches` (rather than `starts_with` + a single
509/// `BOM_LEN` advance) so a multi-BOM run — e.g., a hypothetical
510/// coalesced `\u{FEFF}\u{FEFF}` Err span from a triple-concatenated
511/// Windows file — is ENTIRELY layout-transparent. Advancing
512/// `last_newline_end` past only the first BOM but then clobbering
513/// `at_line_start` because of the second BOM would cascade into
514/// misclassifying the next real token. The contract is: every BOM
515/// byte is layout-transparent; `at_line_start` is preserved iff the
516/// entire error span is BOM bytes; `last_newline_end` advances past
517/// the full run of leading BOMs.
518///
519/// Extracted as a private helper so the multi-BOM defensive code path
520/// can be unit-tested independently of logos's emission strategy.
521/// Today logos emits one Err per unrecognized char, so the coalesced
522/// path is unreachable from real input; the unit tests at the bottom
523/// of this file feed the helper synthetic `invalid_text` values that
524/// exercise the coalesced case directly.
525fn apply_err_layout_transparency(
526 invalid_text: &str,
527 span_start: usize,
528 at_line_start: &mut bool,
529 last_newline_end: &mut usize,
530) {
531 // Round-17 fix: the contract documented above says "every BOM
532 // byte is layout-transparent" — i.e., a span like
533 // `\u{FEFF}@@\u{FEFF}` should classify its non-BOM bytes for the
534 // at_line_start decision, not its BOM bytes. The previous impl
535 // only inspected the LEADING run of BOMs and clobbered
536 // `at_line_start` for any non-empty tail. That sub-case worked
537 // because a coalesced span starting with BOM + non-BOM tail
538 // really does break the indent contract. But a coalesced span
539 // like `@@\u{FEFF}` (non-BOM head followed by BOM tail) would
540 // also clobber — the BOM in the tail is layout-transparent per
541 // contract, but the head is real content so the clobber is
542 // already correct. The genuinely-wrong case (currently
543 // unreachable but reachable under a future logos upgrade that
544 // coalesces error sequences) is when the ENTIRE span is BOMs,
545 // possibly interleaved with whitespace: those should be fully
546 // layout-transparent. We now extract the LEADING run of BOM
547 // bytes for `last_newline_end` advancement, and consult the
548 // FULL invalid_text minus all BOM bytes for the at_line_start
549 // decision.
550 let after_leading_bom = invalid_text.trim_start_matches(crate::bom::BOM_CHAR);
551 let leading_bom_bytes = invalid_text.len() - after_leading_bom.len();
552 if leading_bom_bytes > 0 && *at_line_start && span_start == *last_newline_end {
553 *last_newline_end = span_start + leading_bom_bytes;
554 }
555
556 // Any non-BOM byte ANYWHERE in the span is "real content" for
557 // indent purposes. An all-BOM span (possibly interleaving BOMs
558 // at any position) leaves `at_line_start` untouched. The
559 // previous `is_empty()` check on JUST the after-leading-BOM
560 // tail had a latent gap for a coalesced `@<BOM>` span: the
561 // leading run is empty, so the `else` arm clobbered — which
562 // happens to be correct for that case, but the path was
563 // accidental rather than principled. Walking the whole span
564 // makes the rule explicit.
565 let has_non_bom_byte = invalid_text.chars().any(|c| c != crate::bom::BOM_CHAR);
566 if has_non_bom_byte {
567 *at_line_start = false;
568 }
569}
570
571/// Whether `name` is a valid beancount account name.
572///
573/// This is the CANONICAL account-name rule, shared by every surface that
574/// admits account names (the validator's Open check, the loader's
575/// `account_*`/`name_*` option guards, the FFI directive builder).
576///
577/// Implemented by running the actual lexer and requiring that `name` lex
578/// to exactly one [`Token::Account`] spanning the whole input, so this
579/// predicate CANNOT drift from what the parser accepts: an account name
580/// is valid if and only if it round-trips through the language. (Before
581/// this existed, the validator and loader each hand-implemented the rule
582/// with different accepted character sets, and accounts that could never
583/// be parsed back could enter through synthesized-directive surfaces.)
584///
585/// The rule, per the `Account` token regex: two or more `:`-separated
586/// components; the root starts with an uppercase (`\p{Lu}`), caseless
587/// (`\p{Lo}`), or titlecase (`\p{Lt}`) letter; sub-components may also
588/// start with an ASCII digit; remaining characters are Unicode letters,
589/// ASCII digits, or `-`.
590#[must_use]
591pub fn is_valid_account_name(name: &str) -> bool {
592 let mut lexer = Token::lexer(name);
593 let Some(Ok(Token::Account(_))) = lexer.next() else {
594 return false;
595 };
596 lexer.span() == (0..name.len()) && lexer.next().is_none()
597}
598
599/// Tokenize source code into a vector of (Token, Span) pairs for the
600/// AST-style parser.
601///
602/// Filters out [`Token::Whitespace`] (mid-line horizontal whitespace)
603/// but otherwise emits everything the lexer produces, with
604/// post-processing for line-start `#` comments and indentation.
605/// Callers that need a fully-lossless token stream (the CST builder)
606/// use [`tokenize_lossless`] instead.
607pub fn tokenize(source: &str) -> Vec<(Token<'_>, Span)> {
608 tokenize_inner(source, /* keep_whitespace = */ false)
609}
610
611/// Tokenize source code losslessly: every byte of `source` appears in
612/// exactly one emitted `(Token, Span)` entry. This is the input to
613/// the CST builder.
614///
615/// Differs from [`tokenize`] in that [`Token::Whitespace`] tokens are
616/// preserved (the AST-style parser drops them; the CST keeps them so
617/// the round-trip stays byte-identical).
618pub fn tokenize_lossless(source: &str) -> Vec<(Token<'_>, Span)> {
619 tokenize_inner(source, /* keep_whitespace = */ true)
620}
621
622/// Upper bound on the up-front token-vector reservation (see `tokenize_inner` /
623/// `lossless_kind_tokens`). ~4M entries (~100 MB for a `(kind, span)` tuple) is
624/// far above any real ledger's token count, but stops a pathological input from
625/// turning `source.len() / 4` into a multi-GB reservation.
626pub(crate) const TOKEN_CAPACITY_CAP: usize = 4 << 20;
627
628fn tokenize_inner(source: &str, keep_whitespace: bool) -> Vec<(Token<'_>, Span)> {
629 // Pre-size to avoid reallocation churn. A beancount token averages ~4 bytes
630 // of source (dates, numbers, currencies, whitespace runs), so `len / 4` is a
631 // close estimate of the token count. Profiling showed the unsized `Vec`
632 // reallocating ~17× — the single largest lexer allocation (see the
633 // `profiling` data branch). Capped so a pathological input (e.g. a huge
634 // whitespace/comment run that lexes to few tokens) can't amplify into a
635 // multi-GB up-front reservation — the parser must handle malformed input
636 // gracefully; beyond the cap the `Vec` just grows normally.
637 let mut tokens = Vec::with_capacity((source.len() / 4).min(TOKEN_CAPACITY_CAP));
638 let mut lexer = Token::lexer(source);
639 let mut at_line_start = true;
640 let mut last_newline_end = 0usize;
641
642 while let Some(result) = lexer.next() {
643 let span = lexer.span();
644
645 if !keep_whitespace && matches!(result, Ok(Token::Whitespace(_))) {
646 // AST-path drops mid-line whitespace; the CST path keeps
647 // it. Layout-relevant whitespace (start-of-line indentation,
648 // BOM error spans) is handled by the dedicated arms below
649 // regardless of which path we are on.
650 continue;
651 }
652
653 match result {
654 Ok(Token::Newline) => {
655 tokens.push((Token::Newline, span.clone().into()));
656 at_line_start = true;
657 last_newline_end = span.end;
658 }
659 Ok(Token::Hash) if at_line_start && span.start == last_newline_end => {
660 // Hash at very start of line (no indentation) is a comment
661 // Find end of line and create a comment token for the whole line
662 let comment_start = span.start;
663 let line_end = source[span.end..]
664 .find('\n')
665 .map_or(source.len(), |i| span.end + i);
666 let comment_text = &source[comment_start..line_end];
667 tokens.push((
668 Token::Comment(comment_text),
669 Span {
670 start: comment_start,
671 end: line_end,
672 },
673 ));
674 // Skip lexer tokens until we reach the newline
675 while let Some(peek_result) = lexer.next() {
676 let peek_span = lexer.span();
677 let peek_end = peek_span.end;
678 if peek_result == Ok(Token::Newline) {
679 tokens.push((Token::Newline, peek_span.into()));
680 at_line_start = true;
681 last_newline_end = peek_end;
682 break;
683 }
684 // Skip other tokens on the comment line
685 }
686 }
687 Ok(token) => {
688 // Check for indentation at line start
689 if at_line_start && span.start > last_newline_end {
690 // Count leading whitespace between last newline and this token
691 // Tabs count as indentation (treat 1 tab as 4 spaces for counting purposes)
692 let leading = &source[last_newline_end..span.start];
693 let mut space_count = 0;
694 let mut char_count = 0;
695 for c in leading.chars() {
696 match c {
697 ' ' => {
698 space_count += 1;
699 char_count += 1;
700 }
701 '\t' => {
702 space_count += 4; // Treat tab as 4 spaces
703 char_count += 1;
704 }
705 _ => break,
706 }
707 }
708 // Python beancount accepts 1+ space for metadata indentation
709 if space_count >= 1 {
710 let indent_start = last_newline_end;
711 let indent_end = last_newline_end + char_count;
712 // Use DeepIndent for 3+ spaces (posting metadata level).
713 // Python beancount allows flexible indentation where posting
714 // metadata just needs to be more indented than the posting.
715 // Common patterns: 2-space posting / 4-space meta, or
716 // 1-space posting / 3-space meta (as in beancount_reds_plugins).
717 let indent_token = if space_count >= 3 {
718 Token::DeepIndent(space_count)
719 } else {
720 Token::Indent(space_count)
721 };
722 tokens.push((
723 indent_token,
724 Span {
725 start: indent_start,
726 end: indent_end,
727 },
728 ));
729 }
730 }
731 at_line_start = false;
732 tokens.push((token, span.into()));
733 }
734 Err(()) => {
735 // Lexer error - produce an Error token with the invalid source text.
736 let invalid_text = &source[span.clone()];
737 apply_err_layout_transparency(
738 invalid_text,
739 span.start,
740 &mut at_line_start,
741 &mut last_newline_end,
742 );
743 tokens.push((Token::Error(invalid_text), span.into()));
744 }
745 }
746 }
747
748 tokens
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754
755 #[test]
756 fn test_tokenize_date() {
757 let tokens = tokenize("2024-01-15");
758 assert_eq!(tokens.len(), 1);
759 assert!(matches!(tokens[0].0, Token::Date("2024-01-15")));
760 }
761
762 #[test]
763 fn test_tokenize_date_single_digit_month() {
764 // Single-digit month should be tokenized as Date
765 let tokens = tokenize("2024-1-15");
766 assert_eq!(tokens.len(), 1);
767 assert!(matches!(tokens[0].0, Token::Date("2024-1-15")));
768 }
769
770 #[test]
771 fn test_tokenize_date_single_digit_day() {
772 // Single-digit day should be tokenized as Date
773 let tokens = tokenize("2024-01-5");
774 assert_eq!(tokens.len(), 1);
775 assert!(matches!(tokens[0].0, Token::Date("2024-01-5")));
776 }
777
778 #[test]
779 fn test_tokenize_date_single_digit_month_and_day() {
780 // Single-digit month and day should be tokenized as Date
781 let tokens = tokenize("2024-1-1");
782 assert_eq!(tokens.len(), 1);
783 assert!(matches!(tokens[0].0, Token::Date("2024-1-1")));
784 }
785
786 #[test]
787 fn test_tokenize_date_slash_separator_single_digit() {
788 // Slash separator with single-digit parts
789 let tokens = tokenize("2024/1/5");
790 assert_eq!(tokens.len(), 1);
791 assert!(matches!(tokens[0].0, Token::Date("2024/1/5")));
792 }
793
794 #[test]
795 fn test_tokenize_number() {
796 let tokens = tokenize("1234.56");
797 assert_eq!(tokens.len(), 1);
798 assert!(matches!(tokens[0].0, Token::Number("1234.56")));
799
800 // Negative numbers are now Minus + Number (enables subtraction expressions)
801 let tokens = tokenize("-1,234.56");
802 assert_eq!(tokens.len(), 2);
803 assert!(matches!(tokens[0].0, Token::Minus));
804 assert!(matches!(tokens[1].0, Token::Number("1,234.56")));
805 }
806
807 #[test]
808 fn test_tokenize_account() {
809 let tokens = tokenize("Assets:Bank:Checking");
810 assert_eq!(tokens.len(), 1);
811 assert!(matches!(
812 tokens[0].0,
813 Token::Account("Assets:Bank:Checking")
814 ));
815 }
816
817 #[test]
818 fn test_tokenize_account_unicode() {
819 // Unicode uppercase letters and CJK characters are valid at the
820 // START of account components. Emoji and symbols are not — there.
821 //
822 // INSIDE a component they now are (#1930). This assertion used to
823 // require the opposite; beancount accepts `Assets:CORP✨` and a
824 // committed fava-portfolio-returns fixture uses it, so the old
825 // expectation was pinning a stricter-than-beancount rule that made us
826 // refuse real files. Verified against beancount before flipping it.
827 let tokens = tokenize("Assets:CORP✨");
828 assert!(
829 matches!(tokens[0].0, Token::Account("Assets:CORP✨")),
830 "a non-ASCII symbol inside a component is a valid Account (beancount accepts it)"
831 );
832 assert!(
833 !tokens.iter().any(|(t, _)| matches!(t, Token::Error(_))),
834 "the whole name lexes cleanly now; no Error token should remain"
835 );
836 // The boundary that keeps the widening safe: ASCII punctuation still
837 // terminates the account, so a price sigil cannot be swallowed into it.
838 let tokens = tokenize("Assets:CORP@2.00");
839 assert!(
840 matches!(tokens[0].0, Token::Account("Assets:CORP")),
841 "an ASCII `@` must still end the account name, not join it"
842 );
843
844 // CJK sub-component start — now valid (CJK ideographs are \p{Lo})
845 let tokens = tokenize("Assets:沪深300");
846 assert!(
847 matches!(tokens[0].0, Token::Account("Assets:沪深300")),
848 "CJK characters at the start of a sub-component should tokenize as Account"
849 );
850
851 // Full CJK sub-component — valid
852 let tokens = tokenize("Assets:日本銀行");
853 assert!(
854 matches!(tokens[0].0, Token::Account("Assets:日本銀行")),
855 "CJK sub-component should tokenize as Account"
856 );
857
858 // Cyrillic account type — valid (Cyrillic uppercase is \p{Lu})
859 let tokens = tokenize("Капитал:Retained");
860 assert!(
861 matches!(tokens[0].0, Token::Account("Капитал:Retained")),
862 "Cyrillic-starting account should tokenize as Account"
863 );
864
865 // Fully CJK account — valid
866 let tokens = tokenize("资产:银行:支票");
867 assert!(
868 matches!(tokens[0].0, Token::Account("资产:银行:支票")),
869 "Fully CJK account should tokenize as Account"
870 );
871 }
872
873 /// Regression for issue #736/#739: Unicode letters AFTER an ASCII start
874 /// in account sub-components are valid per the beancount v3 spec.
875 #[test]
876 fn test_tokenize_account_unicode_letters_after_ascii_start() {
877 // French: É after ASCII start
878 let tokens = tokenize("Assets:Banque-Épargne");
879 assert!(
880 matches!(tokens[0].0, Token::Account("Assets:Banque-Épargne")),
881 "accented Latin letter after ASCII start should tokenize as Account, got: {tokens:?}"
882 );
883
884 // German: ü after ASCII start
885 let tokens = tokenize("Assets:Müller");
886 assert!(
887 matches!(tokens[0].0, Token::Account("Assets:Müller")),
888 "German umlaut after ASCII start should tokenize as Account, got: {tokens:?}"
889 );
890
891 // Mixed CJK after ASCII start — letters are allowed
892 let tokens = tokenize("Assets:CorpJP日本");
893 assert!(
894 matches!(tokens[0].0, Token::Account("Assets:CorpJP日本")),
895 "CJK letters after ASCII start should tokenize as Account, got: {tokens:?}"
896 );
897 }
898
899 #[test]
900 fn test_tokenize_currency() {
901 let tokens = tokenize("USD");
902 assert_eq!(tokens.len(), 1);
903 assert!(matches!(tokens[0].0, Token::Currency("USD")));
904 }
905
906 #[test]
907 fn test_tokenize_single_char_currency() {
908 // Single-char NYSE/NASDAQ tickers: T (AT&T), V (Visa), F (Ford), X (US Steel)
909 let tokens = tokenize("T");
910 assert_eq!(tokens.len(), 1);
911 assert!(matches!(tokens[0].0, Token::Currency("T")));
912
913 let tokens = tokenize("V");
914 assert_eq!(tokens.len(), 1);
915 assert!(matches!(tokens[0].0, Token::Currency("V")));
916
917 let tokens = tokenize("F");
918 assert_eq!(tokens.len(), 1);
919 assert!(matches!(tokens[0].0, Token::Currency("F")));
920 }
921
922 #[test]
923 fn test_single_char_currency_is_txn_flag() {
924 // Single-char currencies should be recognized as potential transaction flags
925 let token = Token::Currency("T");
926 assert!(token.is_txn_flag());
927
928 // Multi-char currencies should NOT be transaction flags
929 let token = Token::Currency("USD");
930 assert!(!token.is_txn_flag());
931 }
932
933 #[test]
934 fn test_tokenize_string() {
935 let tokens = tokenize(r#""Hello, World!""#);
936 assert_eq!(tokens.len(), 1);
937 assert!(matches!(tokens[0].0, Token::String(r#""Hello, World!""#)));
938 }
939
940 #[test]
941 fn test_tokenize_keywords() {
942 let tokens = tokenize("txn balance open close");
943 assert_eq!(tokens.len(), 4);
944 assert!(matches!(tokens[0].0, Token::Txn));
945 assert!(matches!(tokens[1].0, Token::Balance));
946 assert!(matches!(tokens[2].0, Token::Open));
947 assert!(matches!(tokens[3].0, Token::Close));
948 }
949
950 #[test]
951 fn test_tokenize_tag_and_link() {
952 let tokens = tokenize("#my-tag ^my-link");
953 assert_eq!(tokens.len(), 2);
954 assert!(matches!(tokens[0].0, Token::Tag("#my-tag")));
955 assert!(matches!(tokens[1].0, Token::Link("^my-link")));
956 }
957
958 #[test]
959 fn test_tokenize_comment() {
960 let tokens = tokenize("; This is a comment");
961 assert_eq!(tokens.len(), 1);
962 assert!(matches!(tokens[0].0, Token::Comment("; This is a comment")));
963 }
964
965 #[test]
966 fn test_tokenize_indentation() {
967 let tokens = tokenize("txn\n Assets:Bank 100 USD");
968 // Should have: Txn, Newline, Indent, Account, Number, Currency
969 assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Indent(_))));
970 }
971
972 /// `Token::Error`'s Display impl strips embedded BOM bytes — if a
973 /// mid-file U+FEFF gets captured into a lexer error span, the
974 /// diagnostic still renders human-readably. The leading-BOM case
975 /// is handled at the `crate::parse` boundary (see `crate::bom`),
976 /// so this defensive measure only matters for mid-file BOMs that
977 /// fall into the lexer's default error path.
978 #[test]
979 fn test_display_token_error_strips_embedded_bom() {
980 let payload = "foo\u{FEFF}bar";
981 let s = format!("{}", Token::Error(payload));
982 assert_eq!(s, "foo<BOM>bar");
983 assert!(!s.contains(crate::bom::BOM_CHAR));
984 }
985
986 /// A mid-file BOM (any U+FEFF not at strict byte 0) reaches the
987 /// lexer with no special handling — there is no BOM regex on the
988 /// Token enum anymore. Logos's default error path emits `Token::Error`
989 /// for the unrecognized byte; the parser's error classifier (which
990 /// searches `error_text` for U+FEFF) surfaces the dedicated
991 /// diagnostic on the parser side. This test pins the lexer side:
992 /// some `Token::Error` appears in the stream containing the BOM byte.
993 #[test]
994 fn test_tokenize_mid_file_bom_falls_into_error_path() {
995 // Note: this test calls `tokenize` directly with the BOM byte
996 // present in the source — it does NOT go through `parse`, which
997 // would have stripped a strict-byte-0 BOM. So we put the BOM
998 // mid-source to bypass the strip.
999 let source = "2024-01-01 open Assets:Bank USD\n\u{FEFF}";
1000 let tokens = tokenize(source);
1001 let has_bom_in_error = tokens.iter().any(|(t, _)| {
1002 if let Token::Error(s) = t {
1003 s.contains(crate::bom::BOM_CHAR)
1004 } else {
1005 false
1006 }
1007 });
1008 assert!(
1009 has_bom_in_error,
1010 "mid-file BOM should fall into `Token::Error`, got: {tokens:?}"
1011 );
1012 }
1013
1014 /// Layout-transparency contract for mid-file BOM: a BOM at line
1015 /// start followed by indented content (the
1016 /// `cat windows-a.bean windows-b.bean` concatenation case) must
1017 /// NOT swallow the indent on the next token. The Err arm in
1018 /// `tokenize` recognizes `Token::Error("\u{FEFF}")` and preserves
1019 /// `at_line_start` + advances `last_newline_end` so the next
1020 /// real token still gets its `Token::Indent` emission.
1021 ///
1022 /// Without this special case, the Err arm sets `at_line_start =
1023 /// false` like for any other lex error, the indented posting
1024 /// fails to produce an Indent token, and the parser misclassifies
1025 /// the posting as a top-level directive — producing cascading
1026 /// errors instead of the targeted BOM diagnostic.
1027 #[test]
1028 fn test_mid_file_bom_at_line_start_preserves_following_indent() {
1029 // First a directive, then newline, then mid-file BOM, then
1030 // indented posting-like content. `tokenize` is called directly
1031 // (bypassing parse's strip-at-entry) so the BOM is mid-file.
1032 let source = "2024-01-01 open Assets:Bank USD\n\u{FEFF} meta-key: \"v\"\n";
1033 let tokens = tokenize(source);
1034 // The Token::Error for the BOM must be present.
1035 let has_bom_error = tokens.iter().any(|(t, _)| {
1036 if let Token::Error(s) = t {
1037 *s == crate::bom::BOM
1038 } else {
1039 false
1040 }
1041 });
1042 assert!(
1043 has_bom_error,
1044 "expected Token::Error(\"\\u{{FEFF}}\") in stream, got: {tokens:?}"
1045 );
1046 // Critically: the indent for the 2-space metadata line must
1047 // survive — it should be a Token::Indent(2), not absorbed.
1048 let has_indent_2 = tokens.iter().any(|(t, _)| matches!(t, Token::Indent(2)));
1049 assert!(
1050 has_indent_2,
1051 "mid-file BOM at line start must not swallow the following Indent; got: {tokens:?}"
1052 );
1053 // And the metadata key tokenizes normally on the same line.
1054 assert!(
1055 tokens
1056 .iter()
1057 .any(|(t, _)| matches!(t, Token::MetaKey("meta-key:"))),
1058 "expected MetaKey after BOM-prefixed indent, got: {tokens:?}"
1059 );
1060 }
1061
1062 /// Consecutive BOMs at line start (logos emits each as its own
1063 /// Err) ALL preserve layout-transparency. The Err arm uses
1064 /// `trim_start_matches(BOM_CHAR)` to find non-BOM content, so a
1065 /// triple-concatenated Windows file producing `\n\u{FEFF}\u{FEFF}`
1066 /// at line start, followed by indented content, still emits the
1067 /// `Indent` for the metadata line. Without the `trim_start_matches`
1068 /// approach (using a single-BOM length check instead), the second
1069 /// BOM would either not advance `last_newline_end` correctly or
1070 /// would clobber `at_line_start`, breaking the indent walk on the
1071 /// next real token.
1072 #[test]
1073 fn test_consecutive_mid_file_boms_preserve_layout() {
1074 let source = "2024-01-01 open Assets:Bank USD\n\u{FEFF}\u{FEFF} meta-key: \"v\"\n";
1075 let tokens = tokenize(source);
1076 // Both BOMs should appear as Token::Error.
1077 let bom_error_count = tokens
1078 .iter()
1079 .filter(|(t, _)| matches!(t, Token::Error(s) if *s == crate::bom::BOM))
1080 .count();
1081 assert_eq!(
1082 bom_error_count, 2,
1083 "expected 2 Token::Error(BOM) tokens, got: {tokens:?}"
1084 );
1085 // And the indent on the line containing the BOMs must survive.
1086 let has_indent_2 = tokens.iter().any(|(t, _)| matches!(t, Token::Indent(2)));
1087 assert!(
1088 has_indent_2,
1089 "consecutive mid-file BOMs at line start must not swallow following indent; \
1090 got: {tokens:?}"
1091 );
1092 assert!(
1093 tokens
1094 .iter()
1095 .any(|(t, _)| matches!(t, Token::MetaKey("meta-key:"))),
1096 "expected MetaKey after consecutive-BOM-prefixed indent, got: {tokens:?}"
1097 );
1098 }
1099
1100 // ===== Direct tests of `apply_err_layout_transparency` =====
1101 //
1102 // These tests exercise the helper independently of logos's
1103 // emission strategy. Today logos emits one Err per unrecognized
1104 // char, so the multi-BOM-in-one-Err code path (the
1105 // `trim_start_matches` loop's motivating case) is unreachable
1106 // from real input. The tests below feed the helper synthetic
1107 // invalid_text values so the defensive code is actually
1108 // validated rather than documentation-only.
1109
1110 /// Coalesced double-BOM at line start: must advance
1111 /// `last_newline_end` past BOTH bytes and keep `at_line_start`.
1112 /// Pins the contract `trim_start_matches` exists to provide.
1113 #[test]
1114 fn err_layout_transparency_coalesced_double_bom_at_line_start() {
1115 let invalid_text = "\u{FEFF}\u{FEFF}";
1116 let span_start = 10;
1117 let mut at_line_start = true;
1118 let mut last_newline_end = 10;
1119 apply_err_layout_transparency(
1120 invalid_text,
1121 span_start,
1122 &mut at_line_start,
1123 &mut last_newline_end,
1124 );
1125 assert!(
1126 at_line_start,
1127 "all-BOM error span must preserve at_line_start"
1128 );
1129 assert_eq!(
1130 last_newline_end,
1131 10 + 2 * crate::bom::BOM_LEN,
1132 "last_newline_end must advance past BOTH BOMs, not just the first"
1133 );
1134 }
1135
1136 /// Coalesced BOM + trailing content: `at_line_start` clobbers (real
1137 /// content follows the BOM run); `last_newline_end` still
1138 /// advances past the BOM portion only.
1139 #[test]
1140 fn err_layout_transparency_coalesced_bom_with_trailing_content() {
1141 let invalid_text = "\u{FEFF}\u{FEFF}xyz";
1142 let span_start = 10;
1143 let mut at_line_start = true;
1144 let mut last_newline_end = 10;
1145 apply_err_layout_transparency(
1146 invalid_text,
1147 span_start,
1148 &mut at_line_start,
1149 &mut last_newline_end,
1150 );
1151 assert!(
1152 !at_line_start,
1153 "trailing non-BOM content must clobber at_line_start"
1154 );
1155 assert_eq!(
1156 last_newline_end,
1157 10 + 2 * crate::bom::BOM_LEN,
1158 "last_newline_end advances past leading BOMs, NOT past trailing content"
1159 );
1160 }
1161
1162 /// Non-BOM error: standard clobber.
1163 #[test]
1164 fn err_layout_transparency_non_bom_clobbers() {
1165 let invalid_text = "garbage";
1166 let mut at_line_start = true;
1167 let mut last_newline_end = 10;
1168 apply_err_layout_transparency(invalid_text, 10, &mut at_line_start, &mut last_newline_end);
1169 assert!(!at_line_start);
1170 assert_eq!(last_newline_end, 10, "non-BOM error must not advance");
1171 }
1172
1173 /// All-BOM error span but NOT at line start (e.g., BOM appears
1174 /// mid-line after some content): `at_line_start` was already
1175 /// false, the inner advance guard fails, and nothing changes.
1176 #[test]
1177 fn err_layout_transparency_all_bom_not_at_line_start_is_noop() {
1178 let invalid_text = "\u{FEFF}\u{FEFF}";
1179 let span_start = 20;
1180 let mut at_line_start = false; // mid-line
1181 let mut last_newline_end = 10;
1182 apply_err_layout_transparency(
1183 invalid_text,
1184 span_start,
1185 &mut at_line_start,
1186 &mut last_newline_end,
1187 );
1188 assert!(!at_line_start);
1189 assert_eq!(last_newline_end, 10, "guard prevents stale advance");
1190 }
1191
1192 /// Complementary to the previous test: the inner `at_line_start &&
1193 /// span_start == last_newline_end` guard has two clauses. The
1194 /// `*_not_at_line_start_*` test above exercises the first
1195 /// (`at_line_start = false`); THIS test pins the second
1196 /// (span doesn't begin at `last_newline_end`).
1197 ///
1198 /// Without exercising both clauses independently, a refactor that
1199 /// flipped `&&` to `||` would not be caught — either clause alone
1200 /// suffices to suppress the advance.
1201 #[test]
1202 fn err_layout_transparency_all_bom_span_mismatch_is_noop() {
1203 let invalid_text = "\u{FEFF}\u{FEFF}";
1204 // at_line_start IS true (the first clause's condition holds)…
1205 let mut at_line_start = true;
1206 // …but span_start (20) != last_newline_end (10), so the
1207 // second clause's condition fails. Combined: the advance
1208 // must NOT fire.
1209 let span_start = 20;
1210 let mut last_newline_end = 10;
1211 apply_err_layout_transparency(
1212 invalid_text,
1213 span_start,
1214 &mut at_line_start,
1215 &mut last_newline_end,
1216 );
1217 assert!(
1218 at_line_start,
1219 "all-BOM error span must preserve at_line_start regardless of span-vs-last-newline match"
1220 );
1221 assert_eq!(
1222 last_newline_end, 10,
1223 "span_start != last_newline_end must prevent stale advance"
1224 );
1225 }
1226
1227 /// Round-17/18: the contract "every BOM byte is layout-
1228 /// transparent" covers BOMs at ANY position in a coalesced error
1229 /// span, not just the leading run. Pre-round-17 the
1230 /// implementation only inspected the leading BOM run for the
1231 /// `at_line_start` decision — a coalesced span like
1232 /// `@@<BOM>` (non-BOM head, BOM tail) was clobbered by the
1233 /// leading-only logic even though the trailing BOM should have
1234 /// been transparent (and the leading `@@` would correctly
1235 /// clobber on its own). The fixed implementation walks the
1236 /// whole span: ANY non-BOM byte clobbers; only an all-BOM span
1237 /// (in any arrangement) preserves `at_line_start`.
1238 ///
1239 /// These tests cover the interleaved shapes the round-17
1240 /// contract claims to handle: BOM-only-tail, BOM-in-middle,
1241 /// and the recently-flagged "BOM-only in any arrangement"
1242 /// preservation guarantee.
1243 #[test]
1244 fn err_layout_transparency_bom_only_in_any_arrangement_preserves() {
1245 // All-BOM coalesced span — preserves at_line_start AND
1246 // advances last_newline_end past the leading run.
1247 let mut at_line_start = true;
1248 let mut last_newline_end = 10;
1249 apply_err_layout_transparency(
1250 "\u{FEFF}\u{FEFF}",
1251 10, // span_start == last_newline_end → advance fires
1252 &mut at_line_start,
1253 &mut last_newline_end,
1254 );
1255 assert!(at_line_start, "all-BOM span preserves at_line_start");
1256 assert_eq!(
1257 last_newline_end, 16,
1258 "leading BOM run advances last_newline_end past both BOM bytes \
1259 (each BOM is 3 UTF-8 bytes)"
1260 );
1261 }
1262
1263 /// Non-BOM head clobbers `at_line_start`. Pre-round-17 also did
1264 /// this (correctly); pinning prevents a regression that re-
1265 /// introduces a BOM-only-trim that misses non-BOM head bytes.
1266 #[test]
1267 fn err_layout_transparency_non_bom_head_clobbers() {
1268 let mut at_line_start = true;
1269 let mut last_newline_end = 0;
1270 apply_err_layout_transparency("@@\u{FEFF}", 10, &mut at_line_start, &mut last_newline_end);
1271 assert!(
1272 !at_line_start,
1273 "non-BOM head ('@@') clobbers at_line_start regardless of trailing BOM"
1274 );
1275 }
1276
1277 /// BOM head + non-BOM tail clobbers (because of the tail).
1278 /// Pre-round-17 the leading-only logic was correct here too;
1279 /// pinning ensures no regression that flips to leading-only.
1280 #[test]
1281 fn err_layout_transparency_bom_head_non_bom_tail_clobbers() {
1282 let mut at_line_start = true;
1283 let mut last_newline_end = 10;
1284 apply_err_layout_transparency("\u{FEFF}@@", 10, &mut at_line_start, &mut last_newline_end);
1285 assert!(
1286 !at_line_start,
1287 "non-BOM tail ('@@') clobbers at_line_start even though span starts with BOM"
1288 );
1289 assert_eq!(
1290 last_newline_end, 13,
1291 "leading BOM run STILL advances last_newline_end past the BOM"
1292 );
1293 }
1294
1295 /// Non-BOM in the middle of a BOM-flanked span clobbers. THIS
1296 /// is the case the round-17 docstring specifically claimed to
1297 /// cover; pre-round-17 the same outcome held (leading BOMs
1298 /// trimmed, non-empty tail clobbered) but only by accident.
1299 /// The fixed `has_non_bom_byte = chars().any(|c| c != BOM)`
1300 /// walks the whole span and makes the case explicit.
1301 #[test]
1302 fn err_layout_transparency_bom_flanking_non_bom_clobbers() {
1303 let mut at_line_start = true;
1304 let mut last_newline_end = 10;
1305 apply_err_layout_transparency(
1306 "\u{FEFF}@@\u{FEFF}",
1307 10,
1308 &mut at_line_start,
1309 &mut last_newline_end,
1310 );
1311 assert!(
1312 !at_line_start,
1313 "non-BOM middle ('@@') clobbers at_line_start"
1314 );
1315 assert_eq!(
1316 last_newline_end, 13,
1317 "leading BOM run advances last_newline_end past the leading BOM only"
1318 );
1319 }
1320
1321 #[test]
1322 fn test_tokenize_transaction_line() {
1323 let source = "2024-01-15 * \"Grocery Store\" #food\n Expenses:Food 50.00 USD";
1324 let tokens = tokenize(source);
1325
1326 // Check key tokens are present
1327 assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Date(_))));
1328 assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Star)));
1329 assert!(tokens.iter().any(|(t, _)| matches!(t, Token::String(_))));
1330 assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Tag(_))));
1331 assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Newline)));
1332 assert!(
1333 tokens
1334 .iter()
1335 .any(|(t, _)| matches!(t, Token::Indent(_) | Token::DeepIndent(_)))
1336 );
1337 assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Account(_))));
1338 assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Number(_))));
1339 assert!(tokens.iter().any(|(t, _)| matches!(t, Token::Currency(_))));
1340 }
1341
1342 #[test]
1343 fn test_tokenize_metadata_key() {
1344 let tokens = tokenize("filename:");
1345 assert_eq!(tokens.len(), 1);
1346 assert!(matches!(tokens[0].0, Token::MetaKey("filename:")));
1347 }
1348
1349 #[test]
1350 fn test_tokenize_punctuation() {
1351 let tokens = tokenize("{ } @ @@ , ~");
1352 let token_types: Vec<_> = tokens.iter().map(|(t, _)| t.clone()).collect();
1353 assert!(token_types.contains(&Token::LBrace));
1354 assert!(token_types.contains(&Token::RBrace));
1355 assert!(token_types.contains(&Token::At));
1356 assert!(token_types.contains(&Token::AtAt));
1357 assert!(token_types.contains(&Token::Comma));
1358 assert!(token_types.contains(&Token::Tilde));
1359 }
1360
1361 #[test]
1362 fn is_valid_account_name_matches_lexer_rule() {
1363 use super::is_valid_account_name as ok;
1364 // Valid: standard, unicode roots/components, digit-start SUB-component,
1365 // hyphens, deep nesting.
1366 assert!(ok("Assets:Cash"));
1367 assert!(ok("Assets:US:BofA:Checking"));
1368 assert!(ok("Assets:2024-Bonus"));
1369 assert!(ok("Активы:Наличные")); // Cyrillic (\p{Lu} root)
1370 assert!(ok("資産:現金")); // CJK (\p{Lo} root)
1371 assert!(ok("Assets:Ægir")); // non-ASCII uppercase start
1372 // Invalid: single component (roots alone are not accounts).
1373 assert!(!ok("Assets"));
1374 // Invalid: digit-start ROOT (sub-components may, roots may not).
1375 assert!(!ok("1Assets:Cash"));
1376 // Invalid: lowercase starts (ASCII and non-ASCII).
1377 assert!(!ok("assets:Cash"));
1378 assert!(!ok("Assets:cash"));
1379 assert!(!ok("Assets:\u{e9}cash")); // é — lowercase letter start
1380 // Invalid: ASCII characters outside letters/digits/hyphen. The ASCII
1381 // restriction is the safety boundary — every character with syntactic
1382 // meaning in beancount is ASCII, so these can never be absorbed into an
1383 // account name.
1384 assert!(!ok("Assets:Ca sh"));
1385 assert!(!ok("Assets:Cash!"));
1386 assert!(!ok("Assets:Ca_sh")); // underscore is currency-only
1387 // Valid since #1930: ANY non-ASCII inside a component. beancount
1388 // accepts all of these; requiring `\p{L}` here meant rejecting files
1389 // that exist.
1390 assert!(ok("Assets:N\u{2116}1")); // № numero sign (So)
1391 assert!(ok("Assets:Cash\u{1F600}")); // emoji (So)
1392 // Invalid: structural.
1393 assert!(!ok(""));
1394 assert!(!ok("Assets:"));
1395 assert!(!ok(":Cash"));
1396 assert!(!ok("Assets::Cash"));
1397 assert!(!ok(" Assets:Cash"));
1398 assert!(!ok("Assets:Cash "));
1399 assert!(!ok("Assets:Cash\nAssets:Two"));
1400 }
1401}