diff --git a/org_mode_samples/greater_element/dynamic_block/simple.org b/org_mode_samples/greater_element/dynamic_block/simple.org new file mode 100644 index 0000000..1034c5e --- /dev/null +++ b/org_mode_samples/greater_element/dynamic_block/simple.org @@ -0,0 +1,25 @@ +#+BEGIN: clocktable :scope file :maxlevel 2 +#+CAPTION: Clock summary at [2023-08-25 Fri 05:34] +| Headline | Time | +|--------------+--------| +| *Total time* | *0:00* | +#+END: + +#+BEGIN: columnview :hlines 1 :id global +| ITEM | TODO | PRIORITY | TAGS | +|-------+------+----------+------------------------------| +| Foo | | B | | +|-------+------+----------+------------------------------| +| Bar | TODO | B | | +|-------+------+----------+------------------------------| +| Baz | | B | :thisisatag: | +| Lorem | | B | :thisshouldinheritfromabove: | +| Ipsum | | B | :multiple:tags: | +#+END: +* Foo +* TODO Bar +* Baz :thisisatag: +** Lorem :thisshouldinheritfromabove: +*** Ipsum :multiple:tags: +* Dolar :: +* cat :dog: bat diff --git a/src/compare/diff.rs b/src/compare/diff.rs index 4b18716..5c22d63 100644 --- a/src/compare/diff.rs +++ b/src/compare/diff.rs @@ -1,5 +1,8 @@ +use std::collections::HashSet; + use super::util::assert_bounds; use super::util::assert_name; +use crate::parser::sexp::unquote; use crate::parser::sexp::Token; use crate::parser::AngleLink; use crate::parser::Bold; @@ -323,6 +326,7 @@ fn compare_heading<'s>( let children = emacs.as_list()?; let mut child_status = Vec::new(); let mut this_status = DiffStatus::Good; + let mut message = None; let emacs_name = "headline"; if assert_name(emacs, emacs_name).is_err() { this_status = DiffStatus::Bad; @@ -332,6 +336,45 @@ fn compare_heading<'s>( this_status = DiffStatus::Bad; } + // Compare tags + let emacs_tags = get_tags_from_heading(emacs)?; + let emacs_tags: HashSet<_> = emacs_tags.iter().map(|val| val.as_str()).collect(); + let rust_tags: HashSet<&str> = rust.tags.iter().map(|val| *val).collect(); + let difference: Vec<&str> = emacs_tags + .symmetric_difference(&rust_tags) + .map(|val| *val) + .collect(); + if !difference.is_empty() { + this_status = DiffStatus::Bad; + message = Some(format!("Mismatched tags: {}", difference.join(", "))); + } + + // Compare todo-keyword + let todo_keyword = { + let children = emacs.as_list()?; + let attributes_child = children + .iter() + .nth(1) + .ok_or("Should have an attributes child.")?; + let attributes_map = attributes_child.as_map()?; + let todo_keyword = attributes_map + .get(":todo-keyword") + .ok_or("Missing :todo-keyword attribute."); + todo_keyword?.as_atom()? + }; + match (todo_keyword, rust.todo_keyword, unquote(todo_keyword)) { + ("nil", None, _) => {} + (_, Some(rust_todo), Ok(emacs_todo)) if emacs_todo == rust_todo => {} + (emacs_todo, rust_todo, _) => { + this_status = DiffStatus::Bad; + message = Some(format!( + "(emacs != rust) {:?} != {:?}", + emacs_todo, rust_todo + )); + } + }; + + // Compare title let title = { let children = emacs.as_list()?; let attributes_child = children @@ -348,6 +391,9 @@ fn compare_heading<'s>( child_status.push(compare_object(source, emacs_child, rust_child)?); } + // TODO: Compare todo-type, level, priority + + // Compare section for (emacs_child, rust_child) in children.iter().skip(2).zip(rust.children.iter()) { match rust_child { DocumentElement::Heading(rust_heading) => { @@ -362,11 +408,44 @@ fn compare_heading<'s>( Ok(DiffResult { status: this_status, name: emacs_name.to_owned(), - message: None, + message, children: child_status, }) } +fn get_tags_from_heading<'s>( + emacs: &'s Token<'s>, +) -> Result, Box> { + let children = emacs.as_list()?; + let attributes_child = children + .iter() + .nth(1) + .ok_or("Should have an attributes child.")?; + let attributes_map = attributes_child.as_map()?; + let tags = attributes_map + .get(":tags") + .ok_or("Missing :tags attribute.")?; + match tags.as_atom() { + Ok("nil") => { + return Ok(HashSet::new()); + } + Ok(val) => panic!("Unexpected value for tags: {:?}", val), + Err(_) => {} + }; + let tags = { + let tags = tags.as_list()?; + let strings = tags + .iter() + .map(Token::as_atom) + .collect::, _>>()?; + strings + .into_iter() + .map(unquote) + .collect::, _>>()? + }; + Ok(tags) +} + fn compare_paragraph<'s>( source: &'s str, emacs: &'s Token<'s>, @@ -1025,7 +1104,7 @@ fn compare_plain_text<'s>( rust.source.len() )); } - let unquoted_text = text.unquote()?; + let unquoted_text = unquote(text.text)?; if unquoted_text != rust.source { this_status = DiffStatus::Bad; message = Some(format!( diff --git a/src/parser/document.rs b/src/parser/document.rs index 957fff2..8fd8624 100644 --- a/src/parser/document.rs +++ b/src/parser/document.rs @@ -1,6 +1,8 @@ use nom::branch::alt; use nom::bytes::complete::tag; +use nom::character::complete::anychar; use nom::character::complete::line_ending; +use nom::character::complete::space0; use nom::character::complete::space1; use nom::combinator::eof; use nom::combinator::map; @@ -12,6 +14,7 @@ use nom::multi::many0; use nom::multi::many1; use nom::multi::many1_count; use nom::multi::many_till; +use nom::multi::separated_list1; use nom::sequence::tuple; use super::element::Element; @@ -50,7 +53,10 @@ pub struct Document<'s> { pub struct Heading<'s> { pub source: &'s str, pub stars: usize, + pub todo_keyword: Option<&'s str>, + // TODO: add todo-type enum pub title: Vec>, + pub tags: Vec<&'s str>, pub children: Vec>, } @@ -267,7 +273,8 @@ fn heading<'r, 's>( input: OrgSource<'s>, ) -> Res, Heading<'s>> { not(|i| context.check_exit_matcher(i))(input)?; - let (remaining, (star_count, _ws, title)) = headline(context, input)?; + let (remaining, (star_count, _ws, maybe_todo_keyword, title, heading_tags)) = + headline(context, input)?; let section_matcher = parser_with_context!(section)(context); let heading_matcher = parser_with_context!(heading)(context); let (remaining, children) = many0(alt(( @@ -283,7 +290,10 @@ fn heading<'r, 's>( Heading { source: source.into(), stars: star_count, + todo_keyword: maybe_todo_keyword + .map(|(todo_keyword, _ws)| Into::<&str>::into(todo_keyword)), title, + tags: heading_tags, children, }, )) @@ -293,30 +303,83 @@ fn heading<'r, 's>( fn headline<'r, 's>( context: Context<'r, 's>, input: OrgSource<'s>, -) -> Res, (usize, OrgSource<'s>, Vec>)> { +) -> Res< + OrgSource<'s>, + ( + usize, + OrgSource<'s>, + Option<(OrgSource<'s>, OrgSource<'s>)>, + Vec>, + Vec<&'s str>, + ), +> { let parser_context = context.with_additional_node(ContextElement::ExitMatcherNode(ExitMatcherNode { class: ExitClass::Document, - exit_matcher: &headline_end, + exit_matcher: &headline_title_end, })); let standard_set_object_matcher = parser_with_context!(standard_set_object)(&parser_context); - let (remaining, (_sol, star_count, ws, title, _line_ending)) = tuple(( + let ( + remaining, + (_sol, star_count, ws, maybe_todo_keyword, title, maybe_tags, _ws, _line_ending), + ) = tuple(( start_of_line, many1_count(tag("*")), space1, + opt(tuple((heading_keyword, space1))), many1(standard_set_object_matcher), + opt(tuple((space0, tags))), + space0, alt((line_ending, eof)), ))(input)?; - Ok((remaining, (star_count, ws, title))) + Ok(( + remaining, + ( + star_count, + ws, + maybe_todo_keyword, + title, + maybe_tags + .map(|(_ws, tags)| { + tags.into_iter() + .map(|single_tag| Into::<&str>::into(single_tag)) + .collect() + }) + .unwrap_or(Vec::new()), + ), + )) } #[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))] -fn headline_end<'r, 's>( +fn headline_title_end<'r, 's>( _context: Context<'r, 's>, input: OrgSource<'s>, ) -> Res, OrgSource<'s>> { - line_ending(input) + recognize(tuple(( + opt(tuple((space0, tags, space0))), + alt((line_ending, eof)), + )))(input) +} + +#[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))] +fn tags<'s>(input: OrgSource<'s>) -> Res, Vec>> { + let (remaining, (_open, tags, _close)) = + tuple((tag(":"), separated_list1(tag(":"), single_tag), tag(":")))(input)?; + Ok((remaining, tags)) +} + +#[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))] +fn single_tag<'r, 's>(input: OrgSource<'s>) -> Res, OrgSource<'s>> { + recognize(many1(verify(anychar, |c| { + c.is_alphanumeric() || "_@#%".contains(*c) + })))(input) +} + +#[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))] +fn heading_keyword<'s>(input: OrgSource<'s>) -> Res, OrgSource<'s>> { + // TODO: This should take into account the value of "#+TODO:" ref https://orgmode.org/manual/Per_002dfile-keywords.html and possibly the configurable variable org-todo-keywords ref https://orgmode.org/manual/Workflow-states.html. Case is significant. + alt((tag("TODO"), tag("DONE")))(input) } impl<'s> Document<'s> { diff --git a/src/parser/sexp.rs b/src/parser/sexp.rs index 49d41d7..3694554 100644 --- a/src/parser/sexp.rs +++ b/src/parser/sexp.rs @@ -35,44 +35,6 @@ pub struct TextWithProperties<'s> { pub properties: Vec>, } -impl<'s> TextWithProperties<'s> { - pub fn unquote(&self) -> Result> { - let mut out = String::with_capacity(self.text.len()); - if !self.text.starts_with(r#"""#) { - return Err("Quoted text does not start with quote.".into()); - } - if !self.text.ends_with(r#"""#) { - return Err("Quoted text does not end with quote.".into()); - } - let interior_text = &self.text[1..(self.text.len() - 1)]; - let mut state = ParseState::Normal; - for current_char in interior_text.chars().into_iter() { - state = match (state, current_char) { - (ParseState::Normal, '\\') => ParseState::Escape, - (ParseState::Normal, _) => { - out.push(current_char); - ParseState::Normal - } - (ParseState::Escape, 'n') => { - out.push('\n'); - ParseState::Normal - } - (ParseState::Escape, '\\') => { - out.push('\\'); - ParseState::Normal - } - (ParseState::Escape, '"') => { - out.push('"'); - ParseState::Normal - } - _ => todo!(), - }; - } - - Ok(out) - } -} - enum ParseState { Normal, Escape, @@ -133,6 +95,42 @@ impl<'s> Token<'s> { } } +pub fn unquote(text: &str) -> Result> { + let mut out = String::with_capacity(text.len()); + if !text.starts_with(r#"""#) { + return Err("Quoted text does not start with quote.".into()); + } + if !text.ends_with(r#"""#) { + return Err("Quoted text does not end with quote.".into()); + } + let interior_text = &text[1..(text.len() - 1)]; + let mut state = ParseState::Normal; + for current_char in interior_text.chars().into_iter() { + state = match (state, current_char) { + (ParseState::Normal, '\\') => ParseState::Escape, + (ParseState::Normal, _) => { + out.push(current_char); + ParseState::Normal + } + (ParseState::Escape, 'n') => { + out.push('\n'); + ParseState::Normal + } + (ParseState::Escape, '\\') => { + out.push('\\'); + ParseState::Normal + } + (ParseState::Escape, '"') => { + out.push('"'); + ParseState::Normal + } + _ => todo!(), + }; + } + + Ok(out) +} + #[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))] pub fn sexp_with_padding<'s>(input: &'s str) -> Res<&'s str, Token<'s>> { let (remaining, _) = multispace0(input)?;