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, 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}