use tree_sitter::{Node, Parser, Tree};
use crate::diagnostics::{DiagnosticKind, SyntaxDiagnostic};
use crate::span::Span;
pub struct ParsedDocument {
source: String,
tree: Tree,
diagnostics: Vec<SyntaxDiagnostic>,
}
impl ParsedDocument {
pub fn source(&self) -> &str {
&self.source
}
pub fn tree(&self) -> &Tree {
&self.tree
}
pub fn root_node(&self) -> Node<'_> {
self.tree.root_node()
}
pub fn has_errors(&self) -> bool {
self.root_node().has_error()
}
pub fn diagnostics(&self) -> &[SyntaxDiagnostic] {
&self.diagnostics
}
pub fn into_result(self) -> Result<Self, Vec<SyntaxDiagnostic>> {
if self.has_errors() {
Err(self.diagnostics)
} else {
Ok(self)
}
}
}
pub fn parse_document(source: &str) -> ParsedDocument {
let mut parser = Parser::new();
if let Err(error) = parser.set_language(&tree_sitter_pactole::LANGUAGE.into()) {
panic!("failed to load Pactole parser: {error}");
}
let tree = match parser.parse(source, None) {
Some(tree) => tree,
None => panic!("tree-sitter failed to produce a tree for a string input"),
};
let diagnostics = collect_diagnostics(&tree, Some(source));
ParsedDocument {
source: source.to_string(),
tree,
diagnostics,
}
}
pub fn analyze(source: &str) -> ParsedDocument {
parse_document(source)
}
pub fn collect_error_nodes(tree: &Tree) -> Vec<SyntaxDiagnostic> {
collect_diagnostics(tree, None)
}
pub fn collect_error_nodes_with_source(tree: &Tree, source: &str) -> Vec<SyntaxDiagnostic> {
collect_diagnostics(tree, Some(source))
}
fn collect_diagnostics(tree: &Tree, source: Option<&str>) -> Vec<SyntaxDiagnostic> {
let mut diagnostics = Vec::new();
let mut cursor = tree.walk();
let mut visited_children = false;
loop {
let node = cursor.node();
if !visited_children {
push_diagnostic(&node, source, &mut diagnostics);
}
if !visited_children && cursor.goto_first_child() {
continue;
}
if cursor.goto_next_sibling() {
visited_children = false;
continue;
}
if !cursor.goto_parent() {
break;
}
visited_children = true;
}
diagnostics
}
fn push_diagnostic(node: &Node, source: Option<&str>, diagnostics: &mut Vec<SyntaxDiagnostic>) {
if node.is_missing() {
diagnostics.push(SyntaxDiagnostic::new(
DiagnosticKind::Missing,
Span::from_node(node),
describe_missing(node),
));
} else if node.is_error() {
let mut ancestor = node.parent();
while let Some(current) = ancestor {
if current.is_error() {
return;
}
ancestor = current.parent();
}
diagnostics.push(SyntaxDiagnostic::new(
DiagnosticKind::Error,
Span::from_node(node),
describe_error(node, source),
));
}
}
fn describe_missing(node: &Node) -> String {
let kind = node.kind();
if kind == "\"" {
return "unterminated string: missing closing `\"`".to_string();
}
let subject = describe_node_kind(kind);
match node.parent().and_then(|parent| describe_context(&parent)) {
Some(context) => format!("missing {subject} in {context}"),
None => format!("missing {subject}"),
}
}
fn describe_error(node: &Node, source: Option<&str>) -> String {
let mut recognized = Vec::new();
let mut cursor = node.walk();
if cursor.goto_first_child() {
loop {
let child = cursor.node();
if child.is_named() && !child.is_error() && !child.is_missing() {
recognized.push(describe_node_kind(child.kind()));
}
if !cursor.goto_next_sibling() {
break;
}
}
}
let snippet = source
.and_then(|src| node.utf8_text(src.as_bytes()).ok())
.map(str::trim)
.filter(|text| !text.is_empty())
.map(snippet_for_display);
match (recognized.is_empty(), snippet) {
(false, Some(snippet)) => {
format!(
"unexpected syntax after {}: `{snippet}`",
recognized.join(", ")
)
}
(false, None) => format!("unexpected syntax after {}", recognized.join(", ")),
(true, Some(snippet)) => format!("unexpected syntax: `{snippet}`"),
(true, None) => "unexpected syntax".to_string(),
}
}
fn snippet_for_display(text: &str) -> String {
const MAX_CHARS: usize = 60;
let first_line = text.lines().next().unwrap_or(text);
if first_line.chars().count() > MAX_CHARS {
let truncated: String = first_line.chars().take(MAX_CHARS).collect();
format!("{truncated}…")
} else {
first_line.to_string()
}
}
fn describe_node_kind(kind: &str) -> String {
match kind {
"date" => "a date".to_string(),
"account" => "an account name".to_string(),
"commodity_name" => "a commodity code".to_string(),
"commodity_list" => "a commodity list".to_string(),
"number" => "a number".to_string(),
"tolerance" => "a tolerance value".to_string(),
"string" => "a quoted string".to_string(),
"status" => "a status flag (`*`, `!`, or `?`)".to_string(),
"payee" => "a payee".to_string(),
"narration" => "a narration".to_string(),
"key" => "a metadata key".to_string(),
"value" => "a metadata value".to_string(),
"path" => "a file path".to_string(),
"comment" => "a comment".to_string(),
"amount" => "an amount".to_string(),
"reference" => "a reference".to_string(),
"tag" => "a tag".to_string(),
"link" => "a link".to_string(),
other if other.chars().all(|c| !c.is_alphanumeric() && c != '_') => {
format!("`{other}`")
}
other => format!("a `{other}`"),
}
}
fn describe_context(parent: &Node) -> Option<&'static str> {
let kind = parent.kind();
if kind == "directive" {
return parent
.named_child(0)
.and_then(|child| describe_context(&child));
}
match kind {
"open" => Some("an `open` directive"),
"close" => Some("a `close` directive"),
"commodity" => Some("a `commodity` directive"),
"payee_declaration" => Some("a `payee` declaration"),
"include" => Some("an `include` directive"),
"balance" => Some("a `balance` directive"),
"transaction" => Some("a transaction"),
"posting" => Some("a posting"),
"property" => Some("a metadata property"),
"string" => Some("a string"),
"amount" => Some("an amount"),
"commodity_list" => Some("a commodity list"),
_ => None,
}
}