It is possible to have two headlines that have the same level but different star counts when set to Odd because of rounding. Deciding nesting by star count instead of headline level avoids this issue.
290 lines
9.5 KiB
Rust
290 lines
9.5 KiB
Rust
use nom::branch::alt;
|
|
use nom::bytes::complete::is_a;
|
|
use nom::bytes::complete::tag;
|
|
use nom::character::complete::anychar;
|
|
use nom::character::complete::space0;
|
|
use nom::character::complete::space1;
|
|
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;
|
|
use nom::multi::many1;
|
|
use nom::multi::separated_list1;
|
|
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::start_of_line;
|
|
use crate::context::parser_with_context;
|
|
use crate::context::ContextElement;
|
|
use crate::context::ExitClass;
|
|
use crate::context::ExitMatcherNode;
|
|
use crate::context::RefContext;
|
|
use crate::error::CustomError;
|
|
use crate::error::MyError;
|
|
use crate::error::Res;
|
|
use crate::parser::object_parser::standard_set_object;
|
|
use crate::parser::util::blank_line;
|
|
use crate::types::DocumentElement;
|
|
use crate::types::Heading;
|
|
use crate::types::HeadlineLevel;
|
|
use crate::types::Object;
|
|
use crate::types::PriorityCookie;
|
|
use crate::types::TodoKeywordType;
|
|
|
|
pub(crate) const fn heading(
|
|
parent_level: HeadlineLevel,
|
|
) -> impl for<'b, 'g, 'r, 's> Fn(
|
|
RefContext<'b, 'g, 'r, 's>,
|
|
OrgSource<'s>,
|
|
) -> Res<OrgSource<'s>, Heading<'s>> {
|
|
move |context, input: OrgSource<'_>| _heading(context, input, parent_level)
|
|
}
|
|
|
|
#[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))]
|
|
fn _heading<'b, 'g, 'r, 's>(
|
|
context: RefContext<'b, 'g, 'r, 's>,
|
|
input: OrgSource<'s>,
|
|
parent_star_count: HeadlineLevel,
|
|
) -> Res<OrgSource<'s>, Heading<'s>> {
|
|
not(|i| context.check_exit_matcher(i))(input)?;
|
|
let (
|
|
remaining,
|
|
(
|
|
headline_level,
|
|
star_count,
|
|
maybe_todo_keyword,
|
|
maybe_priority,
|
|
maybe_comment,
|
|
title,
|
|
heading_tags,
|
|
),
|
|
) = headline(context, input, parent_star_count)?;
|
|
let section_matcher = parser_with_context!(section)(context);
|
|
let heading_matcher = parser_with_context!(heading(star_count))(context);
|
|
let (remaining, maybe_section) =
|
|
opt(map(section_matcher, DocumentElement::Section))(remaining)?;
|
|
let (remaining, _ws) = opt(tuple((start_of_line, many0(blank_line))))(remaining)?;
|
|
let (remaining, mut children) =
|
|
many0(map(heading_matcher, DocumentElement::Heading))(remaining)?;
|
|
if let Some(section) = maybe_section {
|
|
children.insert(0, section);
|
|
}
|
|
let remaining = if children.is_empty() {
|
|
// Support empty headings
|
|
let (remain, _ws) = many0(blank_line)(remaining)?;
|
|
remain
|
|
} else {
|
|
remaining
|
|
};
|
|
let is_archived = heading_tags.contains(&"ARCHIVE");
|
|
|
|
let source = get_consumed(input, remaining);
|
|
Ok((
|
|
remaining,
|
|
Heading {
|
|
source: source.into(),
|
|
level: headline_level,
|
|
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),
|
|
title,
|
|
tags: heading_tags,
|
|
children,
|
|
is_comment: maybe_comment.is_some(),
|
|
is_archived,
|
|
},
|
|
))
|
|
}
|
|
|
|
#[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))]
|
|
pub(crate) fn detect_headline<'s>(input: OrgSource<'s>) -> Res<OrgSource<'s>, ()> {
|
|
tuple((start_of_line, many1(tag("*")), space1))(input)?;
|
|
Ok((input, ()))
|
|
}
|
|
|
|
#[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))]
|
|
fn headline<'b, 'g, 'r, 's>(
|
|
context: RefContext<'b, 'g, 'r, 's>,
|
|
input: OrgSource<'s>,
|
|
parent_star_count: HeadlineLevel,
|
|
) -> Res<
|
|
OrgSource<'s>,
|
|
(
|
|
HeadlineLevel,
|
|
HeadlineLevel,
|
|
Option<(TodoKeywordType, OrgSource<'s>)>,
|
|
Option<(OrgSource<'s>, PriorityCookie)>,
|
|
Option<OrgSource<'s>>,
|
|
Vec<Object<'s>>,
|
|
Vec<&'s str>,
|
|
),
|
|
> {
|
|
let parser_context = ContextElement::ExitMatcherNode(ExitMatcherNode {
|
|
class: ExitClass::Document,
|
|
exit_matcher: &headline_title_end,
|
|
});
|
|
let parser_context = context.with_additional_node(&parser_context);
|
|
|
|
let (remaining, (_, (headline_level, star_count, _), _)) = tuple((
|
|
start_of_line,
|
|
verify(
|
|
parser_with_context!(headline_level)(&parser_context),
|
|
|(_, count, _)| *count > parent_star_count,
|
|
),
|
|
peek(org_space),
|
|
))(input)?;
|
|
|
|
let (remaining, maybe_todo_keyword) = opt(tuple((
|
|
space1,
|
|
parser_with_context!(heading_keyword)(&parser_context),
|
|
peek(org_space_or_line_ending),
|
|
)))(remaining)?;
|
|
|
|
let (remaining, maybe_priority) = opt(tuple((space1, priority_cookie)))(remaining)?;
|
|
|
|
let (remaining, maybe_comment) = opt(tuple((
|
|
space1,
|
|
tag("COMMENT"),
|
|
peek(org_space_or_line_ending),
|
|
)))(remaining)?;
|
|
|
|
let (remaining, maybe_title) = opt(tuple((
|
|
space1,
|
|
many1(parser_with_context!(standard_set_object)(&parser_context)),
|
|
)))(remaining)?;
|
|
|
|
let (remaining, maybe_tags) = opt(tuple((space0, tags)))(remaining)?;
|
|
|
|
let (remaining, _) = tuple((space0, org_line_ending))(remaining)?;
|
|
|
|
Ok((
|
|
remaining,
|
|
(
|
|
headline_level,
|
|
star_count,
|
|
maybe_todo_keyword.map(|(_, todo, _)| todo),
|
|
maybe_priority,
|
|
maybe_comment.map(|(_, comment, _)| comment),
|
|
maybe_title.map(|(_, title)| title).unwrap_or(Vec::new()),
|
|
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_title_end<'b, 'g, 'r, 's>(
|
|
_context: RefContext<'b, 'g, 'r, 's>,
|
|
input: OrgSource<'s>,
|
|
) -> Res<OrgSource<'s>, OrgSource<'s>> {
|
|
recognize(tuple((space0, opt(tuple((tags, space0))), org_line_ending)))(input)
|
|
}
|
|
|
|
#[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))]
|
|
fn tags<'s>(input: OrgSource<'s>) -> Res<OrgSource<'s>, Vec<OrgSource<'s>>> {
|
|
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>, 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<'b, 'g, 'r, 's>(
|
|
context: RefContext<'b, 'g, 'r, 's>,
|
|
input: OrgSource<'s>,
|
|
) -> Res<OrgSource<'s>, (TodoKeywordType, OrgSource<'s>)> {
|
|
let global_settings = context.get_global_settings();
|
|
if global_settings.in_progress_todo_keywords.is_empty()
|
|
&& global_settings.complete_todo_keywords.is_empty()
|
|
{
|
|
alt((
|
|
map(tag("TODO"), |capture| (TodoKeywordType::Todo, capture)),
|
|
map(tag("DONE"), |capture| (TodoKeywordType::Done, capture)),
|
|
))(input)
|
|
} else {
|
|
for todo_keyword in global_settings
|
|
.in_progress_todo_keywords
|
|
.iter()
|
|
.map(String::as_str)
|
|
{
|
|
let result = tag::<_, _, CustomError<_>>(todo_keyword)(input);
|
|
match result {
|
|
Ok((remaining, ent)) => {
|
|
return Ok((remaining, (TodoKeywordType::Todo, ent)));
|
|
}
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
for todo_keyword in global_settings
|
|
.complete_todo_keywords
|
|
.iter()
|
|
.map(String::as_str)
|
|
{
|
|
let result = tag::<_, _, CustomError<_>>(todo_keyword)(input);
|
|
match result {
|
|
Ok((remaining, ent)) => {
|
|
return Ok((remaining, (TodoKeywordType::Done, ent)));
|
|
}
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
Err(nom::Err::Error(CustomError::MyError(MyError(
|
|
"NoTodoKeyword".into(),
|
|
))))
|
|
}
|
|
}
|
|
|
|
fn priority_cookie<'s>(input: OrgSource<'s>) -> Res<OrgSource<'s>, PriorityCookie> {
|
|
let (remaining, (_, priority_character, _)) = tuple((
|
|
tag("[#"),
|
|
verify(anychar, |c| c.is_alphanumeric()),
|
|
tag("]"),
|
|
))(input)?;
|
|
let cookie = PriorityCookie::try_from(priority_character).map_err(|_| {
|
|
nom::Err::Error(CustomError::MyError(MyError(
|
|
"Failed to cast priority cookie to number.".into(),
|
|
)))
|
|
})?;
|
|
Ok((remaining, cookie))
|
|
}
|
|
|
|
#[cfg_attr(feature = "tracing", tracing::instrument(ret, level = "debug"))]
|
|
fn headline_level<'b, 'g, 'r, 's>(
|
|
context: RefContext<'b, 'g, 'r, 's>,
|
|
input: OrgSource<'s>,
|
|
) -> Res<OrgSource<'s>, (HeadlineLevel, HeadlineLevel, OrgSource<'s>)> {
|
|
let (remaining, stars) = is_a("*")(input)?;
|
|
let count = stars.len().try_into().unwrap();
|
|
let level = match context.get_global_settings().odd_levels_only {
|
|
crate::context::HeadlineLevelFilter::Odd => {
|
|
if count % 2 == 0 {
|
|
(count + 2) / 2
|
|
} else {
|
|
(count + 1) / 2
|
|
}
|
|
}
|
|
crate::context::HeadlineLevelFilter::OddEven => count,
|
|
};
|
|
Ok((remaining, (level, count, stars)))
|
|
}
|