pactole_syntax/document.rs
1//! Tolerant parsing of `.pactole` source text into a concrete syntax tree.
2
3use tree_sitter::{Node, Parser, Tree};
4
5use crate::diagnostics::{DiagnosticKind, SyntaxDiagnostic};
6use crate::span::Span;
7
8/// A parsed `.pactole` source text, holding the original source, the
9/// tree-sitter concrete syntax tree, and any syntax diagnostics collected
10/// while walking the tree.
11///
12/// Parsing is tolerant: it always succeeds and produces a tree, even for
13/// invalid input. Errors are surfaced as [`SyntaxDiagnostic`]s rather than
14/// as a `Result::Err`.
15pub struct ParsedDocument {
16 source: String,
17 tree: Tree,
18 diagnostics: Vec<SyntaxDiagnostic>,
19}
20
21impl ParsedDocument {
22 /// The original source text that was parsed.
23 pub fn source(&self) -> &str {
24 &self.source
25 }
26
27 /// The tree-sitter concrete syntax tree produced for this document.
28 pub fn tree(&self) -> &Tree {
29 &self.tree
30 }
31
32 /// The root node of the concrete syntax tree.
33 pub fn root_node(&self) -> Node<'_> {
34 self.tree.root_node()
35 }
36
37 /// Whether the concrete syntax tree contains any `ERROR` or `MISSING` node.
38 pub fn has_errors(&self) -> bool {
39 self.root_node().has_error()
40 }
41
42 /// The syntax diagnostics collected while walking the tree, in document order.
43 pub fn diagnostics(&self) -> &[SyntaxDiagnostic] {
44 &self.diagnostics
45 }
46
47 /// Converts this parsed document into a strict `Result`: `Ok(self)` if
48 /// the concrete syntax tree contains no `ERROR`/`MISSING` node, or
49 /// `Err` with the collected diagnostics otherwise.
50 ///
51 /// This is a convenience for consumers (such as `pactole-storage-fs`)
52 /// that only want to proceed on a syntactically clean document, without
53 /// duplicating the `has_errors` check themselves.
54 pub fn into_result(self) -> Result<Self, Vec<SyntaxDiagnostic>> {
55 if self.has_errors() {
56 Err(self.diagnostics)
57 } else {
58 Ok(self)
59 }
60 }
61}
62
63/// Parses `source` as a `.pactole` document and collects syntax diagnostics.
64///
65/// This function never fails: malformed input yields a [`ParsedDocument`] whose
66/// [`ParsedDocument::diagnostics`] describe the `ERROR`/`MISSING` nodes found in
67/// the resulting concrete syntax tree.
68///
69/// # Panics
70///
71/// Panics if the tree-sitter parser fails to load the Pactole grammar, which
72/// would indicate a build-time inconsistency rather than a user-facing error.
73pub fn parse_document(source: &str) -> ParsedDocument {
74 let mut parser = Parser::new();
75 parser
76 .set_language(&tree_sitter_pactole::LANGUAGE.into())
77 .expect("Error loading Pactole parser");
78
79 let tree = parser
80 .parse(source, None)
81 .expect("tree-sitter parsing should always succeed for a string input");
82
83 // Collect with source text available, so `ERROR` diagnostics can quote
84 // the unexpected text rather than falling back to a bare tree-sitter
85 // S-expression.
86 let diagnostics = collect_diagnostics(&tree, Some(source));
87
88 ParsedDocument {
89 source: source.to_string(),
90 tree,
91 diagnostics,
92 }
93}
94
95/// Alias for [`parse_document`], provided for callers that prefer an `analyze`
96/// naming convention (e.g. editor tooling or language servers).
97pub fn analyze(source: &str) -> ParsedDocument {
98 parse_document(source)
99}
100
101/// Walks a tree-sitter [`Tree`] and collects a [`SyntaxDiagnostic`] for every
102/// `ERROR` node and every "missing" node it contains, in document order.
103///
104/// This is purely a syntactic operation: it does not attempt to convert
105/// anything to `pactole-core` domain models.
106///
107/// This variant has no access to the original source text, so `ERROR`
108/// diagnostics cannot quote the unexpected text; prefer
109/// [`collect_error_nodes_with_source`] when the source text is available
110/// (as [`parse_document`] does internally).
111pub fn collect_error_nodes(tree: &Tree) -> Vec<SyntaxDiagnostic> {
112 collect_diagnostics(tree, None)
113}
114
115/// Like [`collect_error_nodes`], but also takes the original source text so
116/// that `ERROR` diagnostics can include a snippet of the unexpected text,
117/// in addition to the syntactic context (surrounding directive/node kind).
118pub fn collect_error_nodes_with_source(tree: &Tree, source: &str) -> Vec<SyntaxDiagnostic> {
119 collect_diagnostics(tree, Some(source))
120}
121
122fn collect_diagnostics(tree: &Tree, source: Option<&str>) -> Vec<SyntaxDiagnostic> {
123 let mut diagnostics = Vec::new();
124 let mut cursor = tree.walk();
125 let mut visited_children = false;
126
127 loop {
128 let node = cursor.node();
129
130 if !visited_children {
131 push_diagnostic(&node, source, &mut diagnostics);
132 }
133
134 if !visited_children && cursor.goto_first_child() {
135 continue;
136 }
137
138 if cursor.goto_next_sibling() {
139 visited_children = false;
140 continue;
141 }
142
143 if !cursor.goto_parent() {
144 break;
145 }
146 visited_children = true;
147 }
148
149 diagnostics
150}
151
152fn push_diagnostic(node: &Node, source: Option<&str>, diagnostics: &mut Vec<SyntaxDiagnostic>) {
153 if node.is_missing() {
154 diagnostics.push(SyntaxDiagnostic::new(
155 DiagnosticKind::Missing,
156 Span::from_node(node),
157 describe_missing(node),
158 ));
159 } else if node.is_error() {
160 // A nested `ERROR` node inside another `ERROR` node (at any
161 // ancestor depth, not just a direct parent) does not describe a
162 // distinct problem: it is the GLR parser's own internal recovery
163 // structure for the same unexpected input already covered by the
164 // enclosing `ERROR`'s span. Reporting both would just be noise for
165 // the same issue.
166 let mut ancestor = node.parent();
167 while let Some(current) = ancestor {
168 if current.is_error() {
169 return;
170 }
171 ancestor = current.parent();
172 }
173
174 diagnostics.push(SyntaxDiagnostic::new(
175 DiagnosticKind::Error,
176 Span::from_node(node),
177 describe_error(node, source),
178 ));
179 }
180}
181
182/// Describes a "missing" node in a human-readable way, using its own kind
183/// and (when known) its parent's kind to name the surrounding directive or
184/// construct, e.g. `missing an account name in an \`open\` directive`.
185fn describe_missing(node: &Node) -> String {
186 let kind = node.kind();
187
188 // A missing closing `"` is common enough (an unterminated string) to
189 // deserve its own, more specific wording rather than the generic
190 // "missing a `\"`" phrasing.
191 if kind == "\"" {
192 return "unterminated string: missing closing `\"`".to_string();
193 }
194
195 let subject = describe_node_kind(kind);
196 match node.parent().and_then(|parent| describe_context(&parent)) {
197 Some(context) => format!("missing {subject} in {context}"),
198 None => format!("missing {subject}"),
199 }
200}
201
202/// Describes an `ERROR` node by listing the already-recognized named
203/// children (if any) and, when the source text is available, a snippet of
204/// the unexpected text covered by the node's span.
205fn describe_error(node: &Node, source: Option<&str>) -> String {
206 let mut recognized = Vec::new();
207 let mut cursor = node.walk();
208 if cursor.goto_first_child() {
209 loop {
210 let child = cursor.node();
211 if child.is_named() && !child.is_error() && !child.is_missing() {
212 recognized.push(describe_node_kind(child.kind()));
213 }
214 if !cursor.goto_next_sibling() {
215 break;
216 }
217 }
218 }
219
220 let snippet = source
221 .and_then(|src| node.utf8_text(src.as_bytes()).ok())
222 .map(str::trim)
223 .filter(|text| !text.is_empty())
224 .map(snippet_for_display);
225
226 match (recognized.is_empty(), snippet) {
227 (false, Some(snippet)) => {
228 format!(
229 "unexpected syntax after {}: `{snippet}`",
230 recognized.join(", ")
231 )
232 }
233 (false, None) => format!("unexpected syntax after {}", recognized.join(", ")),
234 (true, Some(snippet)) => format!("unexpected syntax: `{snippet}`"),
235 (true, None) => "unexpected syntax".to_string(),
236 }
237}
238
239/// Truncates `text` to its first line and a bounded number of characters,
240/// so an `ERROR` diagnostic's message stays a short, single-line summary
241/// even when the unexpected input spans multiple lines.
242fn snippet_for_display(text: &str) -> String {
243 const MAX_CHARS: usize = 60;
244
245 let first_line = text.lines().next().unwrap_or(text);
246 if first_line.chars().count() > MAX_CHARS {
247 let truncated: String = first_line.chars().take(MAX_CHARS).collect();
248 format!("{truncated}…")
249 } else {
250 first_line.to_string()
251 }
252}
253
254/// A short, human-readable phrase (with an article) describing what a
255/// node of the given tree-sitter `kind` represents, e.g. `"account"` ->
256/// `"an account name"`.
257fn describe_node_kind(kind: &str) -> String {
258 match kind {
259 "date" => "a date".to_string(),
260 "account" => "an account name".to_string(),
261 "commodity_name" => "a commodity code".to_string(),
262 "commodity_list" => "a commodity list".to_string(),
263 "number" => "a number".to_string(),
264 "tolerance" => "a tolerance value".to_string(),
265 "string" => "a quoted string".to_string(),
266 "status" => "a status flag (`*`, `!`, or `?`)".to_string(),
267 "payee" => "a payee".to_string(),
268 "narration" => "a narration".to_string(),
269 "key" => "a metadata key".to_string(),
270 "value" => "a metadata value".to_string(),
271 "path" => "a file path".to_string(),
272 "comment" => "a comment".to_string(),
273 "amount" => "an amount".to_string(),
274 "reference" => "a reference".to_string(),
275 "tag" => "a tag".to_string(),
276 "link" => "a link".to_string(),
277 // `_newline` is a hidden tree-sitter rule (its name starts with
278 // `_`): such rules are never materialized as nodes in the CST, so
279 // this arm can never actually match. There is intentionally no
280 // dedicated case for it here.
281 other if other.chars().all(|c| !c.is_alphanumeric() && c != '_') => {
282 format!("`{other}`")
283 }
284 other => format!("a `{other}`"),
285 }
286}
287
288/// A short, human-readable phrase describing the directive/construct a
289/// node belongs to, used to give a "missing"/"unexpected" diagnostic
290/// surrounding context, e.g. an `open` node's parent -> `"an \`open\`
291/// directive"`. Returns `None` for contexts that would not add useful
292/// context.
293fn describe_context(parent: &Node) -> Option<&'static str> {
294 let kind = parent.kind();
295
296 // `_newline` and directive-level `property` nodes are direct children
297 // of the generic `directive` wrapper node, not of `open`/`close`/etc.
298 // themselves (see `directive` in the grammar): look at the wrapper's
299 // first named child to name the actual directive kind. In practice, a
300 // "missing" node directly under `directive` (e.g. a missing trailing
301 // `_newline` at end-of-file) is not reachable through normal tree
302 // traversal in this version of tree-sitter, so this branch mostly
303 // guards future grammar/tree-sitter changes rather than firing today.
304 if kind == "directive" {
305 return parent
306 .named_child(0)
307 .and_then(|child| describe_context(&child));
308 }
309
310 match kind {
311 "open" => Some("an `open` directive"),
312 "close" => Some("a `close` directive"),
313 "commodity" => Some("a `commodity` directive"),
314 "payee_declaration" => Some("a `payee` declaration"),
315 "include" => Some("an `include` directive"),
316 "balance" => Some("a `balance` directive"),
317 "transaction" => Some("a transaction"),
318 "posting" => Some("a posting"),
319 "property" => Some("a metadata property"),
320 "string" => Some("a string"),
321 "amount" => Some("an amount"),
322 "commodity_list" => Some("a commodity list"),
323 _ => None,
324 }
325}