1use rust_decimal::Decimal;
38use rustledger_core::cost::{CostNumber, CostSpec};
39use rustledger_core::directive::{PriceAnnotation, PriceKind};
40use rustledger_core::{
41 Account, Amount, Currency, Directive, IncompleteAmount, InternedStr, Link, MetaValue, Metadata,
42 NaiveDate, Posting, Span, Spanned, Tag, naive_date,
43};
44
45use crate::ParseResult;
46use crate::cst::ast::{
47 self, AstNode, AstToken, BalanceDirective, CloseDirective, CommodityDirective, CustomDirective,
48 DocumentDirective, EventDirective, IncludeDirective, MetaEntry, NoteDirective, OpenDirective,
49 OptionDirective, PadDirective, PluginDirective, PostingFlagKind, PriceDirective,
50 QueryDirective, SourceFile, Transaction as AstTransaction, TransactionFlagKind,
51};
52
53#[must_use]
59pub fn parse_via_cst(source: &str) -> ParseResult {
60 parse_via_cst_opts(source, true)
61}
62
63#[must_use]
74pub fn parse_via_cst_opts(source: &str, collect_occurrences: bool) -> ParseResult {
75 parse_via_cst_inner(source, collect_occurrences, true)
76}
77
78#[doc(hidden)]
82#[must_use]
83pub fn parse_red_only(source: &str) -> ParseResult {
84 parse_via_cst_inner(
85 source, true, false,
86 )
87}
88
89fn parse_via_cst_inner(source: &str, collect_occurrences: bool, use_green: bool) -> ParseResult {
90 let (stripped, has_leading_bom) = crate::bom::strip_leading(source);
95 let bom_offset: u32 = if has_leading_bom { 3 } else { 0 };
96
97 let source_file = SourceFile::parse(stripped);
98
99 let mut directives: Vec<Spanned<Directive>> = Vec::new();
100 let mut directive_nodes: Vec<crate::SyntaxNode> = Vec::new();
101 let mut options: Vec<(String, String, Span)> = Vec::new();
102 let mut includes: Vec<(String, Span)> = Vec::new();
103 let mut plugins: Vec<(String, Option<String>, Span)> = Vec::new();
104 let DescendantsWalkResult {
108 inline_errors,
109 top_level_comments,
110 currency_occurrences,
111 account_occurrences,
112 } = if use_green {
113 super::green::walk_descendants(source_file.syntax(), bom_offset, collect_occurrences)
115 } else {
116 walk_descendants_once(&source_file, bom_offset, collect_occurrences)
117 };
118
119 let TopLevelWalkResult {
124 errors: top_level_errors,
125 section_marker_comments,
126 } = if use_green {
127 super::green::walk_top_level(source_file.syntax(), stripped, bom_offset)
128 } else {
129 walk_top_level_once(&source_file, stripped, bom_offset)
130 };
131
132 let mut comments: Vec<Spanned<String>> = top_level_comments;
133 comments.extend(section_marker_comments);
134 comments.sort_by_key(|s| s.span.start);
138 comments.dedup_by_key(|s| s.span.start);
139 let mut errors = top_level_errors;
140 if stripped.contains('{') {
149 errors.extend(extract_unclosed_cost_brace_errors(&source_file, bom_offset));
150 }
151 errors.extend(inline_errors);
152 let warnings = Vec::new();
153
154 let mut tag_stack: Vec<(Tag, Span)> = Vec::new();
161 let mut meta_stack: Vec<(String, MetaValue, Span)> = Vec::new();
167
168 for directive in source_file.directives() {
169 let cst_node = directive.syntax().clone();
173 let is_directive_producing = matches!(
182 directive,
183 ast::Directive::Open(_)
184 | ast::Directive::Close(_)
185 | ast::Directive::Commodity(_)
186 | ast::Directive::Note(_)
187 | ast::Directive::Document(_)
188 | ast::Directive::Event(_)
189 | ast::Directive::Query(_)
190 | ast::Directive::Price(_)
191 | ast::Directive::Balance(_)
192 | ast::Directive::Pad(_)
193 | ast::Directive::Custom(_)
194 | ast::Directive::Transaction(_)
195 );
196 let errors_before = errors.len();
197 let pushed_directive = match directive {
198 ast::Directive::Open(node) => convert_open(&node, bom_offset, &mut errors),
199 ast::Directive::Close(node) => convert_close(&node, bom_offset, &mut errors),
200 ast::Directive::Commodity(node) => convert_commodity(&node, bom_offset, &mut errors),
201 ast::Directive::Note(node) => convert_note(&node, bom_offset, &mut errors),
202 ast::Directive::Document(node) => convert_document(&node, bom_offset, &mut errors),
203 ast::Directive::Event(node) => convert_event(&node, bom_offset, &mut errors),
204 ast::Directive::Query(node) => convert_query(&node, bom_offset, &mut errors),
205 ast::Directive::Price(node) => convert_price(&node, bom_offset, &mut errors),
206 ast::Directive::Balance(node) => convert_balance(&node, bom_offset, &mut errors),
207 ast::Directive::Pad(node) => convert_pad(&node, bom_offset, &mut errors),
208 ast::Directive::Custom(node) => convert_custom(&node, bom_offset, &mut errors),
209 ast::Directive::Transaction(node) => {
210 let green = node.syntax().green();
215 let base =
216 u32::from(node.syntax().text_range().start()) as usize + bom_offset as usize;
217 let green_dir = if use_green {
218 super::green::convert_transaction(&green, base)
219 } else {
220 None
221 };
222 match green_dir {
223 Some(d) => Some(d),
224 None => convert_transaction(&node, bom_offset, &mut errors),
225 }
226 }
227 ast::Directive::Option(node) => {
228 if let Some(triple) = convert_option(&node, bom_offset) {
229 options.push(triple);
230 }
231 None
232 }
233 ast::Directive::Include(node) => {
234 if let Some(pair) = convert_include(&node, bom_offset) {
235 includes.push(pair);
236 }
237 None
238 }
239 ast::Directive::Plugin(node) => {
240 if let Some(triple) = convert_plugin(&node, bom_offset) {
241 plugins.push(triple);
242 }
243 None
244 }
245 ast::Directive::Pushtag(node) => {
248 if let Some(tag_token) = node.tag() {
249 let span = node_span(node.syntax(), bom_offset);
250 tag_stack.push((Tag::new(tag_token.text().trim_start_matches('#')), span));
251 }
252 None
253 }
254 ast::Directive::Poptag(node) => {
255 if let Some(tag_token) = node.tag() {
256 let name = tag_token.text().trim_start_matches('#');
257 if let Some(pos) = tag_stack.iter().rposition(|(t, _)| t.as_str() == name) {
258 tag_stack.remove(pos);
259 } else {
260 errors.push(crate::ParseError::new(
261 crate::ParseErrorKind::InvalidPoptag(name.to_string()),
262 node_span(node.syntax(), bom_offset),
263 ));
264 }
265 }
266 None
267 }
268 ast::Directive::Pushmeta(node) => {
269 if let Some(key_token) = node.key() {
270 let key = key_token.text_without_colon().to_string();
271 let value = pushmeta_value(node.syntax());
272 let span = node_span(node.syntax(), bom_offset);
273 meta_stack.push((key, value, span));
274 }
275 None
276 }
277 ast::Directive::Popmeta(node) => {
278 if let Some(key_token) = node.key() {
279 let key = key_token.text_without_colon().to_string();
280 if let Some(pos) = meta_stack.iter().rposition(|(k, _, _)| k == &key) {
281 meta_stack.remove(pos);
282 } else {
283 errors.push(crate::ParseError::new(
284 crate::ParseErrorKind::InvalidPopmeta(key),
285 node_span(node.syntax(), bom_offset),
286 ));
287 }
288 }
289 None
290 }
291 };
292 if let Some(mut spanned) = pushed_directive {
293 apply_inherited_state(&mut spanned.value, &tag_stack, &meta_stack);
294 directives.push(spanned);
295 directive_nodes.push(cst_node);
296 } else if is_directive_producing && errors.len() == errors_before {
297 errors.push(crate::ParseError::new(
305 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
306 node_span(&cst_node, bom_offset),
307 ));
308 }
309 }
310
311 for (tag, span) in &tag_stack {
315 errors.push(crate::ParseError::new(
316 crate::ParseErrorKind::UnclosedPushtag(tag.as_str().to_string()),
317 *span,
318 ));
319 }
320 for (key, _, span) in &meta_stack {
321 errors.push(crate::ParseError::new(
322 crate::ParseErrorKind::UnclosedPushmeta(key.clone()),
323 *span,
324 ));
325 }
326 errors.sort_by_key(|e| e.span.start);
327
328 fixup_directive_spans(&source_file, bom_offset, &directive_nodes, &mut directives);
332
333 let alignment = crate::cst::format::compute_alignment(&source_file);
341
342 let syntax_root = source_file.syntax().green().into_owned();
349
350 ParseResult {
351 directives,
352 options,
353 includes,
354 plugins,
355 comments,
356 errors,
357 warnings,
358 currency_occurrences,
359 account_occurrences,
360 has_leading_bom,
361 syntax_root,
362 alignment,
363 }
364}
365
366const VALID_BOOKING_METHODS: &[&str] = &[
374 "FIFO",
375 "STRICT",
376 "STRICT_WITH_SIZE",
377 "LIFO",
378 "HIFO",
379 "NONE",
380 "AVERAGE",
381];
382
383fn convert_open(
384 node: &OpenDirective,
385 bom_offset: u32,
386 errors: &mut Vec<crate::ParseError>,
387) -> Option<Spanned<Directive>> {
388 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
389 let account = Account::new(node.account()?.text());
390 let currencies: Vec<Currency> = node.currencies().map(|c| Currency::new(c.text())).collect();
391 let booking = node.booking_method().and_then(|s| s.text_decoded());
392 let span = node_span(node.syntax(), bom_offset);
393 if let Some(b) = &booking
394 && !VALID_BOOKING_METHODS.contains(&b.as_str())
395 {
396 errors.push(crate::ParseError::new(
397 crate::ParseErrorKind::InvalidBookingMethod(b.clone()),
398 span,
399 ));
400 return None;
401 }
402 let meta = convert_meta_entries(node.syntax());
403
404 let open = rustledger_core::directive::Open {
405 date,
406 account,
407 currencies,
408 booking,
409 meta,
410 };
411 Some(Spanned::new(Directive::Open(open), span))
412}
413
414fn convert_close(
415 node: &CloseDirective,
416 bom_offset: u32,
417 errors: &mut Vec<crate::ParseError>,
418) -> Option<Spanned<Directive>> {
419 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
420 let account = Account::new(node.account()?.text());
421 let meta = convert_meta_entries(node.syntax());
422
423 let close = rustledger_core::directive::Close {
424 date,
425 account,
426 meta,
427 };
428 let span = node_span(node.syntax(), bom_offset);
429 Some(Spanned::new(Directive::Close(close), span))
430}
431
432fn convert_commodity(
433 node: &CommodityDirective,
434 bom_offset: u32,
435 errors: &mut Vec<crate::ParseError>,
436) -> Option<Spanned<Directive>> {
437 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
438 let currency = Currency::new(node.currency()?.text());
439 let meta = convert_meta_entries(node.syntax());
440
441 let commodity = rustledger_core::directive::Commodity {
442 date,
443 currency,
444 meta,
445 };
446 let span = node_span(node.syntax(), bom_offset);
447 Some(Spanned::new(Directive::Commodity(commodity), span))
448}
449
450fn convert_note(
451 node: &NoteDirective,
452 bom_offset: u32,
453 errors: &mut Vec<crate::ParseError>,
454) -> Option<Spanned<Directive>> {
455 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
456 let account = Account::new(node.account()?.text());
457 let comment = node.text()?.text_decoded()?;
458 let meta = convert_meta_entries(node.syntax());
459
460 let note = rustledger_core::directive::Note {
461 date,
462 account,
463 comment,
464 meta,
465 };
466 let span = node_span(node.syntax(), bom_offset);
467 Some(Spanned::new(Directive::Note(note), span))
468}
469
470fn convert_document(
471 node: &DocumentDirective,
472 bom_offset: u32,
473 errors: &mut Vec<crate::ParseError>,
474) -> Option<Spanned<Directive>> {
475 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
476 let account = Account::new(node.account()?.text());
477 let path = node.path()?.text_decoded()?;
478 let mut tags: Vec<Tag> = Vec::new();
485 let mut links: Vec<Link> = Vec::new();
486 for el in node.syntax().children_with_tokens() {
487 let rowan::NodeOrToken::Token(t) = el else {
488 continue;
489 };
490 match t.kind() {
491 crate::SyntaxKind::NEWLINE => break,
492 crate::SyntaxKind::TAG => {
493 tags.push(Tag::new(t.text().trim_start_matches('#')));
494 }
495 crate::SyntaxKind::LINK => {
496 links.push(Link::new(t.text().trim_start_matches('^')));
497 }
498 _ => {}
499 }
500 }
501 let meta = convert_meta_entries(node.syntax());
502
503 let document = rustledger_core::directive::Document {
504 date,
505 account,
506 path,
507 tags,
508 links,
509 meta,
510 };
511 let span = node_span(node.syntax(), bom_offset);
512 Some(Spanned::new(Directive::Document(document), span))
513}
514
515fn convert_event(
516 node: &EventDirective,
517 bom_offset: u32,
518 errors: &mut Vec<crate::ParseError>,
519) -> Option<Spanned<Directive>> {
520 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
521 let event_type = node.event_type()?.text_decoded()?;
522 let value = node.value()?.text_decoded()?;
523 let meta = convert_meta_entries(node.syntax());
524
525 let event = rustledger_core::directive::Event {
526 date,
527 event_type,
528 value,
529 meta,
530 };
531 let span = node_span(node.syntax(), bom_offset);
532 Some(Spanned::new(Directive::Event(event), span))
533}
534
535fn convert_query(
536 node: &QueryDirective,
537 bom_offset: u32,
538 errors: &mut Vec<crate::ParseError>,
539) -> Option<Spanned<Directive>> {
540 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
541 let name = node.name()?.text_decoded()?;
542 let query = node.query()?.text_decoded()?;
543 let meta = convert_meta_entries(node.syntax());
544
545 let q = rustledger_core::directive::Query {
546 date,
547 name,
548 query,
549 meta,
550 };
551 let span = node_span(node.syntax(), bom_offset);
552 Some(Spanned::new(Directive::Query(q), span))
553}
554
555fn convert_price(
556 node: &PriceDirective,
557 bom_offset: u32,
558 errors: &mut Vec<crate::ParseError>,
559) -> Option<Spanned<Directive>> {
560 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
561 let base_currency = Currency::new(node.base_currency()?.text());
562 let number = directive_arithmetic_value(node.syntax()).or_else(|| {
565 let mut n = parse_decimal_token(node.number()?.text())?;
566 if node_has_minus_before_number(node.syntax()) {
567 n = -n;
568 }
569 Some(n)
570 })?;
571 let quote_currency = Currency::new(node.quote_currency()?.text());
572 let amount = Amount::new(number, quote_currency);
573 let meta = convert_meta_entries(node.syntax());
574
575 let price = rustledger_core::directive::Price {
576 date,
577 currency: base_currency,
578 amount,
579 meta,
580 };
581 let span = node_span(node.syntax(), bom_offset);
582 Some(Spanned::new(Directive::Price(price), span))
583}
584
585fn convert_balance(
586 node: &BalanceDirective,
587 bom_offset: u32,
588 errors: &mut Vec<crate::ParseError>,
589) -> Option<Spanned<Directive>> {
590 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
591 let account = Account::new(node.account()?.text());
592 let number = directive_arithmetic_value(node.syntax()).or_else(|| {
597 let mut n = parse_decimal_token(node.number()?.text())?;
598 if node_has_minus_before_number(node.syntax()) {
599 n = -n;
600 }
601 Some(n)
602 })?;
603 let currency = Currency::new(node.currency()?.text());
604 let amount = Amount::new(number, currency);
605 let tolerance = extract_balance_tolerance(node.syntax());
606 let meta = convert_meta_entries(node.syntax());
607
608 let balance = rustledger_core::directive::Balance {
609 date,
610 account,
611 amount,
612 tolerance,
613 meta,
614 };
615 let span = node_span(node.syntax(), bom_offset);
616 Some(Spanned::new(Directive::Balance(balance), span))
617}
618
619fn extract_balance_tolerance(node: &crate::SyntaxNode) -> Option<Decimal> {
625 let mut past_tilde = false;
626 for el in node.children_with_tokens() {
627 let rowan::NodeOrToken::Token(t) = el else {
628 continue;
629 };
630 if past_tilde && t.kind() == crate::SyntaxKind::NUMBER {
631 return parse_decimal_token(t.text());
632 }
633 if t.kind() == crate::SyntaxKind::TILDE {
634 past_tilde = true;
635 }
636 }
637 None
638}
639
640fn convert_pad(
641 node: &PadDirective,
642 bom_offset: u32,
643 errors: &mut Vec<crate::ParseError>,
644) -> Option<Spanned<Directive>> {
645 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
646 let account = Account::new(node.target_account()?.text());
647 let source_account = Account::new(node.source_account()?.text());
648 let meta = convert_meta_entries(node.syntax());
649
650 let pad = rustledger_core::directive::Pad {
651 date,
652 account,
653 source_account,
654 meta,
655 };
656 let span = node_span(node.syntax(), bom_offset);
657 Some(Spanned::new(Directive::Pad(pad), span))
658}
659
660fn convert_custom(
661 node: &CustomDirective,
662 bom_offset: u32,
663 errors: &mut Vec<crate::ParseError>,
664) -> Option<Spanned<Directive>> {
665 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
666 let custom_type = node.custom_type()?.text_decoded()?;
667 let values = extract_custom_values(node.syntax());
668 let meta = convert_meta_entries(node.syntax());
669
670 let custom = rustledger_core::directive::Custom {
671 date,
672 custom_type,
673 values,
674 meta,
675 };
676 let span = node_span(node.syntax(), bom_offset);
677 Some(Spanned::new(Directive::Custom(custom), span))
678}
679
680fn extract_custom_values(node: &crate::SyntaxNode) -> Vec<MetaValue> {
687 let mut values = Vec::new();
688 let mut seen_type_string = false;
689 let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
693 .children_with_tokens()
694 .filter_map(rowan::NodeOrToken::into_token)
695 .filter(|t| {
696 !matches!(
697 t.kind(),
698 crate::SyntaxKind::WHITESPACE
699 | crate::SyntaxKind::NEWLINE
700 | crate::SyntaxKind::COMMENT
701 )
702 })
703 .collect();
704
705 let mut i = 0;
706 while i < raw.len() {
707 if !seen_type_string {
710 if raw[i].kind() == crate::SyntaxKind::STRING {
711 seen_type_string = true;
712 }
713 i += 1;
714 continue;
715 }
716 if let Some((value, next)) = value_tokens_to_meta(&raw, i) {
720 values.push(value);
721 i = next;
722 } else {
723 i += 1;
724 }
725 }
726 values
727}
728
729fn strip_string_quotes(raw: &str) -> Option<&str> {
730 let bytes = raw.as_bytes();
731 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
732 return None;
733 }
734 Some(&raw[1..raw.len() - 1])
735}
736
737fn convert_option(node: &OptionDirective, bom_offset: u32) -> Option<(String, String, Span)> {
738 let key = node.key()?.text_decoded()?;
739 let value = node.value()?.text_decoded()?;
740 Some((
741 key,
742 value,
743 single_line_directive_span(node.syntax(), bom_offset),
744 ))
745}
746
747fn convert_include(node: &IncludeDirective, bom_offset: u32) -> Option<(String, Span)> {
748 let path = node.path()?.text_decoded()?;
749 Some((path, single_line_directive_span(node.syntax(), bom_offset)))
750}
751
752fn convert_plugin(
753 node: &PluginDirective,
754 bom_offset: u32,
755) -> Option<(String, Option<String>, Span)> {
756 let module = node.module()?.text_decoded()?;
757 let config = node.config().and_then(|c| c.text_decoded());
758 Some((
759 module,
760 config,
761 single_line_directive_span(node.syntax(), bom_offset),
762 ))
763}
764
765fn convert_transaction(
768 node: &AstTransaction,
769 bom_offset: u32,
770 errors: &mut Vec<crate::ParseError>,
771) -> Option<Spanned<Directive>> {
772 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
773
774 let flag = node.flag().map_or('*', |f| flag_char_from_transaction(&f));
777
778 let mut it = node.strings().filter_map(|s| s.text_decoded());
783 let (payee_str, narration_str) = match (it.next(), it.next(), it.next()) {
784 (None, _, _) => (None, String::new()),
785 (Some(n), None, _) => (None, n),
786 (Some(p), Some(n), None) => (Some(p), n),
787 (Some(_), Some(_), Some(c)) => (None, it.last().unwrap_or(c)),
790 };
791
792 let payee = payee_str.map(InternedStr::from);
793 let narration = InternedStr::from(narration_str);
794
795 let mut tags: Vec<Tag> = node
805 .tags()
806 .map(|t| Tag::new(t.text().trim_start_matches('#')))
807 .collect();
808 let mut links: Vec<Link> = node
809 .links()
810 .map(|l| Link::new(l.text().trim_start_matches('^')))
811 .collect();
812 for el in node.syntax().children_with_tokens() {
813 let rowan::NodeOrToken::Token(t) = el else {
814 continue;
817 };
818 match t.kind() {
819 crate::SyntaxKind::TAG => {
820 let stripped = t.text().trim_start_matches('#');
821 let new_tag = Tag::new(stripped);
822 if !tags.contains(&new_tag) {
823 tags.push(new_tag);
824 }
825 }
826 crate::SyntaxKind::LINK => {
827 let stripped = t.text().trim_start_matches('^');
828 let new_link = Link::new(stripped);
829 if !links.contains(&new_link) {
830 links.push(new_link);
831 }
832 }
833 _ => {}
834 }
835 }
836
837 let meta = convert_meta_entries(node.syntax());
840
841 let (postings, trailing_comments) = collect_postings_with_comments(node, bom_offset, errors);
852
853 if header_has_pipe(node) {
858 errors.push(crate::ParseError::new(
859 crate::ParseErrorKind::DeprecatedPipeSymbol,
860 node_span(node.syntax(), bom_offset),
861 ));
862 }
863
864 let txn = rustledger_core::directive::Transaction {
865 date,
866 flag,
867 payee,
868 narration,
869 tags,
870 links,
871 meta,
872 postings,
873 trailing_comments,
874 };
875 let span = node_span(node.syntax(), bom_offset);
876 Some(Spanned::new(Directive::Transaction(txn), span))
877}
878
879fn header_has_pipe(node: &AstTransaction) -> bool {
885 for el in node.syntax().children_with_tokens() {
886 let rowan::NodeOrToken::Token(t) = el else {
887 continue;
888 };
889 if t.kind() == crate::SyntaxKind::NEWLINE {
890 return false;
891 }
892 if t.kind() == crate::SyntaxKind::PIPE {
893 return true;
894 }
895 }
896 false
897}
898
899fn collect_postings_with_comments(
914 node: &AstTransaction,
915 bom_offset: u32,
916 errors: &mut Vec<crate::ParseError>,
917) -> (Vec<Spanned<Posting>>, Vec<String>) {
918 let mut out = Vec::new();
919 let mut pending: Vec<String> = Vec::new();
920 let mut past_header = false;
921 for el in node.syntax().children_with_tokens() {
922 match el {
923 rowan::NodeOrToken::Token(t) => {
924 if !past_header {
925 if t.kind() == crate::SyntaxKind::NEWLINE {
926 past_header = true;
927 }
928 continue;
929 }
930 if is_comment_kind(t.kind()) {
931 pending.push(t.text().to_string());
932 } else if !is_trivia_kind(t.kind())
933 && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
934 {
935 pending.clear();
956 }
957 }
958 rowan::NodeOrToken::Node(n) => {
959 if !past_header {
960 past_header = true;
965 }
966 if let Some(p) = ast::Posting::cast(n) {
967 if let Some(mut spanned) = convert_posting(&p, bom_offset, errors) {
968 if !pending.is_empty() {
969 spanned.value.comments = std::mem::take(&mut pending);
970 }
971 out.push(spanned);
972 } else {
973 pending.clear();
981 }
982 }
983 }
987 }
988 }
989 (out, pending)
990}
991
992fn flag_char_from_transaction(flag: &ast::TransactionFlag) -> char {
993 match flag.classify() {
994 TransactionFlagKind::Star | TransactionFlagKind::Txn => '*',
995 TransactionFlagKind::Pending => '!',
996 TransactionFlagKind::Hash => '#',
997 TransactionFlagKind::Letter | TransactionFlagKind::CurrencyLetter => {
998 flag.text().chars().next().unwrap_or('*')
999 }
1000 }
1001}
1002
1003fn convert_posting(
1004 node: &ast::Posting,
1005 bom_offset: u32,
1006 errors: &mut Vec<crate::ParseError>,
1007) -> Option<Spanned<Posting>> {
1008 let account = Account::new(node.account()?.text());
1009
1010 let flag = node.flag().map(|f| flag_char_from_posting(&f));
1011
1012 let mut amount_children = node
1024 .syntax()
1025 .children()
1026 .filter(|n| ast::Amount::can_cast(n.kind()));
1027 let first_amount = amount_children.next();
1028 let first_amount_end: Option<u32> = first_amount.as_ref().map(|n| n.text_range().end().into());
1029 let mut sibling_start: Option<u32> = None;
1030 let mut sibling_end: u32 = 0;
1031 for extra in amount_children {
1032 let range = extra.text_range();
1033 let start_u32: u32 = range.start().into();
1034 let end_u32: u32 = range.end().into();
1035 if sibling_start.is_none() {
1036 sibling_start = Some(start_u32);
1037 }
1038 sibling_end = end_u32;
1039 }
1040 if let Some(start_u32) = sibling_start {
1041 let underline_start = first_amount_end.unwrap_or(start_u32);
1048 let span = Span::new(
1049 (underline_start + bom_offset) as usize,
1050 (sibling_end + bom_offset) as usize,
1051 );
1052 errors.push(crate::ParseError::new(
1053 crate::ParseErrorKind::SyntaxError(
1054 "unexpected trailing tokens after posting amount".to_string(),
1055 ),
1056 span,
1057 ));
1058 }
1059 let units = first_amount
1060 .and_then(ast::Amount::cast)
1061 .and_then(|amt| convert_amount_to_incomplete(&amt, errors, bom_offset));
1062 let cost = node.cost_spec().map(|cs| convert_cost_spec(&cs));
1063 let price = node
1064 .price_annotation()
1065 .map(|pa| convert_price_annotation(&pa, errors, bom_offset));
1066 let meta = convert_meta_entries(node.syntax());
1067
1068 let trailing_comments: Vec<String> = node
1073 .syntax()
1074 .children_with_tokens()
1075 .filter_map(rowan::NodeOrToken::into_token)
1076 .take_while(|t| t.kind() != crate::SyntaxKind::NEWLINE)
1077 .filter(|t| is_comment_kind(t.kind()))
1078 .map(|t| t.text().to_string())
1079 .collect();
1080
1081 let posting = Posting {
1082 account,
1083 units,
1084 cost,
1085 price,
1086 flag,
1087 meta,
1088 comments: Vec::new(),
1089 trailing_comments,
1090 };
1091 let span = posting_span(node.syntax(), bom_offset);
1092 Some(Spanned::new(posting, span))
1093}
1094
1095fn flag_char_from_posting(flag: &ast::PostingFlag) -> char {
1096 match flag.classify() {
1097 PostingFlagKind::Star => '*',
1098 PostingFlagKind::Pending => '!',
1099 PostingFlagKind::Hash => '#',
1100 PostingFlagKind::Letter | PostingFlagKind::CurrencyLetter => {
1101 flag.text().chars().next().unwrap_or('*')
1102 }
1103 }
1104}
1105
1106fn convert_amount_to_incomplete(
1118 amt: &ast::Amount,
1119 errors: &mut Vec<crate::ParseError>,
1120 bom_offset: u32,
1121) -> Option<IncompleteAmount> {
1122 let number = if amt.is_arithmetic() {
1127 let evaluated = evaluate_amount_expression(amt);
1128 if evaluated.is_none() {
1129 let range = amt.syntax().text_range();
1138 let start: u32 = range.start().into();
1139 let end: u32 = range.end().into();
1140 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1141 errors.push(crate::ParseError::new(
1142 crate::ParseErrorKind::SyntaxError(
1143 "invalid arithmetic expression in amount (overflow, division by zero, or malformed)"
1144 .to_string(),
1145 ),
1146 span,
1147 ));
1148 }
1149 evaluated
1150 } else {
1151 amt.number().and_then(|n| {
1152 let parsed = parse_decimal_token(n.text());
1153 if parsed.is_none() {
1154 let range = n.syntax().text_range();
1164 let start: u32 = range.start().into();
1165 let end: u32 = range.end().into();
1166 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1167 errors.push(crate::ParseError::new(
1168 crate::ParseErrorKind::SyntaxError(
1169 "invalid number in amount (likely exceeds 28-digit Decimal precision)"
1170 .to_string(),
1171 ),
1172 span,
1173 ));
1174 }
1175 let mut value = parsed?;
1176 if let Some(sign) = amt.sign()
1177 && sign.is_minus()
1178 {
1179 value = -value;
1180 }
1181 Some(value)
1182 })
1183 };
1184 let currency = amt.currency().map(|c| Currency::new(c.text()));
1185 match (number, currency) {
1186 (Some(n), Some(c)) => Some(IncompleteAmount::Complete(Amount::new(n, c))),
1187 (Some(n), None) => Some(IncompleteAmount::NumberOnly(n)),
1188 (None, Some(c)) => Some(IncompleteAmount::CurrencyOnly(c)),
1189 (None, None) => None,
1190 }
1191}
1192
1193fn evaluate_amount_expression(amt: &ast::Amount) -> Option<Decimal> {
1210 let tokens = amount_expression_tokens(amt);
1211 let mut cursor = 0usize;
1212 let value = parse_arith_expr(&tokens, &mut cursor)?;
1213 if cursor != tokens.len() {
1217 return None;
1218 }
1219 Some(value)
1220}
1221
1222fn directive_arithmetic_value(node: &crate::SyntaxNode) -> Option<Decimal> {
1241 let raw: Vec<crate::SyntaxToken> = node
1242 .children_with_tokens()
1243 .filter_map(rowan::NodeOrToken::into_token)
1244 .filter(|t| !is_trivia_kind(t.kind()))
1245 .skip_while(|t| t.kind() != crate::SyntaxKind::NUMBER)
1246 .collect();
1247 let mut depth: i32 = 0;
1248 let mut first_currency_idx: Option<usize> = None;
1249 for (i, t) in raw.iter().enumerate() {
1250 match t.kind() {
1251 crate::SyntaxKind::L_PAREN => depth += 1,
1252 crate::SyntaxKind::R_PAREN => depth -= 1,
1253 crate::SyntaxKind::CURRENCY if depth == 0 && first_currency_idx.is_none() => {
1254 first_currency_idx = Some(i);
1255 }
1256 _ => {}
1257 }
1258 }
1259 let end = first_currency_idx.unwrap_or(raw.len());
1260 let tokens: Vec<crate::SyntaxToken> = raw.into_iter().take(end).collect();
1261 let has_op = tokens.iter().any(|t| {
1263 matches!(
1264 t.kind(),
1265 crate::SyntaxKind::PLUS
1266 | crate::SyntaxKind::MINUS
1267 | crate::SyntaxKind::STAR
1268 | crate::SyntaxKind::SLASH
1269 | crate::SyntaxKind::L_PAREN
1270 )
1271 });
1272 if !has_op {
1273 return None;
1274 }
1275 let mut cursor = 0usize;
1276 let value = parse_arith_expr(&tokens, &mut cursor)?;
1277 if cursor != tokens.len() {
1278 return None;
1279 }
1280 Some(value)
1281}
1282
1283fn amount_expression_tokens(amt: &ast::Amount) -> Vec<crate::SyntaxToken> {
1289 let raw: Vec<crate::SyntaxToken> = amt
1290 .syntax()
1291 .children_with_tokens()
1292 .filter_map(rowan::NodeOrToken::into_token)
1293 .filter(|t| !is_trivia_kind(t.kind()))
1294 .collect();
1295 let mut depth: i32 = 0;
1299 let mut trailing_currency_idx: Option<usize> = None;
1300 for (i, t) in raw.iter().enumerate() {
1301 match t.kind() {
1302 crate::SyntaxKind::L_PAREN => depth += 1,
1303 crate::SyntaxKind::R_PAREN => depth -= 1,
1304 crate::SyntaxKind::CURRENCY if depth == 0 => trailing_currency_idx = Some(i),
1305 _ => {}
1306 }
1307 }
1308 let end = trailing_currency_idx.unwrap_or(raw.len());
1309 raw.into_iter().take(end).collect()
1310}
1311
1312fn parse_arith_expr(tokens: &[crate::SyntaxToken], cursor: &mut usize) -> Option<Decimal> {
1314 let mut result = parse_arith_term(tokens, cursor)?;
1315 while let Some(op) = tokens.get(*cursor).map(crate::SyntaxToken::kind) {
1316 match op {
1317 crate::SyntaxKind::PLUS => {
1318 *cursor += 1;
1319 let rhs = parse_arith_term(tokens, cursor)?;
1320 result = result.checked_add(rhs)?;
1321 }
1322 crate::SyntaxKind::MINUS => {
1323 *cursor += 1;
1324 let rhs = parse_arith_term(tokens, cursor)?;
1325 result = result.checked_sub(rhs)?;
1326 }
1327 _ => break,
1328 }
1329 }
1330 Some(result)
1331}
1332
1333fn parse_arith_term(tokens: &[crate::SyntaxToken], cursor: &mut usize) -> Option<Decimal> {
1335 let mut result = parse_arith_primary(tokens, cursor)?;
1336 while let Some(op) = tokens.get(*cursor).map(crate::SyntaxToken::kind) {
1337 match op {
1338 crate::SyntaxKind::STAR => {
1339 *cursor += 1;
1340 let rhs = parse_arith_primary(tokens, cursor)?;
1341 result = result.checked_mul(rhs)?;
1342 }
1343 crate::SyntaxKind::SLASH => {
1344 *cursor += 1;
1345 let rhs = parse_arith_primary(tokens, cursor)?;
1346 if rhs.is_zero() {
1347 return None;
1348 }
1349 result = result.checked_div(rhs)?;
1350 }
1351 _ => break,
1352 }
1353 }
1354 Some(result)
1355}
1356
1357fn parse_arith_primary(tokens: &[crate::SyntaxToken], cursor: &mut usize) -> Option<Decimal> {
1359 let t = tokens.get(*cursor)?;
1360 match t.kind() {
1361 crate::SyntaxKind::L_PAREN => {
1362 *cursor += 1;
1363 let inner = parse_arith_expr(tokens, cursor)?;
1364 let close = tokens.get(*cursor)?;
1369 if close.kind() != crate::SyntaxKind::R_PAREN {
1370 return None;
1371 }
1372 *cursor += 1;
1373 Some(inner)
1374 }
1375 crate::SyntaxKind::MINUS => {
1376 *cursor += 1;
1377 let inner = parse_arith_primary(tokens, cursor)?;
1378 Some(-inner)
1379 }
1380 crate::SyntaxKind::PLUS => {
1381 *cursor += 1;
1382 parse_arith_primary(tokens, cursor)
1383 }
1384 crate::SyntaxKind::NUMBER => {
1385 let value = parse_decimal_token(t.text())?;
1386 *cursor += 1;
1387 Some(value)
1388 }
1389 _ => None,
1390 }
1391}
1392
1393fn convert_cost_spec(cs: &ast::CostSpec) -> CostSpec {
1394 cost_spec_from_tokens(
1395 cs.syntax()
1396 .children_with_tokens()
1397 .filter_map(rowan::NodeOrToken::into_token),
1398 )
1399}
1400
1401pub(super) trait TokenView {
1408 fn kind(&self) -> crate::SyntaxKind;
1410 fn text(&self) -> &str;
1412}
1413
1414impl TokenView for rowan::SyntaxToken<crate::BeancountLanguage> {
1415 fn kind(&self) -> crate::SyntaxKind {
1420 Self::kind(self)
1421 }
1422 fn text(&self) -> &str {
1423 Self::text(self)
1424 }
1425}
1426
1427impl TokenView for &rowan::GreenTokenData {
1428 fn kind(&self) -> crate::SyntaxKind {
1429 <crate::BeancountLanguage as rowan::Language>::kind_from_raw((*self).kind())
1430 }
1431 fn text(&self) -> &str {
1432 (*self).text()
1433 }
1434}
1435
1436pub(super) fn cost_spec_from_tokens(tokens: impl Iterator<Item = impl TokenView>) -> CostSpec {
1468 use crate::SyntaxKind as K;
1469 let mut is_total = false;
1470 let mut first_number: Option<Decimal> = None; let mut seen_number = false;
1472 let mut pre_hash: Option<Decimal> = None; let mut past_hash = false;
1474 let mut post_hash_total: Option<Decimal> = None; let mut currency: Option<Currency> = None;
1476 let mut date: Option<NaiveDate> = None;
1477 let mut date_seen = false;
1478 let mut label: Option<String> = None;
1479 let mut label_seen = false;
1480 let mut merge = false;
1481 let mut merge_decided = false;
1482 let mut past_opener = false;
1483 for t in tokens {
1484 let kind = t.kind();
1485 if !merge_decided {
1489 match kind {
1490 K::L_BRACE | K::L_DOUBLE_BRACE | K::L_BRACE_HASH => past_opener = true,
1491 K::WHITESPACE => {}
1492 K::STAR if past_opener => {
1493 merge = true;
1494 merge_decided = true;
1495 }
1496 _ if past_opener => merge_decided = true,
1497 _ => {}
1498 }
1499 }
1500 match kind {
1501 K::L_DOUBLE_BRACE => is_total = true,
1502 K::NUMBER => {
1503 if past_hash {
1504 if post_hash_total.is_none() {
1505 post_hash_total = parse_decimal_token(t.text());
1506 }
1507 } else {
1508 if pre_hash.is_none() {
1509 pre_hash = parse_decimal_token(t.text());
1510 }
1511 if !seen_number {
1512 seen_number = true;
1513 first_number = parse_decimal_token(t.text());
1514 }
1515 }
1516 }
1517 K::HASH | K::L_BRACE_HASH => past_hash = true,
1518 K::CURRENCY if currency.is_none() => currency = Some(Currency::new(t.text())),
1519 K::DATE if !date_seen => {
1520 date_seen = true;
1521 date = parse_date_token(t.text());
1522 }
1523 K::STRING if !label_seen => {
1524 label_seen = true;
1525 label = decode_string_token(t.text());
1526 }
1527 _ => {}
1528 }
1529 }
1530 let number = if past_hash {
1531 Some(CostNumber::Compound {
1532 per_unit: pre_hash.unwrap_or_default(),
1533 total: post_hash_total.unwrap_or_default(),
1534 })
1535 } else {
1536 match (first_number, is_total) {
1537 (Some(v), true) => Some(CostNumber::Total { value: v }),
1538 (Some(v), false) => Some(CostNumber::PerUnit { value: v }),
1539 (None, _) => None,
1540 }
1541 };
1542 CostSpec {
1543 number,
1544 currency,
1545 date,
1546 label,
1547 merge,
1548 }
1549}
1550
1551fn convert_price_annotation(
1552 pa: &ast::PriceAnnotation,
1553 errors: &mut Vec<crate::ParseError>,
1554 bom_offset: u32,
1555) -> PriceAnnotation {
1556 let kind = if pa.is_total() {
1557 PriceKind::Total
1558 } else {
1559 PriceKind::Unit
1560 };
1561 let amount = pa
1562 .amount()
1563 .and_then(|a| convert_amount_to_incomplete(&a, errors, bom_offset));
1564 PriceAnnotation { kind, amount }
1565}
1566
1567fn convert_meta_entries(node: &crate::SyntaxNode) -> Metadata {
1574 let mut meta = Metadata::default();
1575 for entry in node.children().filter_map(MetaEntry::cast) {
1576 let Some(key_token) = entry.key() else {
1577 continue;
1578 };
1579 let key = key_token.text_without_colon().to_string();
1580 let value = meta_value_from_entry(&entry);
1581 meta.insert(key, value);
1582 }
1583 meta
1584}
1585
1586fn node_has_minus_before_number(node: &crate::SyntaxNode) -> bool {
1591 for el in node.children_with_tokens() {
1592 let rowan::NodeOrToken::Token(t) = el else {
1593 continue;
1594 };
1595 match t.kind() {
1596 crate::SyntaxKind::MINUS => return true,
1597 crate::SyntaxKind::NUMBER => return false,
1598 _ => {}
1599 }
1600 }
1601 false
1602}
1603
1604fn value_tokens_to_meta(
1618 tokens: &[rowan::SyntaxToken<crate::BeancountLanguage>],
1619 start: usize,
1620) -> Option<(MetaValue, usize)> {
1621 let mut i = start;
1622 let mut negate = false;
1623 if tokens.get(i).map(rowan::SyntaxToken::kind) == Some(crate::SyntaxKind::MINUS) {
1624 negate = true;
1625 i += 1;
1626 }
1627 let t = tokens.get(i)?;
1628 match t.kind() {
1629 crate::SyntaxKind::STRING => {
1630 let s = strip_string_quotes(t.text())?;
1631 Some((MetaValue::String(s.to_string()), i + 1))
1632 }
1633 crate::SyntaxKind::NUMBER => {
1634 let mut decimal = parse_decimal_token(t.text())?;
1635 if negate {
1636 decimal = -decimal;
1637 }
1638 if let Some(next) = tokens.get(i + 1)
1640 && next.kind() == crate::SyntaxKind::CURRENCY
1641 {
1642 return Some((
1643 MetaValue::Amount(Amount::new(decimal, Currency::new(next.text()))),
1644 i + 2,
1645 ));
1646 }
1647 Some((number_meta_value(t.text(), decimal), i + 1))
1648 }
1649 crate::SyntaxKind::DATE => Some((MetaValue::Date(parse_date_token(t.text())?), i + 1)),
1650 crate::SyntaxKind::ACCOUNT => Some((MetaValue::Account(Account::new(t.text())), i + 1)),
1651 crate::SyntaxKind::CURRENCY => Some((MetaValue::Currency(Currency::new(t.text())), i + 1)),
1652 crate::SyntaxKind::BOOL_TRUE => Some((MetaValue::Bool(true), i + 1)),
1653 crate::SyntaxKind::BOOL_FALSE => Some((MetaValue::Bool(false), i + 1)),
1654 crate::SyntaxKind::TAG => Some((
1655 MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))),
1656 i + 1,
1657 )),
1658 crate::SyntaxKind::LINK => Some((
1659 MetaValue::Link(Link::new(t.text().trim_start_matches('^'))),
1660 i + 1,
1661 )),
1662 _ => None,
1663 }
1664}
1665
1666fn meta_value_from_entry(entry: &MetaEntry) -> MetaValue {
1670 meta_value_from_tokens(
1671 entry
1672 .syntax()
1673 .children_with_tokens()
1674 .filter_map(rowan::NodeOrToken::into_token),
1675 )
1676}
1677
1678pub(super) fn meta_value_from_tokens(tokens: impl Iterator<Item = impl TokenView>) -> MetaValue {
1693 use crate::SyntaxKind as K;
1694 let mut string_t: Option<String> = None;
1695 let mut number_t: Option<String> = None;
1696 let mut currency_t: Option<String> = None;
1697 let mut date_t: Option<String> = None;
1698 let mut account_t: Option<String> = None;
1699 let mut bool_v: Option<bool> = None;
1700 let mut tag_link: Option<MetaValue> = None;
1701 let mut past_key = false;
1702 let mut minus = false;
1703 let mut minus_decided = false;
1704
1705 for t in tokens {
1706 let kind = t.kind();
1707 match kind {
1709 K::STRING if string_t.is_none() => string_t = Some(t.text().to_string()),
1710 K::NUMBER if number_t.is_none() => number_t = Some(t.text().to_string()),
1711 K::CURRENCY if currency_t.is_none() => currency_t = Some(t.text().to_string()),
1712 K::DATE if date_t.is_none() => date_t = Some(t.text().to_string()),
1713 K::ACCOUNT if account_t.is_none() => account_t = Some(t.text().to_string()),
1714 K::BOOL_TRUE if bool_v.is_none() => bool_v = Some(true),
1715 K::BOOL_FALSE if bool_v.is_none() => bool_v = Some(false),
1716 K::TAG if tag_link.is_none() => {
1717 tag_link = Some(MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))));
1718 }
1719 K::LINK if tag_link.is_none() => {
1720 tag_link = Some(MetaValue::Link(Link::new(t.text().trim_start_matches('^'))));
1721 }
1722 _ => {}
1723 }
1724 if past_key && !minus_decided {
1725 match kind {
1726 K::MINUS => {
1727 minus = true;
1728 minus_decided = true;
1729 }
1730 K::NUMBER => minus_decided = true,
1731 _ => {}
1732 }
1733 }
1734 if kind == K::META_KEY {
1735 past_key = true;
1736 }
1737 }
1738
1739 if let Some(s) = string_t
1740 && let Some(decoded) = decode_string_token(&s)
1741 {
1742 return MetaValue::String(decoded);
1743 }
1744 if let Some(nt) = number_t
1745 && let Some(mut dec) = parse_decimal_token(&nt)
1746 {
1747 if minus {
1748 dec = -dec;
1749 }
1750 if let Some(c) = currency_t {
1751 return MetaValue::Amount(Amount::new(dec, Currency::new(&c)));
1752 }
1753 return number_meta_value(&nt, dec);
1754 }
1755 if let Some(dt) = date_t
1756 && let Some(date) = parse_date_token(&dt)
1757 {
1758 return MetaValue::Date(date);
1759 }
1760 if let Some(a) = account_t {
1761 return MetaValue::Account(Account::new(&a));
1762 }
1763 if let Some(c) = currency_t {
1764 return MetaValue::Currency(Currency::new(&c));
1765 }
1766 if let Some(b) = bool_v {
1767 return MetaValue::Bool(b);
1768 }
1769 if let Some(tl) = tag_link {
1770 return tl;
1771 }
1772 MetaValue::None
1773}
1774
1775fn apply_inherited_state(
1789 value: &mut Directive,
1790 tag_stack: &[(Tag, Span)],
1791 meta_stack: &[(String, MetaValue, Span)],
1792) {
1793 if let Directive::Transaction(txn) = value {
1794 for (tag, _) in tag_stack {
1795 if !txn.tags.contains(tag) {
1796 txn.tags.push(tag.clone());
1797 }
1798 }
1799 }
1800 if meta_stack.is_empty() {
1801 return;
1802 }
1803 let meta = match value {
1804 Directive::Transaction(d) => &mut d.meta,
1805 Directive::Balance(d) => &mut d.meta,
1806 Directive::Open(d) => &mut d.meta,
1807 Directive::Close(d) => &mut d.meta,
1808 Directive::Commodity(d) => &mut d.meta,
1809 Directive::Pad(d) => &mut d.meta,
1810 Directive::Event(d) => &mut d.meta,
1811 Directive::Query(d) => &mut d.meta,
1812 Directive::Note(d) => &mut d.meta,
1813 Directive::Document(d) => &mut d.meta,
1814 Directive::Price(d) => &mut d.meta,
1815 Directive::Custom(d) => &mut d.meta,
1816 };
1817 for (k, v, _) in meta_stack {
1818 meta.insert(k.clone(), v.clone());
1819 }
1820}
1821
1822fn pushmeta_value(node: &crate::SyntaxNode) -> MetaValue {
1827 let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
1832 .children_with_tokens()
1833 .filter_map(rowan::NodeOrToken::into_token)
1834 .filter(|t| {
1835 !matches!(
1836 t.kind(),
1837 crate::SyntaxKind::WHITESPACE
1838 | crate::SyntaxKind::NEWLINE
1839 | crate::SyntaxKind::COMMENT
1840 )
1841 })
1842 .collect();
1843
1844 let mut i = 0;
1845 while i < raw.len() {
1846 if let Some((value, _)) = value_tokens_to_meta(&raw, i) {
1847 return value;
1848 }
1849 i += 1;
1850 }
1851 MetaValue::None
1852}
1853
1854pub(super) const fn is_comment_kind(kind: crate::SyntaxKind) -> bool {
1860 matches!(
1861 kind,
1862 crate::SyntaxKind::COMMENT
1863 | crate::SyntaxKind::PERCENT_COMMENT
1864 | crate::SyntaxKind::SHEBANG
1865 | crate::SyntaxKind::EMACS_DIRECTIVE
1866 )
1867}
1868
1869pub(super) struct TopLevelWalkResult {
1871 pub(super) errors: Vec<crate::ParseError>,
1872 pub(super) section_marker_comments: Vec<Spanned<String>>,
1873}
1874
1875fn walk_top_level_once(
1887 source_file: &SourceFile,
1888 stripped: &str,
1889 bom_offset: u32,
1890) -> TopLevelWalkResult {
1891 let mut errors: Vec<crate::ParseError> = Vec::new();
1892 let mut section_marker_comments: Vec<Spanned<String>> = Vec::new();
1893 for child in source_file.syntax().children() {
1894 let kind = child.kind();
1895 if ast::Directive::can_cast(kind) {
1897 indented_directive_check(&child, stripped, bom_offset, &mut errors);
1898 }
1899 match kind {
1900 crate::SyntaxKind::CUSTOM_DIRECTIVE => {
1901 custom_value_check(&child, bom_offset, &mut errors);
1902 }
1903 crate::SyntaxKind::TRANSACTION => {
1904 transaction_body_check(&child, bom_offset, &mut errors);
1905 }
1906 crate::SyntaxKind::ERROR_NODE => {
1907 error_node_check(&child, stripped, bom_offset, &mut errors);
1908 section_marker_check(&child, bom_offset, &mut section_marker_comments);
1909 }
1910 _ => {}
1911 }
1912 }
1913 TopLevelWalkResult {
1914 errors,
1915 section_marker_comments,
1916 }
1917}
1918
1919fn extract_unclosed_cost_brace_errors(
1928 source_file: &SourceFile,
1929 bom_offset: u32,
1930) -> Vec<crate::ParseError> {
1931 let mut out = Vec::new();
1932 for cs in source_file.syntax().descendants() {
1933 if cs.kind() != crate::SyntaxKind::COST_SPEC {
1934 continue;
1935 }
1936 let mut has_opener = false;
1937 let mut has_closer = false;
1938 for el in cs.children_with_tokens() {
1939 let rowan::NodeOrToken::Token(t) = el else {
1940 continue;
1941 };
1942 match t.kind() {
1943 crate::SyntaxKind::L_BRACE
1944 | crate::SyntaxKind::L_DOUBLE_BRACE
1945 | crate::SyntaxKind::L_BRACE_HASH => has_opener = true,
1946 crate::SyntaxKind::R_BRACE | crate::SyntaxKind::R_DOUBLE_BRACE => has_closer = true,
1947 _ => {}
1948 }
1949 }
1950 if has_opener && !has_closer {
1951 out.push(crate::ParseError::new(
1952 crate::ParseErrorKind::SyntaxError(
1953 "unclosed cost specification: missing '}'".to_string(),
1954 ),
1955 node_span(&cs, bom_offset),
1956 ));
1957 }
1958 }
1959 out
1960}
1961
1962fn indented_directive_check(
1973 child: &crate::SyntaxNode,
1974 stripped: &str,
1975 bom_offset: u32,
1976 out: &mut Vec<crate::ParseError>,
1977) {
1978 let Some(content) = child
1984 .children_with_tokens()
1985 .filter_map(rowan::NodeOrToken::into_token)
1986 .find(|t| !is_trivia_kind(t.kind()))
1987 else {
1988 return;
1989 };
1990 let content_start: usize = u32::from(content.text_range().start()) as usize;
1991 let line_start = stripped
2003 .as_bytes()
2004 .get(..content_start)
2005 .and_then(|bytes| bytes.iter().rposition(|&b| b == b'\n'))
2006 .map_or(0, |nl| nl + 1);
2007 if content_start > line_start {
2008 let end: u32 = content.text_range().end().into();
2009 let span = Span::new(
2010 (line_start as u32 + bom_offset) as usize,
2011 (end + bom_offset) as usize,
2012 );
2013 out.push(crate::ParseError::new(
2014 crate::ParseErrorKind::SyntaxError(
2015 "top-level directive must start at column 0".to_string(),
2016 ),
2017 span,
2018 ));
2019 }
2020}
2021
2022fn custom_value_check(
2037 child: &crate::SyntaxNode,
2038 bom_offset: u32,
2039 out: &mut Vec<crate::ParseError>,
2040) {
2041 {
2043 let raw: Vec<crate::SyntaxToken> = child
2048 .children_with_tokens()
2049 .filter_map(rowan::NodeOrToken::into_token)
2050 .filter(|t| !is_trivia_kind(t.kind()))
2051 .collect();
2052 let mut seen_type_string = false;
2053 let mut i = 0;
2054 while i < raw.len() {
2055 let t = &raw[i];
2056 if !seen_type_string {
2057 if t.kind() == crate::SyntaxKind::STRING {
2058 seen_type_string = true;
2059 }
2060 i += 1;
2061 continue;
2062 }
2063 if t.kind() == crate::SyntaxKind::CURRENCY {
2064 let preceded_by_number = i > 0 && raw[i - 1].kind() == crate::SyntaxKind::NUMBER;
2070 if !preceded_by_number {
2071 let range = t.text_range();
2072 let start: u32 = range.start().into();
2073 let end: u32 = range.end().into();
2074 let span =
2075 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2076 out.push(crate::ParseError::new(
2077 crate::ParseErrorKind::SyntaxError(
2078 "bare currency literal is not a valid custom directive value"
2079 .to_string(),
2080 ),
2081 span,
2082 ));
2083 }
2084 }
2085 i += 1;
2086 }
2087 }
2088}
2089
2090fn transaction_body_check(
2097 child: &crate::SyntaxNode,
2098 bom_offset: u32,
2099 out: &mut Vec<crate::ParseError>,
2100) {
2101 {
2103 let mut past_header = false;
2113 let mut saw_header_content = false;
2114 let mut line_start: Option<u32> = None;
2115 let mut line_has_content = false;
2116 for el in child.children_with_tokens() {
2117 match el {
2118 rowan::NodeOrToken::Token(t) => {
2119 if !past_header {
2120 if t.kind() == crate::SyntaxKind::NEWLINE {
2121 if saw_header_content {
2122 past_header = true;
2123 }
2124 } else if !is_trivia_kind(t.kind()) {
2125 saw_header_content = true;
2126 }
2127 continue;
2128 }
2129 let range = t.text_range();
2130 let start: u32 = range.start().into();
2131 let end: u32 = range.end().into();
2132 if line_start.is_none() {
2133 line_start = Some(start);
2134 }
2135 if t.kind() == crate::SyntaxKind::NEWLINE {
2136 if line_has_content && let Some(ls) = line_start {
2137 let span =
2139 Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2140 out.push(crate::ParseError::new(
2143 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2144 span,
2145 ));
2146 }
2147 line_start = None;
2148 line_has_content = false;
2149 } else if !is_trivia_kind(t.kind())
2150 && !is_comment_kind(t.kind())
2151 && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
2152 {
2153 line_has_content = true;
2159 }
2160 }
2161 rowan::NodeOrToken::Node(_) => {
2162 line_start = None;
2164 line_has_content = false;
2165 if !past_header {
2166 past_header = true;
2167 }
2168 }
2169 }
2170 }
2171 }
2172}
2173
2174fn error_node_check(
2184 child: &crate::SyntaxNode,
2185 stripped: &str,
2186 bom_offset: u32,
2187 out: &mut Vec<crate::ParseError>,
2188) {
2189 {
2191 let mut line_start: Option<u32> = None;
2192 let mut first_non_trivia: Option<crate::SyntaxKind> = None;
2193 for el in child.children_with_tokens() {
2194 let rowan::NodeOrToken::Token(t) = el else {
2195 continue;
2196 };
2197 let range = t.text_range();
2198 let start: u32 = range.start().into();
2199 let end: u32 = range.end().into();
2200 if line_start.is_none() {
2201 line_start = Some(start);
2202 }
2203 if t.kind() == crate::SyntaxKind::NEWLINE {
2204 let is_section = matches!(first_non_trivia, Some(crate::SyntaxKind::STAR));
2206 let is_comment = matches!(first_non_trivia, Some(k) if is_comment_kind(k));
2207 if !is_section
2208 && !is_comment
2209 && first_non_trivia.is_some()
2210 && let Some(ls) = line_start
2211 {
2212 let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2216 let line_text = stripped.get(ls as usize..end as usize).unwrap_or("");
2217 let primary = classify_recovery_error(line_text, span);
2218 let primary_is_bom =
2219 matches!(primary.kind, crate::ParseErrorKind::BomInDirectiveBody);
2220 out.push(primary);
2221 if !primary_is_bom && line_text.contains(crate::bom::BOM_CHAR) {
2231 out.push(
2232 crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2233 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
2234 );
2235 }
2236 }
2237 line_start = None;
2238 first_non_trivia = None;
2239 continue;
2240 }
2241 if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
2242 first_non_trivia = Some(t.kind());
2243 }
2244 }
2245 }
2246}
2247
2248pub(super) fn classify_recovery_error(line_text: &str, span: Span) -> crate::ParseError {
2261 if let Some(account) = crate::diagnostics::find_unicode_account(line_text) {
2262 return crate::ParseError::new(
2263 crate::ParseErrorKind::InvalidAccount(account.to_string()),
2264 span,
2265 );
2266 }
2267 if line_text.contains(crate::bom::BOM_CHAR) {
2268 return crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2269 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT);
2270 }
2271 crate::ParseError::new(
2272 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2273 span,
2274 )
2275}
2276
2277pub(super) struct DescendantsWalkResult {
2296 pub(super) inline_errors: Vec<crate::ParseError>,
2297 pub(super) top_level_comments: Vec<Spanned<String>>,
2298 pub(super) currency_occurrences: Vec<Spanned<Currency>>,
2299 pub(super) account_occurrences: Vec<Spanned<rustledger_core::Account>>,
2300}
2301
2302fn walk_descendants_once(
2310 source_file: &SourceFile,
2311 bom_offset: u32,
2312 collect_occurrences: bool,
2313) -> DescendantsWalkResult {
2314 let mut inline_errors: Vec<crate::ParseError> = Vec::new();
2315 let mut top_level_comments: Vec<Spanned<String>> = Vec::new();
2316 let mut currency_occurrences: Vec<Spanned<Currency>> = Vec::new();
2317 let mut account_occurrences: Vec<Spanned<rustledger_core::Account>> = Vec::new();
2318
2319 let mut preceded_by_ws = false;
2321
2322 for el in source_file.syntax().descendants_with_tokens() {
2323 let rowan::NodeOrToken::Token(t) = el else {
2324 if let rowan::NodeOrToken::Node(n) = el
2329 && ast::Directive::can_cast(n.kind())
2330 {
2331 preceded_by_ws = false;
2332 }
2333 continue;
2334 };
2335
2336 match t.kind() {
2338 crate::SyntaxKind::NEWLINE => preceded_by_ws = false,
2339 crate::SyntaxKind::WHITESPACE => preceded_by_ws = true,
2340 k if is_comment_kind(k) => {
2341 if !preceded_by_ws {
2342 let range = t.text_range();
2343 let start: u32 = range.start().into();
2344 let end: u32 = range.end().into();
2345 let span =
2346 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2347 top_level_comments.push(Spanned::new(t.text().to_string(), span));
2348 }
2349 }
2350 _ => {
2351 preceded_by_ws = false;
2352 }
2353 }
2354
2355 if t.kind() == crate::SyntaxKind::BOM {
2357 continue;
2358 }
2359 let kind = t.kind();
2367 let has_bom = t.text().contains(crate::bom::BOM_CHAR);
2368 let is_error_token = kind == crate::SyntaxKind::ERROR_TOKEN;
2369 let needs_in_error_check = (collect_occurrences
2372 && matches!(
2373 kind,
2374 crate::SyntaxKind::CURRENCY | crate::SyntaxKind::ACCOUNT
2375 ))
2376 || has_bom
2377 || is_error_token;
2378 if !needs_in_error_check {
2379 continue;
2380 }
2381 let in_error_node = t
2382 .parent_ancestors()
2383 .any(|a| a.kind() == crate::SyntaxKind::ERROR_NODE);
2384
2385 if collect_occurrences && kind == crate::SyntaxKind::CURRENCY && !in_error_node {
2388 let range = t.text_range();
2389 let start: u32 = range.start().into();
2390 let end: u32 = range.end().into();
2391 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2392 currency_occurrences.push(Spanned::new(Currency::new(t.text()), span));
2393 }
2394
2395 if collect_occurrences && kind == crate::SyntaxKind::ACCOUNT && !in_error_node {
2403 let range = t.text_range();
2404 let start: u32 = range.start().into();
2405 let end: u32 = range.end().into();
2406 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2407 account_occurrences.push(Spanned::new(rustledger_core::Account::new(t.text()), span));
2408 }
2409
2410 if (!has_bom && !is_error_token) || in_error_node {
2416 continue;
2417 }
2418 let range = t.text_range();
2419 let start: u32 = range.start().into();
2420 let end: u32 = range.end().into();
2421 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2422 if has_bom {
2423 inline_errors.push(
2424 crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2425 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
2426 );
2427 } else {
2428 inline_errors.push(crate::ParseError::new(
2429 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2430 span,
2431 ));
2432 }
2433 }
2434
2435 DescendantsWalkResult {
2436 inline_errors,
2437 top_level_comments,
2438 currency_occurrences,
2439 account_occurrences,
2440 }
2441}
2442
2443fn section_marker_check(
2450 child: &crate::SyntaxNode,
2451 bom_offset: u32,
2452 out: &mut Vec<Spanned<String>>,
2453) {
2454 let mut line_start: Option<u32> = None;
2459 let mut first_non_trivia: Option<crate::SyntaxKind> = None;
2460 for el in child.children_with_tokens() {
2461 let rowan::NodeOrToken::Token(t) = el else {
2462 continue;
2463 };
2464 let range = t.text_range();
2465 let start: u32 = range.start().into();
2466 let end: u32 = range.end().into();
2467 if line_start.is_none() {
2468 line_start = Some(start);
2469 }
2470 if t.kind() == crate::SyntaxKind::NEWLINE {
2471 if first_non_trivia == Some(crate::SyntaxKind::STAR)
2472 && let Some(ls) = line_start
2473 {
2474 let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2475 out.push(Spanned::new(String::new(), span));
2476 }
2477 line_start = None;
2478 first_non_trivia = None;
2479 continue;
2480 }
2481 if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
2482 first_non_trivia = Some(t.kind());
2483 }
2484 }
2485}
2486
2487pub(super) fn parse_date_token(text: &str) -> Option<NaiveDate> {
2499 if text.len() == 10
2501 && text.as_bytes()[4] == b'-'
2502 && text.as_bytes()[7] == b'-'
2503 && let (Ok(y), Ok(m), Ok(d)) = (
2504 text[0..4].parse::<i32>(),
2505 text[5..7].parse::<u32>(),
2506 text[8..10].parse::<u32>(),
2507 )
2508 {
2509 return naive_date(y, m, d);
2510 }
2511 crate::diagnostics::normalize_date_str(text)
2515 .parse::<NaiveDate>()
2516 .ok()
2517}
2518
2519fn parse_directive_date(
2527 date_tok: &ast::Date,
2528 errors: &mut Vec<crate::ParseError>,
2529 bom_offset: u32,
2530) -> Option<NaiveDate> {
2531 let text = date_tok.text();
2532 if let Some(d) = parse_date_token(text) {
2533 return Some(d);
2534 }
2535 let range = date_tok.syntax().text_range();
2536 let start: u32 = range.start().into();
2537 let end: u32 = range.end().into();
2538 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2539 errors.push(crate::ParseError::new(
2540 crate::ParseErrorKind::InvalidDateValue(crate::diagnostics::describe_invalid_date(text)),
2541 span,
2542 ));
2543 None
2544}
2545
2546pub(super) fn decode_string_token(text: &str) -> Option<String> {
2552 let bytes = text.as_bytes();
2553 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
2554 return None;
2555 }
2556 let raw = &text[1..text.len() - 1];
2557 if !raw.contains('\\') {
2558 return Some(raw.to_string());
2559 }
2560 let mut out = String::with_capacity(raw.len());
2561 let mut chars = raw.chars();
2562 while let Some(c) = chars.next() {
2563 if c != '\\' {
2564 out.push(c);
2565 continue;
2566 }
2567 match chars.next() {
2568 Some('"') => out.push('"'),
2569 Some('\\') => out.push('\\'),
2570 Some('n') => out.push('\n'),
2571 Some('t') => out.push('\t'),
2572 Some('r') => out.push('\r'),
2573 Some(other) => out.push(other),
2574 None => {}
2575 }
2576 }
2577 Some(out)
2578}
2579
2580pub(super) fn parse_decimal_token(text: &str) -> Option<Decimal> {
2583 use std::str::FromStr;
2584 let cleaned: String;
2585 let s = if text.contains(',') {
2586 cleaned = text.replace(',', "");
2587 cleaned.as_str()
2588 } else {
2589 text
2590 };
2591 Decimal::from_str(s).ok()
2592}
2593
2594pub(super) fn number_meta_value(text: &str, value: Decimal) -> MetaValue {
2603 use rust_decimal::prelude::ToPrimitive;
2604 if !text.contains('.')
2605 && !text.contains('e')
2606 && !text.contains('E')
2607 && let Some(i) = value.to_i64()
2608 {
2609 return MetaValue::Int(i);
2610 }
2611 MetaValue::Number(value)
2612}
2613
2614fn node_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2620 let range = node.text_range();
2621 let start: u32 = range.start().into();
2622 let end: u32 = range.end().into();
2623 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2624}
2625
2626pub(super) const fn is_trivia_kind(kind: crate::SyntaxKind) -> bool {
2638 matches!(
2639 kind,
2640 crate::SyntaxKind::WHITESPACE
2641 | crate::SyntaxKind::NEWLINE
2642 | crate::SyntaxKind::COMMENT
2643 | crate::SyntaxKind::PERCENT_COMMENT
2644 | crate::SyntaxKind::SHEBANG
2645 | crate::SyntaxKind::EMACS_DIRECTIVE
2646 )
2647}
2648
2649fn posting_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2658 let range = node.text_range();
2659 let start: u32 = range.start().into();
2660 let end_raw: u32 = range.end().into();
2661 let end = node
2664 .children_with_tokens()
2665 .filter_map(rowan::NodeOrToken::into_token)
2666 .find(|t| t.kind() == crate::SyntaxKind::NEWLINE)
2667 .map_or(end_raw, |t| u32::from(t.text_range().start()));
2668 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2669}
2670
2671fn single_line_directive_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2678 let range = node.text_range();
2679 let start_raw: u32 = range.start().into();
2680 let end_raw: u32 = range.end().into();
2681 let mut content_start: Option<u32> = None;
2682 let mut terminator: Option<u32> = None;
2683 for t in node
2684 .children_with_tokens()
2685 .filter_map(rowan::NodeOrToken::into_token)
2686 {
2687 if content_start.is_none() {
2688 if !is_trivia_kind(t.kind()) {
2689 content_start = Some(u32::from(t.text_range().start()));
2690 }
2691 } else if t.kind() == crate::SyntaxKind::NEWLINE {
2692 terminator = Some(u32::from(t.text_range().start()));
2693 break;
2694 }
2695 }
2696 let start = content_start.unwrap_or(start_raw);
2697 let end = terminator.unwrap_or(end_raw);
2698 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2699}
2700
2701fn fixup_directive_spans(
2708 source_file: &SourceFile,
2709 bom_offset: u32,
2710 converted_nodes: &[crate::SyntaxNode],
2711 directives: &mut [Spanned<Directive>],
2712) {
2713 debug_assert_eq!(
2714 converted_nodes.len(),
2715 directives.len(),
2716 "converted_nodes and directives must be parallel arrays"
2717 );
2718
2719 let all_starts: Vec<(usize, usize)> = source_file
2727 .syntax()
2728 .children()
2729 .filter(|n| ast::Directive::can_cast(n.kind()))
2730 .map(|n| {
2731 let raw_start: u32 = n.text_range().start().into();
2732 let content_start = n
2733 .descendants_with_tokens()
2734 .filter_map(rowan::NodeOrToken::into_token)
2735 .find(|t| !is_trivia_kind(t.kind()))
2736 .map_or_else(
2737 || (raw_start + bom_offset) as usize,
2738 |t| (u32::from(t.text_range().start()) + bom_offset) as usize,
2739 );
2740 ((raw_start + bom_offset) as usize, content_start)
2741 })
2742 .collect();
2743
2744 let source_end: usize =
2745 (u32::from(source_file.syntax().text_range().end()) + bom_offset) as usize;
2746
2747 for (i, spanned) in directives.iter_mut().enumerate() {
2761 let node = &converted_nodes[i];
2762 let raw_start: usize = (u32::from(node.text_range().start()) + bom_offset) as usize;
2763 let node_end: usize = (u32::from(node.text_range().end()) + bom_offset) as usize;
2764 if let Some(pos) = all_starts.iter().position(|(rs, _)| *rs == raw_start) {
2765 let start = all_starts[pos].1;
2766 let end = all_starts
2767 .get(pos + 1)
2768 .map_or(source_end, |(_, content)| *content);
2769 spanned.span = Span::new(start, end);
2770 } else {
2771 let content_start = node
2778 .descendants_with_tokens()
2779 .filter_map(rowan::NodeOrToken::into_token)
2780 .find(|t| !is_trivia_kind(t.kind()))
2781 .map_or(raw_start, |t| {
2782 (u32::from(t.text_range().start()) + bom_offset) as usize
2783 });
2784 spanned.span = Span::new(content_start, node_end);
2785 }
2786 }
2787}
2788
2789#[cfg(test)]
2790mod tests {
2791 use super::*;
2792
2793 fn assert_directive_count(result: &ParseResult, expected: usize) {
2794 assert_eq!(
2795 result.directives.len(),
2796 expected,
2797 "directive count mismatch: {:#?}",
2798 result.directives
2799 );
2800 }
2801
2802 #[test]
2803 fn open_directive_basic() {
2804 let src = "2024-01-15 open Assets:Cash\n";
2805 let result = parse_via_cst(src);
2806 assert_directive_count(&result, 1);
2807 let Directive::Open(open) = &result.directives[0].value else {
2808 panic!("expected Open, got {:?}", result.directives[0].value);
2809 };
2810 assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
2811 assert_eq!(open.account.as_str(), "Assets:Cash");
2812 assert!(open.currencies.is_empty());
2813 assert!(open.booking.is_none());
2814 assert!(open.meta.is_empty());
2815 }
2816
2817 #[test]
2818 fn open_directive_with_currencies_and_booking() {
2819 let src = "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n";
2820 let result = parse_via_cst(src);
2821 assert_directive_count(&result, 1);
2822 let Directive::Open(open) = &result.directives[0].value else {
2823 panic!("expected Open");
2824 };
2825 let currencies: Vec<&str> = open.currencies.iter().map(Currency::as_str).collect();
2826 assert_eq!(currencies, vec!["USD", "EUR"]);
2827 assert_eq!(open.booking.as_deref(), Some("STRICT"));
2828 }
2829
2830 #[test]
2831 fn open_directive_with_metadata() {
2832 let src = "2024-01-15 open Assets:Cash\n note: \"main checking\"\n number: 42\n";
2833 let result = parse_via_cst(src);
2834 assert_directive_count(&result, 1);
2835 let Directive::Open(open) = &result.directives[0].value else {
2836 panic!("expected Open");
2837 };
2838 assert_eq!(
2839 open.meta.get("note"),
2840 Some(&MetaValue::String("main checking".to_string()))
2841 );
2842 assert_eq!(
2843 open.meta.get("number"),
2844 Some(&MetaValue::Int(42))
2846 );
2847 }
2848
2849 #[test]
2850 fn close_directive_basic() {
2851 let src = "2024-12-31 close Assets:Cash\n";
2852 let result = parse_via_cst(src);
2853 assert_directive_count(&result, 1);
2854 let Directive::Close(close) = &result.directives[0].value else {
2855 panic!("expected Close, got {:?}", result.directives[0].value);
2856 };
2857 assert_eq!(close.date, naive_date(2024, 12, 31).unwrap());
2858 assert_eq!(close.account.as_str(), "Assets:Cash");
2859 }
2860
2861 #[test]
2862 fn commodity_directive_basic() {
2863 let src = "2024-01-01 commodity HOOL\n";
2864 let result = parse_via_cst(src);
2865 assert_directive_count(&result, 1);
2866 let Directive::Commodity(c) = &result.directives[0].value else {
2867 panic!("expected Commodity");
2868 };
2869 assert_eq!(c.currency.as_str(), "HOOL");
2870 }
2871
2872 #[test]
2873 fn bom_offset_is_included_in_spans() {
2874 let src = "\u{FEFF}2024-01-15 open Assets:Cash\n";
2875 let result = parse_via_cst(src);
2876 assert!(result.has_leading_bom);
2877 let span = result.directives[0].span;
2878 assert_eq!(span.start, 3, "span should include BOM offset");
2879 }
2880
2881 #[test]
2882 fn note_directive_basic() {
2883 let src = "2024-01-15 note Assets:Cash \"deposit received\"\n";
2884 let result = parse_via_cst(src);
2885 assert_directive_count(&result, 1);
2886 let Directive::Note(note) = &result.directives[0].value else {
2887 panic!("expected Note");
2888 };
2889 assert_eq!(note.date, naive_date(2024, 1, 15).unwrap());
2890 assert_eq!(note.account.as_str(), "Assets:Cash");
2891 assert_eq!(note.comment, "deposit received");
2892 }
2893
2894 #[test]
2895 fn document_directive_basic() {
2896 let src = "2024-01-15 document Assets:Cash \"/path/to/file.pdf\"\n";
2897 let result = parse_via_cst(src);
2898 assert_directive_count(&result, 1);
2899 let Directive::Document(d) = &result.directives[0].value else {
2900 panic!("expected Document");
2901 };
2902 assert_eq!(d.account.as_str(), "Assets:Cash");
2903 assert_eq!(d.path, "/path/to/file.pdf");
2904 assert!(d.tags.is_empty());
2906 assert!(d.links.is_empty());
2907 }
2908
2909 #[test]
2910 fn event_directive_basic() {
2911 let src = "2024-01-15 event \"location\" \"Berlin\"\n";
2912 let result = parse_via_cst(src);
2913 assert_directive_count(&result, 1);
2914 let Directive::Event(e) = &result.directives[0].value else {
2915 panic!("expected Event");
2916 };
2917 assert_eq!(e.event_type, "location");
2918 assert_eq!(e.value, "Berlin");
2919 }
2920
2921 #[test]
2922 fn query_directive_basic() {
2923 let src = "2024-01-15 query \"income\" \"SELECT account, sum(position)\"\n";
2924 let result = parse_via_cst(src);
2925 assert_directive_count(&result, 1);
2926 let Directive::Query(q) = &result.directives[0].value else {
2927 panic!("expected Query");
2928 };
2929 assert_eq!(q.name, "income");
2930 assert_eq!(q.query, "SELECT account, sum(position)");
2931 }
2932
2933 #[test]
2934 fn price_directive_basic() {
2935 let src = "2024-01-15 price USD 1.10 EUR\n";
2936 let result = parse_via_cst(src);
2937 assert_directive_count(&result, 1);
2938 let Directive::Price(p) = &result.directives[0].value else {
2939 panic!("expected Price");
2940 };
2941 assert_eq!(p.currency.as_str(), "USD");
2942 assert_eq!(p.amount.number, Decimal::new(110, 2));
2943 assert_eq!(p.amount.currency.as_str(), "EUR");
2944 }
2945
2946 #[test]
2947 fn balance_directive_basic() {
2948 let src = "2024-06-30 balance Assets:Cash 100.00 USD\n";
2949 let result = parse_via_cst(src);
2950 assert_directive_count(&result, 1);
2951 let Directive::Balance(b) = &result.directives[0].value else {
2952 panic!("expected Balance");
2953 };
2954 assert_eq!(b.account.as_str(), "Assets:Cash");
2955 assert_eq!(b.amount.number, Decimal::new(10000, 2));
2956 assert_eq!(b.amount.currency.as_str(), "USD");
2957 assert!(b.tolerance.is_none());
2958 }
2959
2960 #[test]
2961 fn balance_directive_with_explicit_tolerance() {
2962 let src = "2024-06-30 balance Assets:Cash 100.00 ~ 0.05 USD\n";
2963 let result = parse_via_cst(src);
2964 assert_directive_count(&result, 1);
2965 let Directive::Balance(b) = &result.directives[0].value else {
2966 panic!("expected Balance");
2967 };
2968 assert_eq!(b.amount.number, Decimal::new(10000, 2));
2969 assert_eq!(b.tolerance, Some(Decimal::new(5, 2)));
2970 }
2971
2972 #[test]
2973 fn pad_directive_basic() {
2974 let src = "2024-01-01 pad Assets:Cash Equity:Opening-Balances\n";
2975 let result = parse_via_cst(src);
2976 assert_directive_count(&result, 1);
2977 let Directive::Pad(p) = &result.directives[0].value else {
2978 panic!("expected Pad");
2979 };
2980 assert_eq!(p.account.as_str(), "Assets:Cash");
2981 assert_eq!(p.source_account.as_str(), "Equity:Opening-Balances");
2982 }
2983
2984 #[test]
2985 fn custom_directive_basic() {
2986 let src = "2024-01-01 custom \"budget\" \"food\" 500 USD\n";
2987 let result = parse_via_cst(src);
2988 assert_directive_count(&result, 1);
2989 let Directive::Custom(c) = &result.directives[0].value else {
2990 panic!("expected Custom");
2991 };
2992 assert_eq!(c.custom_type, "budget");
2993 assert_eq!(c.values.len(), 2);
2994 assert_eq!(c.values[0], MetaValue::String("food".to_string()));
2995 let MetaValue::Amount(amt) = &c.values[1] else {
2997 panic!("expected Amount, got {:?}", c.values[1]);
2998 };
2999 assert_eq!(amt.number, Decimal::from(500));
3000 assert_eq!(amt.currency.as_str(), "USD");
3001 }
3002
3003 #[test]
3004 fn custom_directive_heterogeneous_values() {
3005 let src = "2024-01-01 custom \"test\" Assets:Cash TRUE 42 2024-06-15\n";
3006 let result = parse_via_cst(src);
3007 let Directive::Custom(c) = &result.directives[0].value else {
3008 panic!("expected Custom");
3009 };
3010 assert_eq!(c.values.len(), 4);
3011 assert!(matches!(c.values[0], MetaValue::Account(_)));
3012 assert_eq!(c.values[1], MetaValue::Bool(true));
3013 assert_eq!(c.values[2], MetaValue::Int(42));
3014 assert!(matches!(c.values[3], MetaValue::Date(_)));
3015 }
3016
3017 #[test]
3018 fn number_meta_value_int_vs_decimal_discriminator() {
3019 use rust_decimal_macros::dec;
3020 assert_eq!(number_meta_value("42", dec!(42)), MetaValue::Int(42));
3023 assert_eq!(number_meta_value("0", dec!(0)), MetaValue::Int(0));
3024 assert_eq!(number_meta_value("1", dec!(-1)), MetaValue::Int(-1));
3025 assert_eq!(
3027 number_meta_value("42.0", dec!(42.0)),
3028 MetaValue::Number(dec!(42.0))
3029 );
3030 assert_eq!(
3034 number_meta_value("1e3", dec!(1000)),
3035 MetaValue::Number(dec!(1000))
3036 );
3037 let huge = "99999999999999999999999999";
3039 let huge_dec = Decimal::from_str_exact(huge).unwrap();
3040 assert_eq!(
3041 number_meta_value(huge, huge_dec),
3042 MetaValue::Number(huge_dec)
3043 );
3044 }
3045
3046 #[test]
3047 fn option_directive_populates_options_field() {
3048 let src = "option \"title\" \"My Ledger\"\n";
3049 let result = parse_via_cst(src);
3050 assert_directive_count(&result, 0);
3051 assert_eq!(result.options.len(), 1);
3052 assert_eq!(result.options[0].0, "title");
3053 assert_eq!(result.options[0].1, "My Ledger");
3054 }
3055
3056 #[test]
3057 fn include_directive_populates_includes_field() {
3058 let src = "include \"shared.beancount\"\n";
3059 let result = parse_via_cst(src);
3060 assert_directive_count(&result, 0);
3061 assert_eq!(result.includes.len(), 1);
3062 assert_eq!(result.includes[0].0, "shared.beancount");
3063 }
3064
3065 #[test]
3066 fn plugin_directive_with_config() {
3067 let src = "plugin \"my.plugin\" \"cfg\"\n";
3068 let result = parse_via_cst(src);
3069 assert_directive_count(&result, 0);
3070 assert_eq!(result.plugins.len(), 1);
3071 assert_eq!(result.plugins[0].0, "my.plugin");
3072 assert_eq!(result.plugins[0].1.as_deref(), Some("cfg"));
3073 }
3074
3075 #[test]
3076 fn plugin_directive_without_config() {
3077 let src = "plugin \"my.plugin\"\n";
3078 let result = parse_via_cst(src);
3079 assert_eq!(result.plugins.len(), 1);
3080 assert_eq!(result.plugins[0].0, "my.plugin");
3081 assert!(result.plugins[0].1.is_none());
3082 }
3083
3084 #[test]
3087 fn transaction_basic_two_postings() {
3088 let src = "2024-01-15 * \"Coffee Shop\" \"Morning coffee\"\n \
3089 Expenses:Food:Coffee 5.00 USD\n \
3090 Assets:Cash\n";
3091 let result = parse_via_cst(src);
3092 assert_directive_count(&result, 1);
3093 let Directive::Transaction(t) = &result.directives[0].value else {
3094 panic!("expected Transaction");
3095 };
3096 assert_eq!(t.date, naive_date(2024, 1, 15).unwrap());
3097 assert_eq!(t.flag, '*');
3098 assert_eq!(
3099 t.payee.as_ref().map(InternedStr::as_str),
3100 Some("Coffee Shop")
3101 );
3102 assert_eq!(t.narration.as_str(), "Morning coffee");
3103 assert_eq!(t.postings.len(), 2);
3104
3105 let p0 = &t.postings[0].value;
3106 assert_eq!(p0.account.as_str(), "Expenses:Food:Coffee");
3107 let Some(IncompleteAmount::Complete(amt)) = &p0.units else {
3108 panic!("expected complete units, got {:?}", p0.units);
3109 };
3110 assert_eq!(amt.number, Decimal::new(500, 2));
3111 assert_eq!(amt.currency.as_str(), "USD");
3112
3113 let p1 = &t.postings[1].value;
3114 assert_eq!(p1.account.as_str(), "Assets:Cash");
3115 assert!(p1.units.is_none(), "auto-posting has no units");
3116 }
3117
3118 #[test]
3119 fn transaction_narration_only_no_payee() {
3120 let src = "2024-01-15 ! \"Pending\"\n Assets:Cash -5 USD\n";
3121 let result = parse_via_cst(src);
3122 let Directive::Transaction(t) = &result.directives[0].value else {
3123 panic!("expected Transaction");
3124 };
3125 assert_eq!(t.flag, '!');
3126 assert!(t.payee.is_none());
3127 assert_eq!(t.narration.as_str(), "Pending");
3128 }
3129
3130 #[test]
3131 fn transaction_three_plus_header_strings_surface_last_as_narration() {
3132 let src = "2024-01-15 * \"a\" \"b\" \"c\"\n Assets:Cash -5 USD\n";
3137 let result = parse_via_cst(src);
3138 let Directive::Transaction(t) = &result.directives[0].value else {
3139 panic!("expected Transaction");
3140 };
3141 assert!(t.payee.is_none(), "3+ strings drop the payee");
3142 assert_eq!(t.narration.as_str(), "c", "last string becomes narration");
3143 }
3144
3145 #[test]
3146 fn transaction_implied_flag_via_leading_string() {
3147 let src = "2024-01-15 \"Implied\"\n Assets:Cash -5 USD\n";
3148 let result = parse_via_cst(src);
3149 let Directive::Transaction(t) = &result.directives[0].value else {
3150 panic!("expected Transaction");
3151 };
3152 assert_eq!(t.flag, '*', "implied flag defaults to *");
3153 }
3154
3155 #[test]
3156 fn transaction_with_tags_and_links() {
3157 let src = "2024-01-15 * \"Coffee\" #daily ^trip1\n Assets:Cash -5 USD\n";
3158 let result = parse_via_cst(src);
3159 let Directive::Transaction(t) = &result.directives[0].value else {
3160 panic!("expected Transaction");
3161 };
3162 assert_eq!(t.tags.len(), 1);
3163 assert_eq!(t.tags[0].as_str(), "daily");
3164 assert_eq!(t.links.len(), 1);
3165 assert_eq!(t.links[0].as_str(), "trip1");
3166 }
3167
3168 #[test]
3169 fn transaction_with_signed_amount() {
3170 let src = "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n";
3171 let result = parse_via_cst(src);
3172 let Directive::Transaction(t) = &result.directives[0].value else {
3173 panic!("expected Transaction");
3174 };
3175 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3176 panic!("expected complete units");
3177 };
3178 assert_eq!(amt.number, Decimal::new(-500, 2));
3179 }
3180
3181 #[test]
3182 fn transaction_with_posting_flag() {
3183 let src = "2024-01-15 * \"x\"\n ! Assets:Cash -5 USD\n";
3184 let result = parse_via_cst(src);
3185 let Directive::Transaction(t) = &result.directives[0].value else {
3186 panic!("expected Transaction");
3187 };
3188 assert_eq!(t.postings[0].value.flag, Some('!'));
3189 }
3190
3191 #[test]
3192 fn transaction_with_cost_spec_per_unit() {
3193 let src = "2024-01-15 * \"buy\"\n \
3194 Assets:Inv 10 HOOL {500.00 USD}\n \
3195 Assets:Cash\n";
3196 let result = parse_via_cst(src);
3197 let Directive::Transaction(t) = &result.directives[0].value else {
3198 panic!("expected Transaction");
3199 };
3200 let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
3201 assert!(!cost.merge);
3202 let Some(CostNumber::PerUnit { value }) = &cost.number else {
3203 panic!("expected PerUnit");
3204 };
3205 assert_eq!(*value, Decimal::new(50000, 2));
3206 assert_eq!(cost.currency.as_ref().unwrap().as_str(), "USD");
3207 }
3208
3209 #[test]
3210 fn transaction_with_cost_spec_total() {
3211 let src = "2024-01-15 * \"buy\"\n \
3212 Assets:Inv 10 HOOL {{5000 USD}}\n \
3213 Assets:Cash\n";
3214 let result = parse_via_cst(src);
3215 let Directive::Transaction(t) = &result.directives[0].value else {
3216 panic!("expected Transaction");
3217 };
3218 let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
3219 let Some(CostNumber::Total { value }) = &cost.number else {
3220 panic!("expected Total");
3221 };
3222 assert_eq!(*value, Decimal::from(5000));
3223 }
3224
3225 #[test]
3226 fn transaction_with_price_annotation_unit() {
3227 let src = "2024-01-15 * \"buy\"\n \
3228 Assets:Inv 10 HOOL @ 510 USD\n \
3229 Assets:Cash\n";
3230 let result = parse_via_cst(src);
3231 let Directive::Transaction(t) = &result.directives[0].value else {
3232 panic!("expected Transaction");
3233 };
3234 let price = t.postings[0]
3235 .value
3236 .price
3237 .as_ref()
3238 .expect("price annotation");
3239 assert!(price.is_unit());
3240 let Some(IncompleteAmount::Complete(amt)) = &price.amount else {
3241 panic!("expected complete price amount");
3242 };
3243 assert_eq!(amt.number, Decimal::from(510));
3244 assert_eq!(amt.currency.as_str(), "USD");
3245 }
3246
3247 #[test]
3248 fn transaction_with_price_annotation_total() {
3249 let src = "2024-01-15 * \"buy\"\n \
3250 Assets:Inv 10 HOOL @@ 5100 USD\n \
3251 Assets:Cash\n";
3252 let result = parse_via_cst(src);
3253 let Directive::Transaction(t) = &result.directives[0].value else {
3254 panic!("expected Transaction");
3255 };
3256 let price = t.postings[0]
3257 .value
3258 .price
3259 .as_ref()
3260 .expect("price annotation");
3261 assert!(!price.is_unit(), "@@ is total form");
3262 }
3263
3264 #[test]
3267 fn document_directive_preserves_tags_and_links() {
3268 let src = "2024-06-01 document Assets:Bank \"stmt.pdf\" #quarter1 ^scan42 #urgent\n";
3272 let result = parse_via_cst(src);
3273 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3274 let Directive::Document(doc) = &result.directives[0].value else {
3275 panic!("expected Document");
3276 };
3277 let tags: Vec<&str> = doc.tags.iter().map(Tag::as_str).collect();
3278 let links: Vec<&str> = doc.links.iter().map(Link::as_str).collect();
3279 assert_eq!(tags, vec!["quarter1", "urgent"]);
3280 assert_eq!(links, vec!["scan42"]);
3281 }
3282
3283 #[test]
3284 fn open_directive_rejects_invalid_booking_method() {
3285 let src = "2024-01-01 open Assets:Bank USD \"GARBAGE\"\n";
3290 let result = parse_via_cst(src);
3291 assert_eq!(result.directives.len(), 0, "directive should be dropped");
3292 assert_eq!(result.errors.len(), 1);
3293 let err = &result.errors[0];
3294 assert!(
3295 matches!(
3296 &err.kind,
3297 crate::ParseErrorKind::InvalidBookingMethod(s) if s == "GARBAGE"
3298 ),
3299 "expected InvalidBookingMethod, got {:?}",
3300 err.kind,
3301 );
3302 }
3303
3304 #[test]
3305 fn open_directive_accepts_all_valid_booking_methods() {
3306 for method in VALID_BOOKING_METHODS {
3307 let src = format!("2024-01-01 open Assets:Bank USD \"{method}\"\n");
3308 let result = parse_via_cst(&src);
3309 assert!(
3310 result.errors.is_empty(),
3311 "{method} rejected: {:?}",
3312 result.errors
3313 );
3314 let Directive::Open(open) = &result.directives[0].value else {
3315 panic!("{method}: expected Open");
3316 };
3317 assert_eq!(open.booking.as_deref(), Some(*method));
3318 }
3319 }
3320
3321 #[test]
3322 fn unclosed_pushtag_at_eof_emits_diagnostic() {
3323 let src = "pushtag #active\n2024-01-01 open Assets:Bank USD\n";
3326 let result = parse_via_cst(src);
3327 let unclosed: Vec<_> = result
3328 .errors
3329 .iter()
3330 .filter_map(|e| match &e.kind {
3331 crate::ParseErrorKind::UnclosedPushtag(t) => Some(t.clone()),
3332 _ => None,
3333 })
3334 .collect();
3335 assert_eq!(unclosed, vec!["active".to_string()]);
3336 }
3337
3338 #[test]
3339 fn unclosed_pushmeta_at_eof_emits_diagnostic() {
3340 let src = "pushmeta location: \"NYC\"\n2024-01-01 open Assets:Bank USD\n";
3342 let result = parse_via_cst(src);
3343 let unclosed: Vec<_> = result
3344 .errors
3345 .iter()
3346 .filter_map(|e| match &e.kind {
3347 crate::ParseErrorKind::UnclosedPushmeta(k) => Some(k.clone()),
3348 _ => None,
3349 })
3350 .collect();
3351 assert_eq!(unclosed, vec!["location".to_string()]);
3352 }
3353
3354 #[test]
3355 fn invalid_poptag_on_mismatch_emits_diagnostic() {
3356 let src = "pushtag #foo\npoptag #bar\npoptag #foo\n";
3359 let result = parse_via_cst(src);
3360 let mismatches: Vec<_> = result
3361 .errors
3362 .iter()
3363 .filter_map(|e| match &e.kind {
3364 crate::ParseErrorKind::InvalidPoptag(t) => Some(t.clone()),
3365 _ => None,
3366 })
3367 .collect();
3368 assert_eq!(mismatches, vec!["bar".to_string()]);
3369 let leftover: Vec<_> = result
3372 .errors
3373 .iter()
3374 .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushtag(_)))
3375 .collect();
3376 assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
3377 }
3378
3379 #[test]
3380 fn invalid_popmeta_on_mismatch_emits_diagnostic() {
3381 let src = "pushmeta location: \"NYC\"\npopmeta nope:\npopmeta location:\n";
3385 let result = parse_via_cst(src);
3386 let mismatches: Vec<_> = result
3387 .errors
3388 .iter()
3389 .filter_map(|e| match &e.kind {
3390 crate::ParseErrorKind::InvalidPopmeta(k) => Some(k.clone()),
3391 _ => None,
3392 })
3393 .collect();
3394 assert_eq!(mismatches, vec!["nope".to_string()]);
3395 let leftover: Vec<_> = result
3396 .errors
3397 .iter()
3398 .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushmeta(_)))
3399 .collect();
3400 assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
3401 }
3402
3403 #[test]
3404 fn pushmeta_shadow_pop_restores_prior_value() {
3405 let src = "pushmeta loc: \"NYC\"\n\
3408 pushmeta loc: \"LDN\"\n\
3409 popmeta loc:\n\
3410 2024-01-01 open Assets:Bank USD\n\
3411 popmeta loc:\n";
3412 let result = parse_via_cst(src);
3413 let Directive::Open(open) = &result.directives[0].value else {
3414 panic!("expected Open");
3415 };
3416 assert_eq!(
3417 open.meta.get("loc"),
3418 Some(&MetaValue::String("NYC".to_string())),
3419 "shadow pop should restore NYC, got {:?}",
3420 open.meta.get("loc"),
3421 );
3422 }
3423
3424 #[test]
3425 fn error_recovery_classifies_bom_in_directive_body() {
3426 let src = "garbage\u{FEFF}content\n";
3430 let result = parse_via_cst(src);
3431 let bom_errors: Vec<_> = result
3432 .errors
3433 .iter()
3434 .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3435 .collect();
3436 assert_eq!(bom_errors.len(), 1, "errors: {:?}", result.errors);
3437 assert!(
3438 bom_errors[0].hint.is_some(),
3439 "BomInDirectiveBody should carry BOM_REMOVAL_HINT",
3440 );
3441 }
3442
3443 #[test]
3444 fn error_recovery_emits_both_invalid_account_and_bom_for_dual_line() {
3445 let src = "garbage Assets:Café\u{FEFF}content\n";
3452 let result = parse_via_cst(src);
3453 let invalid_account_count = result
3454 .errors
3455 .iter()
3456 .filter(|e| matches!(e.kind, crate::ParseErrorKind::InvalidAccount(_)))
3457 .count();
3458 let bom_count = result
3459 .errors
3460 .iter()
3461 .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3462 .count();
3463 assert_eq!(
3464 invalid_account_count, 1,
3465 "expected one InvalidAccount: {:?}",
3466 result.errors
3467 );
3468 assert_eq!(
3469 bom_count, 1,
3470 "expected secondary BomInDirectiveBody: {:?}",
3471 result.errors
3472 );
3473 let bom_err = result
3476 .errors
3477 .iter()
3478 .find(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3479 .unwrap();
3480 assert!(bom_err.hint.is_some());
3481 }
3482
3483 #[test]
3484 fn error_recovery_classifies_unicode_account() {
3485 let src = "garbage Assets:Café content\n";
3490 let result = parse_via_cst(src);
3491 let unicode_errors: Vec<_> = result
3492 .errors
3493 .iter()
3494 .filter_map(|e| match &e.kind {
3495 crate::ParseErrorKind::InvalidAccount(s) => Some(s.clone()),
3496 _ => None,
3497 })
3498 .collect();
3499 assert_eq!(unicode_errors, vec!["Assets:Café".to_string()]);
3500 }
3501
3502 #[test]
3503 fn transaction_with_pipe_emits_deprecated_pipe_symbol() {
3504 let src = "2024-01-15 * \"Acme\" | \"invoice\"\n Assets:Cash -5 USD\n Expenses:X\n";
3507 let result = parse_via_cst(src);
3508 let pipe_count = result
3509 .errors
3510 .iter()
3511 .filter(|e| matches!(e.kind, crate::ParseErrorKind::DeprecatedPipeSymbol))
3512 .count();
3513 assert_eq!(pipe_count, 1, "errors: {:?}", result.errors);
3514 assert_eq!(result.directives.len(), 1);
3516 }
3517
3518 #[test]
3519 fn transaction_trailing_comments_after_final_posting() {
3520 let src = "2024-01-15 * \"x\"\n \
3524 Assets:Cash -5 USD\n \
3525 Expenses:X\n \
3526 ; trailing one\n \
3527 ; trailing two\n";
3528 let result = parse_via_cst(src);
3529 let Directive::Transaction(t) = &result.directives[0].value else {
3530 panic!("expected Transaction");
3531 };
3532 assert_eq!(
3533 t.trailing_comments.len(),
3534 2,
3535 "got: {:?}",
3536 t.trailing_comments
3537 );
3538 assert!(t.trailing_comments[0].contains("trailing one"));
3539 assert!(t.trailing_comments[1].contains("trailing two"));
3540 }
3541
3542 #[test]
3545 fn posting_amount_evaluates_division() {
3546 let src = "2024-01-15 * \"split\"\n \
3551 Expenses:Food 120 / 3 USD\n \
3552 Assets:Bank -40 USD\n";
3553 let result = parse_via_cst(src);
3554 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3555 let Directive::Transaction(t) = &result.directives[0].value else {
3556 panic!("expected Transaction");
3557 };
3558 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3559 panic!("expected complete amount on posting 0");
3560 };
3561 assert_eq!(amt.number, Decimal::from(40));
3562 assert_eq!(amt.currency.as_str(), "USD");
3563 }
3564
3565 #[test]
3566 fn posting_amount_evaluates_addition_and_multiplication_precedence() {
3567 let src = "2024-01-15 * \"x\"\n \
3569 Expenses:X 2 + 3 * 4 USD\n \
3570 Assets:Y -14 USD\n";
3571 let result = parse_via_cst(src);
3572 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3573 let Directive::Transaction(t) = &result.directives[0].value else {
3574 panic!("expected Transaction");
3575 };
3576 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3577 panic!("expected complete amount");
3578 };
3579 assert_eq!(amt.number, Decimal::from(14));
3580 }
3581
3582 #[test]
3583 fn posting_amount_evaluates_parens_override_precedence() {
3584 let src = "2024-01-15 * \"x\"\n \
3586 Expenses:X (2 + 3) * 4 USD\n \
3587 Assets:Y -20 USD\n";
3588 let result = parse_via_cst(src);
3589 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3590 let Directive::Transaction(t) = &result.directives[0].value else {
3591 panic!("expected Transaction");
3592 };
3593 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3594 panic!("expected complete amount");
3595 };
3596 assert_eq!(amt.number, Decimal::from(20));
3597 }
3598
3599 #[test]
3600 fn posting_amount_evaluates_subtraction_left_associative() {
3601 let src = "2024-01-15 * \"x\"\n \
3603 Expenses:X 10 - 3 - 2 USD\n \
3604 Assets:Y -5 USD\n";
3605 let result = parse_via_cst(src);
3606 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3607 let Directive::Transaction(t) = &result.directives[0].value else {
3608 panic!("expected Transaction");
3609 };
3610 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3611 panic!("expected complete amount");
3612 };
3613 assert_eq!(amt.number, Decimal::from(5));
3614 }
3615
3616 #[test]
3617 fn posting_amount_division_by_zero_drops_number() {
3618 let src = "2024-01-15 * \"x\"\n \
3623 Expenses:X 5 / 0 USD\n \
3624 Assets:Y\n";
3625 let result = parse_via_cst(src);
3626 let Directive::Transaction(t) = &result.directives[0].value else {
3627 panic!("expected Transaction");
3628 };
3629 match &t.postings[0].value.units {
3634 None | Some(IncompleteAmount::CurrencyOnly(_)) => {}
3635 other => panic!("div-by-zero leaked: {other:?}"),
3636 }
3637 }
3638
3639 #[test]
3642 fn indented_top_level_directive_emits_error() {
3643 let src = "2020-07-28 open Assets:Foo\n 2020-07-28 open Assets:Bar\n";
3648 let result = parse_via_cst(src);
3649 let indent_errs = result
3650 .errors
3651 .iter()
3652 .filter(|e| match &e.kind {
3653 crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
3654 _ => false,
3655 })
3656 .count();
3657 assert_eq!(
3658 indent_errs, 1,
3659 "expected one column-0 diagnostic, got: {:?}",
3660 result.errors
3661 );
3662 }
3663
3664 #[test]
3665 fn indented_directive_after_blank_line_still_emits_error() {
3666 let src = "2020-07-28 open Assets:Foo\n\n 2020-07-28 open Assets:Bar\n";
3670 let result = parse_via_cst(src);
3671 let indent_errs = result
3672 .errors
3673 .iter()
3674 .filter(|e| match &e.kind {
3675 crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
3676 _ => false,
3677 })
3678 .count();
3679 assert_eq!(indent_errs, 1, "errors: {:?}", result.errors);
3680 }
3681
3682 #[test]
3683 fn top_level_directive_at_column_0_no_diagnostic() {
3684 let src = "2020-07-28 open Assets:Foo\n2020-07-28 open Assets:Bar\n";
3687 let result = parse_via_cst(src);
3688 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3689 }
3690
3691 #[test]
3692 fn custom_directive_with_bare_currency_emits_error() {
3693 let src = "2025-01-01 custom \"x\" 10 USD \"y\" NZD\n";
3696 let result = parse_via_cst(src);
3697 let bare_curr_errs = result
3698 .errors
3699 .iter()
3700 .filter(|e| match &e.kind {
3701 crate::ParseErrorKind::SyntaxError(s) => s.contains("bare currency"),
3702 _ => false,
3703 })
3704 .count();
3705 assert_eq!(
3706 bare_curr_errs, 1,
3707 "expected one bare-currency diagnostic, got: {:?}",
3708 result.errors
3709 );
3710 }
3711
3712 #[test]
3713 fn custom_directive_with_amount_no_error() {
3714 let src = "2025-01-01 custom \"x\" 10 USD\n";
3718 let result = parse_via_cst(src);
3719 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3720 }
3721
3722 #[test]
3725 fn balance_assertion_evaluates_arithmetic_value() {
3726 let src = "2024-01-01 open Assets:X GBP\n\
3732 2024-01-01 open Equity:Open GBP\n\
3733 2024-01-02 * \"deposit\"\n \
3734 Assets:X 1.00 GBP\n \
3735 Equity:Open -1.00 GBP\n\
3736 2024-01-03 balance Assets:X 0.25 + 0.75 GBP\n";
3737 let result = parse_via_cst(src);
3738 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3739 let bal = result
3740 .directives
3741 .iter()
3742 .find_map(|d| match &d.value {
3743 Directive::Balance(b) => Some(b),
3744 _ => None,
3745 })
3746 .expect("expected a Balance directive");
3747 assert_eq!(bal.amount.number, Decimal::from(1));
3748 assert_eq!(bal.amount.currency.as_str(), "GBP");
3749 }
3750
3751 #[test]
3752 fn price_directive_evaluates_arithmetic_value() {
3753 let src = "2024-01-01 price USD 1/2 EUR\n";
3754 let result = parse_via_cst(src);
3755 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3756 let Directive::Price(p) = &result.directives[0].value else {
3757 panic!("expected Price");
3758 };
3759 assert_eq!(p.amount.number, Decimal::new(5, 1));
3760 assert_eq!(p.amount.currency.as_str(), "EUR");
3761 }
3762
3763 #[test]
3766 fn body_line_tag_does_not_drop_following_postings_comment() {
3767 let src = "2024-01-01 * \"x\"\n \
3774 Assets:A 100 USD\n \
3775 ; comment-for-B\n \
3776 #late-tag\n \
3777 Assets:B -100 USD\n";
3778 let result = parse_via_cst(src);
3779 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3780 let Directive::Transaction(t) = &result.directives[0].value else {
3781 panic!("expected Transaction");
3782 };
3783 assert!(
3785 t.tags.iter().any(|tag| tag.as_str() == "late-tag"),
3786 "expected #late-tag in tags: {:?}",
3787 t.tags,
3788 );
3789 let b = t.postings.last().expect("at least one posting");
3791 assert_eq!(b.value.account.as_str(), "Assets:B");
3792 assert!(
3793 b.value.comments.iter().any(|c| c.contains("comment-for-B")),
3794 "expected comment-for-B to survive on Assets:B: {:?}",
3795 b.value.comments,
3796 );
3797 }
3798
3799 #[test]
3800 fn oversized_number_in_amount_emits_diagnostic() {
3801 let huge = "1".to_string() + &"2345678901234567890".repeat(2); let src = format!("2024-01-15 * \"big\"\n Expenses:X {huge} USD\n Assets:Y\n");
3808 let result = parse_via_cst(&src);
3809 let invalid_num = result
3810 .errors
3811 .iter()
3812 .filter(|e| match &e.kind {
3813 crate::ParseErrorKind::SyntaxError(s) => s.contains("invalid number"),
3814 _ => false,
3815 })
3816 .count();
3817 assert_eq!(
3818 invalid_num, 1,
3819 "expected one invalid-number diagnostic, got: {:?}",
3820 result.errors
3821 );
3822 }
3823
3824 #[test]
3827 fn posting_with_two_amount_siblings_emits_error_and_keeps_first() {
3828 let src = "2024-01-15 * \"ambig\"\n \
3835 Expenses:Food 5 USD + 3 USD\n \
3836 Assets:Bank\n";
3837 let result = parse_via_cst(src);
3838 let trailing_count = result
3839 .errors
3840 .iter()
3841 .filter(|e| match &e.kind {
3842 crate::ParseErrorKind::SyntaxError(s) => s.contains("trailing tokens"),
3843 _ => false,
3844 })
3845 .count();
3846 assert_eq!(
3847 trailing_count, 1,
3848 "expected one trailing-tokens diagnostic, got: {:?}",
3849 result.errors
3850 );
3851 let Directive::Transaction(t) = &result.directives[0].value else {
3854 panic!("expected Transaction");
3855 };
3856 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3857 panic!("expected complete units from the first AMOUNT");
3858 };
3859 assert_eq!(amt.number, Decimal::from(5));
3860 }
3861
3862 #[test]
3863 fn comments_dont_leak_across_failed_posting() {
3864 let src = "2024-01-15 * \"test\"\n \
3871 Assets:A 100 USD\n \
3872 ; comment-for-bad\n \
3873 ; another-comment\n \
3874 bogus_token_line_no_account\n \
3875 ; comment-for-good\n \
3876 Assets:B -100 USD\n";
3877 let result = parse_via_cst(src);
3878 let Directive::Transaction(t) = &result.directives[0].value else {
3879 panic!("expected Transaction");
3880 };
3881 let b = t.postings.last().expect("at least one posting");
3887 assert_eq!(b.value.account.as_str(), "Assets:B");
3888 assert!(
3889 !b.value
3890 .comments
3891 .iter()
3892 .any(|c| c.contains("comment-for-bad")),
3893 "comment-for-bad leaked across failed posting onto Assets:B: {:?}",
3894 b.value.comments
3895 );
3896 assert!(
3897 !b.value
3898 .comments
3899 .iter()
3900 .any(|c| c.contains("another-comment")),
3901 "another-comment leaked: {:?}",
3902 b.value.comments
3903 );
3904 }
3905
3906 #[test]
3907 fn arithmetic_overflow_in_amount_emits_diagnostic() {
3908 let huge = "9999999999999999999999999999 * 9999999999999999999999999999";
3916 let src = format!("2024-01-15 * \"big\"\n Expenses:X {huge} USD\n Assets:Y\n");
3917 let result = parse_via_cst(&src);
3918 let arith_errs = result
3919 .errors
3920 .iter()
3921 .filter(|e| match &e.kind {
3922 crate::ParseErrorKind::SyntaxError(s) => s.contains("arithmetic"),
3923 _ => false,
3924 })
3925 .count();
3926 assert_eq!(
3927 arith_errs, 1,
3928 "expected one arithmetic-error diagnostic, got: {:?}",
3929 result.errors
3930 );
3931 }
3932
3933 #[test]
3936 fn date_with_single_digit_month_parses() {
3937 let result = parse_via_cst("2024-1-15 open Assets:Checking\n");
3938 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3939 let Directive::Open(open) = &result.directives[0].value else {
3940 panic!("expected Open");
3941 };
3942 assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
3943 }
3944
3945 #[test]
3946 fn date_with_single_digit_day_parses() {
3947 let result = parse_via_cst("2024-01-5 open Assets:Cash USD\n");
3948 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3949 let Directive::Open(open) = &result.directives[0].value else {
3950 panic!("expected Open");
3951 };
3952 assert_eq!(open.date, naive_date(2024, 1, 5).unwrap());
3953 }
3954
3955 #[test]
3956 fn date_with_single_digit_month_and_day_parses() {
3957 let result = parse_via_cst("2024-1-1 open Assets:Cash USD\n");
3958 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3959 let Directive::Open(open) = &result.directives[0].value else {
3960 panic!("expected Open");
3961 };
3962 assert_eq!(open.date, naive_date(2024, 1, 1).unwrap());
3963 }
3964
3965 #[test]
3966 fn date_with_month_out_of_range_emits_invalid_date_value() {
3967 let result = parse_via_cst("2024-13-01 open Assets:Cash USD\n");
3968 let invalid_date: Vec<_> = result
3969 .errors
3970 .iter()
3971 .filter_map(|e| match &e.kind {
3972 crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
3973 _ => None,
3974 })
3975 .collect();
3976 assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
3977 let msg = &invalid_date[0];
3978 assert!(
3979 msg.contains("month") && msg.contains("out of range"),
3980 "msg: {msg}"
3981 );
3982 }
3983
3984 #[test]
3985 fn date_with_invalid_leap_year_emits_invalid_date_value() {
3986 let result = parse_via_cst("2023-02-29 open Assets:Cash USD\n");
3987 let invalid_date: Vec<_> = result
3988 .errors
3989 .iter()
3990 .filter_map(|e| match &e.kind {
3991 crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
3992 _ => None,
3993 })
3994 .collect();
3995 assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
3996 let msg = &invalid_date[0];
3997 assert!(
3998 msg.contains("day") && msg.contains("out of range") && msg.contains("2023-02"),
3999 "msg: {msg}"
4000 );
4001 }
4002
4003 #[test]
4004 fn date_with_completely_invalid_value_still_emits_error() {
4005 let result = parse_via_cst("2024-13-45 open Assets:Bank\n");
4009 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4010 }
4011
4012 #[test]
4013 fn open_directive_without_account_emits_error() {
4014 let result = parse_via_cst("2024-01-01 open\n");
4019 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4020 }
4021
4022 #[test]
4023 fn open_directive_with_lowercase_account_emits_error() {
4024 let result = parse_via_cst("2024-01-01 open lowercase:invalid\n");
4029 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4030 }
4031
4032 #[test]
4033 fn incomplete_open_at_eof_emits_error() {
4034 let result = parse_via_cst("2024-01-01 open");
4038 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4039 }
4040
4041 #[test]
4042 fn balance_directive_without_amount_emits_error() {
4043 let result = parse_via_cst("2024-01-15 balance Assets:Checking\n");
4044 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4045 }
4046
4047 #[test]
4048 fn pad_directive_without_source_account_emits_error() {
4049 let result = parse_via_cst("2024-01-15 pad Assets:Checking\n");
4050 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4051 }
4052
4053 #[test]
4054 fn cost_spec_n_hash_t_parses_as_compound() {
4055 use rust_decimal_macros::dec;
4056 let src = "2024-01-01 open Assets:Stock\n\
4061 2024-01-01 open Assets:Cash USD\n\
4062 2024-01-15 *\n \
4063 Assets:Stock 10 STK {50 # 1500 USD}\n \
4064 Assets:Cash -1500.00 USD\n";
4065 let result = parse_via_cst(src);
4066 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4067 let Directive::Transaction(txn) = &result.directives[2].value else {
4068 panic!("expected Transaction at index 2");
4069 };
4070 let cost = txn.postings[0]
4071 .value
4072 .cost
4073 .as_ref()
4074 .expect("cost spec present");
4075 assert_eq!(
4076 cost.number,
4077 Some(CostNumber::Compound {
4078 per_unit: dec!(50),
4079 total: dec!(1500)
4080 }),
4081 "the `{{N # T CCY}}` form must carry both components as written"
4082 );
4083 }
4084
4085 #[test]
4086 fn unclosed_cost_brace_emits_error() {
4087 let src = "2024-01-01 open Assets:Stock\n\
4088 2024-01-01 open Assets:Cash USD\n\
4089 2024-01-15 *\n \
4090 Assets:Stock 10 AAPL {150 USD\n \
4091 Assets:Cash -1500 USD\n";
4092 let result = parse_via_cst(src);
4093 let has_unclosed: bool = result
4094 .errors
4095 .iter()
4096 .any(|e| e.message().contains("unclosed cost"));
4097 assert!(
4098 has_unclosed,
4099 "expected 'unclosed cost' error, got: {:?}",
4100 result.errors
4101 );
4102 }
4103
4104 #[test]
4105 fn unclosed_cost_brace_at_eof_emits_error() {
4106 let src = "2024-01-01 open Assets:Stock\n\
4107 2024-01-01 open Assets:Cash USD\n\
4108 2024-01-15 *\n \
4109 Assets:Stock 10 AAPL {150 USD";
4110 let result = parse_via_cst(src);
4111 let has_unclosed: bool = result
4112 .errors
4113 .iter()
4114 .any(|e| e.message().contains("unclosed cost"));
4115 assert!(
4116 has_unclosed,
4117 "expected 'unclosed cost' error at EOF, got: {:?}",
4118 result.errors
4119 );
4120 }
4121
4122 #[test]
4123 fn leading_decimal_in_posting_amount_emits_error() {
4124 let src = "2024-01-15 * \"Test\"\n \
4128 Expenses:Food .50 USD\n \
4129 Assets:Checking\n";
4130 let result = parse_via_cst(src);
4131 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4132 }
4133
4134 #[test]
4135 fn transaction_with_metadata_on_directive_and_posting() {
4136 let src = "2024-01-15 * \"x\"\n \
4137 tag1: \"hello\"\n \
4138 Assets:Cash -5 USD\n \
4139 receipt: \"abc123\"\n";
4140 let result = parse_via_cst(src);
4141 let Directive::Transaction(t) = &result.directives[0].value else {
4142 panic!("expected Transaction");
4143 };
4144 assert_eq!(
4145 t.meta.get("tag1"),
4146 Some(&MetaValue::String("hello".to_string()))
4147 );
4148 let p_meta = &t.postings[0].value.meta;
4149 assert_eq!(
4150 p_meta.get("receipt"),
4151 Some(&MetaValue::String("abc123".to_string()))
4152 );
4153 }
4154
4155 #[test]
4170 fn account_occurrences_policy_for_failing_directives() {
4171 let src = "2024-01-01 open Assets:Bank \"GARBAGE\"\n";
4175 let r = parse_via_cst(src);
4176 assert!(
4177 r.account_occurrences
4178 .iter()
4179 .any(|o| o.value == "Assets:Bank"),
4180 "typed-conversion failure should keep the ACCOUNT token in \
4181 account_occurrences (got {:?}); rename mid-edit relies on this",
4182 r.account_occurrences,
4183 );
4184
4185 let src = "2024-01-01 opn Assets:Bank USD\n";
4190 let r = parse_via_cst(src);
4191 assert!(
4192 !r.account_occurrences
4193 .iter()
4194 .any(|o| o.value == "Assets:Bank"),
4195 "ERROR_NODE-wrapped ACCOUNT should be EXCLUDED from \
4196 account_occurrences (got {:?}); rename should not hit garbled \
4197 mid-edit syntax",
4198 r.account_occurrences,
4199 );
4200 }
4201}