use crate::error::SourceRange;
use std::{collections::HashMap, fmt, path::PathBuf};
pub const WIKI_EXTENSION: &str = "mull";
pub const TITLE_MARKER: &str = "#";
pub const TITLE_PREFIX: &str = "# ";
pub const FILE_LINK_PREFIX: &str = "file:";
pub const DIRECTORY_LINK_PREFIX: &str = "dir:";
pub const HOME_TITLE: &str = "Home";
#[derive(Clone, Debug)]
pub enum Link {
Text {
title: String,
source_range: SourceRange,
},
File {
path: PathBuf,
source_range: SourceRange,
},
Directory {
path: PathBuf,
source_range: SourceRange,
},
}
#[derive(Clone, Debug)]
pub struct TextNode {
pub title: String, pub content: String, pub links: Vec<Link>,
pub depth: Option<usize>, pub source_range: SourceRange, pub title_source_range: SourceRange, }
#[derive(Clone, Debug, Default)]
pub struct Wiki {
pub text_nodes: HashMap<String, TextNode>,
}
impl TextNode {
pub fn to_markdown<F>(&self, mut text_link_url: F) -> String
where
F: FnMut(&str) -> Option<String>,
{
let mut content = String::new();
let mut copied_through = 0;
let mut link_start = None;
let mut links = self.links.iter();
let mut previous_was_backslash = false;
for (index, character) in self.content.char_indices() {
let is_escaped_delimiter = previous_was_backslash && matches!(character, '[' | ']');
previous_was_backslash = character == '\\';
if is_escaped_delimiter {
continue;
}
match character {
'[' => link_start = Some(index),
']' => {
let Some(start) = link_start.take() else {
continue;
};
content.push_str(&render_markdown_prose(&self.content[copied_through..start]));
let target = &self.content[start + '['.len_utf8()..index];
content.push_str(&match links.next() {
Some(Link::Text { title, .. }) => {
let url = text_link_url(title);
render_markdown_text_link(target, url.as_deref())
}
Some(Link::File { .. } | Link::Directory { .. }) => {
render_markdown_filesystem_link(target)
}
None => render_markdown_text_link(target, None),
});
copied_through = index + character.len_utf8();
}
_ => {}
}
}
content.push_str(&render_markdown_prose(&self.content[copied_through..]));
if content.is_empty() {
format!("{TITLE_PREFIX}{}", self.title)
} else {
format!("{TITLE_PREFIX}{}\n\n{content}", self.title)
}
}
}
fn render_markdown_prose(source: &str) -> String {
source.replace("\\[", "[").replace("\\]", "]")
}
fn render_markdown_text_link(target: &str, url: Option<&str>) -> String {
let target = target.replace("\\[", "[").replace("\\]", "]");
let mut label = String::new();
for character in target.chars() {
label.push_str(match character {
'&' => "&",
'<' => "<",
'>' => ">",
'\\' => "\",
'`' => "`",
'*' => "*",
'_' => "_",
'[' => "[",
']' => "]",
'~' => "~",
_ => {
label.push(character);
continue;
}
});
}
let label = format!("[{label}]");
match url {
Some(url) => format!("[{label}]({url})"),
None => label,
}
}
fn render_markdown_filesystem_link(target: &str) -> String {
let target = target.replace("\\[", "[").replace("\\]", "]");
let source = format!("[{target}]");
let longest_run = source
.split(|character| character != '`')
.map(str::len)
.max()
.unwrap_or(0);
let fence = "`".repeat(longest_run + 1);
format!("{fence}{source}{fence}")
}
impl fmt::Display for TextNode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.content.is_empty() {
writeln!(formatter, "{TITLE_PREFIX}{}", self.title)
} else {
writeln!(
formatter,
"{TITLE_PREFIX}{}\n\n{}",
self.title,
self.content,
)
}
}
}
impl fmt::Display for Wiki {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut nodes = self.text_nodes.iter().collect::<Vec<_>>();
nodes.sort_by_key(|(title, node)| (node.depth.is_none(), node.depth, *title));
for (index, (_title, node)) in nodes.into_iter().enumerate() {
if index > 0 {
writeln!(formatter)?;
}
write!(formatter, "{node}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{DIRECTORY_LINK_PREFIX, FILE_LINK_PREFIX, Link, TextNode, Wiki};
use crate::error::SourceRange;
use std::collections::HashMap;
const SOURCE_RANGE: SourceRange = SourceRange { start: 0, end: 0 };
#[test]
fn node_display() {
let node = TextNode {
title: "Greeting".to_owned(),
content: "Hello, world!".to_owned(),
links: Vec::new(),
depth: None,
source_range: SOURCE_RANGE,
title_source_range: SOURCE_RANGE,
};
assert_eq!(node.to_string(), "# Greeting\n\nHello, world!\n");
}
#[test]
fn empty_node_display() {
let node = TextNode {
title: "Greeting".to_owned(),
content: String::new(),
links: Vec::new(),
depth: None,
source_range: SOURCE_RANGE,
title_source_range: SOURCE_RANGE,
};
assert_eq!(node.to_string(), "# Greeting\n");
}
#[test]
fn node_markdown() {
let node = TextNode {
title: "Greeting".to_owned(),
content: "Literal \\[brackets\\] and [Home].".to_owned(),
links: vec![Link::Text {
title: "Home".to_owned(),
source_range: SOURCE_RANGE,
}],
depth: None,
source_range: SOURCE_RANGE,
title_source_range: SOURCE_RANGE,
};
assert_eq!(
node.to_markdown(|title| {
(title == "Home").then(|| "command:mull.revealRange?destination".to_owned())
}),
concat!(
"# Greeting\n\nLiteral [brackets] and ",
"[[Home]](command:mull.revealRange?destination).",
),
);
}
#[test]
fn unresolved_node_markdown_link() {
let node = TextNode {
title: "Greeting".to_owned(),
content: "See [Missing].".to_owned(),
links: vec![Link::Text {
title: "Missing".to_owned(),
source_range: SOURCE_RANGE,
}],
depth: None,
source_range: SOURCE_RANGE,
title_source_range: SOURCE_RANGE,
};
assert_eq!(
node.to_markdown(|_title| None),
"# Greeting\n\nSee [Missing].",
);
}
#[test]
fn filesystem_link_markdown() {
let node = TextNode {
title: "Files".to_owned(),
content: format!("[{FILE_LINK_PREFIX}notes.txt] and [{DIRECTORY_LINK_PREFIX}odd`name]"),
links: vec![
Link::File {
path: "notes.txt".into(),
source_range: SOURCE_RANGE,
},
Link::Directory {
path: "odd`name".into(),
source_range: SOURCE_RANGE,
},
],
depth: None,
source_range: SOURCE_RANGE,
title_source_range: SOURCE_RANGE,
};
assert_eq!(
node.to_markdown(|_title| None),
format!(
"# Files\n\n`[{FILE_LINK_PREFIX}notes.txt]` and \
``[{DIRECTORY_LINK_PREFIX}odd`name]``",
),
);
}
#[test]
fn empty_node_wiki_display() {
let wiki = Wiki {
text_nodes: HashMap::from([(
"Greeting".to_owned(),
TextNode {
title: "Greeting".to_owned(),
content: String::new(),
links: Vec::new(),
depth: None,
source_range: SOURCE_RANGE,
title_source_range: SOURCE_RANGE,
},
)]),
};
assert_eq!(wiki.to_string(), "# Greeting\n");
}
#[test]
fn wiki_display() {
let wiki = Wiki {
text_nodes: HashMap::from([
(
"Greeting".to_owned(),
TextNode {
title: "Greeting".to_owned(),
content: "Hello, world!".to_owned(),
links: Vec::new(),
depth: Some(1),
source_range: SOURCE_RANGE,
title_source_range: SOURCE_RANGE,
},
),
(
"Home".to_owned(),
TextNode {
title: "Home".to_owned(),
content: "Check out the [Greeting].".to_owned(),
links: Vec::new(),
depth: Some(0),
source_range: SOURCE_RANGE,
title_source_range: SOURCE_RANGE,
},
),
(
"Orphan".to_owned(),
TextNode {
title: "Orphan".to_owned(),
content: String::new(),
links: Vec::new(),
depth: None,
source_range: SOURCE_RANGE,
title_source_range: SOURCE_RANGE,
},
),
]),
};
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::default().to_string(), "");
}
}