use crate::{
error::{Error, Fix, SourceRange},
escaping::{Context, invalid_escape_message, is_escapable, unescape},
format::CodeStr,
line_index::LineIndex,
scoring::populate_traversal_order,
wiki::{
Embed, FILESYSTEM_LINK_PREFIX, FilesystemTarget, Link, MIN_FENCE_LENGTH, Page, PageTitle,
TITLE_PREFIX, Wiki,
},
};
use std::{collections::HashSet, path::Path};
const MIN_MULTILINE_CODE_LENGTH: usize = 3;
struct PendingPage {
title: Option<(PageTitle, SourceRange)>,
source_start: usize,
links: Vec<Link>,
embeds: Vec<Embed>,
}
struct OpenEmbed {
fence_length: usize,
start: usize, argument_source_range: SourceRange, link: Option<(PageTitle, SourceRange)>, }
struct OpenLink {
start: usize, has_nested_start: bool, }
#[derive(Debug, Eq, PartialEq)]
enum FenceLine {
Opening {
indentation: usize,
length: usize,
argument: (usize, usize),
},
Closing {
length: usize,
},
}
#[allow(
clippy::too_many_lines,
reason = "The function reads the source in one pass, tracking the state of the region, link, \
embeds, and line being read."
)]
pub fn parse(
source_path: Option<&Path>,
source_contents: &str,
line_index: &LineIndex,
) -> (Wiki, Vec<Error>) {
let mut wiki = Wiki::new(source_contents);
let mut errors = Vec::<Error>::new();
let mut pending_page = PendingPage {
title: None,
source_start: 0,
links: Vec::new(),
embeds: Vec::new(),
};
let mut can_report_content_before_title = true;
let mut open_link = None::<OpenLink>;
let mut open_embeds = Vec::<OpenEmbed>::new();
let mut opening_fence_links_before = None::<usize>; let mut unclosable_lengths = HashSet::new();
let mut is_at_line_start = true;
let mut position = 0;
let syntax_error = |message: &str, error_source_range, fix| {
Error::new(
message,
source_path,
Some((source_contents, line_index, error_source_range)),
None,
fix,
)
};
let close_unclosed_link =
|links: &mut Vec<Link>, errors: &mut Vec<Error>, link: OpenLink, end: usize| {
links.push(Link::Malformed {
source_range: SourceRange {
start: link.start,
end,
},
is_closed: false,
});
errors.push(syntax_error(
"Unclosed link.",
SourceRange {
start: link.start,
end: link.start + '['.len_utf8(),
},
None,
));
};
let read_opening_fence = |open_embed: &mut OpenEmbed,
links: &[Link],
links_before: usize,
errors: &mut Vec<Error>| {
let new_links = &links[links_before..];
let argument_source_range = open_embed.argument_source_range;
match new_links {
[
Link::Text {
title,
source_range,
},
] if *source_range == argument_source_range => {
open_embed.link = Some((title.clone(), *source_range));
}
[Link::Malformed { .. }] => {}
[Link::Filesystem { source_range, .. }] if *source_range == argument_source_range => {
errors.push(syntax_error(
"Only pages can be embedded.",
*source_range,
None,
));
}
_ => errors.push(syntax_error(
&format!(
"An embed names the page to embed with a link, like {}.",
"::: [Page]".code_str(),
),
if argument_source_range.start == argument_source_range.end {
SourceRange {
start: open_embed.start,
end: argument_source_range.end,
}
} else {
argument_source_range
},
None,
)),
}
};
let report_unclosed_embeds = |open_embeds: &mut Vec<OpenEmbed>, errors: &mut Vec<Error>| {
for open_embed in open_embeds.drain(..) {
errors.push(syntax_error(
"Unclosed embed.",
SourceRange {
start: open_embed.start,
end: open_embed.start + open_embed.fence_length,
},
None,
));
}
};
while let Some(character) = source_contents[position..].chars().next() {
if is_at_line_start {
is_at_line_start = false;
let line_end = source_contents[position..]
.find('\n')
.map_or(source_contents.len(), |index| position + index);
let line = &source_contents[position..line_end];
let line = line.strip_suffix('\r').unwrap_or(line);
let line_source_range = SourceRange {
start: position,
end: position + line.len(),
};
if let Some(raw_title) = title_line_text(line) {
can_report_content_before_title = false;
report_unclosed_embeds(&mut open_embeds, &mut errors);
finish_page(&mut wiki, pending_page, position, source_contents);
let title = match parse_title(
raw_title,
source_path,
source_contents,
line_index,
line_source_range,
) {
Ok((title, title_source_range)) if wiki.pages.contains_key(&title) => {
errors.push(syntax_error(
&format!("Page {} already exists.", title.code_str()),
title_source_range,
None,
));
None
}
Ok(title) => Some(title),
Err(error) => {
errors.push(error);
None
}
};
pending_page = PendingPage {
title,
source_start: position,
links: Vec::new(),
embeds: Vec::new(),
};
position = line_end;
continue;
}
if !can_report_content_before_title && let Some(fence) = fence_line(line) {
match fence {
FenceLine::Opening {
indentation,
length,
argument: (argument_start, argument_end),
} => {
open_embeds.push(OpenEmbed {
fence_length: length,
start: position + indentation,
argument_source_range: SourceRange {
start: position + argument_start,
end: position + argument_end,
},
link: None,
});
opening_fence_links_before = Some(pending_page.links.len());
position += argument_start;
}
FenceLine::Closing { length } => {
if let Some(index) = open_embeds
.iter()
.rposition(|open_embed| open_embed.fence_length == length)
{
let mut inner_embeds = open_embeds.split_off(index);
let open_embed = inner_embeds.remove(0);
report_unclosed_embeds(&mut inner_embeds, &mut errors);
if let Some((title, link_source_range)) = open_embed.link
&& index == 0
{
pending_page.embeds.push(Embed {
title,
source_range: SourceRange {
start: open_embed.start,
end: line_source_range.end,
},
link_source_range,
});
}
} else {
errors.push(syntax_error(
"Unexpected closing fence.",
trim_source_range(source_contents, line_source_range),
None,
));
}
position = line_end;
}
}
continue;
}
if can_report_content_before_title && !line.trim().is_empty() {
errors.push(syntax_error(
"This content isn't in any page.",
trim_source_range(source_contents, line_source_range),
None,
));
can_report_content_before_title = false;
}
}
match character {
'\\' => {
let context = if open_link.is_some() {
Context::Link
} else {
Context::Text
};
position += '\\'.len_utf8();
if let Some(escaped) = source_contents[position..]
.chars()
.next()
.filter(|&escaped| is_escapable(escaped, context))
{
position += escaped.len_utf8();
} else {
let offset = position - '\\'.len_utf8();
errors.push(syntax_error(
invalid_escape_message(context),
SourceRange {
start: offset,
end: position,
},
Some(Fix::EscapeBackslash(offset)),
));
}
continue;
}
'\n' => {
if let Some(link) = open_link.take() {
let end = if source_contents[..position].ends_with('\r') {
position - '\r'.len_utf8()
} else {
position
};
close_unclosed_link(&mut pending_page.links, &mut errors, link, end);
}
if let Some(links_before) = opening_fence_links_before.take()
&& let Some(open_embed) = open_embeds.last_mut()
{
read_opening_fence(open_embed, &pending_page.links, links_before, &mut errors);
}
is_at_line_start = true;
}
'[' => {
if let Some(link) = &mut open_link {
errors.push(syntax_error(
"Unexpected opening link delimiter.",
SourceRange {
start: position,
end: position + '['.len_utf8(),
},
None,
));
link.has_nested_start = true;
} else {
open_link = Some(OpenLink {
start: position,
has_nested_start: false,
});
}
}
']' => {
if let Some(link) = open_link.take() {
let link_source_range = SourceRange {
start: link.start,
end: position + ']'.len_utf8(),
};
let target = source_contents[link.start + '['.len_utf8()..position].trim();
let malformed_link = Link::Malformed {
source_range: link_source_range,
is_closed: true,
};
pending_page.links.push(
match (!link.has_nested_start)
.then(|| unescape(target, Context::Link))
.flatten()
{
Some(target) => match parse_link(
&target,
source_path,
source_contents,
line_index,
link_source_range,
) {
Ok(link) => link,
Err(error) => {
errors.push(error);
malformed_link
}
},
None => malformed_link,
},
);
} else {
errors.push(syntax_error(
"Unexpected closing link delimiter.",
SourceRange {
start: position,
end: position + ']'.len_utf8(),
},
None,
));
}
}
'`' if open_link.is_none() => {
let length = backtick_run_length(&source_contents[position..]);
let can_close =
length < MIN_MULTILINE_CODE_LENGTH || !unclosable_lengths.contains(&length);
let closing_end = can_close
.then(|| closing_run_end(source_contents, position + length, length))
.flatten();
if let Some(end) = closing_end {
position = end;
} else {
if length >= MIN_MULTILINE_CODE_LENGTH {
unclosable_lengths.insert(length);
}
errors.push(syntax_error(
"Unclosed code.",
SourceRange {
start: position,
end: position + length,
},
None,
));
position += length;
}
continue;
}
_ => {}
}
position += character.len_utf8();
}
if let Some(link) = open_link.take() {
close_unclosed_link(
&mut pending_page.links,
&mut errors,
link,
source_contents.len(),
);
}
if let Some(links_before) = opening_fence_links_before
&& let Some(open_embed) = open_embeds.last_mut()
{
read_opening_fence(open_embed, &pending_page.links, links_before, &mut errors);
}
report_unclosed_embeds(&mut open_embeds, &mut errors);
finish_page(
&mut wiki,
pending_page,
source_contents.len(),
source_contents,
);
populate_traversal_order(&mut wiki);
errors.sort_by_key(|error| error.source_range().map(|source_range| source_range.start));
(wiki, errors)
}
fn title_line_text(line: &str) -> Option<&str> {
line.strip_prefix(TITLE_PREFIX)
}
fn fence_line(line: &str) -> Option<FenceLine> {
let indentation = line.len() - line.trim_start().len();
let length = line.len() - indentation - line[indentation..].trim_start_matches(':').len();
if length < MIN_FENCE_LENGTH {
return None;
}
let rest = &line[indentation + length..];
if rest.trim().is_empty() {
return Some(FenceLine::Closing { length });
}
if !rest.starts_with(char::is_whitespace) {
return None;
}
Some(FenceLine::Opening {
indentation,
length,
argument: (line.len() - rest.trim_start().len(), line.trim_end().len()),
})
}
fn finish_page(
wiki: &mut Wiki,
pending_page: PendingPage,
source_end: usize,
source_contents: &str,
) {
let PendingPage {
title,
source_start,
links,
embeds,
} = pending_page;
if let Some((title, title_source_range)) = title {
wiki.pages.insert(
title.clone(),
Page {
title,
links,
embeds,
traversal_index: None,
source_range: trim_source_range(
source_contents,
SourceRange {
start: source_start,
end: source_end,
},
),
title_source_range,
},
);
}
}
fn parse_title(
raw_title: &str,
source_path: Option<&Path>,
source_contents: &str,
line_index: &LineIndex,
line_source_range: SourceRange,
) -> Result<(PageTitle, 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,
},
);
match PageTitle::parse(raw_title) {
Ok(title) => Ok((
title,
SourceRange {
start: title_source_range.start,
end: line_source_range.end,
},
)),
Err(message) => Err(Error::new(
&message,
source_path,
Some((
source_contents,
line_index,
if title_source_range.start == title_source_range.end {
line_source_range
} else {
title_source_range
},
)),
None,
None,
)),
}
}
fn parse_link(
target: &str,
source_path: Option<&Path>,
source_contents: &str,
line_index: &LineIndex,
source_range: SourceRange,
) -> Result<Link, Error> {
let link_error = |message: &str| {
Error::new(
message,
source_path,
Some((source_contents, line_index, source_range)),
None,
None,
)
};
if target.starts_with(FILESYSTEM_LINK_PREFIX) {
Ok(Link::Filesystem {
target: FilesystemTarget::parse(target).map_err(|message| link_error(&message))?,
source_range,
})
} else if target.is_empty() {
Err(link_error("This link is missing a target."))
} else {
Ok(Link::Text {
title: PageTitle::parse(target).map_err(|message| link_error(&message))?,
source_range,
})
}
}
fn backtick_run_length(text: &str) -> usize {
text.len() - text.trim_start_matches('`').len()
}
fn closing_run_end(source: &str, start: usize, length: usize) -> Option<usize> {
let end = if length < MIN_MULTILINE_CODE_LENGTH {
source[start..]
.find('\n')
.map_or(source.len(), |index| start + index)
} else {
source.len()
};
let text = &source[start..end];
let mut index = 0;
while let Some(found) = text[index..].find('`') {
let run_start = index + found;
let run_length = backtick_run_length(&text[run_start..]);
if run_length == length {
return Some(start + run_start + run_length);
}
index = run_start + run_length;
}
None
}
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::{FenceLine, fence_line, parse, title_line_text};
use crate::{
assert_fails,
error::{Error, Fix, SourceRange},
escaping::Context,
line_index::LineIndex,
wiki::{Link, Wiki},
};
use std::{
fmt::Write,
path::{Path, PathBuf},
};
fn parse_fixture(source_contents: &str) -> (Wiki, Vec<Error>) {
parse(
Some(Path::new("test.mull")),
source_contents,
&LineIndex::new(source_contents),
)
}
fn parse_test(source_contents: &str) -> Result<Wiki, Vec<Error>> {
let (wiki, errors) = parse_fixture(source_contents);
if errors.is_empty() {
Ok(wiki)
} else {
Err(errors)
}
}
fn link_targets(links: &[Link]) -> Vec<String> {
links
.iter()
.filter_map(|link| match link {
Link::Text { title, .. } => Some(format!("text:{}", title.text(Context::Verbatim))),
Link::Filesystem { target, .. } => Some(format!(
"{}:{}",
if target.is_directory() { "dir" } else { "file" },
target.path().display(),
)),
Link::Malformed { .. } => None,
})
.collect()
}
#[test]
fn title_lines() {
assert_eq!(title_line_text("# "), Some(""));
assert_eq!(title_line_text("# Greeting "), Some("Greeting "));
assert_eq!(title_line_text("#"), None);
assert_eq!(title_line_text("#\tGreeting"), None);
assert_eq!(title_line_text("#Greeting"), None);
assert_eq!(title_line_text("## Greeting"), None);
assert_eq!(title_line_text(""), None);
}
#[test]
fn fence_lines() {
assert_eq!(
fence_line("::: [Page] "),
Some(FenceLine::Opening {
indentation: 0,
length: 3,
argument: (4, 10),
}),
);
assert_eq!(
fence_line("::::\t[Page]"),
Some(FenceLine::Opening {
indentation: 0,
length: 4,
argument: (5, 11),
}),
);
assert_eq!(
fence_line(" \t::: [Page]"),
Some(FenceLine::Opening {
indentation: 2,
length: 3,
argument: (6, 12),
}),
);
assert_eq!(fence_line(" :::"), Some(FenceLine::Closing { length: 3 }));
assert_eq!(fence_line(":::"), Some(FenceLine::Closing { length: 3 }));
assert_eq!(
fence_line("::::: \t"),
Some(FenceLine::Closing { length: 5 }),
);
assert_eq!(fence_line(":: [Page]"), None);
assert_eq!(fence_line(":::[Page]"), None);
assert_eq!(fence_line(" :::[Page]"), None);
}
#[test]
fn pages() {
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.pages.len(), 2);
assert_eq!(wiki.page("Home").title.text(Context::Verbatim), "Home");
assert_eq!(
link_targets(&wiki.page("Home").links),
vec!["text:Greeting"],
);
assert_eq!(
wiki.to_string(),
"# Home\n\n Check out the [Greeting].\n\n# Greeting\n\n Hello,\nworld!\n",
);
assert_eq!(wiki.page("Home").title_source_range.start, 7);
assert_eq!(wiki.page("Home").title_source_range.end, 13);
assert_eq!(wiki.page("Home").source_range.start, 3);
assert_eq!(wiki.page("Home").source_range.end, 41);
let Link::Text { source_range, .. } = &wiki.page("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 unicode_source_ranges() {
let source = "# Home\nSee [Grüße].\n# Grüße";
let wiki = parse_test(source).unwrap();
let Link::Text { source_range, .. } = &wiki.page("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.page("Grüße").title_source_range.start
..wiki.page("Grüße").title_source_range.end],
"Grüße",
);
}
#[test]
fn windows_line_endings() {
let wiki = parse_test("# Greeting\r\n\r\nHello, world!\r\n").unwrap();
assert_eq!(wiki.to_string(), "# Greeting\n\nHello, world!\n");
}
#[test]
fn empty_wiki() {
assert!(parse_test(" \n\t\n").unwrap().pages.is_empty());
}
#[test]
fn empty_content() {
assert_eq!(parse_test("# Empty").unwrap().to_string(), "# Empty\n");
}
#[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.to_string(),
"# Home\n\nFirst\n\nSee [Home]\nand [Home] later.\nLast\n",
);
}
#[test]
fn indented_title_markers() {
let source = "# Home\n\n # Not a title\n";
let wiki = parse_test(source).unwrap();
assert_eq!(wiki.pages.len(), 1);
assert_eq!(wiki.to_string(), source);
}
#[test]
fn non_title_hashes() {
let wiki = parse_test("# Home\n\n## Subtitle\n#not a title").unwrap();
assert_eq!(wiki.to_string(), "# Home\n\n## Subtitle\n#not a title\n");
}
#[test]
fn bare_title_markers() {
let wiki = parse_test("# Home\n\n#\n#\t\n# \tx").unwrap();
assert_eq!(wiki.pages.len(), 2);
assert_eq!(wiki.page("Home").links.len(), 0);
let formatted = wiki.to_string();
assert!(formatted.starts_with("# Home\n\n#\n#\n"));
assert_eq!(parse_test(&formatted).unwrap().pages.len(), 2);
}
#[test]
fn empty_title() {
let errors = parse_test("# \nContent").unwrap_err();
assert_eq!(errors.len(), 1);
assert!(
errors[0]
.to_string()
.contains("A page 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 page title can't start with `/`."),
);
}
}
#[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("Page `Home` already exists."),
);
assert!(errors[0].to_string().contains("3 \u{2502} # Home"));
}
#[test]
fn content_before_title() {
assert_fails!(
parse_test("Introduction\n# Home"),
"This content isn't in any page.",
);
}
#[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 page."),
);
}
#[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 page title can't be empty."),
);
}
#[test]
fn links() {
let wiki = parse_test(concat!(
"# Home\nSee [Greeting], [ About ], and [Greeting].",
"\n# About\n# Greeting",
))
.unwrap();
assert_eq!(
link_targets(&wiki.page("Home").links),
vec!["text:Greeting", "text:About", "text:Greeting"],
);
assert_eq!(
wiki.to_string(),
"# Home\n\nSee [Greeting], [About], and [Greeting].\n\n# About\n\n# Greeting\n",
);
}
#[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.page("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.to_string(),
"# Home\n\n[Home] [/a/b/c\\[1\\].txt] [/images/] [/images/raw/] [/] [/] [/]\n",
);
}
#[test]
fn empty_text_links() {
let (wiki, errors) = parse_fixture("# Home\nSee [] and [ ] and [Home].");
assert_eq!(errors.len(), 2);
assert!(
errors
.iter()
.all(|error| error.to_string().contains("This link is missing a target.")),
);
assert_eq!(link_targets(&wiki.page("Home").links), vec!["text:Home"]);
}
#[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 unclosed_links_end_at_line_breaks() {
let (wiki, errors) =
parse_fixture("# Home\nSee [Greeting\ncontinued]. [Home] and [Other]\n# Other");
assert_eq!(errors.len(), 2);
assert!(errors[0].to_string().contains("Unclosed link."));
assert!(errors[0].to_string().contains("2 \u{2502} See [Greeting"));
assert!(
errors[1]
.to_string()
.contains("Unexpected closing link delimiter."),
);
assert_eq!(
link_targets(&wiki.page("Home").links),
vec!["text:Home", "text:Other"],
);
}
#[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 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.page("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.page("Home").links),
vec!["text:Four", "text:Five\\", "text:A\\B"],
);
}
#[test]
fn invalid_escapes() {
let (wiki, errors) = parse_fixture("# Home\nC:\\Users \\\\ [Other] \\a\\\nend\\\n# Other");
assert_eq!(
errors.iter().map(Error::fix).collect::<Vec<_>>(),
vec![
Some(&Fix::EscapeBackslash(9)),
Some(&Fix::EscapeBackslash(27)),
Some(&Fix::EscapeBackslash(29)),
Some(&Fix::EscapeBackslash(34)),
],
);
assert!(errors.iter().all(|error| {
error
.to_string()
.contains("A backslash must be followed by")
}));
assert_eq!(link_targets(&wiki.page("Home").links), vec!["text:Other"]);
}
#[test]
fn links_with_invalid_escapes() {
let (wiki, errors) = parse_fixture("# Home\n[A\\B] [/c\\d.txt] [Other]\n# Other");
assert_eq!(
errors.iter().map(Error::fix).collect::<Vec<_>>(),
vec![
Some(&Fix::EscapeBackslash(9)),
Some(&Fix::EscapeBackslash(16)),
],
);
assert_eq!(link_targets(&wiki.page("Home").links), vec!["text:Other"]);
}
#[test]
fn hashes_cant_be_escaped() {
let (_wiki, errors) = parse_fixture("# Home\nC\\# and \\#1");
assert_eq!(
errors.iter().map(Error::fix).collect::<Vec<_>>(),
vec![
Some(&Fix::EscapeBackslash(8)),
Some(&Fix::EscapeBackslash(15)),
],
);
}
#[test]
fn escaped_backticks_in_links() {
let (wiki, errors) = parse_fixture("# Home\n\\` [a\\`b] \\` [Home]");
assert_eq!(errors.len(), 1);
assert!(
errors[0]
.to_string()
.contains("In a link, a backslash must be followed by"),
);
assert_eq!(errors[0].fix(), Some(&Fix::EscapeBackslash(12)));
assert_eq!(link_targets(&wiki.page("Home").links), vec!["text:Home"]);
}
#[test]
fn code_spans() {
let source = concat!(
"# Home\n\n",
"Run `[a] C:\\Users`, not ``x ` y``.\n\n",
"```sh\n# A comment\n[Not a link] C:\\Users \\[ ]\n```\n\n",
"See [Other].\n\n",
"# Other\n",
);
let wiki = parse_test(source).unwrap();
assert_eq!(wiki.pages.len(), 2);
assert_eq!(link_targets(&wiki.page("Home").links), vec!["text:Other"]);
assert_eq!(wiki.to_string(), source);
}
#[test]
fn code_closing_runs() {
let wiki = parse_test(concat!(
"# Home\n",
"a `[x]` [Home] ``d ` [x]`` [Home]\n",
"```sh\n# x\n[x] `` ```` \n```\n",
"[Home]\n",
))
.unwrap();
assert_eq!(wiki.pages.len(), 1);
assert_eq!(
link_targets(&wiki.page("Home").links),
vec!["text:Home", "text:Home", "text:Home"],
);
}
#[test]
fn plain_backticks() {
let source = "# The `ls` command\n\n[The `ls` command] and \\`.\n";
let wiki = parse_test(source).unwrap();
assert_eq!(
link_targets(&wiki.page("The `ls` command").links),
vec!["text:The `ls` command"],
);
assert_eq!(wiki.to_string(), source);
}
#[test]
fn backticks_outside_code() {
let wiki = parse_test("# A `title\n[A `title] \\`b\\` `[c]`").unwrap();
assert_eq!(
link_targets(&wiki.page("A `title").links),
vec!["text:A `title"],
);
}
#[test]
fn unclosed_code() {
let (wiki, errors) = parse_fixture("# Home\n```\n[Home] `a\nb`\n# Other");
assert_eq!(errors.len(), 3);
assert!(
errors
.iter()
.all(|error| error.to_string().contains("Unclosed code.")),
);
assert!(errors[0].to_string().contains("2 \u{2502} ```"));
assert_eq!(wiki.pages.len(), 2);
assert_eq!(link_targets(&wiki.page("Home").links), vec!["text:Home"]);
}
#[test]
fn unclosed_code_source_ranges() {
let errors = parse_test("# Home\na `b\nc` ``` d").unwrap_err();
assert_eq!(
errors
.iter()
.map(|error| error.source_range().unwrap())
.collect::<Vec<_>>(),
vec![
SourceRange { start: 9, end: 10 },
SourceRange { start: 13, end: 14 },
SourceRange { start: 15, end: 18 },
],
);
}
#[test]
fn multiple_page_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_page() {
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("Unclosed link"));
assert!(
errors[2]
.to_string()
.contains("Unexpected opening link delimiter"),
);
}
#[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 page"),
);
assert!(
errors[1]
.to_string()
.contains("A page title can't be empty"),
);
assert!(
errors[2]
.to_string()
.contains("Unexpected closing link delimiter"),
);
assert!(errors[3].to_string().contains("Unclosed link"));
}
#[test]
fn recovered_page_keeps_valid_links() {
let (wiki, errors) = parse_fixture(
"# Home\nStray] [Greeting] [Bad\nlink] [nested[link] [/../up] [/notes.txt] [unclosed",
);
assert_eq!(errors.len(), 6);
assert_eq!(
link_targets(&wiki.page("Home").links),
vec![
"text:Greeting".to_owned(),
format!("file:{}", PathBuf::from("notes.txt").display()),
],
);
}
#[test]
fn recovered_wiki_has_traversal_order() {
let (wiki, errors) = parse_fixture("# Home\nStray] [Greeting]\n# Greeting");
assert_eq!(errors.len(), 1);
assert_eq!(wiki.page("Greeting").traversal_index, Some(1));
}
#[test]
fn recovered_wiki_omits_untitled_regions() {
let (wiki, errors) = parse_fixture(
"Before]\n# Home\n[Home]\n# Home\n[Greeting] a]\n# \n[Greeting] b]\n# Greeting",
);
assert_eq!(errors.len(), 6);
assert!(
errors[0]
.to_string()
.contains("This content isn't in any page."),
);
assert!(errors[1].to_string().contains("1 \u{2502} Before]"));
assert!(
errors[2]
.to_string()
.contains("Page `Home` already exists."),
);
assert!(errors[3].to_string().contains("5 \u{2502} [Greeting] a]"));
assert!(
errors[4]
.to_string()
.contains("A page title can't be empty."),
);
assert!(errors[5].to_string().contains("7 \u{2502} [Greeting] b]"));
assert_eq!(wiki.pages.len(), 2);
assert_eq!(link_targets(&wiki.page("Home").links), vec!["text:Home"]);
assert_eq!(wiki.page("Greeting").traversal_index, None);
}
#[test]
fn malformed_links() {
let (wiki, _errors) = parse_fixture("# Home\na `[b]` [] [c\\`] [d [e] [f");
let links = &wiki.page("Home").links;
assert_eq!(
links
.iter()
.map(|link| match link {
Link::Malformed {
source_range,
is_closed,
} => (source_range.start, source_range.end, *is_closed),
Link::Text { .. } | Link::Filesystem { .. } => panic!("The link is malformed."),
})
.collect::<Vec<_>>(),
vec![
(15, 17, true),
(18, 23, true),
(24, 30, true),
(31, 33, false),
],
);
assert_eq!(links[0].closing_delimiter(), Some(16));
assert_eq!(links[3].closing_delimiter(), None);
}
#[test]
fn embeds() {
let source = concat!(
"# Home\n\n:::: [ Other ]\nSee [Last].\n::: [Last]\n:::\n::::\n\n",
"# Other\n# Last",
);
let wiki = parse_test(source).unwrap();
let home = wiki.page("Home");
assert_eq!(
link_targets(&home.links),
vec!["text:Other", "text:Last", "text:Last"],
);
assert_eq!(
home.embeds
.iter()
.map(|embed| {
(
embed.title.text(Context::Verbatim),
&source[embed.source_range.start..embed.source_range.end],
&source[embed.link_source_range.start..embed.link_source_range.end],
)
})
.collect::<Vec<_>>(),
vec![(
"Other".to_owned(),
":::: [ Other ]\nSee [Last].\n::: [Last]\n:::\n::::",
"[ Other ]",
)],
);
}
#[test]
fn indented_embeds() {
let source = "# Home\n- Item\n ::: [Other]\n\t:::\n# Other";
let wiki = parse_test(source).unwrap();
let home = wiki.page("Home");
assert_eq!(
home.embeds
.iter()
.map(|embed| &source[embed.source_range.start..embed.source_range.end])
.collect::<Vec<_>>(),
vec!["::: [Other]\n\t:::"],
);
}
#[test]
fn text_resembling_fences() {
let wiki = parse_test("# Home\n```\n::: [Other]\n```\n:: x\n:::x\n").unwrap();
assert!(wiki.page("Home").embeds.is_empty());
assert!(wiki.page("Home").links.is_empty());
}
#[test]
fn invalid_embed_links() {
assert_fails!(
parse_test("# Home\n::: Other\n:::"),
"An embed names the page to embed with a link, like `::: [Page]`.",
);
assert_fails!(
parse_test("# Home\n::: embed [Other]\n:::\n# Other"),
"An embed names the page to embed with a link, like `::: [Page]`.",
);
assert_eq!(
parse_test("# Home\n::: embed [Other]\n:::\n# Other")
.unwrap_err()
.len(),
1,
);
assert_fails!(
parse_test("# Home\n::: [Other] [Last]\n:::"),
"An embed names the page to embed with a link, like `::: [Page]`.",
);
assert_fails!(
parse_test("# Home\n::: [/notes.txt]\n:::"),
"Only pages can be embedded.",
);
}
#[test]
fn invalid_fences() {
assert_fails!(
parse_test("# Home\n::: [Other]\n::::\n:::"),
"Unexpected closing fence.",
);
assert_fails!(
parse_test("# Home\n::: [Other]\n# Other"),
"Unclosed embed.",
);
}
}