use super::{route_depth, DocPage, DocsSite};
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::PathBuf;
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut word = self.0;
word = (word ^ (word >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
word = (word ^ (word >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
word ^ (word >> 31)
}
fn below(&mut self, bound: usize) -> usize {
usize::try_from(self.next_u64() % bound as u64).unwrap()
}
fn pick<'a, T>(&mut self, choices: &'a [T]) -> &'a T {
&choices[self.below(choices.len())]
}
}
const SITE_ROOT: &str = "/site";
const CANDIDATE_FILES: &[&str] = &[
"README.md",
"docs/index.md",
"docs/reference.md",
"docs/guide/index.md",
"docs/guide/install.md",
"docs/guide/deep/nested.md",
"docs/notes/faq.md",
"docs/notes/v1:draft.md",
];
const ABSENT_FILES: &[&str] = &[
"docs/missing.md",
"docs/guide/gone.md",
"docs/notes/typo.md",
"ghost.md",
];
const NON_DOCUMENT_DESTINATIONS: &[&str] = &[
"assets/diagram.png",
"../scripts/build.sh",
"install.md.txt",
"../CHANGELOG",
"guide/",
"",
];
const ANCHORS: &[&str] = &["usage", "install", "top", "why-this"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Writing {
Inline,
Image,
Reference,
Collapsed,
Shortcut,
Autolink,
}
struct Plant {
destination: String,
writing: Writing,
}
#[derive(Debug, PartialEq, Eq)]
enum Verdict {
Broken(String),
PublishedTarget,
OutOfScope(&'static str),
}
struct Page {
file: &'static str,
plants: Vec<Plant>,
}
struct Corpus {
site: DocsSite,
published: BTreeSet<String>,
pages: Vec<Page>,
}
impl Corpus {
fn page(&self, source_path: &str) -> Option<&Page> {
self.pages.iter().find(|page| page.file == source_path)
}
fn verdicts(&self) -> impl Iterator<Item = (&Page, &Plant, Verdict)> {
self.pages.iter().flat_map(move |page| {
page.plants
.iter()
.map(move |plant| (page, plant, verdict(plant, page.file, &self.published)))
})
}
}
fn is_local_markdown_destination(destination: &str) -> bool {
if destination.starts_with('#') || destination.starts_with('/') {
return false;
}
let ahead_of_first_slash = destination.split('/').next().unwrap_or_default();
if ahead_of_first_slash.contains(':') && !ahead_of_first_slash.starts_with(':') {
return false;
}
let named = destination.split('#').next().unwrap_or_default();
!named.is_empty() && named.to_ascii_lowercase().ends_with(".md")
}
fn resolve_beside(source_file: &str, destination: &str) -> String {
let mut walked: Vec<&str> = source_file.split('/').collect();
walked.pop();
for part in destination.split('#').next().unwrap_or_default().split('/') {
match part {
"" | "." => {}
".." => {
walked.pop();
}
name => walked.push(name),
}
}
format!("{SITE_ROOT}/{}", walked.join("/"))
}
fn verdict(plant: &Plant, source_file: &str, published: &BTreeSet<String>) -> Verdict {
if plant.writing == Writing::Image {
return Verdict::OutOfScope("an image, not a link");
}
if plant.destination.starts_with('#') {
return Verdict::OutOfScope("a fragment on the page itself");
}
if plant.destination.starts_with('/') {
return Verdict::OutOfScope("an absolute path");
}
if !is_local_markdown_destination(&plant.destination) {
return Verdict::OutOfScope("not a local markdown document");
}
let resolved = resolve_beside(source_file, &plant.destination);
if published.contains(&resolved) {
Verdict::PublishedTarget
} else {
Verdict::Broken(resolved)
}
}
fn relative_destination(source_file: &str, target_file: &str) -> String {
let source: Vec<&str> = source_file.split('/').collect();
let source_directories = &source[..source.len() - 1];
let target: Vec<&str> = target_file.split('/').collect();
let target_directories = &target[..target.len() - 1];
let shared = source_directories
.iter()
.zip(target_directories)
.take_while(|(here, there)| here == there)
.count();
let mut parts = vec![".."; source_directories.len() - shared];
parts.extend(&target_directories[shared..]);
parts.push(target[target.len() - 1]);
parts.join("/")
}
fn gen_local_destination(rng: &mut Rng, source_file: &str) -> String {
let target = if rng.below(3) == 0 {
*rng.pick(ABSENT_FILES)
} else {
*rng.pick(CANDIDATE_FILES)
};
let relative = relative_destination(source_file, target);
let anchor = rng.pick(ANCHORS);
match rng.below(5) {
1 => format!("./{relative}"),
2 => format!("{relative}#{anchor}"),
3 => format!("./{relative}#{anchor}"),
4 => relative.replace(".md", ".MD"),
_ => relative,
}
}
fn gen_plant(rng: &mut Rng, source_file: &str) -> Plant {
match rng.below(12) {
0..=5 => Plant {
destination: gen_local_destination(rng, source_file),
writing: *rng.pick(&[
Writing::Inline,
Writing::Reference,
Writing::Collapsed,
Writing::Shortcut,
]),
},
6 => Plant {
destination: gen_local_destination(rng, source_file),
writing: Writing::Image,
},
7 => Plant {
destination: format!("#{}", rng.pick(ANCHORS)),
writing: Writing::Inline,
},
8 => Plant {
destination: format!("/{}", rng.pick(CANDIDATE_FILES)),
writing: Writing::Inline,
},
9 => Plant {
destination: format!("https://example.com/{}", rng.pick(CANDIDATE_FILES)),
writing: if rng.below(2) == 0 {
Writing::Inline
} else {
Writing::Autolink
},
},
10 => Plant {
destination: (*rng.pick(&["mailto:docs@example.com", "http://example.org/a.md"]))
.to_string(),
writing: Writing::Inline,
},
_ => Plant {
destination: (*rng.pick(NON_DOCUMENT_DESTINATIONS)).to_string(),
writing: Writing::Inline,
},
}
}
fn gen_plants(rng: &mut Rng, source_file: &str) -> Vec<Plant> {
let mut seen = BTreeSet::new();
let mut plants = Vec::new();
for _ in 0..rng.below(9) {
let plant = gen_plant(rng, source_file);
if seen.insert(plant.destination.clone()) {
plants.push(plant);
}
}
plants
}
fn write_document(title: &str, plants: &[Plant]) -> String {
let mut body = format!("# {title}\n\nA page with links on it.\n\n");
let mut definitions = String::new();
for (index, plant) in plants.iter().enumerate() {
let destination = &plant.destination;
let label = format!("ref{index}");
let line = match plant.writing {
Writing::Inline => format!("See [link {index}]({destination})."),
Writing::Image => format!(""),
Writing::Autolink => format!("See <{destination}>."),
Writing::Reference => format!("See [link {index}][{label}]."),
Writing::Collapsed => format!("See [{label}][]."),
Writing::Shortcut => format!("See [{label}]."),
};
writeln!(body, "{line}\n").expect("writing to a String cannot fail");
if matches!(
plant.writing,
Writing::Reference | Writing::Collapsed | Writing::Shortcut
) {
writeln!(definitions, "[{label}]: {destination}")
.expect("writing to a String cannot fail");
}
}
body.push_str(&definitions);
body
}
fn route_for(file: &str) -> String {
if file == "README.md" {
return "/".to_string();
}
let trimmed = file.strip_prefix("docs/").unwrap_or(file);
let stem = trimmed.strip_suffix(".md").unwrap_or(trimmed);
let mut parts: Vec<&str> = stem.split('/').collect();
if parts.last() == Some(&"index") {
parts.pop();
}
if parts.is_empty() {
"/".to_string()
} else {
format!("/{}/", parts.join("/"))
}
}
fn gen_published(rng: &mut Rng) -> Vec<&'static str> {
let mut published = vec![if rng.below(2) == 0 {
"README.md"
} else {
"docs/index.md"
}];
for file in CANDIDATE_FILES {
if *file != "README.md" && *file != "docs/index.md" && rng.below(3) > 0 {
published.push(file);
}
}
published
}
fn gen_corpus(rng: &mut Rng) -> Corpus {
let published_files = gen_published(rng);
let published: BTreeSet<String> = published_files
.iter()
.map(|file| format!("{SITE_ROOT}/{file}"))
.collect();
let mut pages = Vec::new();
let mut doc_pages = Vec::new();
for file in published_files {
let plants = gen_plants(rng, file);
let route = route_for(file);
doc_pages.push(DocPage {
depth: route_depth(&route),
route,
title: file.to_string(),
source_path: file.to_string(),
file_path: PathBuf::from(format!("{SITE_ROOT}/{file}")),
markdown: write_document(file, &plants),
});
pages.push(Page { file, plants });
}
let site = DocsSite {
source_root: "docs".to_string(),
route_by_path: doc_pages
.iter()
.map(|page| (page.file_path.clone(), page.route.clone()))
.collect(),
page_by_route: doc_pages
.iter()
.enumerate()
.map(|(index, page)| (page.route.clone(), index))
.collect(),
pages: doc_pages,
};
Corpus {
site,
published,
pages,
}
}
mod link_resolution;