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;
#[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)
}
pub fn page_content(&self, page: &Page) -> String {
self.rendered_content(page, &mut Vec::new())
}
fn rendered_content(&self, page: &Page, embedding_pages: &mut Vec<PageTitle>) -> String {
embedding_pages.push(page.title.clone());
let content = self.canonical_content(page, |embed| {
self.pages
.get(&embed.title)
.filter(|embedded_page| !embedding_pages.contains(&embedded_page.title))
.map(|embedded_page| self.rendered_content(embedded_page, embedding_pages))
});
embedding_pages.pop();
content
}
pub fn own_content(&self, page: &Page) -> String {
self.canonical_content(page, |_embed| Some(String::new()))
}
fn canonical_content(
&self,
page: &Page,
mut body: impl FnMut(&Embed) -> Option<String>,
) -> 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;
for embed in &page.embeds {
let Some(body) = body(embed) else {
continue;
};
self.copy_links(page, copied_through, embed.source_range.start, &mut content);
let indentation = self.indentation(embed.source_range.start);
let longest_run = body
.lines()
.map(str::trim_start)
.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.title.text(Context::Link), "]\n"] {
content.push_str(part);
}
for line in body.lines() {
content.push_str(indentation);
content.push_str(line);
content.push('\n');
}
content.push_str(indentation);
content.push_str(&fence);
copied_through = embed.source_range.end;
}
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]);
}
pub fn body(&self, block: SourceRange) -> Option<(String, Vec<(usize, usize)>)> {
let block_source = &self.source[block.start..block.end];
let body_start = block_source.find('\n')? + '\n'.len_utf8();
let body_end = block_source.rfind('\n')? + '\n'.len_utf8();
let indentation = self.indentation(block.start);
let mut body = String::new();
let mut line_starts = Vec::new();
let mut line_source_start = block.start + body_start;
for line in block_source[body_start..body_end].split_inclusive('\n') {
let unindented_line = line
.strip_prefix(indentation)
.unwrap_or_else(|| line.trim_start_matches([' ', '\t']));
line_starts.push((
body.len(),
line_source_start + line.len() - unindented_line.len(),
));
body.push_str(unindented_line);
line_source_start += line.len();
}
Some((body, line_starts))
}
pub fn indentation(&self, block_start: usize) -> &str {
&self.source[self.source[..block_start]
.rfind('\n')
.map_or(0, |index| index + '\n'.len_utf8())..block_start]
}
}
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.page_content(page);
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 script_blocks: Vec<ScriptBlock>, 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)]
pub struct ScriptBlock {
pub name: String,
pub source_range: SourceRange, pub name_source_range: SourceRange, pub arguments_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::{FilesystemTarget, PageTitle, Wiki};
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 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 embeds_display() {
let wiki = parse_wiki(concat!(
"# Home\n\n::: [ Other ]\nstale\n:::\n\n",
"::: [Missing]\nkept\n:::\n\n",
"::: [Home]\nkept\n:::\n\n",
"# Other\n\nSee [ Last ].\n\n::: [Last]\n:::\n\n",
"# Last\n\nThe end.",
));
assert_eq!(
wiki.to_string(),
concat!(
"# Home\n\n",
":::: [Other]\nSee [Last].\n\n::: [Last]\nThe end.\n:::\n::::\n\n",
"::: [Missing]\nkept\n:::\n\n",
"::: [Home]\nkept\n:::\n\n",
"# Other\n\nSee [Last].\n\n::: [Last]\nThe end.\n:::\n\n",
"# Last\n\nThe end.\n",
),
);
}
#[test]
fn indented_embeds_display() {
let wiki = parse_wiki(concat!(
"# Home\n\n- Item\n ::: [Other]\nstale\n:::\n\n",
"# Other\n\nSee:\n\n\t::: [Last]\n\t:::\n\n",
"# Last\n\nThe end.",
));
assert_eq!(
wiki.to_string(),
concat!(
"# Home\n\n- Item\n",
" :::: [Other]\n See:\n\n \t::: [Last]\n \tThe end.\n \t:::\n",
" ::::\n\n",
"# Other\n\nSee:\n\n\t::: [Last]\n\tThe end.\n\t:::\n\n",
"# Last\n\nThe end.\n",
),
);
}
#[test]
fn own_content() {
let wiki = parse_wiki(concat!(
"# Home\n\nSee [ Other ]. \r\n\n",
" :::: [Other]\n Stale.\n ::: [Last]\n :::\n ::::\n",
"# Other\n# Last",
));
assert_eq!(
wiki.own_content(&wiki.pages[&PageTitle::parse("Home").unwrap()]),
"See [Other].\n\n ::: [Other]\n :::",
);
}
#[test]
fn bodies() {
let source = "# Home\n ::: [Other]\n First\n\n Second\n :::\n::: [Other] :::\n# Other";
let wiki = parse_wiki(source);
let embeds = &wiki.pages[&PageTitle::parse("Home").unwrap()].embeds;
assert_eq!(
wiki.body(embeds[0].source_range),
Some((
"First\n\nSecond\n".to_owned(),
vec![(0, 23), (6, 29), (7, 31)],
)),
);
assert_eq!(&source[23..28], "First");
assert_eq!(&source[31..37], "Second");
assert_eq!(wiki.body(embeds[1].source_range), None);
}
#[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);
}
}