use crate::{error::SourceRange, escaping::escape, format::CodeStr, spelled_path::code_file_path};
use colored::ColoredString;
use std::{
collections::HashMap,
fmt,
path::{Component, Path, PathBuf},
};
pub const WIKI_EXTENSION: &str = "mull";
pub const FILE_ROOT_SUFFIX: &str = "_files";
const TITLE_MARKER: &str = "#";
pub const TITLE_PREFIX: &str = "# ";
pub const FILESYSTEM_LINK_PREFIX: &str = "/";
pub fn title_line_text(line: &str) -> Option<&str> {
if line == TITLE_MARKER {
Some("")
} else {
line.strip_prefix(TITLE_PREFIX)
}
}
pub const DIRECTORY_LINK_SUFFIX: &str = "/";
pub const HOME_TITLE: &str = "Home";
#[derive(Clone, Debug)]
pub struct Wiki {
pub pages: HashMap<String, 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)
}
pub fn rendering_order_key(
traversal_index: Option<usize>,
title: &str,
) -> (bool, Option<usize>, &str) {
(traversal_index.is_none(), traversal_index, title)
}
}
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 content = self.rendered_content(page);
if content.is_empty() {
writeln!(formatter, "{TITLE_PREFIX}{}", page.title)?;
} else {
writeln!(formatter, "{TITLE_PREFIX}{}\n\n{content}", page.title)?;
}
}
Ok(())
}
}
impl Wiki {
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]);
content.push('[');
content.push_str(&match link {
Link::Text { title, .. } => escape(title),
Link::Filesystem { target, .. } => target.text(),
});
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")
}
}
#[derive(Clone, Debug)]
pub struct Page {
pub title: String, pub links: Vec<Link>,
pub traversal_index: Option<usize>, pub source_range: SourceRange, pub title_source_range: SourceRange, }
#[derive(Clone, Debug)]
pub enum Link {
Text {
title: String,
source_range: SourceRange,
},
Filesystem {
target: FilesystemTarget,
source_range: SourceRange,
},
}
impl Link {
pub fn source_range(&self) -> SourceRange {
match self {
Link::Text { source_range, .. } | Link::Filesystem { source_range, .. } => {
*source_range
}
}
}
}
#[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)
}
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) -> 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)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn is_file_root(&self) -> bool {
self.path.as_os_str().is_empty()
}
pub fn is_directory(&self) -> bool {
self.is_directory
}
fn new(path: PathBuf, is_directory: bool) -> Self {
Self {
is_directory: is_directory || path.as_os_str().is_empty(),
path,
}
}
}
impl CodeStr for FilesystemTarget {
fn code_str(&self) -> ColoredString {
code_file_path(&self.path)
}
}
#[cfg(test)]
mod tests {
use super::{FilesystemTarget, Wiki, title_line_text};
use crate::{escaping::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("#Greeting"), None);
assert_eq!(title_line_text("## Greeting"), None);
assert_eq!(title_line_text(""), None);
}
#[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).unwrap()).unwrap();
assert_eq!(target.text(), canonical);
assert_eq!(target.is_directory(), is_directory);
assert_eq!(
FilesystemTarget::parse(&unescape(&target.text()).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(), "/photos/a\\[1\\]");
assert_eq!(
FilesystemTarget::from_name("photos", true).unwrap().text(),
"/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())
};
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);
}
#[test]
fn empty_page_wiki_display() {
assert_eq!(parse_wiki("# Greeting\n\n\n").to_string(), "# Greeting\n");
}
#[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 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 empty_wiki_display() {
assert_eq!(Wiki::new("").to_string(), "");
}
}