use crate::{
document::{DIRECTORY_LINK_PREFIX, Document, FILE_LINK_PREFIX, Link, TITLE_PREFIX, TextNode},
format::CodeStr,
scoring::populate_depths,
};
use std::collections::HashSet;
use std::path::PathBuf;
fn insert_node(
document: &mut Document,
title: String,
title_line: usize,
content_lines: &[&str],
) -> Result<(), String> {
let original_content = content_lines.join("\n").trim().to_owned();
let mut content = String::new();
let mut copied_through = 0;
let mut links = HashSet::<Link>::new();
let mut errors = Vec::<String>::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;
}
if character == '\n' && link_start.is_some() && !link_has_line_break {
errors.push(format!(
"Link in node {} contains a line break.",
title.code_str(),
));
link_has_line_break = true;
}
match character {
'[' => {
if link_start.is_some() {
errors.push(format!(
"Unexpected opening link delimiter in node {}.",
title.code_str(),
));
} else {
link_start = Some(index + character.len_utf8());
link_has_line_break = false;
}
}
']' => {
if let Some(start) = link_start.take() {
let original_link = &original_content[start..index];
let trimmed_link = original_link.trim();
let link = trimmed_link.replace("\\[", "[").replace("\\]", "]");
if let Some(path) = link.strip_prefix(FILE_LINK_PREFIX) {
links.insert(Link::File(PathBuf::from(path)));
} else if let Some(path) = link.strip_prefix(DIRECTORY_LINK_PREFIX) {
links.insert(Link::Directory(PathBuf::from(path)));
} else {
links.insert(Link::Text(link));
}
content.push_str(&original_content[copied_through..start]);
content.push_str(trimmed_link);
content.push(']');
copied_through = index + character.len_utf8();
} else {
errors.push(format!(
"Unexpected closing link delimiter in node {}.",
title.code_str(),
));
}
}
_ => {}
}
}
if link_start.is_some() {
errors.push(format!("Unclosed link in node {}.", title.code_str()));
}
content.push_str(&original_content[copied_through..]);
if document.text_nodes.contains_key(&title) {
errors.push(format!(
"Duplicate title {} on line {title_line}.",
title.code_str(),
));
}
if errors.is_empty() {
document.text_nodes.insert(
title.clone(),
TextNode {
title,
content,
links,
depth: None,
},
);
Ok(())
} else {
Err(errors.join("\n"))
}
}
pub fn parse(contents: &str) -> Result<Document, String> {
let mut document = Document::default();
let mut errors = Vec::<String>::new();
let mut current_title = None::<(String, usize)>;
let mut content_lines = Vec::<&str>::new();
let mut reported_content_before_title = false;
for (line_index, line) in contents.lines().enumerate() {
let line_number = line_index + 1;
if let Some(title) = line.strip_prefix(TITLE_PREFIX) {
if let Some((title, title_line)) = current_title.take() {
if let Err(error) = insert_node(&mut document, title, title_line, &content_lines) {
errors.push(error);
}
content_lines.clear();
}
let title = title.trim();
if title.is_empty() {
errors.push(format!("Title on line {line_number} is empty."));
} else {
current_title = Some((title.to_owned(), line_number));
}
} else if current_title.is_some() {
content_lines.push(line);
} else if !reported_content_before_title && !line.trim().is_empty() {
errors.push(format!(
"Content appears before the first title on line {line_number}.",
));
reported_content_before_title = true;
}
}
if let Some((title, title_line)) = current_title
&& let Err(error) = insert_node(&mut document, title, title_line, &content_lines)
{
errors.push(error);
}
if errors.is_empty() {
populate_depths(&mut document);
Ok(document)
} else {
Err(errors.join("\n"))
}
}
#[cfg(test)]
mod tests {
use super::parse;
use crate::document::Link;
use std::collections::HashSet;
use std::path::PathBuf;
#[test]
fn nodes() {
let document = parse(
" \n# Home \n\n Check out the [Greeting]. \n\n# Greeting\n Hello,\nworld! \n",
)
.unwrap();
assert_eq!(document.text_nodes.len(), 2);
assert_eq!(document.text_nodes["Home"].title, "Home");
assert_eq!(
document.text_nodes["Home"].content,
"Check out the [Greeting].",
);
assert_eq!(
document.text_nodes["Home"].links,
HashSet::from([Link::Text("Greeting".to_owned())]),
);
assert_eq!(document.text_nodes["Greeting"].content, "Hello,\nworld!");
}
#[test]
fn links() {
let document = parse(concat!(
"# Home\nSee [Greeting], [ About ], and [Greeting].",
"\n# About\n# Greeting",
))
.unwrap();
assert_eq!(
document.text_nodes["Home"].links,
HashSet::from([
Link::Text("About".to_owned()),
Link::Text("Greeting".to_owned()),
]),
);
assert_eq!(
document.text_nodes["Home"].content,
"See [Greeting], [About], and [Greeting].",
);
}
#[test]
fn escaped_link_delimiters() {
let document = parse(
r"# Home
See \[Ignored\], [One\]Two], [\[Three], [Four], and \[also ignored\].
# Four
# One]Two
# [Three",
)
.unwrap();
assert_eq!(
document.text_nodes["Home"].links,
HashSet::from([
Link::Text("Four".to_owned()),
Link::Text("One]Two".to_owned()),
Link::Text("[Three".to_owned()),
]),
);
}
#[test]
fn filesystem_links() {
let document = parse(concat!(
"# Home\nSee [",
"file:notes.txt] and [",
"dir:images].",
))
.unwrap();
assert_eq!(
document.text_nodes["Home"].links,
HashSet::from([
Link::File(PathBuf::from("notes.txt")),
Link::Directory(PathBuf::from("images")),
]),
);
}
#[test]
fn unclosed_link() {
assert_eq!(
parse("# Home\nSee [Greeting.").unwrap_err(),
"Unclosed link in node `Home`.",
);
}
#[test]
fn link_with_line_break() {
assert_eq!(
parse("# Home\nSee [Greeting\ncontinued].").unwrap_err(),
"Link in node `Home` contains a line break.",
);
}
#[test]
fn unexpected_opening_delimiter() {
assert_eq!(
parse("# Home\nSee [nested[Greeting].").unwrap_err(),
"Unexpected opening link delimiter in node `Home`.",
);
}
#[test]
fn unexpected_closing_delimiter() {
assert_eq!(
parse("# Home\nSee Greeting].").unwrap_err(),
"Unexpected closing link delimiter in node `Home`.",
);
}
#[test]
fn non_title_hashes() {
let document = parse("# Home\n\n## Subtitle\n#not a title").unwrap();
assert_eq!(
document.text_nodes["Home"].content,
"## Subtitle\n#not a title",
);
}
#[test]
fn empty_document() {
assert!(parse(" \n\t\n").unwrap().text_nodes.is_empty());
}
#[test]
fn empty_content() {
assert!(
parse("# Empty").unwrap().text_nodes["Empty"]
.content
.is_empty(),
);
}
#[test]
fn windows_line_endings() {
let document = parse("# Greeting\r\n\r\nHello, world!\r\n").unwrap();
assert_eq!(document.text_nodes["Greeting"].content, "Hello, world!");
}
#[test]
fn content_before_title() {
assert_eq!(
parse("Introduction\n# Home").unwrap_err(),
"Content appears before the first title on line 1.",
);
}
#[test]
fn empty_title() {
assert_eq!(
parse("# \nContent").unwrap_err(),
concat!(
"Title on line 1 is empty.\n",
"Content appears before the first title on line 2.",
),
);
}
#[test]
fn repeated_content_before_title() {
assert_eq!(
parse("First\nSecond\n# Home").unwrap_err(),
"Content appears before the first title on line 1.",
);
}
#[test]
fn duplicate_title() {
assert_eq!(
parse("# Home\nFirst\n# Home\nSecond").unwrap_err(),
"Duplicate title `Home` on line 3.",
);
}
#[test]
fn multiple_node_errors() {
assert_eq!(
parse("# First\nUnexpected].\n# Second\nUnclosed [link.").unwrap_err(),
concat!(
"Unexpected closing link delimiter in node `First`.\n",
"Unclosed link in node `Second`.",
),
);
}
#[test]
fn multiple_errors_in_node() {
assert_eq!(
parse("# Home\nUnexpected] and [nested[link.").unwrap_err(),
concat!(
"Unexpected closing link delimiter in node `Home`.\n",
"Unexpected opening link delimiter in node `Home`.\n",
"Unclosed link in node `Home`.",
),
);
}
#[test]
fn multiple_error_types() {
assert_eq!(
parse(concat!(
"Introduction\n",
"# \n",
"Content\n",
"# First\n",
"Unexpected].\n",
"# Second\n",
"Unclosed [link.",
))
.unwrap_err(),
concat!(
"Content appears before the first title on line 1.\n",
"Title on line 2 is empty.\n",
"Unexpected closing link delimiter in node `First`.\n",
"Unclosed link in node `Second`.",
),
);
}
}