Skip to main content

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}