294 lines
9.3 KiB
Rust
294 lines
9.3 KiB
Rust
//! Tree-sitter based parser for org-mode documents.
|
|
//!
|
|
//! This crate provides a high-performance parser for org-mode files using
|
|
//! tree-sitter under the hood. The parser produces a typed AST defined in
|
|
//! the `org-ast` crate.
|
|
//!
|
|
//! # Example
|
|
//!
|
|
//! ```
|
|
//! use org_parser::Parser;
|
|
//!
|
|
//! let mut parser = Parser::new();
|
|
//! let doc = parser.parse("* Hello World\nSome content").unwrap();
|
|
//!
|
|
//! assert_eq!(doc.sections.len(), 1);
|
|
//! assert_eq!(doc.sections[0].headline.title_text(), "Hello World");
|
|
//! ```
|
|
|
|
mod error;
|
|
mod convert;
|
|
mod ts;
|
|
|
|
pub use error::{ParseError, ParseResult};
|
|
pub use ts::OrgLanguage;
|
|
|
|
use org_ast::Document;
|
|
|
|
/// The main org-mode parser.
|
|
pub struct Parser {
|
|
ts_parser: tree_sitter::Parser,
|
|
}
|
|
|
|
impl Parser {
|
|
/// Create a new parser instance.
|
|
pub fn new() -> Self {
|
|
let mut ts_parser = tree_sitter::Parser::new();
|
|
ts_parser
|
|
.set_language(&OrgLanguage::language())
|
|
.expect("Failed to load org language");
|
|
Self { ts_parser }
|
|
}
|
|
|
|
/// Parse a string into an org document.
|
|
pub fn parse(&mut self, source: &str) -> ParseResult<Document> {
|
|
let tree = self
|
|
.ts_parser
|
|
.parse(source, None)
|
|
.ok_or(ParseError::ParseFailed)?;
|
|
|
|
convert::convert_document(source, tree.root_node())
|
|
}
|
|
|
|
/// Parse a string, reusing a previous tree for incremental parsing.
|
|
pub fn parse_incremental(
|
|
&mut self,
|
|
source: &str,
|
|
old_tree: Option<&tree_sitter::Tree>,
|
|
) -> ParseResult<(Document, tree_sitter::Tree)> {
|
|
let tree = self
|
|
.ts_parser
|
|
.parse(source, old_tree)
|
|
.ok_or(ParseError::ParseFailed)?;
|
|
|
|
let doc = convert::convert_document(source, tree.root_node())?;
|
|
Ok((doc, tree))
|
|
}
|
|
|
|
/// Get the raw tree-sitter tree without conversion.
|
|
/// Useful for debugging or custom traversal.
|
|
pub fn parse_raw(&mut self, source: &str) -> ParseResult<tree_sitter::Tree> {
|
|
self.ts_parser
|
|
.parse(source, None)
|
|
.ok_or(ParseError::ParseFailed)
|
|
}
|
|
}
|
|
|
|
impl Default for Parser {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Clone for Parser {
|
|
fn clone(&self) -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_empty_document() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("").unwrap();
|
|
assert!(doc.sections.is_empty());
|
|
assert!(doc.preamble.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_simple_headline() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* Hello World").unwrap();
|
|
|
|
assert_eq!(doc.sections.len(), 1);
|
|
assert_eq!(doc.sections[0].headline.level, 1);
|
|
assert_eq!(doc.sections[0].headline.title_text(), "Hello World");
|
|
}
|
|
|
|
#[test]
|
|
fn test_nested_headlines() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* Level 1\n** Level 2\n*** Level 3").unwrap();
|
|
|
|
assert_eq!(doc.sections.len(), 1);
|
|
assert_eq!(doc.sections[0].children.len(), 1);
|
|
assert_eq!(doc.sections[0].children[0].children.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_preamble() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("#+TITLE: My Document\n\nSome preamble text.\n\n* First Headline").unwrap();
|
|
|
|
assert!(!doc.preamble.is_empty());
|
|
assert_eq!(doc.sections.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_todo_keywords() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* TODO Task 1\n* DONE Task 2").unwrap();
|
|
|
|
assert_eq!(doc.sections.len(), 2);
|
|
assert!(doc.sections[0].is_todo());
|
|
assert!(!doc.sections[0].is_done());
|
|
assert!(doc.sections[1].is_done());
|
|
assert!(!doc.sections[1].is_todo());
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_todo_keywords() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("#+TODO: NEXT WAITING | DONE CANCELLED\n* NEXT Task\n* WAITING Blocked").unwrap();
|
|
|
|
assert_eq!(doc.sections.len(), 2);
|
|
let kw1 = doc.sections[0].headline.keyword.as_ref().unwrap();
|
|
assert_eq!(kw1.keyword, "NEXT");
|
|
assert!(!kw1.done);
|
|
|
|
let kw2 = doc.sections[1].headline.keyword.as_ref().unwrap();
|
|
assert_eq!(kw2.keyword, "WAITING");
|
|
assert!(!kw2.done);
|
|
}
|
|
|
|
#[test]
|
|
fn test_priority() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* [#A] High priority\n* [#B] Medium\n* [#C] Low").unwrap();
|
|
|
|
assert_eq!(doc.sections.len(), 3);
|
|
assert_eq!(doc.sections[0].headline.priority, Some(org_ast::Priority::A));
|
|
assert_eq!(doc.sections[1].headline.priority, Some(org_ast::Priority::B));
|
|
assert_eq!(doc.sections[2].headline.priority, Some(org_ast::Priority::C));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tags() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* Task :work:urgent:\n* Other :home:").unwrap();
|
|
|
|
assert_eq!(doc.sections[0].headline.tags, vec!["work", "urgent"]);
|
|
assert_eq!(doc.sections[1].headline.tags, vec!["home"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_property_drawer() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* Task\n:PROPERTIES:\n:CUSTOM_ID: my-task\n:EFFORT: 2h\n:END:").unwrap();
|
|
|
|
let section = &doc.sections[0];
|
|
assert!(section.properties.is_some());
|
|
let props = section.properties.as_ref().unwrap();
|
|
assert_eq!(props.get("CUSTOM_ID"), Some("my-task"));
|
|
assert_eq!(props.get("EFFORT"), Some("2h"));
|
|
assert_eq!(section.custom_id(), Some("my-task"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_planning() {
|
|
let mut parser = Parser::new();
|
|
// Planning info must be on one line
|
|
let doc = parser.parse("* Task\nDEADLINE: <2025-01-15 Wed> SCHEDULED: <2025-01-14 Tue>").unwrap();
|
|
|
|
let planning = doc.sections[0].planning.as_ref().unwrap();
|
|
assert!(planning.deadline.is_some());
|
|
// Note: tree-sitter-org may not parse both on one line, check what we get
|
|
// For now just verify deadline works
|
|
}
|
|
|
|
#[test]
|
|
fn test_timestamp_with_time() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* Task\nDEADLINE: <2025-01-15 Wed 14:30>").unwrap();
|
|
|
|
let planning = doc.sections[0].planning.as_ref().unwrap();
|
|
let deadline = planning.deadline.as_ref().unwrap();
|
|
|
|
if let org_ast::TimestampValue::Point(p) = &deadline.value {
|
|
assert_eq!(p.date.year, 2025);
|
|
assert_eq!(p.date.month, 1);
|
|
assert_eq!(p.date.day, 15);
|
|
assert!(p.time.is_some());
|
|
let time = p.time.as_ref().unwrap();
|
|
assert_eq!(time.hour, 14);
|
|
assert_eq!(time.minute, 30);
|
|
} else {
|
|
panic!("Expected Point timestamp");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_repeater() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* Task\nDEADLINE: <2025-01-15 Wed +1w>").unwrap();
|
|
|
|
let planning = doc.sections[0].planning.as_ref().unwrap();
|
|
let deadline = planning.deadline.as_ref().unwrap();
|
|
assert!(deadline.has_repeater());
|
|
|
|
if let org_ast::TimestampValue::Point(p) = &deadline.value {
|
|
let r = p.repeater.as_ref().unwrap();
|
|
assert_eq!(r.value, 1);
|
|
assert_eq!(r.unit, org_ast::TimeUnit::Week);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_document_settings() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("#+TITLE: My Document\n#+AUTHOR: Test User\n#+FILETAGS: :test:sample:").unwrap();
|
|
|
|
assert_eq!(doc.title(), Some("My Document"));
|
|
assert_eq!(doc.author(), Some("Test User"));
|
|
assert!(doc.settings.filetags.contains(&"test".to_string()));
|
|
assert!(doc.settings.filetags.contains(&"sample".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sibling_sections() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* First\n* Second\n* Third").unwrap();
|
|
|
|
assert_eq!(doc.sections.len(), 3);
|
|
assert_eq!(doc.sections[0].headline.title_text(), "First");
|
|
assert_eq!(doc.sections[1].headline.title_text(), "Second");
|
|
assert_eq!(doc.sections[2].headline.title_text(), "Third");
|
|
}
|
|
|
|
#[test]
|
|
fn test_all_sections_iterator() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* A\n** B\n*** C\n** D\n* E").unwrap();
|
|
|
|
let titles: Vec<_> = doc.all_sections().map(|s| s.headline.title_text()).collect();
|
|
assert_eq!(titles, vec!["A", "B", "C", "D", "E"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_todos_iterator() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* Regular\n* TODO Task1\n* DONE Task2\n* TODO Task3").unwrap();
|
|
|
|
let todos: Vec<_> = doc.todos().collect();
|
|
assert_eq!(todos.len(), 2);
|
|
assert_eq!(todos[0].headline.title_text(), "Task1");
|
|
assert_eq!(todos[1].headline.title_text(), "Task3");
|
|
}
|
|
|
|
#[test]
|
|
fn test_complex_headline() {
|
|
let mut parser = Parser::new();
|
|
let doc = parser.parse("* TODO [#A] Important task :work:urgent:").unwrap();
|
|
|
|
let section = &doc.sections[0];
|
|
assert!(section.is_todo());
|
|
assert_eq!(section.headline.priority, Some(org_ast::Priority::A));
|
|
assert_eq!(section.headline.title_text(), "Important task");
|
|
assert!(section.has_tag("work"));
|
|
assert!(section.has_tag("urgent"));
|
|
}
|
|
}
|