use crate::git::git_whatchanged; use crate::name::{Name, NameBuilder, Names}; use crate::page::{MarkdownPage, Page, PageError, UnprocessedPage, WikitextPage}; use crate::parser::{ParserError, WikitextParser}; use crate::srcdir::{PathFilter, SourceDir}; use crate::token::TokenPatterns; use crate::util::make_relative_link; use log::{debug, info, trace}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; #[derive(Debug, thiserror::Error)] pub enum SiteError { #[error("link to missing page {1} on {0}")] PageMissing(PathBuf, PathBuf), #[error(transparent)] Page(#[from] Box), #[error(transparent)] Git(#[from] crate::git::GitError), #[error(transparent)] Parser(#[from] Box), #[error(transparent)] WalkDir(#[from] crate::srcdir::SourceDirError), } impl From for SiteError { fn from(e: PageError) -> Self { Self::Page(Box::new(e)) } } impl From for SiteError { fn from(e: ParserError) -> Self { Self::Parser(Box::new(e)) } } pub struct Site { patterns: TokenPatterns, shortcuts: HashMap, name_builder: NameBuilder, unprocessed_pages: PageSet, markdown_pages: PageSet, files: Names, } impl Site { const EXCLUDE_SUBSTRINGS: &'static [&'static str] = &[".git"]; const EXCLUDE_ENDS: &'static [&'static str] = &[".git", "~", "#"]; pub fn new

