Skip to main content

pactole_syntax/
lib.rs

1//! Tolerant syntax analysis for `.pactole` source text.
2//!
3//! `pactole-syntax` sits between the raw tree-sitter grammar
4//! (`tree-sitter-pactole`) and higher-level consumers such as `pactole-storage-fs`
5//! or a future language server. It exposes:
6//!
7//! - [`span`]: byte-offset and row/column positions ([`span::Point`], [`span::Span`]).
8//! - [`diagnostics`]: syntax-level diagnostics ([`diagnostics::SyntaxDiagnostic`],
9//!   [`diagnostics::DiagnosticKind`]) that do not reference `pactole-core`.
10//! - [`document`]: tolerant parsing entry points ([`document::parse_document`],
11//!   [`document::analyze`]) producing a [`document::ParsedDocument`], plus
12//!   [`document::collect_error_nodes`]/[`document::collect_error_nodes_with_source`]
13//!   to walk a tree-sitter [`tree_sitter::Tree`] for `ERROR`/`MISSING` nodes.
14//!   Diagnostic messages are contextual human-readable phrases (e.g. "missing an
15//!   account name in an `open` directive") rather than raw tree-sitter
16//!   S-expressions. [`document::ParsedDocument::into_result`] offers a strict
17//!   conversion to `Result` for consumers (e.g. `pactole-storage-fs`) that only
18//!   want to proceed on a syntactically clean document.
19//!
20//! This crate performs no I/O and has no dependency on `pactole-core`: it only
21//! deals with the concrete syntax tree and its diagnostics.
22
23pub mod diagnostics;
24pub mod document;
25pub mod span;
26
27pub use diagnostics::{DiagnosticKind, SyntaxDiagnostic};
28pub use document::{
29    ParsedDocument, analyze, collect_error_nodes, collect_error_nodes_with_source, parse_document,
30};
31pub use span::{Point, Span};
32
33#[cfg(test)]
34mod tests {
35    use super::*;
36
37    #[test]
38    fn parses_valid_source_without_diagnostics() {
39        let source = "2024-01-01 open Assets:Cash\n";
40        let doc = parse_document(source);
41
42        assert!(!doc.has_errors());
43        assert!(doc.diagnostics().is_empty());
44        assert_eq!(doc.source(), source);
45    }
46
47    #[test]
48    fn reports_positioned_diagnostic_for_invalid_source() {
49        let source = "2024-01-01 open\n";
50        let doc = parse_document(source);
51
52        assert!(doc.has_errors());
53        assert!(!doc.diagnostics().is_empty());
54
55        let diagnostic = &doc.diagnostics()[0];
56        // The diagnostic must be anchored somewhere within the source text.
57        assert!(diagnostic.span.start.byte <= source.len());
58        assert!(diagnostic.span.end.byte <= source.len());
59        assert!(diagnostic.span.start.row == 0);
60    }
61
62    #[test]
63    fn into_result_succeeds_for_valid_source() {
64        let source = "2024-01-01 open Assets:Cash\n";
65        let doc = parse_document(source).into_result();
66
67        assert!(doc.is_ok());
68    }
69
70    #[test]
71    fn into_result_fails_for_invalid_source() {
72        let source = "2024-01-01 open\n";
73        let result = parse_document(source).into_result();
74
75        let diagnostics = match result {
76            Ok(_) => panic!("expected an error"),
77            Err(diagnostics) => diagnostics,
78        };
79        assert!(!diagnostics.is_empty());
80    }
81
82    #[test]
83    fn analyze_is_an_alias_for_parse_document() {
84        let source = "2024-01-01 open Assets:Cash\n";
85        let doc = analyze(source);
86
87        assert!(!doc.has_errors());
88    }
89
90    #[test]
91    fn collect_error_nodes_matches_document_diagnostics_count() {
92        let source = "2024-01-01 open\n";
93        let doc = parse_document(source);
94
95        let diagnostics = collect_error_nodes(doc.tree());
96        assert_eq!(diagnostics.len(), doc.diagnostics().len());
97    }
98
99    #[test]
100    fn missing_closing_quote_is_reported_as_an_unterminated_string() {
101        // The grammar keeps looking for the closing `"` across the newline,
102        // so the GLR parser recovers by inserting a "missing" node rather
103        // than wrapping the whole line in an `ERROR`.
104        let source = "2024-01-01 open Assets:Cash\n  note: \"unterminated\n";
105        let doc = parse_document(source);
106
107        assert!(doc.has_errors());
108        let diagnostic = doc
109            .diagnostics()
110            .iter()
111            .find(|d| d.kind == DiagnosticKind::Missing)
112            .expect("expected a missing-node diagnostic");
113        assert_eq!(
114            diagnostic.message,
115            "unterminated string: missing closing `\"`"
116        );
117    }
118
119    #[test]
120    fn missing_closing_quote_inside_a_transaction_payee_is_contextual() {
121        // Unterminated strings are the one case where this grammar's GLR
122        // error recovery reliably produces a `MISSING` node reachable via
123        // normal tree traversal (as opposed to `ERROR`-wrapping the whole
124        // construct); exercise it for a payee string too, not just a
125        // directive-level metadata value.
126        let source = "2024-01-01 * \"unterminated\npayee\n";
127        let doc = parse_document(source);
128
129        assert!(doc.has_errors());
130        let diagnostic = doc
131            .diagnostics()
132            .iter()
133            .find(|d| d.kind == DiagnosticKind::Missing)
134            .expect("expected a missing-node diagnostic");
135        assert_eq!(
136            diagnostic.message,
137            "unterminated string: missing closing `\"`"
138        );
139    }
140
141    #[test]
142    fn unexpected_syntax_after_known_tokens_lists_them_and_quotes_the_snippet() {
143        // `balance` requires a trailing commodity code after the number;
144        // omitting it leaves date/account/number recognized but makes the
145        // whole directive unparsable, so it is wrapped in a single `ERROR`.
146        let source = "2024-01-01 balance Assets:Cash 10\n";
147        let doc = parse_document(source);
148
149        assert!(doc.has_errors());
150        let diagnostic = doc
151            .diagnostics()
152            .iter()
153            .find(|d| d.kind == DiagnosticKind::Error)
154            .expect("expected an error-node diagnostic");
155        assert!(
156            diagnostic.message.contains("a date"),
157            "message should mention the recognized date: {}",
158            diagnostic.message
159        );
160        assert!(
161            diagnostic.message.contains("an account name"),
162            "message should mention the recognized account: {}",
163            diagnostic.message
164        );
165        assert!(
166            !diagnostic.message.contains('('),
167            "message should not be a raw tree-sitter S-expression: {}",
168            diagnostic.message
169        );
170    }
171
172    #[test]
173    fn unexpected_syntax_with_no_recognized_tokens_still_quotes_the_snippet() {
174        let source = "garbage line not a directive\n";
175        let doc = parse_document(source);
176
177        assert!(doc.has_errors());
178        let diagnostic = &doc.diagnostics()[0];
179        assert_eq!(diagnostic.kind, DiagnosticKind::Error);
180        assert!(
181            diagnostic.message.contains("garbage line not a directive"),
182            "message should quote the unexpected text: {}",
183            diagnostic.message
184        );
185        assert!(!diagnostic.message.starts_with('('));
186    }
187
188    #[test]
189    fn nested_error_nodes_are_not_reported_twice() {
190        // Each of these garbage lines is wrapped in its own top-level
191        // `ERROR` node by the GLR parser; make sure that does not somehow
192        // explode into extra, nested diagnostics for the same text.
193        let source = "@@@\n###\n$$$\n";
194        let doc = parse_document(source);
195
196        assert!(doc.has_errors());
197        assert!(
198            doc.diagnostics()
199                .iter()
200                .all(|d| d.kind == DiagnosticKind::Error)
201        );
202    }
203
204    #[test]
205    fn collect_error_nodes_without_source_still_gives_a_contextual_message() {
206        let source = "2024-01-01 balance Assets:Cash 10\n";
207        let doc = parse_document(source);
208
209        let diagnostics = collect_error_nodes(doc.tree());
210        let diagnostic = diagnostics
211            .iter()
212            .find(|d| d.kind == DiagnosticKind::Error)
213            .expect("expected an error-node diagnostic");
214        // No source text was provided, so there is no quoted snippet, but
215        // the recognized tokens are still named.
216        assert!(diagnostic.message.contains("a date"));
217        assert!(!diagnostic.message.contains('('));
218    }
219
220    #[test]
221    fn collect_error_nodes_with_source_matches_document_diagnostics() {
222        let source = "2024-01-01 balance Assets:Cash 10\n";
223        let doc = parse_document(source);
224
225        let diagnostics = collect_error_nodes_with_source(doc.tree(), source);
226        assert_eq!(diagnostics, doc.diagnostics());
227    }
228}