use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::ops::Range;
use pulldown_cmark::{Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd};
use crate::frontmatter::{FrontMatter, FrontMatterError};
use crate::markdown::parent_dir;
use crate::model::ReasonCode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexEntry {
pub link_text: String,
pub dest: String,
pub description: String,
pub separator: Option<char>,
pub text_span: Range<usize>,
pub tail_span: Range<usize>,
pub line_span: Range<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
External,
Unresolvable(&'static str),
File(String),
Directory(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Document {
pub path: String,
pub title: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexMeta {
pub path: String,
pub title: String,
pub description: Option<String>,
pub invalid: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct IndexIssue {
pub reason: ReasonCode,
pub target: String,
pub detail: String,
}
#[derive(Debug)]
pub struct ParsedText {
pub text: String,
pub front_matter: Result<Option<FrontMatter>, FrontMatterError>,
}
const BOM: char = '\u{feff}';
fn unescape_brackets(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && matches!(chars.peek(), Some('\\' | '[' | ']')) {
out.push(chars.next().unwrap_or(c));
} else {
out.push(c);
}
}
out
}
fn escape_brackets(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '\\' | '[' | ']') {
out.push('\\');
}
out.push(c);
}
out
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
' ' | '(' | ')' | '<' | '>' | '%' | '#' | '\\' => {
out.push_str(&format!("%{:02X}", c as u32));
}
_ => out.push(c),
}
}
out
}
fn is_inline_event(ev: &Event) -> bool {
match ev {
Event::Text(_)
| Event::Code(_)
| Event::InlineMath(_)
| Event::DisplayMath(_)
| Event::InlineHtml(_)
| Event::FootnoteReference(_)
| Event::SoftBreak
| Event::HardBreak => true,
Event::Start(t) => matches!(
t,
Tag::Emphasis
| Tag::Strong
| Tag::Strikethrough
| Tag::Superscript
| Tag::Subscript
| Tag::Link { .. }
| Tag::Image { .. }
),
Event::End(t) => matches!(
t,
TagEnd::Emphasis
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Superscript
| TagEnd::Subscript
| TagEnd::Link
| TagEnd::Image
),
_ => false,
}
}
pub fn parse_entries(text: &str) -> Vec<IndexEntry> {
let (body, off) = match text.strip_prefix(BOM) {
Some(b) => (b, BOM.len_utf8()),
None => (text, 0),
};
let events: Vec<(Event, Range<usize>)> = Parser::new_ext(body, Options::ENABLE_TASKLISTS)
.into_offset_iter()
.collect();
let mut entries = Vec::new();
for i in 0..events.len() {
if !matches!(events[i].0, Event::Start(Tag::Item)) {
continue;
}
let mut k = i + 1;
if matches!(events.get(k), Some((Event::Start(Tag::Paragraph), _))) {
k += 1;
}
let (dest_url, link_type, link_range) = match events.get(k) {
Some((
Event::Start(Tag::Link {
dest_url,
link_type,
..
}),
r,
)) => (dest_url.to_string(), *link_type, r.clone()),
_ => continue,
};
let mut j = k + 1;
let mut t_max = link_range.start + 1;
while j < events.len() && !matches!(events[j].0, Event::End(TagEnd::Link)) {
t_max = t_max.max(events[j].1.end);
j += 1;
}
let close = body.as_bytes()[t_max.min(body.len())..]
.iter()
.position(|&b| b == b']')
.map_or(t_max, |p| t_max + p);
let text_span = link_range.start + 1 + off..close + off;
let mut link_end = link_range.end;
if link_type == LinkType::Collapsed && body[link_end..].starts_with("[]") {
link_end += 2;
}
let mut m = j + 1;
let mut a_max = None::<usize>;
while m < events.len() && is_inline_event(&events[m].0) {
if !matches!(events[m].0, Event::SoftBreak | Event::HardBreak) {
a_max = Some(a_max.map_or(events[m].1.end, |x| x.max(events[m].1.end)));
}
m += 1;
}
let tail_end = a_max.map_or(link_end, |x| x.max(link_end));
let tail_span = link_end + off..tail_end + off;
let raw = &text[tail_span.clone()];
let trimmed = raw.trim();
let (separator, description) = match trimmed.chars().next() {
Some(c) if matches!(c, '-' | '–' | '—' | ':') => {
(Some(c), trimmed[c.len_utf8()..].trim_start().to_string())
}
_ => (None, trimmed.to_string()),
};
let line_start = text[..text_span.start]
.rfind('\n')
.map(|p| p + 1)
.unwrap_or(0)
.max(off);
let line_end = match text[tail_span.end..].find('\n') {
Some(p) => tail_span.end + p + 1,
None => text.len(),
};
entries.push(IndexEntry {
link_text: text[text_span.clone()].to_string(),
dest: dest_url,
description,
separator,
text_span,
tail_span,
line_span: line_start..line_end,
});
}
entries
}
pub fn resolve(index_path: &str, root: &str, dest: &str, repo: &RepoFiles) -> Resolution {
if dest.is_empty() || dest.starts_with('#') {
return Resolution::Unresolvable("empty target");
}
if dest.contains("://") || crate::markdown::has_uri_scheme(dest) {
return Resolution::External;
}
if dest.starts_with('/') {
return Resolution::Unresolvable("absolute path");
}
let no_frag = dest.split('#').next().unwrap_or("");
let decoded = crate::markdown::percent_decode(no_frag);
let path = match crate::markdown::normalize_relative(index_path, &decoded) {
Some(p) => p,
None => return Resolution::Unresolvable("escapes repository"),
};
if !crate::config::is_under(&path, root) {
return Resolution::Unresolvable("outside index root");
}
let own_dir = parent_dir(index_path);
let dir_candidate = |dir: String| {
if own_dir == dir || own_dir.starts_with(&format!("{dir}/")) {
Resolution::Unresolvable("self or ancestor directory")
} else {
Resolution::Directory(dir)
}
};
let file_name = path.rsplit('/').next().unwrap_or("");
if decoded.ends_with('/') {
return dir_candidate(path);
}
if file_name == "index.md" {
return dir_candidate(parent_dir(&path).to_string());
}
if repo.files.contains(path.as_str()) {
return Resolution::File(path);
}
if repo.dirs.contains(path.as_str()) {
return dir_candidate(path);
}
Resolution::File(path)
}
pub struct RepoFiles<'a> {
pub files: HashSet<&'a str>,
pub dirs: HashSet<String>,
}
impl<'a> RepoFiles<'a> {
pub fn new(paths: impl IntoIterator<Item = &'a str>) -> Self {
let files: HashSet<&str> = paths.into_iter().collect();
let mut dirs = HashSet::new();
for f in &files {
let mut cur = parent_dir(f);
while !cur.is_empty() && dirs.insert(cur.to_string()) {
cur = parent_dir(cur);
}
}
RepoFiles { files, dirs }
}
}
pub struct IndexTree {
root: String,
indexes: BTreeSet<String>,
}
impl IndexTree {
pub fn new(root: &str, indexes: impl IntoIterator<Item = String>) -> Self {
IndexTree {
root: root.to_string(),
indexes: indexes.into_iter().collect(),
}
}
fn root_index(&self) -> String {
format!("{}/index.md", self.root)
}
fn nearest(&self, dir: &str) -> String {
let mut cur = dir;
loop {
let cand = format!("{cur}/index.md");
if self.indexes.contains(&cand) {
return cand;
}
if cur == self.root || cur.is_empty() || !crate::config::is_under(cur, &self.root) {
return self.root_index();
}
cur = parent_dir(cur);
}
}
pub fn owner_of(&self, doc_path: &str) -> String {
self.nearest(parent_dir(doc_path))
}
pub fn parent_of(&self, index_path: &str) -> Option<String> {
let dir = parent_dir(index_path);
if dir == self.root {
None
} else {
Some(self.nearest(parent_dir(dir)))
}
}
pub fn children_of(&self, index_path: &str) -> Vec<String> {
self.indexes
.iter()
.filter(|c| self.parent_of(c).as_deref() == Some(index_path))
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexStatus {
Created,
Updated,
Unchanged,
}
impl IndexStatus {
pub fn as_str(self) -> &'static str {
match self {
IndexStatus::Created => "created",
IndexStatus::Updated => "updated",
IndexStatus::Unchanged => "unchanged",
}
}
}
#[derive(Debug, Default)]
pub struct RootFiles {
pub documents: Vec<Document>,
pub untyped: Vec<String>,
pub indexes: Vec<IndexMeta>,
}
pub fn collect_documents(root: &str, texts: &BTreeMap<String, ParsedText>) -> RootFiles {
let mut out = RootFiles::default();
for (path, parsed) in texts {
if !path.ends_with(".md") || !crate::config::is_under(path, root) {
continue;
}
match path.rsplit('/').next() {
Some("index.md") => {
let invalid = parsed.front_matter.is_err();
let (fm_title, description) = match &parsed.front_matter {
Ok(Some(fm)) => (front_title(fm), normalize_description(fm)),
_ => (None, None),
};
let title = fm_title.unwrap_or_else(|| {
parent_dir(path)
.rsplit('/')
.next()
.unwrap_or_default()
.to_string()
});
out.indexes.push(IndexMeta {
path: path.clone(),
title,
description,
invalid,
});
continue;
}
Some("log.md") => continue,
_ => {}
}
match &parsed.front_matter {
Err(_) => {}
Ok(Some(fm)) if fm.kind.is_some() => {
let description = normalize_description(fm);
out.documents.push(Document {
path: path.clone(),
title: document_title(path, fm),
description,
});
}
Ok(_) => out.untyped.push(path.clone()),
}
}
out
}
fn child_index_of(dir: &str) -> String {
format!("{dir}/index.md")
}
fn front_title(fm: &FrontMatter) -> Option<String> {
fm.title
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty() && !t.contains('\n') && !t.contains('\r'))
.map(str::to_string)
}
pub fn normalize_description(fm: &FrontMatter) -> Option<String> {
fm.description
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty() && !d.contains('\n') && !d.contains('\r'))
.map(str::to_string)
}
pub fn document_title(path: &str, fm: &FrontMatter) -> String {
if let Some(t) = front_title(fm) {
return t;
}
let body = fm.body.as_str();
let mut in_h1 = false;
let (mut min, mut max) = (usize::MAX, 0usize);
for (ev, range) in Parser::new(body).into_offset_iter() {
match ev {
Event::Start(Tag::Heading {
level: HeadingLevel::H1,
..
}) => {
in_h1 = true;
min = usize::MAX;
max = 0;
}
Event::End(TagEnd::Heading(_)) if in_h1 => {
if min != usize::MAX {
return body[min..max].to_string();
}
break;
}
_ if in_h1 => {
min = min.min(range.start);
max = max.max(range.end);
}
_ => {}
}
}
let name = path.rsplit('/').next().unwrap_or(path);
name.strip_suffix(".md").unwrap_or(name).to_string()
}
fn is_excluded(path: &str, config: &crate::config::Config) -> bool {
crate::source::is_under_dir_named(path, &config.exclude)
|| crate::source::is_under_dir_named(path, &crate::source::DEFAULT_EXCLUDED_DIRS)
}
fn entry_target(index: &str, dest: &str) -> String {
format!("{index} -> {dest}")
}
fn issue(reason: ReasonCode, target: impl Into<String>, detail: impl Into<String>) -> IndexIssue {
IndexIssue {
reason,
target: target.into(),
detail: detail.into(),
}
}
pub fn collect_issues(
config: &crate::config::Config,
files: &[String],
texts: &BTreeMap<String, ParsedText>,
) -> BTreeSet<IndexIssue> {
let mut out = BTreeSet::new();
let repo = RepoFiles::new(files.iter().map(String::as_str));
for raw in &config.index {
let root = crate::config::normalize_dir(raw);
check_root(config, &root, &repo, texts, &mut out);
}
out
}
fn check_root(
config: &crate::config::Config,
root: &str,
repo: &RepoFiles,
texts: &BTreeMap<String, ParsedText>,
out: &mut BTreeSet<IndexIssue>,
) {
let RootFiles {
documents,
untyped,
indexes,
} = collect_documents(root, texts);
for u in &untyped {
out.insert(issue(
ReasonCode::UntypedFile,
u.clone(),
"file has no `type` in its front matter",
));
}
for d in &documents {
if d.description.is_none() {
out.insert(issue(
ReasonCode::MissingDescription,
d.path.clone(),
"document has no single-line `description`",
));
}
}
let root_index = crate::config::join_under(root, "index.md");
let index_set: HashSet<&str> = indexes.iter().map(|m| m.path.as_str()).collect();
if !index_set.contains(root_index.as_str()) {
if !documents.is_empty() {
out.insert(issue(
ReasonCode::MissingIndex,
root.to_string(),
format!("{root_index} does not exist"),
));
}
return;
}
let doc_map: HashMap<&str, &Document> =
documents.iter().map(|d| (d.path.as_str(), d)).collect();
let untyped_set: HashSet<&str> = untyped.iter().map(String::as_str).collect();
let meta_map: HashMap<&str, &IndexMeta> =
indexes.iter().map(|m| (m.path.as_str(), m)).collect();
let tree = IndexTree::new(root, indexes.iter().map(|m| m.path.clone()));
let mut listed: HashMap<&str, HashSet<String>> = HashMap::new();
let mut linked: HashSet<String> = HashSet::new();
for index in indexes.iter().map(|m| &m.path) {
let text = &texts[index].text;
for e in parse_entries(text) {
match resolve(index, root, &e.dest, repo) {
Resolution::External => {}
Resolution::Unresolvable(why) => {
out.insert(issue(
ReasonCode::BrokenEntry,
entry_target(index, &e.dest),
why,
));
}
Resolution::File(p) => match doc_map.get(p.as_str()) {
Some(doc) => {
listed.entry(index).or_default().insert(p.clone());
if unescape_brackets(&e.link_text) != doc.title {
out.insert(issue(
ReasonCode::TitleMismatch,
p.clone(),
format!(
"link text differs from the document title `{}`",
doc.title
),
));
}
if let Some(d) = &doc.description {
if unescape_brackets(&e.description) != *d {
out.insert(issue(
ReasonCode::DescriptionMismatch,
p.clone(),
format!("summary differs from the document description `{d}`"),
));
}
}
}
None => {
let why = if is_excluded(&p, config) {
"target is excluded from scan"
} else if !repo.files.contains(p.as_str()) {
"target does not exist"
} else if untyped_set.contains(p.as_str()) {
"target has no `type`"
} else {
"target is not a document"
};
out.insert(issue(
ReasonCode::BrokenEntry,
entry_target(index, &e.dest),
why,
));
}
},
Resolution::Directory(d) => {
let child = child_index_of(&d);
if !index_set.contains(child.as_str()) {
out.insert(issue(
ReasonCode::BrokenEntry,
entry_target(index, &e.dest),
"directory has no index.md",
));
} else {
if let Some(m) = meta_map.get(child.as_str()).filter(|m| !m.invalid) {
let target = entry_target(index, &e.dest);
if unescape_brackets(&e.link_text) != m.title {
out.insert(issue(
ReasonCode::TitleMismatch,
target.clone(),
format!(
"link text differs from the directory title `{}`",
m.title
),
));
}
match &m.description {
Some(d) => {
if unescape_brackets(&e.description) != *d {
out.insert(issue(
ReasonCode::DescriptionMismatch,
target,
format!(
"summary differs from the directory description `{d}`"
),
));
}
}
None => {
out.insert(issue(
ReasonCode::MissingDirectoryDescription,
child.clone(),
"directory index has no single-line `description`",
));
}
}
}
if tree.parent_of(&child).as_deref() == Some(index.as_str()) {
linked.insert(child);
}
}
}
}
}
}
for d in &documents {
let owner = tree.owner_of(&d.path);
let is_listed = listed
.get(owner.as_str())
.is_some_and(|s| s.contains(&d.path));
if !is_listed {
out.insert(issue(
ReasonCode::NotListed,
d.path.clone(),
format!("not listed in {owner}"),
));
}
}
for index in indexes.iter().map(|m| &m.path) {
if *index != root_index && !linked.contains(index) {
out.insert(issue(
ReasonCode::UnlinkedIndex,
index.clone(),
"not linked from its parent index",
));
}
}
}
fn is_list_item_line(line: &str) -> bool {
let l = line.trim_start();
if l.starts_with("- ") || l.starts_with("* ") || l.starts_with("+ ") {
return true;
}
let digits = l.chars().take_while(|c| c.is_ascii_digit()).count();
digits > 0 && {
let rest = &l[digits..];
rest.starts_with(". ") || rest.starts_with(") ")
}
}
#[allow(clippy::too_many_arguments)]
pub fn update_index(
text: &str,
index_path: &str,
tree: &IndexTree,
documents: &[Document],
indexes: &[IndexMeta],
repo: &RepoFiles,
unlinked_child_indexes: &[String],
excluded: &[String],
) -> String {
let root = tree.root.as_str();
let index_dir = parent_dir(index_path);
let doc_map: HashMap<&str, &Document> =
documents.iter().map(|d| (d.path.as_str(), d)).collect();
let meta_map: HashMap<&str, &IndexMeta> =
indexes.iter().map(|m| (m.path.as_str(), m)).collect();
let mut edits: Vec<(Range<usize>, String)> = Vec::new();
let mut listed: HashSet<String> = HashSet::new();
for e in parse_entries(text) {
let (doc_path, title, description): (Option<String>, &str, &Option<String>) =
match resolve(index_path, root, &e.dest, repo) {
Resolution::File(p) => match doc_map.get(p.as_str()) {
Some(doc) => (Some(p), doc.title.as_str(), &doc.description),
None => {
if !repo.files.contains(p.as_str())
&& !crate::source::is_under_dir_named(&p, excluded)
{
edits.push((e.line_span.clone(), String::new()));
}
continue;
}
},
Resolution::Directory(d) => match meta_map.get(child_index_of(&d).as_str()) {
Some(m) if !m.invalid => (None, m.title.as_str(), &m.description),
_ => continue,
},
_ => continue,
};
if let Some(p) = doc_path {
listed.insert(p);
}
let after = &text.as_bytes()[e.text_span.end..];
let keep_label = after.first() == Some(&b']')
&& match after.get(1) {
Some(b'(') => false,
Some(b'[') => after.get(2) == Some(&b']'),
_ => true,
};
if !keep_label {
edits.push((e.text_span.clone(), escape_brackets(title)));
}
let tail = match description {
Some(d) => format!(" {} {}", e.separator.unwrap_or('—'), escape_brackets(d)),
None => String::new(),
};
edits.push((e.tail_span.clone(), tail));
}
edits.sort_by_key(|e| e.0.start);
let mut out = String::with_capacity(text.len());
let mut pos = 0;
for (range, rep) in edits {
out.push_str(&text[pos..range.start]);
out.push_str(&rep);
pos = range.end;
}
out.push_str(&text[pos..]);
let rel = |path: &str| -> String {
path.strip_prefix(&format!("{index_dir}/"))
.unwrap_or(path)
.to_string()
};
let mut missing: Vec<&Document> = documents
.iter()
.filter(|d| tree.owner_of(&d.path) == index_path && !listed.contains(&d.path))
.collect();
missing.sort_by(|a, b| a.path.cmp(&b.path));
let mut children: Vec<&String> = unlinked_child_indexes.iter().collect();
children.sort();
let mut lines: Vec<String> = Vec::new();
for d in missing {
let mut l = format!(
"- [{}]({})",
escape_brackets(&d.title),
percent_encode(&rel(&d.path))
);
if let Some(desc) = &d.description {
l.push_str(&format!(" — {}", escape_brackets(desc)));
}
lines.push(l);
}
for c in children {
if meta_map.get(c.as_str()).is_some_and(|m| m.invalid) {
continue;
}
let dir = rel(parent_dir(c));
let (title, description) = match meta_map.get(c.as_str()) {
Some(m) => (m.title.clone(), m.description.clone()),
None => (dir.rsplit('/').next().unwrap_or(&dir).to_string(), None),
};
let mut l = format!("- [{}]({}/)", escape_brackets(&title), percent_encode(&dir));
if let Some(desc) = &description {
l.push_str(&format!(" — {}", escape_brackets(desc)));
}
lines.push(l);
}
if lines.is_empty() {
return out;
}
let nl = if text.contains("\r\n") { "\r\n" } else { "\n" };
if !out.is_empty() {
if !out.ends_with('\n') {
out.push_str(nl);
}
if let Some(last) = out.lines().rev().find(|l| !l.trim().is_empty()) {
if !is_list_item_line(last) {
out.push_str(nl);
}
}
}
for l in lines {
out.push_str(&l);
out.push_str(nl);
}
out
}
fn write_atomic(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let name = format!(
".index.md.{}.{nanos}.{}.tmp",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
);
let tmp = path.with_file_name(name);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
let tmp_ref = &tmp;
let result = (move || -> std::io::Result<()> {
f.write_all(content.as_bytes())?;
f.sync_all()?;
drop(f);
std::fs::rename(tmp_ref, path)
})();
if let Err(e) = result {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
Ok(())
}
pub fn run(
repo_root: &std::path::Path,
config: &crate::config::Config,
) -> anyhow::Result<Vec<(IndexStatus, String)>> {
use crate::source::Source;
if config.index.is_empty() {
anyhow::bail!("index is not configured in .miwa/config.yaml");
}
let source = crate::source::FsSource::new(repo_root);
let files: Vec<String> = source
.list_files()?
.into_iter()
.filter(|p| !crate::source::is_under_dir_named(p, &config.exclude))
.collect();
let repo = RepoFiles::new(files.iter().map(String::as_str));
let roots: Vec<String> = config
.index
.iter()
.map(|r| crate::config::normalize_dir(r))
.collect();
let mut texts: BTreeMap<String, ParsedText> = BTreeMap::new();
for path in &files {
if path.ends_with(".md") && roots.iter().any(|r| crate::config::is_under(path, r)) {
let bytes = source.read(path)?;
let text = if path.rsplit('/').next() == Some("index.md") {
String::from_utf8(bytes).map_err(|_| {
anyhow::anyhow!("{path} is not valid UTF-8; refusing to rewrite it")
})?
} else {
String::from_utf8_lossy(&bytes).into_owned()
};
let front_matter = crate::frontmatter::parse(&text);
texts.insert(path.clone(), ParsedText { text, front_matter });
}
}
let excluded: Vec<String> = config
.exclude
.iter()
.cloned()
.chain(
crate::source::DEFAULT_EXCLUDED_DIRS
.iter()
.map(|s| s.to_string()),
)
.collect();
let mut results = Vec::new();
for root in &roots {
let RootFiles {
documents, indexes, ..
} = collect_documents(root, &texts);
let index_paths: Vec<String> = indexes.iter().map(|m| m.path.clone()).collect();
let tree = IndexTree::new(root, index_paths.iter().cloned());
let root_index = crate::config::join_under(root, "index.md");
let targets: Vec<String> = if index_paths.contains(&root_index) {
index_paths.clone()
} else if documents.is_empty() {
continue;
} else {
let mut all = vec![root_index.clone()];
all.extend(index_paths.iter().cloned());
all
};
for index in targets {
let existing = texts.get(&index).map(|t| t.text.as_str());
let text = existing.unwrap_or("");
let linked: HashSet<String> = parse_entries(text)
.into_iter()
.filter_map(|e| match resolve(&index, root, &e.dest, &repo) {
Resolution::Directory(d) => Some(child_index_of(&d)),
_ => None,
})
.collect();
let unlinked: Vec<String> = tree
.children_of(&index)
.into_iter()
.filter(|c| !linked.contains(c))
.collect();
let new_text = update_index(
text, &index, &tree, &documents, &indexes, &repo, &unlinked, &excluded,
);
let status = if existing.is_none() {
IndexStatus::Created
} else if new_text == text {
IndexStatus::Unchanged
} else {
IndexStatus::Updated
};
if status != IndexStatus::Unchanged {
write_atomic(&repo_root.join(&index), &new_text)?;
}
results.push((status, index));
}
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn dests(text: &str) -> Vec<String> {
parse_entries(text).into_iter().map(|e| e.dest).collect()
}
fn doc(path: &str, title: &str, description: Option<&str>) -> Document {
Document {
path: path.to_string(),
title: title.to_string(),
description: description.map(str::to_string),
}
}
fn typed(title: &str, description: Option<&str>) -> String {
match description {
Some(d) => format!("---\ntype: BusinessNeed\ntitle: {title}\ndescription: {d}\n---\n"),
None => format!("---\ntype: BusinessNeed\ntitle: {title}\n---\n"),
}
}
fn texts_of(files: &[(&str, &str)]) -> BTreeMap<String, ParsedText> {
files
.iter()
.filter(|(p, _)| p.ends_with(".md"))
.map(|(p, t)| {
(
p.to_string(),
ParsedText {
text: t.to_string(),
front_matter: crate::frontmatter::parse(t),
},
)
})
.collect()
}
fn issues_with(
roots: &[&str],
exclude: &[&str],
files: &[(&str, &str)],
) -> BTreeSet<IndexIssue> {
let mut config = Config::default_config();
config.index = roots.iter().map(|s| s.to_string()).collect();
config.exclude = exclude.iter().map(|s| s.to_string()).collect();
let names: Vec<String> = files.iter().map(|(p, _)| p.to_string()).collect();
collect_issues(&config, &names, &texts_of(files))
}
fn issues(files: &[(&str, &str)]) -> BTreeSet<IndexIssue> {
issues_with(&["docs"], &[], files)
}
fn has(set: &BTreeSet<IndexIssue>, reason: ReasonCode, target: &str) -> bool {
set.iter().any(|i| i.reason == reason && i.target == target)
}
fn count(set: &BTreeSet<IndexIssue>, reason: ReasonCode, target: &str) -> usize {
set.iter()
.filter(|i| i.reason == reason && i.target == target)
.count()
}
fn detail_of(set: &BTreeSet<IndexIssue>, reason: ReasonCode, target: &str) -> String {
set.iter()
.find(|i| i.reason == reason && i.target == target)
.unwrap_or_else(|| panic!("no {reason:?} for {target:?} in {set:?}"))
.detail
.clone()
}
fn fm(text: &str) -> FrontMatter {
crate::frontmatter::parse(text).unwrap().unwrap()
}
fn upd(text: &str, documents: &[Document], files: &[&str], unlinked: &[&str]) -> String {
upd_meta(text, documents, &[], files, unlinked)
}
fn meta(path: &str, title: &str, description: Option<&str>) -> IndexMeta {
IndexMeta {
path: path.to_string(),
title: title.to_string(),
description: description.map(str::to_string),
invalid: false,
}
}
fn index_with(title: Option<&str>, description: Option<&str>) -> String {
let mut s = String::from("---\n");
if let Some(t) = title {
s.push_str(&format!("title: {t}\n"));
}
if let Some(d) = description {
s.push_str(&format!("description: {d}\n"));
}
s.push_str("---\n- [B](b.md) — DB\n");
s
}
fn upd_meta(
text: &str,
documents: &[Document],
indexes: &[IndexMeta],
files: &[&str],
unlinked: &[&str],
) -> String {
let unlinked: Vec<String> = unlinked.iter().map(|s| s.to_string()).collect();
update_index(
text,
"docs/index.md",
&tree_of(files),
documents,
indexes,
&RepoFiles::new(files.iter().copied()),
&unlinked,
&test_excluded(),
)
}
fn test_excluded() -> Vec<String> {
crate::source::DEFAULT_EXCLUDED_DIRS
.iter()
.map(|s| s.to_string())
.collect()
}
fn tree_of(files: &[&str]) -> IndexTree {
IndexTree::new(
"docs",
files
.iter()
.filter(|f| f.ends_with("/index.md"))
.map(|f| f.to_string()),
)
}
#[test]
fn 箇条書き記号とorderedの各形式がエントリとして検出される() {
let text = "- [a](a.md)\n\n* [b](b.md)\n\n+ [c](c.md)\n\n1. [d](d.md)\n\n2) [e](e.md)\n";
assert_eq!(dests(text), ["a.md", "b.md", "c.md", "d.md", "e.md"]);
}
#[test]
fn 入れ子の項目も任意の深さでエントリとして検出される() {
let text = "- [a](a.md)\n - [b](sub/b.md)\n - [c](sub/c.md)\n";
assert_eq!(dests(text), ["a.md", "sub/b.md", "sub/c.md"]);
}
#[test]
fn ルーズリストでも同じ結果になる() {
let text = "- [a](a.md) — A\n\n- [b](b.md) — B\n";
let es = parse_entries(text);
assert_eq!(es.len(), 2);
assert_eq!(es[0].description, "A");
assert_eq!(es[1].description, "B");
assert_eq!(&text[es[0].text_span.clone()], "a");
assert_eq!(&text[es[1].text_span.clone()], "b");
}
#[test]
fn 太字で包まれたリンクで始まる項目はエントリではない() {
assert!(parse_entries("- **[a](a.md)** — x\n").is_empty());
}
#[test]
fn タスクリストの項目はエントリではない() {
assert!(parse_entries("- [ ] [a](a.md)\n- [x] [b](b.md)\n").is_empty());
}
#[test]
fn リンクで始まらない項目はエントリではない() {
assert!(parse_entries("- see [a](a.md)\n- 説明のみ\n").is_empty());
}
#[test]
fn 段落中や見出し中のリンクはエントリではない() {
let text = "intro [a](a.md)\n\n# [h](h.md)\n\n- [b](b.md)\n";
assert_eq!(dests(text), ["b.md"]);
}
#[test]
fn 参照形式のリンクで始まる項目もエントリになる() {
let text = "- [a][ref]\n- [b][]\n- [c]\n\n[ref]: a.md\n[b]: b.md\n[c]: c.md\n";
let es = parse_entries(text);
assert_eq!(
es.iter().map(|e| e.dest.as_str()).collect::<Vec<_>>(),
["a.md", "b.md", "c.md"]
);
assert_eq!(&text[es[0].text_span.clone()], "a");
assert_eq!(&text[es[1].text_span.clone()], "b");
assert_eq!(&text[es[2].text_span.clone()], "c");
}
#[test]
fn リンクのtitle属性は無視される() {
let text = "- [a](a.md \"tt\") — d\n";
let es = parse_entries(text);
assert_eq!(es.len(), 1);
assert_eq!(es[0].dest, "a.md");
assert_eq!(es[0].description, "d");
}
#[test]
fn 複数行にわたる項目も1つのエントリになる() {
let text = "- [a](a.md) — one\n two\n- [b](b.md)\n";
let es = parse_entries(text);
assert_eq!(es.len(), 2);
assert_eq!(es[0].dest, "a.md");
}
#[test]
fn bomがあっても同じエントリが検出される() {
let text = "\u{feff}- [a](a.md) — d\n";
let es = parse_entries(text);
assert_eq!(es.len(), 1);
assert_eq!(&text[es[0].text_span.clone()], "a");
assert_eq!(&text[es[0].tail_span.clone()], " — d");
}
#[test]
fn okf_version等のfrontmatter付き索引でも検出される() {
let text = "---\nokf_version: 1\n---\n\n- [a](a.md)\n";
let es = parse_entries(text);
assert_eq!(es.len(), 1);
assert_eq!(&text[es[0].text_span.clone()], "a");
}
#[test]
fn 空のリンク文は空文字列でspanは開き括弧の直後になる() {
let text = "- [](x.md)\n";
let es = parse_entries(text);
assert_eq!(es.len(), 1);
assert_eq!(es[0].link_text, "");
let pos = text.find('[').unwrap() + 1;
assert_eq!(es[0].text_span, pos..pos);
}
#[test]
fn リンク文のコードスパンや入れ子の角括弧はソースのまま扱う() {
let es = parse_entries("- [`a` b](x.md)\n- [T [x]](y.md)\n");
assert_eq!(es[0].link_text, "`a` b");
assert_eq!(es[1].link_text, "T [x]");
}
#[test]
fn 区切り4種を除いて要約を取り出す() {
for sep in ['-', '–', '—', ':'] {
let text = format!("- [a](a.md) {sep} desc\n");
let es = parse_entries(&text);
assert_eq!(es[0].separator, Some(sep), "{sep}");
assert_eq!(es[0].description, "desc", "{sep}");
}
}
#[test]
fn 区切りがなくても要約になり_区切りの二重指定は1文字だけ除く() {
let es = parse_entries("- [a](a.md) desc\n- [b](b.md) — — x\n");
assert_eq!(es[0].separator, None);
assert_eq!(es[0].description, "desc");
assert_eq!(es[1].separator, Some('—'));
assert_eq!(es[1].description, "— x");
}
#[test]
fn 要約のないエントリは空の要約になる() {
let es = parse_entries("- [a](a.md)\n");
assert_eq!(es[0].description, "");
assert_eq!(es[0].separator, None);
assert!(es[0].tail_span.is_empty());
}
#[test]
fn 折り畳み参照の後ろから要約を取り出す() {
let text = "- [a][] — desc\n\n[a]: a.md\n";
let es = parse_entries(text);
assert_eq!(es[0].description, "desc");
assert_eq!(&text[es[0].tail_span.clone()], " — desc");
}
#[test]
fn 遅延継続行と複数行の要約を含む() {
let lazy = parse_entries("- [a](a.md) — first\nsecond\n");
assert_eq!(lazy[0].description, "first\nsecond");
let indented = parse_entries("- [a](a.md) — first\n second\n");
assert_eq!(indented[0].description, "first\n second");
}
#[test]
fn 要約に入れ子の子項目は含まれない() {
let text = "- [a](a.md) — d\n - [b](b.md) — e\n";
let es = parse_entries(text);
assert_eq!(es.len(), 2);
assert_eq!(es[0].description, "d");
assert_eq!(&text[es[0].tail_span.clone()], " — d");
assert_eq!(es[1].description, "e");
}
#[test]
fn line_spanは先頭行から改行までを含む() {
let text = "- [a](a.md) — d\r\n - [b](b.md)\r\n- [c](c.md) — z";
let es = parse_entries(text);
assert_eq!(&text[es[0].line_span.clone()], "- [a](a.md) — d\r\n");
assert_eq!(&text[es[1].line_span.clone()], " - [b](b.md)\r\n");
assert_eq!(&text[es[2].line_span.clone()], "- [c](c.md) — z");
}
#[test]
fn 角括弧のエスケープは比較時に戻される() {
let files = [
("docs/index.md", "- [a\\[1\\]](a.md) — x \\] y\n"),
(
"docs/a.md",
"---\ntype: BusinessNeed\ntitle: \"a[1]\"\ndescription: \"x ] y\"\n---\n",
),
];
assert!(issues(&files).is_empty(), "{:?}", issues(&files));
}
fn res(index_path: &str, dest: &str, files: &[&str]) -> Resolution {
resolve(
index_path,
"docs",
dest,
&RepoFiles::new(files.iter().copied()),
)
}
#[test]
fn 空とフラグメントのみは解決不能になる() {
assert_eq!(
res("docs/index.md", "", &[]),
Resolution::Unresolvable("empty target")
);
assert_eq!(
res("docs/index.md", "#x", &[]),
Resolution::Unresolvable("empty target")
);
}
#[test]
fn 外部リンクは無視される() {
assert_eq!(
res("docs/index.md", "https://example.com/a", &[]),
Resolution::External
);
assert_eq!(
res("docs/index.md", "mailto:a@example.com", &[]),
Resolution::External
);
}
#[test]
fn 絶対パスとリポジトリ外と根の外は解決不能になる() {
assert_eq!(
res("docs/index.md", "/abs.md", &[]),
Resolution::Unresolvable("absolute path")
);
assert_eq!(
res("docs/index.md", "../../out.md", &[]),
Resolution::Unresolvable("escapes repository")
);
assert_eq!(
res("docs/index.md", "../req/x.md", &["req/x.md"]),
Resolution::Unresolvable("outside index root")
);
}
#[test]
fn フラグメントを除いてファイルに解決される() {
assert_eq!(
res("docs/index.md", "a.md#sec", &["docs/a.md"]),
Resolution::File("docs/a.md".to_string())
);
assert_eq!(
res("docs/sub/index.md", "../a.md", &["docs/a.md"]),
Resolution::File("docs/a.md".to_string())
);
}
#[test]
fn パーセントデコードされ_デコード後のシャープは再分割されない() {
assert_eq!(
res("docs/index.md", "%E9%9B%86.md", &["docs/集.md"]),
Resolution::File("docs/集.md".to_string())
);
assert_eq!(
res("docs/index.md", "a%23b.md", &["docs/a#b.md"]),
Resolution::File("docs/a#b.md".to_string())
);
assert_eq!(
res("docs/index.md", "100%.md", &["docs/100%.md"]),
Resolution::File("docs/100%.md".to_string())
);
}
#[test]
fn ディレクトリを指す3形は同じディレクトリエントリになる() {
let files = ["docs/sub/index.md", "docs/sub/x.md"];
let want = Resolution::Directory("docs/sub".to_string());
assert_eq!(res("docs/index.md", "sub/", &files), want);
assert_eq!(res("docs/index.md", "sub", &files), want);
assert_eq!(res("docs/index.md", "sub/index.md", &files), want);
}
#[test]
fn スラッシュなしでも配下にファイルがあればディレクトリになる() {
assert_eq!(
res("docs/index.md", "sub", &["docs/sub/x.md"]),
Resolution::Directory("docs/sub".to_string())
);
}
#[test]
fn 自身と祖先のディレクトリは解決不能になる() {
let why = Resolution::Unresolvable("self or ancestor directory");
assert_eq!(res("docs/index.md", "./", &[]), why);
assert_eq!(res("docs/index.md", "index.md", &[]), why);
assert_eq!(res("docs/a/index.md", "../", &[]), why);
assert_eq!(res("docs/a/index.md", "../index.md", &[]), why);
assert_eq!(res("docs/a/index.md", "./", &[]), why);
}
#[test]
fn 存在しないファイルは文書エントリとして扱う() {
assert_eq!(
res("docs/index.md", "missing.md", &[]),
Resolution::File("docs/missing.md".to_string())
);
}
#[test]
fn titleはフロントマターのtitleを優先し前後の空白を除く() {
let t = fm("---\ntype: X\ntitle: \" Front \"\n---\n# Heading\n");
assert_eq!(document_title("docs/a.md", &t), "Front");
}
#[test]
fn titleが空なら最初のh1にフォールバックする() {
let t = fm("---\ntype: X\ntitle: \" \"\n---\n# Heading\n");
assert_eq!(document_title("docs/a.md", &t), "Heading");
}
#[test]
fn h1はatx閉じのシャープを含まない() {
let t = fm("---\ntype: X\n---\n# Hello World #\n");
assert_eq!(document_title("docs/a.md", &t), "Hello World");
}
#[test]
fn h1はsetext形式でも取れて下線を含まない() {
let t = fm("---\ntype: X\n---\nHello World\n===\n");
assert_eq!(document_title("docs/a.md", &t), "Hello World");
}
#[test]
fn h1のコードスパンはソースのまま含む() {
let t = fm("---\ntype: X\n---\n# `code` and *em*\n");
assert_eq!(document_title("docs/a.md", &t), "`code` and *em*");
}
#[test]
fn コードブロック内のシャープ行はh1として扱わない() {
let t = fm("---\ntype: X\n---\n```\n# not heading\n```\n\n# Real\n");
assert_eq!(document_title("docs/a.md", &t), "Real");
}
#[test]
fn h1以外の見出しは無視されファイル名にフォールバックする() {
let t = fm("---\ntype: X\n---\n## second level\n");
assert_eq!(document_title("docs/foo-bar.md", &t), "foo-bar");
}
fn collect(files: &[(&str, &str)]) -> RootFiles {
collect_documents("docs", &texts_of(files))
}
#[test]
fn descriptionの有無と空と改行の扱い() {
let files = [
("docs/none.md", "---\ntype: X\n---\n"),
("docs/empty.md", "---\ntype: X\ndescription: \"\"\n---\n"),
(
"docs/newline.md",
"---\ntype: X\ndescription: \"a\\nb\"\n---\n",
),
(
"docs/folded.md",
"---\ntype: X\ndescription: >\n line1\n line2\n---\n",
),
(
"docs/block.md",
"---\ntype: X\ndescription: |\n line1\n line2\n---\n",
),
(
"docs/trim.md",
"---\ntype: X\ndescription: \" x \"\n---\n",
),
];
let RootFiles {
documents: docs,
untyped,
..
} = collect(&files);
assert!(untyped.is_empty());
let d = |p: &str| {
docs.iter()
.find(|d| d.path == p)
.unwrap_or_else(|| panic!("{p}"))
.description
.clone()
};
assert_eq!(d("docs/none.md"), None);
assert_eq!(d("docs/empty.md"), None);
assert_eq!(d("docs/newline.md"), None);
assert_eq!(d("docs/folded.md"), Some("line1 line2".to_string()));
assert_eq!(d("docs/block.md"), None);
assert_eq!(d("docs/trim.md"), Some("x".to_string()));
}
#[test]
fn 索引のtitleとdescriptionはディレクトリのタイトルと要約になる() {
let files = [
(
"docs/a/index.md",
"---\ntitle: \" Alpha \"\ndescription: \" DA \"\n---\n",
),
("docs/b/index.md", "---\ndescription: DB\n---\n"),
(
"docs/c/index.md",
"---\ntitle: \"\"\ndescription: \"\"\n---\n",
),
(
"docs/d/index.md",
"---\ntitle: \"x\\ny\"\ndescription: \"a\\nb\"\n---\n",
),
("docs/e/f/index.md", "plain\n"),
(
"docs/g/index.md",
"---\ntype: BusinessNeed\ntitle: G\ndescription: DG\n---\n",
),
];
let RootFiles {
documents, indexes, ..
} = collect(&files);
assert!(documents.is_empty(), "{documents:?}");
let m = |p: &str| {
indexes
.iter()
.find(|m| m.path == p)
.unwrap_or_else(|| panic!("{p}"))
.clone()
};
assert_eq!(
m("docs/a/index.md"),
meta("docs/a/index.md", "Alpha", Some("DA"))
);
assert_eq!(
m("docs/b/index.md"),
meta("docs/b/index.md", "b", Some("DB"))
);
assert_eq!(m("docs/c/index.md"), meta("docs/c/index.md", "c", None));
assert_eq!(m("docs/d/index.md"), meta("docs/d/index.md", "d", None));
assert_eq!(m("docs/e/f/index.md"), meta("docs/e/f/index.md", "f", None));
assert_eq!(
m("docs/g/index.md"),
meta("docs/g/index.md", "G", Some("DG"))
);
}
#[test]
fn indexとlogは予約名で文書にならず索引として分類される() {
let files = [
("docs/index.md", "- [a](a.md)\n"),
("docs/sub/index.md", "- [b](b.md)\n"),
("docs/log.md", "---\ntype: BusinessNeed\n---\n"),
("docs/a.md", "---\ntype: Note\ntitle: A\n---\n"),
];
let RootFiles {
documents: docs,
untyped,
indexes,
} = collect(&files);
assert_eq!(docs.len(), 1, "{docs:?}");
assert!(untyped.is_empty(), "{untyped:?}");
let mut paths: Vec<String> = indexes.into_iter().map(|m| m.path).collect();
paths.sort();
assert_eq!(paths, ["docs/index.md", "docs/sub/index.md"]);
}
#[test]
fn typeなしとフロントマターなしはuntypedで壊れたフロントマターは無視される() {
let files = [
("docs/plain.md", "# no frontmatter\n"),
("docs/notype.md", "---\ntitle: x\n---\n"),
("docs/broken.md", "---\ntype: [unclosed\n---\n"),
];
let RootFiles {
documents: docs,
untyped,
..
} = collect(&files);
assert!(docs.is_empty(), "{docs:?}");
let mut untyped = untyped;
untyped.sort();
assert_eq!(untyped, ["docs/notype.md", "docs/plain.md"]);
}
#[test]
fn typeは任意の値でも文書になる() {
let files = [("docs/a.md", "---\ntype: Note\ntitle: A\n---\n")];
let RootFiles {
documents: docs, ..
} = collect(&files);
assert_eq!(docs.len(), 1, "{docs:?}");
assert_eq!(docs[0].path, "docs/a.md");
assert_eq!(docs[0].title, "A");
}
#[test]
fn 根のみの索引で整合していればfindingは出ない() {
let a = typed("A", Some("D1"));
let b = typed("B", Some("D2"));
let files = [
("docs/index.md", "- [A](a.md) — D1\n- [B](b.md) - D2\n"),
("docs/a.md", a.as_str()),
("docs/b.md", b.as_str()),
];
assert!(issues(&files).is_empty(), "{:?}", issues(&files));
}
#[test]
fn 入れ子の索引で子の文書は子の索引が担当する() {
let sub = index_with(Some("sub"), Some("S"));
let a = typed("A", Some("DA"));
let b = typed("B", Some("DB"));
let files = [
("docs/index.md", "- [A](a.md) — DA\n- [sub](sub/) — S\n"),
("docs/sub/index.md", sub.as_str()),
("docs/a.md", a.as_str()),
("docs/sub/b.md", b.as_str()),
];
assert!(issues(&files).is_empty(), "{:?}", issues(&files));
}
#[test]
fn 親が子の担当文書を直接列挙しても_同じ文書の重複列挙もfindingにならない() {
let sub = index_with(Some("sub"), Some("S"));
let a = typed("A", Some("DA"));
let b = typed("B", Some("DB"));
let files = [
(
"docs/index.md",
"- [A](a.md) — DA\n- [A](a.md) — DA\n- [sub](sub/index.md) — S\n- [B](sub/b.md) — DB\n",
),
("docs/sub/index.md", sub.as_str()),
("docs/a.md", a.as_str()),
("docs/sub/b.md", b.as_str()),
];
assert!(issues(&files).is_empty(), "{:?}", issues(&files));
}
#[test]
fn 担当索引が列挙していなければ他の索引が列挙していてもnot_listedになる() {
let a = typed("A", Some("DA"));
let b = typed("B", Some("DB"));
let files = [
(
"docs/index.md",
"- [A](a.md) — DA\n- [sub](sub/) — S\n- [B](sub/b.md) — DB\n",
),
("docs/sub/index.md", "intro only\n"),
("docs/a.md", a.as_str()),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&files);
assert!(has(&set, ReasonCode::NotListed, "docs/sub/b.md"), "{set:?}");
assert!(!has(&set, ReasonCode::NotListed, "docs/a.md"));
}
#[test]
fn 中間ディレクトリに索引がなくても最も近い祖先の索引が親になる() {
let c = typed("C", Some("DC"));
let ab = "---\ntitle: a/b\ndescription: AB\n---\n- [C](c.md) — DC\n";
let files = [
("docs/index.md", "- [a/b](a/b/) — AB\n"),
("docs/a/b/index.md", ab),
("docs/a/b/c.md", c.as_str()),
];
assert!(issues(&files).is_empty(), "{:?}", issues(&files));
}
#[test]
fn 自前の索引を持たない中間ディレクトリへのエントリはbroken_entryになる() {
let c = typed("C", Some("DC"));
let files = [
("docs/index.md", "- [a](a/) — A\n"),
("docs/a/b/index.md", "- [C](c.md) — DC\n"),
("docs/a/b/c.md", c.as_str()),
];
let set = issues(&files);
assert_eq!(
detail_of(&set, ReasonCode::BrokenEntry, "docs/index.md -> a/"),
"directory has no index.md"
);
assert!(has(&set, ReasonCode::UnlinkedIndex, "docs/a/b/index.md"));
}
#[test]
fn 設定した根が配下のディレクトリでも担当範囲はその根の中だけになる() {
let g = typed("G", Some("DG"));
let files = [
("docs/guide/index.md", "- [G](g.md) — DG\n"),
("docs/guide/g.md", g.as_str()),
("docs/other.md", "---\ntype: X\n---\n"),
("docs/untyped.md", "plain\n"),
];
let set = issues_with(&["docs/guide"], &[], &files);
assert!(set.is_empty(), "{set:?}");
}
#[test]
fn 根の索引がなく文書があればmissing_indexで他の検査は省く() {
let a = typed("A", Some("DA"));
let files = [
("docs/a.md", a.as_str()),
("docs/nodesc.md", "---\ntype: X\ntitle: N\n---\n"),
("docs/sub/index.md", "- [gone](gone.md)\n"),
("docs/u.md", "plain\n"),
];
let set = issues(&files);
assert!(has(&set, ReasonCode::MissingIndex, "docs"), "{set:?}");
assert!(has(&set, ReasonCode::UntypedFile, "docs/u.md"));
for r in [
ReasonCode::NotListed,
ReasonCode::BrokenEntry,
ReasonCode::UnlinkedIndex,
ReasonCode::TitleMismatch,
] {
assert!(set.iter().all(|i| i.reason != r), "{r:?} in {set:?}");
}
}
#[test]
fn 文書が0件の根ではmissing_indexを出さない() {
assert!(issues(&[]).is_empty());
assert!(issues(&[("docs/log.md", "---\ntype: X\n---\n")]).is_empty());
let set = issues(&[("docs/u.md", "plain\n")]);
assert!(!set.iter().any(|i| i.reason == ReasonCode::MissingIndex));
assert!(has(&set, ReasonCode::UntypedFile, "docs/u.md"));
}
#[test]
fn 列挙されていない文書はnot_listedになりidの妥当性に依存しない() {
let files = [
("docs/index.md", "intro\n"),
(
"docs/a.md",
"---\ntype: X\nid: \"bad id!\"\ndescription: D\n---\n",
),
("docs/b.md", "---\ntype: X\ndescription: D\n---\n"),
];
let set = issues(&files);
assert!(has(&set, ReasonCode::NotListed, "docs/a.md"), "{set:?}");
assert!(has(&set, ReasonCode::NotListed, "docs/b.md"));
assert_eq!(count(&set, ReasonCode::NotListed, "docs/a.md"), 1);
}
#[test]
fn typeのないmdはuntyped_fileでindexとlogと根の外は対象外() {
let files = [
("docs/index.md", "- [x](x.md)\n"),
("docs/log.md", "plain\n"),
("docs/plain.md", "no frontmatter\n"),
("docs/notype.md", "---\ntitle: T\n---\n"),
("req/outside.md", "plain\n"),
];
let set = issues(&files);
assert!(
has(&set, ReasonCode::UntypedFile, "docs/plain.md"),
"{set:?}"
);
assert!(has(&set, ReasonCode::UntypedFile, "docs/notype.md"));
assert!(!has(&set, ReasonCode::UntypedFile, "docs/index.md"));
assert!(!has(&set, ReasonCode::UntypedFile, "docs/log.md"));
assert!(!has(&set, ReasonCode::UntypedFile, "req/outside.md"));
}
#[test]
fn broken_entryのdetailとtargetの正準形() {
let t = typed("T", Some("D"));
let index = [
"- [e]()\n",
"- [a](#x)\n",
"- [b](/abs.md)\n",
"- [c](../../out.md)\n",
"- [d](../req/x.md)\n",
"- [s](./)\n",
"- [f](missing.md)\n",
"- [g](untyped.md)\n",
"- [h](dirnoindex/)\n",
"- [i](pic.png)\n",
"- [j](vendor/x.md)\n",
"- [k](node_modules/x.md)\n",
"- [ext](https://example.com/x)\n",
"- [mail](mailto:a@example.com)\n",
]
.concat();
let files = [
("docs/index.md", index.as_str()),
("docs/untyped.md", "plain\n"),
("docs/pic.png", ""),
("docs/dirnoindex/x.md", t.as_str()),
];
let set = issues_with(&["docs"], &["vendor"], &files);
let b = |dest: &str| {
detail_of(
&set,
ReasonCode::BrokenEntry,
&format!("docs/index.md -> {dest}"),
)
};
assert_eq!(b(""), "empty target");
assert_eq!(b("#x"), "empty target");
assert_eq!(b("/abs.md"), "absolute path");
assert_eq!(b("../../out.md"), "escapes repository");
assert_eq!(b("../req/x.md"), "outside index root");
assert_eq!(b("./"), "self or ancestor directory");
assert_eq!(b("missing.md"), "target does not exist");
assert_eq!(b("untyped.md"), "target has no `type`");
assert_eq!(b("dirnoindex/"), "directory has no index.md");
assert_eq!(b("pic.png"), "target is not a document");
assert_eq!(b("vendor/x.md"), "target is excluded from scan");
assert_eq!(b("node_modules/x.md"), "target is excluded from scan");
assert!(!set.iter().any(|i| i.target.contains("example.com")));
}
#[test]
fn タイトルが一致しなければtitle_mismatchで対象は文書のパス() {
let a = typed("Real", Some("D"));
let files = [
("docs/index.md", "- [Wrong](a.md) — D\n"),
("docs/a.md", a.as_str()),
];
let set = issues(&files);
assert!(has(&set, ReasonCode::TitleMismatch, "docs/a.md"), "{set:?}");
assert!(set
.iter()
.all(|i| i.reason != ReasonCode::DescriptionMismatch));
}
#[test]
fn 要約が一致しなければdescription_mismatchで空白は正規化しない() {
let a = typed("A", Some("x y"));
let files = [
("docs/index.md", "- [A](a.md) — x y\n"),
("docs/a.md", a.as_str()),
];
let set = issues(&files);
assert!(
has(&set, ReasonCode::DescriptionMismatch, "docs/a.md"),
"{set:?}"
);
assert!(set.iter().all(|i| i.reason != ReasonCode::TitleMismatch));
}
#[test]
fn 文書にdescriptionがなければdescription_mismatchを出さない() {
let a = typed("A", None);
let files = [
("docs/index.md", "- [A](a.md) — whatever\n"),
("docs/a.md", a.as_str()),
];
let set = issues(&files);
assert!(set
.iter()
.all(|i| i.reason != ReasonCode::DescriptionMismatch));
assert!(has(&set, ReasonCode::MissingDescription, "docs/a.md"));
}
#[test]
fn 同じ文書を指す複数エントリはすべて比較され_findingは重複しない() {
let a = typed("A", Some("D"));
let files = [
(
"docs/index.md",
"- [A](a.md) — D\n- [X](a.md) — D\n- [Y](a.md) — D\n",
),
("docs/a.md", a.as_str()),
];
let set = issues(&files);
assert_eq!(
count(&set, ReasonCode::TitleMismatch, "docs/a.md"),
1,
"{set:?}"
);
}
#[test]
fn descriptionがない_空_改行入りの文書はmissing_description() {
let files = [
("docs/index.md", "- [A](a.md)\n- [B](b.md)\n- [C](c.md)\n"),
("docs/a.md", "---\ntype: X\ntitle: A\n---\n"),
(
"docs/b.md",
"---\ntype: X\ntitle: B\ndescription: \"\"\n---\n",
),
(
"docs/c.md",
"---\ntype: X\ntitle: C\ndescription: |\n l1\n l2\n---\n",
),
];
let set = issues(&files);
for p in ["docs/a.md", "docs/b.md", "docs/c.md"] {
assert!(has(&set, ReasonCode::MissingDescription, p), "{p}: {set:?}");
}
}
#[test]
fn missing_descriptionは未列挙の文書にも出る() {
let files = [
("docs/index.md", "intro\n"),
("docs/a.md", "---\ntype: X\ntitle: A\n---\n"),
];
let set = issues(&files);
assert!(has(&set, ReasonCode::MissingDescription, "docs/a.md"));
assert!(has(&set, ReasonCode::NotListed, "docs/a.md"));
}
#[test]
fn 子の索引にdescriptionがなければmissing_directory_descriptionで対象は子の索引のパス() {
let b = typed("B", Some("DB"));
let sub = index_with(Some("S"), None);
let files = [
("docs/index.md", "- [S](sub/) — 何か\n"),
("docs/sub/index.md", sub.as_str()),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&files);
assert!(
has(
&set,
ReasonCode::MissingDirectoryDescription,
"docs/sub/index.md"
),
"{set:?}"
);
assert!(
set.iter()
.all(|i| i.reason != ReasonCode::DescriptionMismatch),
"{set:?}"
);
assert!(set.iter().all(|i| i.reason != ReasonCode::UnlinkedIndex));
}
#[test]
fn 子のdescriptionが空や改行入りでもmissing_directory_description() {
let b = typed("B", Some("DB"));
for desc in ["\"\"", "\"a\\nb\""] {
let sub = index_with(Some("S"), Some(desc));
let files = [
("docs/index.md", "- [S](sub/) — x\n"),
("docs/sub/index.md", sub.as_str()),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&files);
assert!(
has(
&set,
ReasonCode::MissingDirectoryDescription,
"docs/sub/index.md"
),
"{desc}: {set:?}"
);
}
}
#[test]
fn エントリの要約が空でも子にdescriptionがあればdescription_mismatchだけが出る() {
let b = typed("B", Some("DB"));
let sub = index_with(Some("S"), Some("DS"));
let files = [
("docs/index.md", "- [S](sub/)\n"),
("docs/sub/index.md", sub.as_str()),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&files);
assert!(
has(
&set,
ReasonCode::DescriptionMismatch,
"docs/index.md -> sub/"
),
"{set:?}"
);
assert!(
set.iter()
.all(|i| i.reason != ReasonCode::MissingDirectoryDescription),
"{set:?}"
);
}
#[test]
fn エントリの要約が空で子にもなければmissing_directory_descriptionだけが出る() {
let b = typed("B", Some("DB"));
let sub = index_with(Some("S"), None);
let files = [
("docs/index.md", "- [S](sub/)\n"),
("docs/sub/index.md", sub.as_str()),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&files);
assert_eq!(set.len(), 1, "{set:?}");
assert!(has(
&set,
ReasonCode::MissingDirectoryDescription,
"docs/sub/index.md"
));
}
#[test]
fn 子の索引と一致するディレクトリエントリは問題にならない() {
let b = typed("B", Some("DB"));
let sub = index_with(Some("S"), Some("DS"));
let files = [
("docs/index.md", "- [S](sub/) — DS\n"),
("docs/sub/index.md", sub.as_str()),
("docs/sub/b.md", b.as_str()),
];
assert!(issues(&files).is_empty());
}
#[test]
fn ディレクトリエントリのリンク文が子のtitleと違えばtitle_mismatch() {
let b = typed("B", Some("DB"));
let sub = index_with(Some("S"), Some("DS"));
let files = [
("docs/index.md", "- [sub](sub/) — DS\n"),
("docs/sub/index.md", sub.as_str()),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&files);
assert!(
has(&set, ReasonCode::TitleMismatch, "docs/index.md -> sub/"),
"{set:?}"
);
assert!(
detail_of(&set, ReasonCode::TitleMismatch, "docs/index.md -> sub/").contains("`S`")
);
assert_eq!(set.len(), 1, "{set:?}");
}
#[test]
fn ディレクトリエントリの要約が子のdescriptionと違えばdescription_mismatch() {
let b = typed("B", Some("DB"));
let sub = index_with(Some("S"), Some("DS"));
let files = [
("docs/index.md", "- [S](sub/) — 古い\n"),
("docs/sub/index.md", sub.as_str()),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&files);
assert!(
has(
&set,
ReasonCode::DescriptionMismatch,
"docs/index.md -> sub/"
),
"{set:?}"
);
assert_eq!(set.len(), 1, "{set:?}");
}
#[test]
fn 子にtitleがなければディレクトリ名と照合される() {
let b = typed("B", Some("DB"));
let sub = index_with(None, Some("DS"));
let ok = [
("docs/index.md", "- [sub](sub/) — DS\n"),
("docs/sub/index.md", sub.as_str()),
("docs/sub/b.md", b.as_str()),
];
assert!(issues(&ok).is_empty());
let ng = [
("docs/index.md", "- [other](sub/) — DS\n"),
("docs/sub/index.md", sub.as_str()),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&ng);
assert!(
has(&set, ReasonCode::TitleMismatch, "docs/index.md -> sub/"),
"{set:?}"
);
}
#[test]
fn 中間索引を飛ばしたディレクトリエントリも子の索引で判定する() {
let c = typed("C", Some("DC"));
let ab = index_with(Some("AB"), None);
let files = [
("docs/index.md", "- [AB](a/b/) — x\n"),
("docs/a/b/index.md", ab.as_str()),
("docs/a/b/c.md", c.as_str()),
];
let set = issues(&files);
assert!(
has(
&set,
ReasonCode::MissingDirectoryDescription,
"docs/a/b/index.md"
),
"{set:?}"
);
assert!(set.iter().all(|i| i.reason != ReasonCode::UnlinkedIndex));
}
#[test]
fn 親から辿れない子の索引はunlinked_index() {
let b = typed("B", Some("DB"));
let files = [
("docs/index.md", "intro\n"),
("docs/sub/index.md", "- [B](b.md) — DB\n"),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&files);
assert!(
has(&set, ReasonCode::UnlinkedIndex, "docs/sub/index.md"),
"{set:?}"
);
}
#[test]
fn リンク文と要約を文書の値で置き換え区切りは既存を保つ() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let files = ["docs/a.md"];
assert_eq!(
upd("- [Old](a.md) - old\n", &docs, &files, &[]),
"- [A](a.md) - D\n"
);
assert_eq!(
upd("- [Old](a.md) : old\n", &docs, &files, &[]),
"- [A](a.md) : D\n"
);
assert_eq!(
upd("- [Old](a.md)\n", &docs, &files, &[]),
"- [A](a.md) — D\n"
);
}
#[test]
fn descriptionのない文書は区切りも要約も付けない() {
let docs = [doc("docs/a.md", "A", None)];
assert_eq!(
upd("- [Old](a.md) — x\n", &docs, &["docs/a.md"], &[]),
"- [A](a.md)\n"
);
}
#[test]
fn リンク先のソースは変更しない() {
let docs = [doc("docs/a b.md", "A", Some("D"))];
let files = ["docs/a b.md"];
assert_eq!(
upd("- [Old](<a b.md> \"t\") — x\n", &docs, &files, &[]),
"- [A](<a b.md> \"t\") — D\n"
);
assert_eq!(
upd("- [Old](a%20b.md) — x\n", &docs, &files, &[]),
"- [A](a%20b.md) — D\n"
);
}
#[test]
fn 参照形式の定義とラベルは変更しない() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let files = ["docs/a.md"];
assert_eq!(
upd("- [Old][r] — x\n\n[r]: a.md\n", &docs, &files, &[]),
"- [A][r] — D\n\n[r]: a.md\n"
);
let docs = [doc("docs/a.md", "Old", Some("D"))];
assert_eq!(
upd("- [Old][] — x\n\n[Old]: a.md\n", &docs, &files, &[]),
"- [Old][] — D\n\n[Old]: a.md\n"
);
assert_eq!(
upd("- [Old] — x\n\n[Old]: a.md\n", &docs, &files, &[]),
"- [Old] — D\n\n[Old]: a.md\n"
);
}
#[test]
fn タイトルの角括弧はエスケープされる() {
let docs = [doc("docs/a.md", "a[1]", Some("D"))];
assert_eq!(
upd("- [Old](a.md) — x\n", &docs, &["docs/a.md"], &[]),
"- [a\\[1\\]](a.md) — D\n"
);
}
#[test]
fn 複数行エントリは1行に畳まれる() {
let docs = [doc("docs/a.md", "A", Some("first second"))];
let text = "- [A](a.md) — first\n second\n- [B](b.md) — DB\n";
let docs = [docs[0].clone(), doc("docs/b.md", "B", Some("DB"))];
assert_eq!(
upd(text, &docs, &["docs/a.md", "docs/b.md"], &[]),
"- [A](a.md) — first second\n- [B](b.md) — DB\n"
);
}
#[test]
fn 同じ文書を指す重複エントリはすべて書き換える() {
let docs = [doc("docs/a.md", "A", Some("D"))];
assert_eq!(
upd(
"- [x](a.md) — 1\n- [y](./a.md) — 2\n",
&docs,
&["docs/a.md"],
&[]
),
"- [A](a.md) — D\n- [A](./a.md) — D\n"
);
}
#[test]
fn すでに一致していればバイト列は変わらない() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let text = "- [A](a.md) — D\n";
assert_eq!(upd(text, &docs, &["docs/a.md"], &[]), text);
}
#[test]
fn bomは書き換え後も保存される() {
let docs = [doc("docs/a.md", "A", Some("D"))];
assert_eq!(
upd("\u{feff}- [Old](a.md) — x\n", &docs, &["docs/a.md"], &[]),
"\u{feff}- [A](a.md) — D\n"
);
}
#[test]
fn 未列挙の文書がパス順に末尾へ追加される() {
let docs = [
doc("docs/c.md", "C", None),
doc("docs/a.md", "A", Some("DA")),
doc("docs/b.md", "B", Some("DB")),
];
let text = "- [A](a.md) — DA\n";
let files = ["docs/a.md", "docs/b.md", "docs/c.md"];
assert_eq!(
upd(text, &docs, &files, &[]),
"- [A](a.md) — DA\n- [B](b.md) — DB\n- [C](c.md)\n"
);
}
#[test]
fn 追加するパスは相対でエンコードされタイトルはエスケープされる() {
let docs = [doc("docs/sub/a b(c)<d>%e#f\\g.md", "t[1]", Some("D"))];
let files = ["docs/sub/a b(c)<d>%e#f\\g.md", "docs/x.md"];
assert_eq!(
upd("- [x](x.md)\n", &docs, &files, &[]),
"- [x](x.md)\n- [t\\[1\\]](sub/a%20b%28c%29%3Cd%3E%25e%23f%5Cg.md) — D\n"
);
}
#[test]
fn 末尾の改行と空行の規則() {
let docs = [doc("docs/b.md", "B", Some("DB"))];
let files = ["docs/b.md", "docs/x.md"];
let item = "- [B](b.md) — DB\n";
assert_eq!(
upd("- [x](x.md)", &docs, &files, &[]),
format!("- [x](x.md)\n{item}")
);
assert_eq!(
upd("# Index\n\nintro\n", &docs, &files, &[]),
format!("# Index\n\nintro\n\n{item}")
);
assert_eq!(upd("intro", &docs, &files, &[]), format!("intro\n\n{item}"));
assert_eq!(upd("1. x\n", &docs, &files, &[]), format!("1. x\n{item}"));
assert_eq!(upd("* x\n", &docs, &files, &[]), format!("* x\n{item}"));
assert_eq!(upd("", &docs, &files, &[]), item);
}
#[test]
fn 改行コードは既存本文にcrlfがあればcrlfになる() {
let docs = [doc("docs/b.md", "B", Some("DB"))];
assert_eq!(
upd("- [x](x.md)\r\n", &docs, &["docs/b.md", "docs/x.md"], &[]),
"- [x](x.md)\r\n- [B](b.md) — DB\r\n"
);
assert_eq!(
upd("# T\r\n\r\nintro\r\n", &docs, &["docs/b.md"], &[]),
"# T\r\n\r\nintro\r\n\r\n- [B](b.md) — DB\r\n"
);
}
#[test]
fn 辿れない子の索引はディレクトリエントリとして文書の後に追加される() {
let docs = [doc("docs/a.md", "A", Some("DA"))];
let metas = [
meta("docs/zeta/index.md", "Zeta [z]", Some("DZ")),
meta("docs/sub/index.md", "Sub", Some("DS")),
];
let out = upd_meta(
"- [x](x.md)\n",
&docs,
&metas,
&["docs/a.md", "docs/x.md"],
&["docs/zeta/index.md", "docs/sub/index.md"],
);
assert_eq!(
out,
"- [x](x.md)\n- [A](a.md) — DA\n- [Sub](sub/) — DS\n- [Zeta \\[z\\]](zeta/) — DZ\n"
);
}
#[test]
fn 子に要約がなければ追加するディレクトリエントリは区切りを付けない() {
let metas = [
meta("docs/sub/index.md", "sub", None),
meta("docs/two/index.md", "Two", None),
];
let out = upd_meta(
"- [x](x.md)\n",
&[],
&metas,
&["docs/x.md"],
&["docs/sub/index.md", "docs/two/index.md"],
);
assert_eq!(out, "- [x](x.md)\n- [sub](sub/)\n- [Two](two/)\n");
}
#[test]
fn ディレクトリエントリのリンク文と要約を子の索引の値で置き換える() {
let metas = [meta("docs/sub/index.md", "Sub", Some("DS"))];
let files = ["docs/sub/index.md"];
let out = upd_meta("- [old](sub/) - 古い\n", &[], &metas, &files, &[]);
assert_eq!(out, "- [Sub](sub/) - DS\n");
let out = upd_meta("- [old](<sub/> \"t\")\n", &[], &metas, &files, &[]);
assert_eq!(out, "- [Sub](<sub/> \"t\") — DS\n");
let same = "- [Sub](sub/) — DS\n";
assert_eq!(upd_meta(same, &[], &metas, &files, &[]), same);
}
#[test]
fn 子に要約がなければディレクトリエントリの後ろは空になる() {
let metas = [meta("docs/sub/index.md", "sub", None)];
let out = upd_meta(
"- [sub](sub/) — 古い\n",
&[],
&metas,
&["docs/sub/index.md"],
&[],
);
assert_eq!(out, "- [sub](sub/)\n");
}
#[test]
fn ディレクトリエントリの折り畳み参照はリンク文を保ち後ろだけ更新する() {
let metas = [meta("docs/sub/index.md", "Sub", Some("DS"))];
let out = upd_meta(
"- [sub][] — x\n\n[sub]: sub/\n",
&[],
&metas,
&["docs/sub/index.md"],
&[],
);
assert_eq!(out, "- [sub][] — DS\n\n[sub]: sub/\n");
}
#[test]
fn ディレクトリエントリの書き換えは冪等() {
let metas = [meta("docs/sub/index.md", "Sub", Some("DS"))];
let files = ["docs/sub/index.md"];
let once = upd_meta("- [x](sub/) — y\n", &[], &metas, &files, &[]);
let twice = upd_meta(&once, &[], &metas, &files, &[]);
assert_eq!(once, twice);
assert_eq!(once, "- [Sub](sub/) — DS\n");
}
#[test]
fn 存在しない文書へのエントリ行は削除される() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let text = "- [A](a.md) — D\n- [G](gone.md) — g\n- [A](a.md) — D\n";
assert_eq!(
upd(text, &docs, &["docs/a.md"], &[]),
"- [A](a.md) — D\n- [A](a.md) — D\n"
);
}
#[test]
fn 削除した項目の子項目は残る() {
let docs = [
doc("docs/a.md", "A", Some("D")),
doc("docs/b.md", "B", Some("DB")),
];
let files = ["docs/a.md", "docs/b.md"];
let text = "- [G](gone.md) — g\n - [A](a.md) — D\n- [B](b.md) — DB\n";
assert_eq!(
upd(text, &docs, &files, &[]),
" - [A](a.md) — D\n- [B](b.md) — DB\n"
);
}
#[test]
fn 削除時もbomは保存される() {
let docs = [doc("docs/a.md", "A", Some("D"))];
assert_eq!(
upd(
"\u{feff}- [G](gone.md)\n- [A](a.md) — D\n",
&docs,
&["docs/a.md"],
&[]
),
"\u{feff}- [A](a.md) — D\n"
);
}
#[test]
fn 除外配下_typeなし_解決不能_ディレクトリは削除しない() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let text = "- [A](a.md) — D\n\
- [n](node_modules/x.md) — 1\n\
- [u](untyped.md) — 2\n\
- [o](../req/x.md) — 3\n\
- [d](sub/) — 4\n\
- [e](#x) — 5\n";
let files = ["docs/a.md", "docs/untyped.md"];
assert_eq!(upd(text, &docs, &files, &[]), text);
}
#[test]
fn 索引がなければ見出しなしの平坦なリストを作る() {
let docs = [
doc("docs/sub/b.md", "B", None),
doc("docs/a.md", "A", Some("DA")),
];
let out = upd("", &docs, &["docs/a.md", "docs/sub/b.md"], &[]);
assert_eq!(out, "- [A](a.md) — DA\n- [B](sub/b.md)\n");
assert!(!out.contains('#'));
}
#[test]
fn 新規作成でも子索引のディレクトリエントリは子のtitleと要約を使う() {
let docs = [doc("docs/a.md", "A", Some("DA"))];
let metas = [
meta("docs/sub/index.md", "Sub", Some("DS")),
meta("docs/two/index.md", "two", None),
];
let out = upd_meta(
"",
&docs,
&metas,
&["docs/a.md", "docs/sub/index.md", "docs/two/index.md"],
&["docs/sub/index.md", "docs/two/index.md"],
);
assert_eq!(out, "- [A](a.md) — DA\n- [Sub](sub/) — DS\n- [two](two/)\n");
}
#[test]
fn エントリ行以外のバイト列は変更されない() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let files = ["docs/a.md", "docs/sub/index.md"];
let metas = [meta("docs/sub/index.md", "sub", Some("S"))];
let text = "\u{feff}# Index\r\n\r\nintro text\r\n\r\n## Sec\r\n\r\n- [Old](a.md) — x\r\n - child [c](c.md)\r\n- **[b](b.md)** — bold\r\n- [sub](sub/) — S\r\n";
let want = "\u{feff}# Index\r\n\r\nintro text\r\n\r\n## Sec\r\n\r\n- [A](a.md) — D\r\n - child [c](c.md)\r\n- **[b](b.md)** — bold\r\n- [sub](sub/) — S\r\n";
assert_eq!(upd_meta(text, &docs, &metas, &files, &[]), want);
}
#[test]
fn 更新は冪等で2回目は同一文字列になる() {
let docs = [
doc("docs/a.md", "A", Some("D")),
doc("docs/b.md", "B[1]", Some("DB")),
doc("docs/c d.md", "C", None),
];
let files = ["docs/a.md", "docs/b.md", "docs/c d.md"];
let text = "# Index\n\n- [Old](a.md) - x\n y\n- [G](gone.md)\n";
let once = upd(text, &docs, &files, &[]);
let twice = upd(&once, &docs, &files, &[]);
assert_eq!(once, twice);
}
#[test]
fn 参照形式とショートカットの後ろから要約を切り出す() {
let text = "- [T][ref] — 要約1\n- [U] — 要約2\n\n[ref]: a.md\n[U]: b.md\n";
let es = parse_entries(text);
assert_eq!(es.len(), 2);
assert_eq!(es[0].description, "要約1");
assert_eq!(es[1].description, "要約2");
}
#[test]
fn パーセントエンコードされたドットドットはデコード後に正規化される() {
assert_eq!(
res("docs/sub/index.md", "%2E%2E/a.md", &["docs/a.md"]),
Resolution::File("docs/a.md".to_string())
);
assert_eq!(
res("docs/index.md", "%2E%2E/%2E%2E/x.md", &[]),
Resolution::Unresolvable("escapes repository")
);
}
#[test]
fn フラグメント付きの索引ファイル指定もディレクトリエントリになる() {
assert_eq!(
res("docs/index.md", "sub/index.md#frag", &["docs/sub/index.md"]),
Resolution::Directory("docs/sub".to_string())
);
}
#[test]
fn setextのレベル2下線はh1として扱わない() {
let t = fm("---\ntype: X\n---\nIntro\n---\n");
assert_eq!(document_title("docs/foo.md", &t), "foo");
}
#[test]
fn 改行を含むtitleは無いものとしてh1にフォールバックする() {
let t = fm("---\ntype: X\ntitle: \"a\\nb\"\n---\n# Heading\n");
assert_eq!(document_title("docs/foo.md", &t), "Heading");
let t = fm("---\ntype: X\ntitle: \"a\\nb\"\n---\n");
assert_eq!(document_title("docs/foo.md", &t), "foo");
}
#[test]
fn missing_index時は索引側の6種のfindingがすべて出ない() {
let a = typed("A", Some("DA"));
let b = typed("B", Some("DB"));
let files = [
("docs/a.md", a.as_str()),
(
"docs/sub/index.md",
"- [X](../a.md) — other\n- [gone](gone.md)\n- [d](dir/)\n- [e](dir2/)\n",
),
("docs/sub/dir/index.md", "intro\n"),
("docs/sub/dir/b.md", b.as_str()),
("docs/sub/dir2/index.md", "intro\n"),
];
let set = issues(&files);
assert!(has(&set, ReasonCode::MissingIndex, "docs"), "{set:?}");
for r in [
ReasonCode::NotListed,
ReasonCode::BrokenEntry,
ReasonCode::TitleMismatch,
ReasonCode::DescriptionMismatch,
ReasonCode::MissingDirectoryDescription,
ReasonCode::UnlinkedIndex,
] {
assert!(set.iter().all(|i| i.reason != r), "{r:?} in {set:?}");
}
}
#[test]
fn 索引自身を指すエントリは自己か祖先として壊れている() {
let a = typed("A", Some("D"));
let files = [
(
"docs/index.md",
"- [A](a.md) — D\n- [me](index.md)\n- [s](sub/index.md)\n",
),
("docs/a.md", a.as_str()),
];
let set = issues(&files);
assert_eq!(
detail_of(&set, ReasonCode::BrokenEntry, "docs/index.md -> index.md"),
"self or ancestor directory"
);
assert_eq!(
detail_of(
&set,
ReasonCode::BrokenEntry,
"docs/index.md -> sub/index.md"
),
"directory has no index.md"
);
}
#[test]
fn 同じ文書を指す2本目だけ不一致でもfindingが出る() {
let a = typed("A", Some("D"));
let files = [
("docs/index.md", "- [A](a.md) — D\n- [X](a.md) — Y\n"),
("docs/a.md", a.as_str()),
];
let set = issues(&files);
assert!(has(&set, ReasonCode::TitleMismatch, "docs/a.md"), "{set:?}");
assert!(has(&set, ReasonCode::DescriptionMismatch, "docs/a.md"));
}
#[test]
fn 区切りのen_dashは書き戻しで保持される() {
let docs = [doc("docs/a.md", "A", Some("D"))];
assert_eq!(
upd("- [Old](a.md) – old\n", &docs, &["docs/a.md"], &[]),
"- [A](a.md) – D\n"
);
}
#[test]
fn 折り畳み参照のラベルは変えず要約だけ更新されtitle_mismatchが残る() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let text = "- [旧][] — x\n\n[旧]: a.md\n";
let out = upd(text, &docs, &["docs/a.md"], &[]);
assert_eq!(out, "- [旧][] — D\n\n[旧]: a.md\n");
let a = typed("A", Some("D"));
let set = issues(&[("docs/index.md", out.as_str()), ("docs/a.md", a.as_str())]);
assert!(has(&set, ReasonCode::TitleMismatch, "docs/a.md"), "{set:?}");
assert!(set
.iter()
.all(|i| i.reason != ReasonCode::DescriptionMismatch));
}
#[test]
fn 直前に項目がある状態で削除すると子項目は直前の項目の下に残る() {
let docs = [
doc("docs/a.md", "A", Some("D")),
doc("docs/b.md", "B", Some("DB")),
];
let files = ["docs/a.md", "docs/b.md"];
let text = "- [A](a.md) — D\n- [G](gone.md) — g\n - [B](b.md) — DB\n";
assert_eq!(
upd(text, &docs, &files, &[]),
"- [A](a.md) — D\n - [B](b.md) — DB\n"
);
}
#[test]
fn 複数行エントリの削除で末尾行まで消える() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let text = "- [G](gone.md) — first\n second line\n- [A](a.md) — D\n";
assert_eq!(upd(text, &docs, &["docs/a.md"], &[]), "- [A](a.md) — D\n");
}
#[test]
fn 書き込みはrename失敗時に同じディレクトリの一時ファイルを残さない() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("index.md");
std::fs::create_dir(&target).unwrap();
assert!(write_atomic(&target, "x").is_err());
let names: Vec<String> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["index.md"]);
let ok = dir.path().join("ok.md");
write_atomic(&ok, "y").unwrap();
assert_eq!(std::fs::read_to_string(&ok).unwrap(), "y");
assert!(std::fs::read_dir(dir.path()).unwrap().all(|e| !e
.unwrap()
.file_name()
.to_string_lossy()
.ends_with(".tmp")));
}
#[test]
fn エスケープされた角括弧を含むリンク文は壊れずに冪等に書き換わる() {
let docs = [doc("docs/a.md", "[x] y", Some("D"))];
let files = ["docs/a.md"];
let once = upd("- [\\[x\\]](a.md) — d\n", &docs, &files, &[]);
assert_eq!(once, "- [\\[x\\] y](a.md) — D\n");
assert_eq!(upd(&once, &docs, &files, &[]), once);
}
#[test]
fn 各リンク形式でリンク文の範囲は元のソースの閉じ括弧までになる() {
let e = &parse_entries("- [a\\]b](a.md \"t\") — d\n")[0];
assert_eq!(e.link_text, "a\\]b");
let e = &parse_entries("- [a *b*][r] — d\n\n[r]: a.md\n")[0];
assert_eq!(e.link_text, "a *b*");
assert_eq!(e.description, "d");
let e = &parse_entries("- [a\\]][] — d\n\n[a\\]]: a.md\n")[0];
assert_eq!(e.link_text, "a\\]");
assert_eq!(e.description, "d");
let e = &parse_entries("- [a] — d\n\n[a]: a.md\n")[0];
assert_eq!(e.link_text, "a");
assert_eq!(e.description, "d");
let e = &parse_entries("- [](a.md) — d\n")[0];
assert_eq!(e.link_text, "");
}
#[test]
fn ハードブレークとソフトブレークの直後の要約は1行に畳まれる() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let files = ["docs/a.md"];
assert_eq!(
upd("- [a](a.md) \n hard\n", &docs, &files, &[]),
"- [A](a.md) — D\n"
);
assert_eq!(
upd("- [a](a.md)\n soft\n", &docs, &files, &[]),
"- [A](a.md) — D\n"
);
}
#[test]
fn バックスラッシュはエスケープされ再パースで元のタイトルに戻る() {
let docs = [doc("docs/a.md", "a\\", Some("D"))];
let out = upd("", &docs, &["docs/a.md"], &[]);
assert_eq!(out, "- [a\\\\](a.md) — D\n");
let e = &parse_entries(&out)[0];
assert_eq!(unescape_brackets(&e.link_text), "a\\");
assert_eq!(upd(&out, &docs, &["docs/a.md"], &[]), out);
assert_eq!(unescape_brackets("\\\\ \\[ \\] \\x"), "\\ [ ] \\x");
}
#[test]
fn 要約の角括弧とバックスラッシュもエスケープされ比較で不一致にならない() {
let docs = [doc("docs/a.md", "T", Some("a [b] c\\d"))];
let files = ["docs/a.md"];
let once = upd("- [T](a.md) — old\n", &docs, &files, &[]);
assert_eq!(once, "- [T](a.md) — a \\[b\\] c\\\\d\n");
let e = &parse_entries(&once)[0];
assert_eq!(unescape_brackets(&e.description), "a [b] c\\d");
assert_eq!(upd(&once, &docs, &files, &[]), once);
}
#[test]
fn 除外名の配下を指す存在しないエントリ行は削除されない() {
let docs = [doc("docs/a.md", "A", Some("D"))];
let files = ["docs/a.md"];
let text = "- [A](a.md) — D\n- [X](vendor/x.md) — x\n- [G](gone.md) — g\n";
let excluded = vec!["vendor".to_string()];
let out = update_index(
text,
"docs/index.md",
&tree_of(&files),
&docs,
&[],
&RepoFiles::new(files.iter().copied()),
&[],
&excluded,
);
assert_eq!(out, "- [A](a.md) — D\n- [X](vendor/x.md) — x\n");
}
#[test]
fn 改行crを含むdescriptionは要約なし() {
let files = [("docs/cr.md", "---\ntype: X\ndescription: \"a\\rb\"\n---\n")];
let docs = collect(&files).documents;
assert_eq!(docs[0].description, None);
}
#[test]
fn 子の索引のフロントマターが壊れていればタイトルと要約の照合をしない() {
let b = typed("B", Some("DB"));
let files = [
("docs/index.md", "- [S](sub/) — 何か\n"),
(
"docs/sub/index.md",
"---\ntitle: [\n---\n- [B](b.md) — DB\n",
),
("docs/sub/b.md", b.as_str()),
];
let set = issues(&files);
for r in [
ReasonCode::TitleMismatch,
ReasonCode::DescriptionMismatch,
ReasonCode::MissingDirectoryDescription,
] {
assert!(set.iter().all(|i| i.reason != r), "{set:?}");
}
}
#[test]
fn 子の索引のフロントマターが壊れていれば親のエントリを書き換えない() {
let files = ["docs/index.md", "docs/sub/index.md"];
let mut m = meta("docs/sub/index.md", "sub", None);
m.invalid = true;
let text = "- [Old](sub/) — 古い\n";
assert_eq!(upd_meta(text, &[], &[m.clone()], &files, &[]), text);
assert_eq!(upd_meta("", &[], &[m], &files, &["docs/sub/index.md"]), "");
}
#[test]
fn 最初のh1が空なら次のh1を探さずファイル名にフォールバックする() {
let t = fm("---\ntype: X\n---\n#\n\n# Second\n");
assert_eq!(document_title("docs/foo.md", &t), "foo");
}
#[test]
fn 強調を含むリンク文はソース文字列のまま扱われる() {
let e = &parse_entries("- [*a*](x.md)\n")[0];
assert_eq!(e.link_text, "*a*");
}
#[test]
fn 山括弧とtitle付きの宛先のbroken_entryのtargetは解釈後のdestinationになる() {
let files = [
(
"docs/index.md",
"- [x](<gone file.md> \"t\")\n- [a](a.md)\n",
),
("docs/a.md", &typed("a", Some("d"))),
];
let set = issues(&files);
assert_eq!(
detail_of(
&set,
ReasonCode::BrokenEntry,
"docs/index.md -> gone file.md"
),
"target does not exist"
);
}
#[test]
fn 根が子孫を列挙していても直近の親が列挙していなければunlinked_indexになる() {
let files = [
("docs/index.md", "- [a](a/) — A\n- [a/b](a/b/) — B\n"),
("docs/a/index.md", "- [x](x.md)\n"),
("docs/a/x.md", &typed("x", Some("d"))),
("docs/a/b/index.md", "- [y](y.md)\n"),
("docs/a/b/y.md", &typed("y", Some("d"))),
];
let set = issues(&files);
assert!(has(&set, ReasonCode::UnlinkedIndex, "docs/a/b/index.md"));
assert!(!has(&set, ReasonCode::UnlinkedIndex, "docs/a/index.md"));
}
#[test]
fn 子索引の追加は直近の親の索引の末尾にだけ行われる() {
let files = [
"docs/index.md",
"docs/a/index.md",
"docs/a/b/index.md",
"docs/a/x.md",
];
let tree = tree_of(&files);
let repo = RepoFiles::new(files.iter().copied());
let child = vec!["docs/a/b/index.md".to_string()];
let metas = [
meta("docs/a/index.md", "A", Some("DA")),
meta("docs/a/b/index.md", "B", Some("DB")),
];
let out = update_index(
"- [x](x.md)\n",
"docs/a/index.md",
&tree,
&[],
&metas,
&repo,
&child,
&test_excluded(),
);
assert_eq!(out, "- [x](x.md)\n- [B](b/) — DB\n");
let root = update_index(
"- [A](a/) — DA\n",
"docs/index.md",
&tree,
&[],
&metas,
&repo,
&[],
&test_excluded(),
);
assert_eq!(root, "- [A](a/) — DA\n");
}
}