(srcdir: P, destdir: P) -> Self where P: AsRef, { Self { name_builder: NameBuilder::new(srcdir.as_ref(), destdir.as_ref()), unprocessed_pages: PageSet::default(), markdown_pages: PageSet::default(), files: Names::default(), patterns: TokenPatterns::default(), shortcuts: HashMap::new(), } } pub fn scan(&mut self) -> Result<(), SiteError> { for name in self.all_files()? { trace!("scan: name={}", name); if name.is_wikitext_page() { trace!("scan: it's a page"); debug!("loading wikitext page {}", name.source_path().display()); let page = WikitextPage::read(&name)?; self.files.insert(name); self.add_wikitextpage(page)?; } else { trace!("scan: it's a non-page file"); let filename = name.source_path(); if filename.is_file() || filename.is_symlink() { self.add_other_file(name); } } } Ok(()) } fn add_wikitextpage(&mut self, page: WikitextPage) -> Result<(), SiteError> { info!("add wikitext page {}", page.meta().path().display()); trace!("parsing wikitext page {}", page.meta().path().display()); let mut parser = WikitextParser::new(page.wikitext(), &self.patterns); let page = UnprocessedPage::new(page.meta().clone(), &mut parser)?; page.prepare(self)?; let page = Page::new(page.meta().clone(), page); self.unprocessed_pages .insert(page.meta().path(), page.clone()); Ok(()) } fn add_other_file(&mut self, name: Name) { info!("add other file {}", name); self.files.insert(name); } pub fn process(&mut self) -> Result<(), SiteError> { trace!("processing queues"); loop { if !self.process_page()? { trace!("processing queues done"); break; } } Ok(()) } fn process_page(&mut self) -> Result { if let Some(page) = self.unprocessed_pages.remove_random_page() { debug!( "processing unprocessed page {}", page.meta().path().display() ); let page = page.markdown(self)?; self.markdown_pages.insert(page.meta().path(), page.clone()); Ok(true) } else { trace!("no pages to process"); Ok(false) } } pub fn markdown_pages(&self) -> impl Iterator { self.markdown_pages.pages() } pub fn files_only(&self) -> impl Iterator { self.files.files() } pub fn pages_and_files(&self) -> impl Iterator { self.files.iter() } pub fn is_page(&self, path: &Path) -> bool { self.unprocessed_pages.contains(path) || self.markdown_pages.contains(path) } pub fn page(&self, path: &Path) -> Option<&MarkdownPage> { self.markdown_pages.get_page(path) } fn all_files(&self) -> Result, SiteError> { let whatchanged = git_whatchanged(self.name_builder.srcdir())?; let mut srcdir = SourceDir::new(self.name_builder.srcdir()); srcdir.scan()?; let filter = PathFilter::new(Self::EXCLUDE_SUBSTRINGS, Self::EXCLUDE_ENDS); let mut names = vec![]; for path in srcdir.files().iter().filter(|x| filter.is_included(x)) { let relative = path.strip_prefix(self.name_builder.srcdir()).unwrap(); let mtime = whatchanged.get(relative).copied().unwrap_or(UNIX_EPOCH); if Self::is_markdown(path) { names.push(self.name_builder.page(path, mtime)); } else if path.is_file() { names.push(self.name_builder.file(path, mtime)); } else { trace!("not a file, ignoring: {}", path.display()); } } Ok(names) } fn is_markdown(path: &Path) -> bool { if let Some(ext) = path.extension() { ext == "mdwn" } else { false } } pub fn resolve>(&self, page: P, target: P) -> Result { let page = page.as_ref(); let target = target.as_ref(); let resolved = self.resolve_helper(page, target)?; assert!(page.is_absolute()); assert!(resolved.is_absolute()); let resolved = make_relative_link(page, &resolved); trace!( "resolve: page={}, target={} -> {}", page.display(), target.display(), resolved.display() ); Ok(resolved) } fn resolve_helper(&self, page: &Path, target: &Path) -> Result { trace!( "recursive_helper: page={} target={}", page.display(), target.display() ); // Is target absolute? if target.starts_with("/") { if self.is_page(target) { trace!("absolute target exists"); return Ok(target.into()); } else { trace!("absolute target does not exist"); return Err(SiteError::PageMissing(page.into(), target.into())); } } // Does a sub-page or file exist? let wanted = page.join(target); trace!("checking for subpage or file {}", wanted.display()); if self.is_page(&wanted) { trace!("subpage exists: {}", wanted.display()); return Ok(wanted); } else if self.file_exists(&wanted) { trace!("subpage file exists: {}", wanted.display()); return Ok(wanted); } // Does a sibling page or file exist? if let Some(parent) = page.parent() { let path = parent.join(target); trace!( "checking for sibling in {}: {}", parent.display(), path.display() ); if let Some(actual) = self.unprocessed_pages.get_path(&path) { trace!("sibling page exists: {}", actual.display()); return Ok(actual.into()); } if let Some(actual) = self.markdown_pages.get_path(&path) { trace!("sibling page exists: {}", actual.display()); return Ok(actual.into()); } // trace!("consider files: {:?}", self.files); if self.file_exists(&path) { trace!("sibling file exists: {}", path.display()); return Ok(path); } } // Does target exist relative to root? let wanted = Path::new("/").join(target); trace!("checking for absolute path {}", wanted.display()); if self.is_page(&wanted) { trace!("page at absolute path exists: {}", wanted.display()); return Ok(wanted); } else if self.file_exists(&wanted) { trace!("file at absolute path exists: {}", wanted.display()); return Ok(wanted); } // Target doesn't exist. trace!("target does not exist: {}", target.display()); Err(SiteError::PageMissing(page.into(), target.into())) } fn file_exists(&self, filename: &Path) -> bool { trace!("file_exists: looking for {}", filename.display()); for existing in self.files.files() { trace!("file_exists: consider {}", existing.page_path().display(),); if filename == existing.page_path() { trace!("file_exists: that's it!"); return true; } } trace!("file_exists: found nothing good!"); false } pub fn add_shortcut(&mut self, shortcut: Shortcut) { self.shortcuts.insert(shortcut.name().into(), shortcut); } pub fn shortcut(&self, name: &str) -> Option<&Shortcut> { self.shortcuts.get(name) } } #[derive(Debug)] struct PageSet { map: HashMap, } impl PageSet { fn insert(&mut self, path: &Path, page: T) { let key = Self::normalize(path); self.map.insert(key, (path.into(), page)); } fn contains(&self, path: &Path) -> bool { self.map.contains_key(&Self::normalize(path)) } fn get_page(&self, path: &Path) -> Option<&T> { self.map.get(&Self::normalize(path)).map(|(_, page)| page) } fn get_path(&self, wanted: &Path) -> Option<&Path> { self.map .get(&Self::normalize(wanted)) .map(|(path, _)| path.as_path()) } fn pages(&self) -> impl Iterator { self.map.values().map(|(_, page)| page) } fn remove_random_page(&mut self) -> Option { let mut keys: Vec = self.map.keys().take(1).map(|k| k.into()).collect(); if let Some(key) = keys.pop() { self.map.remove(&key).map(|(_, page)| page) } else { None } } fn normalize(path: &Path) -> String { path.to_str().expect("path is UTF8").to_lowercase() } } impl Default for PageSet { fn default() -> Self { Self { map: HashMap::default(), } } } #[derive(Debug, Clone, Eq, PartialEq)] pub struct Shortcut { name: String, desc: String, url: String, } impl Shortcut { pub fn new(name: &str, desc: &str, url: &str) -> Self { Self { name: name.into(), desc: desc.into(), url: url.into(), } } pub fn name(&self) -> &str { &self.name } pub fn desc(&self, arg: &str) -> String { self.desc.replace("%s", arg) } pub fn url(&self, arg: &str) -> String { self.url.replace("%s", arg) } } #[cfg(test)] mod test { use super::{Name, NameBuilder, Site, SiteError, WikitextPage}; use crate::page::MetaBuilder; use crate::pagespec::PageSpec; use std::{ path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; fn site() -> Site { Site::new("/src", "/dest") } fn builder() -> NameBuilder { NameBuilder::new(Path::new("/src"), Path::new("/dest")) } fn page(path: &str) -> WikitextPage { let name = builder().page(Path::new(path), UNIX_EPOCH); let mtime = SystemTime::now(); let meta = MetaBuilder::default().name(name).mtime(mtime).build(); WikitextPage::new(meta, "".into()) } fn file(path: &str) -> Name { builder().file(Path::new(path), UNIX_EPOCH) } #[test] fn has_no_pages_initially() { assert_eq!(site().markdown_pages().count(), 0); } #[test] fn absolute_link_resolves_to_link_relative_root_of_site() { let mut site = site(); site.add_wikitextpage(page("/src/yo/yoyo")).unwrap(); assert_eq!( site.resolve("/foo/bar", "/yo/yoyo").unwrap(), Path::new("../yo/yoyo") ); } #[test] fn link_to_missing_is_an_error() { let site = site(); match site.resolve("/src/foo/bar", "yo") { Err(SiteError::PageMissing(page, target)) => { assert_eq!(target, PathBuf::from("yo")); assert_eq!(page, PathBuf::from("/src/foo/bar")); } _ => panic!("unexpected success"), } } #[test] fn link_to_sibling_resolves_to_it() { let mut site = site(); site.add_wikitextpage(page("/src/foo/yo")).unwrap(); site.process().unwrap(); assert_eq!(site.resolve("/foo/bar", "yo").unwrap(), Path::new("yo")); } #[test] fn link_using_other_casing_is_resolved() { let mut site = site(); site.add_wikitextpage(page("/src/foo/yo")).unwrap(); site.process().unwrap(); assert_eq!(site.resolve("/foo/bar", "YO").unwrap(), Path::new("yo")); } #[test] fn link_to_sublpage_resolves_to_it() { let mut site = site(); site.add_wikitextpage(page("/src/foo/bar/yo")).unwrap(); site.process().unwrap(); assert_eq!(site.resolve("/foo/bar", "yo").unwrap(), Path::new("bar/yo")); } #[test] fn link_to_sublpage_resolves_to_it_and_not_sibling() { let mut site = site(); site.add_wikitextpage(page("/src/foo/bar/yo")).unwrap(); site.add_wikitextpage(page("/src/foo/yo")).unwrap(); site.process().unwrap(); assert_eq!(site.resolve("/foo/bar", "yo").unwrap(), Path::new("bar/yo")); } #[test] fn link_to_unrelated_subpage_is_an_error() { let mut site = site(); site.process().unwrap(); match site.resolve("/foo/bar", "yo/yoyo") { Err(SiteError::PageMissing(page, target)) => { assert_eq!(target, PathBuf::from("yo/yoyo")); assert_eq!(page, PathBuf::from("/foo/bar")); } _ => panic!("unexpected success"), } } #[test] fn link_to_subsubpage_resolves_to_it() { let mut site = site(); site.add_wikitextpage(page("/src/foo/bar/yo/yoyo")).unwrap(); site.process().unwrap(); assert_eq!( site.resolve("/foo/bar", "yo/yoyo").unwrap(), Path::new("bar/yo/yoyo") ); } #[test] fn link_to_sibling_file_resolves_to_it() { let mut site = site(); let name = builder().file(Path::new("/src/foo/bar.jpg"), UNIX_EPOCH); site.add_other_file(name); site.process().unwrap(); assert_eq!( site.resolve("/foo/bar", "bar.jpg").unwrap(), Path::new("bar.jpg") ); } #[test] fn link_relative_to_root_as_last_resort() { let mut site = site(); site.add_wikitextpage(page("/src/foo.mdwn")).unwrap(); site.process().unwrap(); assert_eq!( site.resolve("/yo/yoyo/yoyoyo", "foo").unwrap(), Path::new("../../foo") ); } #[test] fn pagespec_page_func_matches_page() { let mut site = Site::new("/src", "/dest"); site.add_wikitextpage(page("/src/foobar.mdwn")).unwrap(); let spec = PageSpec::new(Path::new("/"), "page(foo*)").unwrap(); assert!(spec.matches(&site, Path::new("/foobar"))); } #[test] fn pagespec_page_func_doesnt_match_file() { let mut site = Site::new("/src", "/dest"); site.add_other_file(file("/src/foobar.jpg")); let spec = PageSpec::new(Path::new("/"), "page(foo*)").unwrap(); assert!(!spec.matches(&site, Path::new("/foobar.jpg"))); } }