blob: 59f6f3fc1e40e35420518e0509012324f51e3216 [file] [log] [blame]
//! A `mdbook` backend which will check all links in a document are valid.
//!
//! The link-checking process has roughly three stages:
//!
//! 1. Find all the links in a body of markdown text (see [`extract_links`])
//! 2. Validate all the links we've found, taking into account cached results
//! and configuration options
//! 3. Cache the results in the output directory for reuse by step 2 in the next
//! round
//! 4. Emit errors/warnings to the user
#![deny(
intra_doc_link_resolution_failure,
missing_docs,
missing_debug_implementations,
missing_copy_implementations
)]
#[cfg(test)]
#[macro_use]
extern crate pretty_assertions;
/// A semver range specifying which versions of `mdbook` this crate supports.
pub const COMPATIBLE_MDBOOK_VERSIONS: &str = "^0.3.0";
mod cache;
mod config;
mod links;
mod validate;
pub use crate::{
cache::Cache,
config::{Config, WarningPolicy},
links::{extract as extract_links, IncompleteLink, Link},
validate::{
validate, InvalidLink, Reason, UnknownScheme, ValidationOutcome,
},
};
use codespan::{FileId, Files};
use codespan_reporting::{
diagnostic::{Diagnostic, Severity},
term::termcolor::{ColorChoice, StandardStream},
};
use failure::{Error, ResultExt};
use mdbook::{
book::{Book, BookItem},
renderer::RenderContext,
};
use semver::{Version, VersionReq};
use std::{fs::File, path::Path};
/// Run the link checking pipeline.
pub fn run(
cache_file: &Path,
colour: ColorChoice,
ctx: &RenderContext,
) -> Result<(), Error> {
let cache = load_cache(cache_file);
log::info!("Started the link checker");
let cfg = crate::get_config(&ctx.config)?;
crate::version_check(&ctx.version)?;
if log::log_enabled!(log::Level::Trace) {
for line in format!("{:#?}", cfg).lines() {
log::trace!("{}", line);
}
}
let (files, outcome) = check_links(&ctx, &cache, &cfg).compat()?;
log::debug!(
"cache hits: {}, cache misses: {}",
cache.cache_hits(),
cache.cache_misses()
);
let diags = outcome.generate_diagnostics(&files, cfg.warning_policy);
report_errors(&files, &diags, colour).compat()?;
save_cache(cache_file, &cache);
if diags.iter().any(|diag| diag.severity >= Severity::Error) {
log::info!("{} broken links found", outcome.invalid_links.len());
Err(failure::err_msg("One or more incorrect links"))
} else {
log::info!("No broken links found");
Ok(())
}
}
/// Get the configuration used by `mdbook-linkcheck`.
pub fn get_config(cfg: &mdbook::Config) -> Result<Config, Error> {
match cfg.get("output.linkcheck") {
Some(raw) => raw
.clone()
.try_into()
.context("Unable to deserialize the `output.linkcheck` table.")
.map_err(Error::from),
None => Ok(Config::default()),
}
}
/// Check whether this library is compatible with the provided version string.
pub fn version_check(version: &str) -> Result<(), Error> {
let constraints = VersionReq::parse(COMPATIBLE_MDBOOK_VERSIONS)?;
let found = Version::parse(version)?;
if constraints.matches(&found) {
Ok(())
} else {
let msg = format!(
"mdbook-linkcheck isn't compatible with this version of mdbook ({} is not in the range {})",
found, constraints
);
Err(failure::err_msg(msg))
}
}
/// A helper for reading the chapters of a [`Book`] into memory.
pub fn load_files_into_memory(book: &Book, dest: &mut Files) -> Vec<FileId> {
let mut ids = Vec::new();
for item in book.iter() {
match item {
BookItem::Chapter(ref ch) => {
let id = dest.add(ch.path.display().to_string(), &ch.content);
ids.push(id);
},
BookItem::Separator => {},
}
}
ids
}
fn report_errors(
files: &Files,
diags: &[Diagnostic],
colour: ColorChoice,
) -> Result<(), Error> {
let mut writer = StandardStream::stderr(colour);
let cfg = codespan_reporting::term::Config::default();
for diag in diags {
codespan_reporting::term::emit(&mut writer, &cfg, files, diag)?;
}
Ok(())
}
fn check_links(
ctx: &RenderContext,
cache: &Cache,
cfg: &Config,
) -> Result<(Files, ValidationOutcome), Error> {
log::info!("Scanning book for links");
let mut files = Files::new();
let file_ids = crate::load_files_into_memory(&ctx.book, &mut files);
let (links, incomplete_links) = crate::extract_links(file_ids, &files);
log::info!(
"Found {} links ({} incomplete links)",
links.len(),
incomplete_links.len()
);
let src = dunce::canonicalize(ctx.source_dir())
.context("Unable to resolve the source directory")?;
let outcome =
crate::validate(&links, &cfg, &src, &cache, &files, incomplete_links)?;
Ok((files, outcome))
}
fn load_cache(filename: &Path) -> Cache {
log::debug!("Loading cache from {}", filename.display());
match File::open(filename) {
Ok(f) => match Cache::load(f) {
Ok(cache) => cache,
Err(e) => {
log::warn!("Unable to deserialize the cache: {}", e);
Cache::default()
},
},
Err(e) => {
log::debug!("Unable to open the cache: {}", e);
Cache::default()
},
}
}
fn save_cache(filename: &Path, cache: &Cache) {
if let Some(parent) = filename.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Unable to create the cache's directory: {}", e);
}
}
log::debug!("Saving the cache to {}", filename.display());
match File::create(filename) {
Ok(f) => {
if let Err(e) = cache.save(f) {
log::warn!("Saving the cache as JSON failed: {}", e);
}
},
Err(e) => log::warn!("Unable to create the cache file: {}", e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn always_stay_compatible_with_mdbook_dependency() {
version_check(mdbook::MDBOOK_VERSION).unwrap();
}
}