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 fn title_line_text(line: &str) -> Option<&str> {
line.strip_prefix(TITLE_PREFIX)
}
#[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) -> 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 links = page.links.iter().collect::<Vec<_>>();
links.sort_by_key(|link| link.source_range().start);
let mut content = String::new();
let mut copied_through = content_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..content_end]);
content
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
}
}
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);
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 traversal_index: Option<usize>, pub source_range: SourceRange, pub title_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, 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 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);
}
}