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 compound = cost_compound_numbers(cs);
1404
1405 let cost_number = if let Some((per_unit, total)) = compound {
1406 Some(CostNumber::Compound {
1407 per_unit: per_unit.unwrap_or_default(),
1408 total: total.unwrap_or_default(),
1409 })
1410 } else {
1411 let number = cs.number().and_then(|n| parse_decimal_token(n.text()));
1412 match (number, is_total) {
1413 (Some(v), true) => Some(CostNumber::Total { value: v }),
1414 (Some(v), false) => Some(CostNumber::PerUnit { value: v }),
1415 (None, _) => None,
1416 }
1417 };
1418
1419 let currency = cs.currency().map(|c| Currency::new(c.text()));
1420 let date = cs.date().and_then(|d| parse_date_token(d.text()));
1421 let label = cs.label().and_then(|s| s.text_decoded());
1422
1423 CostSpec {
1424 number: cost_number,
1425 currency,
1426 date,
1427 label,
1428 merge,
1429 }
1430}
1431
1432fn cost_compound_numbers(cs: &ast::CostSpec) -> Option<(Option<Decimal>, Option<Decimal>)> {
1444 let mut before: Option<Decimal> = None;
1445 let mut after: Option<Decimal> = None;
1446 let mut past_hash = false;
1449 for el in cs.syntax().children_with_tokens() {
1450 let rowan::NodeOrToken::Token(t) = el else {
1451 continue;
1452 };
1453 match t.kind() {
1454 crate::SyntaxKind::HASH | crate::SyntaxKind::L_BRACE_HASH => past_hash = true,
1455 crate::SyntaxKind::NUMBER if !past_hash && before.is_none() => {
1456 before = parse_decimal_token(t.text());
1457 }
1458 crate::SyntaxKind::NUMBER if past_hash && after.is_none() => {
1459 after = parse_decimal_token(t.text());
1460 }
1461 _ => {}
1462 }
1463 }
1464 past_hash.then_some((before, after))
1465}
1466
1467fn convert_price_annotation(
1468 pa: &ast::PriceAnnotation,
1469 errors: &mut Vec<crate::ParseError>,
1470 bom_offset: u32,
1471) -> PriceAnnotation {
1472 let kind = if pa.is_total() {
1473 PriceKind::Total
1474 } else {
1475 PriceKind::Unit
1476 };
1477 let amount = pa
1478 .amount()
1479 .and_then(|a| convert_amount_to_incomplete(&a, errors, bom_offset));
1480 PriceAnnotation { kind, amount }
1481}
1482
1483fn convert_meta_entries(node: &crate::SyntaxNode) -> Metadata {
1490 let mut meta = Metadata::default();
1491 for entry in node.children().filter_map(MetaEntry::cast) {
1492 let Some(key_token) = entry.key() else {
1493 continue;
1494 };
1495 let key = key_token.text_without_colon().to_string();
1496 let value = meta_value_from_entry(&entry);
1497 meta.insert(key, value);
1498 }
1499 meta
1500}
1501
1502fn node_has_minus_before_number(node: &crate::SyntaxNode) -> bool {
1507 for el in node.children_with_tokens() {
1508 let rowan::NodeOrToken::Token(t) = el else {
1509 continue;
1510 };
1511 match t.kind() {
1512 crate::SyntaxKind::MINUS => return true,
1513 crate::SyntaxKind::NUMBER => return false,
1514 _ => {}
1515 }
1516 }
1517 false
1518}
1519
1520fn meta_entry_has_minus_sign(entry: &MetaEntry) -> bool {
1525 let mut past_key = false;
1526 for el in entry.syntax().children_with_tokens() {
1527 let rowan::NodeOrToken::Token(t) = el else {
1528 continue;
1529 };
1530 if !past_key {
1531 if t.kind() == crate::SyntaxKind::META_KEY {
1532 past_key = true;
1533 }
1534 continue;
1535 }
1536 match t.kind() {
1537 crate::SyntaxKind::MINUS => return true,
1538 crate::SyntaxKind::NUMBER => return false,
1539 _ => {}
1540 }
1541 }
1542 false
1543}
1544
1545fn value_tokens_to_meta(
1559 tokens: &[rowan::SyntaxToken<crate::BeancountLanguage>],
1560 start: usize,
1561) -> Option<(MetaValue, usize)> {
1562 let mut i = start;
1563 let mut negate = false;
1564 if tokens.get(i).map(rowan::SyntaxToken::kind) == Some(crate::SyntaxKind::MINUS) {
1565 negate = true;
1566 i += 1;
1567 }
1568 let t = tokens.get(i)?;
1569 match t.kind() {
1570 crate::SyntaxKind::STRING => {
1571 let s = strip_string_quotes(t.text())?;
1572 Some((MetaValue::String(s.to_string()), i + 1))
1573 }
1574 crate::SyntaxKind::NUMBER => {
1575 let mut decimal = parse_decimal_token(t.text())?;
1576 if negate {
1577 decimal = -decimal;
1578 }
1579 if let Some(next) = tokens.get(i + 1)
1581 && next.kind() == crate::SyntaxKind::CURRENCY
1582 {
1583 return Some((
1584 MetaValue::Amount(Amount::new(decimal, Currency::new(next.text()))),
1585 i + 2,
1586 ));
1587 }
1588 Some((number_meta_value(t.text(), decimal), i + 1))
1589 }
1590 crate::SyntaxKind::DATE => Some((MetaValue::Date(parse_date_token(t.text())?), i + 1)),
1591 crate::SyntaxKind::ACCOUNT => Some((MetaValue::Account(Account::new(t.text())), i + 1)),
1592 crate::SyntaxKind::CURRENCY => Some((MetaValue::Currency(Currency::new(t.text())), i + 1)),
1593 crate::SyntaxKind::BOOL_TRUE => Some((MetaValue::Bool(true), i + 1)),
1594 crate::SyntaxKind::BOOL_FALSE => Some((MetaValue::Bool(false), i + 1)),
1595 crate::SyntaxKind::TAG => Some((
1596 MetaValue::Tag(Tag::new(t.text().trim_start_matches('#'))),
1597 i + 1,
1598 )),
1599 crate::SyntaxKind::LINK => Some((
1600 MetaValue::Link(Link::new(t.text().trim_start_matches('^'))),
1601 i + 1,
1602 )),
1603 _ => None,
1604 }
1605}
1606
1607fn meta_value_from_entry(entry: &MetaEntry) -> MetaValue {
1613 if let Some(s) = entry.value_string()
1614 && let Some(text) = s.text_decoded()
1615 {
1616 return MetaValue::String(text);
1617 }
1618 if let Some(n) = entry.value_number()
1619 && let Some(mut decimal) = parse_decimal_token(n.text())
1620 {
1621 if meta_entry_has_minus_sign(entry) {
1625 decimal = -decimal;
1626 }
1627 if let Some(c) = entry.value_currency() {
1632 return MetaValue::Amount(Amount::new(decimal, Currency::new(c.text())));
1633 }
1634 return number_meta_value(n.text(), decimal);
1635 }
1636 if let Some(d) = entry.value_date()
1637 && let Some(date) = parse_date_token(d.text())
1638 {
1639 return MetaValue::Date(date);
1640 }
1641 if let Some(a) = entry.value_account() {
1642 return MetaValue::Account(Account::new(a.text()));
1643 }
1644 if let Some(c) = entry.value_currency() {
1645 return MetaValue::Currency(Currency::new(c.text()));
1646 }
1647 if let Some(b) = entry.value_bool() {
1648 return MetaValue::Bool(b);
1649 }
1650 for tok in entry.syntax().children_with_tokens() {
1654 let rowan::NodeOrToken::Token(t) = tok else {
1655 continue;
1656 };
1657 match t.kind() {
1658 crate::SyntaxKind::TAG => {
1659 let stripped = t.text().trim_start_matches('#');
1660 return MetaValue::Tag(Tag::new(stripped));
1661 }
1662 crate::SyntaxKind::LINK => {
1663 let stripped = t.text().trim_start_matches('^');
1664 return MetaValue::Link(Link::new(stripped));
1665 }
1666 _ => {}
1667 }
1668 }
1669 MetaValue::None
1670}
1671
1672fn apply_inherited_state(
1686 value: &mut Directive,
1687 tag_stack: &[(Tag, Span)],
1688 meta_stack: &[(String, MetaValue, Span)],
1689) {
1690 if let Directive::Transaction(txn) = value {
1691 for (tag, _) in tag_stack {
1692 if !txn.tags.contains(tag) {
1693 txn.tags.push(tag.clone());
1694 }
1695 }
1696 }
1697 if meta_stack.is_empty() {
1698 return;
1699 }
1700 let meta = match value {
1701 Directive::Transaction(d) => &mut d.meta,
1702 Directive::Balance(d) => &mut d.meta,
1703 Directive::Open(d) => &mut d.meta,
1704 Directive::Close(d) => &mut d.meta,
1705 Directive::Commodity(d) => &mut d.meta,
1706 Directive::Pad(d) => &mut d.meta,
1707 Directive::Event(d) => &mut d.meta,
1708 Directive::Query(d) => &mut d.meta,
1709 Directive::Note(d) => &mut d.meta,
1710 Directive::Document(d) => &mut d.meta,
1711 Directive::Price(d) => &mut d.meta,
1712 Directive::Custom(d) => &mut d.meta,
1713 };
1714 for (k, v, _) in meta_stack {
1715 meta.insert(k.clone(), v.clone());
1716 }
1717}
1718
1719fn pushmeta_value(node: &crate::SyntaxNode) -> MetaValue {
1724 let raw: Vec<rowan::SyntaxToken<crate::BeancountLanguage>> = node
1729 .children_with_tokens()
1730 .filter_map(rowan::NodeOrToken::into_token)
1731 .filter(|t| {
1732 !matches!(
1733 t.kind(),
1734 crate::SyntaxKind::WHITESPACE
1735 | crate::SyntaxKind::NEWLINE
1736 | crate::SyntaxKind::COMMENT
1737 )
1738 })
1739 .collect();
1740
1741 let mut i = 0;
1742 while i < raw.len() {
1743 if let Some((value, _)) = value_tokens_to_meta(&raw, i) {
1744 return value;
1745 }
1746 i += 1;
1747 }
1748 MetaValue::None
1749}
1750
1751pub(super) const fn is_comment_kind(kind: crate::SyntaxKind) -> bool {
1757 matches!(
1758 kind,
1759 crate::SyntaxKind::COMMENT
1760 | crate::SyntaxKind::PERCENT_COMMENT
1761 | crate::SyntaxKind::SHEBANG
1762 | crate::SyntaxKind::EMACS_DIRECTIVE
1763 )
1764}
1765
1766pub(super) struct TopLevelWalkResult {
1768 pub(super) errors: Vec<crate::ParseError>,
1769 pub(super) section_marker_comments: Vec<Spanned<String>>,
1770}
1771
1772fn walk_top_level_once(
1784 source_file: &SourceFile,
1785 stripped: &str,
1786 bom_offset: u32,
1787) -> TopLevelWalkResult {
1788 let mut errors: Vec<crate::ParseError> = Vec::new();
1789 let mut section_marker_comments: Vec<Spanned<String>> = Vec::new();
1790 for child in source_file.syntax().children() {
1791 let kind = child.kind();
1792 if ast::Directive::can_cast(kind) {
1794 indented_directive_check(&child, stripped, bom_offset, &mut errors);
1795 }
1796 match kind {
1797 crate::SyntaxKind::CUSTOM_DIRECTIVE => {
1798 custom_value_check(&child, bom_offset, &mut errors);
1799 }
1800 crate::SyntaxKind::TRANSACTION => {
1801 transaction_body_check(&child, bom_offset, &mut errors);
1802 }
1803 crate::SyntaxKind::ERROR_NODE => {
1804 error_node_check(&child, stripped, bom_offset, &mut errors);
1805 section_marker_check(&child, bom_offset, &mut section_marker_comments);
1806 }
1807 _ => {}
1808 }
1809 }
1810 TopLevelWalkResult {
1811 errors,
1812 section_marker_comments,
1813 }
1814}
1815
1816fn extract_unclosed_cost_brace_errors(
1825 source_file: &SourceFile,
1826 bom_offset: u32,
1827) -> Vec<crate::ParseError> {
1828 let mut out = Vec::new();
1829 for cs in source_file.syntax().descendants() {
1830 if cs.kind() != crate::SyntaxKind::COST_SPEC {
1831 continue;
1832 }
1833 let mut has_opener = false;
1834 let mut has_closer = false;
1835 for el in cs.children_with_tokens() {
1836 let rowan::NodeOrToken::Token(t) = el else {
1837 continue;
1838 };
1839 match t.kind() {
1840 crate::SyntaxKind::L_BRACE
1841 | crate::SyntaxKind::L_DOUBLE_BRACE
1842 | crate::SyntaxKind::L_BRACE_HASH => has_opener = true,
1843 crate::SyntaxKind::R_BRACE | crate::SyntaxKind::R_DOUBLE_BRACE => has_closer = true,
1844 _ => {}
1845 }
1846 }
1847 if has_opener && !has_closer {
1848 out.push(crate::ParseError::new(
1849 crate::ParseErrorKind::SyntaxError(
1850 "unclosed cost specification: missing '}'".to_string(),
1851 ),
1852 node_span(&cs, bom_offset),
1853 ));
1854 }
1855 }
1856 out
1857}
1858
1859fn indented_directive_check(
1870 child: &crate::SyntaxNode,
1871 stripped: &str,
1872 bom_offset: u32,
1873 out: &mut Vec<crate::ParseError>,
1874) {
1875 let Some(content) = child
1881 .children_with_tokens()
1882 .filter_map(rowan::NodeOrToken::into_token)
1883 .find(|t| !is_trivia_kind(t.kind()))
1884 else {
1885 return;
1886 };
1887 let content_start: usize = u32::from(content.text_range().start()) as usize;
1888 let line_start = stripped
1900 .as_bytes()
1901 .get(..content_start)
1902 .and_then(|bytes| bytes.iter().rposition(|&b| b == b'\n'))
1903 .map_or(0, |nl| nl + 1);
1904 if content_start > line_start {
1905 let end: u32 = content.text_range().end().into();
1906 let span = Span::new(
1907 (line_start as u32 + bom_offset) as usize,
1908 (end + bom_offset) as usize,
1909 );
1910 out.push(crate::ParseError::new(
1911 crate::ParseErrorKind::SyntaxError(
1912 "top-level directive must start at column 0".to_string(),
1913 ),
1914 span,
1915 ));
1916 }
1917}
1918
1919fn custom_value_check(
1934 child: &crate::SyntaxNode,
1935 bom_offset: u32,
1936 out: &mut Vec<crate::ParseError>,
1937) {
1938 {
1940 let raw: Vec<crate::SyntaxToken> = child
1945 .children_with_tokens()
1946 .filter_map(rowan::NodeOrToken::into_token)
1947 .filter(|t| !is_trivia_kind(t.kind()))
1948 .collect();
1949 let mut seen_type_string = false;
1950 let mut i = 0;
1951 while i < raw.len() {
1952 let t = &raw[i];
1953 if !seen_type_string {
1954 if t.kind() == crate::SyntaxKind::STRING {
1955 seen_type_string = true;
1956 }
1957 i += 1;
1958 continue;
1959 }
1960 if t.kind() == crate::SyntaxKind::CURRENCY {
1961 let preceded_by_number = i > 0 && raw[i - 1].kind() == crate::SyntaxKind::NUMBER;
1967 if !preceded_by_number {
1968 let range = t.text_range();
1969 let start: u32 = range.start().into();
1970 let end: u32 = range.end().into();
1971 let span =
1972 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
1973 out.push(crate::ParseError::new(
1974 crate::ParseErrorKind::SyntaxError(
1975 "bare currency literal is not a valid custom directive value"
1976 .to_string(),
1977 ),
1978 span,
1979 ));
1980 }
1981 }
1982 i += 1;
1983 }
1984 }
1985}
1986
1987fn transaction_body_check(
1994 child: &crate::SyntaxNode,
1995 bom_offset: u32,
1996 out: &mut Vec<crate::ParseError>,
1997) {
1998 {
2000 let mut past_header = false;
2010 let mut saw_header_content = false;
2011 let mut line_start: Option<u32> = None;
2012 let mut line_has_content = false;
2013 for el in child.children_with_tokens() {
2014 match el {
2015 rowan::NodeOrToken::Token(t) => {
2016 if !past_header {
2017 if t.kind() == crate::SyntaxKind::NEWLINE {
2018 if saw_header_content {
2019 past_header = true;
2020 }
2021 } else if !is_trivia_kind(t.kind()) {
2022 saw_header_content = true;
2023 }
2024 continue;
2025 }
2026 let range = t.text_range();
2027 let start: u32 = range.start().into();
2028 let end: u32 = range.end().into();
2029 if line_start.is_none() {
2030 line_start = Some(start);
2031 }
2032 if t.kind() == crate::SyntaxKind::NEWLINE {
2033 if line_has_content && let Some(ls) = line_start {
2034 let span =
2036 Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2037 out.push(crate::ParseError::new(
2040 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2041 span,
2042 ));
2043 }
2044 line_start = None;
2045 line_has_content = false;
2046 } else if !is_trivia_kind(t.kind())
2047 && !is_comment_kind(t.kind())
2048 && !matches!(t.kind(), crate::SyntaxKind::TAG | crate::SyntaxKind::LINK)
2049 {
2050 line_has_content = true;
2056 }
2057 }
2058 rowan::NodeOrToken::Node(_) => {
2059 line_start = None;
2061 line_has_content = false;
2062 if !past_header {
2063 past_header = true;
2064 }
2065 }
2066 }
2067 }
2068 }
2069}
2070
2071fn error_node_check(
2081 child: &crate::SyntaxNode,
2082 stripped: &str,
2083 bom_offset: u32,
2084 out: &mut Vec<crate::ParseError>,
2085) {
2086 {
2088 let mut line_start: Option<u32> = None;
2089 let mut first_non_trivia: Option<crate::SyntaxKind> = None;
2090 for el in child.children_with_tokens() {
2091 let rowan::NodeOrToken::Token(t) = el else {
2092 continue;
2093 };
2094 let range = t.text_range();
2095 let start: u32 = range.start().into();
2096 let end: u32 = range.end().into();
2097 if line_start.is_none() {
2098 line_start = Some(start);
2099 }
2100 if t.kind() == crate::SyntaxKind::NEWLINE {
2101 let is_section = matches!(first_non_trivia, Some(crate::SyntaxKind::STAR));
2103 let is_comment = matches!(first_non_trivia, Some(k) if is_comment_kind(k));
2104 if !is_section
2105 && !is_comment
2106 && first_non_trivia.is_some()
2107 && let Some(ls) = line_start
2108 {
2109 let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2113 let line_text = stripped.get(ls as usize..end as usize).unwrap_or("");
2114 let primary = classify_recovery_error(line_text, span);
2115 let primary_is_bom =
2116 matches!(primary.kind, crate::ParseErrorKind::BomInDirectiveBody);
2117 out.push(primary);
2118 if !primary_is_bom && line_text.contains(crate::bom::BOM_CHAR) {
2128 out.push(
2129 crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2130 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
2131 );
2132 }
2133 }
2134 line_start = None;
2135 first_non_trivia = None;
2136 continue;
2137 }
2138 if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
2139 first_non_trivia = Some(t.kind());
2140 }
2141 }
2142 }
2143}
2144
2145pub(super) fn classify_recovery_error(line_text: &str, span: Span) -> crate::ParseError {
2158 if let Some(account) = crate::diagnostics::find_unicode_account(line_text) {
2159 return crate::ParseError::new(
2160 crate::ParseErrorKind::InvalidAccount(account.to_string()),
2161 span,
2162 );
2163 }
2164 if line_text.contains(crate::bom::BOM_CHAR) {
2165 return crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2166 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT);
2167 }
2168 crate::ParseError::new(
2169 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2170 span,
2171 )
2172}
2173
2174pub(super) struct DescendantsWalkResult {
2193 pub(super) inline_errors: Vec<crate::ParseError>,
2194 pub(super) top_level_comments: Vec<Spanned<String>>,
2195 pub(super) currency_occurrences: Vec<Spanned<Currency>>,
2196 pub(super) account_occurrences: Vec<Spanned<rustledger_core::Account>>,
2197}
2198
2199fn walk_descendants_once(
2207 source_file: &SourceFile,
2208 bom_offset: u32,
2209 collect_occurrences: bool,
2210) -> DescendantsWalkResult {
2211 let mut inline_errors: Vec<crate::ParseError> = Vec::new();
2212 let mut top_level_comments: Vec<Spanned<String>> = Vec::new();
2213 let mut currency_occurrences: Vec<Spanned<Currency>> = Vec::new();
2214 let mut account_occurrences: Vec<Spanned<rustledger_core::Account>> = Vec::new();
2215
2216 let mut preceded_by_ws = false;
2218
2219 for el in source_file.syntax().descendants_with_tokens() {
2220 let rowan::NodeOrToken::Token(t) = el else {
2221 if let rowan::NodeOrToken::Node(n) = el
2226 && ast::Directive::can_cast(n.kind())
2227 {
2228 preceded_by_ws = false;
2229 }
2230 continue;
2231 };
2232
2233 match t.kind() {
2235 crate::SyntaxKind::NEWLINE => preceded_by_ws = false,
2236 crate::SyntaxKind::WHITESPACE => preceded_by_ws = true,
2237 k if is_comment_kind(k) => {
2238 if !preceded_by_ws {
2239 let range = t.text_range();
2240 let start: u32 = range.start().into();
2241 let end: u32 = range.end().into();
2242 let span =
2243 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2244 top_level_comments.push(Spanned::new(t.text().to_string(), span));
2245 }
2246 }
2247 _ => {
2248 preceded_by_ws = false;
2249 }
2250 }
2251
2252 if t.kind() == crate::SyntaxKind::BOM {
2254 continue;
2255 }
2256 let kind = t.kind();
2264 let has_bom = t.text().contains(crate::bom::BOM_CHAR);
2265 let is_error_token = kind == crate::SyntaxKind::ERROR_TOKEN;
2266 let needs_in_error_check = (collect_occurrences
2269 && matches!(
2270 kind,
2271 crate::SyntaxKind::CURRENCY | crate::SyntaxKind::ACCOUNT
2272 ))
2273 || has_bom
2274 || is_error_token;
2275 if !needs_in_error_check {
2276 continue;
2277 }
2278 let in_error_node = t
2279 .parent_ancestors()
2280 .any(|a| a.kind() == crate::SyntaxKind::ERROR_NODE);
2281
2282 if collect_occurrences && kind == crate::SyntaxKind::CURRENCY && !in_error_node {
2285 let range = t.text_range();
2286 let start: u32 = range.start().into();
2287 let end: u32 = range.end().into();
2288 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2289 currency_occurrences.push(Spanned::new(Currency::new(t.text()), span));
2290 }
2291
2292 if collect_occurrences && kind == crate::SyntaxKind::ACCOUNT && !in_error_node {
2300 let range = t.text_range();
2301 let start: u32 = range.start().into();
2302 let end: u32 = range.end().into();
2303 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2304 account_occurrences.push(Spanned::new(rustledger_core::Account::new(t.text()), span));
2305 }
2306
2307 if (!has_bom && !is_error_token) || in_error_node {
2313 continue;
2314 }
2315 let range = t.text_range();
2316 let start: u32 = range.start().into();
2317 let end: u32 = range.end().into();
2318 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2319 if has_bom {
2320 inline_errors.push(
2321 crate::ParseError::new(crate::ParseErrorKind::BomInDirectiveBody, span)
2322 .with_hint(crate::diagnostics::BOM_REMOVAL_HINT),
2323 );
2324 } else {
2325 inline_errors.push(crate::ParseError::new(
2326 crate::ParseErrorKind::SyntaxError("unexpected input".to_string()),
2327 span,
2328 ));
2329 }
2330 }
2331
2332 DescendantsWalkResult {
2333 inline_errors,
2334 top_level_comments,
2335 currency_occurrences,
2336 account_occurrences,
2337 }
2338}
2339
2340fn section_marker_check(
2347 child: &crate::SyntaxNode,
2348 bom_offset: u32,
2349 out: &mut Vec<Spanned<String>>,
2350) {
2351 let mut line_start: Option<u32> = None;
2356 let mut first_non_trivia: Option<crate::SyntaxKind> = None;
2357 for el in child.children_with_tokens() {
2358 let rowan::NodeOrToken::Token(t) = el else {
2359 continue;
2360 };
2361 let range = t.text_range();
2362 let start: u32 = range.start().into();
2363 let end: u32 = range.end().into();
2364 if line_start.is_none() {
2365 line_start = Some(start);
2366 }
2367 if t.kind() == crate::SyntaxKind::NEWLINE {
2368 if first_non_trivia == Some(crate::SyntaxKind::STAR)
2369 && let Some(ls) = line_start
2370 {
2371 let span = Span::new((ls + bom_offset) as usize, (end + bom_offset) as usize);
2372 out.push(Spanned::new(String::new(), span));
2373 }
2374 line_start = None;
2375 first_non_trivia = None;
2376 continue;
2377 }
2378 if first_non_trivia.is_none() && !is_trivia_kind(t.kind()) {
2379 first_non_trivia = Some(t.kind());
2380 }
2381 }
2382}
2383
2384pub(super) fn parse_date_token(text: &str) -> Option<NaiveDate> {
2396 if text.len() == 10
2398 && text.as_bytes()[4] == b'-'
2399 && text.as_bytes()[7] == b'-'
2400 && let (Ok(y), Ok(m), Ok(d)) = (
2401 text[0..4].parse::<i32>(),
2402 text[5..7].parse::<u32>(),
2403 text[8..10].parse::<u32>(),
2404 )
2405 {
2406 return naive_date(y, m, d);
2407 }
2408 crate::diagnostics::normalize_date_str(text)
2412 .parse::<NaiveDate>()
2413 .ok()
2414}
2415
2416fn parse_directive_date(
2424 date_tok: &ast::Date,
2425 errors: &mut Vec<crate::ParseError>,
2426 bom_offset: u32,
2427) -> Option<NaiveDate> {
2428 let text = date_tok.text();
2429 if let Some(d) = parse_date_token(text) {
2430 return Some(d);
2431 }
2432 let range = date_tok.syntax().text_range();
2433 let start: u32 = range.start().into();
2434 let end: u32 = range.end().into();
2435 let span = Span::new((start + bom_offset) as usize, (end + bom_offset) as usize);
2436 errors.push(crate::ParseError::new(
2437 crate::ParseErrorKind::InvalidDateValue(crate::diagnostics::describe_invalid_date(text)),
2438 span,
2439 ));
2440 None
2441}
2442
2443pub(super) fn decode_string_token(text: &str) -> Option<String> {
2449 let bytes = text.as_bytes();
2450 if bytes.len() < 2 || bytes[0] != b'"' || bytes[bytes.len() - 1] != b'"' {
2451 return None;
2452 }
2453 let raw = &text[1..text.len() - 1];
2454 if !raw.contains('\\') {
2455 return Some(raw.to_string());
2456 }
2457 let mut out = String::with_capacity(raw.len());
2458 let mut chars = raw.chars();
2459 while let Some(c) = chars.next() {
2460 if c != '\\' {
2461 out.push(c);
2462 continue;
2463 }
2464 match chars.next() {
2465 Some('"') => out.push('"'),
2466 Some('\\') => out.push('\\'),
2467 Some('n') => out.push('\n'),
2468 Some('t') => out.push('\t'),
2469 Some('r') => out.push('\r'),
2470 Some(other) => out.push(other),
2471 None => {}
2472 }
2473 }
2474 Some(out)
2475}
2476
2477pub(super) fn parse_decimal_token(text: &str) -> Option<Decimal> {
2480 use std::str::FromStr;
2481 let cleaned: String;
2482 let s = if text.contains(',') {
2483 cleaned = text.replace(',', "");
2484 cleaned.as_str()
2485 } else {
2486 text
2487 };
2488 Decimal::from_str(s).ok()
2489}
2490
2491pub(super) fn number_meta_value(text: &str, value: Decimal) -> MetaValue {
2500 use rust_decimal::prelude::ToPrimitive;
2501 if !text.contains('.')
2502 && !text.contains('e')
2503 && !text.contains('E')
2504 && let Some(i) = value.to_i64()
2505 {
2506 return MetaValue::Int(i);
2507 }
2508 MetaValue::Number(value)
2509}
2510
2511fn node_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2517 let range = node.text_range();
2518 let start: u32 = range.start().into();
2519 let end: u32 = range.end().into();
2520 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2521}
2522
2523pub(super) const fn is_trivia_kind(kind: crate::SyntaxKind) -> bool {
2535 matches!(
2536 kind,
2537 crate::SyntaxKind::WHITESPACE
2538 | crate::SyntaxKind::NEWLINE
2539 | crate::SyntaxKind::COMMENT
2540 | crate::SyntaxKind::PERCENT_COMMENT
2541 | crate::SyntaxKind::SHEBANG
2542 | crate::SyntaxKind::EMACS_DIRECTIVE
2543 )
2544}
2545
2546fn posting_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2555 let range = node.text_range();
2556 let start: u32 = range.start().into();
2557 let end_raw: u32 = range.end().into();
2558 let end = node
2561 .children_with_tokens()
2562 .filter_map(rowan::NodeOrToken::into_token)
2563 .find(|t| t.kind() == crate::SyntaxKind::NEWLINE)
2564 .map_or(end_raw, |t| u32::from(t.text_range().start()));
2565 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2566}
2567
2568fn single_line_directive_span(node: &crate::SyntaxNode, bom_offset: u32) -> Span {
2575 let range = node.text_range();
2576 let start_raw: u32 = range.start().into();
2577 let end_raw: u32 = range.end().into();
2578 let mut content_start: Option<u32> = None;
2579 let mut terminator: Option<u32> = None;
2580 for t in node
2581 .children_with_tokens()
2582 .filter_map(rowan::NodeOrToken::into_token)
2583 {
2584 if content_start.is_none() {
2585 if !is_trivia_kind(t.kind()) {
2586 content_start = Some(u32::from(t.text_range().start()));
2587 }
2588 } else if t.kind() == crate::SyntaxKind::NEWLINE {
2589 terminator = Some(u32::from(t.text_range().start()));
2590 break;
2591 }
2592 }
2593 let start = content_start.unwrap_or(start_raw);
2594 let end = terminator.unwrap_or(end_raw);
2595 Span::new((start + bom_offset) as usize, (end + bom_offset) as usize)
2596}
2597
2598fn fixup_directive_spans(
2605 source_file: &SourceFile,
2606 bom_offset: u32,
2607 converted_nodes: &[crate::SyntaxNode],
2608 directives: &mut [Spanned<Directive>],
2609) {
2610 debug_assert_eq!(
2611 converted_nodes.len(),
2612 directives.len(),
2613 "converted_nodes and directives must be parallel arrays"
2614 );
2615
2616 let all_starts: Vec<(usize, usize)> = source_file
2624 .syntax()
2625 .children()
2626 .filter(|n| ast::Directive::can_cast(n.kind()))
2627 .map(|n| {
2628 let raw_start: u32 = n.text_range().start().into();
2629 let content_start = n
2630 .descendants_with_tokens()
2631 .filter_map(rowan::NodeOrToken::into_token)
2632 .find(|t| !is_trivia_kind(t.kind()))
2633 .map_or_else(
2634 || (raw_start + bom_offset) as usize,
2635 |t| (u32::from(t.text_range().start()) + bom_offset) as usize,
2636 );
2637 ((raw_start + bom_offset) as usize, content_start)
2638 })
2639 .collect();
2640
2641 let source_end: usize =
2642 (u32::from(source_file.syntax().text_range().end()) + bom_offset) as usize;
2643
2644 for (i, spanned) in directives.iter_mut().enumerate() {
2658 let node = &converted_nodes[i];
2659 let raw_start: usize = (u32::from(node.text_range().start()) + bom_offset) as usize;
2660 let node_end: usize = (u32::from(node.text_range().end()) + bom_offset) as usize;
2661 if let Some(pos) = all_starts.iter().position(|(rs, _)| *rs == raw_start) {
2662 let start = all_starts[pos].1;
2663 let end = all_starts
2664 .get(pos + 1)
2665 .map_or(source_end, |(_, content)| *content);
2666 spanned.span = Span::new(start, end);
2667 } else {
2668 let content_start = node
2675 .descendants_with_tokens()
2676 .filter_map(rowan::NodeOrToken::into_token)
2677 .find(|t| !is_trivia_kind(t.kind()))
2678 .map_or(raw_start, |t| {
2679 (u32::from(t.text_range().start()) + bom_offset) as usize
2680 });
2681 spanned.span = Span::new(content_start, node_end);
2682 }
2683 }
2684}
2685
2686#[cfg(test)]
2687mod tests {
2688 use super::*;
2689
2690 fn assert_directive_count(result: &ParseResult, expected: usize) {
2691 assert_eq!(
2692 result.directives.len(),
2693 expected,
2694 "directive count mismatch: {:#?}",
2695 result.directives
2696 );
2697 }
2698
2699 #[test]
2700 fn open_directive_basic() {
2701 let src = "2024-01-15 open Assets:Cash\n";
2702 let result = parse_via_cst(src);
2703 assert_directive_count(&result, 1);
2704 let Directive::Open(open) = &result.directives[0].value else {
2705 panic!("expected Open, got {:?}", result.directives[0].value);
2706 };
2707 assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
2708 assert_eq!(open.account.as_str(), "Assets:Cash");
2709 assert!(open.currencies.is_empty());
2710 assert!(open.booking.is_none());
2711 assert!(open.meta.is_empty());
2712 }
2713
2714 #[test]
2715 fn open_directive_with_currencies_and_booking() {
2716 let src = "2024-01-15 open Assets:Brokerage USD,EUR \"STRICT\"\n";
2717 let result = parse_via_cst(src);
2718 assert_directive_count(&result, 1);
2719 let Directive::Open(open) = &result.directives[0].value else {
2720 panic!("expected Open");
2721 };
2722 let currencies: Vec<&str> = open.currencies.iter().map(Currency::as_str).collect();
2723 assert_eq!(currencies, vec!["USD", "EUR"]);
2724 assert_eq!(open.booking.as_deref(), Some("STRICT"));
2725 }
2726
2727 #[test]
2728 fn open_directive_with_metadata() {
2729 let src = "2024-01-15 open Assets:Cash\n note: \"main checking\"\n number: 42\n";
2730 let result = parse_via_cst(src);
2731 assert_directive_count(&result, 1);
2732 let Directive::Open(open) = &result.directives[0].value else {
2733 panic!("expected Open");
2734 };
2735 assert_eq!(
2736 open.meta.get("note"),
2737 Some(&MetaValue::String("main checking".to_string()))
2738 );
2739 assert_eq!(
2740 open.meta.get("number"),
2741 Some(&MetaValue::Int(42))
2743 );
2744 }
2745
2746 #[test]
2747 fn close_directive_basic() {
2748 let src = "2024-12-31 close Assets:Cash\n";
2749 let result = parse_via_cst(src);
2750 assert_directive_count(&result, 1);
2751 let Directive::Close(close) = &result.directives[0].value else {
2752 panic!("expected Close, got {:?}", result.directives[0].value);
2753 };
2754 assert_eq!(close.date, naive_date(2024, 12, 31).unwrap());
2755 assert_eq!(close.account.as_str(), "Assets:Cash");
2756 }
2757
2758 #[test]
2759 fn commodity_directive_basic() {
2760 let src = "2024-01-01 commodity HOOL\n";
2761 let result = parse_via_cst(src);
2762 assert_directive_count(&result, 1);
2763 let Directive::Commodity(c) = &result.directives[0].value else {
2764 panic!("expected Commodity");
2765 };
2766 assert_eq!(c.currency.as_str(), "HOOL");
2767 }
2768
2769 #[test]
2770 fn bom_offset_is_included_in_spans() {
2771 let src = "\u{FEFF}2024-01-15 open Assets:Cash\n";
2772 let result = parse_via_cst(src);
2773 assert!(result.has_leading_bom);
2774 let span = result.directives[0].span;
2775 assert_eq!(span.start, 3, "span should include BOM offset");
2776 }
2777
2778 #[test]
2779 fn note_directive_basic() {
2780 let src = "2024-01-15 note Assets:Cash \"deposit received\"\n";
2781 let result = parse_via_cst(src);
2782 assert_directive_count(&result, 1);
2783 let Directive::Note(note) = &result.directives[0].value else {
2784 panic!("expected Note");
2785 };
2786 assert_eq!(note.date, naive_date(2024, 1, 15).unwrap());
2787 assert_eq!(note.account.as_str(), "Assets:Cash");
2788 assert_eq!(note.comment, "deposit received");
2789 }
2790
2791 #[test]
2792 fn document_directive_basic() {
2793 let src = "2024-01-15 document Assets:Cash \"/path/to/file.pdf\"\n";
2794 let result = parse_via_cst(src);
2795 assert_directive_count(&result, 1);
2796 let Directive::Document(d) = &result.directives[0].value else {
2797 panic!("expected Document");
2798 };
2799 assert_eq!(d.account.as_str(), "Assets:Cash");
2800 assert_eq!(d.path, "/path/to/file.pdf");
2801 assert!(d.tags.is_empty());
2803 assert!(d.links.is_empty());
2804 }
2805
2806 #[test]
2807 fn event_directive_basic() {
2808 let src = "2024-01-15 event \"location\" \"Berlin\"\n";
2809 let result = parse_via_cst(src);
2810 assert_directive_count(&result, 1);
2811 let Directive::Event(e) = &result.directives[0].value else {
2812 panic!("expected Event");
2813 };
2814 assert_eq!(e.event_type, "location");
2815 assert_eq!(e.value, "Berlin");
2816 }
2817
2818 #[test]
2819 fn query_directive_basic() {
2820 let src = "2024-01-15 query \"income\" \"SELECT account, sum(position)\"\n";
2821 let result = parse_via_cst(src);
2822 assert_directive_count(&result, 1);
2823 let Directive::Query(q) = &result.directives[0].value else {
2824 panic!("expected Query");
2825 };
2826 assert_eq!(q.name, "income");
2827 assert_eq!(q.query, "SELECT account, sum(position)");
2828 }
2829
2830 #[test]
2831 fn price_directive_basic() {
2832 let src = "2024-01-15 price USD 1.10 EUR\n";
2833 let result = parse_via_cst(src);
2834 assert_directive_count(&result, 1);
2835 let Directive::Price(p) = &result.directives[0].value else {
2836 panic!("expected Price");
2837 };
2838 assert_eq!(p.currency.as_str(), "USD");
2839 assert_eq!(p.amount.number, Decimal::new(110, 2));
2840 assert_eq!(p.amount.currency.as_str(), "EUR");
2841 }
2842
2843 #[test]
2844 fn balance_directive_basic() {
2845 let src = "2024-06-30 balance Assets:Cash 100.00 USD\n";
2846 let result = parse_via_cst(src);
2847 assert_directive_count(&result, 1);
2848 let Directive::Balance(b) = &result.directives[0].value else {
2849 panic!("expected Balance");
2850 };
2851 assert_eq!(b.account.as_str(), "Assets:Cash");
2852 assert_eq!(b.amount.number, Decimal::new(10000, 2));
2853 assert_eq!(b.amount.currency.as_str(), "USD");
2854 assert!(b.tolerance.is_none());
2855 }
2856
2857 #[test]
2858 fn balance_directive_with_explicit_tolerance() {
2859 let src = "2024-06-30 balance Assets:Cash 100.00 ~ 0.05 USD\n";
2860 let result = parse_via_cst(src);
2861 assert_directive_count(&result, 1);
2862 let Directive::Balance(b) = &result.directives[0].value else {
2863 panic!("expected Balance");
2864 };
2865 assert_eq!(b.amount.number, Decimal::new(10000, 2));
2866 assert_eq!(b.tolerance, Some(Decimal::new(5, 2)));
2867 }
2868
2869 #[test]
2870 fn pad_directive_basic() {
2871 let src = "2024-01-01 pad Assets:Cash Equity:Opening-Balances\n";
2872 let result = parse_via_cst(src);
2873 assert_directive_count(&result, 1);
2874 let Directive::Pad(p) = &result.directives[0].value else {
2875 panic!("expected Pad");
2876 };
2877 assert_eq!(p.account.as_str(), "Assets:Cash");
2878 assert_eq!(p.source_account.as_str(), "Equity:Opening-Balances");
2879 }
2880
2881 #[test]
2882 fn custom_directive_basic() {
2883 let src = "2024-01-01 custom \"budget\" \"food\" 500 USD\n";
2884 let result = parse_via_cst(src);
2885 assert_directive_count(&result, 1);
2886 let Directive::Custom(c) = &result.directives[0].value else {
2887 panic!("expected Custom");
2888 };
2889 assert_eq!(c.custom_type, "budget");
2890 assert_eq!(c.values.len(), 2);
2891 assert_eq!(c.values[0], MetaValue::String("food".to_string()));
2892 let MetaValue::Amount(amt) = &c.values[1] else {
2894 panic!("expected Amount, got {:?}", c.values[1]);
2895 };
2896 assert_eq!(amt.number, Decimal::from(500));
2897 assert_eq!(amt.currency.as_str(), "USD");
2898 }
2899
2900 #[test]
2901 fn custom_directive_heterogeneous_values() {
2902 let src = "2024-01-01 custom \"test\" Assets:Cash TRUE 42 2024-06-15\n";
2903 let result = parse_via_cst(src);
2904 let Directive::Custom(c) = &result.directives[0].value else {
2905 panic!("expected Custom");
2906 };
2907 assert_eq!(c.values.len(), 4);
2908 assert!(matches!(c.values[0], MetaValue::Account(_)));
2909 assert_eq!(c.values[1], MetaValue::Bool(true));
2910 assert_eq!(c.values[2], MetaValue::Int(42));
2911 assert!(matches!(c.values[3], MetaValue::Date(_)));
2912 }
2913
2914 #[test]
2915 fn number_meta_value_int_vs_decimal_discriminator() {
2916 use rust_decimal_macros::dec;
2917 assert_eq!(number_meta_value("42", dec!(42)), MetaValue::Int(42));
2920 assert_eq!(number_meta_value("0", dec!(0)), MetaValue::Int(0));
2921 assert_eq!(number_meta_value("1", dec!(-1)), MetaValue::Int(-1));
2922 assert_eq!(
2924 number_meta_value("42.0", dec!(42.0)),
2925 MetaValue::Number(dec!(42.0))
2926 );
2927 assert_eq!(
2931 number_meta_value("1e3", dec!(1000)),
2932 MetaValue::Number(dec!(1000))
2933 );
2934 let huge = "99999999999999999999999999";
2936 let huge_dec = Decimal::from_str_exact(huge).unwrap();
2937 assert_eq!(
2938 number_meta_value(huge, huge_dec),
2939 MetaValue::Number(huge_dec)
2940 );
2941 }
2942
2943 #[test]
2944 fn option_directive_populates_options_field() {
2945 let src = "option \"title\" \"My Ledger\"\n";
2946 let result = parse_via_cst(src);
2947 assert_directive_count(&result, 0);
2948 assert_eq!(result.options.len(), 1);
2949 assert_eq!(result.options[0].0, "title");
2950 assert_eq!(result.options[0].1, "My Ledger");
2951 }
2952
2953 #[test]
2954 fn include_directive_populates_includes_field() {
2955 let src = "include \"shared.beancount\"\n";
2956 let result = parse_via_cst(src);
2957 assert_directive_count(&result, 0);
2958 assert_eq!(result.includes.len(), 1);
2959 assert_eq!(result.includes[0].0, "shared.beancount");
2960 }
2961
2962 #[test]
2963 fn plugin_directive_with_config() {
2964 let src = "plugin \"my.plugin\" \"cfg\"\n";
2965 let result = parse_via_cst(src);
2966 assert_directive_count(&result, 0);
2967 assert_eq!(result.plugins.len(), 1);
2968 assert_eq!(result.plugins[0].0, "my.plugin");
2969 assert_eq!(result.plugins[0].1.as_deref(), Some("cfg"));
2970 }
2971
2972 #[test]
2973 fn plugin_directive_without_config() {
2974 let src = "plugin \"my.plugin\"\n";
2975 let result = parse_via_cst(src);
2976 assert_eq!(result.plugins.len(), 1);
2977 assert_eq!(result.plugins[0].0, "my.plugin");
2978 assert!(result.plugins[0].1.is_none());
2979 }
2980
2981 #[test]
2984 fn transaction_basic_two_postings() {
2985 let src = "2024-01-15 * \"Coffee Shop\" \"Morning coffee\"\n \
2986 Expenses:Food:Coffee 5.00 USD\n \
2987 Assets:Cash\n";
2988 let result = parse_via_cst(src);
2989 assert_directive_count(&result, 1);
2990 let Directive::Transaction(t) = &result.directives[0].value else {
2991 panic!("expected Transaction");
2992 };
2993 assert_eq!(t.date, naive_date(2024, 1, 15).unwrap());
2994 assert_eq!(t.flag, '*');
2995 assert_eq!(
2996 t.payee.as_ref().map(InternedStr::as_str),
2997 Some("Coffee Shop")
2998 );
2999 assert_eq!(t.narration.as_str(), "Morning coffee");
3000 assert_eq!(t.postings.len(), 2);
3001
3002 let p0 = &t.postings[0].value;
3003 assert_eq!(p0.account.as_str(), "Expenses:Food:Coffee");
3004 let Some(IncompleteAmount::Complete(amt)) = &p0.units else {
3005 panic!("expected complete units, got {:?}", p0.units);
3006 };
3007 assert_eq!(amt.number, Decimal::new(500, 2));
3008 assert_eq!(amt.currency.as_str(), "USD");
3009
3010 let p1 = &t.postings[1].value;
3011 assert_eq!(p1.account.as_str(), "Assets:Cash");
3012 assert!(p1.units.is_none(), "auto-posting has no units");
3013 }
3014
3015 #[test]
3016 fn transaction_narration_only_no_payee() {
3017 let src = "2024-01-15 ! \"Pending\"\n Assets:Cash -5 USD\n";
3018 let result = parse_via_cst(src);
3019 let Directive::Transaction(t) = &result.directives[0].value else {
3020 panic!("expected Transaction");
3021 };
3022 assert_eq!(t.flag, '!');
3023 assert!(t.payee.is_none());
3024 assert_eq!(t.narration.as_str(), "Pending");
3025 }
3026
3027 #[test]
3028 fn transaction_three_plus_header_strings_surface_last_as_narration() {
3029 let src = "2024-01-15 * \"a\" \"b\" \"c\"\n Assets:Cash -5 USD\n";
3034 let result = parse_via_cst(src);
3035 let Directive::Transaction(t) = &result.directives[0].value else {
3036 panic!("expected Transaction");
3037 };
3038 assert!(t.payee.is_none(), "3+ strings drop the payee");
3039 assert_eq!(t.narration.as_str(), "c", "last string becomes narration");
3040 }
3041
3042 #[test]
3043 fn transaction_implied_flag_via_leading_string() {
3044 let src = "2024-01-15 \"Implied\"\n Assets:Cash -5 USD\n";
3045 let result = parse_via_cst(src);
3046 let Directive::Transaction(t) = &result.directives[0].value else {
3047 panic!("expected Transaction");
3048 };
3049 assert_eq!(t.flag, '*', "implied flag defaults to *");
3050 }
3051
3052 #[test]
3053 fn transaction_with_tags_and_links() {
3054 let src = "2024-01-15 * \"Coffee\" #daily ^trip1\n Assets:Cash -5 USD\n";
3055 let result = parse_via_cst(src);
3056 let Directive::Transaction(t) = &result.directives[0].value else {
3057 panic!("expected Transaction");
3058 };
3059 assert_eq!(t.tags.len(), 1);
3060 assert_eq!(t.tags[0].as_str(), "daily");
3061 assert_eq!(t.links.len(), 1);
3062 assert_eq!(t.links[0].as_str(), "trip1");
3063 }
3064
3065 #[test]
3066 fn transaction_with_signed_amount() {
3067 let src = "2024-01-15 * \"x\"\n Assets:Cash -5.00 USD\n";
3068 let result = parse_via_cst(src);
3069 let Directive::Transaction(t) = &result.directives[0].value else {
3070 panic!("expected Transaction");
3071 };
3072 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3073 panic!("expected complete units");
3074 };
3075 assert_eq!(amt.number, Decimal::new(-500, 2));
3076 }
3077
3078 #[test]
3079 fn transaction_with_posting_flag() {
3080 let src = "2024-01-15 * \"x\"\n ! Assets:Cash -5 USD\n";
3081 let result = parse_via_cst(src);
3082 let Directive::Transaction(t) = &result.directives[0].value else {
3083 panic!("expected Transaction");
3084 };
3085 assert_eq!(t.postings[0].value.flag, Some('!'));
3086 }
3087
3088 #[test]
3089 fn transaction_with_cost_spec_per_unit() {
3090 let src = "2024-01-15 * \"buy\"\n \
3091 Assets:Inv 10 HOOL {500.00 USD}\n \
3092 Assets:Cash\n";
3093 let result = parse_via_cst(src);
3094 let Directive::Transaction(t) = &result.directives[0].value else {
3095 panic!("expected Transaction");
3096 };
3097 let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
3098 assert!(!cost.merge);
3099 let Some(CostNumber::PerUnit { value }) = &cost.number else {
3100 panic!("expected PerUnit");
3101 };
3102 assert_eq!(*value, Decimal::new(50000, 2));
3103 assert_eq!(cost.currency.as_ref().unwrap().as_str(), "USD");
3104 }
3105
3106 #[test]
3107 fn transaction_with_cost_spec_total() {
3108 let src = "2024-01-15 * \"buy\"\n \
3109 Assets:Inv 10 HOOL {{5000 USD}}\n \
3110 Assets:Cash\n";
3111 let result = parse_via_cst(src);
3112 let Directive::Transaction(t) = &result.directives[0].value else {
3113 panic!("expected Transaction");
3114 };
3115 let cost = t.postings[0].value.cost.as_ref().expect("cost spec");
3116 let Some(CostNumber::Total { value }) = &cost.number else {
3117 panic!("expected Total");
3118 };
3119 assert_eq!(*value, Decimal::from(5000));
3120 }
3121
3122 #[test]
3123 fn transaction_with_price_annotation_unit() {
3124 let src = "2024-01-15 * \"buy\"\n \
3125 Assets:Inv 10 HOOL @ 510 USD\n \
3126 Assets:Cash\n";
3127 let result = parse_via_cst(src);
3128 let Directive::Transaction(t) = &result.directives[0].value else {
3129 panic!("expected Transaction");
3130 };
3131 let price = t.postings[0]
3132 .value
3133 .price
3134 .as_ref()
3135 .expect("price annotation");
3136 assert!(price.is_unit());
3137 let Some(IncompleteAmount::Complete(amt)) = &price.amount else {
3138 panic!("expected complete price amount");
3139 };
3140 assert_eq!(amt.number, Decimal::from(510));
3141 assert_eq!(amt.currency.as_str(), "USD");
3142 }
3143
3144 #[test]
3145 fn transaction_with_price_annotation_total() {
3146 let src = "2024-01-15 * \"buy\"\n \
3147 Assets:Inv 10 HOOL @@ 5100 USD\n \
3148 Assets:Cash\n";
3149 let result = parse_via_cst(src);
3150 let Directive::Transaction(t) = &result.directives[0].value else {
3151 panic!("expected Transaction");
3152 };
3153 let price = t.postings[0]
3154 .value
3155 .price
3156 .as_ref()
3157 .expect("price annotation");
3158 assert!(!price.is_unit(), "@@ is total form");
3159 }
3160
3161 #[test]
3164 fn document_directive_preserves_tags_and_links() {
3165 let src = "2024-06-01 document Assets:Bank \"stmt.pdf\" #quarter1 ^scan42 #urgent\n";
3169 let result = parse_via_cst(src);
3170 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3171 let Directive::Document(doc) = &result.directives[0].value else {
3172 panic!("expected Document");
3173 };
3174 let tags: Vec<&str> = doc.tags.iter().map(Tag::as_str).collect();
3175 let links: Vec<&str> = doc.links.iter().map(Link::as_str).collect();
3176 assert_eq!(tags, vec!["quarter1", "urgent"]);
3177 assert_eq!(links, vec!["scan42"]);
3178 }
3179
3180 #[test]
3181 fn open_directive_rejects_invalid_booking_method() {
3182 let src = "2024-01-01 open Assets:Bank USD \"GARBAGE\"\n";
3187 let result = parse_via_cst(src);
3188 assert_eq!(result.directives.len(), 0, "directive should be dropped");
3189 assert_eq!(result.errors.len(), 1);
3190 let err = &result.errors[0];
3191 assert!(
3192 matches!(
3193 &err.kind,
3194 crate::ParseErrorKind::InvalidBookingMethod(s) if s == "GARBAGE"
3195 ),
3196 "expected InvalidBookingMethod, got {:?}",
3197 err.kind,
3198 );
3199 }
3200
3201 #[test]
3202 fn open_directive_accepts_all_valid_booking_methods() {
3203 for method in VALID_BOOKING_METHODS {
3204 let src = format!("2024-01-01 open Assets:Bank USD \"{method}\"\n");
3205 let result = parse_via_cst(&src);
3206 assert!(
3207 result.errors.is_empty(),
3208 "{method} rejected: {:?}",
3209 result.errors
3210 );
3211 let Directive::Open(open) = &result.directives[0].value else {
3212 panic!("{method}: expected Open");
3213 };
3214 assert_eq!(open.booking.as_deref(), Some(*method));
3215 }
3216 }
3217
3218 #[test]
3219 fn unclosed_pushtag_at_eof_emits_diagnostic() {
3220 let src = "pushtag #active\n2024-01-01 open Assets:Bank USD\n";
3223 let result = parse_via_cst(src);
3224 let unclosed: Vec<_> = result
3225 .errors
3226 .iter()
3227 .filter_map(|e| match &e.kind {
3228 crate::ParseErrorKind::UnclosedPushtag(t) => Some(t.clone()),
3229 _ => None,
3230 })
3231 .collect();
3232 assert_eq!(unclosed, vec!["active".to_string()]);
3233 }
3234
3235 #[test]
3236 fn unclosed_pushmeta_at_eof_emits_diagnostic() {
3237 let src = "pushmeta location: \"NYC\"\n2024-01-01 open Assets:Bank USD\n";
3239 let result = parse_via_cst(src);
3240 let unclosed: Vec<_> = result
3241 .errors
3242 .iter()
3243 .filter_map(|e| match &e.kind {
3244 crate::ParseErrorKind::UnclosedPushmeta(k) => Some(k.clone()),
3245 _ => None,
3246 })
3247 .collect();
3248 assert_eq!(unclosed, vec!["location".to_string()]);
3249 }
3250
3251 #[test]
3252 fn invalid_poptag_on_mismatch_emits_diagnostic() {
3253 let src = "pushtag #foo\npoptag #bar\npoptag #foo\n";
3256 let result = parse_via_cst(src);
3257 let mismatches: Vec<_> = result
3258 .errors
3259 .iter()
3260 .filter_map(|e| match &e.kind {
3261 crate::ParseErrorKind::InvalidPoptag(t) => Some(t.clone()),
3262 _ => None,
3263 })
3264 .collect();
3265 assert_eq!(mismatches, vec!["bar".to_string()]);
3266 let leftover: Vec<_> = result
3269 .errors
3270 .iter()
3271 .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushtag(_)))
3272 .collect();
3273 assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
3274 }
3275
3276 #[test]
3277 fn invalid_popmeta_on_mismatch_emits_diagnostic() {
3278 let src = "pushmeta location: \"NYC\"\npopmeta nope:\npopmeta location:\n";
3282 let result = parse_via_cst(src);
3283 let mismatches: Vec<_> = result
3284 .errors
3285 .iter()
3286 .filter_map(|e| match &e.kind {
3287 crate::ParseErrorKind::InvalidPopmeta(k) => Some(k.clone()),
3288 _ => None,
3289 })
3290 .collect();
3291 assert_eq!(mismatches, vec!["nope".to_string()]);
3292 let leftover: Vec<_> = result
3293 .errors
3294 .iter()
3295 .filter(|e| matches!(e.kind, crate::ParseErrorKind::UnclosedPushmeta(_)))
3296 .collect();
3297 assert!(leftover.is_empty(), "unexpected leftover: {leftover:?}");
3298 }
3299
3300 #[test]
3301 fn pushmeta_shadow_pop_restores_prior_value() {
3302 let src = "pushmeta loc: \"NYC\"\n\
3305 pushmeta loc: \"LDN\"\n\
3306 popmeta loc:\n\
3307 2024-01-01 open Assets:Bank USD\n\
3308 popmeta loc:\n";
3309 let result = parse_via_cst(src);
3310 let Directive::Open(open) = &result.directives[0].value else {
3311 panic!("expected Open");
3312 };
3313 assert_eq!(
3314 open.meta.get("loc"),
3315 Some(&MetaValue::String("NYC".to_string())),
3316 "shadow pop should restore NYC, got {:?}",
3317 open.meta.get("loc"),
3318 );
3319 }
3320
3321 #[test]
3322 fn error_recovery_classifies_bom_in_directive_body() {
3323 let src = "garbage\u{FEFF}content\n";
3327 let result = parse_via_cst(src);
3328 let bom_errors: Vec<_> = result
3329 .errors
3330 .iter()
3331 .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3332 .collect();
3333 assert_eq!(bom_errors.len(), 1, "errors: {:?}", result.errors);
3334 assert!(
3335 bom_errors[0].hint.is_some(),
3336 "BomInDirectiveBody should carry BOM_REMOVAL_HINT",
3337 );
3338 }
3339
3340 #[test]
3341 fn error_recovery_emits_both_invalid_account_and_bom_for_dual_line() {
3342 let src = "garbage Assets:Café\u{FEFF}content\n";
3349 let result = parse_via_cst(src);
3350 let invalid_account_count = result
3351 .errors
3352 .iter()
3353 .filter(|e| matches!(e.kind, crate::ParseErrorKind::InvalidAccount(_)))
3354 .count();
3355 let bom_count = result
3356 .errors
3357 .iter()
3358 .filter(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3359 .count();
3360 assert_eq!(
3361 invalid_account_count, 1,
3362 "expected one InvalidAccount: {:?}",
3363 result.errors
3364 );
3365 assert_eq!(
3366 bom_count, 1,
3367 "expected secondary BomInDirectiveBody: {:?}",
3368 result.errors
3369 );
3370 let bom_err = result
3373 .errors
3374 .iter()
3375 .find(|e| matches!(e.kind, crate::ParseErrorKind::BomInDirectiveBody))
3376 .unwrap();
3377 assert!(bom_err.hint.is_some());
3378 }
3379
3380 #[test]
3381 fn error_recovery_classifies_unicode_account() {
3382 let src = "garbage Assets:Café content\n";
3387 let result = parse_via_cst(src);
3388 let unicode_errors: Vec<_> = result
3389 .errors
3390 .iter()
3391 .filter_map(|e| match &e.kind {
3392 crate::ParseErrorKind::InvalidAccount(s) => Some(s.clone()),
3393 _ => None,
3394 })
3395 .collect();
3396 assert_eq!(unicode_errors, vec!["Assets:Café".to_string()]);
3397 }
3398
3399 #[test]
3400 fn transaction_with_pipe_emits_deprecated_pipe_symbol() {
3401 let src = "2024-01-15 * \"Acme\" | \"invoice\"\n Assets:Cash -5 USD\n Expenses:X\n";
3404 let result = parse_via_cst(src);
3405 let pipe_count = result
3406 .errors
3407 .iter()
3408 .filter(|e| matches!(e.kind, crate::ParseErrorKind::DeprecatedPipeSymbol))
3409 .count();
3410 assert_eq!(pipe_count, 1, "errors: {:?}", result.errors);
3411 assert_eq!(result.directives.len(), 1);
3413 }
3414
3415 #[test]
3416 fn transaction_trailing_comments_after_final_posting() {
3417 let src = "2024-01-15 * \"x\"\n \
3421 Assets:Cash -5 USD\n \
3422 Expenses:X\n \
3423 ; trailing one\n \
3424 ; trailing two\n";
3425 let result = parse_via_cst(src);
3426 let Directive::Transaction(t) = &result.directives[0].value else {
3427 panic!("expected Transaction");
3428 };
3429 assert_eq!(
3430 t.trailing_comments.len(),
3431 2,
3432 "got: {:?}",
3433 t.trailing_comments
3434 );
3435 assert!(t.trailing_comments[0].contains("trailing one"));
3436 assert!(t.trailing_comments[1].contains("trailing two"));
3437 }
3438
3439 #[test]
3442 fn posting_amount_evaluates_division() {
3443 let src = "2024-01-15 * \"split\"\n \
3448 Expenses:Food 120 / 3 USD\n \
3449 Assets:Bank -40 USD\n";
3450 let result = parse_via_cst(src);
3451 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3452 let Directive::Transaction(t) = &result.directives[0].value else {
3453 panic!("expected Transaction");
3454 };
3455 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3456 panic!("expected complete amount on posting 0");
3457 };
3458 assert_eq!(amt.number, Decimal::from(40));
3459 assert_eq!(amt.currency.as_str(), "USD");
3460 }
3461
3462 #[test]
3463 fn posting_amount_evaluates_addition_and_multiplication_precedence() {
3464 let src = "2024-01-15 * \"x\"\n \
3466 Expenses:X 2 + 3 * 4 USD\n \
3467 Assets:Y -14 USD\n";
3468 let result = parse_via_cst(src);
3469 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3470 let Directive::Transaction(t) = &result.directives[0].value else {
3471 panic!("expected Transaction");
3472 };
3473 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3474 panic!("expected complete amount");
3475 };
3476 assert_eq!(amt.number, Decimal::from(14));
3477 }
3478
3479 #[test]
3480 fn posting_amount_evaluates_parens_override_precedence() {
3481 let src = "2024-01-15 * \"x\"\n \
3483 Expenses:X (2 + 3) * 4 USD\n \
3484 Assets:Y -20 USD\n";
3485 let result = parse_via_cst(src);
3486 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3487 let Directive::Transaction(t) = &result.directives[0].value else {
3488 panic!("expected Transaction");
3489 };
3490 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3491 panic!("expected complete amount");
3492 };
3493 assert_eq!(amt.number, Decimal::from(20));
3494 }
3495
3496 #[test]
3497 fn posting_amount_evaluates_subtraction_left_associative() {
3498 let src = "2024-01-15 * \"x\"\n \
3500 Expenses:X 10 - 3 - 2 USD\n \
3501 Assets:Y -5 USD\n";
3502 let result = parse_via_cst(src);
3503 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3504 let Directive::Transaction(t) = &result.directives[0].value else {
3505 panic!("expected Transaction");
3506 };
3507 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3508 panic!("expected complete amount");
3509 };
3510 assert_eq!(amt.number, Decimal::from(5));
3511 }
3512
3513 #[test]
3514 fn posting_amount_division_by_zero_drops_number() {
3515 let src = "2024-01-15 * \"x\"\n \
3520 Expenses:X 5 / 0 USD\n \
3521 Assets:Y\n";
3522 let result = parse_via_cst(src);
3523 let Directive::Transaction(t) = &result.directives[0].value else {
3524 panic!("expected Transaction");
3525 };
3526 match &t.postings[0].value.units {
3531 None | Some(IncompleteAmount::CurrencyOnly(_)) => {}
3532 other => panic!("div-by-zero leaked: {other:?}"),
3533 }
3534 }
3535
3536 #[test]
3539 fn indented_top_level_directive_emits_error() {
3540 let src = "2020-07-28 open Assets:Foo\n 2020-07-28 open Assets:Bar\n";
3545 let result = parse_via_cst(src);
3546 let indent_errs = result
3547 .errors
3548 .iter()
3549 .filter(|e| match &e.kind {
3550 crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
3551 _ => false,
3552 })
3553 .count();
3554 assert_eq!(
3555 indent_errs, 1,
3556 "expected one column-0 diagnostic, got: {:?}",
3557 result.errors
3558 );
3559 }
3560
3561 #[test]
3562 fn indented_directive_after_blank_line_still_emits_error() {
3563 let src = "2020-07-28 open Assets:Foo\n\n 2020-07-28 open Assets:Bar\n";
3567 let result = parse_via_cst(src);
3568 let indent_errs = result
3569 .errors
3570 .iter()
3571 .filter(|e| match &e.kind {
3572 crate::ParseErrorKind::SyntaxError(s) => s.contains("column 0"),
3573 _ => false,
3574 })
3575 .count();
3576 assert_eq!(indent_errs, 1, "errors: {:?}", result.errors);
3577 }
3578
3579 #[test]
3580 fn top_level_directive_at_column_0_no_diagnostic() {
3581 let src = "2020-07-28 open Assets:Foo\n2020-07-28 open Assets:Bar\n";
3584 let result = parse_via_cst(src);
3585 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3586 }
3587
3588 #[test]
3589 fn custom_directive_with_bare_currency_emits_error() {
3590 let src = "2025-01-01 custom \"x\" 10 USD \"y\" NZD\n";
3593 let result = parse_via_cst(src);
3594 let bare_curr_errs = result
3595 .errors
3596 .iter()
3597 .filter(|e| match &e.kind {
3598 crate::ParseErrorKind::SyntaxError(s) => s.contains("bare currency"),
3599 _ => false,
3600 })
3601 .count();
3602 assert_eq!(
3603 bare_curr_errs, 1,
3604 "expected one bare-currency diagnostic, got: {:?}",
3605 result.errors
3606 );
3607 }
3608
3609 #[test]
3610 fn custom_directive_with_amount_no_error() {
3611 let src = "2025-01-01 custom \"x\" 10 USD\n";
3615 let result = parse_via_cst(src);
3616 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3617 }
3618
3619 #[test]
3622 fn balance_assertion_evaluates_arithmetic_value() {
3623 let src = "2024-01-01 open Assets:X GBP\n\
3629 2024-01-01 open Equity:Open GBP\n\
3630 2024-01-02 * \"deposit\"\n \
3631 Assets:X 1.00 GBP\n \
3632 Equity:Open -1.00 GBP\n\
3633 2024-01-03 balance Assets:X 0.25 + 0.75 GBP\n";
3634 let result = parse_via_cst(src);
3635 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3636 let bal = result
3637 .directives
3638 .iter()
3639 .find_map(|d| match &d.value {
3640 Directive::Balance(b) => Some(b),
3641 _ => None,
3642 })
3643 .expect("expected a Balance directive");
3644 assert_eq!(bal.amount.number, Decimal::from(1));
3645 assert_eq!(bal.amount.currency.as_str(), "GBP");
3646 }
3647
3648 #[test]
3649 fn price_directive_evaluates_arithmetic_value() {
3650 let src = "2024-01-01 price USD 1/2 EUR\n";
3651 let result = parse_via_cst(src);
3652 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3653 let Directive::Price(p) = &result.directives[0].value else {
3654 panic!("expected Price");
3655 };
3656 assert_eq!(p.amount.number, Decimal::new(5, 1));
3657 assert_eq!(p.amount.currency.as_str(), "EUR");
3658 }
3659
3660 #[test]
3663 fn body_line_tag_does_not_drop_following_postings_comment() {
3664 let src = "2024-01-01 * \"x\"\n \
3671 Assets:A 100 USD\n \
3672 ; comment-for-B\n \
3673 #late-tag\n \
3674 Assets:B -100 USD\n";
3675 let result = parse_via_cst(src);
3676 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3677 let Directive::Transaction(t) = &result.directives[0].value else {
3678 panic!("expected Transaction");
3679 };
3680 assert!(
3682 t.tags.iter().any(|tag| tag.as_str() == "late-tag"),
3683 "expected #late-tag in tags: {:?}",
3684 t.tags,
3685 );
3686 let b = t.postings.last().expect("at least one posting");
3688 assert_eq!(b.value.account.as_str(), "Assets:B");
3689 assert!(
3690 b.value.comments.iter().any(|c| c.contains("comment-for-B")),
3691 "expected comment-for-B to survive on Assets:B: {:?}",
3692 b.value.comments,
3693 );
3694 }
3695
3696 #[test]
3697 fn oversized_number_in_amount_emits_diagnostic() {
3698 let huge = "1".to_string() + &"2345678901234567890".repeat(2); let src = format!("2024-01-15 * \"big\"\n Expenses:X {huge} USD\n Assets:Y\n");
3705 let result = parse_via_cst(&src);
3706 let invalid_num = result
3707 .errors
3708 .iter()
3709 .filter(|e| match &e.kind {
3710 crate::ParseErrorKind::SyntaxError(s) => s.contains("invalid number"),
3711 _ => false,
3712 })
3713 .count();
3714 assert_eq!(
3715 invalid_num, 1,
3716 "expected one invalid-number diagnostic, got: {:?}",
3717 result.errors
3718 );
3719 }
3720
3721 #[test]
3724 fn posting_with_two_amount_siblings_emits_error_and_keeps_first() {
3725 let src = "2024-01-15 * \"ambig\"\n \
3732 Expenses:Food 5 USD + 3 USD\n \
3733 Assets:Bank\n";
3734 let result = parse_via_cst(src);
3735 let trailing_count = result
3736 .errors
3737 .iter()
3738 .filter(|e| match &e.kind {
3739 crate::ParseErrorKind::SyntaxError(s) => s.contains("trailing tokens"),
3740 _ => false,
3741 })
3742 .count();
3743 assert_eq!(
3744 trailing_count, 1,
3745 "expected one trailing-tokens diagnostic, got: {:?}",
3746 result.errors
3747 );
3748 let Directive::Transaction(t) = &result.directives[0].value else {
3751 panic!("expected Transaction");
3752 };
3753 let Some(IncompleteAmount::Complete(amt)) = &t.postings[0].value.units else {
3754 panic!("expected complete units from the first AMOUNT");
3755 };
3756 assert_eq!(amt.number, Decimal::from(5));
3757 }
3758
3759 #[test]
3760 fn comments_dont_leak_across_failed_posting() {
3761 let src = "2024-01-15 * \"test\"\n \
3768 Assets:A 100 USD\n \
3769 ; comment-for-bad\n \
3770 ; another-comment\n \
3771 bogus_token_line_no_account\n \
3772 ; comment-for-good\n \
3773 Assets:B -100 USD\n";
3774 let result = parse_via_cst(src);
3775 let Directive::Transaction(t) = &result.directives[0].value else {
3776 panic!("expected Transaction");
3777 };
3778 let b = t.postings.last().expect("at least one posting");
3784 assert_eq!(b.value.account.as_str(), "Assets:B");
3785 assert!(
3786 !b.value
3787 .comments
3788 .iter()
3789 .any(|c| c.contains("comment-for-bad")),
3790 "comment-for-bad leaked across failed posting onto Assets:B: {:?}",
3791 b.value.comments
3792 );
3793 assert!(
3794 !b.value
3795 .comments
3796 .iter()
3797 .any(|c| c.contains("another-comment")),
3798 "another-comment leaked: {:?}",
3799 b.value.comments
3800 );
3801 }
3802
3803 #[test]
3804 fn arithmetic_overflow_in_amount_emits_diagnostic() {
3805 let huge = "9999999999999999999999999999 * 9999999999999999999999999999";
3813 let src = format!("2024-01-15 * \"big\"\n Expenses:X {huge} USD\n Assets:Y\n");
3814 let result = parse_via_cst(&src);
3815 let arith_errs = result
3816 .errors
3817 .iter()
3818 .filter(|e| match &e.kind {
3819 crate::ParseErrorKind::SyntaxError(s) => s.contains("arithmetic"),
3820 _ => false,
3821 })
3822 .count();
3823 assert_eq!(
3824 arith_errs, 1,
3825 "expected one arithmetic-error diagnostic, got: {:?}",
3826 result.errors
3827 );
3828 }
3829
3830 #[test]
3833 fn date_with_single_digit_month_parses() {
3834 let result = parse_via_cst("2024-1-15 open Assets:Checking\n");
3835 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3836 let Directive::Open(open) = &result.directives[0].value else {
3837 panic!("expected Open");
3838 };
3839 assert_eq!(open.date, naive_date(2024, 1, 15).unwrap());
3840 }
3841
3842 #[test]
3843 fn date_with_single_digit_day_parses() {
3844 let result = parse_via_cst("2024-01-5 open Assets:Cash USD\n");
3845 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3846 let Directive::Open(open) = &result.directives[0].value else {
3847 panic!("expected Open");
3848 };
3849 assert_eq!(open.date, naive_date(2024, 1, 5).unwrap());
3850 }
3851
3852 #[test]
3853 fn date_with_single_digit_month_and_day_parses() {
3854 let result = parse_via_cst("2024-1-1 open Assets:Cash USD\n");
3855 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3856 let Directive::Open(open) = &result.directives[0].value else {
3857 panic!("expected Open");
3858 };
3859 assert_eq!(open.date, naive_date(2024, 1, 1).unwrap());
3860 }
3861
3862 #[test]
3863 fn date_with_month_out_of_range_emits_invalid_date_value() {
3864 let result = parse_via_cst("2024-13-01 open Assets:Cash USD\n");
3865 let invalid_date: Vec<_> = result
3866 .errors
3867 .iter()
3868 .filter_map(|e| match &e.kind {
3869 crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
3870 _ => None,
3871 })
3872 .collect();
3873 assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
3874 let msg = &invalid_date[0];
3875 assert!(
3876 msg.contains("month") && msg.contains("out of range"),
3877 "msg: {msg}"
3878 );
3879 }
3880
3881 #[test]
3882 fn date_with_invalid_leap_year_emits_invalid_date_value() {
3883 let result = parse_via_cst("2023-02-29 open Assets:Cash USD\n");
3884 let invalid_date: Vec<_> = result
3885 .errors
3886 .iter()
3887 .filter_map(|e| match &e.kind {
3888 crate::ParseErrorKind::InvalidDateValue(s) => Some(s.clone()),
3889 _ => None,
3890 })
3891 .collect();
3892 assert_eq!(invalid_date.len(), 1, "errors: {:?}", result.errors);
3893 let msg = &invalid_date[0];
3894 assert!(
3895 msg.contains("day") && msg.contains("out of range") && msg.contains("2023-02"),
3896 "msg: {msg}"
3897 );
3898 }
3899
3900 #[test]
3901 fn date_with_completely_invalid_value_still_emits_error() {
3902 let result = parse_via_cst("2024-13-45 open Assets:Bank\n");
3906 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3907 }
3908
3909 #[test]
3910 fn open_directive_without_account_emits_error() {
3911 let result = parse_via_cst("2024-01-01 open\n");
3916 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3917 }
3918
3919 #[test]
3920 fn open_directive_with_lowercase_account_emits_error() {
3921 let result = parse_via_cst("2024-01-01 open lowercase:invalid\n");
3926 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3927 }
3928
3929 #[test]
3930 fn incomplete_open_at_eof_emits_error() {
3931 let result = parse_via_cst("2024-01-01 open");
3935 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3936 }
3937
3938 #[test]
3939 fn balance_directive_without_amount_emits_error() {
3940 let result = parse_via_cst("2024-01-15 balance Assets:Checking\n");
3941 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3942 }
3943
3944 #[test]
3945 fn pad_directive_without_source_account_emits_error() {
3946 let result = parse_via_cst("2024-01-15 pad Assets:Checking\n");
3947 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
3948 }
3949
3950 #[test]
3951 fn cost_spec_n_hash_t_parses_as_compound() {
3952 use rust_decimal_macros::dec;
3953 let src = "2024-01-01 open Assets:Stock\n\
3958 2024-01-01 open Assets:Cash USD\n\
3959 2024-01-15 *\n \
3960 Assets:Stock 10 STK {50 # 1500 USD}\n \
3961 Assets:Cash -1500.00 USD\n";
3962 let result = parse_via_cst(src);
3963 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
3964 let Directive::Transaction(txn) = &result.directives[2].value else {
3965 panic!("expected Transaction at index 2");
3966 };
3967 let cost = txn.postings[0]
3968 .value
3969 .cost
3970 .as_ref()
3971 .expect("cost spec present");
3972 assert_eq!(
3973 cost.number,
3974 Some(CostNumber::Compound {
3975 per_unit: dec!(50),
3976 total: dec!(1500)
3977 }),
3978 "the `{{N # T CCY}}` form must carry both components as written"
3979 );
3980 }
3981
3982 #[test]
3983 fn unclosed_cost_brace_emits_error() {
3984 let src = "2024-01-01 open Assets:Stock\n\
3985 2024-01-01 open Assets:Cash USD\n\
3986 2024-01-15 *\n \
3987 Assets:Stock 10 AAPL {150 USD\n \
3988 Assets:Cash -1500 USD\n";
3989 let result = parse_via_cst(src);
3990 let has_unclosed: bool = result
3991 .errors
3992 .iter()
3993 .any(|e| e.message().contains("unclosed cost"));
3994 assert!(
3995 has_unclosed,
3996 "expected 'unclosed cost' error, got: {:?}",
3997 result.errors
3998 );
3999 }
4000
4001 #[test]
4002 fn unclosed_cost_brace_at_eof_emits_error() {
4003 let src = "2024-01-01 open Assets:Stock\n\
4004 2024-01-01 open Assets:Cash USD\n\
4005 2024-01-15 *\n \
4006 Assets:Stock 10 AAPL {150 USD";
4007 let result = parse_via_cst(src);
4008 let has_unclosed: bool = result
4009 .errors
4010 .iter()
4011 .any(|e| e.message().contains("unclosed cost"));
4012 assert!(
4013 has_unclosed,
4014 "expected 'unclosed cost' error at EOF, got: {:?}",
4015 result.errors
4016 );
4017 }
4018
4019 #[test]
4020 fn leading_decimal_in_posting_amount_emits_error() {
4021 let src = "2024-01-15 * \"Test\"\n \
4025 Expenses:Food .50 USD\n \
4026 Assets:Checking\n";
4027 let result = parse_via_cst(src);
4028 assert!(!result.errors.is_empty(), "errors: {:?}", result.errors);
4029 }
4030
4031 #[test]
4032 fn transaction_with_metadata_on_directive_and_posting() {
4033 let src = "2024-01-15 * \"x\"\n \
4034 tag1: \"hello\"\n \
4035 Assets:Cash -5 USD\n \
4036 receipt: \"abc123\"\n";
4037 let result = parse_via_cst(src);
4038 let Directive::Transaction(t) = &result.directives[0].value else {
4039 panic!("expected Transaction");
4040 };
4041 assert_eq!(
4042 t.meta.get("tag1"),
4043 Some(&MetaValue::String("hello".to_string()))
4044 );
4045 let p_meta = &t.postings[0].value.meta;
4046 assert_eq!(
4047 p_meta.get("receipt"),
4048 Some(&MetaValue::String("abc123".to_string()))
4049 );
4050 }
4051
4052 #[test]
4067 fn account_occurrences_policy_for_failing_directives() {
4068 let src = "2024-01-01 open Assets:Bank \"GARBAGE\"\n";
4072 let r = parse_via_cst(src);
4073 assert!(
4074 r.account_occurrences
4075 .iter()
4076 .any(|o| o.value == "Assets:Bank"),
4077 "typed-conversion failure should keep the ACCOUNT token in \
4078 account_occurrences (got {:?}); rename mid-edit relies on this",
4079 r.account_occurrences,
4080 );
4081
4082 let src = "2024-01-01 opn Assets:Bank USD\n";
4087 let r = parse_via_cst(src);
4088 assert!(
4089 !r.account_occurrences
4090 .iter()
4091 .any(|o| o.value == "Assets:Bank"),
4092 "ERROR_NODE-wrapped ACCOUNT should be EXCLUDED from \
4093 account_occurrences (got {:?}); rename should not hit garbled \
4094 mid-edit syntax",
4095 r.account_occurrences,
4096 );
4097 }
4098}