use crate::{
error::SourceRange,
escaping::{Context, escape},
format::CodeStr,
spelled_path::code_file_path,
};
use colored::ColoredString;
use serde::{Deserialize, Serialize, Serializer};
use std::{
collections::HashMap,
fmt,
path::{Component, Path, PathBuf},
};
pub const WIKI_EXTENSION: &str = "mull";
pub const FILE_ROOT_SUFFIX: &str = "_files";
pub const TITLE_PREFIX: &str = "# ";
pub const HOME_TITLE: &str = "Home";
pub const FILESYSTEM_LINK_PREFIX: &str = "/";
pub const DIRECTORY_LINK_SUFFIX: &str = "/";
pub const MIN_FENCE_LENGTH: usize = 3;
pub const EMBED_VERB: &str = "embed";
pub fn title_line_text(line: &str) -> Option<&str> {
line.strip_prefix(TITLE_PREFIX)
}
#[derive(Debug, Eq, PartialEq)]
pub enum FenceLine<'a> {
Opening {
length: usize,
verb: &'a str,
argument: (usize, usize),
},
Closing {
length: usize,
},
}
pub fn fence_line(line: &str) -> Option<FenceLine<'_>> {
let length = line.len() - line.trim_start_matches(':').len();
if length < MIN_FENCE_LENGTH {
return None;
}
let rest = &line[length..];
if rest.trim().is_empty() {
return Some(FenceLine::Closing { length });
}
if !rest.starts_with(char::is_whitespace) {
return None;
}
let verb_start = line.len() - rest.trim_start().len();
let verb_end = line[verb_start..]
.find(char::is_whitespace)
.map_or(line.len(), |index| verb_start + index);
let argument_start = line.len() - line[verb_end..].trim_start().len();
let argument_end = line.trim_end().len().max(argument_start);
Some(FenceLine::Opening {
length,
verb: &line[verb_start..verb_end],
argument: (argument_start, argument_end),
})
}
#[derive(Clone, Debug)]
pub struct Wiki {
pub pages: HashMap<PageTitle, Page>,
source: String,
}
impl Wiki {
pub fn new(source: &str) -> Self {
Self {
pages: HashMap::new(),
source: source.to_owned(),
}
}
pub fn links(&self) -> impl Iterator<Item = &Link> {
self.pages.values().flat_map(|page| &page.links)
}
#[cfg(test)]
pub fn page(&self, title: &str) -> &Page {
&self.pages[&PageTitle::parse(title).expect("The title should be valid.")]
}
pub fn rendering_order_key(
traversal_index: Option<usize>,
title: &PageTitle,
) -> (bool, Option<usize>, &PageTitle) {
(traversal_index.is_none(), traversal_index, title)
}
fn rendered_content(&self, page: &Page, embedding_pages: &mut Vec<PageTitle>) -> String {
let content_end = page.source_range.end;
let after_title = &self.source[page.title_source_range.end..content_end];
let Some(first_character) = after_title.find(|character: char| !character.is_whitespace())
else {
return String::new();
};
let content_start = page.title_source_range.end
+ after_title[..first_character]
.rfind('\n')
.map_or(0, |index| index + '\n'.len_utf8());
let mut content = String::new();
let mut copied_through = content_start;
embedding_pages.push(page.title.clone());
for embed in &page.embeds {
let Some(embedded_page) = self
.pages
.get(&embed.title)
.filter(|embedded_page| !embedding_pages.contains(&embedded_page.title))
else {
continue;
};
self.copy_links(page, copied_through, embed.source_range.start, &mut content);
let body = self.rendered_content(embedded_page, embedding_pages);
let longest_run = body
.lines()
.map(|line| line.len() - line.trim_start_matches(':').len())
.max()
.unwrap_or(0);
let fence = ":".repeat((longest_run + 1).max(MIN_FENCE_LENGTH));
for part in [
&fence,
" ",
EMBED_VERB,
" [",
&embed.title.text(Context::Link),
"]\n",
] {
content.push_str(part);
}
if !body.is_empty() {
content.push_str(&body);
content.push('\n');
}
content.push_str(&fence);
copied_through = embed.source_range.end;
}
embedding_pages.pop();
self.copy_links(page, copied_through, content_end, &mut content);
content
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
}
fn copy_links(&self, page: &Page, start: usize, end: usize, content: &mut String) {
let mut links = page
.links
.iter()
.filter(|link| start <= link.source_range().start && link.source_range().end <= end)
.collect::<Vec<_>>();
links.sort_by_key(|link| link.source_range().start);
let mut copied_through = start;
for link in links {
let source_range = link.source_range();
content.push_str(&self.source[copied_through..source_range.start]);
let target = match link {
Link::Text { title, .. } => title.text(Context::Link),
Link::Filesystem { target, .. } => target.text(Context::Link),
Link::Malformed { .. } => {
content.push_str(&self.source[source_range.start..source_range.end]);
copied_through = source_range.end;
continue;
}
};
content.push('[');
content.push_str(&target);
content.push(']');
copied_through = source_range.end;
}
content.push_str(&self.source[copied_through..end]);
}
}
impl fmt::Display for Wiki {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut pages = self.pages.iter().collect::<Vec<_>>();
pages.sort_by_key(|(title, page)| Self::rendering_order_key(page.traversal_index, title));
for (index, (_title, page)) in pages.into_iter().enumerate() {
if index > 0 {
writeln!(formatter)?;
}
let title = page.title.text(Context::Verbatim);
let content = self.rendered_content(page, &mut Vec::new());
if content.is_empty() {
writeln!(formatter, "{TITLE_PREFIX}{title}")?;
} else {
writeln!(formatter, "{TITLE_PREFIX}{title}\n\n{content}")?;
}
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct Page {
pub title: PageTitle,
pub links: Vec<Link>,
pub embeds: Vec<Embed>, pub traversal_index: Option<usize>, pub source_range: SourceRange, pub title_source_range: SourceRange, }
#[derive(Clone, Debug)]
pub struct Embed {
pub title: PageTitle,
pub source_range: SourceRange, pub link_source_range: SourceRange, }
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[serde(try_from = "String")]
pub struct PageTitle(String);
impl PageTitle {
pub fn parse(text: &str) -> Result<Self, String> {
if text.contains(['\r', '\n']) {
return Err("A page title can't contain a line break.".to_owned());
}
let title = text.trim();
if title.is_empty() {
Err("A page title can't be empty.".to_owned())
} else if title.starts_with(FILESYSTEM_LINK_PREFIX) {
Err(format!(
"A page title can't start with {}.",
FILESYSTEM_LINK_PREFIX.code_str(),
))
} else {
Ok(Self(title.to_owned()))
}
}
pub fn home() -> Self {
Self(HOME_TITLE.to_owned())
}
pub fn text(&self, context: Context) -> String {
escape(&self.0, context)
}
}
impl TryFrom<String> for PageTitle {
type Error = String;
fn try_from(text: String) -> Result<Self, Self::Error> {
Self::parse(&text)
}
}
impl Serialize for PageTitle {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl CodeStr for PageTitle {
fn code_str(&self) -> ColoredString {
self.0.code_str()
}
}
#[derive(Clone, Debug)]
pub enum Link {
Text {
title: PageTitle,
source_range: SourceRange,
},
Filesystem {
target: FilesystemTarget,
source_range: SourceRange,
},
Malformed {
source_range: SourceRange,
is_closed: bool,
},
}
impl Link {
pub fn source_range(&self) -> SourceRange {
match self {
Link::Text { source_range, .. }
| Link::Filesystem { source_range, .. }
| Link::Malformed { source_range, .. } => *source_range,
}
}
pub fn closing_delimiter(&self) -> Option<usize> {
let is_closed = match self {
Link::Text { .. } | Link::Filesystem { .. } => true,
Link::Malformed { is_closed, .. } => *is_closed,
};
is_closed.then(|| self.source_range().end - ']'.len_utf8())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FilesystemTarget {
path: PathBuf,
is_directory: bool,
}
impl FilesystemTarget {
pub fn parse(text: &str) -> Result<Self, String> {
let Some(path) = text.strip_prefix(FILESYSTEM_LINK_PREFIX) else {
return Err(format!(
"A filesystem link must start with {}.",
FILESYSTEM_LINK_PREFIX.code_str(),
));
};
Self::from_name(path, text.ends_with(DIRECTORY_LINK_SUFFIX))
}
pub fn from_name(path: &str, is_directory: bool) -> Result<Self, String> {
let path = Path::new(path.trim_start_matches('/'));
if path
.components()
.any(|component| matches!(component, Component::RootDir | Component::Prefix(_)))
{
return Err(format!("Path {} can't be absolute.", path.code_str()));
}
if path
.components()
.any(|component| component == Component::ParentDir)
{
return Err(format!(
"Path {} must not contain {}.",
path.code_str(),
"..".code_str(),
));
}
Ok(Self::new(
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(),
is_directory,
))
}
pub fn file_root() -> Self {
Self::new(PathBuf::new(), true)
}
fn new(path: PathBuf, is_directory: bool) -> Self {
Self {
is_directory: is_directory || path.as_os_str().is_empty(),
path,
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn is_directory(&self) -> bool {
self.is_directory
}
pub fn is_file_root(&self) -> bool {
self.path.as_os_str().is_empty()
}
pub fn ancestors(&self) -> impl Iterator<Item = Self> + '_ {
self.path
.ancestors()
.skip(1)
.map(|ancestor| Self::new(ancestor.to_owned(), true))
}
pub fn moved(&self, from: &FilesystemTarget, to: &FilesystemTarget) -> Option<Self> {
let suffix = self.path.strip_prefix(&from.path).ok()?;
Some(Self::new(to.path.join(suffix), self.is_directory))
}
pub fn text(&self, context: Context) -> String {
let components = self
.path
.components()
.map(|component| {
component
.as_os_str()
.to_str()
.expect("Link paths should come from UTF-8 text.")
})
.collect::<Vec<_>>();
let mut text = format!("{FILESYSTEM_LINK_PREFIX}{}", components.join("/"));
if self.is_directory && !components.is_empty() {
text.push_str(DIRECTORY_LINK_SUFFIX);
}
escape(&text, context)
}
}
impl CodeStr for FilesystemTarget {
fn code_str(&self) -> ColoredString {
code_file_path(&self.path)
}
}
#[cfg(test)]
mod tests {
use super::{FenceLine, FilesystemTarget, PageTitle, Wiki, fence_line, title_line_text};
use crate::{
escaping::{Context, unescape},
line_index::LineIndex,
parser::parse,
};
fn parse_wiki(source: &str) -> Wiki {
let (wiki, errors) = parse(None, source, &LineIndex::new(source));
assert!(errors.is_empty());
wiki
}
#[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 wiki_display() {
let wiki = parse_wiki(concat!(
"# Orphan\n",
"# Greeting\n\nHello, world!\n",
"# Home\n\nCheck out the [Greeting].",
));
assert_eq!(
wiki.to_string(),
concat!(
"# Home\n\nCheck out the [Greeting].\n\n",
"# Greeting\n\nHello, world!\n\n",
"# Orphan\n",
),
);
}
#[test]
fn empty_wiki_display() {
assert_eq!(Wiki::new("").to_string(), "");
}
#[test]
fn empty_page_wiki_display() {
assert_eq!(parse_wiki("# Greeting\n\n\n").to_string(), "# Greeting\n");
}
#[test]
fn content_display() {
let wiki = parse_wiki(concat!(
"# Home\r\n\r\n\r\n # Indented \r\n",
"See [ Other ] and [//images/./raw//]. \r\n\r\n",
"\tDone.\r\n\r\n",
"# Other",
));
assert_eq!(
wiki.to_string(),
concat!(
"# Home\n\n # Indented\nSee [Other] and [/images/raw/].\n\n\tDone.\n\n",
"# Other\n",
),
);
}
#[test]
fn fence_lines() {
assert_eq!(
fence_line("::: embed [Page] "),
Some(FenceLine::Opening {
length: 3,
verb: "embed",
argument: (10, 16),
}),
);
assert_eq!(
fence_line("::::\tembed"),
Some(FenceLine::Opening {
length: 4,
verb: "embed",
argument: (10, 10),
}),
);
assert_eq!(fence_line(":::"), Some(FenceLine::Closing { length: 3 }));
assert_eq!(
fence_line("::::: \t"),
Some(FenceLine::Closing { length: 5 }),
);
assert_eq!(fence_line(":: embed [Page]"), None);
assert_eq!(fence_line(":::embed [Page]"), None);
assert_eq!(fence_line(" ::: embed [Page]"), None);
}
#[test]
fn embeds_display() {
let wiki = parse_wiki(concat!(
"# Home\n\n::: embed [ Other ]\nstale\n:::\n\n",
"::: embed [Missing]\nkept\n:::\n\n",
"::: embed [Home]\nkept\n:::\n\n",
"# Other\n\nSee [ Last ].\n\n::: embed [Last]\n:::\n\n",
"# Last\n\nThe end.",
));
assert_eq!(
wiki.to_string(),
concat!(
"# Home\n\n",
":::: embed [Other]\nSee [Last].\n\n::: embed [Last]\nThe end.\n:::\n::::\n\n",
"::: embed [Missing]\nkept\n:::\n\n",
"::: embed [Home]\nkept\n:::\n\n",
"# Other\n\nSee [Last].\n\n::: embed [Last]\nThe end.\n:::\n\n",
"# Last\n\nThe end.\n",
),
);
}
#[test]
fn page_titles() {
assert_eq!(
PageTitle::parse(" Greeting ")
.unwrap()
.text(Context::Verbatim),
"Greeting",
);
assert_eq!(
PageTitle::parse(" ").unwrap_err(),
"A page title can't be empty.",
);
assert_eq!(
PageTitle::parse("Two\nlines").unwrap_err(),
"A page title can't contain a line break.",
);
assert!(
PageTitle::parse("/notes")
.unwrap_err()
.starts_with("A page title can't start with"),
);
}
#[test]
fn page_title_json() {
let title = PageTitle::parse("Greeting").unwrap();
let json = serde_json::to_value(&title).unwrap();
assert_eq!(json, "Greeting");
assert_eq!(serde_json::from_value::<PageTitle>(json).unwrap(), title);
assert!(serde_json::from_value::<PageTitle>("/notes".into()).is_err());
}
#[test]
fn filesystem_target_canonical_text() {
for (text, canonical, is_directory) in [
("/notes.txt", "/notes.txt", false),
("//images/./raw//", "/images/raw/", true),
("/a\\[1\\]/b.txt", "/a\\[1\\]/b.txt", false),
("/", "/", true),
("/.", "/", true),
] {
let target = FilesystemTarget::parse(&unescape(text, Context::Link).unwrap()).unwrap();
assert_eq!(target.text(Context::Link), canonical);
assert_eq!(target.is_directory(), is_directory);
assert_eq!(
FilesystemTarget::parse(
&unescape(&target.text(Context::Link), Context::Link).unwrap(),
)
.unwrap(),
target,
);
}
}
#[test]
fn filesystem_target_escaping_paths() {
assert!(FilesystemTarget::parse("/../notes.txt").is_err());
assert!(FilesystemTarget::parse("notes.txt").is_err());
}
#[test]
fn filesystem_target_from_name() {
let target = FilesystemTarget::from_name("photos/a[1]/", false).unwrap();
assert_eq!(target.text(Context::Link), "/photos/a\\[1\\]");
assert_eq!(
FilesystemTarget::from_name("photos", true)
.unwrap()
.text(Context::Link),
"/photos/",
);
}
#[test]
fn filesystem_target_moved() {
let directory = FilesystemTarget::parse("/photos/").unwrap();
let destination = FilesystemTarget::parse("/archive/photos/").unwrap();
let moved = |text| {
FilesystemTarget::parse(text)
.unwrap()
.moved(&directory, &destination)
.map(|target| target.text(Context::Link))
};
assert_eq!(moved("/photos/"), Some("/archive/photos/".to_owned()));
assert_eq!(
moved("/photos/cat.jpg"),
Some("/archive/photos/cat.jpg".to_owned()),
);
assert_eq!(moved("/photographs/cat.jpg"), None);
}
}