use crate::{
error::{Error, SourceRange},
format::CodeStr,
scoring::populate_depths,
wiki::{
ContentText, FILESYSTEM_LINK_PREFIX, FilesystemTarget, Link, TITLE_MARKER, TITLE_PREFIX,
TextNode, Wiki, unescaped_characters,
},
};
use std::path::Path;
struct PendingNode {
title: String,
source_start: usize,
content_start: usize,
title_source_range: SourceRange,
}
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);
}
match parse_title(raw_title, source_path, source_contents, line_source_range) {
Ok(title_source_range) => {
pending_node = Some(PendingNode {
title: source_contents[title_source_range.start..title_source_range.end]
.to_owned(),
source_start: line_start,
content_start: next_line_start,
title_source_range,
});
}
Err(error) => errors.push(error),
}
} else if !has_seen_title_marker
&& !reported_content_before_title
&& !line.trim().is_empty()
{
errors.push(Error::new(
"This content isn't in any node.",
source_path,
Some((
source_contents,
trim_source_range(source_contents, line_source_range),
)),
None,
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)
}
}
fn parse_title(
raw_title: &str,
source_path: Option<&Path>,
source_contents: &str,
line_source_range: SourceRange,
) -> Result<SourceRange, Error> {
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() {
Err(Error::new(
"A node title can't be empty.",
source_path,
Some((source_contents, line_source_range)),
None,
None,
))
} else if !title.starts_with(FILESYSTEM_LINK_PREFIX) {
Ok(title_source_range)
} else {
Err(Error::new(
&format!(
"A node title can't start with {}.",
FILESYSTEM_LINK_PREFIX.code_str(),
),
source_path,
Some((source_contents, title_source_range)),
None,
None,
))
}
}
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 = trim_source_range(
source_contents,
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!("Node {} already exists.", title.code_str()),
source_path,
Some((source_contents, title_source_range)),
None,
None,
));
}
if errors.is_empty() {
wiki.text_nodes.insert(
title.clone(),
TextNode {
title,
content: ContentText::from_source(&content),
links,
depth: None,
source_range,
title_source_range,
},
);
Ok(())
} else {
Err(errors)
}
}
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 syntax_error = |message: &str, error_source_range| {
Error::new(
message,
source_path,
Some((source_contents, error_source_range)),
None,
None,
)
};
for (index, character) in unescaped_characters(original_content) {
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(syntax_error(
"A link can't contain a line break.",
link_source_range,
));
link_has_line_break = true;
}
match character {
'[' if link_start.is_some() => errors.push(syntax_error(
"Unexpected opening link delimiter.",
character_source_range,
)),
'[' => {
link_start = Some(index);
link_has_line_break = false;
}
']' if link_start.is_none() => {
errors.push(syntax_error(
"Unexpected closing link delimiter.",
character_source_range,
));
}
']' => {
let start = link_start.take().expect("The link start was checked.");
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(),
};
let link = parse_link(
trimmed_target,
source_path,
source_contents,
link_source_range,
);
let formatted_target = match &link {
Ok(Link::Filesystem { target, .. }) => target.text().into_string(),
Ok(Link::Text { .. }) | Err(_) => trimmed_target.to_owned(),
};
match link {
Ok(link) => links.push(link),
Err(error) => errors.push(error),
}
content.push_str(&original_content[copied_through..inner_start]);
content.push_str(&formatted_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(syntax_error("Unclosed link.", link_source_range));
}
content.push_str(&original_content[copied_through..]);
(normalize_lines(&content), links, errors)
}
fn parse_link(
target: &str,
source_path: Option<&Path>,
source_contents: &str,
source_range: SourceRange,
) -> Result<Link, Error> {
let target = ContentText::from_source(target);
if target.as_str().starts_with(FILESYSTEM_LINK_PREFIX) {
let target = FilesystemTarget::parse(&target).map_err(|message| {
Error::new(
&message,
source_path,
Some((source_contents, source_range)),
None,
None,
)
})?;
Ok(Link::Filesystem {
target,
source_range,
})
} else {
Ok(Link::Text {
title: target.unescape(),
source_range,
})
}
}
fn normalize_lines(text: &str) -> String {
text.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
}
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(),
}
}
#[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::Filesystem { target, .. } => format!(
"{}:{}",
if target.is_directory() { "dir" } else { "file" },
target.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.as_str(),
"Check out the [Greeting].",
);
assert_eq!(
link_targets(&wiki.text_nodes["Home"].links),
vec!["text:Greeting"],
);
assert_eq!(
wiki.text_nodes["Greeting"].content.as_str(),
"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, 41);
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.as_str(),
"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 escaped_backslashes() {
let wiki = parse_test(
r"# Home
See \\[Four], [Five\\], [A\B], and \\\[ignored\].
# Four
# Five\
# A\B",
)
.unwrap();
assert_eq!(
link_targets(&wiki.text_nodes["Home"].links),
vec!["text:Four", "text:Five\\", "text:A\\B"],
);
}
#[test]
fn filesystem_links() {
let wiki = parse_test(
"# Home\nSee [/notes.txt], [/images/], [/images/./raw/], [/], [//docs/], and \
[/ spaced.txt].",
)
.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(),
format!("dir:{}", PathBuf::from("docs").display()),
format!("file:{}", PathBuf::from(" spaced.txt").display()),
],
);
}
#[test]
fn formatted_links() {
let wiki = parse_test(
"# Home\n[ Home ] [ /a//b/./c\\[1\\].txt ] [/images/] [/images/./raw//] [/] [/./] \
[///]",
)
.unwrap();
assert_eq!(
wiki.text_nodes["Home"].content.as_str(),
"[Home] [/a/b/c\\[1\\].txt] [/images/] [/images/raw/] [/] [/] [/]",
);
}
#[test]
fn trailing_whitespace() {
let wiki =
parse_test("# Home\r\nFirst \t\r\n \nSee [Home] \nand [Home] later.\t\nLast")
.unwrap();
assert_eq!(
wiki.text_nodes["Home"].content.as_str(),
"First\n\nSee [Home]\nand [Home] later.\nLast",
);
}
#[test]
fn invalid_filesystem_link_paths() {
let result = parse_test("# Home\n[/../notes.txt] [/notes/../notes.txt]");
assert_fails!(result.clone(), "Path `../notes.txt` must not contain `..`.");
assert_fails!(result, "Path `notes/../notes.txt` must not contain `..`.");
}
#[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]."),
"A link can't contain 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.as_str(),
"## 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
.as_str()
.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.as_str(),
"Hello, world!",
);
}
#[test]
fn content_before_title() {
assert_fails!(
parse_test("Introduction\n# Home"),
"This content isn't 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("A node title can't be empty."),
);
}
#[test]
fn filesystem_link_titles() {
let errors = parse_test("# Home\n\n# /notes.txt\n\n# /\n\n# notes: /draft").unwrap_err();
assert_eq!(errors.len(), 2);
for error in &errors {
assert!(
error
.to_string()
.contains("A node title can't start with `/`."),
);
}
}
#[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("A node title can't be empty."),
);
}
#[test]
fn bare_empty_title() {
let errors = parse_test("#").unwrap_err();
assert_eq!(errors.len(), 1);
assert!(
errors[0]
.to_string()
.contains("A node title can't be 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 isn't 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("Node `Home` already exists."),
);
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 isn't in any node"),
);
assert!(
errors[1]
.to_string()
.contains("A node title can't be empty"),
);
assert!(
errors[2]
.to_string()
.contains("Unexpected closing link delimiter"),
);
assert!(errors[3].to_string().contains("Unclosed link"));
}
}