From 44e9f708c95227030f82e3925170975e86c8cf78 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 14 Sep 2023 01:41:09 -0400 Subject: [PATCH] Handle the possibility of a title-less headline. --- .../sections_and_headings/empty_heading.org | 1 + src/compare/diff.rs | 28 +++++-- src/parser/headline.rs | 74 ++++++++++--------- src/parser/util.rs | 47 ++++++++++++ 4 files changed, 108 insertions(+), 42 deletions(-) diff --git a/org_mode_samples/sections_and_headings/empty_heading.org b/org_mode_samples/sections_and_headings/empty_heading.org index 20a0030..741bea2 100644 --- a/org_mode_samples/sections_and_headings/empty_heading.org +++ b/org_mode_samples/sections_and_headings/empty_heading.org @@ -1 +1,2 @@ * DONE +* diff --git a/src/compare/diff.rs b/src/compare/diff.rs index f4f194c..c21db5f 100644 --- a/src/compare/diff.rs +++ b/src/compare/diff.rs @@ -546,14 +546,26 @@ fn compare_heading<'s>( }; // Compare title - let title = get_property(emacs, ":title")?.ok_or("Missing :title attribute.")?; - let title_status = title - .as_list()? - .iter() - .zip(rust.title.iter()) - .map(|(emacs_child, rust_child)| compare_object(source, emacs_child, rust_child)) - .collect::, _>>()?; - child_status.push(artificial_diff_scope("title".to_owned(), title_status)?); + let title = get_property(emacs, ":title")?; + match (title, rust.title.len()) { + (None, 0) => {} + (None, _) => { + this_status = DiffStatus::Bad; + message = Some(format!( + "Titles do not match (emacs != rust): {:?} != {:?}", + title, rust.title + )) + } + (Some(title), _) => { + let title_status = title + .as_list()? + .iter() + .zip(rust.title.iter()) + .map(|(emacs_child, rust_child)| compare_object(source, emacs_child, rust_child)) + .collect::, _>>()?; + child_status.push(artificial_diff_scope("title".to_owned(), title_status)?); + } + }; // Compare priority let priority = get_property(emacs, ":priority")?; diff --git a/src/parser/headline.rs b/src/parser/headline.rs index 3b472de..a6e43b3 100644 --- a/src/parser/headline.rs +++ b/src/parser/headline.rs @@ -8,6 +8,7 @@ use nom::combinator::eof; use nom::combinator::map; use nom::combinator::not; use nom::combinator::opt; +use nom::combinator::peek; use nom::combinator::recognize; use nom::combinator::verify; use nom::multi::many0; @@ -19,6 +20,11 @@ use nom::sequence::tuple; use super::org_source::OrgSource; use super::section::section; use super::util::get_consumed; +use super::util::org_line_ending; +use super::util::org_space; +use super::util::org_space_or_line_ending; +use super::util::org_spaces0; +use super::util::org_spaces1; use super::util::start_of_line; use crate::context::parser_with_context; use crate::context::ContextElement; @@ -81,10 +87,10 @@ fn _heading<'b, 'g, 'r, 's>( Heading { source: source.into(), stars: star_count, - todo_keyword: maybe_todo_keyword.map(|((todo_keyword_type, todo_keyword), _ws)| { + todo_keyword: maybe_todo_keyword.map(|(todo_keyword_type, todo_keyword)| { (todo_keyword_type, Into::<&str>::into(todo_keyword)) }), - priority_cookie: maybe_priority.map(|(priority, _)| priority), + priority_cookie: maybe_priority.map(|(_, priority)| priority), title, tags: heading_tags, children, @@ -109,9 +115,9 @@ fn headline<'b, 'g, 'r, 's>( OrgSource<'s>, ( usize, - Option<((TodoKeywordType, OrgSource<'s>), OrgSource<'s>)>, - Option<(PriorityCookie, OrgSource<'s>)>, - Option<(OrgSource<'s>, OrgSource<'s>)>, + Option<(TodoKeywordType, OrgSource<'s>)>, + Option<(OrgSource<'s>, PriorityCookie)>, + Option>, Vec>, Vec<&'s str>, ), @@ -122,45 +128,45 @@ fn headline<'b, 'g, 'r, 's>( }); let parser_context = context.with_additional_node(&parser_context); - let ( - remaining, - ( - _, - star_count, - _, - maybe_todo_keyword, - maybe_priority, - maybe_comment, - title, - maybe_tags, - _, - _, - ), - ) = tuple(( + let (remaining, (_, star_count, _)) = tuple(( start_of_line, verify(many1_count(tag("*")), |star_count| { *star_count > parent_stars }), - space1, - opt(tuple(( - parser_with_context!(heading_keyword)(&parser_context), - space1, - ))), - opt(tuple((priority_cookie, space1))), - opt(tuple((tag("COMMENT"), space1))), - many1(parser_with_context!(standard_set_object)(&parser_context)), - opt(tuple((space0, tags))), - space0, - alt((line_ending, eof)), + peek(org_space), ))(input)?; + + let (remaining, maybe_todo_keyword) = opt(tuple(( + org_spaces1, + parser_with_context!(heading_keyword)(&parser_context), + peek(org_space_or_line_ending), + )))(remaining)?; + + let (remaining, maybe_priority) = opt(tuple((org_spaces1, priority_cookie)))(remaining)?; + + let (remaining, maybe_comment) = opt(tuple(( + org_spaces1, + tag("COMMENT"), + peek(org_space_or_line_ending), + )))(remaining)?; + + let (remaining, maybe_title) = opt(tuple(( + org_spaces1, + many1(parser_with_context!(standard_set_object)(&parser_context)), + )))(remaining)?; + + let (remaining, maybe_tags) = opt(tuple((org_spaces0, tags)))(remaining)?; + + let (remaining, _) = tuple((org_spaces0, org_line_ending))(remaining)?; + Ok(( remaining, ( star_count, - maybe_todo_keyword, + maybe_todo_keyword.map(|(_, todo, _)| todo), maybe_priority, - maybe_comment, - title, + maybe_comment.map(|(_, comment, _)| comment), + maybe_title.map(|(_, title)| title).unwrap_or(Vec::new()), maybe_tags .map(|(_ws, tags)| { tags.into_iter() diff --git a/src/parser/util.rs b/src/parser/util.rs index f7bfc10..25453be 100644 --- a/src/parser/util.rs +++ b/src/parser/util.rs @@ -1,4 +1,5 @@ use nom::branch::alt; +use nom::bytes::complete::is_a; use nom::character::complete::anychar; use nom::character::complete::line_ending; use nom::character::complete::none_of; @@ -9,9 +10,11 @@ use nom::combinator::not; use nom::combinator::opt; use nom::combinator::peek; use nom::combinator::recognize; +use nom::combinator::verify; use nom::multi::many0; use nom::multi::many_till; use nom::sequence::tuple; +use nom::Slice; use super::org_source::OrgSource; use crate::context::parser_with_context; @@ -212,6 +215,9 @@ fn text_until_eol<'r, 's>( Ok(line.trim()) } +/// Return a tuple of (input, output) from a nom parser. +/// +/// This is similar to recognize except it returns the input instead of the portion of the input that was consumed. pub(crate) fn include_input<'s, F, O>( mut inner: F, ) -> impl FnMut(OrgSource<'s>) -> Res, (OrgSource<'s>, O)> @@ -223,3 +229,44 @@ where Ok((remaining, (input, output))) } } + +/// Match single space or tab. +/// +/// In org-mode syntax, spaces and tabs are interchangeable. +pub(crate) fn org_space<'s>(input: OrgSource<'s>) -> Res, char> { + one_of(" \t")(input) +} + +/// Matches a single space, tab, line ending, or end of file. +/// +/// In org-mode syntax there are often delimiters that could be any whitespace at all or the end of file. +pub(crate) fn org_space_or_line_ending<'s>( + input: OrgSource<'s>, +) -> Res, OrgSource<'s>> { + alt((recognize(one_of(" \t")), org_line_ending))(input) +} + +/// Match as many spaces and tabs as possible. No minimum match. +/// +/// In org-mode syntax, spaces and tabs are interchangeable. +pub(crate) fn org_spaces0<'s>(input: OrgSource<'s>) -> Res, OrgSource<'s>> { + let found = is_a(" \t")(input); + if found.is_ok() { + return found; + } + Ok((input, input.slice(..0))) +} + +/// Match as many spaces and tabs as possible. Minimum 1 character. +/// +/// In org-mode syntax, spaces and tabs are interchangeable. +pub(crate) fn org_spaces1<'s>(input: OrgSource<'s>) -> Res, OrgSource<'s>> { + verify(is_a(" \t"), |res: &OrgSource<'_>| res.len() > 0)(input) +} + +/// Match a line break or the end of the file. +/// +/// In org-mode syntax, the end of the file can serve the same purpose as a line break syntactically. +pub(crate) fn org_line_ending<'s>(input: OrgSource<'s>) -> Res, OrgSource<'s>> { + alt((line_ending, eof))(input) +}