1use rust_decimal::Decimal;
38use rustledger_core::cost::{CostNumber, CostSpec};
39use rustledger_core::directive::{PriceAnnotation, PriceKind};
40use rustledger_core::{
41 Account, Amount, Currency, Directive, IncompleteAmount, InternedStr, Link, MetaValue, Metadata,
42 NaiveDate, Posting, Span, Spanned, Tag, naive_date,
43};
44
45use crate::ParseResult;
46use crate::cst::ast::{
47 self, AstNode, AstToken, BalanceDirective, CloseDirective, CommodityDirective, CustomDirective,
48 DocumentDirective, EventDirective, IncludeDirective, MetaEntry, NoteDirective, OpenDirective,
49 OptionDirective, PadDirective, PluginDirective, PostingFlagKind, PriceDirective,
50 QueryDirective, SourceFile, Transaction as AstTransaction, TransactionFlagKind,
51};
52
53#[must_use]
59pub fn parse_via_cst(source: &str) -> ParseResult {
60 parse_via_cst_opts(source, true)
61}
62
63#[must_use]
74pub fn parse_via_cst_opts(source: &str, collect_occurrences: bool) -> ParseResult {
75 parse_via_cst_inner(source, collect_occurrences, true)
76}
77
78#[doc(hidden)]
82#[must_use]
83pub fn parse_red_only(source: &str) -> ParseResult {
84 parse_via_cst_inner(
85 source, true, false,
86 )
87}
88
89fn parse_via_cst_inner(source: &str, collect_occurrences: bool, use_green: bool) -> ParseResult {
90 let (stripped, has_leading_bom) = crate::bom::strip_leading(source);
95 let bom_offset: u32 = if has_leading_bom { 3 } else { 0 };
96
97 let source_file = SourceFile::parse(stripped);
98
99 let mut directives: Vec<Spanned<Directive>> = Vec::new();
100 let mut directive_nodes: Vec<crate::SyntaxNode> = Vec::new();
101 let mut options: Vec<(String, String, Span)> = Vec::new();
102 let mut includes: Vec<(String, Span)> = Vec::new();
103 let mut plugins: Vec<(String, Option<String>, Span)> = Vec::new();
104 let DescendantsWalkResult {
108 inline_errors,
109 top_level_comments,
110 currency_occurrences,
111 account_occurrences,
112 } = if use_green {
113 super::green::walk_descendants(source_file.syntax(), bom_offset, collect_occurrences)
115 } else {
116 walk_descendants_once(&source_file, bom_offset, collect_occurrences)
117 };
118
119 let TopLevelWalkResult {
124 errors: top_level_errors,
125 section_marker_comments,
126 } = if use_green {
127 super::green::walk_top_level(source_file.syntax(), stripped, bom_offset)
128 } else {
129 walk_top_level_once(&source_file, stripped, bom_offset)
130 };
131
132 let mut comments: Vec<Spanned<String>> = top_level_comments;
133 comments.extend(section_marker_comments);
134 comments.sort_by_key(|s| s.span.start);
138 comments.dedup_by_key(|s| s.span.start);
139 let mut errors = top_level_errors;
140 if stripped.contains('{') {
149 errors.extend(extract_unclosed_cost_brace_errors(&source_file, bom_offset));
150 }
151 errors.extend(inline_errors);
152 let warnings = Vec::new();
153
154 let mut tag_stack: Vec<(Tag, Span)> = Vec::new();
161 let mut meta_stack: Vec<(String, MetaValue, Span)> = Vec::new();
167
168 for directive in source_file.directives() {
169 let cst_node = directive.syntax().clone();
173 let is_directive_producing = matches!(
182 directive,
183 ast::Directive::Open(_)
184 | ast::Directive::Close(_)
185 | ast::Directive::Commodity(_)
186 | ast::Directive::Note(_)
187 | ast::Directive::Document(_)
188 | ast::Directive::Event(_)
189 | ast::Directive::Query(_)
190 | ast::Directive::Price(_)
191 | ast::Directive::Balance(_)
192 | ast::Directive::Pad(_)
193 | ast::Directive::Custom(_)
194 | ast::Directive::Transaction(_)
195 );
196 let errors_before = errors.len();
197 let pushed_directive = match directive {
198 ast::Directive::Open(node) => convert_open(&node, bom_offset, &mut errors),
199 ast::Directive::Close(node) => convert_close(&node, bom_offset, &mut errors),
200 ast::Directive::Commodity(node) => convert_commodity(&node, bom_offset, &mut errors),
201 ast::Directive::Note(node) => convert_note(&node, bom_offset, &mut errors),
202 ast::Directive::Document(node) => convert_document(&node, bom_offset, &mut errors),
203 ast::Directive::Event(node) => convert_event(&node, bom_offset, &mut errors),
204 ast::Directive::Query(node) => convert_query(&node, bom_offset, &mut errors),
205 ast::Directive::Price(node) => convert_price(&node, bom_offset, &mut errors),
206 ast::Directive::Balance(node) => convert_balance(&node, bom_offset, &mut errors),
207 ast::Directive::Pad(node) => convert_pad(&node, bom_offset, &mut errors),
208 ast::Directive::Custom(node) => convert_custom(&node, bom_offset, &mut errors),
209 ast::Directive::Transaction(node) => {
210 let green = node.syntax().green();
215 let base =
216 u32::from(node.syntax().text_range().start()) as usize + bom_offset as usize;
217 let green_dir = if use_green {
218 super::green::convert_transaction(&green, base)
219 } else {
220 None
221 };
222 match green_dir {
223 Some(d) => Some(d),
224 None => convert_transaction(&node, bom_offset, &mut errors),
225 }
226 }
227 ast::Directive::Option(node) => {
228 if let Some(triple) = convert_option(&node, bom_offset) {
229 options.push(triple);
230 }
231 None
232 }
233 ast::Directive::Include(node) => {
234 if let Some(pair) = convert_include(&node, bom_offset) {
235 includes.push(pair);
236 }
237 None
238 }
239 ast::Directive::Plugin(node) => {
240 if let Some(triple) = convert_plugin(&node, bom_offset) {
241 plugins.push(triple);
242 }
243 None
244 }
245 ast::Directive::Pushtag(node) => {
248 if let Some(tag_token) = node.tag() {
249 let span = node_span(node.syntax(), bom_offset);
250 tag_stack.push((Tag::new(tag_token.text().trim_start_matches('#')), span));
251 }
252 None
253 }
254 ast::Directive::Poptag(node) => {
255 if let Some(tag_token) = node.tag() {
256 let name = tag_token.text().trim_start_matches('#');
257 if let Some(pos) = tag_stack.iter().rposition(|(t, _)| t.as_str() == name) {
258 tag_stack.remove(pos);
259 } else {
260 errors.push(crate::ParseError::new(
261 crate::ParseErrorKind::InvalidPoptag(name.to_string()),
262 node_span(node.syntax(), bom_offset),
263 ));
264 }
265 }
266 None
267 }
268 ast::Directive::Pushmeta(node) => {
269 if let Some(key_token) = node.key() {
270 let key = key_token.text_without_colon().to_string();
271 let value = pushmeta_value(node.syntax());
272 let span = node_span(node.syntax(), bom_offset);
273 meta_stack.push((key, value, span));
274 }
275 None
276 }
277 ast::Directive::Popmeta(node) => {
278 if let Some(key_token) = node.key() {
279 let key = key_token.text_without_colon().to_string();
280 if let Some(pos) = meta_stack.iter().rposition(|(k, _, _)| k == &key) {
281 meta_stack.remove(pos);
282 } else {
283 errors.push(crate::ParseError::new(
284 crate::ParseErrorKind::InvalidPopmeta(key),
285 node_span(node.syntax(), bom_offset),
286 ));
287 }
288 }
289 None
290 }
291 };
292 if let Some(mut spanned) = pushed_directive {
293 apply_inherited_state(&mut spanned.value, &tag_stack, &meta_stack);
294 directives.push(spanned);
295 directive_nodes.push(cst_node);
296 } else if is_directive_producing && errors.len() == errors_before {
297 errors.push(crate::ParseError::new(
305 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
306 node_span(&cst_node, bom_offset),
307 ));
308 }
309 }
310
311 for (tag, span) in &tag_stack {
315 errors.push(crate::ParseError::new(
316 crate::ParseErrorKind::UnclosedPushtag(tag.as_str().to_string()),
317 *span,
318 ));
319 }
320 for (key, _, span) in &meta_stack {
321 errors.push(crate::ParseError::new(
322 crate::ParseErrorKind::UnclosedPushmeta(key.clone()),
323 *span,
324 ));
325 }
326 errors.sort_by_key(|e| e.span.start);
327
328 fixup_directive_spans(&source_file, bom_offset, &directive_nodes, &mut directives);
332
333 let alignment = crate::cst::format::compute_alignment(&source_file);
341
342 let syntax_root = source_file.syntax().green().into_owned();
349
350 ParseResult {
351 directives,
352 options,
353 includes,
354 plugins,
355 comments,
356 errors,
357 warnings,
358 currency_occurrences,
359 account_occurrences,
360 has_leading_bom,
361 syntax_root,
362 alignment,
363 }
364}
365
366const VALID_BOOKING_METHODS: &[&str] = &[
374 "FIFO",
375 "STRICT",
376 "STRICT_WITH_SIZE",
377 "LIFO",
378 "HIFO",
379 "NONE",
380 "AVERAGE",
381];
382
383fn convert_open(
384 node: &OpenDirective,
385 bom_offset: u32,
386 errors: &mut Vec<crate::ParseError>,
387) -> Option<Spanned<Directive>> {
388 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
389 let account = Account::new(node.account()?.text());
390 let currencies: Vec<Currency> = node.currencies().map(|c| Currency::new(c.text())).collect();
391 let booking = node.booking_method().and_then(|s| s.text_decoded());
392 let span = node_span(node.syntax(), bom_offset);
393 if let Some(b) = &booking
394 && !VALID_BOOKING_METHODS.contains(&b.as_str())
395 {
396 errors.push(crate::ParseError::new(
397 crate::ParseErrorKind::InvalidBookingMethod(b.clone()),
398 span,
399 ));
400 return None;
401 }
402 let meta = convert_meta_entries(node.syntax());
403
404 let open = rustledger_core::directive::Open {
405 date,
406 account,
407 currencies,
408 booking,
409 meta,
410 };
411 Some(Spanned::new(Directive::Open(open), span))
412}
413
414fn convert_close(
415 node: &CloseDirective,
416 bom_offset: u32,
417 errors: &mut Vec<crate::ParseError>,
418) -> Option<Spanned<Directive>> {
419 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
420 let account = Account::new(node.account()?.text());
421 let meta = convert_meta_entries(node.syntax());
422
423 let close = rustledger_core::directive::Close {
424 date,
425 account,
426 meta,
427 };
428 let span = node_span(node.syntax(), bom_offset);
429 Some(Spanned::new(Directive::Close(close), span))
430}
431
432fn convert_commodity(
433 node: &CommodityDirective,
434 bom_offset: u32,
435 errors: &mut Vec<crate::ParseError>,
436) -> Option<Spanned<Directive>> {
437 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
438 let currency = Currency::new(node.currency()?.text());
439 let meta = convert_meta_entries(node.syntax());
440
441 let commodity = rustledger_core::directive::Commodity {
442 date,
443 currency,
444 meta,
445 };
446 let span = node_span(node.syntax(), bom_offset);
447 Some(Spanned::new(Directive::Commodity(commodity), span))
448}
449
450fn convert_note(
451 node: &NoteDirective,
452 bom_offset: u32,
453 errors: &mut Vec<crate::ParseError>,
454) -> Option<Spanned<Directive>> {
455 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
456 let account = Account::new(node.account()?.text());
457 let comment = node.text()?.text_decoded()?;
458 let meta = convert_meta_entries(node.syntax());
459
460 let note = rustledger_core::directive::Note {
461 date,
462 account,
463 comment,
464 meta,
465 };
466 let span = node_span(node.syntax(), bom_offset);
467 Some(Spanned::new(Directive::Note(note), span))
468}
469
470fn convert_document(
471 node: &DocumentDirective,
472 bom_offset: u32,
473 errors: &mut Vec<crate::ParseError>,
474) -> Option<Spanned<Directive>> {
475 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
476 let account = Account::new(node.account()?.text());
477 let path = node.path()?.text_decoded()?;
478 let mut tags: Vec<Tag> = Vec::new();
485 let mut links: Vec<Link> = Vec::new();
486 for el in node.syntax().children_with_tokens() {
487 let rowan::NodeOrToken::Token(t) = el else {
488 continue;
489 };
490 match t.kind() {
491 crate::SyntaxKind::NEWLINE => break,
492 crate::SyntaxKind::TAG => {
493 tags.push(Tag::new(t.text().trim_start_matches('#')));
494 }
495 crate::SyntaxKind::LINK => {
496 links.push(Link::new(t.text().trim_start_matches('^')));
497 }
498 _ => {}
499 }
500 }
501 let meta = convert_meta_entries(node.syntax());
502
503 let document = rustledger_core::directive::Document {
504 date,
505 account,
506 path,
507 tags,
508 links,
509 meta,
510 };
511 let span = node_span(node.syntax(), bom_offset);
512 Some(Spanned::new(Directive::Document(document), span))
513}
514
515fn convert_event(
516 node: &EventDirective,
517 bom_offset: u32,
518 errors: &mut Vec<crate::ParseError>,
519) -> Option<Spanned<Directive>> {
520 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
521 let event_type = node.event_type()?.text_decoded()?;
522 let value = node.value()?.text_decoded()?;
523 let meta = convert_meta_entries(node.syntax());
524
525 let event = rustledger_core::directive::Event {
526 date,
527 event_type,
528 value,
529 meta,
530 };
531 let span = node_span(node.syntax(), bom_offset);
532 Some(Spanned::new(Directive::Event(event), span))
533}
534
535fn convert_query(
536 node: &QueryDirective,
537 bom_offset: u32,
538 errors: &mut Vec<crate::ParseError>,
539) -> Option<Spanned<Directive>> {
540 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
541 let name = node.name()?.text_decoded()?;
542 let query = node.query()?.text_decoded()?;
543 let meta = convert_meta_entries(node.syntax());
544
545 let q = rustledger_core::directive::Query {
546 date,
547 name,
548 query,
549 meta,
550 };
551 let span = node_span(node.syntax(), bom_offset);
552 Some(Spanned::new(Directive::Query(q), span))
553}
554
555fn convert_price(
556 node: &PriceDirective,
557 bom_offset: u32,
558 errors: &mut Vec<crate::ParseError>,
559) -> Option<Spanned<Directive>> {
560 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
561 let base_currency = Currency::new(node.base_currency()?.text());
562 let number = directive_arithmetic_value(node.syntax()).or_else(|| {
565 let mut n = parse_decimal_token(node.number()?.text())?;
566 if node_has_minus_before_number(node.syntax()) {
567 n = -n;
568 }
569 Some(n)
570 })?;
571 let quote_currency = Currency::new(node.quote_currency()?.text());
572 let amount = Amount::new(number, quote_currency);
573 let meta = convert_meta_entries(node.syntax());
574
575 let price = rustledger_core::directive::Price {
576 date,
577 currency: base_currency,
578 amount,
579 meta,
580 };
581 let span = node_span(node.syntax(), bom_offset);
582 Some(Spanned::new(Directive::Price(price), span))
583}
584
585fn convert_balance(
586 node: &BalanceDirective,
587 bom_offset: u32,
588 errors: &mut Vec<crate::ParseError>,
589) -> Option<Spanned<Directive>> {
590 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
591 let account = Account::new(node.account()?.text());
592 let number = directive_arithmetic_value(node.syntax()).or_else(|| {
597 let mut n = parse_decimal_token(node.number()?.text())?;
598 if node_has_minus_before_number(node.syntax()) {
599 n = -n;
600 }
601 Some(n)
602 })?;
603 let currency = Currency::new(node.currency()?.text());
604 let amount = Amount::new(number, currency);
605 let tolerance = extract_balance_tolerance(node.syntax());
606 let meta = convert_meta_entries(node.syntax());
607
608 let balance = rustledger_core::directive::Balance {
609 date,
610 account,
611 amount,
612 tolerance,
613 meta,
614 };
615 let span = node_span(node.syntax(), bom_offset);
616 Some(Spanned::new(Directive::Balance(balance), span))
617}
618
619fn extract_balance_tolerance(node: &crate::SyntaxNode) -> Option<Decimal> {
625 let mut past_tilde = false;
626 for el in node.children_with_tokens() {
627 let rowan::NodeOrToken::Token(t) = el else {
628 continue;
629 };
630 if past_tilde && t.kind() == crate::SyntaxKind::NUMBER {
631 return parse_decimal_token(t.text());
632 }
633 if t.kind() == crate::SyntaxKind::TILDE {
634 past_tilde = true;
635 }
636 }
637 None
638}
639
640fn convert_pad(
641 node: &PadDirective,
642 bom_offset: u32,
643 errors: &mut Vec<crate::ParseError>,
644) -> Option<Spanned<Directive>> {
645 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
646 let account = Account::new(node.target_account()?.text());
647 let source_account = Account::new(node.source_account()?.text());
648 let meta = convert_meta_entries(node.syntax());
649
650 let pad = rustledger_core::directive::Pad {
651 date,
652 account,
653 source_account,
654 meta,
655 };
656 let span = node_span(node.syntax(), bom_offset);
657 Some(Spanned::new(Directive::Pad(pad), span))
658}
659
660fn convert_custom(
661 node: &CustomDirective,
662 bom_offset: u32,
663 errors: &mut Vec<crate::ParseError>,
664) -> Option<Spanned<Directive>> {
665 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
666 let custom_type = node.custom_type()?.text_decoded()?;
667 let values = extract_custom_values(node.syntax());
668 let meta = convert_meta_entries(node.syntax());
669
670 let custom = rustledger_core::directive::Custom {
671 date,
672 custom_type,
673 values,
674 meta,
675 };
676 let span = node_span(node.syntax(), bom_offset);
677 Some(Spanned::new(Directive::Custom(custom), span))
678}
679
680fn extract_custom_values(node: &crate::SyntaxNode) -> Vec<MetaValue> {
687 let mut values = Vec::new();
688 let mut seen_type_string = false;
689 let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
693 .children_with_tokens()
694 .filter_map(rowan::NodeOrToken::into_token)
695 .filter(|t| {
696 !matches!(
697 t.kind(),
698 crate::SyntaxKind::WHITESPACE
699 | crate::SyntaxKind::NEWLINE
700 | crate::SyntaxKind::COMMENT
701 )
702 })
703 .collect();
704
705 let mut i = 0;
706 while i < raw.len() {
707 if !seen_type_string {
710 if raw[i].kind() == crate::SyntaxKind::STRING {
711 seen_type_string = true;
712 }
713 i += 1;
714 continue;
715 }
716 if let Some((value, next)) = value_tokens_to_meta(&raw, i) {
720 values.push(value);
721 i = next;
722 } else {
723 i += 1;
724 }
725 }
726 values
727}
728
729fn strip_string_quotes(raw: &str) -> Option<&str> {
730 let bytes = raw.as_bytes();
731 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
732 return None;
733 }
734 Some(&raw[1..raw.len() - 1])
735}
736
737fn convert_option(node: &OptionDirective, bom_offset: u32) -> Option<(String, String, Span)> {
738 let key = node.key()?.text_decoded()?;
739 let value = node.value()?.text_decoded()?;
740 Some((
741 key,
742 value,
743 single_line_directive_span(node.syntax(), bom_offset),
744 ))
745}
746
747fn convert_include(node: &IncludeDirective, bom_offset: u32) -> Option<(String, Span)> {
748 let path = node.path()?.text_decoded()?;
749 Some((path, single_line_directive_span(node.syntax(), bom_offset)))
750}
751
752fn convert_plugin(
753 node: &PluginDirective,
754 bom_offset: u32,
755) -> Option<(String, Option<String>, Span)> {
756 let module = node.module()?.text_decoded()?;
757 let config = node.config().and_then(|c| c.text_decoded());
758 Some((
759 module,
760 config,
761 single_line_directive_span(node.syntax(), bom_offset),
762 ))
763}
764
765fn convert_transaction(
768 node: &AstTransaction,
769 bom_offset: u32,
770 errors: &mut Vec<crate::ParseError>,
771) -> Option<Spanned<Directive>> {
772 let date = parse_directive_date(&node.date()?, errors, bom_offset)?;
773
774 let flag = node.flag().map_or('*', |f| flag_char_from_transaction(&f));
777
778 let mut it = node.strings().filter_map(|s| s.text_decoded());
783 let (payee_str, narration_str) = match (it.next(), it.next(), it.next()) {
784 (None, _, _) => (None, String::new()),
785 (Some(n), None, _) => (None, n),
786 (Some(p), Some(n), None) => (Some(p), n),
787 (Some(_), Some(_), Some(c)) => (None, it.last().unwrap_or(c)),
790 };
791
792 let payee = payee_str.map(InternedStr::from);
793 let narration = InternedStr::from(narration_str);
794
795 let mut tags: Vec<Tag> = node
805 .tags()
806 .map(|t| Tag::new(t.text().trim_start_matches('#')))
807 .collect();
808 let mut links: Vec<Link> = node
809 .links()
810 .map(|l| Link::new(l.text().trim_start_matches('^')))
811 .collect();
812 for el in node.syntax().children_with_tokens() {
813 let rowan::NodeOrToken::Token(t) = el else {
814 continue;
817 };
818 match t.kind() {
819 crate::SyntaxKind::TAG => {
820 let stripped = t.text().trim_start_matches('#');
821 let new_tag = Tag::new(stripped);
822 if !tags.contains(&new_tag) {
823 tags.push(new_tag);
824 }
825 }
826 crate::SyntaxKind::LINK => {
827 let stripped = t.text().trim_start_matches('^');
828 let new_link = Link::new(stripped);
829 if !links.contains(&new_link) {
830 links.push(new_link);
831 }
832 }
833 _ => {}
834 }
835 }
836
837 let meta = convert_meta_entries(node.syntax());
840
841 let (postings, trailing_comments) = collect_postings_with_comments(node, bom_offset, errors);
852
853 if header_has_pipe(node) {
858 errors.push(crate::ParseError::new(
859 crate::ParseErrorKind::DeprecatedPipeSymbol,
860 node_span(node.syntax(), bom_offset),
861 ));
862 }
863
864 let txn = rustledger_core::directive::Transaction {
865 date,
866 flag,
867 payee,
868 narration,
869 tags,
870 links,
871 meta,
872 postings,
873 trailing_comments,
874 };
875 let span = node_span(node.syntax(), bom_offset);
876 Some(Spanned::new(Directive::Transaction(txn), span))
877}
878
879fn header_has_pipe(node: &AstTransaction) -> bool {
885 for el in node.syntax().children_with_tokens() {
886 let rowan::NodeOrToken::Token(t) = el else {
887 continue;
888 };
889 if t.kind() == crate::SyntaxKind::NEWLINE {
890 return false;
891 }
892 if t.kind() == crate::SyntaxKind::PIPE {
893 return true;
894 }
895 }
896 false
897}
898
899fn collect_postings_with_comments(
914 node: &AstTransaction,
915 bom_offset: u32,
916 errors: &mut Vec<crate::ParseError>,
917) -> (Vec<Spanned<Posting>>, Vec<String>) {
918 let mut out = Vec::new();
919 let mut pending: Vec<String> = Vec::new();
920 let mut past_header = false;
921 for el in node.syntax().children_with_tokens() {
922 match el {
923 rowan::NodeOrToken::Token(t) => {
924 if !past_header {
925 if t.kind() == crate::SyntaxKind::NEWLINE {
926 past_header = true;
927 }
928 continue;
929 }
930 if is_comment_kind(t.kind()) {
931 pending.push(t.text().to_string());
932 } else if !is_trivia_kind(t.kind())
933 && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
934 {
935 pending.clear();
956 }
957 }
958 rowan::NodeOrToken::Node(n) => {
959 if !past_header {
960 past_header = true;
965 }
966 if let Some(p) = ast::Posting::cast(n) {
967 if let Some(mut spanned) = convert_posting(&p, bom_offset, errors) {
968 if !pending.is_empty() {
969 spanned.value.comments = std::mem::take(&mut pending);
970 }
971 out.push(spanned);
972 } else {
973 pending.clear();
981 }
982 }
983 }
987 }
988 }
989 (out, pending)
990}
991
992fn flag_char_from_transaction(flag: &ast::TransactionFlag) -> char {
993 match flag.classify() {
994 TransactionFlagKind::Star | TransactionFlagKind::Txn => '*',
995 TransactionFlagKind::Pending => '!',
996 TransactionFlagKind::Hash => '#',
997 TransactionFlagKind::Letter | TransactionFlagKind::CurrencyLetter => {
998 flag.text().chars().next().unwrap_or('*')
999 }
1000 }
1001}
1002
1003fn convert_posting(
1004 node: &ast::Posting,
1005 bom_offset: u32,
1006 errors: &mut Vec<crate::ParseError>,
1007) -> Option<Spanned<Posting>> {
1008 let account = Account::new(node.account()?.text());
1009
1010 let flag = node.flag().map(|f| flag_char_from_posting(&f));
1011
1012 let mut amount_children = node
1024 .syntax()
1025 .children()
1026 .filter(|n| ast::Amount::can_cast(n.kind()));
1027 let first_amount = amount_children.next();
1028 let first_amount_end: Option<u32> = first_amount.as_ref().map(|n| n.text_range().end().into());
1029 let mut sibling_start: Option<u32> = None;
1030 let mut sibling_end: u32 = 0;
1031 for extra in amount_children {
1032 let range = extra.text_range();
1033 let start_u32: u32 = range.start().into();
1034 let end_u32: u32 = range.end().into();
1035 if sibling_start.is_none() {
1036 sibling_start = Some(start_u32);
1037 }
1038 sibling_end = end_u32;
1039 }
1040 if let Some(start_u32) = sibling_start {
1041 let underline_start = first_amount_end.unwrap_or(start_u32);
1048 let span = Span::new(
1049 (underline_start + bom_offset) as usize,
1050 (sibling_end + bom_offset) as usize,
1051 );
1052 errors.push(crate::ParseError::new(
1053 crate::ParseErrorKind::SyntaxError(
1054 "unexpected trailing tokens after posting amount".to_string(),
1055 ),
1056 span,
1057 ));
1058 }
1059 let units = first_amount
1060 .and_then(ast::Amount::cast)
1061 .and_then(|amt| convert_amount_to_incomplete(&amt, errors, bom_offset));
1062 let cost = node.cost_spec().map(|cs| convert_cost_spec(&cs));
1063 let price = node
1064 .price_annotation()
1065 .map(|pa| convert_price_annotation(&pa, errors, bom_offset));
1066 let meta = convert_meta_entries(node.syntax());
1067
1068 let trailing_comments: Vec<String> = node
1073 .syntax()
1074 .children_with_tokens()
1075 .filter_map(rowan::NodeOrToken::into_token)
1076 .take_while(|t| t.kind() != crate::SyntaxKind::NEWLINE)
1077 .filter(|t| is_comment_kind(t.kind()))
1078 .map(|t| t.text().to_string())
1079 .collect();
1080
1081 let posting = Posting {
1082 account,
1083 units,
1084 cost,
1085 price,
1086 flag,
1087 meta,
1088 comments: Vec::new(),
1089 trailing_comments,
1090 };
1091 let span = posting_span(node.syntax(), bom_offset);
1092 Some(Spanned::new(posting, span))
1093}
1094
1095fn flag_char_from_posting(flag: &ast::PostingFlag) -> char {
1096 match flag.classify() {
1097 PostingFlagKind::Star => '*',
1098 PostingFlagKind::Pending => '!',
1099 PostingFlagKind::Hash => '#',
1100 PostingFlagKind::Letter | PostingFlagKind::CurrencyLetter => {
1101 flag.text().chars().next().unwrap_or('*')
1102 }
1103 }
1104}
1105
1106fn convert_amount_to_incomplete(
1118 amt: &ast::Amount,
1119 errors: &mut Vec<crate::ParseError>,
1120 bom_offset: u32,
1121) -> Option<IncompleteAmount> {
1122 let number = if amt.is_arithmetic() {
1127 let evaluated = evaluate_amount_expression(amt);
1128 if evaluated.is_none() {
1129 let range = amt.syntax().text_range();
1138 let start: u32 = range.start().into();
1139 let end: u32 = range.end().into();
1140 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1141 errors.push(crate::ParseError::new(
1142 crate::ParseErrorKind::SyntaxError(
1143 "invalid arithmetic expression in amount (overflow, division by zero, or malformed)"
1144 .to_string(),
1145 ),
1146 span,
1147 ));
1148 }
1149 evaluated
1150 } else {
1151 amt.number().and_then(|n| {
1152 let parsed = parse_decimal_token(n.text());
1153 if parsed.is_none() {
1154 let range = n.syntax().text_range();
1164 let start: u32 = range.start().into();
1165 let end: u32 = range.end().into();
1166 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1167 errors.push(crate::ParseError::new(
1168 crate::ParseErrorKind::SyntaxError(
1169 "invalid number in amount (likely exceeds 28-digit Decimal precision)"
1170 .to_string(),
1171 ),
1172 span,
1173 ));
1174 }
1175 let mut value = parsed?;
1176 if let Some(sign) = amt.sign()
1177 && sign.is_minus()
1178 {
1179 value = -value;
1180 }
1181 Some(value)
1182 })
1183 };
1184 let currency = amt.currency().map(|c| Currency::new(c.text()));
1185 match (number, currency) {
1186 (Some(n), Some(c)) => Some(IncompleteAmount::Complete(Amount::new(n, c))),
1187 (Some(n), None) => Some(IncompleteAmount::NumberOnly(n)),
1188 (None, Some(c)) => Some(IncompleteAmount::CurrencyOnly(c)),
1189 (None, None) => None,
1190 }
1191}
1192
1193fn evaluate_amount_expression(amt: &ast::Amount) -> Option<Decimal> {
1210 let tokens = amount_expression_tokens(amt);
1211 let mut cursor = 0usize;
1212 let value = parse_arith_expr(&tokens, &mut cursor)?;
1213 if cursor != tokens.len() {
1217 return None;
1218 }
1219 Some(value)
1220}
1221
1222fn directive_arithmetic_value(node: &crate::SyntaxNode) -> Option<Decimal> {
1241 let raw: Vec<crate::SyntaxToken> = node
1242 .children_with_tokens()
1243 .filter_map(rowan::NodeOrToken::into_token)
1244 .filter(|t| !is_trivia_kind(t.kind()))
1245 .skip_while(|t| t.kind() != crate::SyntaxKind::NUMBER)
1246 .collect();
1247 let mut depth: i32 = 0;
1248 let mut first_currency_idx: Option<usize> = None;
1249 for (i, t) in raw.iter().enumerate() {
1250 match t.kind() {
1251 crate::SyntaxKind::L_PAREN => depth += 1,
1252 crate::SyntaxKind::R_PAREN => depth -= 1,
1253 crate::SyntaxKind::CURRENCY if depth == 0 && first_currency_idx.is_none() => {
1254 first_currency_idx = Some(i);
1255 }
1256 _ => {}
1257 }
1258 }
1259 let end = first_currency_idx.unwrap_or(raw.len());
1260 let tokens: Vec<crate::SyntaxToken> = raw.into_iter().take(end).collect();
1261 let has_op = tokens.iter().any(|t| {
1263 matches!(
1264 t.kind(),
1265 crate::SyntaxKind::PLUS
1266 | crate::SyntaxKind::MINUS
1267 | crate::SyntaxKind::STAR
1268 | crate::SyntaxKind::SLASH
1269 | crate::SyntaxKind::L_PAREN
1270 )
1271 });
1272 if !has_op {
1273 return None;
1274 }
1275 let mut cursor = 0usize;
1276 let value = parse_arith_expr(&tokens, &mut cursor)?;
1277 if cursor != tokens.len() {
1278 return None;
1279 }
1280 Some(value)
1281}
1282
1283fn amount_expression_tokens(amt: &ast::Amount) -> Vec<crate::SyntaxToken> {
1289 let raw: Vec<crate::SyntaxToken> = amt
1290 .syntax()
1291 .children_with_tokens()
1292 .filter_map(rowan::NodeOrToken::into_token)
1293 .filter(|t| !is_trivia_kind(t.kind()))
1294 .collect();
1295 let mut depth: i32 = 0;
1299 let mut trailing_currency_idx: Option<usize> = None;
1300 for (i, t) in raw.iter().enumerate() {
1301 match t.kind() {
1302 crate::SyntaxKind::L_PAREN => depth += 1,
1303 crate::SyntaxKind::R_PAREN => depth -= 1,
1304 crate::SyntaxKind::CURRENCY if depth == 0 => trailing_currency_idx = Some(i),
1305 _ => {}
1306 }
1307 }
1308 let end = trailing_currency_idx.unwrap_or(raw.len());
1309 raw.into_iter().take(end).collect()
1310}
1311
1312fn parse_arith_expr(tokens: &[crate::SyntaxToken], cursor: &mut usize) -> Option<Decimal> {
1314 let mut result = parse_arith_term(tokens, cursor)?;
1315 while let Some(op) = tokens.get(*cursor).map(crate::SyntaxToken::kind) {
1316 match op {
1317 crate::SyntaxKind::PLUS => {
1318 *cursor += 1;
1319 let rhs = parse_arith_term(tokens, cursor)?;
1320 result = result.checked_add(rhs)?;
1321 }
1322 crate::SyntaxKind::MINUS => {
1323 *cursor += 1;
1324 let rhs = parse_arith_term(tokens, cursor)?;
1325 result = result.checked_sub(rhs)?;
1326 }
1327 _ => break,
1328 }
1329 }
1330 Some(result)
1331}
1332
1333fn parse_arith_term(tokens: &[crate::SyntaxToken], cursor: &mut usize) -> Option<Decimal> {
1335 let mut result = parse_arith_primary(tokens, cursor)?;
1336 while let Some(op) = tokens.get(*cursor).map(crate::SyntaxToken::kind) {
1337 match op {
1338 crate::SyntaxKind::STAR => {
1339 *cursor += 1;
1340 let rhs = parse_arith_primary(tokens, cursor)?;
1341 result = result.checked_mul(rhs)?;
1342 }
1343 crate::SyntaxKind::SLASH => {
1344 *cursor += 1;
1345 let rhs = parse_arith_primary(tokens, cursor)?;
1346 if rhs.is_zero() {
1347 return None;
1348 }
1349 result = result.checked_div(rhs)?;
1350 }
1351 _ => break,
1352 }
1353 }
1354 Some(result)
1355}
1356
1357fn parse_arith_primary(tokens: &[crate::SyntaxToken], cursor: &mut usize) -> Option<Decimal> {
1359 let t = tokens.get(*cursor)?;
1360 match t.kind() {
1361 crate::SyntaxKind::L_PAREN => {
1362 *cursor += 1;
1363 let inner = parse_arith_expr(tokens, cursor)?;
1364 let close = tokens.get(*cursor)?;
1369 if close.kind() != crate::SyntaxKind::R_PAREN {
1370 return None;
1371 }
1372 *cursor += 1;
1373 Some(inner)
1374 }
1375 crate::SyntaxKind::MINUS => {
1376 *cursor += 1;
1377 let inner = parse_arith_primary(tokens, cursor)?;
1378 Some(-inner)
1379 }
1380 crate::SyntaxKind::PLUS => {
1381 *cursor += 1;
1382 parse_arith_primary(tokens, cursor)
1383 }
1384 crate::SyntaxKind::NUMBER => {
1385 let value = parse_decimal_token(t.text())?;
1386 *cursor += 1;
1387 Some(value)
1388 }
1389 _ => None,
1390 }
1391}
1392
1393fn convert_cost_spec(cs: &ast::CostSpec) -> CostSpec {
1394 let merge = cs.is_merge();
1395 let is_total = cs.is_total();
1396
1397 let post_hash_total = cost_total_after_hash(cs);
1406
1407 let cost_number = if let Some(total) = post_hash_total {
1408 Some(CostNumber::Total { value: total })
1409 } else {
1410 let number = cs.number().and_then(|n| parse_decimal_token(n.text()));
1411 match (number, is_total) {
1412 (Some(v), true) => Some(CostNumber::Total { value: v }),
1413 (Some(v), false) => Some(CostNumber::PerUnit { value: v }),
1414 (None, _) => None,
1415 }
1416 };
1417
1418 let currency = cs.currency().map(|c| Currency::new(c.text()));
1419 let date = cs.date().and_then(|d| parse_date_token(d.text()));
1420 let label = cs.label().and_then(|s| s.text_decoded());
1421
1422 CostSpec {
1423 number: cost_number,
1424 currency,
1425 date,
1426 label,
1427 merge,
1428 }
1429}
1430
1431fn cost_total_after_hash(cs: &ast::CostSpec) -> Option<Decimal> {
1436 let mut seen_number = false;
1437 let mut past_hash = false;
1438 for el in cs.syntax().children_with_tokens() {
1439 let rowan::NodeOrToken::Token(t) = el else {
1440 continue;
1441 };
1442 match t.kind() {
1443 crate::SyntaxKind::NUMBER if !seen_number => {
1444 seen_number = true;
1445 }
1446 crate::SyntaxKind::HASH if seen_number => {
1447 past_hash = true;
1448 }
1449 crate::SyntaxKind::NUMBER if past_hash => {
1450 return parse_decimal_token(t.text());
1451 }
1452 _ => {}
1453 }
1454 }
1455 None
1456}
1457
1458fn convert_price_annotation(
1459 pa: &ast::PriceAnnotation,
1460 errors: &mut Vec<crate::ParseError>,
1461 bom_offset: u32,
1462) -> PriceAnnotation {
1463 let kind = if pa.is_total() {
1464 PriceKind::Total
1465 } else {
1466 PriceKind::Unit
1467 };
1468 let amount = pa
1469 .amount()
1470 .and_then(|a| convert_amount_to_incomplete(&a, errors, bom_offset));
1471 PriceAnnotation { kind, amount }
1472}
1473
1474fn convert_meta_entries(node: &crate::SyntaxNode) -> Metadata {
1481 let mut meta = Metadata::default();
1482 for entry in node.children().filter_map(MetaEntry::cast) {
1483 let Some(key_token) = entry.key() else {
1484 continue;
1485 };
1486 let key = key_token.text_without_colon().to_string();
1487 let value = meta_value_from_entry(&entry);
1488 meta.insert(key, value);
1489 }
1490 meta
1491}
1492
1493fn node_has_minus_before_number(node: &crate::SyntaxNode) -> bool {
1498 for el in node.children_with_tokens() {
1499 let rowan::NodeOrToken::Token(t) = el else {
1500 continue;
1501 };
1502 match t.kind() {
1503 crate::SyntaxKind::MINUS => return true,
1504 crate::SyntaxKind::NUMBER => return false,
1505 _ => {}
1506 }
1507 }
1508 false
1509}
1510
1511fn meta_entry_has_minus_sign(entry: &MetaEntry) -> bool {
1516 let mut past_key = false;
1517 for el in entry.syntax().children_with_tokens() {
1518 let rowan::NodeOrToken::Token(t) = el else {
1519 continue;
1520 };
1521 if !past_key {
1522 if t.kind() == crate::SyntaxKind::META_KEY {
1523 past_key = true;
1524 }
1525 continue;
1526 }
1527 match t.kind() {
1528 crate::SyntaxKind::MINUS => return true,
1529 crate::SyntaxKind::NUMBER => return false,
1530 _ => {}
1531 }
1532 }
1533 false
1534}
1535
1536fn value_tokens_to_meta(
1550 tokens: &[rowan::SyntaxToken<crate::BeancountLanguage>],
1551 start: usize,
1552) -> Option<(MetaValue, usize)> {
1553 let mut i = start;
1554 let mut negate = false;
1555 if tokens.get(i).map(rowan::SyntaxToken::kind) == Some(crate::SyntaxKind::MINUS) {
1556 negate = true;
1557 i += 1;
1558 }
1559 let t = tokens.get(i)?;
1560 match t.kind() {
1561 crate::SyntaxKind::STRING => {
1562 let s = strip_string_quotes(t.text())?;
1563 Some((MetaValue::String(s.to_string()), i + 1))
1564 }
1565 crate::SyntaxKind::NUMBER => {
1566 let mut decimal = parse_decimal_token(t.text())?;
1567 if negate {
1568 decimal = -decimal;
1569 }
1570 if let Some(next) = tokens.get(i + 1)
1572 && next.kind() == crate::SyntaxKind::CURRENCY
1573 {
1574 return Some((
1575 MetaValue::Amount(Amount::new(decimal, Currency::new(next.text()))),
1576 i + 2,
1577 ));
1578 }
1579 Some((number_meta_value(t.text(), decimal), i + 1))
1580 }
1581 crate::SyntaxKind::DATE => Some((MetaValue::Date(parse_date_token(t.text())?), i + 1)),
1582 crate::SyntaxKind::ACCOUNT => Some((MetaValue::Account(Account::new(t.text())), i + 1)),
1583 crate::SyntaxKind::CURRENCY => Some((MetaValue::Currency(Currency::new(t.text())), i + 1)),
1584 crate::SyntaxKind::BOOL_TRUE => Some((MetaValue::Bool(true), i + 1)),
1585 crate::SyntaxKind::BOOL_FALSE => Some((MetaValue::Bool(false), i + 1)),
1586 crate::SyntaxKind::TAG => Some((
1587 MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))),
1588 i + 1,
1589 )),
1590 crate::SyntaxKind::LINK => Some((
1591 MetaValue::Link(Link::new(t.text().trim_start_matches('^'))),
1592 i + 1,
1593 )),
1594 _ => None,
1595 }
1596}
1597
1598fn meta_value_from_entry(entry: &MetaEntry) -> MetaValue {
1604 if let Some(s) = entry.value_string()
1605 && let Some(text) = s.text_decoded()
1606 {
1607 return MetaValue::String(text);
1608 }
1609 if let Some(n) = entry.value_number()
1610 && let Some(mut decimal) = parse_decimal_token(n.text())
1611 {
1612 if meta_entry_has_minus_sign(entry) {
1616 decimal = -decimal;
1617 }
1618 if let Some(c) = entry.value_currency() {
1623 return MetaValue::Amount(Amount::new(decimal, Currency::new(c.text())));
1624 }
1625 return number_meta_value(n.text(), decimal);
1626 }
1627 if let Some(d) = entry.value_date()
1628 && let Some(date) = parse_date_token(d.text())
1629 {
1630 return MetaValue::Date(date);
1631 }
1632 if let Some(a) = entry.value_account() {
1633 return MetaValue::Account(Account::new(a.text()));
1634 }
1635 if let Some(c) = entry.value_currency() {
1636 return MetaValue::Currency(Currency::new(c.text()));
1637 }
1638 if let Some(b) = entry.value_bool() {
1639 return MetaValue::Bool(b);
1640 }
1641 for tok in entry.syntax().children_with_tokens() {
1645 let rowan::NodeOrToken::Token(t) = tok else {
1646 continue;
1647 };
1648 match t.kind() {
1649 crate::SyntaxKind::TAG => {
1650 let stripped = t.text().trim_start_matches('#');
1651 return MetaValue::Tag(Tag::new(stripped));
1652 }
1653 crate::SyntaxKind::LINK => {
1654 let stripped = t.text().trim_start_matches('^');
1655 return MetaValue::Link(Link::new(stripped));
1656 }
1657 _ => {}
1658 }
1659 }
1660 MetaValue::None
1661}
1662
1663fn apply_inherited_state(
1677 value: &mut Directive,
1678 tag_stack: &[(Tag, Span)],
1679 meta_stack: &[(String, MetaValue, Span)],
1680) {
1681 if let Directive::Transaction(txn) = value {
1682 for (tag, _) in tag_stack {
1683 if !txn.tags.contains(tag) {
1684 txn.tags.push(tag.clone());
1685 }
1686 }
1687 }
1688 if meta_stack.is_empty() {
1689 return;
1690 }
1691 let meta = match value {
1692 Directive::Transaction(d) => &mut d.meta,
1693 Directive::Balance(d) => &mut d.meta,
1694 Directive::Open(d) => &mut d.meta,
1695 Directive::Close(d) => &mut d.meta,
1696 Directive::Commodity(d) => &mut d.meta,
1697 Directive::Pad(d) => &mut d.meta,
1698 Directive::Event(d) => &mut d.meta,
1699 Directive::Query(d) => &mut d.meta,
1700 Directive::Note(d) => &mut d.meta,
1701 Directive::Document(d) => &mut d.meta,
1702 Directive::Price(d) => &mut d.meta,
1703 Directive::Custom(d) => &mut d.meta,
1704 };
1705 for (k, v, _) in meta_stack {
1706 meta.insert(k.clone(), v.clone());
1707 }
1708}
1709
1710fn pushmeta_value(node: &crate::SyntaxNode) -> MetaValue {
1715 let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
1720 .children_with_tokens()
1721 .filter_map(rowan::NodeOrToken::into_token)
1722 .filter(|t| {
1723 !matches!(
1724 t.kind(),
1725 crate::SyntaxKind::WHITESPACE
1726 | crate::SyntaxKind::NEWLINE
1727 | crate::SyntaxKind::COMMENT
1728 )
1729 })
1730 .collect();
1731
1732 let mut i = 0;
1733 while i < raw.len() {
1734 if let Some((value, _)) = value_tokens_to_meta(&raw, i) {
1735 return value;
1736 }
1737 i += 1;
1738 }
1739 MetaValue::None
1740}
1741
1742pub(super) const fn is_comment_kind(kind: crate::SyntaxKind) -> bool {
1748 matches!(
1749 kind,
1750 crate::SyntaxKind::COMMENT
1751 | crate::SyntaxKind::PERCENT_COMMENT
1752 | crate::SyntaxKind::SHEBANG
1753 | crate::SyntaxKind::EMACS_DIRECTIVE
1754 )
1755}
1756
1757pub(super) struct TopLevelWalkResult {
1759 pub(super) errors: Vec<crate::ParseError>,
1760 pub(super) section_marker_comments: Vec<Spanned<String>>,
1761}
1762
1763fn walk_top_level_once(
1775 source_file: &SourceFile,
1776 stripped: &str,
1777 bom_offset: u32,
1778) -> TopLevelWalkResult {
1779 let mut errors: Vec<crate::ParseError> = Vec::new();
1780 let mut section_marker_comments: Vec<Spanned<String>> = Vec::new();
1781 for child in source_file.syntax().children() {
1782 let kind = child.kind();
1783 if ast::Directive::can_cast(kind) {
1785 indented_directive_check(&child, stripped, bom_offset, &mut errors);
1786 }
1787 match kind {
1788 crate::SyntaxKind::CUSTOM_DIRECTIVE => {
1789 custom_value_check(&child, bom_offset, &mut errors);
1790 }
1791 crate::SyntaxKind::TRANSACTION => {
1792 transaction_body_check(&child, bom_offset, &mut errors);
1793 }
1794 crate::SyntaxKind::ERROR_NODE => {
1795 error_node_check(&child, stripped, bom_offset, &mut errors);
1796 section_marker_check(&child, bom_offset, &mut section_marker_comments);
1797 }
1798 _ => {}
1799 }
1800 }
1801 TopLevelWalkResult {
1802 errors,
1803 section_marker_comments,
1804 }
1805}
1806
1807fn extract_unclosed_cost_brace_errors(
1816 source_file: &SourceFile,
1817 bom_offset: u32,
1818) -> Vec<crate::ParseError> {
1819 let mut out = Vec::new();
1820 for cs in source_file.syntax().descendants() {
1821 if cs.kind() != crate::SyntaxKind::COST_SPEC {
1822 continue;
1823 }
1824 let mut has_opener = false;
1825 let mut has_closer = false;
1826 for el in cs.children_with_tokens() {
1827 let rowan::NodeOrToken::Token(t) = el else {
1828 continue;
1829 };
1830 match t.kind() {
1831 crate::SyntaxKind::L_BRACE
1832 | crate::SyntaxKind::L_DOUBLE_BRACE
1833 | crate::SyntaxKind::L_BRACE_HASH => has_opener = true,
1834 crate::SyntaxKind::R_BRACE | crate::SyntaxKind::R_DOUBLE_BRACE => has_closer = true,
1835 _ => {}
1836 }
1837 }
1838 if has_opener && !has_closer {
1839 out.push(crate::ParseError::new(
1840 crate::ParseErrorKind::SyntaxError(
1841 "unclosed cost specification: missing '}'".to_string(),
1842 ),
1843 node_span(&cs, bom_offset),
1844 ));
1845 }
1846 }
1847 out
1848}
1849
1850fn indented_directive_check(
1861 child: &crate::SyntaxNode,
1862 stripped: &str,
1863 bom_offset: u32,
1864 out: &mut Vec<crate::ParseError>,
1865) {
1866 let Some(content) = child
1872 .children_with_tokens()
1873 .filter_map(rowan::NodeOrToken::into_token)
1874 .find(|t| !is_trivia_kind(t.kind()))
1875 else {
1876 return;
1877 };
1878 let content_start: usize = u32::from(content.text_range().start()) as usize;
1879 let line_start = stripped
1891 .as_bytes()
1892 .get(..content_start)
1893 .and_then(|bytes| bytes.iter().rposition(|&b| b == b'\n'))
1894 .map_or(0, |nl| nl + 1);
1895 if content_start > line_start {
1896 let end: u32 = content.text_range().end().into();
1897 let span = Span::new(
1898 (line_start as u32 + bom_offset) as usize,
1899 (end + bom_offset) as usize,
1900 );
1901 out.push(crate::ParseError::new(
1902 crate::ParseErrorKind::SyntaxError(
1903 "top-level directive must start at column 0".to_string(),
1904 ),
1905 span,
1906 ));
1907 }
1908}
1909
1910fn custom_value_check(
1925 child: &crate::SyntaxNode,
1926 bom_offset: u32,
1927 out: &mut Vec<crate::ParseError>,
1928) {
1929 {
1931 let raw: Vec<crate::SyntaxToken> = child
1936 .children_with_tokens()
1937 .filter_map(rowan::NodeOrToken::into_token)
1938 .filter(|t| !is_trivia_kind(t.kind()))
1939 .collect();
1940 let mut seen_type_string = false;
1941 let mut i = 0;
1942 while i < raw.len() {
1943 let t = &raw[i];
1944 if !seen_type_string {
1945 if t.kind() == crate::SyntaxKind::STRING {
1946 seen_type_string = true;
1947 }
1948 i += 1;
1949 continue;
1950 }
1951 if t.kind() == crate::SyntaxKind::CURRENCY {
1952 let preceded_by_number = i > 0 && raw[i - 1].kind() == crate::SyntaxKind::NUMBER;
1958 if !preceded_by_number {
1959 let range = t.text_range();
1960 let start: u32 = range.start().into();
1961 let end: u32 = range.end().into();
1962 let span =
1963 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1964 out.push(crate::ParseError::new(
1965 crate::ParseErrorKind::SyntaxError(
1966 "bare currency literal is not a valid custom directive value"
1967 .to_string(),
1968 ),
1969 span,
1970 ));
1971 }
1972 }
1973 i += 1;
1974 }
1975 }
1976}
1977
1978fn transaction_body_check(
1985 child: &crate::SyntaxNode,
1986 bom_offset: u32,
1987 out: &mut Vec<crate::ParseError>,
1988) {
1989 {
1991 let mut past_header = false;
2001 let mut saw_header_content = false;
2002 let mut line_start: Option<u32> = None;
2003 let mut line_has_content = false;
2004 for el in child.children_with_tokens() {
2005 match el {
2006 rowan::NodeOrToken::Token(t) => {
2007 if !past_header {
2008 if t.kind() == crate::SyntaxKind::NEWLINE {
2009 if saw_header_content {
2010 past_header = true;
2011 }
2012 } else if !is_trivia_kind(t.kind()) {
2013 saw_header_content = true;
2014 }
2015 continue;
2016 }
2017 let range = t.text_range();
2018 let start: u32 = range.start().into();
2019 let end: u32 = range.end().into();
2020 if line_start.is_none() {
2021 line_start = Some(start);
2022 }
2023 if t.kind() == crate::SyntaxKind::NEWLINE {
2024 if line_has_content && let Some(ls) = line_start {
2025 let span =
2027 Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2028 out.push(crate::ParseError::new(
2031 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2032 span,
2033 ));
2034 }
2035 line_start = None;
2036 line_has_content = false;
2037 } else if !is_trivia_kind(t.kind())
2038 && !is_comment_kind(t.kind())
2039 && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
2040 {
2041 line_has_content = true;
2047 }
2048 }
2049 rowan::NodeOrToken::Node(_) => {
2050 line_start = None;
2052 line_has_content = false;
2053 if !past_header {
2054 past_header = true;
2055 }
2056 }
2057 }
2058 }
2059 }
2060}
2061
2062fn error_node_check(
2072 child: &crate::SyntaxNode,
2073 stripped: &str,
2074 bom_offset: u32,
2075 out: &mut Vec<crate::ParseError>,
2076) {
2077 {
2079 let mut line_start: Option<u32> = None;
2080 let mut first_non_trivia: Option<crate::SyntaxKind> = None;
2081 for el in child.children_with_tokens() {
2082 let rowan::NodeOrToken::Token(t) = el else {
2083 continue;
2084 };
2085 let range = t.text_range();
2086 let start: u32 = range.start().into();
2087 let end: u32 = range.end().into();
2088 if line_start.is_none() {
2089 line_start = Some(start);
2090 }
2091 if t.kind() == crate::SyntaxKind::NEWLINE {
2092 let is_section = matches!(first_non_trivia, Some(crate::SyntaxKind::STAR));
2094 let is_comment = matches!(first_non_trivia, Some(k) if is_comment_kind(k));
2095 if !is_section
2096 && !is_comment
2097 && first_non_trivia.is_some()
2098 && let Some(ls) = line_start
2099 {
2100 let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2104 let line_text = stripped.get(ls as usize..end as usize).unwrap_or("");
2105 let primary = classify_recovery_error(line_text, span);
2106 let primary_is_bom =
2107 matches!(primary.kind, crate::ParseErrorKind::BomInDirectiveBody);
2108 out.push(primary);
2109 if !primary_is_bom && line_text.contains(crate::bom::BOM_CHAR) {
2119 out.push(
2120 crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2121 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
2122 );
2123 }
2124 }
2125 line_start = None;
2126 first_non_trivia = None;
2127 continue;
2128 }
2129 if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
2130 first_non_trivia = Some(t.kind());
2131 }
2132 }
2133 }
2134}
2135
2136pub(super) fn classify_recovery_error(line_text: &str, span: Span) -> crate::ParseError {
2149 if let Some(account) = crate::diagnostics::find_unicode_account(line_text) {
2150 return crate::ParseError::new(
2151 crate::ParseErrorKind::InvalidAccount(account.to_string()),
2152 span,
2153 );
2154 }
2155 if line_text.contains(crate::bom::BOM_CHAR) {
2156 return crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2157 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT);
2158 }
2159 crate::ParseError::new(
2160 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2161 span,
2162 )
2163}
2164
2165pub(super) struct DescendantsWalkResult {
2184 pub(super) inline_errors: Vec<crate::ParseError>,
2185 pub(super) top_level_comments: Vec<Spanned<String>>,
2186 pub(super) currency_occurrences: Vec<Spanned<Currency>>,
2187 pub(super) account_occurrences: Vec<Spanned<rustledger_core::Account>>,
2188}
2189
2190fn walk_descendants_once(
2198 source_file: &SourceFile,
2199 bom_offset: u32,
2200 collect_occurrences: bool,
2201) -> DescendantsWalkResult {
2202 let mut inline_errors: Vec<crate::ParseError> = Vec::new();
2203 let mut top_level_comments: Vec<Spanned<String>> = Vec::new();
2204 let mut currency_occurrences: Vec<Spanned<Currency>> = Vec::new();
2205 let mut account_occurrences: Vec<Spanned<rustledger_core::Account>> = Vec::new();
2206
2207 let mut preceded_by_ws = false;
2209
2210 for el in source_file.syntax().descendants_with_tokens() {
2211 let rowan::NodeOrToken::Token(t) = el else {
2212 if let rowan::NodeOrToken::Node(n) = el
2217 && ast::Directive::can_cast(n.kind())
2218 {
2219 preceded_by_ws = false;
2220 }
2221 continue;
2222 };
2223
2224 match t.kind() {
2226 crate::SyntaxKind::NEWLINE => preceded_by_ws = false,
2227 crate::SyntaxKind::WHITESPACE => preceded_by_ws = true,
2228 k if is_comment_kind(k) => {
2229 if !preceded_by_ws {
2230 let range = t.text_range();
2231 let start: u32 = range.start().into();
2232 let end: u32 = range.end().into();
2233 let span =
2234 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2235 top_level_comments.push(Spanned::new(t.text().to_string(), span));
2236 }
2237 }
2238 _ => {
2239 preceded_by_ws = false;
2240 }
2241 }
2242
2243 if t.kind() == crate::SyntaxKind::BOM {
2245 continue;
2246 }
2247 let kind = t.kind();
2255 let has_bom = t.text().contains(crate::bom::BOM_CHAR);
2256 let is_error_token = kind == crate::SyntaxKind::ERROR_TOKEN;
2257 let needs_in_error_check = (collect_occurrences
2260 && matches!(
2261 kind,
2262 crate::SyntaxKind::CURRENCY | crate::SyntaxKind::ACCOUNT
2263 ))
2264 || has_bom
2265 || is_error_token;
2266 if !needs_in_error_check {
2267 continue;
2268 }
2269 let in_error_node = t
2270 .parent_ancestors()
2271 .any(|a| a.kind() == crate::SyntaxKind::ERROR_NODE);
2272
2273 if collect_occurrences && kind == crate::SyntaxKind::CURRENCY && !in_error_node {
2276 let range = t.text_range();
2277 let start: u32 = range.start().into();
2278 let end: u32 = range.end().into();
2279 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2280 currency_occurrences.push(Spanned::new(Currency::new(t.text()), span));
2281 }
2282
2283 if collect_occurrences && kind == crate::SyntaxKind::ACCOUNT && !in_error_node {
2291 let range = t.text_range();
2292 let start: u32 = range.start().into();
2293 let end: u32 = range.end().into();
2294 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2295 account_occurrences.push(Spanned::new(rustledger_core::Account::new(t.text()), span));
2296 }
2297
2298 if (!has_bom && !is_error_token) || in_error_node {
2304 continue;
2305 }
2306 let range = t.text_range();
2307 let start: u32 = range.start().into();
2308 let end: u32 = range.end().into();
2309 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2310 if has_bom {
2311 inline_errors.push(
2312 crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2313 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
2314 );
2315 } else {
2316 inline_errors.push(crate::ParseError::new(
2317 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2318 span,
2319 ));
2320 }
2321 }
2322
2323 DescendantsWalkResult {
2324 inline_errors,
2325 top_level_comments,
2326 currency_occurrences,
2327 account_occurrences,
2328 }
2329}
2330
2331fn section_marker_check(
2338 child: &crate::SyntaxNode,
2339 bom_offset: u32,
2340 out: &mut Vec<Spanned<String>>,
2341) {
2342 let mut line_start: Option<u32> = None;
2347 let mut first_non_trivia: Option<crate::SyntaxKind> = None;
2348 for el in child.children_with_tokens() {
2349 let rowan::NodeOrToken::Token(t) = el else {
2350 continue;
2351 };
2352 let range = t.text_range();
2353 let start: u32 = range.start().into();
2354 let end: u32 = range.end().into();
2355 if line_start.is_none() {
2356 line_start = Some(start);
2357 }
2358 if t.kind() == crate::SyntaxKind::NEWLINE {
2359 if first_non_trivia == Some(crate::SyntaxKind::STAR)
2360 && let Some(ls) = line_start
2361 {
2362 let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2363 out.push(Spanned::new(String::new(), span));
2364 }
2365 line_start = None;
2366 first_non_trivia = None;
2367 continue;
2368 }
2369 if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
2370 first_non_trivia = Some(t.kind());
2371 }
2372 }
2373}
2374
2375pub(super) fn parse_date_token(text: &str) -> Option<NaiveDate> {
2387 if text.len() == 10
2389 && text.as_bytes()[4] == b'-'
2390 && text.as_bytes()[7] == b'-'
2391 && let (Ok(y), Ok(m), Ok(d)) = (
2392 text[0..4].parse::<i32>(),
2393 text[5..7].parse::<u32>(),
2394 text[8..10].parse::<u32>(),
2395 )
2396 {
2397 return naive_date(y, m, d);
2398 }
2399 crate::diagnostics::normalize_date_str(text)
2403 .parse::<NaiveDate>()
2404 .ok()
2405}
2406
2407fn parse_directive_date(
2415 date_tok: &ast::Date,
2416 errors: &mut Vec<crate::ParseError>,
2417 bom_offset: u32,
2418) -> Option<NaiveDate> {
2419 let text = date_tok.text();
2420 if let Some(d) = parse_date_token(text) {
2421 return Some(d);
2422 }
2423 let range = date_tok.syntax().text_range();
2424 let start: u32 = range.start().into();
2425 let end: u32 = range.end().into();
2426 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2427 errors.push(crate::ParseError::new(
2428 crate::ParseErrorKind::InvalidDateValue(crate::diagnostics::describe_invalid_date(text)),
2429 span,
2430 ));
2431 None
2432}
2433
2434pub(super) fn decode_string_token(text: &str) -> Option<String> {
2440 let bytes = text.as_bytes();
2441 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
2442 return None;
2443 }
2444 let raw = &text[1..text.len() - 1];
2445 if !raw.contains('\\') {
2446 return Some(raw.to_string());
2447 }
2448 let mut out = String::with_capacity(raw.len());
2449 let mut chars = raw.chars();
2450 while let Some(c) = chars.next() {
2451 if c != '\\' {
2452 out.push(c);
2453 continue;
2454 }
2455 match chars.next() {
2456 Some('"') => out.push('"'),
2457 Some('\\') => out.push('\\'),
2458 Some('n') => out.push('\n'),
2459 Some('t') => out.push('\t'),
2460 Some('r') => out.push('\r'),
2461 Some(other) => out.push(other),
2462 None => {}
2463 }
2464 }
2465 Some(out)
2466}
2467
2468pub(super) fn parse_decimal_token(text: &str) -> Option<Decimal> {
2471 use std::str::FromStr;
2472 let cleaned: String;
2473 let s = if text.contains(',') {
2474 cleaned = text.replace(',', "");
2475 cleaned.as_str()
2476 } else {
2477 text
2478 };
2479 Decimal::from_str(s).ok()
2480}
2481
2482pub(super) fn number_meta_value(text: &str, value: Decimal) -> MetaValue {
2491 use rust_decimal::prelude::ToPrimitive;
2492 if !text.contains('.')
2493 && !text.contains('e')
2494 && !text.contains('E')
2495 && let Some(i) = value.to_i64()
2496 {
2497 return MetaValue::Int(i);
2498 }
2499 MetaValue::Number(value)
2500}
2501
2502fn node_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2508 let range = node.text_range();
2509 let start: u32 = range.start().into();
2510 let end: u32 = range.end().into();
2511 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2512}
2513
2514pub(super) const fn is_trivia_kind(kind: crate::SyntaxKind) -> bool {
2526 matches!(
2527 kind,
2528 crate::SyntaxKind::WHITESPACE
2529 | crate::SyntaxKind::NEWLINE
2530 | crate::SyntaxKind::COMMENT
2531 | crate::SyntaxKind::PERCENT_COMMENT
2532 | crate::SyntaxKind::SHEBANG
2533 | crate::SyntaxKind::EMACS_DIRECTIVE
2534 )
2535}
2536
2537fn posting_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2546 let range = node.text_range();
2547 let start: u32 = range.start().into();
2548 let end_raw: u32 = range.end().into();
2549 let end = node
2552 .children_with_tokens()
2553 .filter_map(rowan::NodeOrToken::into_token)
2554 .find(|t| t.kind() == crate::SyntaxKind::NEWLINE)
2555 .map_or(end_raw, |t| u32::from(t.text_range().start()));
2556 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2557}
2558
2559fn single_line_directive_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2566 let range = node.text_range();
2567 let start_raw: u32 = range.start().into();
2568 let end_raw: u32 = range.end().into();
2569 let mut content_start: Option<u32> = None;
2570 let mut terminator: Option<u32> = None;
2571 for t in node
2572 .children_with_tokens()
2573 .filter_map(rowan::NodeOrToken::into_token)
2574 {
2575 if content_start.is_none() {
2576 if !is_trivia_kind(t.kind()) {
2577 content_start = Some(u32::from(t.text_range().start()));
2578 }
2579 } else if t.kind() == crate::SyntaxKind::NEWLINE {
2580 terminator = Some(u32::from(t.text_range().start()));
2581 break;
2582 }
2583 }
2584 let start = content_start.unwrap_or(start_raw);
2585 let end = terminator.unwrap_or(end_raw);
2586 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2587}
2588
2589fn fixup_directive_spans(
2596 source_file: &SourceFile,
2597 bom_offset: u32,
2598 converted_nodes: &[crate::SyntaxNode],
2599 directives: &mut [Spanned<Directive>],
2600) {
2601 debug_assert_eq!(
2602 converted_nodes.len(),
2603 directives.len(),
2604 "converted_nodes and directives must be parallel arrays"
2605 );
2606
2607 let all_starts: Vec<(usize, usize)> = source_file
2615 .syntax()
2616 .children()
2617 .filter(|n| ast::Directive::can_cast(n.kind()))
2618 .map(|n| {
2619 let raw_start: u32 = n.text_range().start().into();
2620 let content_start = n
2621 .descendants_with_tokens()
2622 .filter_map(rowan::NodeOrToken::into_token)
2623 .find(|t| !is_trivia_kind(t.kind()))
2624 .map_or_else(
2625 || (raw_start + bom_offset) as usize,
2626 |t| (u32::from(t.text_range().start()) + bom_offset) as usize,
2627 );
2628 ((raw_start + bom_offset) as usize, content_start)
2629 })
2630 .collect();
2631
2632 let source_end: usize =
2633 (u32::from(source_file.syntax().text_range().end()) + bom_offset) as usize;
2634
2635 for (i, spanned) in directives.iter_mut().enumerate() {
2649 let node = &converted_nodes[i];
2650 let raw_start: usize = (u32::from(node.text_range().start()) + bom_offset) as usize;
2651 let node_end: usize = (u32::from(node.text_range().end()) + bom_offset) as usize;
2652 if let Some(pos) = all_starts.iter().position(|(rs, _)| *rs == raw_start) {
2653 let start = all_starts[pos].1;
2654 let end = all_starts
2655 .get(pos + 1)
2656 .map_or(source_end, |(_, content)| *content);
2657 spanned.span = Span::new(start, end);
2658 } else {
2659 let content_start = node
2666 .descendants_with_tokens()
2667 .filter_map(rowan::NodeOrToken::into_token)
2668 .find(|t| !is_trivia_kind(t.kind()))
2669 .map_or(raw_start, |t| {
2670 (u32::from(t.text_range().start()) + bom_offset) as usize
2671 });
2672 spanned.span = Span::new(content_start, node_end);
2673 }
2674 }
2675}
2676
2677#[cfg(test)]
2678mod tests {
2679 use super::*;
2680
2681 fn assert_directive_count(result: &ParseResult, expected: usize) {
2682 assert_eq!(
2683 result.directives.len(),
2684 expected,
2685 "directive count mismatch: {:#?}",
2686 result.directives
2687 );
2688 }
2689
2690 #[test]
2691 fn open_directive_basic() {
2692 let src = "2024-01-15 open Assets:Cash\n";
2693 let result = parse_via_cst(src);
2694 assert_directive_count(&result, 1);
2695 let Directive::Open(open) = &result.directives[0].value else {
2696 panic!("expected Open, got {:?}", result.directives[0].value);
2697 };
2698 assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
2699 assert_eq!(open.account.as_str(), "Assets:Cash");
2700 assert!(open.currencies.is_empty());
2701 assert!(open.booking.is_none());
2702 assert!(open.meta.is_empty());
2703 }
2704
2705 #[test]
2706 fn open_directive_with_currencies_and_booking() {
2707 let src = "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n";
2708 let result = parse_via_cst(src);
2709 assert_directive_count(&result, 1);
2710 let Directive::Open(open) = &result.directives[0].value else {
2711 panic!("expected Open");
2712 };
2713 let currencies: Vec<&str> = open.currencies.iter().map(Currency::as_str).collect();
2714 assert_eq!(currencies, vec!["USD", "EUR"]);
2715 assert_eq!(open.booking.as_deref(), Some("STRICT"));
2716 }
2717
2718 #[test]
2719 fn open_directive_with_metadata() {
2720 let src = "2024-01-15 open Assets:Cash\n note: \"main checking\"\n number: 42\n";
2721 let result = parse_via_cst(src);
2722 assert_directive_count(&result, 1);
2723 let Directive::Open(open) = &result.directives[0].value else {
2724 panic!("expected Open");
2725 };
2726 assert_eq!(
2727 open.meta.get("note"),
2728 Some(&MetaValue::String("main checking".to_string()))
2729 );
2730 assert_eq!(
2731 open.meta.get("number"),
2732 Some(&MetaValue::Int(42))
2734 );
2735 }
2736
2737 #[test]
2738 fn close_directive_basic() {
2739 let src = "2024-12-31 close Assets:Cash\n";
2740 let result = parse_via_cst(src);
2741 assert_directive_count(&result, 1);
2742 let Directive::Close(close) = &result.directives[0].value else {
2743 panic!("expected Close, got {:?}", result.directives[0].value);
2744 };
2745 assert_eq!(close.date, naive_date(2024, 12, 31).unwrap());
2746 assert_eq!(close.account.as_str(), "Assets:Cash");
2747 }
2748
2749 #[test]
2750 fn commodity_directive_basic() {
2751 let src = "2024-01-01 commodity HOOL\n";
2752 let result = parse_via_cst(src);
2753 assert_directive_count(&result, 1);
2754 let Directive::Commodity(c) = &result.directives[0].value else {
2755 panic!("expected Commodity");
2756 };
2757 assert_eq!(c.currency.as_str(), "HOOL");
2758 }
2759
2760 #[test]
2761 fn bom_offset_is_included_in_spans() {
2762 let src = "\u{FEFF}2024-01-15 open Assets:Cash\n";
2763 let result = parse_via_cst(src);
2764 assert!(result.has_leading_bom);
2765 let span = result.directives[0].span;
2766 assert_eq!(span.start, 3, "span should include BOM offset");
2767 }
2768
2769 #[test]
2770 fn note_directive_basic() {
2771 let src = "2024-01-15 note Assets:Cash \"deposit received\"\n";
2772 let result = parse_via_cst(src);
2773 assert_directive_count(&result, 1);
2774 let Directive::Note(note) = &result.directives[0].value else {
2775 panic!("expected Note");
2776 };
2777 assert_eq!(note.date, naive_date(2024, 1, 15).unwrap());
2778 assert_eq!(note.account.as_str(), "Assets:Cash");
2779 assert_eq!(note.comment, "deposit received");
2780 }
2781
2782 #[test]
2783 fn document_directive_basic() {
2784 let src = "2024-01-15 document Assets:Cash \"/path/to/file.pdf\"\n";
2785 let result = parse_via_cst(src);
2786 assert_directive_count(&result, 1);
2787 let Directive::Document(d) = &result.directives[0].value else {
2788 panic!("expected Document");
2789 };
2790 assert_eq!(d.account.as_str(), "Assets:Cash");
2791 assert_eq!(d.path, "/path/to/file.pdf");
2792 assert!(d.tags.is_empty());
2794 assert!(d.links.is_empty());
2795 }
2796
2797 #[test]
2798 fn event_directive_basic() {
2799 let src = "2024-01-15 event \"location\" \"Berlin\"\n";
2800 let result = parse_via_cst(src);
2801 assert_directive_count(&result, 1);
2802 let Directive::Event(e) = &result.directives[0].value else {
2803 panic!("expected Event");
2804 };
2805 assert_eq!(e.event_type, "location");
2806 assert_eq!(e.value, "Berlin");
2807 }
2808
2809 #[test]
2810 fn query_directive_basic() {
2811 let src = "2024-01-15 query \"income\" \"SELECT account, sum(position)\"\n";
2812 let result = parse_via_cst(src);
2813 assert_directive_count(&result, 1);
2814 let Directive::Query(q) = &result.directives[0].value else {
2815 panic!("expected Query");
2816 };
2817 assert_eq!(q.name, "income");
2818 assert_eq!(q.query, "SELECT account, sum(position)");
2819 }
2820
2821 #[test]
2822 fn price_directive_basic() {
2823 let src = "2024-01-15 price USD 1.10 EUR\n";
2824 let result = parse_via_cst(src);
2825 assert_directive_count(&result, 1);
2826 let Directive::Price(p) = &result.directives[0].value else {
2827 panic!("expected Price");
2828 };
2829 assert_eq!(p.currency.as_str(), "USD");
2830 assert_eq!(p.amount.number, Decimal::new(110, 2));
2831 assert_eq!(p.amount.currency.as_str(), "EUR");
2832 }
2833
2834 #[test]
2835 fn balance_directive_basic() {
2836 let src = "2024-06-30 balance Assets:Cash 100.00 USD\n";
2837 let result = parse_via_cst(src);
2838 assert_directive_count(&result, 1);
2839 let Directive::Balance(b) = &result.directives[0].value else {
2840 panic!("expected Balance");
2841 };
2842 assert_eq!(b.account.as_str(), "Assets:Cash");
2843 assert_eq!(b.amount.number, Decimal::new(10000, 2));
2844 assert_eq!(b.amount.currency.as_str(), "USD");
2845 assert!(b.tolerance.is_none());
2846 }
2847
2848 #[test]
2849 fn balance_directive_with_explicit_tolerance() {
2850 let src = "2024-06-30 balance Assets:Cash 100.00 ~ 0.05 USD\n";
2851 let result = parse_via_cst(src);
2852 assert_directive_count(&result, 1);
2853 let Directive::Balance(b) = &result.directives[0].value else {
2854 panic!("expected Balance");
2855 };
2856 assert_eq!(b.amount.number, Decimal::new(10000, 2));
2857 assert_eq!(b.tolerance, Some(Decimal::new(5, 2)));
2858 }
2859
2860 #[test]
2861 fn pad_directive_basic() {
2862 let src = "2024-01-01 pad Assets:Cash Equity:Opening-Balances\n";
2863 let result = parse_via_cst(src);
2864 assert_directive_count(&result, 1);
2865 let Directive::Pad(p) = &result.directives[0].value else {
2866 panic!("expected Pad");
2867 };
2868 assert_eq!(p.account.as_str(), "Assets:Cash");
2869 assert_eq!(p.source_account.as_str(), "Equity:Opening-Balances");
2870 }
2871
2872 #[test]
2873 fn custom_directive_basic() {
2874 let src = "2024-01-01 custom \"budget\" \"food\" 500 USD\n";
2875 let result = parse_via_cst(src);
2876 assert_directive_count(&result, 1);
2877 let Directive::Custom(c) = &result.directives[0].value else {
2878 panic!("expected Custom");
2879 };
2880 assert_eq!(c.custom_type, "budget");
2881 assert_eq!(c.values.len(), 2);
2882 assert_eq!(c.values[0], MetaValue::String("food".to_string()));
2883 let MetaValue::Amount(amt) = &c.values[1] else {
2885 panic!("expected Amount, got {:?}", c.values[1]);
2886 };
2887 assert_eq!(amt.number, Decimal::from(500));
2888 assert_eq!(amt.currency.as_str(), "USD");
2889 }
2890
2891 #[test]
2892 fn custom_directive_heterogeneous_values() {
2893 let src = "2024-01-01 custom \"test\" Assets:Cash TRUE 42 2024-06-15\n";
2894 let result = parse_via_cst(src);
2895 let Directive::Custom(c) = &result.directives[0].value else {
2896 panic!("expected Custom");
2897 };
2898 assert_eq!(c.values.len(), 4);
2899 assert!(matches!(c.values[0], MetaValue::Account(_)));
2900 assert_eq!(c.values[1], MetaValue::Bool(true));
2901 assert_eq!(c.values[2], MetaValue::Int(42));
2902 assert!(matches!(c.values[3], MetaValue::Date(_)));
2903 }
2904
2905 #[test]
2906 fn number_meta_value_int_vs_decimal_discriminator() {
2907 use rust_decimal_macros::dec;
2908 assert_eq!(number_meta_value("42", dec!(42)), MetaValue::Int(42));
2911 assert_eq!(number_meta_value("0", dec!(0)), MetaValue::Int(0));
2912 assert_eq!(number_meta_value("1", dec!(-1)), MetaValue::Int(-1));
2913 assert_eq!(
2915 number_meta_value("42.0", dec!(42.0)),
2916 MetaValue::Number(dec!(42.0))
2917 );
2918 assert_eq!(
2922 number_meta_value("1e3", dec!(1000)),
2923 MetaValue::Number(dec!(1000))
2924 );
2925 let huge = "99999999999999999999999999";
2927 let huge_dec = Decimal::from_str_exact(huge).unwrap();
2928 assert_eq!(
2929 number_meta_value(huge, huge_dec),
2930 MetaValue::Number(huge_dec)
2931 );
2932 }
2933
2934 #[test]
2935 fn option_directive_populates_options_field() {
2936 let src = "option \"title\" \"My Ledger\"\n";
2937 let result = parse_via_cst(src);
2938 assert_directive_count(&result, 0);
2939 assert_eq!(result.options.len(), 1);
2940 assert_eq!(result.options[0].0, "title");
2941 assert_eq!(result.options[0].1, "My Ledger");
2942 }
2943
2944 #[test]
2945 fn include_directive_populates_includes_field() {
2946 let src = "include \"shared.beancount\"\n";
2947 let result = parse_via_cst(src);
2948 assert_directive_count(&result, 0);
2949 assert_eq!(result.includes.len(), 1);
2950 assert_eq!(result.includes[0].0, "shared.beancount");
2951 }
2952
2953 #[test]
2954 fn plugin_directive_with_config() {
2955 let src = "plugin \"my.plugin\" \"cfg\"\n";
2956 let result = parse_via_cst(src);
2957 assert_directive_count(&result, 0);
2958 assert_eq!(result.plugins.len(), 1);
2959 assert_eq!(result.plugins[0].0, "my.plugin");
2960 assert_eq!(result.plugins[0].1.as_deref(), Some("cfg"));
2961 }
2962
2963 #[test]
2964 fn plugin_directive_without_config() {
2965 let src = "plugin \"my.plugin\"\n";
2966 let result = parse_via_cst(src);
2967 assert_eq!(result.plugins.len(), 1);
2968 assert_eq!(result.plugins[0].0, "my.plugin");
2969 assert!(result.plugins[0].1.is_none());
2970 }
2971
2972 #[test]
2975 fn transaction_basic_two_postings() {
2976 let src = "2024-01-15 * \"Coffee Shop\" \"Morning coffee\"\n \
2977 Expenses:Food:Coffee 5.00 USD\n \
2978 Assets:Cash\n";
2979 let result = parse_via_cst(src);
2980 assert_directive_count(&result, 1);
2981 let Directive::Transaction(t) = &result.directives[0].value else {
2982 panic!("expected Transaction");
2983 };
2984 assert_eq!(t.date, naive_date(2024, 1, 15).unwrap());
2985 assert_eq!(t.flag, '*');
2986 assert_eq!(
2987 t.payee.as_ref().map(InternedStr::as_str),
2988 Some("Coffee Shop")
2989 );
2990 assert_eq!(t.narration.as_str(), "Morning coffee");
2991 assert_eq!(t.postings.len(), 2);
2992
2993 let p0 = &t.postings[0].value;
2994 assert_eq!(p0.account.as_str(), "Expenses:Food:Coffee");
2995 let Some(IncompleteAmount::Complete(amt)) = &p0.units else {
2996 panic!("expected complete units, got {:?}", p0.units);
2997 };
2998 assert_eq!(amt.number, Decimal::new(500, 2));
2999 assert_eq!(amt.currency.as_str(), "USD");
3000
3001 let p1 = &t.postings[1].value;
3002 assert_eq!(p1.account.as_str(), "Assets:Cash");
3003 assert!(p1.units.is_none(), "auto-posting has no units");
3004 }
3005
3006 #[test]
3007 fn transaction_narration_only_no_payee() {
3008 let src = "2024-01-15 ! \"Pending\"\n Assets:Cash -5 USD\n";
3009 let result = parse_via_cst(src);
3010 let Directive::Transaction(t) = &result.directives[0].value else {
3011 panic!("expected Transaction");
3012 };
3013 assert_eq!(t.flag, '!');
3014 assert!(t.payee.is_none());
3015 assert_eq!(t.narration.as_str(), "Pending");
3016 }
3017
3018 #[test]
3019 fn transaction_three_plus_header_strings_surface_last_as_narration() {
3020 let src = "2024-01-15 * \"a\" \"b\" \"c\"\n Assets:Cash -5 USD\n";
3025 let result = parse_via_cst(src);
3026 let Directive::Transaction(t) = &result.directives[0].value else {
3027 panic!("expected Transaction");
3028 };
3029 assert!(t.payee.is_none(), "3+ strings drop the payee");
3030 assert_eq!(t.narration.as_str(), "c", "last string becomes narration");
3031 }
3032
3033 #[test]
3034 fn transaction_implied_flag_via_leading_string() {
3035 let src = "2024-01-15 \"Implied\"\n Assets:Cash -5 USD\n";
3036 let result = parse_via_cst(src);
3037 let Directive::Transaction(t) = &result.directives[0].value else {
3038 panic!("expected Transaction");
3039 };
3040 assert_eq!(t.flag, '*', "implied flag defaults to *");
3041 }
3042
3043 #[test]
3044 fn transaction_with_tags_and_links() {
3045 let src = "2024-01-15 * \"Coffee\" #daily ^trip1\n Assets:Cash -5 USD\n";
3046 let result = parse_via_cst(src);
3047 let Directive::Transaction(t) = &result.directives[0].value else {
3048 panic!("expected Transaction");
3049 };
3050 assert_eq!(t.tags.len(), 1);
3051 assert_eq!(t.tags[0].as_str(), "daily");
3052 assert_eq!(t.links.len(), 1);
3053 assert_eq!(t.links[0].as_str(), "trip1");
3054 }
3055
3056 #[test]
3057 fn transaction_with_signed_amount() {
3058 let src = "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n";
3059 let result = parse_via_cst(src);
3060 let Directive::Transaction(t) = &result.directives[0].value else {
3061 panic!("expected Transaction");
3062 };
3063 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3064 panic!("expected complete units");
3065 };
3066 assert_eq!(amt.number, Decimal::new(-500, 2));
3067 }
3068
3069 #[test]
3070 fn transaction_with_posting_flag() {
3071 let src = "2024-01-15 * \"x\"\n ! Assets:Cash -5 USD\n";
3072 let result = parse_via_cst(src);
3073 let Directive::Transaction(t) = &result.directives[0].value else {
3074 panic!("expected Transaction");
3075 };
3076 assert_eq!(t.postings[0].value.flag, Some('!'));
3077 }
3078
3079 #[test]
3080 fn transaction_with_cost_spec_per_unit() {
3081 let src = "2024-01-15 * \"buy\"\n \
3082 Assets:Inv 10 HOOL {500.00 USD}\n \
3083 Assets:Cash\n";
3084 let result = parse_via_cst(src);
3085 let Directive::Transaction(t) = &result.directives[0].value else {
3086 panic!("expected Transaction");
3087 };
3088 let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
3089 assert!(!cost.merge);
3090 let Some(CostNumber::PerUnit { value }) = &cost.number else {
3091 panic!("expected PerUnit");
3092 };
3093 assert_eq!(*value, Decimal::new(50000, 2));
3094 assert_eq!(cost.currency.as_ref().unwrap().as_str(), "USD");
3095 }
3096
3097 #[test]
3098 fn transaction_with_cost_spec_total() {
3099 let src = "2024-01-15 * \"buy\"\n \
3100 Assets:Inv 10 HOOL {{5000 USD}}\n \
3101 Assets:Cash\n";
3102 let result = parse_via_cst(src);
3103 let Directive::Transaction(t) = &result.directives[0].value else {
3104 panic!("expected Transaction");
3105 };
3106 let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
3107 let Some(CostNumber::Total { value }) = &cost.number else {
3108 panic!("expected Total");
3109 };
3110 assert_eq!(*value, Decimal::from(5000));
3111 }
3112
3113 #[test]
3114 fn transaction_with_price_annotation_unit() {
3115 let src = "2024-01-15 * \"buy\"\n \
3116 Assets:Inv 10 HOOL @ 510 USD\n \
3117 Assets:Cash\n";
3118 let result = parse_via_cst(src);
3119 let Directive::Transaction(t) = &result.directives[0].value else {
3120 panic!("expected Transaction");
3121 };
3122 let price = t.postings[0]
3123 .value
3124 .price
3125 .as_ref()
3126 .expect("price annotation");
3127 assert!(price.is_unit());
3128 let Some(IncompleteAmount::Complete(amt)) = &price.amount else {
3129 panic!("expected complete price amount");
3130 };
3131 assert_eq!(amt.number, Decimal::from(510));
3132 assert_eq!(amt.currency.as_str(), "USD");
3133 }
3134
3135 #[test]
3136 fn transaction_with_price_annotation_total() {
3137 let src = "2024-01-15 * \"buy\"\n \
3138 Assets:Inv 10 HOOL @@ 5100 USD\n \
3139 Assets:Cash\n";
3140 let result = parse_via_cst(src);
3141 let Directive::Transaction(t) = &result.directives[0].value else {
3142 panic!("expected Transaction");
3143 };
3144 let price = t.postings[0]
3145 .value
3146 .price
3147 .as_ref()
3148 .expect("price annotation");
3149 assert!(!price.is_unit(), "@@ is total form");
3150 }
3151
3152 #[test]
3155 fn document_directive_preserves_tags_and_links() {
3156 let src = "2024-06-01 document Assets:Bank \"stmt.pdf\" #quarter1 ^scan42 #urgent\n";
3160 let result = parse_via_cst(src);
3161 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3162 let Directive::Document(doc) = &result.directives[0].value else {
3163 panic!("expected Document");
3164 };
3165 let tags: Vec<&str> = doc.tags.iter().map(Tag::as_str).collect();
3166 let links: Vec<&str> = doc.links.iter().map(Link::as_str).collect();
3167 assert_eq!(tags, vec!["quarter1", "urgent"]);
3168 assert_eq!(links, vec!["scan42"]);
3169 }
3170
3171 #[test]
3172 fn open_directive_rejects_invalid_booking_method() {
3173 let src = "2024-01-01 open Assets:Bank USD \"GARBAGE\"\n";
3178 let result = parse_via_cst(src);
3179 assert_eq!(result.directives.len(), 0, "directive should be dropped");
3180 assert_eq!(result.errors.len(), 1);
3181 let err = &result.errors[0];
3182 assert!(
3183 matches!(
3184 &err.kind,
3185 crate::ParseErrorKind::InvalidBookingMethod(s) if s == "GARBAGE"
3186 ),
3187 "expected InvalidBookingMethod, got {:?}",
3188 err.kind,
3189 );
3190 }
3191
3192 #[test]
3193 fn open_directive_accepts_all_valid_booking_methods() {
3194 for method in VALID_BOOKING_METHODS {
3195 let src = format!("2024-01-01 open Assets:Bank USD \"{method}\"\n");
3196 let result = parse_via_cst(&src);
3197 assert!(
3198 result.errors.is_empty(),
3199 "{method} rejected: {:?}",
3200 result.errors
3201 );
3202 let Directive::Open(open) = &result.directives[0].value else {
3203 panic!("{method}: expected Open");
3204 };
3205 assert_eq!(open.booking.as_deref(), Some(*method));
3206 }
3207 }
3208
3209 #[test]
3210 fn unclosed_pushtag_at_eof_emits_diagnostic() {
3211 let src = "pushtag #active\n2024-01-01 open Assets:Bank USD\n";
3214 let result = parse_via_cst(src);
3215 let unclosed: Vec<_> = result
3216 .errors
3217 .iter()
3218 .filter_map(|e| match &e.kind {
3219 crate::ParseErrorKind::UnclosedPushtag(t) => Some(t.clone()),
3220 _ => None,
3221 })
3222 .collect();
3223 assert_eq!(unclosed, vec!["active".to_string()]);
3224 }
3225
3226 #[test]
3227 fn unclosed_pushmeta_at_eof_emits_diagnostic() {
3228 let src = "pushmeta location: \"NYC\"\n2024-01-01 open Assets:Bank USD\n";
3230 let result = parse_via_cst(src);
3231 let unclosed: Vec<_> = result
3232 .errors
3233 .iter()
3234 .filter_map(|e| match &e.kind {
3235 crate::ParseErrorKind::UnclosedPushmeta(k) => Some(k.clone()),
3236 _ => None,
3237 })
3238 .collect();
3239 assert_eq!(unclosed, vec!["location".to_string()]);
3240 }
3241
3242 #[test]
3243 fn invalid_poptag_on_mismatch_emits_diagnostic() {
3244 let src = "pushtag #foo\npoptag #bar\npoptag #foo\n";
3247 let result = parse_via_cst(src);
3248 let mismatches: Vec<_> = result
3249 .errors
3250 .iter()
3251 .filter_map(|e| match &e.kind {
3252 crate::ParseErrorKind::InvalidPoptag(t) => Some(t.clone()),
3253 _ => None,
3254 })
3255 .collect();
3256 assert_eq!(mismatches, vec!["bar".to_string()]);
3257 let leftover: Vec<_> = result
3260 .errors
3261 .iter()
3262 .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushtag(_)))
3263 .collect();
3264 assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
3265 }
3266
3267 #[test]
3268 fn invalid_popmeta_on_mismatch_emits_diagnostic() {
3269 let src = "pushmeta location: \"NYC\"\npopmeta nope:\npopmeta location:\n";
3273 let result = parse_via_cst(src);
3274 let mismatches: Vec<_> = result
3275 .errors
3276 .iter()
3277 .filter_map(|e| match &e.kind {
3278 crate::ParseErrorKind::InvalidPopmeta(k) => Some(k.clone()),
3279 _ => None,
3280 })
3281 .collect();
3282 assert_eq!(mismatches, vec!["nope".to_string()]);
3283 let leftover: Vec<_> = result
3284 .errors
3285 .iter()
3286 .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushmeta(_)))
3287 .collect();
3288 assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
3289 }
3290
3291 #[test]
3292 fn pushmeta_shadow_pop_restores_prior_value() {
3293 let src = "pushmeta loc: \"NYC\"\n\
3296 pushmeta loc: \"LDN\"\n\
3297 popmeta loc:\n\
3298 2024-01-01 open Assets:Bank USD\n\
3299 popmeta loc:\n";
3300 let result = parse_via_cst(src);
3301 let Directive::Open(open) = &result.directives[0].value else {
3302 panic!("expected Open");
3303 };
3304 assert_eq!(
3305 open.meta.get("loc"),
3306 Some(&MetaValue::String("NYC".to_string())),
3307 "shadow pop should restore NYC, got {:?}",
3308 open.meta.get("loc"),
3309 );
3310 }
3311
3312 #[test]
3313 fn error_recovery_classifies_bom_in_directive_body() {
3314 let src = "garbage\u{FEFF}content\n";
3318 let result = parse_via_cst(src);
3319 let bom_errors: Vec<_> = result
3320 .errors
3321 .iter()
3322 .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3323 .collect();
3324 assert_eq!(bom_errors.len(), 1, "errors: {:?}", result.errors);
3325 assert!(
3326 bom_errors[0].hint.is_some(),
3327 "BomInDirectiveBody should carry BOM_REMOVAL_HINT",
3328 );
3329 }
3330
3331 #[test]
3332 fn error_recovery_emits_both_invalid_account_and_bom_for_dual_line() {
3333 let src = "garbage Assets:Café\u{FEFF}content\n";
3340 let result = parse_via_cst(src);
3341 let invalid_account_count = result
3342 .errors
3343 .iter()
3344 .filter(|e| matches!(e.kind, crate::ParseErrorKind::InvalidAccount(_)))
3345 .count();
3346 let bom_count = result
3347 .errors
3348 .iter()
3349 .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3350 .count();
3351 assert_eq!(
3352 invalid_account_count, 1,
3353 "expected one InvalidAccount: {:?}",
3354 result.errors
3355 );
3356 assert_eq!(
3357 bom_count, 1,
3358 "expected secondary BomInDirectiveBody: {:?}",
3359 result.errors
3360 );
3361 let bom_err = result
3364 .errors
3365 .iter()
3366 .find(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3367 .unwrap();
3368 assert!(bom_err.hint.is_some());
3369 }
3370
3371 #[test]
3372 fn error_recovery_classifies_unicode_account() {
3373 let src = "garbage Assets:Café content\n";
3378 let result = parse_via_cst(src);
3379 let unicode_errors: Vec<_> = result
3380 .errors
3381 .iter()
3382 .filter_map(|e| match &e.kind {
3383 crate::ParseErrorKind::InvalidAccount(s) => Some(s.clone()),
3384 _ => None,
3385 })
3386 .collect();
3387 assert_eq!(unicode_errors, vec!["Assets:Café".to_string()]);
3388 }
3389
3390 #[test]
3391 fn transaction_with_pipe_emits_deprecated_pipe_symbol() {
3392 let src = "2024-01-15 * \"Acme\" | \"invoice\"\n Assets:Cash -5 USD\n Expenses:X\n";
3395 let result = parse_via_cst(src);
3396 let pipe_count = result
3397 .errors
3398 .iter()
3399 .filter(|e| matches!(e.kind, crate::ParseErrorKind::DeprecatedPipeSymbol))
3400 .count();
3401 assert_eq!(pipe_count, 1, "errors: {:?}", result.errors);
3402 assert_eq!(result.directives.len(), 1);
3404 }
3405
3406 #[test]
3407 fn transaction_trailing_comments_after_final_posting() {
3408 let src = "2024-01-15 * \"x\"\n \
3412 Assets:Cash -5 USD\n \
3413 Expenses:X\n \
3414 ; trailing one\n \
3415 ; trailing two\n";
3416 let result = parse_via_cst(src);
3417 let Directive::Transaction(t) = &result.directives[0].value else {
3418 panic!("expected Transaction");
3419 };
3420 assert_eq!(
3421 t.trailing_comments.len(),
3422 2,
3423 "got: {:?}",
3424 t.trailing_comments
3425 );
3426 assert!(t.trailing_comments[0].contains("trailing one"));
3427 assert!(t.trailing_comments[1].contains("trailing two"));
3428 }
3429
3430 #[test]
3433 fn posting_amount_evaluates_division() {
3434 let src = "2024-01-15 * \"split\"\n \
3439 Expenses:Food 120 / 3 USD\n \
3440 Assets:Bank -40 USD\n";
3441 let result = parse_via_cst(src);
3442 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3443 let Directive::Transaction(t) = &result.directives[0].value else {
3444 panic!("expected Transaction");
3445 };
3446 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3447 panic!("expected complete amount on posting 0");
3448 };
3449 assert_eq!(amt.number, Decimal::from(40));
3450 assert_eq!(amt.currency.as_str(), "USD");
3451 }
3452
3453 #[test]
3454 fn posting_amount_evaluates_addition_and_multiplication_precedence() {
3455 let src = "2024-01-15 * \"x\"\n \
3457 Expenses:X 2 + 3 * 4 USD\n \
3458 Assets:Y -14 USD\n";
3459 let result = parse_via_cst(src);
3460 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3461 let Directive::Transaction(t) = &result.directives[0].value else {
3462 panic!("expected Transaction");
3463 };
3464 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3465 panic!("expected complete amount");
3466 };
3467 assert_eq!(amt.number, Decimal::from(14));
3468 }
3469
3470 #[test]
3471 fn posting_amount_evaluates_parens_override_precedence() {
3472 let src = "2024-01-15 * \"x\"\n \
3474 Expenses:X (2 + 3) * 4 USD\n \
3475 Assets:Y -20 USD\n";
3476 let result = parse_via_cst(src);
3477 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3478 let Directive::Transaction(t) = &result.directives[0].value else {
3479 panic!("expected Transaction");
3480 };
3481 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3482 panic!("expected complete amount");
3483 };
3484 assert_eq!(amt.number, Decimal::from(20));
3485 }
3486
3487 #[test]
3488 fn posting_amount_evaluates_subtraction_left_associative() {
3489 let src = "2024-01-15 * \"x\"\n \
3491 Expenses:X 10 - 3 - 2 USD\n \
3492 Assets:Y -5 USD\n";
3493 let result = parse_via_cst(src);
3494 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3495 let Directive::Transaction(t) = &result.directives[0].value else {
3496 panic!("expected Transaction");
3497 };
3498 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3499 panic!("expected complete amount");
3500 };
3501 assert_eq!(amt.number, Decimal::from(5));
3502 }
3503
3504 #[test]
3505 fn posting_amount_division_by_zero_drops_number() {
3506 let src = "2024-01-15 * \"x\"\n \
3511 Expenses:X 5 / 0 USD\n \
3512 Assets:Y\n";
3513 let result = parse_via_cst(src);
3514 let Directive::Transaction(t) = &result.directives[0].value else {
3515 panic!("expected Transaction");
3516 };
3517 match &t.postings[0].value.units {
3522 None | Some(IncompleteAmount::CurrencyOnly(_)) => {}
3523 other => panic!("div-by-zero leaked: {other:?}"),
3524 }
3525 }
3526
3527 #[test]
3530 fn indented_top_level_directive_emits_error() {
3531 let src = "2020-07-28 open Assets:Foo\n 2020-07-28 open Assets:Bar\n";
3536 let result = parse_via_cst(src);
3537 let indent_errs = result
3538 .errors
3539 .iter()
3540 .filter(|e| match &e.kind {
3541 crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
3542 _ => false,
3543 })
3544 .count();
3545 assert_eq!(
3546 indent_errs, 1,
3547 "expected one column-0 diagnostic, got: {:?}",
3548 result.errors
3549 );
3550 }
3551
3552 #[test]
3553 fn indented_directive_after_blank_line_still_emits_error() {
3554 let src = "2020-07-28 open Assets:Foo\n\n 2020-07-28 open Assets:Bar\n";
3558 let result = parse_via_cst(src);
3559 let indent_errs = result
3560 .errors
3561 .iter()
3562 .filter(|e| match &e.kind {
3563 crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
3564 _ => false,
3565 })
3566 .count();
3567 assert_eq!(indent_errs, 1, "errors: {:?}", result.errors);
3568 }
3569
3570 #[test]
3571 fn top_level_directive_at_column_0_no_diagnostic() {
3572 let src = "2020-07-28 open Assets:Foo\n2020-07-28 open Assets:Bar\n";
3575 let result = parse_via_cst(src);
3576 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3577 }
3578
3579 #[test]
3580 fn custom_directive_with_bare_currency_emits_error() {
3581 let src = "2025-01-01 custom \"x\" 10 USD \"y\" NZD\n";
3584 let result = parse_via_cst(src);
3585 let bare_curr_errs = result
3586 .errors
3587 .iter()
3588 .filter(|e| match &e.kind {
3589 crate::ParseErrorKind::SyntaxError(s) => s.contains("bare currency"),
3590 _ => false,
3591 })
3592 .count();
3593 assert_eq!(
3594 bare_curr_errs, 1,
3595 "expected one bare-currency diagnostic, got: {:?}",
3596 result.errors
3597 );
3598 }
3599
3600 #[test]
3601 fn custom_directive_with_amount_no_error() {
3602 let src = "2025-01-01 custom \"x\" 10 USD\n";
3606 let result = parse_via_cst(src);
3607 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3608 }
3609
3610 #[test]
3613 fn balance_assertion_evaluates_arithmetic_value() {
3614 let src = "2024-01-01 open Assets:X GBP\n\
3620 2024-01-01 open Equity:Open GBP\n\
3621 2024-01-02 * \"deposit\"\n \
3622 Assets:X 1.00 GBP\n \
3623 Equity:Open -1.00 GBP\n\
3624 2024-01-03 balance Assets:X 0.25 + 0.75 GBP\n";
3625 let result = parse_via_cst(src);
3626 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3627 let bal = result
3628 .directives
3629 .iter()
3630 .find_map(|d| match &d.value {
3631 Directive::Balance(b) => Some(b),
3632 _ => None,
3633 })
3634 .expect("expected a Balance directive");
3635 assert_eq!(bal.amount.number, Decimal::from(1));
3636 assert_eq!(bal.amount.currency.as_str(), "GBP");
3637 }
3638
3639 #[test]
3640 fn price_directive_evaluates_arithmetic_value() {
3641 let src = "2024-01-01 price USD 1/2 EUR\n";
3642 let result = parse_via_cst(src);
3643 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3644 let Directive::Price(p) = &result.directives[0].value else {
3645 panic!("expected Price");
3646 };
3647 assert_eq!(p.amount.number, Decimal::new(5, 1));
3648 assert_eq!(p.amount.currency.as_str(), "EUR");
3649 }
3650
3651 #[test]
3654 fn body_line_tag_does_not_drop_following_postings_comment() {
3655 let src = "2024-01-01 * \"x\"\n \
3662 Assets:A 100 USD\n \
3663 ; comment-for-B\n \
3664 #late-tag\n \
3665 Assets:B -100 USD\n";
3666 let result = parse_via_cst(src);
3667 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3668 let Directive::Transaction(t) = &result.directives[0].value else {
3669 panic!("expected Transaction");
3670 };
3671 assert!(
3673 t.tags.iter().any(|tag| tag.as_str() == "late-tag"),
3674 "expected #late-tag in tags: {:?}",
3675 t.tags,
3676 );
3677 let b = t.postings.last().expect("at least one posting");
3679 assert_eq!(b.value.account.as_str(), "Assets:B");
3680 assert!(
3681 b.value.comments.iter().any(|c| c.contains("comment-for-B")),
3682 "expected comment-for-B to survive on Assets:B: {:?}",
3683 b.value.comments,
3684 );
3685 }
3686
3687 #[test]
3688 fn oversized_number_in_amount_emits_diagnostic() {
3689 let huge = "1".to_string() + &"2345678901234567890".repeat(2); let src = format!("2024-01-15 * \"big\"\n Expenses:X {huge} USD\n Assets:Y\n");
3696 let result = parse_via_cst(&src);
3697 let invalid_num = result
3698 .errors
3699 .iter()
3700 .filter(|e| match &e.kind {
3701 crate::ParseErrorKind::SyntaxError(s) => s.contains("invalid number"),
3702 _ => false,
3703 })
3704 .count();
3705 assert_eq!(
3706 invalid_num, 1,
3707 "expected one invalid-number diagnostic, got: {:?}",
3708 result.errors
3709 );
3710 }
3711
3712 #[test]
3715 fn posting_with_two_amount_siblings_emits_error_and_keeps_first() {
3716 let src = "2024-01-15 * \"ambig\"\n \
3723 Expenses:Food 5 USD + 3 USD\n \
3724 Assets:Bank\n";
3725 let result = parse_via_cst(src);
3726 let trailing_count = result
3727 .errors
3728 .iter()
3729 .filter(|e| match &e.kind {
3730 crate::ParseErrorKind::SyntaxError(s) => s.contains("trailing tokens"),
3731 _ => false,
3732 })
3733 .count();
3734 assert_eq!(
3735 trailing_count, 1,
3736 "expected one trailing-tokens diagnostic, got: {:?}",
3737 result.errors
3738 );
3739 let Directive::Transaction(t) = &result.directives[0].value else {
3742 panic!("expected Transaction");
3743 };
3744 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3745 panic!("expected complete units from the first AMOUNT");
3746 };
3747 assert_eq!(amt.number, Decimal::from(5));
3748 }
3749
3750 #[test]
3751 fn comments_dont_leak_across_failed_posting() {
3752 let src = "2024-01-15 * \"test\"\n \
3759 Assets:A 100 USD\n \
3760 ; comment-for-bad\n \
3761 ; another-comment\n \
3762 bogus_token_line_no_account\n \
3763 ; comment-for-good\n \
3764 Assets:B -100 USD\n";
3765 let result = parse_via_cst(src);
3766 let Directive::Transaction(t) = &result.directives[0].value else {
3767 panic!("expected Transaction");
3768 };
3769 let b = t.postings.last().expect("at least one posting");
3775 assert_eq!(b.value.account.as_str(), "Assets:B");
3776 assert!(
3777 !b.value
3778 .comments
3779 .iter()
3780 .any(|c| c.contains("comment-for-bad")),
3781 "comment-for-bad leaked across failed posting onto Assets:B: {:?}",
3782 b.value.comments
3783 );
3784 assert!(
3785 !b.value
3786 .comments
3787 .iter()
3788 .any(|c| c.contains("another-comment")),
3789 "another-comment leaked: {:?}",
3790 b.value.comments
3791 );
3792 }
3793
3794 #[test]
3795 fn arithmetic_overflow_in_amount_emits_diagnostic() {
3796 let huge = "9999999999999999999999999999 * 9999999999999999999999999999";
3804 let src = format!("2024-01-15 * \"big\"\n Expenses:X {huge} USD\n Assets:Y\n");
3805 let result = parse_via_cst(&src);
3806 let arith_errs = result
3807 .errors
3808 .iter()
3809 .filter(|e| match &e.kind {
3810 crate::ParseErrorKind::SyntaxError(s) => s.contains("arithmetic"),
3811 _ => false,
3812 })
3813 .count();
3814 assert_eq!(
3815 arith_errs, 1,
3816 "expected one arithmetic-error diagnostic, got: {:?}",
3817 result.errors
3818 );
3819 }
3820
3821 #[test]
3824 fn date_with_single_digit_month_parses() {
3825 let result = parse_via_cst("2024-1-15 open Assets:Checking\n");
3826 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3827 let Directive::Open(open) = &result.directives[0].value else {
3828 panic!("expected Open");
3829 };
3830 assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
3831 }
3832
3833 #[test]
3834 fn date_with_single_digit_day_parses() {
3835 let result = parse_via_cst("2024-01-5 open Assets:Cash USD\n");
3836 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3837 let Directive::Open(open) = &result.directives[0].value else {
3838 panic!("expected Open");
3839 };
3840 assert_eq!(open.date, naive_date(2024, 1, 5).unwrap());
3841 }
3842
3843 #[test]
3844 fn date_with_single_digit_month_and_day_parses() {
3845 let result = parse_via_cst("2024-1-1 open Assets:Cash USD\n");
3846 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3847 let Directive::Open(open) = &result.directives[0].value else {
3848 panic!("expected Open");
3849 };
3850 assert_eq!(open.date, naive_date(2024, 1, 1).unwrap());
3851 }
3852
3853 #[test]
3854 fn date_with_month_out_of_range_emits_invalid_date_value() {
3855 let result = parse_via_cst("2024-13-01 open Assets:Cash USD\n");
3856 let invalid_date: Vec<_> = result
3857 .errors
3858 .iter()
3859 .filter_map(|e| match &e.kind {
3860 crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
3861 _ => None,
3862 })
3863 .collect();
3864 assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
3865 let msg = &invalid_date[0];
3866 assert!(
3867 msg.contains("month") && msg.contains("out of range"),
3868 "msg: {msg}"
3869 );
3870 }
3871
3872 #[test]
3873 fn date_with_invalid_leap_year_emits_invalid_date_value() {
3874 let result = parse_via_cst("2023-02-29 open Assets:Cash USD\n");
3875 let invalid_date: Vec<_> = result
3876 .errors
3877 .iter()
3878 .filter_map(|e| match &e.kind {
3879 crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
3880 _ => None,
3881 })
3882 .collect();
3883 assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
3884 let msg = &invalid_date[0];
3885 assert!(
3886 msg.contains("day") && msg.contains("out of range") && msg.contains("2023-02"),
3887 "msg: {msg}"
3888 );
3889 }
3890
3891 #[test]
3892 fn date_with_completely_invalid_value_still_emits_error() {
3893 let result = parse_via_cst("2024-13-45 open Assets:Bank\n");
3897 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3898 }
3899
3900 #[test]
3901 fn open_directive_without_account_emits_error() {
3902 let result = parse_via_cst("2024-01-01 open\n");
3907 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3908 }
3909
3910 #[test]
3911 fn open_directive_with_lowercase_account_emits_error() {
3912 let result = parse_via_cst("2024-01-01 open lowercase:invalid\n");
3917 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3918 }
3919
3920 #[test]
3921 fn incomplete_open_at_eof_emits_error() {
3922 let result = parse_via_cst("2024-01-01 open");
3926 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3927 }
3928
3929 #[test]
3930 fn balance_directive_without_amount_emits_error() {
3931 let result = parse_via_cst("2024-01-15 balance Assets:Checking\n");
3932 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3933 }
3934
3935 #[test]
3936 fn pad_directive_without_source_account_emits_error() {
3937 let result = parse_via_cst("2024-01-15 pad Assets:Checking\n");
3938 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3939 }
3940
3941 #[test]
3942 fn cost_spec_n_hash_t_uses_total_form() {
3943 use rust_decimal_macros::dec;
3944 let src = "2024-01-01 open Assets:Stock\n\
3945 2024-01-01 open Assets:Cash USD\n\
3946 2024-01-15 *\n \
3947 Assets:Stock 10 STK {50 # 1500 USD}\n \
3948 Assets:Cash -1500.00 USD\n";
3949 let result = parse_via_cst(src);
3950 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3951 let Directive::Transaction(txn) = &result.directives[2].value else {
3952 panic!("expected Transaction at index 2");
3953 };
3954 let cost = txn.postings[0]
3955 .value
3956 .cost
3957 .as_ref()
3958 .expect("cost spec present");
3959 assert_eq!(
3960 cost.number,
3961 Some(CostNumber::Total { value: dec!(1500) }),
3962 "the `{{N # T CCY}}` form must store the post-`#` total"
3963 );
3964 }
3965
3966 #[test]
3967 fn unclosed_cost_brace_emits_error() {
3968 let src = "2024-01-01 open Assets:Stock\n\
3969 2024-01-01 open Assets:Cash USD\n\
3970 2024-01-15 *\n \
3971 Assets:Stock 10 AAPL {150 USD\n \
3972 Assets:Cash -1500 USD\n";
3973 let result = parse_via_cst(src);
3974 let has_unclosed: bool = result
3975 .errors
3976 .iter()
3977 .any(|e| e.message().contains("unclosed cost"));
3978 assert!(
3979 has_unclosed,
3980 "expected 'unclosed cost' error, got: {:?}",
3981 result.errors
3982 );
3983 }
3984
3985 #[test]
3986 fn unclosed_cost_brace_at_eof_emits_error() {
3987 let src = "2024-01-01 open Assets:Stock\n\
3988 2024-01-01 open Assets:Cash USD\n\
3989 2024-01-15 *\n \
3990 Assets:Stock 10 AAPL {150 USD";
3991 let result = parse_via_cst(src);
3992 let has_unclosed: bool = result
3993 .errors
3994 .iter()
3995 .any(|e| e.message().contains("unclosed cost"));
3996 assert!(
3997 has_unclosed,
3998 "expected 'unclosed cost' error at EOF, got: {:?}",
3999 result.errors
4000 );
4001 }
4002
4003 #[test]
4004 fn leading_decimal_in_posting_amount_emits_error() {
4005 let src = "2024-01-15 * \"Test\"\n \
4009 Expenses:Food .50 USD\n \
4010 Assets:Checking\n";
4011 let result = parse_via_cst(src);
4012 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4013 }
4014
4015 #[test]
4016 fn transaction_with_metadata_on_directive_and_posting() {
4017 let src = "2024-01-15 * \"x\"\n \
4018 tag1: \"hello\"\n \
4019 Assets:Cash -5 USD\n \
4020 receipt: \"abc123\"\n";
4021 let result = parse_via_cst(src);
4022 let Directive::Transaction(t) = &result.directives[0].value else {
4023 panic!("expected Transaction");
4024 };
4025 assert_eq!(
4026 t.meta.get("tag1"),
4027 Some(&MetaValue::String("hello".to_string()))
4028 );
4029 let p_meta = &t.postings[0].value.meta;
4030 assert_eq!(
4031 p_meta.get("receipt"),
4032 Some(&MetaValue::String("abc123".to_string()))
4033 );
4034 }
4035
4036 #[test]
4051 fn account_occurrences_policy_for_failing_directives() {
4052 let src = "2024-01-01 open Assets:Bank \"GARBAGE\"\n";
4056 let r = parse_via_cst(src);
4057 assert!(
4058 r.account_occurrences
4059 .iter()
4060 .any(|o| o.value == "Assets:Bank"),
4061 "typed-conversion failure should keep the ACCOUNT token in \
4062 account_occurrences (got {:?}); rename mid-edit relies on this",
4063 r.account_occurrences,
4064 );
4065
4066 let src = "2024-01-01 opn Assets:Bank USD\n";
4071 let r = parse_via_cst(src);
4072 assert!(
4073 !r.account_occurrences
4074 .iter()
4075 .any(|o| o.value == "Assets:Bank"),
4076 "ERROR_NODE-wrapped ACCOUNT should be EXCLUDED from \
4077 account_occurrences (got {:?}); rename should not hit garbled \
4078 mid-edit syntax",
4079 r.account_occurrences,
4080 );
4081 }
4082}