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