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 cost_brace_errors,
113 link_meta_errors,
114 custom_pushmeta_errors,
115 } = if use_green {
116 super::green::walk_descendants(
118 source_file.syntax(),
119 stripped,
120 bom_offset,
121 collect_occurrences,
122 )
123 } else {
124 walk_descendants_once(&source_file, bom_offset, collect_occurrences)
125 };
126
127 let TopLevelWalkResult {
132 errors: top_level_errors,
133 section_marker_comments,
134 } = if use_green {
135 super::green::walk_top_level(source_file.syntax(), stripped, bom_offset)
136 } else {
137 walk_top_level_once(&source_file, stripped, bom_offset)
138 };
139
140 let mut comments: Vec<Spanned<String>> = top_level_comments;
141 comments.extend(section_marker_comments);
142 comments.sort_by_key(|s| s.span.start);
146 comments.dedup_by_key(|s| s.span.start);
147 let mut errors = top_level_errors;
148 if use_green {
168 errors.extend(cost_brace_errors);
169 errors.extend(link_meta_errors);
170 errors.extend(custom_pushmeta_errors);
171 } else {
172 if stripped.contains('{') {
173 errors.extend(extract_unclosed_cost_brace_errors(
174 &source_file,
175 stripped,
176 bom_offset,
177 ));
178 }
179 if stripped.contains('^') {
180 errors.extend(extract_link_metadata_value_errors(&source_file, bom_offset));
181 }
182 if stripped.contains('^') || stripped.contains('#') {
183 errors.extend(extract_custom_pushmeta_taglink_errors(
184 &source_file,
185 bom_offset,
186 ));
187 }
188 }
189 errors.extend(inline_errors);
190 let warnings = Vec::new();
191
192 let mut tag_stack: Vec<(Tag, Span)> = Vec::new();
199 let mut meta_stack: Vec<(String, MetaValue, Span)> = Vec::new();
205
206 for directive in source_file.directives() {
207 let cst_node = directive.syntax().clone();
211 let is_directive_producing = matches!(
220 directive,
221 ast::Directive::Open(_)
222 | ast::Directive::Close(_)
223 | ast::Directive::Commodity(_)
224 | ast::Directive::Note(_)
225 | ast::Directive::Document(_)
226 | ast::Directive::Event(_)
227 | ast::Directive::Query(_)
228 | ast::Directive::Price(_)
229 | ast::Directive::Balance(_)
230 | ast::Directive::Pad(_)
231 | ast::Directive::Custom(_)
232 | ast::Directive::Transaction(_)
233 );
234 let errors_before = errors.len();
235 let pushed_directive = match directive {
236 ast::Directive::Open(node) => convert_open(&node, bom_offset, &mut errors),
237 ast::Directive::Close(node) => convert_close(&node, bom_offset, &mut errors),
238 ast::Directive::Commodity(node) => convert_commodity(&node, bom_offset, &mut errors),
239 ast::Directive::Note(node) => convert_note(&node, bom_offset, &mut errors),
240 ast::Directive::Document(node) => convert_document(&node, bom_offset, &mut errors),
241 ast::Directive::Event(node) => convert_event(&node, bom_offset, &mut errors),
242 ast::Directive::Query(node) => convert_query(&node, bom_offset, &mut errors),
243 ast::Directive::Price(node) => convert_price(&node, bom_offset, &mut errors),
244 ast::Directive::Balance(node) => convert_balance(&node, bom_offset, &mut errors),
245 ast::Directive::Pad(node) => convert_pad(&node, bom_offset, &mut errors),
246 ast::Directive::Custom(node) => convert_custom(&node, bom_offset, &mut errors),
247 ast::Directive::Transaction(node) => {
248 let green = node.syntax().green();
253 let base =
254 u32::from(node.syntax().text_range().start()) as usize + bom_offset as usize;
255 let green_dir = if use_green {
256 super::green::convert_transaction(green, base)
257 } else {
258 None
259 };
260 match green_dir {
261 Some(d) => Some(d),
262 None => convert_transaction(&node, bom_offset, &mut errors),
263 }
264 }
265 ast::Directive::Option(node) => {
266 if let Some(triple) = convert_option(&node, bom_offset) {
267 options.push(triple);
268 }
269 None
270 }
271 ast::Directive::Include(node) => {
272 if let Some(pair) = convert_include(&node, bom_offset) {
273 includes.push(pair);
274 }
275 None
276 }
277 ast::Directive::Plugin(node) => {
278 if let Some(triple) = convert_plugin(&node, bom_offset) {
279 plugins.push(triple);
280 }
281 None
282 }
283 ast::Directive::Pushtag(node) => {
286 if let Some(tag_token) = node.tag() {
287 let span = node_span(node.syntax(), bom_offset);
288 tag_stack.push((Tag::new(tag_token.text().trim_start_matches('#')), span));
289 }
290 None
291 }
292 ast::Directive::Poptag(node) => {
293 if let Some(tag_token) = node.tag() {
294 let name = tag_token.text().trim_start_matches('#');
295 if let Some(pos) = tag_stack.iter().rposition(|(t, _)| t.as_str() == name) {
296 tag_stack.remove(pos);
297 } else {
298 errors.push(crate::ParseError::new(
299 crate::ParseErrorKind::InvalidPoptag(name.to_string()),
300 node_span(node.syntax(), bom_offset),
301 ));
302 }
303 }
304 None
305 }
306 ast::Directive::Pushmeta(node) => {
307 if let Some(key_token) = node.key() {
308 let key = key_token.text_without_colon().to_string();
309 let value = pushmeta_value(node.syntax());
310 let span = node_span(node.syntax(), bom_offset);
311 meta_stack.push((key, value, span));
312 }
313 None
314 }
315 ast::Directive::Popmeta(node) => {
316 if let Some(key_token) = node.key() {
317 let key = key_token.text_without_colon().to_string();
318 if let Some(pos) = meta_stack.iter().rposition(|(k, _, _)| k == &key) {
319 meta_stack.remove(pos);
320 } else {
321 errors.push(crate::ParseError::new(
322 crate::ParseErrorKind::InvalidPopmeta(key),
323 node_span(node.syntax(), bom_offset),
324 ));
325 }
326 }
327 None
328 }
329 };
330 if let Some(mut spanned) = pushed_directive {
331 apply_inherited_state(&mut spanned.value, &tag_stack, &meta_stack);
332 directives.push(spanned);
333 directive_nodes.push(cst_node);
334 } else if is_directive_producing && errors.len() == errors_before {
335 errors.push(crate::ParseError::new(
343 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
344 node_span(&cst_node, bom_offset),
345 ));
346 }
347 }
348
349 for (tag, span) in &tag_stack {
353 errors.push(crate::ParseError::new(
354 crate::ParseErrorKind::UnclosedPushtag(tag.as_str().to_string()),
355 *span,
356 ));
357 }
358 for (key, _, span) in &meta_stack {
359 errors.push(crate::ParseError::new(
360 crate::ParseErrorKind::UnclosedPushmeta(key.clone()),
361 *span,
362 ));
363 }
364 errors.sort_by_key(|e| e.span.start);
365
366 fixup_directive_spans(&source_file, bom_offset, &directive_nodes, &mut directives);
370
371 let alignment = std::sync::OnceLock::new();
384
385 let syntax_root = source_file.syntax().green().to_owned();
394
395 ParseResult {
396 directives,
397 options,
398 includes,
399 plugins,
400 comments,
401 errors,
402 warnings,
403 currency_occurrences,
404 account_occurrences,
405 has_leading_bom,
406 syntax_root,
407 alignment,
408 }
409}
410
411const VALID_BOOKING_METHODS: &[&str] = &[
419 "FIFO",
420 "STRICT",
421 "STRICT_WITH_SIZE",
422 "LIFO",
423 "HIFO",
424 "NONE",
425 "AVERAGE",
426];
427
428fn reject_tags_and_links(
465 node: &crate::SyntaxNode,
466 directive: &str,
467 bom_offset: u32,
468 errors: &mut Vec<crate::ParseError>,
469) {
470 use crate::SyntaxKind as K;
471 for t in node
472 .children_with_tokens()
473 .filter_map(rowan::NodeOrToken::into_token)
474 {
475 let kind = t.kind();
476 if !matches!(kind, K::TAG | K::LINK) {
477 continue;
478 }
479 let what = if kind == K::TAG { "tag" } else { "link" };
480 let range = t.text_range();
481 let off = bom_offset as usize;
482 let span = Span::new(
483 usize::from(range.start()) + off,
484 usize::from(range.end()) + off,
485 );
486 errors.push(crate::ParseError::new(
487 crate::ParseErrorKind::SyntaxError(format!(
488 "the {directive} directive does not take a {what} ({}); \
489 tags and links belong to transactions, and to note and \
490 document directives",
491 t.text()
492 )),
493 span,
494 ));
495 }
496}
497
498fn convert_open(
499 node: &OpenDirective,
500 bom_offset: u32,
501 errors: &mut Vec<crate::ParseError>,
502) -> Option<Spanned<Directive>> {
503 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
504 reject_tags_and_links(node.syntax(), "open", bom_offset, errors);
505 let account = Account::new(node.account()?.text());
506 let currencies: Vec<Currency> = node.currencies().map(|c| Currency::new(c.text())).collect();
507 let booking = node.booking_method().and_then(|s| s.text_decoded());
508 let span = node_span(node.syntax(), bom_offset);
509 if let Some(b) = &booking
510 && !VALID_BOOKING_METHODS.contains(&b.as_str())
511 {
512 errors.push(crate::ParseError::new(
513 crate::ParseErrorKind::InvalidBookingMethod(b.clone()),
514 span,
515 ));
516 return None;
517 }
518 let meta = convert_meta_entries(node.syntax());
519
520 let open = rustledger_core::directive::Open {
521 date,
522 account,
523 currencies,
524 booking,
525 meta,
526 };
527 Some(Spanned::new(Directive::Open(open), span))
528}
529
530fn convert_close(
531 node: &CloseDirective,
532 bom_offset: u32,
533 errors: &mut Vec<crate::ParseError>,
534) -> Option<Spanned<Directive>> {
535 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
536 reject_tags_and_links(node.syntax(), "close", bom_offset, errors);
537 let account = Account::new(node.account()?.text());
538 let meta = convert_meta_entries(node.syntax());
539
540 let close = rustledger_core::directive::Close {
541 date,
542 account,
543 meta,
544 };
545 let span = node_span(node.syntax(), bom_offset);
546 Some(Spanned::new(Directive::Close(close), span))
547}
548
549fn convert_commodity(
550 node: &CommodityDirective,
551 bom_offset: u32,
552 errors: &mut Vec<crate::ParseError>,
553) -> Option<Spanned<Directive>> {
554 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
555 reject_tags_and_links(node.syntax(), "commodity", bom_offset, errors);
556 let currency = Currency::new(node.currency()?.text());
557 let meta = convert_meta_entries(node.syntax());
558
559 let commodity = rustledger_core::directive::Commodity {
560 date,
561 currency,
562 meta,
563 };
564 let span = node_span(node.syntax(), bom_offset);
565 Some(Spanned::new(Directive::Commodity(commodity), span))
566}
567
568fn convert_note(
569 node: &NoteDirective,
570 bom_offset: u32,
571 errors: &mut Vec<crate::ParseError>,
572) -> Option<Spanned<Directive>> {
573 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
574 let account = Account::new(node.account()?.text());
575 let comment = node.text()?.text_decoded()?;
576 let meta = convert_meta_entries(node.syntax());
577
578 let note = rustledger_core::directive::Note {
579 date,
580 account,
581 comment,
582 meta,
583 };
584 let span = node_span(node.syntax(), bom_offset);
585 Some(Spanned::new(Directive::Note(note), span))
586}
587
588fn convert_document(
589 node: &DocumentDirective,
590 bom_offset: u32,
591 errors: &mut Vec<crate::ParseError>,
592) -> Option<Spanned<Directive>> {
593 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
594 let account = Account::new(node.account()?.text());
595 let path = node.path()?.text_decoded()?;
596 let mut tags: Vec<Tag> = Vec::new();
603 let mut links: Vec<Link> = Vec::new();
604 for el in node.syntax().children_with_tokens() {
605 let rowan::NodeOrToken::Token(t) = el else {
606 continue;
607 };
608 match t.kind() {
609 crate::SyntaxKind::NEWLINE => break,
610 crate::SyntaxKind::TAG => {
611 tags.push(Tag::new(t.text().trim_start_matches('#')));
612 }
613 crate::SyntaxKind::LINK => {
614 links.push(Link::new(t.text().trim_start_matches('^')));
615 }
616 _ => {}
617 }
618 }
619 let meta = convert_meta_entries(node.syntax());
620
621 let document = rustledger_core::directive::Document {
622 date,
623 account,
624 path,
625 tags,
626 links,
627 meta,
628 };
629 let span = node_span(node.syntax(), bom_offset);
630 Some(Spanned::new(Directive::Document(document), span))
631}
632
633fn convert_event(
634 node: &EventDirective,
635 bom_offset: u32,
636 errors: &mut Vec<crate::ParseError>,
637) -> Option<Spanned<Directive>> {
638 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
639 reject_tags_and_links(node.syntax(), "event", bom_offset, errors);
640 let event_type = node.event_type()?.text_decoded()?;
641 let value = node.value()?.text_decoded()?;
642 let meta = convert_meta_entries(node.syntax());
643
644 let event = rustledger_core::directive::Event {
645 date,
646 event_type,
647 value,
648 meta,
649 };
650 let span = node_span(node.syntax(), bom_offset);
651 Some(Spanned::new(Directive::Event(event), span))
652}
653
654fn convert_query(
655 node: &QueryDirective,
656 bom_offset: u32,
657 errors: &mut Vec<crate::ParseError>,
658) -> Option<Spanned<Directive>> {
659 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
660 let name = node.name()?.text_decoded()?;
661 let query = node.query()?.text_decoded()?;
662 let meta = convert_meta_entries(node.syntax());
663
664 let q = rustledger_core::directive::Query {
665 date,
666 name,
667 query,
668 meta,
669 };
670 let span = node_span(node.syntax(), bom_offset);
671 Some(Spanned::new(Directive::Query(q), span))
672}
673
674fn malformed_directive_value(node: &crate::SyntaxNode) -> Option<crate::TextRange> {
692 let mut numbers = 0usize;
693 let mut saw_comma = false;
694 let mut start: Option<crate::TextRange> = None;
695 let mut end: Option<crate::TextRange> = None;
696 for t in node
697 .children_with_tokens()
698 .filter_map(rowan::NodeOrToken::into_token)
699 {
700 match t.kind() {
701 crate::SyntaxKind::TILDE => break,
703 crate::SyntaxKind::CURRENCY if numbers > 0 => break,
707 crate::SyntaxKind::NUMBER => {
708 numbers += 1;
709 start.get_or_insert(t.text_range());
710 end = Some(t.text_range());
711 }
712 crate::SyntaxKind::COMMA => {
713 saw_comma = true;
714 start.get_or_insert(t.text_range());
715 end = Some(t.text_range());
716 }
717 _ => {}
718 }
719 }
720 if !saw_comma && numbers <= 1 {
721 return None;
722 }
723 Some(crate::TextRange::new(start?.start(), end?.end()))
724}
725
726fn convert_price(
727 node: &PriceDirective,
728 bom_offset: u32,
729 errors: &mut Vec<crate::ParseError>,
730) -> Option<Spanned<Directive>> {
731 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
732 reject_tags_and_links(node.syntax(), "price", bom_offset, errors);
733 let base_currency = Currency::new(node.base_currency()?.text());
734 let number = directive_arithmetic_value(node.syntax()).or_else(|| {
737 if let Some(range) = malformed_directive_value(node.syntax()) {
740 let start: u32 = range.start().into();
741 let end: u32 = range.end().into();
742 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
743 errors.push(crate::ParseError::new(
744 crate::ParseErrorKind::SyntaxError(
745 "malformed amount: expected one number, optionally signed, \
746 or an arithmetic expression. A thousands separator must be \
747 inside the number, as in `-1,234.00`"
748 .to_string(),
749 ),
750 span,
751 ));
752 return None;
753 }
754 let mut n = parse_decimal_token(node.number()?.text())?;
755 if node_has_minus_before_number(node.syntax()) {
756 n = rustledger_core::negate_python(n);
760 }
761 Some(n)
762 })?;
763 let quote_currency = Currency::new(node.quote_currency()?.text());
764 let amount = Amount::new(number, quote_currency);
765 let meta = convert_meta_entries(node.syntax());
766
767 let price = rustledger_core::directive::Price {
768 date,
769 currency: base_currency,
770 amount,
771 meta,
772 };
773 let span = node_span(node.syntax(), bom_offset);
774 Some(Spanned::new(Directive::Price(price), span))
775}
776
777fn convert_balance(
778 node: &BalanceDirective,
779 bom_offset: u32,
780 errors: &mut Vec<crate::ParseError>,
781) -> Option<Spanned<Directive>> {
782 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
783 reject_tags_and_links(node.syntax(), "balance", bom_offset, errors);
784 let account = Account::new(node.account()?.text());
785 let number = directive_arithmetic_value(node.syntax()).or_else(|| {
790 if let Some(range) = malformed_directive_value(node.syntax()) {
793 let start: u32 = range.start().into();
794 let end: u32 = range.end().into();
795 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
796 errors.push(crate::ParseError::new(
797 crate::ParseErrorKind::SyntaxError(
798 "malformed amount: expected one number, optionally signed, \
799 or an arithmetic expression. A thousands separator must be \
800 inside the number, as in `-1,234.00`"
801 .to_string(),
802 ),
803 span,
804 ));
805 return None;
806 }
807 let mut n = parse_decimal_token(node.number()?.text())?;
808 if node_has_minus_before_number(node.syntax()) {
809 n = rustledger_core::negate_python(n);
813 }
814 Some(n)
815 })?;
816 let currency = Currency::new(node.currency()?.text());
817 let amount = Amount::new(number, currency);
818 let tolerance = extract_balance_tolerance(node.syntax());
819 let meta = convert_meta_entries(node.syntax());
820
821 let balance = rustledger_core::directive::Balance {
822 date,
823 account,
824 amount,
825 tolerance,
826 meta,
827 };
828 let span = node_span(node.syntax(), bom_offset);
829 Some(Spanned::new(Directive::Balance(balance), span))
830}
831
832fn extract_balance_tolerance(node: &crate::SyntaxNode) -> Option<Decimal> {
838 let tail: Vec<crate::SyntaxToken> = node
847 .children_with_tokens()
848 .filter_map(rowan::NodeOrToken::into_token)
849 .skip_while(|t| t.kind() != crate::SyntaxKind::TILDE)
850 .skip(1)
851 .filter(|t| !is_trivia_kind(t.kind()))
852 .collect();
853 if tail.is_empty() {
854 return None;
855 }
856 if let Some(value) = cost_region_value(&tail) {
857 return Some(value);
858 }
859 tail.iter()
861 .find(|t| t.kind() == crate::SyntaxKind::NUMBER)
862 .and_then(|t| parse_decimal_token(t.text()))
863}
864
865fn convert_pad(
866 node: &PadDirective,
867 bom_offset: u32,
868 errors: &mut Vec<crate::ParseError>,
869) -> Option<Spanned<Directive>> {
870 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
871 reject_tags_and_links(node.syntax(), "pad", bom_offset, errors);
872 let account = Account::new(node.target_account()?.text());
873 let source_account = Account::new(node.source_account()?.text());
874 let meta = convert_meta_entries(node.syntax());
875
876 let pad = rustledger_core::directive::Pad {
877 date,
878 account,
879 source_account,
880 meta,
881 };
882 let span = node_span(node.syntax(), bom_offset);
883 Some(Spanned::new(Directive::Pad(pad), span))
884}
885
886fn convert_custom(
887 node: &CustomDirective,
888 bom_offset: u32,
889 errors: &mut Vec<crate::ParseError>,
890) -> Option<Spanned<Directive>> {
891 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
892 let custom_type = node.custom_type()?.text_decoded()?;
893 let values = extract_custom_values(node.syntax());
894 let meta = convert_meta_entries(node.syntax());
895
896 let custom = rustledger_core::directive::Custom {
897 date,
898 custom_type,
899 values,
900 meta,
901 };
902 let span = node_span(node.syntax(), bom_offset);
903 Some(Spanned::new(Directive::Custom(custom), span))
904}
905
906fn extract_custom_values(node: &crate::SyntaxNode) -> Vec<MetaValue> {
913 let mut values = Vec::new();
914 let mut seen_type_string = false;
915 let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
919 .children_with_tokens()
920 .filter_map(rowan::NodeOrToken::into_token)
921 .filter(|t| {
922 !matches!(
923 t.kind(),
924 crate::SyntaxKind::WHITESPACE
925 | crate::SyntaxKind::NEWLINE
926 | crate::SyntaxKind::COMMENT
927 )
928 })
929 .collect();
930
931 let mut i = 0;
932 while i < raw.len() {
933 if !seen_type_string {
936 if raw[i].kind() == crate::SyntaxKind::STRING {
937 seen_type_string = true;
938 }
939 i += 1;
940 continue;
941 }
942 let next = if let Some((value, consumed)) = value_tokens_to_meta(&raw, i) {
961 values.push(value);
962 consumed
963 } else {
964 i + 1
965 };
966 debug_assert!(
967 next > i,
968 "value_tokens_to_meta must advance: returned {next} at {i}"
969 );
970 i = next.max(i + 1);
971 }
972 values
973}
974
975fn strip_string_quotes(raw: &str) -> Option<&str> {
976 let bytes = raw.as_bytes();
977 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
978 return None;
979 }
980 Some(&raw[1..raw.len() - 1])
981}
982
983fn convert_option(node: &OptionDirective, bom_offset: u32) -> Option<(String, String, Span)> {
984 let key = node.key()?.text_decoded()?;
985 let value = node.value()?.text_decoded()?;
986 Some((
987 key,
988 value,
989 single_line_directive_span(node.syntax(), bom_offset),
990 ))
991}
992
993fn convert_include(node: &IncludeDirective, bom_offset: u32) -> Option<(String, Span)> {
994 let path = node.path()?.text_decoded()?;
995 Some((path, single_line_directive_span(node.syntax(), bom_offset)))
996}
997
998fn convert_plugin(
999 node: &PluginDirective,
1000 bom_offset: u32,
1001) -> Option<(String, Option<String>, Span)> {
1002 let module = node.module()?.text_decoded()?;
1003 let config = node.config().and_then(|c| c.text_decoded());
1004 Some((
1005 module,
1006 config,
1007 single_line_directive_span(node.syntax(), bom_offset),
1008 ))
1009}
1010
1011fn convert_transaction(
1014 node: &AstTransaction,
1015 bom_offset: u32,
1016 errors: &mut Vec<crate::ParseError>,
1017) -> Option<Spanned<Directive>> {
1018 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
1019
1020 let flag = node.flag().map_or('*', |f| flag_char_from_transaction(&f));
1023
1024 let mut it = node.strings().filter_map(|s| s.text_decoded());
1029 let (payee_str, narration_str) = match (it.next(), it.next(), it.next()) {
1030 (None, _, _) => (None, String::new()),
1031 (Some(n), None, _) => (None, n),
1032 (Some(p), Some(n), None) => (Some(p), n),
1033 (Some(_), Some(_), Some(c)) => (None, it.last().unwrap_or(c)),
1036 };
1037
1038 let payee = payee_str.map(InternedStr::from);
1039 let narration = InternedStr::from(narration_str);
1040
1041 let mut tags: Vec<Tag> = node
1051 .tags()
1052 .map(|t| Tag::new(t.text().trim_start_matches('#')))
1053 .collect();
1054 let mut links: Vec<Link> = node
1055 .links()
1056 .map(|l| Link::new(l.text().trim_start_matches('^')))
1057 .collect();
1058 for el in node.syntax().children_with_tokens() {
1059 let rowan::NodeOrToken::Token(t) = el else {
1060 continue;
1063 };
1064 match t.kind() {
1065 crate::SyntaxKind::TAG => {
1066 let stripped = t.text().trim_start_matches('#');
1067 let new_tag = Tag::new(stripped);
1068 if !tags.contains(&new_tag) {
1069 tags.push(new_tag);
1070 }
1071 }
1072 crate::SyntaxKind::LINK => {
1073 let stripped = t.text().trim_start_matches('^');
1074 let new_link = Link::new(stripped);
1075 if !links.contains(&new_link) {
1076 links.push(new_link);
1077 }
1078 }
1079 _ => {}
1080 }
1081 }
1082
1083 let meta = convert_meta_entries(node.syntax());
1086
1087 let (postings, trailing_comments) = collect_postings_with_comments(node, bom_offset, errors);
1098
1099 if header_has_pipe(node) {
1104 errors.push(crate::ParseError::new(
1105 crate::ParseErrorKind::DeprecatedPipeSymbol,
1106 node_span(node.syntax(), bom_offset),
1107 ));
1108 }
1109
1110 let txn = rustledger_core::directive::Transaction {
1111 date,
1112 flag,
1113 payee,
1114 narration,
1115 tags,
1116 links,
1117 meta,
1118 postings,
1119 trailing_comments,
1120 };
1121 let span = node_span(node.syntax(), bom_offset);
1122 Some(Spanned::new(Directive::Transaction(txn), span))
1123}
1124
1125fn header_has_pipe(node: &AstTransaction) -> bool {
1131 for el in node.syntax().children_with_tokens() {
1132 let rowan::NodeOrToken::Token(t) = el else {
1133 continue;
1134 };
1135 if t.kind() == crate::SyntaxKind::NEWLINE {
1136 return false;
1137 }
1138 if t.kind() == crate::SyntaxKind::PIPE {
1139 return true;
1140 }
1141 }
1142 false
1143}
1144
1145fn collect_postings_with_comments(
1160 node: &AstTransaction,
1161 bom_offset: u32,
1162 errors: &mut Vec<crate::ParseError>,
1163) -> (Vec<Spanned<Posting>>, Vec<String>) {
1164 let mut out = Vec::new();
1165 let mut pending: Vec<String> = Vec::new();
1166 let mut past_header = false;
1167 for el in node.syntax().children_with_tokens() {
1168 match el {
1169 rowan::NodeOrToken::Token(t) => {
1170 if !past_header {
1171 if t.kind() == crate::SyntaxKind::NEWLINE {
1172 past_header = true;
1173 }
1174 continue;
1175 }
1176 if is_comment_kind(t.kind()) {
1177 pending.push(t.text().to_string());
1178 } else if !is_trivia_kind(t.kind())
1179 && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
1180 {
1181 pending.clear();
1202 }
1203 }
1204 rowan::NodeOrToken::Node(n) => {
1205 if !past_header {
1206 past_header = true;
1211 }
1212 if let Some(p) = ast::Posting::cast(n) {
1213 if let Some(mut spanned) = convert_posting(&p, bom_offset, errors) {
1214 if !pending.is_empty() {
1215 spanned.value.comments = std::mem::take(&mut pending);
1216 }
1217 out.push(spanned);
1218 } else {
1219 pending.clear();
1227 }
1228 }
1229 }
1233 }
1234 }
1235 (out, pending)
1236}
1237
1238fn flag_char_from_transaction(flag: &ast::TransactionFlag) -> char {
1239 match flag.classify() {
1240 TransactionFlagKind::Star | TransactionFlagKind::Txn => '*',
1241 TransactionFlagKind::Pending => '!',
1242 TransactionFlagKind::Hash => '#',
1243 TransactionFlagKind::Letter | TransactionFlagKind::CurrencyLetter => {
1244 flag.text().chars().next().unwrap_or('*')
1245 }
1246 }
1247}
1248
1249fn convert_posting(
1250 node: &ast::Posting,
1251 bom_offset: u32,
1252 errors: &mut Vec<crate::ParseError>,
1253) -> Option<Spanned<Posting>> {
1254 let account = Account::new(node.account()?.text());
1255
1256 let flag = node.flag().map(|f| flag_char_from_posting(&f));
1257
1258 let mut amount_children = node
1270 .syntax()
1271 .children()
1272 .filter(|n| ast::Amount::can_cast(n.kind()));
1273 let first_amount = amount_children.next();
1274 let first_amount_end: Option<u32> = first_amount.as_ref().map(|n| n.text_range().end().into());
1275 let mut sibling_start: Option<u32> = None;
1276 let mut sibling_end: u32 = 0;
1277 for extra in amount_children {
1278 let range = extra.text_range();
1279 let start_u32: u32 = range.start().into();
1280 let end_u32: u32 = range.end().into();
1281 if sibling_start.is_none() {
1282 sibling_start = Some(start_u32);
1283 }
1284 sibling_end = end_u32;
1285 }
1286 if let Some(start_u32) = sibling_start {
1287 let underline_start = first_amount_end.unwrap_or(start_u32);
1294 let span = Span::new(
1295 (underline_start + bom_offset) as usize,
1296 (sibling_end + bom_offset) as usize,
1297 );
1298 errors.push(crate::ParseError::new(
1299 crate::ParseErrorKind::SyntaxError(
1300 "unexpected trailing tokens after posting amount".to_string(),
1301 ),
1302 span,
1303 ));
1304 }
1305 if let Some(range) = orphaned_amount_prefix(node.syntax()) {
1320 let start: u32 = range.start().into();
1321 let end: u32 = range.end().into();
1322 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1323 errors.push(crate::ParseError::new(
1324 crate::ParseErrorKind::SyntaxError(
1325 "unexpected token before posting amount: a `+`/`-` must be \
1326 followed by a number, and a thousands separator must be \
1327 inside one (as in `-1,234.00`)"
1328 .to_string(),
1329 ),
1330 span,
1331 ));
1332 }
1333
1334 let units = first_amount
1335 .and_then(ast::Amount::cast)
1336 .and_then(|amt| convert_amount_to_incomplete(&amt, errors, bom_offset));
1337 let cost = node.cost_spec().map(|cs| convert_cost_spec(&cs));
1338 let price = node
1339 .price_annotation()
1340 .map(|pa| convert_price_annotation(&pa, errors, bom_offset));
1341 let meta = convert_meta_entries(node.syntax());
1342
1343 let trailing_comments: Vec<String> = node
1348 .syntax()
1349 .children_with_tokens()
1350 .filter_map(rowan::NodeOrToken::into_token)
1351 .take_while(|t| t.kind() != crate::SyntaxKind::NEWLINE)
1352 .filter(|t| is_comment_kind(t.kind()))
1353 .map(|t| t.text().to_string())
1354 .collect();
1355
1356 let posting = Posting {
1357 account,
1358 units,
1359 cost: cost.map(Box::new),
1360 price: price.map(Box::new),
1361 flag,
1362 meta,
1363 comments: Vec::new(),
1364 trailing_comments,
1365 };
1366 let span = posting_span(node.syntax(), bom_offset);
1367 Some(Spanned::new(posting, span))
1368}
1369
1370fn flag_char_from_posting(flag: &ast::PostingFlag) -> char {
1371 match flag.classify() {
1372 PostingFlagKind::Star => '*',
1373 PostingFlagKind::Pending => '!',
1374 PostingFlagKind::Hash => '#',
1375 PostingFlagKind::Letter | PostingFlagKind::CurrencyLetter => {
1376 flag.text().chars().next().unwrap_or('*')
1377 }
1378 }
1379}
1380
1381fn convert_amount_to_incomplete(
1393 amt: &ast::Amount,
1394 errors: &mut Vec<crate::ParseError>,
1395 bom_offset: u32,
1396) -> Option<IncompleteAmount> {
1397 let number = if amt.is_arithmetic() {
1402 let evaluated = evaluate_amount_expression(amt);
1403 if evaluated.is_none() {
1404 let range = amt.syntax().text_range();
1413 let start: u32 = range.start().into();
1414 let end: u32 = range.end().into();
1415 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1416 errors.push(crate::ParseError::new(
1417 crate::ParseErrorKind::SyntaxError(
1418 "invalid arithmetic expression in amount (overflow, division by zero, or malformed)"
1419 .to_string(),
1420 ),
1421 span,
1422 ));
1423 }
1424 evaluated
1425 } else {
1426 amt.number().and_then(|n| {
1427 let parsed = parse_decimal_token(n.text());
1428 if parsed.is_none() {
1429 let range = n.syntax().text_range();
1439 let start: u32 = range.start().into();
1440 let end: u32 = range.end().into();
1441 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1442 errors.push(crate::ParseError::new(
1443 crate::ParseErrorKind::SyntaxError(
1444 "invalid number in amount (likely exceeds 28-digit Decimal precision)"
1445 .to_string(),
1446 ),
1447 span,
1448 ));
1449 }
1450 let mut value = parsed?;
1451 if let Some(sign) = amt.sign()
1452 && sign.is_minus()
1453 {
1454 value = rustledger_core::negate_python(value);
1457 }
1458 Some(value)
1459 })
1460 };
1461 let currency = amt.currency().map(|c| Currency::new(c.text()));
1462 match (number, currency) {
1463 (Some(n), Some(c)) => Some(IncompleteAmount::Complete(Amount::new(n, c))),
1464 (Some(n), None) => Some(IncompleteAmount::NumberOnly(n)),
1465 (None, Some(c)) => Some(IncompleteAmount::CurrencyOnly(c)),
1466 (None, None) => None,
1467 }
1468}
1469
1470fn evaluate_amount_expression(amt: &ast::Amount) -> Option<Decimal> {
1487 let tokens = amount_expression_tokens(amt);
1488 let mut cursor = 0usize;
1489 let value = parse_arith_expr(&tokens, &mut cursor)?;
1490 if cursor != tokens.len() {
1494 return None;
1495 }
1496 Some(value)
1497}
1498
1499fn directive_arithmetic_value(node: &crate::SyntaxNode) -> Option<Decimal> {
1518 let raw: Vec<crate::SyntaxToken> = node
1519 .children_with_tokens()
1520 .filter_map(rowan::NodeOrToken::into_token)
1521 .filter(|t| !is_trivia_kind(t.kind()))
1522 .skip_while(|t| {
1532 !matches!(
1533 t.kind(),
1534 crate::SyntaxKind::NUMBER
1535 | crate::SyntaxKind::L_PAREN
1536 | crate::SyntaxKind::MINUS
1537 | crate::SyntaxKind::PLUS
1538 )
1539 })
1540 .collect();
1541 let mut depth: i32 = 0;
1542 let mut first_currency_idx: Option<usize> = None;
1543 for (i, t) in raw.iter().enumerate() {
1544 match t.kind() {
1545 crate::SyntaxKind::L_PAREN => depth += 1,
1546 crate::SyntaxKind::R_PAREN => depth -= 1,
1547 crate::SyntaxKind::CURRENCY if depth == 0 && first_currency_idx.is_none() => {
1548 first_currency_idx = Some(i);
1549 }
1550 _ => {}
1551 }
1552 }
1553 let end = first_currency_idx.unwrap_or(raw.len());
1554 let tokens: Vec<crate::SyntaxToken> = raw.into_iter().take(end).collect();
1555 let has_op = tokens.iter().any(|t| {
1557 matches!(
1558 t.kind(),
1559 crate::SyntaxKind::PLUS
1560 | crate::SyntaxKind::MINUS
1561 | crate::SyntaxKind::STAR
1562 | crate::SyntaxKind::SLASH
1563 | crate::SyntaxKind::L_PAREN
1564 )
1565 });
1566 if !has_op {
1567 return None;
1568 }
1569 let mut cursor = 0usize;
1570 let value = parse_arith_expr(&tokens, &mut cursor)?;
1571 if cursor != tokens.len() {
1572 return None;
1573 }
1574 Some(value)
1575}
1576
1577fn amount_expression_tokens(amt: &ast::Amount) -> Vec<crate::SyntaxToken> {
1583 let raw: Vec<crate::SyntaxToken> = amt
1584 .syntax()
1585 .children_with_tokens()
1586 .filter_map(rowan::NodeOrToken::into_token)
1587 .filter(|t| !is_trivia_kind(t.kind()))
1588 .collect();
1589 let mut depth: i32 = 0;
1593 let mut trailing_currency_idx: Option<usize> = None;
1594 for (i, t) in raw.iter().enumerate() {
1595 match t.kind() {
1596 crate::SyntaxKind::L_PAREN => depth += 1,
1597 crate::SyntaxKind::R_PAREN => depth -= 1,
1598 crate::SyntaxKind::CURRENCY if depth == 0 => trailing_currency_idx = Some(i),
1599 _ => {}
1600 }
1601 }
1602 let end = trailing_currency_idx.unwrap_or(raw.len());
1603 raw.into_iter().take(end).collect()
1604}
1605
1606fn parse_arith_expr<T: TokenView>(tokens: &[T], cursor: &mut usize) -> Option<Decimal> {
1608 let mut result = parse_arith_term(tokens, cursor)?;
1609 while let Some(op) = tokens.get(*cursor).map(TokenView::kind) {
1610 match op {
1611 crate::SyntaxKind::PLUS => {
1612 *cursor += 1;
1613 let rhs = parse_arith_term(tokens, cursor)?;
1614 result = result.checked_add(rhs)?;
1615 }
1616 crate::SyntaxKind::MINUS => {
1617 *cursor += 1;
1618 let rhs = parse_arith_term(tokens, cursor)?;
1619 result = result.checked_sub(rhs)?;
1620 }
1621 _ => break,
1622 }
1623 }
1624 Some(result)
1625}
1626
1627fn parse_arith_term<T: TokenView>(tokens: &[T], cursor: &mut usize) -> Option<Decimal> {
1629 let mut result = parse_arith_primary(tokens, cursor)?;
1630 while let Some(op) = tokens.get(*cursor).map(TokenView::kind) {
1631 match op {
1632 crate::SyntaxKind::STAR => {
1633 *cursor += 1;
1634 let rhs = parse_arith_primary(tokens, cursor)?;
1635 result = result.checked_mul(rhs)?;
1636 }
1637 crate::SyntaxKind::SLASH => {
1638 *cursor += 1;
1639 let rhs = parse_arith_primary(tokens, cursor)?;
1640 if rhs.is_zero() {
1641 return None;
1642 }
1643 result = result.checked_div(rhs)?;
1644 }
1645 _ => break,
1646 }
1647 }
1648 Some(result)
1649}
1650
1651fn parse_arith_primary<T: TokenView>(tokens: &[T], cursor: &mut usize) -> Option<Decimal> {
1653 let t = tokens.get(*cursor)?;
1654 match t.kind() {
1655 crate::SyntaxKind::L_PAREN => {
1656 *cursor += 1;
1657 let inner = parse_arith_expr(tokens, cursor)?;
1658 let close = tokens.get(*cursor)?;
1663 if close.kind() != crate::SyntaxKind::R_PAREN {
1664 return None;
1665 }
1666 *cursor += 1;
1667 Some(inner)
1668 }
1669 crate::SyntaxKind::MINUS => {
1670 *cursor += 1;
1671 let inner = parse_arith_primary(tokens, cursor)?;
1672 Some(-inner)
1673 }
1674 crate::SyntaxKind::PLUS => {
1675 *cursor += 1;
1676 parse_arith_primary(tokens, cursor)
1677 }
1678 crate::SyntaxKind::NUMBER => {
1679 let value = parse_decimal_token(t.text())?;
1680 *cursor += 1;
1681 Some(value)
1682 }
1683 _ => None,
1684 }
1685}
1686
1687fn cost_number_region<T: TokenView>(seg: &[T]) -> &[T] {
1695 use crate::SyntaxKind as K;
1696 let start = seg
1697 .iter()
1698 .position(|t| matches!(t.kind(), K::NUMBER | K::L_PAREN | K::MINUS | K::PLUS))
1699 .unwrap_or(seg.len());
1700 let mut depth = 0i32;
1701 let mut end = seg.len();
1702 for (i, t) in seg.iter().enumerate().skip(start) {
1703 match t.kind() {
1704 K::L_PAREN => depth += 1,
1705 K::R_PAREN => depth -= 1,
1706 K::CURRENCY if depth == 0 => {
1707 end = i;
1708 break;
1709 }
1710 K::COMMA if depth == 0 => {
1714 end = i;
1715 break;
1716 }
1717 _ => {}
1718 }
1719 }
1720 &seg[start..end]
1721}
1722
1723fn cost_region_value<T: TokenView>(seg: &[T]) -> Option<Decimal> {
1745 use crate::SyntaxKind as K;
1746 let kept: Vec<&T> = seg.iter().filter(|t| !is_trivia_kind(t.kind())).collect();
1754 let region = cost_number_region(&kept);
1755 if !region.iter().any(|t| {
1756 matches!(
1757 t.kind(),
1758 K::PLUS | K::MINUS | K::STAR | K::SLASH | K::L_PAREN
1759 )
1760 }) {
1761 return None;
1762 }
1763 let mut cursor = 0usize;
1764 let value = parse_arith_expr(region, &mut cursor)?;
1765 if cursor != region.len() {
1768 return None;
1769 }
1770 Some(value)
1771}
1772
1773fn convert_cost_spec(cs: &ast::CostSpec) -> CostSpec {
1774 cost_spec_from_tokens(
1775 cs.syntax()
1776 .children_with_tokens()
1777 .filter_map(rowan::NodeOrToken::into_token),
1778 )
1779}
1780
1781pub(super) trait TokenView {
1788 fn kind(&self) -> crate::SyntaxKind;
1790 fn text(&self) -> &str;
1792}
1793
1794impl<T: TokenView> TokenView for &T {
1795 fn kind(&self) -> crate::SyntaxKind {
1796 (*self).kind()
1797 }
1798 fn text(&self) -> &str {
1799 (*self).text()
1800 }
1801}
1802
1803impl TokenView for rowan::SyntaxToken<crate::BeancountLanguage> {
1804 fn kind(&self) -> crate::SyntaxKind {
1809 Self::kind(self)
1810 }
1811 fn text(&self) -> &str {
1812 Self::text(self)
1813 }
1814}
1815
1816impl TokenView for &rowan::GreenTokenData {
1817 fn kind(&self) -> crate::SyntaxKind {
1818 <crate::BeancountLanguage as rowan::Language>::kind_from_raw((*self).kind())
1819 }
1820 fn text(&self) -> &str {
1821 (*self).text()
1822 }
1823}
1824#[derive(Default)]
1840pub(in crate::cst) struct MergeFlag {
1841 past_opener: bool,
1842 decided: bool,
1843 merge: bool,
1844}
1845
1846impl MergeFlag {
1847 pub(in crate::cst) const fn feed(&mut self, kind: crate::SyntaxKind) {
1850 use crate::SyntaxKind as K;
1851 if self.decided {
1852 return;
1853 }
1854 match kind {
1855 K::L_BRACE | K::L_DOUBLE_BRACE | K::L_BRACE_HASH => self.past_opener = true,
1856 K::WHITESPACE => {}
1857 K::STAR if self.past_opener => {
1858 self.merge = true;
1859 self.decided = true;
1860 }
1861 _ if self.past_opener => self.decided = true,
1862 _ => {}
1863 }
1864 }
1865
1866 pub(in crate::cst) const fn is_merge(&self) -> bool {
1868 self.merge
1869 }
1870}
1871
1872pub(super) fn cost_spec_from_tokens(tokens: impl Iterator<Item = impl TokenView>) -> CostSpec {
1904 use crate::SyntaxKind as K;
1905 let toks: Vec<_> = tokens.collect();
1909 let mut is_total = false;
1910 let mut first_number: Option<Decimal> = None; let mut seen_number = false;
1912 let mut pre_hash: Option<Decimal> = None; let mut past_hash = false;
1914 let mut post_hash_total: Option<Decimal> = None; let mut currency: Option<Currency> = None;
1916 let mut date: Option<NaiveDate> = None;
1917 let mut date_seen = false;
1918 let mut label: Option<String> = None;
1919 let mut label_seen = false;
1920 let mut merge_flag = MergeFlag::default();
1921 for t in &toks {
1922 let kind = t.kind();
1923 merge_flag.feed(kind);
1925 match kind {
1926 K::L_DOUBLE_BRACE => is_total = true,
1927 K::NUMBER => {
1928 if past_hash {
1929 if post_hash_total.is_none() {
1930 post_hash_total = parse_decimal_token(t.text());
1931 }
1932 } else {
1933 if pre_hash.is_none() {
1934 pre_hash = parse_decimal_token(t.text());
1935 }
1936 if !seen_number {
1937 seen_number = true;
1938 first_number = parse_decimal_token(t.text());
1939 }
1940 }
1941 }
1942 K::HASH | K::L_BRACE_HASH => past_hash = true,
1943 K::CURRENCY if currency.is_none() => currency = Some(Currency::new(t.text())),
1944 K::DATE if !date_seen => {
1945 date_seen = true;
1946 date = parse_date_token(t.text());
1947 }
1948 K::STRING if !label_seen => {
1949 label_seen = true;
1950 label = decode_string_token(t.text());
1951 }
1952 _ => {}
1953 }
1954 }
1955 let hash_at = toks
1962 .iter()
1963 .position(|t| matches!(t.kind(), K::HASH | K::L_BRACE_HASH));
1964 match hash_at {
1965 Some(i) => {
1966 if let Some(v) = cost_region_value(&toks[..i]) {
1967 pre_hash = Some(v);
1968 }
1969 if let Some(v) = cost_region_value(&toks[i + 1..]) {
1970 post_hash_total = Some(v);
1971 }
1972 }
1973 None => {
1974 if let Some(v) = cost_region_value(&toks) {
1975 first_number = Some(v);
1976 }
1977 }
1978 }
1979
1980 let shape_ok =
1994 super::cost_spec_shape::first_cost_spec_defect(toks.iter().map(|t| (t.kind(), ())))
1995 .is_none();
1996
1997 let number = if !shape_ok {
1998 None
1999 } else if past_hash {
2000 match (pre_hash, post_hash_total) {
2001 (None, None) => None,
2022 (per_unit, total) => Some(CostNumber::Compound {
2023 per_unit: per_unit.unwrap_or_default(),
2024 total: total.unwrap_or_default(),
2025 }),
2026 }
2027 } else {
2028 match (first_number, is_total) {
2029 (Some(v), true) => Some(CostNumber::Total { value: v }),
2030 (Some(v), false) => Some(CostNumber::PerUnit { value: v }),
2031 (None, _) => None,
2032 }
2033 };
2034 CostSpec {
2035 number,
2036 currency,
2037 date,
2038 label,
2039 merge: merge_flag.is_merge(),
2040 }
2041}
2042
2043fn convert_price_annotation(
2044 pa: &ast::PriceAnnotation,
2045 errors: &mut Vec<crate::ParseError>,
2046 bom_offset: u32,
2047) -> PriceAnnotation {
2048 let kind = if pa.is_total() {
2049 PriceKind::Total
2050 } else {
2051 PriceKind::Unit
2052 };
2053 let amount = pa
2054 .amount()
2055 .and_then(|a| convert_amount_to_incomplete(&a, errors, bom_offset));
2056 PriceAnnotation { kind, amount }
2057}
2058
2059fn convert_meta_entries(node: &crate::SyntaxNode) -> Metadata {
2066 let mut meta = Metadata::default();
2067 for entry in node.children().filter_map(MetaEntry::cast) {
2068 let Some(key_token) = entry.key() else {
2069 continue;
2070 };
2071 let key = key_token.text_without_colon().to_string();
2072 let value = meta_value_from_entry(&entry);
2073 meta.insert(key, value);
2074 }
2075 meta
2076}
2077
2078pub(super) const fn is_orphanable_amount_prefix(kind: crate::SyntaxKind) -> bool {
2097 matches!(
2098 kind,
2099 crate::SyntaxKind::MINUS | crate::SyntaxKind::PLUS | crate::SyntaxKind::COMMA
2100 )
2101}
2102
2103pub(super) fn orphaned_amount_prefix(node: &crate::SyntaxNode) -> Option<crate::TextRange> {
2104 let mut seen_account = false;
2105 let mut start: Option<crate::TextRange> = None;
2106 let mut end: Option<crate::TextRange> = None;
2107 for el in node.children_with_tokens() {
2108 match el {
2109 rowan::NodeOrToken::Node(n) => {
2110 if ast::Amount::can_cast(n.kind()) {
2118 break;
2119 }
2120 }
2121 rowan::NodeOrToken::Token(t) => {
2122 let kind = t.kind();
2123 if kind == crate::SyntaxKind::ACCOUNT {
2124 seen_account = true;
2125 continue;
2126 }
2127 if kind == crate::SyntaxKind::NEWLINE {
2133 break;
2134 }
2135 if !seen_account || is_trivia_kind(kind) || is_comment_kind(kind) {
2136 continue;
2137 }
2138 if !is_orphanable_amount_prefix(kind) {
2139 continue;
2140 }
2141 start.get_or_insert(t.text_range());
2142 end = Some(t.text_range());
2143 }
2144 }
2145 }
2146 let (s, e) = (start?, end?);
2147 Some(crate::TextRange::new(s.start(), e.end()))
2148}
2149
2150fn node_has_minus_before_number(node: &crate::SyntaxNode) -> bool {
2155 for el in node.children_with_tokens() {
2156 let rowan::NodeOrToken::Token(t) = el else {
2157 continue;
2158 };
2159 match t.kind() {
2160 crate::SyntaxKind::MINUS => return true,
2161 crate::SyntaxKind::NUMBER => return false,
2162 _ => {}
2163 }
2164 }
2165 false
2166}
2167
2168fn value_tokens_to_meta(
2182 tokens: &[rowan::SyntaxToken<crate::BeancountLanguage>],
2183 start: usize,
2184) -> Option<(MetaValue, usize)> {
2185 let mut i = start;
2186 let mut negate = false;
2187 if tokens.get(i).map(rowan::SyntaxToken::kind) == Some(crate::SyntaxKind::MINUS) {
2188 negate = true;
2189 i += 1;
2190 }
2191 let t = tokens.get(i)?;
2192 match t.kind() {
2193 crate::SyntaxKind::STRING => {
2194 let s = strip_string_quotes(t.text())?;
2195 Some((MetaValue::String(s.to_string()), i + 1))
2196 }
2197 crate::SyntaxKind::NUMBER => {
2198 let mut decimal = parse_decimal_token(t.text())?;
2199 if negate {
2200 decimal = -decimal;
2201 }
2202 if let Some(next) = tokens.get(i + 1)
2204 && next.kind() == crate::SyntaxKind::CURRENCY
2205 {
2206 return Some((
2207 MetaValue::Amount(Amount::new(decimal, Currency::new(next.text()))),
2208 i + 2,
2209 ));
2210 }
2211 Some((number_meta_value(t.text(), decimal), i + 1))
2212 }
2213 crate::SyntaxKind::DATE => Some((MetaValue::Date(parse_date_token(t.text())?), i + 1)),
2214 crate::SyntaxKind::ACCOUNT => Some((MetaValue::Account(Account::new(t.text())), i + 1)),
2215 crate::SyntaxKind::CURRENCY => Some((MetaValue::Currency(Currency::new(t.text())), i + 1)),
2216 crate::SyntaxKind::BOOL_TRUE => Some((MetaValue::Bool(true), i + 1)),
2217 crate::SyntaxKind::BOOL_FALSE => Some((MetaValue::Bool(false), i + 1)),
2218 crate::SyntaxKind::TAG => Some((
2219 MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))),
2220 i + 1,
2221 )),
2222 crate::SyntaxKind::LINK => Some((
2223 MetaValue::Link(Link::new(t.text().trim_start_matches('^'))),
2224 i + 1,
2225 )),
2226 _ => None,
2227 }
2228}
2229
2230fn meta_value_from_entry(entry: &MetaEntry) -> MetaValue {
2234 meta_value_from_tokens(
2235 entry
2236 .syntax()
2237 .children_with_tokens()
2238 .filter_map(rowan::NodeOrToken::into_token),
2239 )
2240}
2241
2242pub(super) fn meta_value_from_tokens(tokens: impl Iterator<Item = impl TokenView>) -> MetaValue {
2257 use crate::SyntaxKind as K;
2258 let toks: Vec<_> = tokens.collect();
2263 let mut string_t: Option<String> = None;
2264 let mut number_t: Option<String> = None;
2265 let mut currency_t: Option<String> = None;
2266 let mut date_t: Option<String> = None;
2267 let mut account_t: Option<String> = None;
2268 let mut bool_v: Option<bool> = None;
2269 let mut tag_link: Option<MetaValue> = None;
2270 let mut past_key = false;
2271 let mut minus = false;
2272 let mut minus_decided = false;
2273
2274 for t in &toks {
2275 let kind = t.kind();
2276 match kind {
2278 K::STRING if string_t.is_none() => string_t = Some(t.text().to_string()),
2279 K::NUMBER if number_t.is_none() => number_t = Some(t.text().to_string()),
2280 K::CURRENCY if currency_t.is_none() => currency_t = Some(t.text().to_string()),
2281 K::DATE if date_t.is_none() => date_t = Some(t.text().to_string()),
2282 K::ACCOUNT if account_t.is_none() => account_t = Some(t.text().to_string()),
2283 K::BOOL_TRUE if bool_v.is_none() => bool_v = Some(true),
2284 K::BOOL_FALSE if bool_v.is_none() => bool_v = Some(false),
2285 K::TAG if tag_link.is_none() => {
2286 tag_link = Some(MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))));
2287 }
2288 K::LINK if tag_link.is_none() => {
2289 tag_link = Some(MetaValue::Link(Link::new(t.text().trim_start_matches('^'))));
2290 }
2291 _ => {}
2292 }
2293 if past_key && !minus_decided {
2294 match kind {
2295 K::MINUS => {
2296 minus = true;
2297 minus_decided = true;
2298 }
2299 K::NUMBER => minus_decided = true,
2300 _ => {}
2301 }
2302 }
2303 if kind == K::META_KEY {
2313 past_key = true;
2314 }
2315 }
2316
2317 if let Some(s) = string_t
2318 && let Some(decoded) = decode_string_token(&s)
2319 {
2320 return MetaValue::String(decoded);
2321 }
2322 let value_region: Vec<&_> = toks
2330 .iter()
2331 .skip_while(|t| t.kind() != K::META_KEY)
2332 .filter(|t| !is_trivia_kind(t.kind()) && t.kind() != K::META_KEY)
2333 .collect();
2334 if let Some(dec) = cost_region_value(&value_region) {
2335 if let Some(c) = currency_t {
2336 return MetaValue::Amount(Amount::new(dec, Currency::new(&c)));
2337 }
2338 return number_meta_value(&dec.to_string(), dec);
2342 }
2343 if let Some(nt) = number_t
2344 && let Some(mut dec) = parse_decimal_token(&nt)
2345 {
2346 if minus {
2347 dec = -dec;
2348 }
2349 if let Some(c) = currency_t {
2350 return MetaValue::Amount(Amount::new(dec, Currency::new(&c)));
2351 }
2352 return number_meta_value(&nt, dec);
2353 }
2354 if let Some(dt) = date_t
2355 && let Some(date) = parse_date_token(&dt)
2356 {
2357 return MetaValue::Date(date);
2358 }
2359 if let Some(a) = account_t {
2360 return MetaValue::Account(Account::new(&a));
2361 }
2362 if let Some(c) = currency_t {
2363 return MetaValue::Currency(Currency::new(&c));
2364 }
2365 if let Some(b) = bool_v {
2366 return MetaValue::Bool(b);
2367 }
2368 if let Some(tl) = tag_link {
2369 return tl;
2370 }
2371 MetaValue::None
2372}
2373
2374fn apply_inherited_state(
2388 value: &mut Directive,
2389 tag_stack: &[(Tag, Span)],
2390 meta_stack: &[(String, MetaValue, Span)],
2391) {
2392 if let Directive::Transaction(txn) = value {
2393 for (tag, _) in tag_stack {
2394 if !txn.tags.contains(tag) {
2395 txn.tags.push(tag.clone());
2396 }
2397 }
2398 }
2399 if meta_stack.is_empty() {
2400 return;
2401 }
2402 let meta = match value {
2403 Directive::Transaction(d) => &mut d.meta,
2404 Directive::Balance(d) => &mut d.meta,
2405 Directive::Open(d) => &mut d.meta,
2406 Directive::Close(d) => &mut d.meta,
2407 Directive::Commodity(d) => &mut d.meta,
2408 Directive::Pad(d) => &mut d.meta,
2409 Directive::Event(d) => &mut d.meta,
2410 Directive::Query(d) => &mut d.meta,
2411 Directive::Note(d) => &mut d.meta,
2412 Directive::Document(d) => &mut d.meta,
2413 Directive::Price(d) => &mut d.meta,
2414 Directive::Custom(d) => &mut d.meta,
2415 };
2416 for (k, v, _) in meta_stack {
2417 meta.insert(k.clone(), v.clone());
2418 }
2419}
2420
2421fn pushmeta_value(node: &crate::SyntaxNode) -> MetaValue {
2426 let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
2431 .children_with_tokens()
2432 .filter_map(rowan::NodeOrToken::into_token)
2433 .filter(|t| {
2434 !matches!(
2435 t.kind(),
2436 crate::SyntaxKind::WHITESPACE
2437 | crate::SyntaxKind::NEWLINE
2438 | crate::SyntaxKind::COMMENT
2439 )
2440 })
2441 .collect();
2442
2443 let mut i = 0;
2444 while i < raw.len() {
2445 if let Some((value, _)) = value_tokens_to_meta(&raw, i) {
2446 return value;
2447 }
2448 i += 1;
2449 }
2450 MetaValue::None
2451}
2452
2453pub(super) const fn is_comment_kind(kind: crate::SyntaxKind) -> bool {
2459 matches!(
2460 kind,
2461 crate::SyntaxKind::COMMENT
2462 | crate::SyntaxKind::PERCENT_COMMENT
2463 | crate::SyntaxKind::SHEBANG
2464 | crate::SyntaxKind::EMACS_DIRECTIVE
2465 )
2466}
2467
2468pub(super) struct TopLevelWalkResult {
2470 pub(super) errors: Vec<crate::ParseError>,
2471 pub(super) section_marker_comments: Vec<Spanned<String>>,
2472}
2473
2474fn walk_top_level_once(
2486 source_file: &SourceFile,
2487 stripped: &str,
2488 bom_offset: u32,
2489) -> TopLevelWalkResult {
2490 let mut errors: Vec<crate::ParseError> = Vec::new();
2491 let mut section_marker_comments: Vec<Spanned<String>> = Vec::new();
2492 for child in source_file.syntax().children() {
2493 let kind = child.kind();
2494 if ast::Directive::can_cast(kind) {
2496 indented_directive_check(&child, stripped, bom_offset, &mut errors);
2497 }
2498 match kind {
2499 crate::SyntaxKind::CUSTOM_DIRECTIVE => {
2500 custom_value_check(&child, bom_offset, &mut errors);
2501 }
2502 crate::SyntaxKind::TRANSACTION => {
2503 transaction_header_check(&child, stripped, bom_offset, &mut errors);
2504 transaction_body_check(&child, bom_offset, &mut errors);
2505 }
2506 crate::SyntaxKind::ERROR_NODE => {
2507 error_node_check(&child, stripped, bom_offset, &mut errors);
2508 section_marker_check(&child, bom_offset, &mut section_marker_comments);
2509 }
2510 _ => {}
2511 }
2512 }
2513 TopLevelWalkResult {
2514 errors,
2515 section_marker_comments,
2516 }
2517}
2518
2519fn extract_link_metadata_value_errors(
2540 source_file: &SourceFile,
2541 bom_offset: u32,
2542) -> Vec<crate::ParseError> {
2543 let mut out = Vec::new();
2544 for entry in source_file.syntax().descendants() {
2545 if entry.kind() != crate::SyntaxKind::META_ENTRY {
2546 continue;
2547 }
2548 for el in entry.children_with_tokens() {
2549 let rowan::NodeOrToken::Token(t) = el else {
2550 continue;
2551 };
2552 if t.kind() != crate::SyntaxKind::LINK {
2553 continue;
2554 }
2555 let range = t.text_range();
2556 let off = bom_offset as usize;
2557 out.push(crate::ParseError::new(
2558 crate::ParseErrorKind::SyntaxError(format!(
2559 "a link ({}) is not a valid metadata value; beancount \
2560 accepts a tag here but not a link",
2561 t.text()
2562 )),
2563 Span::new(
2564 usize::from(range.start()) + off,
2565 usize::from(range.end()) + off,
2566 ),
2567 ));
2568 }
2569 }
2570 out
2571}
2572
2573fn extract_custom_pushmeta_taglink_errors(
2603 source_file: &SourceFile,
2604 bom_offset: u32,
2605) -> Vec<crate::ParseError> {
2606 use crate::SyntaxKind as K;
2607 let mut out = Vec::new();
2608 for node in source_file.syntax().descendants() {
2609 let (reject_tag, what) = match node.kind() {
2610 K::CUSTOM_DIRECTIVE => (true, "custom"),
2611 K::PUSHMETA_DIRECTIVE => (false, "pushmeta"),
2612 _ => continue,
2613 };
2614 for el in node.children_with_tokens() {
2615 let rowan::NodeOrToken::Token(t) = el else {
2616 continue;
2617 };
2618 let kind = t.kind();
2619 let bad = match kind {
2620 K::LINK => true,
2621 K::TAG => reject_tag,
2622 _ => false,
2623 };
2624 if !bad {
2625 continue;
2626 }
2627 let noun = if kind == K::TAG { "tag" } else { "link" };
2628 let range = t.text_range();
2629 let off = bom_offset as usize;
2630 out.push(crate::ParseError::new(
2631 crate::ParseErrorKind::SyntaxError(format!(
2632 "a {noun} ({}) is not a valid {what} value",
2633 t.text()
2634 )),
2635 Span::new(
2636 usize::from(range.start()) + off,
2637 usize::from(range.end()) + off,
2638 ),
2639 ));
2640 }
2641 }
2642 out
2643}
2644
2645fn extract_unclosed_cost_brace_errors(
2646 source_file: &SourceFile,
2647 stripped: &str,
2648 bom_offset: u32,
2649) -> Vec<crate::ParseError> {
2650 let mut out = Vec::new();
2651 for cs in source_file.syntax().descendants() {
2652 if cs.kind() != crate::SyntaxKind::COST_SPEC {
2653 continue;
2654 }
2655 let mut has_opener = false;
2656 let mut has_closer = false;
2657 for el in cs.children_with_tokens() {
2658 let rowan::NodeOrToken::Token(t) = el else {
2659 continue;
2660 };
2661 match t.kind() {
2662 crate::SyntaxKind::L_BRACE
2663 | crate::SyntaxKind::L_DOUBLE_BRACE
2664 | crate::SyntaxKind::L_BRACE_HASH => has_opener = true,
2665 crate::SyntaxKind::R_BRACE | crate::SyntaxKind::R_DOUBLE_BRACE => has_closer = true,
2666 _ => {}
2667 }
2668 }
2669 if has_opener && !has_closer {
2670 out.push(crate::ParseError::new(
2671 crate::ParseErrorKind::SyntaxError(
2672 "unclosed cost specification: missing '}'".to_string(),
2673 ),
2674 node_span(&cs, bom_offset),
2675 ));
2676 continue;
2679 }
2680
2681 let tokens = cs
2687 .children_with_tokens()
2688 .filter_map(rowan::NodeOrToken::into_token)
2689 .map(|t| {
2690 let r = t.text_range();
2691 (t.kind(), usize::from(r.start())..usize::from(r.end()))
2692 });
2693 if let Some((defect, range)) = super::cost_spec_shape::first_cost_spec_defect(tokens) {
2694 let message = match stripped.get(range.clone()) {
2697 Some(text) => super::cost_spec_shape::cost_defect_message(defect, text),
2698 None => format!(
2699 "malformed cost specification at bytes {}..{} ({defect:?})",
2700 range.start, range.end
2701 ),
2702 };
2703 out.push(crate::ParseError::new(
2704 crate::ParseErrorKind::SyntaxError(message),
2705 Span::new(
2706 range.start + bom_offset as usize,
2707 range.end + bom_offset as usize,
2708 ),
2709 ));
2710 }
2711 }
2712 out
2713}
2714
2715fn indented_directive_check(
2726 child: &crate::SyntaxNode,
2727 stripped: &str,
2728 bom_offset: u32,
2729 out: &mut Vec<crate::ParseError>,
2730) {
2731 let Some(content) = child
2737 .children_with_tokens()
2738 .filter_map(rowan::NodeOrToken::into_token)
2739 .find(|t| !is_trivia_kind(t.kind()))
2740 else {
2741 return;
2742 };
2743 let content_start: usize = u32::from(content.text_range().start()) as usize;
2744 let line_start = stripped
2756 .as_bytes()
2757 .get(..content_start)
2758 .and_then(|bytes| bytes.iter().rposition(|&b| b == b'\n'))
2759 .map_or(0, |nl| nl + 1);
2760 if content_start > line_start {
2761 let end: u32 = content.text_range().end().into();
2762 let span = Span::new(
2763 (line_start as u32 + bom_offset) as usize,
2764 (end + bom_offset) as usize,
2765 );
2766 out.push(crate::ParseError::new(
2767 crate::ParseErrorKind::SyntaxError(
2768 "top-level directive must start at column 0".to_string(),
2769 ),
2770 span,
2771 ));
2772 }
2773}
2774
2775fn custom_value_check(
2790 child: &crate::SyntaxNode,
2791 bom_offset: u32,
2792 out: &mut Vec<crate::ParseError>,
2793) {
2794 {
2796 let raw: Vec<crate::SyntaxToken> = child
2801 .children_with_tokens()
2802 .filter_map(rowan::NodeOrToken::into_token)
2803 .filter(|t| !is_trivia_kind(t.kind()))
2804 .collect();
2805 let mut seen_type_string = false;
2806 let mut i = 0;
2807 while i < raw.len() {
2808 let t = &raw[i];
2809 if !seen_type_string {
2810 if t.kind() == crate::SyntaxKind::STRING {
2811 seen_type_string = true;
2812 }
2813 i += 1;
2814 continue;
2815 }
2816 if t.kind() == crate::SyntaxKind::CURRENCY {
2817 let preceded_by_number = i > 0 && raw[i - 1].kind() == crate::SyntaxKind::NUMBER;
2823 if !preceded_by_number {
2824 let range = t.text_range();
2825 let start: u32 = range.start().into();
2826 let end: u32 = range.end().into();
2827 let span =
2828 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2829 out.push(crate::ParseError::new(
2830 crate::ParseErrorKind::SyntaxError(
2831 "bare currency literal is not a valid custom directive value"
2832 .to_string(),
2833 ),
2834 span,
2835 ));
2836 }
2837 }
2838 i += 1;
2839 }
2840 }
2841}
2842
2843fn unexpected_body_input(line_start: u32, end: u32, bom_offset: u32) -> crate::ParseError {
2854 crate::ParseError::new(
2855 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2856 Span::new(
2857 (line_start + bom_offset) as usize,
2858 (end + bom_offset) as usize,
2859 ),
2860 )
2861}
2862
2863fn transaction_header_check(
2872 child: &crate::SyntaxNode,
2873 stripped: &str,
2874 bom_offset: u32,
2875 out: &mut Vec<crate::ParseError>,
2876) {
2877 let Some(txn) = ast::Transaction::cast(child.clone()) else {
2878 return;
2879 };
2880 let tokens = txn.header_tokens().map(|t| {
2881 let r = t.text_range();
2882 (t.kind(), usize::from(r.start())..usize::from(r.end()))
2883 });
2884 if let Some((defect, range)) = super::txn_header::first_header_defect(tokens) {
2885 out.push(header_defect_error(defect, &range, stripped, bom_offset));
2886 }
2887}
2888
2889pub(super) fn header_defect_error(
2891 defect: super::txn_header::HeaderDefect,
2892 range: &std::ops::Range<usize>,
2893 stripped: &str,
2894 bom_offset: u32,
2895) -> crate::ParseError {
2896 let message = match stripped.get(range.clone()) {
2903 Some(text) => super::txn_header::defect_message(defect, text),
2904 None => format!(
2905 "malformed transaction header at bytes {}..{} ({defect:?})",
2906 range.start, range.end
2907 ),
2908 };
2909 crate::ParseError::new(
2910 crate::ParseErrorKind::SyntaxError(message),
2911 Span::new(
2912 range.start + bom_offset as usize,
2913 range.end + bom_offset as usize,
2914 ),
2915 )
2916}
2917
2918fn transaction_body_check(
2919 child: &crate::SyntaxNode,
2920 bom_offset: u32,
2921 out: &mut Vec<crate::ParseError>,
2922) {
2923 {
2925 let mut past_header = false;
2935 let mut saw_header_content = false;
2936 let mut line_start: Option<u32> = None;
2937 let mut line_has_content = false;
2938 for el in child.children_with_tokens() {
2939 match el {
2940 rowan::NodeOrToken::Token(t) => {
2941 if !past_header {
2942 if t.kind() == crate::SyntaxKind::NEWLINE {
2943 if saw_header_content {
2944 past_header = true;
2945 }
2946 } else if !is_trivia_kind(t.kind()) {
2947 saw_header_content = true;
2948 }
2949 continue;
2950 }
2951 let range = t.text_range();
2952 let start: u32 = range.start().into();
2953 let end: u32 = range.end().into();
2954 if line_start.is_none() {
2955 line_start = Some(start);
2956 }
2957 if t.kind() == crate::SyntaxKind::NEWLINE {
2958 if line_has_content && let Some(ls) = line_start {
2959 out.push(unexpected_body_input(ls, end, bom_offset));
2960 }
2961 line_start = None;
2962 line_has_content = false;
2963 } else if !is_trivia_kind(t.kind())
2964 && !is_comment_kind(t.kind())
2965 && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
2966 {
2967 line_has_content = true;
2973 }
2974 }
2975 rowan::NodeOrToken::Node(_) => {
2976 line_start = None;
2978 line_has_content = false;
2979 if !past_header {
2980 past_header = true;
2981 }
2982 }
2983 }
2984 }
2985 if past_header
2988 && line_has_content
2989 && let Some(ls) = line_start
2990 {
2991 let end: u32 = child.text_range().end().into();
2992 out.push(unexpected_body_input(ls, end, bom_offset));
2993 }
2994 }
2995}
2996
2997fn emit_error_node_line(
3013 first_non_trivia: Option<crate::SyntaxKind>,
3014 line_start: Option<u32>,
3015 end: u32,
3016 bom_offset: u32,
3017 stripped: &str,
3018 out: &mut Vec<crate::ParseError>,
3019) {
3020 let is_section = matches!(first_non_trivia, Some(crate::SyntaxKind::STAR));
3021 let is_comment = matches!(first_non_trivia, Some(k) if is_comment_kind(k));
3022 if is_section || is_comment || first_non_trivia.is_none() {
3023 return;
3024 }
3025 let Some(ls) = line_start else { return };
3026 let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
3029 let line_text = stripped.get(ls as usize..end as usize).unwrap_or("");
3030 let primary = classify_recovery_error(line_text, span);
3031 let primary_is_bom = matches!(primary.kind, crate::ParseErrorKind::BomInDirectiveBody);
3032 out.push(primary);
3033 if !primary_is_bom && line_text.contains(crate::bom::BOM_CHAR) {
3039 out.push(
3040 crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
3041 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
3042 );
3043 }
3044}
3045
3046fn error_node_check(
3047 child: &crate::SyntaxNode,
3048 stripped: &str,
3049 bom_offset: u32,
3050 out: &mut Vec<crate::ParseError>,
3051) {
3052 {
3054 let mut line_start: Option<u32> = None;
3055 let mut first_non_trivia: Option<crate::SyntaxKind> = None;
3056 for el in child.children_with_tokens() {
3057 let rowan::NodeOrToken::Token(t) = el else {
3058 continue;
3059 };
3060 let range = t.text_range();
3061 let start: u32 = range.start().into();
3062 let end: u32 = range.end().into();
3063 if line_start.is_none() {
3064 line_start = Some(start);
3065 }
3066 if t.kind() == crate::SyntaxKind::NEWLINE {
3067 emit_error_node_line(first_non_trivia, line_start, end, bom_offset, stripped, out);
3068 line_start = None;
3069 first_non_trivia = None;
3070 continue;
3071 }
3072 if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
3073 first_non_trivia = Some(t.kind());
3074 }
3075 }
3076 let end: u32 = child.text_range().end().into();
3081 emit_error_node_line(first_non_trivia, line_start, end, bom_offset, stripped, out);
3082 }
3083}
3084
3085pub(super) fn classify_recovery_error(line_text: &str, span: Span) -> crate::ParseError {
3098 if let Some(account) = crate::diagnostics::find_unicode_account(line_text) {
3099 return crate::ParseError::new(
3100 crate::ParseErrorKind::InvalidAccount(account.to_string()),
3101 span,
3102 );
3103 }
3104 if line_text.contains(crate::bom::BOM_CHAR) {
3105 return crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
3106 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT);
3107 }
3108 crate::ParseError::new(
3109 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
3110 span,
3111 )
3112}
3113
3114pub(super) struct DescendantsWalkResult {
3133 pub(super) inline_errors: Vec<crate::ParseError>,
3134 pub(super) top_level_comments: Vec<Spanned<String>>,
3135 pub(super) currency_occurrences: Vec<Spanned<Currency>>,
3136 pub(super) account_occurrences: Vec<Spanned<rustledger_core::Account>>,
3137 pub(super) cost_brace_errors: Vec<crate::ParseError>,
3152 pub(super) link_meta_errors: Vec<crate::ParseError>,
3153 pub(super) custom_pushmeta_errors: Vec<crate::ParseError>,
3154}
3155
3156fn walk_descendants_once(
3164 source_file: &SourceFile,
3165 bom_offset: u32,
3166 collect_occurrences: bool,
3167) -> DescendantsWalkResult {
3168 let mut inline_errors: Vec<crate::ParseError> = Vec::new();
3169 let mut top_level_comments: Vec<Spanned<String>> = Vec::new();
3170 let mut currency_occurrences: Vec<Spanned<Currency>> = Vec::new();
3171 let mut account_occurrences: Vec<Spanned<rustledger_core::Account>> = Vec::new();
3172
3173 let mut preceded_by_ws = false;
3175
3176 for el in source_file.syntax().descendants_with_tokens() {
3177 let rowan::NodeOrToken::Token(t) = el else {
3178 if let rowan::NodeOrToken::Node(n) = el
3183 && ast::Directive::can_cast(n.kind())
3184 {
3185 preceded_by_ws = false;
3186 }
3187 continue;
3188 };
3189
3190 match t.kind() {
3192 crate::SyntaxKind::NEWLINE => preceded_by_ws = false,
3193 crate::SyntaxKind::WHITESPACE => preceded_by_ws = true,
3194 k if is_comment_kind(k) => {
3195 if !preceded_by_ws {
3196 let range = t.text_range();
3197 let start: u32 = range.start().into();
3198 let end: u32 = range.end().into();
3199 let span =
3200 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3201 top_level_comments.push(Spanned::new(t.text().to_string(), span));
3202 }
3203 }
3204 _ => {
3205 preceded_by_ws = false;
3206 }
3207 }
3208
3209 if t.kind() == crate::SyntaxKind::BOM {
3211 continue;
3212 }
3213 let kind = t.kind();
3221 let has_bom = t.text().contains(crate::bom::BOM_CHAR);
3222 let is_error_token = kind == crate::SyntaxKind::ERROR_TOKEN;
3223 let needs_in_error_check = (collect_occurrences
3226 && matches!(
3227 kind,
3228 crate::SyntaxKind::CURRENCY | crate::SyntaxKind::ACCOUNT
3229 ))
3230 || has_bom
3231 || is_error_token;
3232 if !needs_in_error_check {
3233 continue;
3234 }
3235 let in_error_node = t
3236 .parent_ancestors()
3237 .any(|a| a.kind() == crate::SyntaxKind::ERROR_NODE);
3238
3239 if collect_occurrences && kind == crate::SyntaxKind::CURRENCY && !in_error_node {
3242 let range = t.text_range();
3243 let start: u32 = range.start().into();
3244 let end: u32 = range.end().into();
3245 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3246 currency_occurrences.push(Spanned::new(Currency::new(t.text()), span));
3247 }
3248
3249 if collect_occurrences && kind == crate::SyntaxKind::ACCOUNT && !in_error_node {
3257 let range = t.text_range();
3258 let start: u32 = range.start().into();
3259 let end: u32 = range.end().into();
3260 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3261 account_occurrences.push(Spanned::new(rustledger_core::Account::new(t.text()), span));
3262 }
3263
3264 if (!has_bom && !is_error_token) || in_error_node {
3270 continue;
3271 }
3272 let range = t.text_range();
3273 let start: u32 = range.start().into();
3274 let end: u32 = range.end().into();
3275 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3276 if has_bom {
3277 inline_errors.push(
3278 crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
3279 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
3280 );
3281 } else {
3282 inline_errors.push(crate::ParseError::new(
3283 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
3284 span,
3285 ));
3286 }
3287 }
3288
3289 DescendantsWalkResult {
3290 inline_errors,
3291 top_level_comments,
3292 currency_occurrences,
3293 account_occurrences,
3294 cost_brace_errors: Vec::new(),
3301 link_meta_errors: Vec::new(),
3302 custom_pushmeta_errors: Vec::new(),
3303 }
3304}
3305
3306fn section_marker_check(
3313 child: &crate::SyntaxNode,
3314 bom_offset: u32,
3315 out: &mut Vec<Spanned<String>>,
3316) {
3317 let mut line_start: Option<u32> = None;
3322 let mut first_non_trivia: Option<crate::SyntaxKind> = None;
3323 for el in child.children_with_tokens() {
3324 let rowan::NodeOrToken::Token(t) = el else {
3325 continue;
3326 };
3327 let range = t.text_range();
3328 let start: u32 = range.start().into();
3329 let end: u32 = range.end().into();
3330 if line_start.is_none() {
3331 line_start = Some(start);
3332 }
3333 if t.kind() == crate::SyntaxKind::NEWLINE {
3334 if first_non_trivia == Some(crate::SyntaxKind::STAR)
3335 && let Some(ls) = line_start
3336 {
3337 let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
3338 out.push(Spanned::new(String::new(), span));
3339 }
3340 line_start = None;
3341 first_non_trivia = None;
3342 continue;
3343 }
3344 if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
3345 first_non_trivia = Some(t.kind());
3346 }
3347 }
3348 if first_non_trivia == Some(crate::SyntaxKind::STAR)
3350 && let Some(ls) = line_start
3351 {
3352 let end: u32 = child.text_range().end().into();
3353 let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
3354 out.push(Spanned::new(String::new(), span));
3355 }
3356}
3357
3358pub(super) fn parse_date_token(text: &str) -> Option<NaiveDate> {
3370 if text.len() == 10
3372 && text.as_bytes()[4] == b'-'
3373 && text.as_bytes()[7] == b'-'
3374 && let (Ok(y), Ok(m), Ok(d)) = (
3375 text[0..4].parse::<i32>(),
3376 text[5..7].parse::<u32>(),
3377 text[8..10].parse::<u32>(),
3378 )
3379 {
3380 return naive_date(y, m, d);
3381 }
3382 crate::diagnostics::normalize_date_str(text)
3386 .parse::<NaiveDate>()
3387 .ok()
3388}
3389
3390fn parse_directive_date(
3398 date_tok: &ast::Date,
3399 errors: &mut Vec<crate::ParseError>,
3400 bom_offset: u32,
3401) -> Option<NaiveDate> {
3402 let text = date_tok.text();
3403 if let Some(d) = parse_date_token(text) {
3404 return Some(d);
3405 }
3406 let range = date_tok.syntax().text_range();
3407 let start: u32 = range.start().into();
3408 let end: u32 = range.end().into();
3409 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
3410 errors.push(crate::ParseError::new(
3411 crate::ParseErrorKind::InvalidDateValue(crate::diagnostics::describe_invalid_date(text)),
3412 span,
3413 ));
3414 None
3415}
3416
3417pub(super) fn decode_string_token(text: &str) -> Option<String> {
3423 let bytes = text.as_bytes();
3424 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
3425 return None;
3426 }
3427 let raw = &text[1..text.len() - 1];
3428 if !raw.contains('\\') {
3429 return Some(raw.to_string());
3430 }
3431 let mut out = String::with_capacity(raw.len());
3432 let mut chars = raw.chars();
3433 while let Some(c) = chars.next() {
3434 if c != '\\' {
3435 out.push(c);
3436 continue;
3437 }
3438 match chars.next() {
3439 Some('"') => out.push('"'),
3440 Some('\\') => out.push('\\'),
3441 Some('n') => out.push('\n'),
3442 Some('t') => out.push('\t'),
3443 Some('r') => out.push('\r'),
3444 Some(other) => out.push(other),
3445 None => {}
3446 }
3447 }
3448 Some(out)
3449}
3450
3451pub(super) fn parse_decimal_token(text: &str) -> Option<Decimal> {
3454 use std::str::FromStr;
3455 let cleaned: String;
3456 let s = if text.contains(',') {
3457 cleaned = text.replace(',', "");
3458 cleaned.as_str()
3459 } else {
3460 text
3461 };
3462 Decimal::from_str(s).ok()
3463}
3464
3465pub(super) fn number_meta_value(text: &str, value: Decimal) -> MetaValue {
3474 use rust_decimal::prelude::ToPrimitive;
3475 if !text.contains('.')
3476 && !text.contains('e')
3477 && !text.contains('E')
3478 && let Some(i) = value.to_i64()
3479 {
3480 return MetaValue::Int(i);
3481 }
3482 MetaValue::Number(value)
3483}
3484
3485fn node_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
3491 let range = node.text_range();
3492 let start: u32 = range.start().into();
3493 let end: u32 = range.end().into();
3494 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
3495}
3496
3497pub(super) const fn is_trivia_kind(kind: crate::SyntaxKind) -> bool {
3509 matches!(
3510 kind,
3511 crate::SyntaxKind::WHITESPACE
3512 | crate::SyntaxKind::NEWLINE
3513 | crate::SyntaxKind::COMMENT
3514 | crate::SyntaxKind::PERCENT_COMMENT
3515 | crate::SyntaxKind::SHEBANG
3516 | crate::SyntaxKind::EMACS_DIRECTIVE
3517 )
3518}
3519
3520fn posting_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
3529 let range = node.text_range();
3530 let start: u32 = range.start().into();
3531 let end_raw: u32 = range.end().into();
3532 let end = node
3535 .children_with_tokens()
3536 .filter_map(rowan::NodeOrToken::into_token)
3537 .find(|t| t.kind() == crate::SyntaxKind::NEWLINE)
3538 .map_or(end_raw, |t| u32::from(t.text_range().start()));
3539 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
3540}
3541
3542fn single_line_directive_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
3549 let range = node.text_range();
3550 let start_raw: u32 = range.start().into();
3551 let end_raw: u32 = range.end().into();
3552 let mut content_start: Option<u32> = None;
3553 let mut terminator: Option<u32> = None;
3554 for t in node
3555 .children_with_tokens()
3556 .filter_map(rowan::NodeOrToken::into_token)
3557 {
3558 if content_start.is_none() {
3559 if !is_trivia_kind(t.kind()) {
3560 content_start = Some(u32::from(t.text_range().start()));
3561 }
3562 } else if t.kind() == crate::SyntaxKind::NEWLINE {
3563 terminator = Some(u32::from(t.text_range().start()));
3564 break;
3565 }
3566 }
3567 let start = content_start.unwrap_or(start_raw);
3568 let end = terminator.unwrap_or(end_raw);
3569 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
3570}
3571
3572fn fixup_directive_spans(
3579 source_file: &SourceFile,
3580 bom_offset: u32,
3581 converted_nodes: &[crate::SyntaxNode],
3582 directives: &mut [Spanned<Directive>],
3583) {
3584 debug_assert_eq!(
3585 converted_nodes.len(),
3586 directives.len(),
3587 "converted_nodes and directives must be parallel arrays"
3588 );
3589
3590 let all_starts: Vec<(usize, usize)> = source_file
3598 .syntax()
3599 .children()
3600 .filter(|n| ast::Directive::can_cast(n.kind()))
3601 .map(|n| {
3602 let raw_start: u32 = n.text_range().start().into();
3603 let content_start = n
3604 .descendants_with_tokens()
3605 .filter_map(rowan::NodeOrToken::into_token)
3606 .find(|t| !is_trivia_kind(t.kind()))
3607 .map_or_else(
3608 || (raw_start + bom_offset) as usize,
3609 |t| (u32::from(t.text_range().start()) + bom_offset) as usize,
3610 );
3611 ((raw_start + bom_offset) as usize, content_start)
3612 })
3613 .collect();
3614
3615 debug_assert!(
3620 all_starts.windows(2).all(|w| w[0].0 < w[1].0),
3621 "all_starts must be strictly ascending by raw_start for the binary \
3622 search below; sibling text ranges are disjoint and increasing, so a \
3623 failure here means the enumeration is no longer document-ordered",
3624 );
3625
3626 let source_end: usize =
3627 (u32::from(source_file.syntax().text_range().end()) + bom_offset) as usize;
3628
3629 for (i, spanned) in directives.iter_mut().enumerate() {
3643 let node = &converted_nodes[i];
3644 let raw_start: usize = (u32::from(node.text_range().start()) + bom_offset) as usize;
3645 let node_end: usize = (u32::from(node.text_range().end()) + bom_offset) as usize;
3646 if let Ok(pos) = all_starts.binary_search_by_key(&raw_start, |(rs, _)| *rs) {
3660 let start = all_starts[pos].1;
3661 let end = all_starts
3662 .get(pos + 1)
3663 .map_or(source_end, |(_, content)| *content);
3664 spanned.span = Span::new(start, end);
3665 } else {
3666 let content_start = node
3673 .descendants_with_tokens()
3674 .filter_map(rowan::NodeOrToken::into_token)
3675 .find(|t| !is_trivia_kind(t.kind()))
3676 .map_or(raw_start, |t| {
3677 (u32::from(t.text_range().start()) + bom_offset) as usize
3678 });
3679 spanned.span = Span::new(content_start, node_end);
3680 }
3681 }
3682}
3683
3684#[cfg(test)]
3685mod tests {
3686 use super::*;
3687
3688 #[test]
3698 fn spans_end_at_the_next_input_directive_even_when_it_is_filtered_out() {
3699 let src = "2024-01-01 open Assets:Bank USD\n\
3700 pushtag #trip\n\
3701 2024-01-02 * \"a\"\n Assets:Bank 1 USD\n Assets:Other\n\
3702 poptag #trip\n\
3703 2024-01-03 close Assets:Bank\n";
3704 let parsed = crate::parse(src);
3705
3706 let at = |needle: &str| src.find(needle).expect("fixture contains it");
3707 let spans: Vec<(usize, usize)> = parsed
3708 .directives
3709 .iter()
3710 .map(|d| (d.span.start, d.span.end))
3711 .collect();
3712
3713 assert_eq!(
3714 spans,
3715 vec![
3716 (0, at("pushtag")),
3718 (at("2024-01-02"), at("poptag")),
3720 (at("2024-01-03"), src.len()),
3722 ],
3723 "pushtag/poptag are filtered from `directives` but still bound the \
3724 preceding directive's span",
3725 );
3726 }
3727
3728 fn has_syntax_error(result: &ParseResult, prefix: &str) -> bool {
3733 result.errors.iter().any(
3734 |e| matches!(&e.kind, crate::ParseErrorKind::SyntaxError(m) if m.starts_with(prefix)),
3735 )
3736 }
3737
3738 fn assert_directive_count(result: &ParseResult, expected: usize) {
3739 assert_eq!(
3740 result.directives.len(),
3741 expected,
3742 "directive count mismatch: {:#?}",
3743 result.directives
3744 );
3745 }
3746
3747 #[test]
3748 fn open_directive_basic() {
3749 let src = "2024-01-15 open Assets:Cash\n";
3750 let result = parse_via_cst(src);
3751 assert_directive_count(&result, 1);
3752 let Directive::Open(open) = &result.directives[0].value else {
3753 panic!("expected Open, got {:?}", result.directives[0].value);
3754 };
3755 assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
3756 assert_eq!(open.account.as_str(), "Assets:Cash");
3757 assert!(open.currencies.is_empty());
3758 assert!(open.booking.is_none());
3759 assert!(open.meta.is_empty());
3760 }
3761
3762 #[test]
3763 fn open_directive_with_currencies_and_booking() {
3764 let src = "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n";
3765 let result = parse_via_cst(src);
3766 assert_directive_count(&result, 1);
3767 let Directive::Open(open) = &result.directives[0].value else {
3768 panic!("expected Open");
3769 };
3770 let currencies: Vec<&str> = open.currencies.iter().map(Currency::as_str).collect();
3771 assert_eq!(currencies, vec!["USD", "EUR"]);
3772 assert_eq!(open.booking.as_deref(), Some("STRICT"));
3773 }
3774
3775 #[test]
3776 fn open_directive_with_metadata() {
3777 let src = "2024-01-15 open Assets:Cash\n note: \"main checking\"\n number: 42\n";
3778 let result = parse_via_cst(src);
3779 assert_directive_count(&result, 1);
3780 let Directive::Open(open) = &result.directives[0].value else {
3781 panic!("expected Open");
3782 };
3783 assert_eq!(
3784 open.meta.get("note"),
3785 Some(&MetaValue::String("main checking".to_string()))
3786 );
3787 assert_eq!(
3788 open.meta.get("number"),
3789 Some(&MetaValue::Int(42))
3791 );
3792 }
3793
3794 #[test]
3795 fn close_directive_basic() {
3796 let src = "2024-12-31 close Assets:Cash\n";
3797 let result = parse_via_cst(src);
3798 assert_directive_count(&result, 1);
3799 let Directive::Close(close) = &result.directives[0].value else {
3800 panic!("expected Close, got {:?}", result.directives[0].value);
3801 };
3802 assert_eq!(close.date, naive_date(2024, 12, 31).unwrap());
3803 assert_eq!(close.account.as_str(), "Assets:Cash");
3804 }
3805
3806 #[test]
3807 fn commodity_directive_basic() {
3808 let src = "2024-01-01 commodity HOOL\n";
3809 let result = parse_via_cst(src);
3810 assert_directive_count(&result, 1);
3811 let Directive::Commodity(c) = &result.directives[0].value else {
3812 panic!("expected Commodity");
3813 };
3814 assert_eq!(c.currency.as_str(), "HOOL");
3815 }
3816
3817 #[test]
3818 fn bom_offset_is_included_in_spans() {
3819 let src = "\u{FEFF}2024-01-15 open Assets:Cash\n";
3820 let result = parse_via_cst(src);
3821 assert!(result.has_leading_bom);
3822 let span = result.directives[0].span;
3823 assert_eq!(span.start, 3, "span should include BOM offset");
3824 }
3825
3826 #[test]
3827 fn note_directive_basic() {
3828 let src = "2024-01-15 note Assets:Cash \"deposit received\"\n";
3829 let result = parse_via_cst(src);
3830 assert_directive_count(&result, 1);
3831 let Directive::Note(note) = &result.directives[0].value else {
3832 panic!("expected Note");
3833 };
3834 assert_eq!(note.date, naive_date(2024, 1, 15).unwrap());
3835 assert_eq!(note.account.as_str(), "Assets:Cash");
3836 assert_eq!(note.comment, "deposit received");
3837 }
3838
3839 #[test]
3840 fn document_directive_basic() {
3841 let src = "2024-01-15 document Assets:Cash \"/path/to/file.pdf\"\n";
3842 let result = parse_via_cst(src);
3843 assert_directive_count(&result, 1);
3844 let Directive::Document(d) = &result.directives[0].value else {
3845 panic!("expected Document");
3846 };
3847 assert_eq!(d.account.as_str(), "Assets:Cash");
3848 assert_eq!(d.path, "/path/to/file.pdf");
3849 assert!(d.tags.is_empty());
3851 assert!(d.links.is_empty());
3852 }
3853
3854 #[test]
3855 fn event_directive_basic() {
3856 let src = "2024-01-15 event \"location\" \"Berlin\"\n";
3857 let result = parse_via_cst(src);
3858 assert_directive_count(&result, 1);
3859 let Directive::Event(e) = &result.directives[0].value else {
3860 panic!("expected Event");
3861 };
3862 assert_eq!(e.event_type, "location");
3863 assert_eq!(e.value, "Berlin");
3864 }
3865
3866 #[test]
3867 fn query_directive_basic() {
3868 let src = "2024-01-15 query \"income\" \"SELECT account, sum(position)\"\n";
3869 let result = parse_via_cst(src);
3870 assert_directive_count(&result, 1);
3871 let Directive::Query(q) = &result.directives[0].value else {
3872 panic!("expected Query");
3873 };
3874 assert_eq!(q.name, "income");
3875 assert_eq!(q.query, "SELECT account, sum(position)");
3876 }
3877
3878 #[test]
3879 fn price_directive_basic() {
3880 let src = "2024-01-15 price USD 1.10 EUR\n";
3881 let result = parse_via_cst(src);
3882 assert_directive_count(&result, 1);
3883 let Directive::Price(p) = &result.directives[0].value else {
3884 panic!("expected Price");
3885 };
3886 assert_eq!(p.currency.as_str(), "USD");
3887 assert_eq!(p.amount.number, Decimal::new(110, 2));
3888 assert_eq!(p.amount.currency.as_str(), "EUR");
3889 }
3890
3891 #[test]
3892 fn balance_directive_basic() {
3893 let src = "2024-06-30 balance Assets:Cash 100.00 USD\n";
3894 let result = parse_via_cst(src);
3895 assert_directive_count(&result, 1);
3896 let Directive::Balance(b) = &result.directives[0].value else {
3897 panic!("expected Balance");
3898 };
3899 assert_eq!(b.account.as_str(), "Assets:Cash");
3900 assert_eq!(b.amount.number, Decimal::new(10000, 2));
3901 assert_eq!(b.amount.currency.as_str(), "USD");
3902 assert!(b.tolerance.is_none());
3903 }
3904
3905 #[test]
3906 fn balance_directive_with_explicit_tolerance() {
3907 let src = "2024-06-30 balance Assets:Cash 100.00 ~ 0.05 USD\n";
3908 let result = parse_via_cst(src);
3909 assert_directive_count(&result, 1);
3910 let Directive::Balance(b) = &result.directives[0].value else {
3911 panic!("expected Balance");
3912 };
3913 assert_eq!(b.amount.number, Decimal::new(10000, 2));
3914 assert_eq!(b.tolerance, Some(Decimal::new(5, 2)));
3915 }
3916
3917 #[test]
3918 fn pad_directive_basic() {
3919 let src = "2024-01-01 pad Assets:Cash Equity:Opening-Balances\n";
3920 let result = parse_via_cst(src);
3921 assert_directive_count(&result, 1);
3922 let Directive::Pad(p) = &result.directives[0].value else {
3923 panic!("expected Pad");
3924 };
3925 assert_eq!(p.account.as_str(), "Assets:Cash");
3926 assert_eq!(p.source_account.as_str(), "Equity:Opening-Balances");
3927 }
3928
3929 #[test]
3930 fn custom_directive_basic() {
3931 let src = "2024-01-01 custom \"budget\" \"food\" 500 USD\n";
3932 let result = parse_via_cst(src);
3933 assert_directive_count(&result, 1);
3934 let Directive::Custom(c) = &result.directives[0].value else {
3935 panic!("expected Custom");
3936 };
3937 assert_eq!(c.custom_type, "budget");
3938 assert_eq!(c.values.len(), 2);
3939 assert_eq!(c.values[0], MetaValue::String("food".to_string()));
3940 let MetaValue::Amount(amt) = &c.values[1] else {
3942 panic!("expected Amount, got {:?}", c.values[1]);
3943 };
3944 assert_eq!(amt.number, Decimal::from(500));
3945 assert_eq!(amt.currency.as_str(), "USD");
3946 }
3947
3948 #[test]
3949 fn custom_directive_heterogeneous_values() {
3950 let src = "2024-01-01 custom \"test\" Assets:Cash TRUE 42 2024-06-15\n";
3951 let result = parse_via_cst(src);
3952 let Directive::Custom(c) = &result.directives[0].value else {
3953 panic!("expected Custom");
3954 };
3955 assert_eq!(c.values.len(), 4);
3956 assert!(matches!(c.values[0], MetaValue::Account(_)));
3957 assert_eq!(c.values[1], MetaValue::Bool(true));
3958 assert_eq!(c.values[2], MetaValue::Int(42));
3959 assert!(matches!(c.values[3], MetaValue::Date(_)));
3960 }
3961
3962 #[test]
3963 fn number_meta_value_int_vs_decimal_discriminator() {
3964 use rust_decimal_macros::dec;
3965 assert_eq!(number_meta_value("42", dec!(42)), MetaValue::Int(42));
3968 assert_eq!(number_meta_value("0", dec!(0)), MetaValue::Int(0));
3969 assert_eq!(number_meta_value("1", dec!(-1)), MetaValue::Int(-1));
3970 assert_eq!(
3972 number_meta_value("42.0", dec!(42.0)),
3973 MetaValue::Number(dec!(42.0))
3974 );
3975 assert_eq!(
3979 number_meta_value("1e3", dec!(1000)),
3980 MetaValue::Number(dec!(1000))
3981 );
3982 let huge = "99999999999999999999999999";
3984 let huge_dec = Decimal::from_str_exact(huge).unwrap();
3985 assert_eq!(
3986 number_meta_value(huge, huge_dec),
3987 MetaValue::Number(huge_dec)
3988 );
3989 }
3990
3991 #[test]
3992 fn option_directive_populates_options_field() {
3993 let src = "option \"title\" \"My Ledger\"\n";
3994 let result = parse_via_cst(src);
3995 assert_directive_count(&result, 0);
3996 assert_eq!(result.options.len(), 1);
3997 assert_eq!(result.options[0].0, "title");
3998 assert_eq!(result.options[0].1, "My Ledger");
3999 }
4000
4001 #[test]
4002 fn include_directive_populates_includes_field() {
4003 let src = "include \"shared.beancount\"\n";
4004 let result = parse_via_cst(src);
4005 assert_directive_count(&result, 0);
4006 assert_eq!(result.includes.len(), 1);
4007 assert_eq!(result.includes[0].0, "shared.beancount");
4008 }
4009
4010 #[test]
4011 fn plugin_directive_with_config() {
4012 let src = "plugin \"my.plugin\" \"cfg\"\n";
4013 let result = parse_via_cst(src);
4014 assert_directive_count(&result, 0);
4015 assert_eq!(result.plugins.len(), 1);
4016 assert_eq!(result.plugins[0].0, "my.plugin");
4017 assert_eq!(result.plugins[0].1.as_deref(), Some("cfg"));
4018 }
4019
4020 #[test]
4021 fn plugin_directive_without_config() {
4022 let src = "plugin \"my.plugin\"\n";
4023 let result = parse_via_cst(src);
4024 assert_eq!(result.plugins.len(), 1);
4025 assert_eq!(result.plugins[0].0, "my.plugin");
4026 assert!(result.plugins[0].1.is_none());
4027 }
4028
4029 #[test]
4032 fn transaction_basic_two_postings() {
4033 let src = "2024-01-15 * \"Coffee Shop\" \"Morning coffee\"\n \
4034 Expenses:Food:Coffee 5.00 USD\n \
4035 Assets:Cash\n";
4036 let result = parse_via_cst(src);
4037 assert_directive_count(&result, 1);
4038 let Directive::Transaction(t) = &result.directives[0].value else {
4039 panic!("expected Transaction");
4040 };
4041 assert_eq!(t.date, naive_date(2024, 1, 15).unwrap());
4042 assert_eq!(t.flag, '*');
4043 assert_eq!(
4044 t.payee.as_ref().map(InternedStr::as_str),
4045 Some("Coffee Shop")
4046 );
4047 assert_eq!(t.narration.as_str(), "Morning coffee");
4048 assert_eq!(t.postings.len(), 2);
4049
4050 let p0 = &t.postings[0].value;
4051 assert_eq!(p0.account.as_str(), "Expenses:Food:Coffee");
4052 let Some(IncompleteAmount::Complete(amt)) = &p0.units else {
4053 panic!("expected complete units, got {:?}", p0.units);
4054 };
4055 assert_eq!(amt.number, Decimal::new(500, 2));
4056 assert_eq!(amt.currency.as_str(), "USD");
4057
4058 let p1 = &t.postings[1].value;
4059 assert_eq!(p1.account.as_str(), "Assets:Cash");
4060 assert!(p1.units.is_none(), "auto-posting has no units");
4061 }
4062
4063 #[test]
4064 fn transaction_narration_only_no_payee() {
4065 let src = "2024-01-15 ! \"Pending\"\n Assets:Cash -5 USD\n";
4066 let result = parse_via_cst(src);
4067 let Directive::Transaction(t) = &result.directives[0].value else {
4068 panic!("expected Transaction");
4069 };
4070 assert_eq!(t.flag, '!');
4071 assert!(t.payee.is_none());
4072 assert_eq!(t.narration.as_str(), "Pending");
4073 }
4074
4075 #[test]
4076 fn transaction_three_plus_header_strings_surface_last_as_narration() {
4077 let src = "2024-01-15 * \"a\" \"b\" \"c\"\n Assets:Cash -5 USD\n";
4082 let result = parse_via_cst(src);
4083 let Directive::Transaction(t) = &result.directives[0].value else {
4084 panic!("expected Transaction");
4085 };
4086 assert!(t.payee.is_none(), "3+ strings drop the payee");
4087 assert_eq!(t.narration.as_str(), "c", "last string becomes narration");
4088 }
4089
4090 #[test]
4091 fn transaction_implied_flag_via_leading_string() {
4092 let src = "2024-01-15 \"Implied\"\n Assets:Cash -5 USD\n";
4093 let result = parse_via_cst(src);
4094 let Directive::Transaction(t) = &result.directives[0].value else {
4095 panic!("expected Transaction");
4096 };
4097 assert_eq!(t.flag, '*', "implied flag defaults to *");
4098 }
4099
4100 #[test]
4101 fn transaction_with_tags_and_links() {
4102 let src = "2024-01-15 * \"Coffee\" #daily ^trip1\n Assets:Cash -5 USD\n";
4103 let result = parse_via_cst(src);
4104 let Directive::Transaction(t) = &result.directives[0].value else {
4105 panic!("expected Transaction");
4106 };
4107 assert_eq!(t.tags.len(), 1);
4108 assert_eq!(t.tags[0].as_str(), "daily");
4109 assert_eq!(t.links.len(), 1);
4110 assert_eq!(t.links[0].as_str(), "trip1");
4111 }
4112
4113 #[test]
4114 fn transaction_with_signed_amount() {
4115 let src = "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n";
4116 let result = parse_via_cst(src);
4117 let Directive::Transaction(t) = &result.directives[0].value else {
4118 panic!("expected Transaction");
4119 };
4120 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4121 panic!("expected complete units");
4122 };
4123 assert_eq!(amt.number, Decimal::new(-500, 2));
4124 }
4125
4126 #[test]
4127 fn transaction_with_posting_flag() {
4128 let src = "2024-01-15 * \"x\"\n ! Assets:Cash -5 USD\n";
4129 let result = parse_via_cst(src);
4130 let Directive::Transaction(t) = &result.directives[0].value else {
4131 panic!("expected Transaction");
4132 };
4133 assert_eq!(t.postings[0].value.flag, Some('!'));
4134 }
4135
4136 #[test]
4137 fn transaction_with_cost_spec_per_unit() {
4138 let src = "2024-01-15 * \"buy\"\n \
4139 Assets:Inv 10 HOOL {500.00 USD}\n \
4140 Assets:Cash\n";
4141 let result = parse_via_cst(src);
4142 let Directive::Transaction(t) = &result.directives[0].value else {
4143 panic!("expected Transaction");
4144 };
4145 let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
4146 assert!(!cost.merge);
4147 let Some(CostNumber::PerUnit { value }) = &cost.number else {
4148 panic!("expected PerUnit");
4149 };
4150 assert_eq!(*value, Decimal::new(50000, 2));
4151 assert_eq!(cost.currency.as_ref().unwrap().as_str(), "USD");
4152 }
4153
4154 #[test]
4155 fn transaction_with_cost_spec_total() {
4156 let src = "2024-01-15 * \"buy\"\n \
4157 Assets:Inv 10 HOOL {{5000 USD}}\n \
4158 Assets:Cash\n";
4159 let result = parse_via_cst(src);
4160 let Directive::Transaction(t) = &result.directives[0].value else {
4161 panic!("expected Transaction");
4162 };
4163 let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
4164 let Some(CostNumber::Total { value }) = &cost.number else {
4165 panic!("expected Total");
4166 };
4167 assert_eq!(*value, Decimal::from(5000));
4168 }
4169
4170 #[test]
4171 fn transaction_with_price_annotation_unit() {
4172 let src = "2024-01-15 * \"buy\"\n \
4173 Assets:Inv 10 HOOL @ 510 USD\n \
4174 Assets:Cash\n";
4175 let result = parse_via_cst(src);
4176 let Directive::Transaction(t) = &result.directives[0].value else {
4177 panic!("expected Transaction");
4178 };
4179 let price = t.postings[0]
4180 .value
4181 .price
4182 .as_ref()
4183 .expect("price annotation");
4184 assert!(price.is_unit());
4185 let Some(IncompleteAmount::Complete(amt)) = &price.amount else {
4186 panic!("expected complete price amount");
4187 };
4188 assert_eq!(amt.number, Decimal::from(510));
4189 assert_eq!(amt.currency.as_str(), "USD");
4190 }
4191
4192 #[test]
4193 fn transaction_with_price_annotation_total() {
4194 let src = "2024-01-15 * \"buy\"\n \
4195 Assets:Inv 10 HOOL @@ 5100 USD\n \
4196 Assets:Cash\n";
4197 let result = parse_via_cst(src);
4198 let Directive::Transaction(t) = &result.directives[0].value else {
4199 panic!("expected Transaction");
4200 };
4201 let price = t.postings[0]
4202 .value
4203 .price
4204 .as_ref()
4205 .expect("price annotation");
4206 assert!(!price.is_unit(), "@@ is total form");
4207 }
4208
4209 #[test]
4212 fn document_directive_preserves_tags_and_links() {
4213 let src = "2024-06-01 document Assets:Bank \"stmt.pdf\" #quarter1 ^scan42 #urgent\n";
4217 let result = parse_via_cst(src);
4218 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4219 let Directive::Document(doc) = &result.directives[0].value else {
4220 panic!("expected Document");
4221 };
4222 let tags: Vec<&str> = doc.tags.iter().map(Tag::as_str).collect();
4223 let links: Vec<&str> = doc.links.iter().map(Link::as_str).collect();
4224 assert_eq!(tags, vec!["quarter1", "urgent"]);
4225 assert_eq!(links, vec!["scan42"]);
4226 }
4227
4228 #[test]
4229 fn open_directive_rejects_invalid_booking_method() {
4230 let src = "2024-01-01 open Assets:Bank USD \"GARBAGE\"\n";
4235 let result = parse_via_cst(src);
4236 assert_eq!(result.directives.len(), 0, "directive should be dropped");
4237 assert_eq!(result.errors.len(), 1);
4238 let err = &result.errors[0];
4239 assert!(
4240 matches!(
4241 &err.kind,
4242 crate::ParseErrorKind::InvalidBookingMethod(s) if s == "GARBAGE"
4243 ),
4244 "expected InvalidBookingMethod, got {:?}",
4245 err.kind,
4246 );
4247 }
4248
4249 #[test]
4250 fn open_directive_accepts_all_valid_booking_methods() {
4251 for method in VALID_BOOKING_METHODS {
4252 let src = format!("2024-01-01 open Assets:Bank USD \"{method}\"\n");
4253 let result = parse_via_cst(&src);
4254 assert!(
4255 result.errors.is_empty(),
4256 "{method} rejected: {:?}",
4257 result.errors
4258 );
4259 let Directive::Open(open) = &result.directives[0].value else {
4260 panic!("{method}: expected Open");
4261 };
4262 assert_eq!(open.booking.as_deref(), Some(*method));
4263 }
4264 }
4265
4266 #[test]
4267 fn unclosed_pushtag_at_eof_emits_diagnostic() {
4268 let src = "pushtag #active\n2024-01-01 open Assets:Bank USD\n";
4271 let result = parse_via_cst(src);
4272 let unclosed: Vec<_> = result
4273 .errors
4274 .iter()
4275 .filter_map(|e| match &e.kind {
4276 crate::ParseErrorKind::UnclosedPushtag(t) => Some(t.clone()),
4277 _ => None,
4278 })
4279 .collect();
4280 assert_eq!(unclosed, vec!["active".to_string()]);
4281 }
4282
4283 #[test]
4284 fn unclosed_pushmeta_at_eof_emits_diagnostic() {
4285 let src = "pushmeta location: \"NYC\"\n2024-01-01 open Assets:Bank USD\n";
4287 let result = parse_via_cst(src);
4288 let unclosed: Vec<_> = result
4289 .errors
4290 .iter()
4291 .filter_map(|e| match &e.kind {
4292 crate::ParseErrorKind::UnclosedPushmeta(k) => Some(k.clone()),
4293 _ => None,
4294 })
4295 .collect();
4296 assert_eq!(unclosed, vec!["location".to_string()]);
4297 }
4298
4299 #[test]
4300 fn invalid_poptag_on_mismatch_emits_diagnostic() {
4301 let src = "pushtag #foo\npoptag #bar\npoptag #foo\n";
4304 let result = parse_via_cst(src);
4305 let mismatches: Vec<_> = result
4306 .errors
4307 .iter()
4308 .filter_map(|e| match &e.kind {
4309 crate::ParseErrorKind::InvalidPoptag(t) => Some(t.clone()),
4310 _ => None,
4311 })
4312 .collect();
4313 assert_eq!(mismatches, vec!["bar".to_string()]);
4314 let leftover: Vec<_> = result
4317 .errors
4318 .iter()
4319 .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushtag(_)))
4320 .collect();
4321 assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
4322 }
4323
4324 #[test]
4325 fn invalid_popmeta_on_mismatch_emits_diagnostic() {
4326 let src = "pushmeta location: \"NYC\"\npopmeta nope:\npopmeta location:\n";
4330 let result = parse_via_cst(src);
4331 let mismatches: Vec<_> = result
4332 .errors
4333 .iter()
4334 .filter_map(|e| match &e.kind {
4335 crate::ParseErrorKind::InvalidPopmeta(k) => Some(k.clone()),
4336 _ => None,
4337 })
4338 .collect();
4339 assert_eq!(mismatches, vec!["nope".to_string()]);
4340 let leftover: Vec<_> = result
4341 .errors
4342 .iter()
4343 .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushmeta(_)))
4344 .collect();
4345 assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
4346 }
4347
4348 #[test]
4349 fn pushmeta_shadow_pop_restores_prior_value() {
4350 let src = "pushmeta loc: \"NYC\"\n\
4353 pushmeta loc: \"LDN\"\n\
4354 popmeta loc:\n\
4355 2024-01-01 open Assets:Bank USD\n\
4356 popmeta loc:\n";
4357 let result = parse_via_cst(src);
4358 let Directive::Open(open) = &result.directives[0].value else {
4359 panic!("expected Open");
4360 };
4361 assert_eq!(
4362 open.meta.get("loc"),
4363 Some(&MetaValue::String("NYC".to_string())),
4364 "shadow pop should restore NYC, got {:?}",
4365 open.meta.get("loc"),
4366 );
4367 }
4368
4369 #[test]
4370 fn error_recovery_classifies_bom_in_directive_body() {
4371 let src = "garbage\u{FEFF}content\n";
4375 let result = parse_via_cst(src);
4376 let bom_errors: Vec<_> = result
4377 .errors
4378 .iter()
4379 .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
4380 .collect();
4381 assert_eq!(bom_errors.len(), 1, "errors: {:?}", result.errors);
4382 assert!(
4383 bom_errors[0].hint.is_some(),
4384 "BomInDirectiveBody should carry BOM_REMOVAL_HINT",
4385 );
4386 }
4387
4388 #[test]
4389 fn error_recovery_emits_both_invalid_account_and_bom_for_dual_line() {
4390 let src = "garbage Assets:Café\u{FEFF}content\n";
4397 let result = parse_via_cst(src);
4398 let invalid_account_count = result
4399 .errors
4400 .iter()
4401 .filter(|e| matches!(e.kind, crate::ParseErrorKind::InvalidAccount(_)))
4402 .count();
4403 let bom_count = result
4404 .errors
4405 .iter()
4406 .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
4407 .count();
4408 assert_eq!(
4409 invalid_account_count, 1,
4410 "expected one InvalidAccount: {:?}",
4411 result.errors
4412 );
4413 assert_eq!(
4414 bom_count, 1,
4415 "expected secondary BomInDirectiveBody: {:?}",
4416 result.errors
4417 );
4418 let bom_err = result
4421 .errors
4422 .iter()
4423 .find(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
4424 .unwrap();
4425 assert!(bom_err.hint.is_some());
4426 }
4427
4428 #[test]
4429 fn error_recovery_classifies_unicode_account() {
4430 let src = "garbage Assets:Café content\n";
4435 let result = parse_via_cst(src);
4436 let unicode_errors: Vec<_> = result
4437 .errors
4438 .iter()
4439 .filter_map(|e| match &e.kind {
4440 crate::ParseErrorKind::InvalidAccount(s) => Some(s.clone()),
4441 _ => None,
4442 })
4443 .collect();
4444 assert_eq!(unicode_errors, vec!["Assets:Café".to_string()]);
4445 }
4446
4447 #[test]
4448 fn transaction_with_pipe_emits_deprecated_pipe_symbol() {
4449 let src = "2024-01-15 * \"Acme\" | \"invoice\"\n Assets:Cash -5 USD\n Expenses:X\n";
4452 let result = parse_via_cst(src);
4453 let pipe_count = result
4454 .errors
4455 .iter()
4456 .filter(|e| matches!(e.kind, crate::ParseErrorKind::DeprecatedPipeSymbol))
4457 .count();
4458 assert_eq!(pipe_count, 1, "errors: {:?}", result.errors);
4459 assert_eq!(result.directives.len(), 1);
4461 }
4462
4463 #[test]
4464 fn transaction_trailing_comments_after_final_posting() {
4465 let src = "2024-01-15 * \"x\"\n \
4469 Assets:Cash -5 USD\n \
4470 Expenses:X\n \
4471 ; trailing one\n \
4472 ; trailing two\n";
4473 let result = parse_via_cst(src);
4474 let Directive::Transaction(t) = &result.directives[0].value else {
4475 panic!("expected Transaction");
4476 };
4477 assert_eq!(
4478 t.trailing_comments.len(),
4479 2,
4480 "got: {:?}",
4481 t.trailing_comments
4482 );
4483 assert!(t.trailing_comments[0].contains("trailing one"));
4484 assert!(t.trailing_comments[1].contains("trailing two"));
4485 }
4486
4487 #[test]
4490 fn posting_amount_evaluates_division() {
4491 let src = "2024-01-15 * \"split\"\n \
4496 Expenses:Food 120 / 3 USD\n \
4497 Assets:Bank -40 USD\n";
4498 let result = parse_via_cst(src);
4499 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4500 let Directive::Transaction(t) = &result.directives[0].value else {
4501 panic!("expected Transaction");
4502 };
4503 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4504 panic!("expected complete amount on posting 0");
4505 };
4506 assert_eq!(amt.number, Decimal::from(40));
4507 assert_eq!(amt.currency.as_str(), "USD");
4508 }
4509
4510 #[test]
4511 fn posting_amount_evaluates_addition_and_multiplication_precedence() {
4512 let src = "2024-01-15 * \"x\"\n \
4514 Expenses:X 2 + 3 * 4 USD\n \
4515 Assets:Y -14 USD\n";
4516 let result = parse_via_cst(src);
4517 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4518 let Directive::Transaction(t) = &result.directives[0].value else {
4519 panic!("expected Transaction");
4520 };
4521 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4522 panic!("expected complete amount");
4523 };
4524 assert_eq!(amt.number, Decimal::from(14));
4525 }
4526
4527 #[test]
4528 fn posting_amount_evaluates_parens_override_precedence() {
4529 let src = "2024-01-15 * \"x\"\n \
4531 Expenses:X (2 + 3) * 4 USD\n \
4532 Assets:Y -20 USD\n";
4533 let result = parse_via_cst(src);
4534 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4535 let Directive::Transaction(t) = &result.directives[0].value else {
4536 panic!("expected Transaction");
4537 };
4538 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4539 panic!("expected complete amount");
4540 };
4541 assert_eq!(amt.number, Decimal::from(20));
4542 }
4543
4544 #[test]
4545 fn posting_amount_evaluates_subtraction_left_associative() {
4546 let src = "2024-01-15 * \"x\"\n \
4548 Expenses:X 10 - 3 - 2 USD\n \
4549 Assets:Y -5 USD\n";
4550 let result = parse_via_cst(src);
4551 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4552 let Directive::Transaction(t) = &result.directives[0].value else {
4553 panic!("expected Transaction");
4554 };
4555 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4556 panic!("expected complete amount");
4557 };
4558 assert_eq!(amt.number, Decimal::from(5));
4559 }
4560
4561 #[test]
4562 fn posting_amount_division_by_zero_drops_number() {
4563 let src = "2024-01-15 * \"x\"\n \
4568 Expenses:X 5 / 0 USD\n \
4569 Assets:Y\n";
4570 let result = parse_via_cst(src);
4571 let Directive::Transaction(t) = &result.directives[0].value else {
4572 panic!("expected Transaction");
4573 };
4574 match &t.postings[0].value.units {
4579 None | Some(IncompleteAmount::CurrencyOnly(_)) => {}
4580 other => panic!("div-by-zero leaked: {other:?}"),
4581 }
4582 }
4583
4584 #[test]
4587 fn indented_top_level_directive_emits_error() {
4588 let src = "2020-07-28 open Assets:Foo\n 2020-07-28 open Assets:Bar\n";
4593 let result = parse_via_cst(src);
4594 let indent_errs = result
4595 .errors
4596 .iter()
4597 .filter(|e| match &e.kind {
4598 crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
4599 _ => false,
4600 })
4601 .count();
4602 assert_eq!(
4603 indent_errs, 1,
4604 "expected one column-0 diagnostic, got: {:?}",
4605 result.errors
4606 );
4607 }
4608
4609 #[test]
4610 fn indented_directive_after_blank_line_still_emits_error() {
4611 let src = "2020-07-28 open Assets:Foo\n\n 2020-07-28 open Assets:Bar\n";
4615 let result = parse_via_cst(src);
4616 let indent_errs = result
4617 .errors
4618 .iter()
4619 .filter(|e| match &e.kind {
4620 crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
4621 _ => false,
4622 })
4623 .count();
4624 assert_eq!(indent_errs, 1, "errors: {:?}", result.errors);
4625 }
4626
4627 #[test]
4628 fn top_level_directive_at_column_0_no_diagnostic() {
4629 let src = "2020-07-28 open Assets:Foo\n2020-07-28 open Assets:Bar\n";
4632 let result = parse_via_cst(src);
4633 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4634 }
4635
4636 #[test]
4637 fn custom_directive_with_bare_currency_emits_error() {
4638 let src = "2025-01-01 custom \"x\" 10 USD \"y\" NZD\n";
4641 let result = parse_via_cst(src);
4642 let bare_curr_errs = result
4643 .errors
4644 .iter()
4645 .filter(|e| match &e.kind {
4646 crate::ParseErrorKind::SyntaxError(s) => s.contains("bare currency"),
4647 _ => false,
4648 })
4649 .count();
4650 assert_eq!(
4651 bare_curr_errs, 1,
4652 "expected one bare-currency diagnostic, got: {:?}",
4653 result.errors
4654 );
4655 }
4656
4657 #[test]
4658 fn custom_directive_with_amount_no_error() {
4659 let src = "2025-01-01 custom \"x\" 10 USD\n";
4663 let result = parse_via_cst(src);
4664 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4665 }
4666
4667 #[test]
4670 fn balance_assertion_evaluates_arithmetic_value() {
4671 let src = "2024-01-01 open Assets:X GBP\n\
4677 2024-01-01 open Equity:Open GBP\n\
4678 2024-01-02 * \"deposit\"\n \
4679 Assets:X 1.00 GBP\n \
4680 Equity:Open -1.00 GBP\n\
4681 2024-01-03 balance Assets:X 0.25 + 0.75 GBP\n";
4682 let result = parse_via_cst(src);
4683 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4684 let bal = result
4685 .directives
4686 .iter()
4687 .find_map(|d| match &d.value {
4688 Directive::Balance(b) => Some(b),
4689 _ => None,
4690 })
4691 .expect("expected a Balance directive");
4692 assert_eq!(bal.amount.number, Decimal::from(1));
4693 assert_eq!(bal.amount.currency.as_str(), "GBP");
4694 }
4695
4696 #[test]
4697 fn price_directive_evaluates_arithmetic_value() {
4698 let src = "2024-01-01 price USD 1/2 EUR\n";
4699 let result = parse_via_cst(src);
4700 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4701 let Directive::Price(p) = &result.directives[0].value else {
4702 panic!("expected Price");
4703 };
4704 assert_eq!(p.amount.number, Decimal::new(5, 1));
4705 assert_eq!(p.amount.currency.as_str(), "EUR");
4706 }
4707
4708 #[test]
4711 fn body_line_tag_does_not_drop_following_postings_comment() {
4712 let src = "2024-01-01 * \"x\"\n \
4719 Assets:A 100 USD\n \
4720 ; comment-for-B\n \
4721 #late-tag\n \
4722 Assets:B -100 USD\n";
4723 let result = parse_via_cst(src);
4724 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4725 let Directive::Transaction(t) = &result.directives[0].value else {
4726 panic!("expected Transaction");
4727 };
4728 assert!(
4730 t.tags.iter().any(|tag| tag.as_str() == "late-tag"),
4731 "expected #late-tag in tags: {:?}",
4732 t.tags,
4733 );
4734 let b = t.postings.last().expect("at least one posting");
4736 assert_eq!(b.value.account.as_str(), "Assets:B");
4737 assert!(
4738 b.value.comments.iter().any(|c| c.contains("comment-for-B")),
4739 "expected comment-for-B to survive on Assets:B: {:?}",
4740 b.value.comments,
4741 );
4742 }
4743
4744 #[test]
4745 fn oversized_number_in_amount_emits_diagnostic() {
4746 let huge = "1".to_string() + &"2345678901234567890".repeat(2); let src = format!("2024-01-15 * \"big\"\n Expenses:X {huge} USD\n Assets:Y\n");
4753 let result = parse_via_cst(&src);
4754 let invalid_num = result
4755 .errors
4756 .iter()
4757 .filter(|e| match &e.kind {
4758 crate::ParseErrorKind::SyntaxError(s) => s.contains("invalid number"),
4759 _ => false,
4760 })
4761 .count();
4762 assert_eq!(
4763 invalid_num, 1,
4764 "expected one invalid-number diagnostic, got: {:?}",
4765 result.errors
4766 );
4767 }
4768
4769 #[test]
4772 fn posting_with_two_amount_siblings_emits_error_and_keeps_first() {
4773 let src = "2024-01-15 * \"ambig\"\n \
4780 Expenses:Food 5 USD + 3 USD\n \
4781 Assets:Bank\n";
4782 let result = parse_via_cst(src);
4783 let trailing_count = result
4784 .errors
4785 .iter()
4786 .filter(|e| match &e.kind {
4787 crate::ParseErrorKind::SyntaxError(s) => s.contains("trailing tokens"),
4788 _ => false,
4789 })
4790 .count();
4791 assert_eq!(
4792 trailing_count, 1,
4793 "expected one trailing-tokens diagnostic, got: {:?}",
4794 result.errors
4795 );
4796 let Directive::Transaction(t) = &result.directives[0].value else {
4799 panic!("expected Transaction");
4800 };
4801 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
4802 panic!("expected complete units from the first AMOUNT");
4803 };
4804 assert_eq!(amt.number, Decimal::from(5));
4805 }
4806
4807 #[test]
4808 fn comments_dont_leak_across_failed_posting() {
4809 let src = "2024-01-15 * \"test\"\n \
4816 Assets:A 100 USD\n \
4817 ; comment-for-bad\n \
4818 ; another-comment\n \
4819 bogus_token_line_no_account\n \
4820 ; comment-for-good\n \
4821 Assets:B -100 USD\n";
4822 let result = parse_via_cst(src);
4823 let Directive::Transaction(t) = &result.directives[0].value else {
4824 panic!("expected Transaction");
4825 };
4826 let b = t.postings.last().expect("at least one posting");
4832 assert_eq!(b.value.account.as_str(), "Assets:B");
4833 assert!(
4834 !b.value
4835 .comments
4836 .iter()
4837 .any(|c| c.contains("comment-for-bad")),
4838 "comment-for-bad leaked across failed posting onto Assets:B: {:?}",
4839 b.value.comments
4840 );
4841 assert!(
4842 !b.value
4843 .comments
4844 .iter()
4845 .any(|c| c.contains("another-comment")),
4846 "another-comment leaked: {:?}",
4847 b.value.comments
4848 );
4849 }
4850
4851 #[test]
4852 fn arithmetic_overflow_in_amount_emits_diagnostic() {
4853 let huge = "9999999999999999999999999999 * 9999999999999999999999999999";
4861 let src = format!("2024-01-15 * \"big\"\n Expenses:X {huge} USD\n Assets:Y\n");
4862 let result = parse_via_cst(&src);
4863 let arith_errs = result
4864 .errors
4865 .iter()
4866 .filter(|e| match &e.kind {
4867 crate::ParseErrorKind::SyntaxError(s) => s.contains("arithmetic"),
4868 _ => false,
4869 })
4870 .count();
4871 assert_eq!(
4872 arith_errs, 1,
4873 "expected one arithmetic-error diagnostic, got: {:?}",
4874 result.errors
4875 );
4876 }
4877
4878 #[test]
4881 fn date_with_single_digit_month_parses() {
4882 let result = parse_via_cst("2024-1-15 open Assets:Checking\n");
4883 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4884 let Directive::Open(open) = &result.directives[0].value else {
4885 panic!("expected Open");
4886 };
4887 assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
4888 }
4889
4890 #[test]
4891 fn date_with_single_digit_day_parses() {
4892 let result = parse_via_cst("2024-01-5 open Assets:Cash USD\n");
4893 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4894 let Directive::Open(open) = &result.directives[0].value else {
4895 panic!("expected Open");
4896 };
4897 assert_eq!(open.date, naive_date(2024, 1, 5).unwrap());
4898 }
4899
4900 #[test]
4901 fn date_with_single_digit_month_and_day_parses() {
4902 let result = parse_via_cst("2024-1-1 open Assets:Cash USD\n");
4903 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
4904 let Directive::Open(open) = &result.directives[0].value else {
4905 panic!("expected Open");
4906 };
4907 assert_eq!(open.date, naive_date(2024, 1, 1).unwrap());
4908 }
4909
4910 #[test]
4911 fn date_with_month_out_of_range_emits_invalid_date_value() {
4912 let result = parse_via_cst("2024-13-01 open Assets:Cash USD\n");
4913 let invalid_date: Vec<_> = result
4914 .errors
4915 .iter()
4916 .filter_map(|e| match &e.kind {
4917 crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
4918 _ => None,
4919 })
4920 .collect();
4921 assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
4922 let msg = &invalid_date[0];
4923 assert!(
4924 msg.contains("month") && msg.contains("out of range"),
4925 "msg: {msg}"
4926 );
4927 }
4928
4929 #[test]
4930 fn date_with_invalid_leap_year_emits_invalid_date_value() {
4931 let result = parse_via_cst("2023-02-29 open Assets:Cash USD\n");
4932 let invalid_date: Vec<_> = result
4933 .errors
4934 .iter()
4935 .filter_map(|e| match &e.kind {
4936 crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
4937 _ => None,
4938 })
4939 .collect();
4940 assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
4941 let msg = &invalid_date[0];
4942 assert!(
4943 msg.contains("day") && msg.contains("out of range") && msg.contains("2023-02"),
4944 "msg: {msg}"
4945 );
4946 }
4947
4948 #[test]
4949 fn date_with_completely_invalid_value_still_emits_error() {
4950 let result = parse_via_cst("2024-13-45 open Assets:Bank\n");
4954 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4955 }
4956
4957 #[test]
4958 fn open_directive_without_account_emits_error() {
4959 let result = parse_via_cst("2024-01-01 open\n");
4964 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4965 }
4966
4967 #[test]
4968 fn open_directive_with_lowercase_account_emits_error() {
4969 let result = parse_via_cst("2024-01-01 open lowercase:invalid\n");
4974 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4975 }
4976
4977 #[test]
4978 fn incomplete_open_at_eof_emits_error() {
4979 let result = parse_via_cst("2024-01-01 open");
4983 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4984 }
4985
4986 #[test]
4987 fn balance_directive_without_amount_emits_error() {
4988 let result = parse_via_cst("2024-01-15 balance Assets:Checking\n");
4989 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4990 }
4991
4992 #[test]
4993 fn pad_directive_without_source_account_emits_error() {
4994 let result = parse_via_cst("2024-01-15 pad Assets:Checking\n");
4995 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4996 }
4997
4998 #[test]
4999 fn cost_spec_n_hash_t_parses_as_compound() {
5000 use rust_decimal_macros::dec;
5001 let src = "2024-01-01 open Assets:Stock\n\
5006 2024-01-01 open Assets:Cash USD\n\
5007 2024-01-15 *\n \
5008 Assets:Stock 10 STK {50 # 1500 USD}\n \
5009 Assets:Cash -1500.00 USD\n";
5010 let result = parse_via_cst(src);
5011 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
5012 let Directive::Transaction(txn) = &result.directives[2].value else {
5013 panic!("expected Transaction at index 2");
5014 };
5015 let cost = txn.postings[0]
5016 .value
5017 .cost
5018 .as_ref()
5019 .expect("cost spec present");
5020 assert_eq!(
5021 cost.number,
5022 Some(CostNumber::Compound {
5023 per_unit: dec!(50),
5024 total: dec!(1500)
5025 }),
5026 "the `{{N # T CCY}}` form must carry both components as written"
5027 );
5028 }
5029
5030 #[test]
5031 fn unclosed_cost_brace_emits_error() {
5032 let src = "2024-01-01 open Assets:Stock\n\
5033 2024-01-01 open Assets:Cash USD\n\
5034 2024-01-15 *\n \
5035 Assets:Stock 10 AAPL {150 USD\n \
5036 Assets:Cash -1500 USD\n";
5037 let result = parse_via_cst(src);
5038 let has_unclosed: bool = result
5039 .errors
5040 .iter()
5041 .any(|e| e.message().contains("unclosed cost"));
5042 assert!(
5043 has_unclosed,
5044 "expected 'unclosed cost' error, got: {:?}",
5045 result.errors
5046 );
5047 }
5048
5049 #[test]
5050 fn unclosed_cost_brace_at_eof_emits_error() {
5051 let src = "2024-01-01 open Assets:Stock\n\
5052 2024-01-01 open Assets:Cash USD\n\
5053 2024-01-15 *\n \
5054 Assets:Stock 10 AAPL {150 USD";
5055 let result = parse_via_cst(src);
5056 let has_unclosed: bool = result
5057 .errors
5058 .iter()
5059 .any(|e| e.message().contains("unclosed cost"));
5060 assert!(
5061 has_unclosed,
5062 "expected 'unclosed cost' error at EOF, got: {:?}",
5063 result.errors
5064 );
5065 }
5066
5067 #[test]
5068 fn leading_decimal_in_posting_amount_emits_error() {
5069 let src = "2024-01-15 * \"Test\"\n \
5073 Expenses:Food .50 USD\n \
5074 Assets:Checking\n";
5075 let result = parse_via_cst(src);
5076 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
5077 }
5078
5079 #[test]
5080 fn transaction_with_metadata_on_directive_and_posting() {
5081 let src = "2024-01-15 * \"x\"\n \
5082 tag1: \"hello\"\n \
5083 Assets:Cash -5 USD\n \
5084 receipt: \"abc123\"\n";
5085 let result = parse_via_cst(src);
5086 let Directive::Transaction(t) = &result.directives[0].value else {
5087 panic!("expected Transaction");
5088 };
5089 assert_eq!(
5090 t.meta.get("tag1"),
5091 Some(&MetaValue::String("hello".to_string()))
5092 );
5093 let p_meta = &t.postings[0].value.meta;
5094 assert_eq!(
5095 p_meta.get("receipt"),
5096 Some(&MetaValue::String("abc123".to_string()))
5097 );
5098 }
5099
5100 #[test]
5115 fn account_occurrences_policy_for_failing_directives() {
5116 let src = "2024-01-01 open Assets:Bank \"GARBAGE\"\n";
5120 let r = parse_via_cst(src);
5121 assert!(
5122 r.account_occurrences
5123 .iter()
5124 .any(|o| o.value == "Assets:Bank"),
5125 "typed-conversion failure should keep the ACCOUNT token in \
5126 account_occurrences (got {:?}); rename mid-edit relies on this",
5127 r.account_occurrences,
5128 );
5129
5130 let src = "2024-01-01 opn Assets:Bank USD\n";
5135 let r = parse_via_cst(src);
5136 assert!(
5137 !r.account_occurrences
5138 .iter()
5139 .any(|o| o.value == "Assets:Bank"),
5140 "ERROR_NODE-wrapped ACCOUNT should be EXCLUDED from \
5141 account_occurrences (got {:?}); rename should not hit garbled \
5142 mid-edit syntax",
5143 r.account_occurrences,
5144 );
5145 }
5146
5147 fn cost_of(src: &str) -> CostSpec {
5157 let result = parse_via_cst(src);
5158 let Some(Directive::Transaction(txn)) = result.directives.first().map(|d| &d.value) else {
5159 panic!("expected a transaction from {src:?}");
5160 };
5161 txn.postings
5162 .first()
5163 .and_then(|p| p.cost.as_deref().cloned())
5164 .unwrap_or_else(|| panic!("expected a cost spec from {src:?}"))
5165 }
5166
5167 fn posting_with_cost(spec: &str) -> String {
5168 format!("2020-01-01 * \"t\"\n Assets:A 1 HOOL {spec}\n Assets:B\n")
5169 }
5170
5171 #[test]
5175 fn cost_spec_latches_the_first_date_label_and_currency() {
5176 let cost = cost_of(&posting_with_cost(
5177 "{2 USD, 2020-06-01, 2021-02-02, \"first\", \"second\"}",
5178 ));
5179 assert_eq!(cost.date, naive_date(2020, 6, 1), "the FIRST date wins");
5180 assert_eq!(cost.label.as_deref(), Some("first"), "the FIRST label wins");
5181 assert_eq!(
5182 cost.currency
5183 .as_ref()
5184 .map(rustledger_core::Currency::as_str),
5185 Some("USD"),
5186 "the FIRST currency wins"
5187 );
5188
5189 let cost = cost_of(&posting_with_cost("{2 USD, EUR}"));
5191 assert_eq!(
5192 cost.currency
5193 .as_ref()
5194 .map(rustledger_core::Currency::as_str),
5195 Some("USD")
5196 );
5197 }
5198
5199 #[test]
5206 fn cost_spec_latch_closes_on_an_unparsable_first_token() {
5207 let cost = cost_of(&posting_with_cost("{2 USD, 9999-99-99, 2021-02-02}"));
5208 assert_eq!(
5209 cost.date, None,
5210 "an unparsable first DATE must not let a later one through"
5211 );
5212 }
5213
5214 #[test]
5217 fn cost_spec_merge_flag_is_decided_by_the_first_token_after_an_opener() {
5218 for (spec, expected, why) in [
5219 ("{*}", true, "bare star directly after the opener"),
5220 (
5221 "{ * }",
5222 true,
5223 "whitespace never decides, so the star still does",
5224 ),
5225 ("{{*}}", true, "`{{` is an opener too"),
5226 (
5227 "{2 USD, *}",
5228 false,
5229 "the number decided it first; a later star cannot re-arm",
5230 ),
5231 (
5232 "{500 * 2 USD}",
5233 false,
5234 "a star past the first token is multiplication",
5235 ),
5236 ] {
5237 assert_eq!(
5238 cost_of(&posting_with_cost(spec)).merge,
5239 expected,
5240 "{spec}: {why}"
5241 );
5242 }
5243 }
5244
5245 #[test]
5249 fn ast_is_merge_agrees_with_the_converted_cost_spec() {
5250 for spec in [
5251 "{*}",
5252 "{ * }",
5253 "{{*}}",
5254 "{2 USD, *}",
5255 "{500 * 2 USD}",
5256 "{2 USD}",
5257 ] {
5258 let src = posting_with_cost(spec);
5259 let converted = cost_of(&src).merge;
5260
5261 let parsed = crate::parse(&src);
5262 let root = ast::SourceFile::cast(parsed.syntax_node()).expect("source file");
5263 let from_ast = root
5264 .syntax()
5265 .descendants()
5266 .find_map(ast::CostSpec::cast)
5267 .map_or_else(|| panic!("no CostSpec node in {src:?}"), |cs| cs.is_merge());
5268
5269 assert_eq!(
5270 from_ast, converted,
5271 "{spec}: ast::CostSpec::is_merge disagrees with the converted CostSpec"
5272 );
5273 }
5274 }
5275
5276 #[test]
5282 fn merge_flag_ignores_tokens_before_the_opener() {
5283 use crate::SyntaxKind as K;
5284
5285 let mut flag = MergeFlag::default();
5288 for kind in [K::STAR, K::L_BRACE, K::R_BRACE] {
5289 flag.feed(kind);
5290 }
5291 assert!(
5292 !flag.is_merge(),
5293 "a star before the opener must not decide the flag"
5294 );
5295
5296 let mut flag = MergeFlag::default();
5299 for kind in [K::NUMBER, K::L_BRACE, K::STAR, K::R_BRACE] {
5300 flag.feed(kind);
5301 }
5302 assert!(
5303 flag.is_merge(),
5304 "a token before the opener must not consume the decision"
5305 );
5306 }
5307
5308 #[test]
5318 fn diagnostic_spans_point_at_the_offending_text_with_and_without_a_bom() {
5319 let cases = [
5321 (
5322 "price with two numbers",
5323 "2024-01-15 price HOOL 1 2 USD\n",
5324 "1 2",
5325 ),
5326 (
5327 "balance with two numbers",
5328 "2024-01-15 balance Assets:Cash 1 2 USD\n",
5329 "1 2",
5330 ),
5331 (
5332 "posting with a second amount",
5333 "2024-01-15 *\n Assets:A 5 USD + 3 USD\n Assets:B\n",
5334 " + 3 USD",
5337 ),
5338 (
5339 "orphaned comma before a posting amount",
5343 "2024-01-15 *\n Assets:A , 1,234.00 USD\n Assets:B\n",
5344 ",",
5345 ),
5346 ];
5347
5348 for (label, src, needle) in cases {
5349 for bom in [false, true] {
5350 let full = if bom {
5351 format!("\u{FEFF}{src}")
5352 } else {
5353 src.to_string()
5354 };
5355 let result = parse_via_cst(&full);
5356 let bom_len = if bom { "\u{FEFF}".len() } else { 0 };
5357
5358 let expected_start = src
5359 .find(needle)
5360 .unwrap_or_else(|| panic!("{label}: {needle:?} not in the fixture"))
5361 + bom_len;
5362 let expected_end = expected_start + needle.len();
5363
5364 let hit = result
5365 .errors
5366 .iter()
5367 .find(|e| e.span.start == expected_start && e.span.end == expected_end);
5368 assert!(
5369 hit.is_some(),
5370 "{label} (bom={bom}): expected an error spanning {expected_start}..{expected_end} \
5371 (the {needle:?}), got {:?}",
5372 result
5373 .errors
5374 .iter()
5375 .map(|e| (e.span.start, e.span.end))
5376 .collect::<Vec<_>>()
5377 );
5378 }
5379 }
5380 }
5381
5382 #[test]
5386 fn negative_numbers_in_price_and_balance_keep_their_sign() {
5387 let result = parse_via_cst("2024-01-15 price HOOL - 1.50 USD\n");
5391 let Some(Directive::Price(p)) = result.directives.first().map(|d| &d.value) else {
5392 panic!("expected a Price, got {:?}", result.directives);
5393 };
5394 assert_eq!(p.amount.number, rust_decimal_macros::dec!(-1.50));
5395
5396 let result = parse_via_cst("2024-01-15 balance Assets:Cash - 1.50 USD\n");
5397 let Some(Directive::Balance(b)) = result.directives.first().map(|d| &d.value) else {
5398 panic!("expected a Balance, got {:?}", result.directives);
5399 };
5400 assert_eq!(b.amount.number, rust_decimal_macros::dec!(-1.50));
5401
5402 let result = parse_via_cst("2024-01-15 price HOOL 1.50 USD\n");
5405 let Some(Directive::Price(p)) = result.directives.first().map(|d| &d.value) else {
5406 panic!("expected a Price");
5407 };
5408 assert_eq!(p.amount.number, rust_decimal_macros::dec!(1.50));
5409 }
5410
5411 #[test]
5416 fn price_base_currency_before_the_number_is_not_a_malformed_value() {
5417 let result = parse_via_cst("2024-01-15 price HOOL 1.50 USD\n");
5418 assert!(
5419 result.errors.is_empty(),
5420 "a well-formed price must not be reported as malformed: {:?}",
5421 result.errors
5422 );
5423 assert_eq!(result.directives.len(), 1);
5424
5425 let result = parse_via_cst("2024-01-15 price HOOL 1 2 USD\n");
5427 assert!(
5428 has_syntax_error(&result, "malformed amount"),
5429 "two numbers must still be refused: {:?}",
5430 result.errors
5431 );
5432 }
5433
5434 #[test]
5438 fn a_posting_flag_is_not_an_orphaned_amount_prefix() {
5439 let result = parse_via_cst("2024-01-15 *\n ! Assets:A 5 USD\n Assets:B\n");
5440 assert!(
5441 !has_syntax_error(&result, "unexpected token before posting amount"),
5442 "a posting flag is not an orphan: {:?}",
5443 result.errors
5444 );
5445 }
5446
5447 #[test]
5452 fn posting_amount_diagnostics_point_at_the_offending_amount() {
5453 let huge = "1".repeat(30);
5455 let cases = [
5456 (
5457 "unevaluatable arithmetic",
5461 "2024-01-15 *\n Assets:A (1/0) USD\n Assets:B\n".to_string(),
5462 "(1/0) USD".to_string(),
5463 ),
5464 (
5465 "number past the Decimal ceiling",
5466 format!("2024-01-15 *\n Assets:A {huge} USD\n Assets:B\n"),
5467 huge,
5468 ),
5469 ];
5470
5471 for (label, src, needle) in cases {
5472 for bom in [false, true] {
5473 let full = if bom {
5474 format!("\u{FEFF}{src}")
5475 } else {
5476 src.clone()
5477 };
5478 let bom_len = if bom { "\u{FEFF}".len() } else { 0 };
5479 let result = parse_via_cst(&full);
5480
5481 let start = src.find(&needle).expect("needle present") + bom_len;
5482 let end = start + needle.len();
5483 assert!(
5484 result
5485 .errors
5486 .iter()
5487 .any(|e| e.span.start == start && e.span.end == end),
5488 "{label} (bom={bom}): expected a span {start}..{end}, got {:?}",
5489 result
5490 .errors
5491 .iter()
5492 .map(|e| (e.span.start, e.span.end))
5493 .collect::<Vec<_>>()
5494 );
5495 }
5496 }
5497 }
5498
5499 #[test]
5504 fn a_trailing_currency_closes_the_value_scan() {
5505 let result = parse_via_cst("2024-01-15 price HOOL 1.50 USD 2\n");
5506 assert!(
5507 !has_syntax_error(&result, "malformed amount"),
5508 "the scan must stop at the closing currency, so the stray `2` is not \
5509 a second number of the VALUE: {:?}",
5510 result.errors
5511 );
5512 }
5513
5514 #[test]
5518 fn orphan_detection_ignores_pre_account_and_non_sign_tokens() {
5519 let orphan_reported = |src: &str| {
5520 has_syntax_error(
5521 &parse_via_cst(src),
5522 "unexpected token before posting amount",
5523 )
5524 };
5525
5526 assert!(
5527 !orphan_reported("2024-01-15 *\n , Assets:A 1 USD\n Assets:B\n"),
5528 "a comma BEFORE the account is not an orphaned amount prefix"
5529 );
5530 assert!(
5531 !orphan_reported("2024-01-15 *\n Assets:A \"note\" 1 USD\n Assets:B\n"),
5532 "a non-sign token between account and amount is not an orphan"
5533 );
5534 assert!(
5537 orphan_reported("2024-01-15 *\n Assets:A , 1 USD\n Assets:B\n"),
5538 "a comma after the account IS an orphan"
5539 );
5540 }
5541
5542 #[test]
5545 fn posting_trailing_comment_is_captured() {
5546 let result = parse_via_cst("2024-01-15 *\n Assets:A 1 USD ; why\n Assets:B\n");
5547 let Some(Directive::Transaction(txn)) = result.directives.first().map(|d| &d.value) else {
5548 panic!("expected a transaction");
5549 };
5550 let first = &txn.postings[0];
5551 assert!(
5552 first.trailing_comments.iter().any(|c| c.contains("why")),
5553 "expected the trailing comment on the posting, got {:?}",
5554 first.trailing_comments
5555 );
5556 }
5557
5558 #[test]
5572 fn price_and_balance_fallback_re_signs_a_leading_minus() {
5573 let number_of = |src: &str| -> Decimal {
5574 let r = parse_via_cst(src);
5575 match r.directives.first().map(|d| &d.value) {
5576 Some(Directive::Price(p)) => p.amount.number,
5577 Some(Directive::Balance(b)) => b.amount.number,
5578 other => panic!("expected price/balance from {src:?}, got {other:?}"),
5579 }
5580 };
5581
5582 assert_eq!(
5584 number_of("2024-01-15 price HOOL - 1.50 - USD\n"),
5585 rust_decimal_macros::dec!(-1.50)
5586 );
5587 assert_eq!(
5588 number_of("2024-01-15 balance Assets:C - 1.50 - USD\n"),
5589 rust_decimal_macros::dec!(-1.50)
5590 );
5591
5592 assert_eq!(
5595 number_of("2024-01-15 price HOOL 1.50 - USD\n"),
5596 rust_decimal_macros::dec!(1.50)
5597 );
5598 assert_eq!(
5599 number_of("2024-01-15 balance Assets:C 1.50 - USD\n"),
5600 rust_decimal_macros::dec!(1.50)
5601 );
5602 }
5603
5604 #[test]
5613 fn red_path_matches_green_on_posting_comments_and_orphan_detection() {
5614 let orphan_msg = "unexpected token before posting amount";
5615
5616 let src = "2024-01-15 *\n Assets:A 1 USD ; why\n Assets:B\n";
5619 for (label, result) in [("green", parse_via_cst(src)), ("red", parse_red_only(src))] {
5620 let Some(Directive::Transaction(txn)) = result.directives.first().map(|d| &d.value)
5621 else {
5622 panic!("{label}: expected a transaction");
5623 };
5624 assert!(
5625 txn.postings[0]
5626 .trailing_comments
5627 .iter()
5628 .any(|c| c.contains("why")),
5629 "{label}: trailing comment lost, got {:?}",
5630 txn.postings[0].trailing_comments
5631 );
5632 }
5633
5634 let orphan_reported = |src: &str| has_syntax_error(&parse_red_only(src), orphan_msg);
5637 assert!(
5638 orphan_reported("2024-01-15 *\n Assets:A , 1 USD\n Assets:B\n"),
5639 "red: a comma after the account IS an orphan"
5640 );
5641 assert!(
5642 !orphan_reported("2024-01-15 *\n , Assets:A 1 USD\n Assets:B\n"),
5643 "red: a comma BEFORE the account is not"
5644 );
5645 assert!(
5646 !orphan_reported("2024-01-15 *\n Assets:A \"note\" 1 USD\n Assets:B\n"),
5647 "red: a non-sign token between account and amount is not"
5648 );
5649 }
5650
5651 fn meta_of(entries: &str) -> rustledger_core::Metadata {
5659 let src = format!("2024-01-15 open Assets:A\n{entries}");
5660 let result = parse_via_cst(&src);
5661 let Some(Directive::Open(open)) = result.directives.first().map(|d| &d.value) else {
5662 panic!("expected an Open from {src:?}, errors {:?}", result.errors);
5663 };
5664 open.meta.clone()
5665 }
5666
5667 fn custom_values(line: &str) -> Vec<MetaValue> {
5668 let result = parse_via_cst(line);
5669 let Some(Directive::Custom(c)) = result.directives.first().map(|d| &d.value) else {
5670 panic!(
5671 "expected a Custom from {line:?}, errors {:?}",
5672 result.errors
5673 );
5674 };
5675 c.values.clone()
5676 }
5677
5678 #[test]
5682 fn metadata_values_cover_every_kind() {
5683 let meta = meta_of(
5684 " str: \"hello\"\n num: 42\n amt: 42 USD\n dt: 2024-06-01\n \
5685 acct: Assets:B\n cur: USD\n yes: TRUE\n no: FALSE\n \
5686 tg: #mytag\n lk: ^mylink\n",
5687 );
5688 let got = |k: &str| meta.get(k).cloned().unwrap_or(MetaValue::None);
5689
5690 assert_eq!(got("str"), MetaValue::String("hello".into()));
5691 assert_eq!(got("num"), MetaValue::Int(42));
5692 assert_eq!(
5693 got("amt"),
5694 MetaValue::Amount(Amount::new(rust_decimal_macros::dec!(42), "USD"))
5695 );
5696 assert_eq!(got("dt"), MetaValue::Date(naive_date(2024, 6, 1).unwrap()));
5697 assert_eq!(got("acct"), MetaValue::Account(Account::new("Assets:B")));
5698 assert_eq!(got("cur"), MetaValue::Currency(Currency::new("USD")));
5699 assert_eq!(got("yes"), MetaValue::Bool(true));
5700 assert_eq!(got("no"), MetaValue::Bool(false));
5701 assert_eq!(got("tg"), MetaValue::Tag(Tag::new("mytag")));
5702 assert_eq!(got("lk"), MetaValue::Link(Link::new("mylink")));
5703 }
5704
5705 #[test]
5709 fn metadata_latches_the_first_token_of_each_kind() {
5710 let meta = meta_of(
5714 " ss: \"one\" \"two\"\n nn: 1 2\n cc: USD EUR\n dd: 2024-06-01 2025-07-02\n \
5715 aa: Assets:First Assets:Second\n bb: TRUE FALSE\n tt: #first #second\n",
5716 );
5717 let got = |k: &str| meta.get(k).cloned().unwrap_or(MetaValue::None);
5718
5719 assert_eq!(got("ss"), MetaValue::String("one".into()));
5720 assert_eq!(got("nn"), MetaValue::Int(1));
5721 assert_eq!(got("dd"), MetaValue::Date(naive_date(2024, 6, 1).unwrap()));
5722 assert_eq!(got("aa"), MetaValue::Account(Account::new("Assets:First")));
5723 assert_eq!(got("bb"), MetaValue::Bool(true), "TRUE came first");
5724 assert_eq!(got("tt"), MetaValue::Tag(Tag::new("first")));
5725 assert_eq!(got("cc"), MetaValue::Currency(Currency::new("USD")));
5727 }
5728
5729 #[test]
5743 fn metadata_minus_applies_only_before_the_number() {
5744 let meta = meta_of(" neg: -42\n negamt: -42 USD\n after: 42 - 1\n");
5745 let got = |k: &str| meta.get(k).cloned().unwrap_or(MetaValue::None);
5746
5747 assert_eq!(got("neg"), MetaValue::Int(-42));
5748 assert_eq!(
5749 got("negamt"),
5750 MetaValue::Amount(Amount::new(rust_decimal_macros::dec!(-42), "USD")),
5751 "the sign applies to the amount too"
5752 );
5753 assert_eq!(
5754 got("after"),
5755 MetaValue::Int(41),
5756 "an expression in a metadata value is evaluated, matching beancount"
5757 );
5758 }
5759
5760 #[test]
5765 fn custom_directive_values_advance_one_value_at_a_time() {
5766 assert_eq!(
5767 custom_values("2024-01-15 custom \"b\" FALSE TRUE FALSE\n"),
5768 vec![
5769 MetaValue::Bool(false),
5770 MetaValue::Bool(true),
5771 MetaValue::Bool(false)
5772 ],
5773 "each bool consumes exactly one token"
5774 );
5775
5776 assert_eq!(
5777 custom_values("2024-01-15 custom \"b\" 42 USD TRUE\n"),
5778 vec![
5779 MetaValue::Amount(Amount::new(rust_decimal_macros::dec!(42), "USD")),
5780 MetaValue::Bool(true)
5781 ],
5782 "NUMBER + CURRENCY consumes TWO tokens and the next value still lands"
5783 );
5784
5785 assert_eq!(
5786 custom_values("2024-01-15 custom \"b\" USD TRUE\n"),
5787 vec![
5788 MetaValue::Currency(Currency::new("USD")),
5789 MetaValue::Bool(true)
5790 ],
5791 "a lone CURRENCY is a value in its own right, not an amount fragment"
5792 );
5793
5794 assert_eq!(
5795 custom_values("2024-01-15 custom \"b\" -42 #tag ^link 2024-06-01 Assets:B\n"),
5796 vec![
5797 MetaValue::Int(-42),
5798 MetaValue::Tag(Tag::new("tag")),
5799 MetaValue::Link(Link::new("link")),
5800 MetaValue::Date(naive_date(2024, 6, 1).unwrap()),
5801 MetaValue::Account(Account::new("Assets:B")),
5802 ],
5803 "MINUS + NUMBER consumes two tokens; the rest follow in order"
5804 );
5805 }
5806
5807 #[test]
5811 fn metadata_latches_bool_and_taglink_in_either_order() {
5812 let meta = meta_of(" bb: FALSE TRUE\n tl: #tag ^link\n lt: ^link #tag\n");
5814 let got = |k: &str| meta.get(k).cloned().unwrap_or(MetaValue::None);
5815
5816 assert_eq!(
5817 got("bb"),
5818 MetaValue::Bool(false),
5819 "FALSE came first, so the later TRUE must not overwrite it"
5820 );
5821 assert_eq!(
5822 got("tl"),
5823 MetaValue::Tag(Tag::new("tag")),
5824 "tag and link share one slot; the tag came first"
5825 );
5826 assert_eq!(
5827 got("lt"),
5828 MetaValue::Link(Link::new("link")),
5829 "and the link wins when it comes first"
5830 );
5831 }
5832
5833 #[test]
5838 fn custom_values_terminate_on_a_long_run() {
5839 let values = custom_values(
5840 "2024-01-15 custom \"b\" 1 USD 2 EUR TRUE FALSE #a ^b 2024-06-01 Assets:X \"s\"\n",
5841 );
5842 assert_eq!(
5843 values.len(),
5844 9,
5845 "every value consumed exactly once, got {values:?}"
5846 );
5847 assert_eq!(
5848 values.first(),
5849 Some(&MetaValue::Amount(Amount::new(
5850 rust_decimal_macros::dec!(1),
5851 "USD"
5852 )))
5853 );
5854 assert_eq!(values.last(), Some(&MetaValue::String("s".into())));
5855 }
5856
5857 #[test]
5862 fn custom_directive_scan_skips_the_header_and_non_values() {
5863 assert_eq!(
5864 custom_values("2024-01-15 custom \"b\"\n"),
5865 vec![],
5866 "the type name is the header, not a value, and no values is valid"
5867 );
5868
5869 assert_eq!(
5870 custom_values("2024-01-15 custom \"b\" \"x\" 42\n"),
5871 vec![MetaValue::String("x".into()), MetaValue::Int(42)],
5872 "the FIRST string is the type name; a later one IS a value"
5873 );
5874
5875 assert_eq!(
5878 custom_values("2024-01-15 custom \"b\" * 42\n"),
5879 vec![MetaValue::Int(42)],
5880 "a non-value token before the first value is stepped over"
5881 );
5882 assert_eq!(
5883 custom_values("2024-01-15 custom \"b\" 42 * 7\n"),
5884 vec![MetaValue::Int(42), MetaValue::Int(7)],
5885 "and between two values"
5886 );
5887 }
5888}