blob: ae614ed69d3b52f4cc3120585e1690cbe816d9c5 [file] [log] [blame]
#![deny(rust_2018_idioms, unused_lifetimes)]
use crate::rules::Rules;
use anyhow::{bail, Context, Result};
use mdbook::book::{Book, Chapter};
use mdbook::errors::Error;
use mdbook::preprocess::{CmdPreprocessor, Preprocessor, PreprocessorContext};
use mdbook::BookItem;
use once_cell::sync::Lazy;
use regex::{Captures, Regex};
use semver::{Version, VersionReq};
use std::io;
use std::path::PathBuf;
mod rules;
mod std_links;
mod test_links;
/// The Regex for the syntax for blockquotes that have a specific CSS class,
/// like `> [!WARNING]`.
static ADMONITION_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^ *> \[!(?<admon>[^]]+)\]\n(?<blockquote>(?: *>.*\n)+)").unwrap()
});
pub fn handle_preprocessing() -> Result<(), Error> {
let pre = Spec::new(None)?;
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
let book_version = Version::parse(&ctx.mdbook_version)?;
let version_req = VersionReq::parse(mdbook::MDBOOK_VERSION)?;
if !version_req.matches(&book_version) {
eprintln!(
"warning: The {} plugin was built against version {} of mdbook, \
but we're being called from version {}",
pre.name(),
mdbook::MDBOOK_VERSION,
ctx.mdbook_version
);
}
let processed_book = pre.run(&ctx, book)?;
serde_json::to_writer(io::stdout(), &processed_book)?;
Ok(())
}
pub struct Spec {
/// Whether or not warnings should be errors (set by SPEC_DENY_WARNINGS
/// environment variable).
deny_warnings: bool,
/// Path to the rust-lang/rust git repository (set by SPEC_RUST_ROOT
/// environment variable).
rust_root: Option<PathBuf>,
/// The git ref that can be used in a URL to the rust-lang/rust repository.
git_ref: String,
}
impl Spec {
/// Creates a new `Spec` preprocessor.
///
/// The `rust_root` parameter specifies an optional path to the root of
/// the rust git checkout. If `None`, it will use the `SPEC_RUST_ROOT`
/// environment variable. If the root is not specified, then no tests will
/// be linked unless `SPEC_DENY_WARNINGS` is set in which case this will
/// return an error..
pub fn new(rust_root: Option<PathBuf>) -> Result<Spec> {
let deny_warnings = std::env::var("SPEC_DENY_WARNINGS").as_deref() == Ok("1");
let rust_root = rust_root.or_else(|| std::env::var_os("SPEC_RUST_ROOT").map(PathBuf::from));
if deny_warnings && rust_root.is_none() {
bail!("SPEC_RUST_ROOT environment variable must be set");
}
let git_ref = match git_ref(&rust_root) {
Ok(s) => s,
Err(e) => {
if deny_warnings {
eprintln!("error: {e:?}");
std::process::exit(1);
} else {
eprintln!("warning: {e:?}");
"master".into()
}
}
};
Ok(Spec {
deny_warnings,
rust_root,
git_ref,
})
}
/// Generates link references to all rules on all pages, so you can easily
/// refer to rules anywhere in the book.
fn auto_link_references(&self, chapter: &Chapter, rules: &Rules) -> String {
let current_path = chapter.path.as_ref().unwrap().parent().unwrap();
let definitions: String = rules
.def_paths
.iter()
.map(|(rule_id, (_, path))| {
let relative = pathdiff::diff_paths(path, current_path).unwrap();
// Adjust paths for Windows.
let relative = relative.display().to_string().replace('\\', "/");
format!("[{rule_id}]: {}#r-{rule_id}\n", relative)
})
.collect();
format!(
"{}\n\
{definitions}",
chapter.content
)
}
/// Converts blockquotes with special headers into admonitions.
///
/// The blockquote should look something like:
///
/// ```markdown
/// > [!WARNING]
/// > ...
/// ```
///
/// This will add a `<div class="warning">` around the blockquote so that
/// it can be styled differently. Any text between the brackets that can
/// be a CSS class is valid. The actual styling needs to be added in a CSS
/// file.
fn admonitions(&self, chapter: &Chapter) -> String {
ADMONITION_RE
.replace_all(&chapter.content, |caps: &Captures<'_>| {
let lower = caps["admon"].to_lowercase();
let term = to_initial_case(&caps["admon"]);
let blockquote = &caps["blockquote"];
let initial_spaces = blockquote.chars().position(|ch| ch != ' ').unwrap_or(0);
let space = &blockquote[..initial_spaces];
format!(
"{space}<div class=\"{lower}\">\n\
\n\
{space}> ***{term}:***\n\
{blockquote}\n\
\n\
{space}</div>\n",
)
})
.to_string()
}
}
fn to_initial_case(s: &str) -> String {
let mut chars = s.chars();
let first = chars.next().expect("not empty").to_uppercase();
let rest = chars.as_str().to_lowercase();
format!("{first}{rest}")
}
/// Determines the git ref used for linking to a particular branch/tag in GitHub.
fn git_ref(rust_root: &Option<PathBuf>) -> Result<String> {
let Some(rust_root) = rust_root else {
return Ok("master".into());
};
let channel = std::fs::read_to_string(rust_root.join("src/ci/channel"))
.context("failed to read src/ci/channel")?;
let git_ref = match channel.trim() {
// nightly/beta are branches, not stable references. Should be ok
// because we're not expecting those channels to be long-lived.
"nightly" => "master".into(),
"beta" => "beta".into(),
"stable" => {
let version = std::fs::read_to_string(rust_root.join("src/version"))
.context("|| failed to read src/version")?;
version.trim().into()
}
ch => bail!("unknown channel {ch}"),
};
Ok(git_ref)
}
impl Preprocessor for Spec {
fn name(&self) -> &str {
"spec"
}
fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
let rules = self.collect_rules(&book);
let tests = self.collect_tests(&rules);
let summary_table = test_links::make_summary_table(&book, &tests, &rules);
book.for_each_mut(|item| {
let BookItem::Chapter(ch) = item else {
return;
};
if ch.is_draft_chapter() {
return;
}
ch.content = self.admonitions(&ch);
ch.content = self.auto_link_references(&ch, &rules);
ch.content = self.render_rule_definitions(&ch.content, &tests);
if ch.name == "Test summary" {
ch.content = ch.content.replace("{{summary-table}}", &summary_table);
}
});
// Final pass will resolve everything as a std link (or error if the
// link is unknown).
std_links::std_links(&mut book);
Ok(book)
}
}