use crate::{
error::{Error, SourceRange},
format::{CodePath, CodeStr},
scoring::populate_depths,
wiki::{
DIRECTORY_LINK_PREFIX, FILE_LINK_PREFIX, Link, TITLE_MARKER, TITLE_PREFIX, TextNode, Wiki,
},
};
use std::path::{Component, Path, PathBuf};
struct PendingNode {
title: String,
source_start: usize,
content_start: usize,
title_source_range: SourceRange,
}
fn trim_source_range(source_contents: &str, source_range: SourceRange) -> SourceRange {
let source = &source_contents[source_range.start..source_range.end];
let start_trimmed = source.trim_start();
let start = source_range.start + source.len() - start_trimmed.len();
let trimmed = start_trimmed.trim_end();
SourceRange {
start,
end: start + trimmed.len(),
}
}
fn push_normalized(target: &mut String, source: &str) {
target.push_str(&source.replace("\r\n", "\n"));
}
fn parse_filesystem_path(
path: &str,
source_path: Option<&Path>,
source_contents: &str,
source_range: SourceRange,
) -> Result<PathBuf, Error> {
let parsed_path = Path::new(path);
if parsed_path.as_os_str().is_empty() {
return Err(Error::new(
"This link is missing a path.",
source_path,
Some((source_contents, source_range)),
None,
));
}
let has_invalid_component = parsed_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_),
)
});
if has_invalid_component {
return Err(Error::new(
&format!(
concat!(
"Path {} must be relative to the wiki directory ",
"without using {}.",
),
parsed_path.code_path(),
"..".code_str(),
),
source_path,
Some((source_contents, source_range)),
None,
));
}
Ok(parsed_path
.components()
.filter_map(|component| match component {
Component::Normal(component) => Some(component),
Component::CurDir => None,
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
unreachable!("filesystem link path components were already validated")
}
})
.collect())
}
fn parse_link(
target: &str,
source_path: Option<&Path>,
source_contents: &str,
source_range: SourceRange,
) -> Result<Link, Error> {
let target = target.replace("\\[", "[").replace("\\]", "]");
if let Some(path) = target.strip_prefix(FILE_LINK_PREFIX) {
parse_filesystem_path(path, source_path, source_contents, source_range)
.map(|path| Link::File { path, source_range })
} else if let Some(path) = target.strip_prefix(DIRECTORY_LINK_PREFIX) {
parse_filesystem_path(path, source_path, source_contents, source_range)
.map(|path| Link::Directory { path, source_range })
} else {
Ok(Link::Text {
title: target,
source_range,
})
}
}
fn parse_content(
source_path: Option<&Path>,
source_contents: &str,
source_range: SourceRange,
) -> (String, Vec<Link>, Vec<Error>) {
let original_content = &source_contents[source_range.start..source_range.end];
let mut content = String::new();
let mut copied_through = 0;
let mut links = Vec::<Link>::new();
let mut errors = Vec::<Error>::new();
let mut link_start = None::<usize>;
let mut link_has_line_break = false;
let mut previous_was_backslash = false;
for (index, character) in original_content.char_indices() {
let is_escaped_delimiter = previous_was_backslash && matches!(character, '[' | ']');
previous_was_backslash = character == '\\';
if is_escaped_delimiter {
continue;
}
let character_source_range = SourceRange {
start: source_range.start + index,
end: source_range.start + index + character.len_utf8(),
};
if character == '\n'
&& let Some(start) = link_start
&& !link_has_line_break
{
let link_source_range = SourceRange {
start: source_range.start + start,
end: source_range.start + index + character.len_utf8(),
};
errors.push(Error::new(
"This link contains a line break.",
source_path,
Some((source_contents, link_source_range)),
None,
));
link_has_line_break = true;
}
match character {
'[' if link_start.is_some() => errors.push(Error::new(
"Unexpected opening link delimiter.",
source_path,
Some((source_contents, character_source_range)),
None,
)),
'[' => {
link_start = Some(index);
link_has_line_break = false;
}
']' if link_start.is_none() => {
errors.push(Error::new(
"Unexpected closing link delimiter.",
source_path,
Some((source_contents, character_source_range)),
None,
));
}
']' => {
let start = link_start.take().expect("the link start was checked above");
let inner_start = start + '['.len_utf8();
let trimmed_target = original_content[inner_start..index].trim();
let link_source_range = SourceRange {
start: source_range.start + start,
end: source_range.start + index + character.len_utf8(),
};
match parse_link(
trimmed_target,
source_path,
source_contents,
link_source_range,
) {
Ok(link) => links.push(link),
Err(error) => errors.push(error),
}
push_normalized(&mut content, &original_content[copied_through..inner_start]);
content.push_str(trimmed_target);
content.push(']');
copied_through = index + character.len_utf8();
}
_ => {}
}
}
if let Some(start) = link_start {
let link_source_range = SourceRange {
start: source_range.start + start,
end: source_range.start + start + '['.len_utf8(),
};
errors.push(Error::new(
"Unclosed link.",
source_path,
Some((source_contents, link_source_range)),
None,
));
}
push_normalized(&mut content, &original_content[copied_through..]);
(content, links, errors)
}
fn insert_node(
wiki: &mut Wiki,
pending_node: PendingNode,
source_end: usize,
source_path: Option<&Path>,
source_contents: &str,
) -> Result<(), Vec<Error>> {
let PendingNode {
title,
source_start,
content_start,
title_source_range,
} = pending_node;
let source_range = SourceRange {
start: source_start,
end: source_end,
};
let content_source_range = trim_source_range(
source_contents,
SourceRange {
start: content_start,
end: source_end,
},
);
let (content, links, mut errors) =
parse_content(source_path, source_contents, content_source_range);
if wiki.text_nodes.contains_key(&title) {
errors.push(Error::new(
&format!("Duplicate title {}.", title.code_str()),
source_path,
Some((source_contents, title_source_range)),
None,
));
}
if errors.is_empty() {
wiki.text_nodes.insert(
title.clone(),
TextNode {
title,
content,
links,
depth: None,
source_range,
title_source_range,
},
);
Ok(())
} else {
Err(errors)
}
}
pub fn parse(source_path: Option<&Path>, source_contents: &str) -> Result<Wiki, Vec<Error>> {
let mut wiki = Wiki::default();
let mut errors = Vec::<Error>::new();
let mut pending_node = None::<PendingNode>;
let mut has_seen_title_marker = false;
let mut reported_content_before_title = false;
let mut line_start = 0;
for raw_line in source_contents.split_inclusive('\n') {
let next_line_start = line_start + raw_line.len();
let line_with_possible_carriage_return = raw_line.strip_suffix('\n').unwrap_or(raw_line);
let line = line_with_possible_carriage_return
.strip_suffix('\r')
.unwrap_or(line_with_possible_carriage_return);
let line_source_range = SourceRange {
start: line_start,
end: line_start + line.len(),
};
let raw_title = if line == TITLE_MARKER {
Some("")
} else {
line.strip_prefix(TITLE_PREFIX)
};
if let Some(raw_title) = raw_title {
has_seen_title_marker = true;
if let Some(previous_node) = pending_node.take()
&& let Err(node_errors) = insert_node(
&mut wiki,
previous_node,
line_start,
source_path,
source_contents,
)
{
errors.extend(node_errors);
}
let title_source_range = trim_source_range(
source_contents,
SourceRange {
start: line_source_range.end - raw_title.len(),
end: line_source_range.end,
},
);
let title = &source_contents[title_source_range.start..title_source_range.end];
if title.is_empty() {
errors.push(Error::new(
"This title is empty.",
source_path,
Some((source_contents, line_source_range)),
None,
));
} else {
pending_node = Some(PendingNode {
title: title.to_owned(),
source_start: line_start,
content_start: next_line_start,
title_source_range,
});
}
} else if !has_seen_title_marker
&& !reported_content_before_title
&& !line.trim().is_empty()
{
errors.push(Error::new(
"This content is not in any node.",
source_path,
Some((
source_contents,
trim_source_range(source_contents, line_source_range),
)),
None,
));
reported_content_before_title = true;
}
line_start = next_line_start;
}
if let Some(final_node) = pending_node
&& let Err(node_errors) = insert_node(
&mut wiki,
final_node,
source_contents.len(),
source_path,
source_contents,
)
{
errors.extend(node_errors);
}
if errors.is_empty() {
populate_depths(&mut wiki);
Ok(wiki)
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::parse;
use crate::{
assert_fails,
error::Error,
wiki::{Link, Wiki},
};
use std::{
fmt::Write,
path::{Path, PathBuf},
};
fn parse_test(source_contents: &str) -> Result<Wiki, Vec<Error>> {
parse(Some(Path::new("test.mull")), source_contents)
}
fn link_targets(links: &[Link]) -> Vec<String> {
links
.iter()
.map(|link| match link {
Link::Text { title, .. } => format!("text:{title}"),
Link::File { path, .. } => format!("file:{}", path.display()),
Link::Directory { path, .. } => format!("dir:{}", path.display()),
})
.collect()
}
#[test]
fn nodes() {
let source =
" \n# Home \n\n Check out the [Greeting]. \n\n# Greeting\n Hello,\nworld! \n";
let wiki = parse_test(source).unwrap();
assert_eq!(wiki.text_nodes.len(), 2);
assert_eq!(wiki.text_nodes["Home"].title, "Home");
assert_eq!(wiki.text_nodes["Home"].content, "Check out the [Greeting].");
assert_eq!(
link_targets(&wiki.text_nodes["Home"].links),
vec!["text:Greeting"],
);
assert_eq!(wiki.text_nodes["Greeting"].content, "Hello,\nworld!");
assert_eq!(wiki.text_nodes["Home"].title_source_range.start, 7);
assert_eq!(wiki.text_nodes["Home"].title_source_range.end, 11);
assert_eq!(wiki.text_nodes["Home"].source_range.start, 3);
assert_eq!(wiki.text_nodes["Home"].source_range.end, 44);
let Link::Text { source_range, .. } = &wiki.text_nodes["Home"].links[0] else {
panic!("the parsed link should be a text link");
};
assert_eq!(&source[source_range.start..source_range.end], "[Greeting]");
}
#[test]
fn links() {
let wiki = parse_test(concat!(
"# Home\nSee [Greeting], [ About ], and [Greeting].",
"\n# About\n# Greeting",
))
.unwrap();
assert_eq!(
link_targets(&wiki.text_nodes["Home"].links),
vec!["text:Greeting", "text:About", "text:Greeting"],
);
assert_eq!(
wiki.text_nodes["Home"].content,
"See [Greeting], [About], and [Greeting].",
);
}
#[test]
fn unicode_source_ranges() {
let source = "# Home\nSee [Grüße].\n# Grüße";
let wiki = parse_test(source).unwrap();
let Link::Text { source_range, .. } = &wiki.text_nodes["Home"].links[0] else {
panic!("the parsed link should be a text link");
};
assert_eq!(&source[source_range.start..source_range.end], "[Grüße]");
assert_eq!(
&source[wiki.text_nodes["Grüße"].title_source_range.start
..wiki.text_nodes["Grüße"].title_source_range.end],
"Grüße",
);
}
#[test]
fn escaped_link_delimiters() {
let wiki = parse_test(
r"# Home
See \[Ignored\], [One\]Two], [\[Three], [Four], and \[also ignored\].
# Four
# One]Two
# [Three",
)
.unwrap();
assert_eq!(
link_targets(&wiki.text_nodes["Home"].links),
vec!["text:One]Two", "text:[Three", "text:Four"],
);
}
#[test]
fn filesystem_links() {
let wiki = parse_test(concat!(
"# Home\nSee [",
"file:./notes.txt], [",
"dir:images], and [",
"dir:images/./raw], plus [",
"dir:.].",
))
.unwrap();
assert_eq!(
link_targets(&wiki.text_nodes["Home"].links),
vec![
format!("file:{}", PathBuf::from("notes.txt").display()),
format!("dir:{}", PathBuf::from("images").display()),
format!("dir:{}", PathBuf::from("images").join("raw").display()),
"dir:".to_owned(),
],
);
}
#[test]
fn invalid_filesystem_link_paths() {
let result = parse_test(concat!(
"# Home\n[",
"file:] [",
"file:../notes.txt] [",
"dir:/images]",
));
assert_fails!(result.clone(), "This link is missing a path.");
assert_fails!(result.clone(), "Path `../notes.txt` must be relative");
assert_fails!(result, "Path `/images` must be relative");
}
#[test]
fn unclosed_link() {
assert_fails!(parse_test("# Home\nSee [Greeting."), "Unclosed link.");
}
#[test]
fn link_with_line_break() {
assert_fails!(
parse_test("# Home\nSee [Greeting\ncontinued]."),
"This link contains a line break.",
);
}
#[test]
fn unexpected_opening_delimiter() {
assert_fails!(
parse_test("# Home\nSee [nested[Greeting]."),
"Unexpected opening link delimiter.",
);
}
#[test]
fn unexpected_closing_delimiter() {
let errors = parse_test("# Home\nSee Greeting].").unwrap_err();
assert!(
errors[0]
.to_string()
.contains("Unexpected closing link delimiter"),
);
assert!(errors[0].to_string().contains("2 \u{2502} See Greeting]."));
}
#[test]
fn non_title_hashes() {
let wiki = parse_test("# Home\n\n## Subtitle\n#not a title").unwrap();
assert_eq!(wiki.text_nodes["Home"].content, "## Subtitle\n#not a title");
}
#[test]
fn empty_wiki() {
assert!(parse_test(" \n\t\n").unwrap().text_nodes.is_empty());
}
#[test]
fn empty_content() {
assert!(
parse_test("# Empty").unwrap().text_nodes["Empty"]
.content
.is_empty(),
);
}
#[test]
fn windows_line_endings() {
let wiki = parse_test("# Greeting\r\n\r\nHello, world!\r\n").unwrap();
assert_eq!(wiki.text_nodes["Greeting"].content, "Hello, world!");
}
#[test]
fn content_before_title() {
assert_fails!(
parse_test("Introduction\n# Home"),
"This content is not in any node.",
);
}
#[test]
fn empty_title() {
let errors = parse_test("# \nContent").unwrap_err();
assert_eq!(errors.len(), 1);
assert!(errors[0].to_string().contains("This title is empty."));
}
#[test]
fn content_after_empty_title() {
let errors = parse_test("# Home\n\nfoo\n\n#\n\nbar").unwrap_err();
assert_eq!(errors.len(), 1);
assert!(errors[0].to_string().contains("This title is empty."));
}
#[test]
fn bare_empty_title() {
let errors = parse_test("#").unwrap_err();
assert_eq!(errors.len(), 1);
assert!(errors[0].to_string().contains("This title is empty."));
assert!(errors[0].to_string().contains("1 │ #"));
}
#[test]
fn repeated_content_before_title() {
let errors = parse_test("First\nSecond\n# Home").unwrap_err();
assert_eq!(errors.len(), 1);
assert!(
errors[0]
.to_string()
.contains("This content is not in any node."),
);
}
#[test]
fn duplicate_title() {
let errors = parse_test("# Home\nFirst\n# Home\nSecond").unwrap_err();
assert_eq!(errors.len(), 1);
assert!(errors[0].to_string().contains("Duplicate title `Home`."));
assert!(errors[0].to_string().contains("3 \u{2502} # Home"));
}
#[test]
fn multiple_node_errors() {
let errors = parse_test("# First\nUnexpected].\n# Second\nUnclosed [link.").unwrap_err();
assert_eq!(errors.len(), 2);
assert!(
errors[0]
.to_string()
.contains("Unexpected closing link delimiter"),
);
assert!(errors[1].to_string().contains("Unclosed link"));
}
#[test]
fn multiple_errors_in_node() {
let errors = parse_test("# Home\nUnexpected] and [nested[link.").unwrap_err();
assert_eq!(errors.len(), 3);
assert!(
errors[0]
.to_string()
.contains("Unexpected closing link delimiter"),
);
assert!(
errors[1]
.to_string()
.contains("Unexpected opening link delimiter"),
);
assert!(errors[2].to_string().contains("Unclosed link"));
}
#[test]
fn multiple_error_types() {
let errors = parse_test(concat!(
"Introduction\n",
"# \n",
"Content\n",
"# First\n",
"Unexpected].\n",
"# Second\n",
"Unclosed [link.",
))
.unwrap_err();
assert_eq!(errors.len(), 4);
assert!(
errors[0]
.to_string()
.contains("This content is not in any node"),
);
assert!(errors[1].to_string().contains("This title is empty"));
assert!(
errors[2]
.to_string()
.contains("Unexpected closing link delimiter"),
);
assert!(errors[3].to_string().contains("Unclosed link"));
}
}