blob: f5799ae0f459f03a4942c84aaff657894da81192 [file] [log] [blame]
#![deny(rust_2018_idioms, unused_lifetimes)]
use crate::rules::Rules;
use anyhow::{Context, Result, bail};
use mdbook::BookItem;
use mdbook::book::{Book, Chapter};
use mdbook::errors::Error;
use mdbook::preprocess::{CmdPreprocessor, Preprocessor, PreprocessorContext};
use once_cell::sync::Lazy;
use regex::{Captures, Regex};
use semver::{Version, VersionReq};
use std::fmt;
use std::io;
use std::ops::Range;
use std::path::PathBuf;
mod admonitions;
pub mod grammar;
mod rules;
mod std_links;
mod test_links;
/// A primitive regex to find link reference definitions.
static MD_LINK_REFERENCE_DEFINITION: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^\[(?<label>[^]]+)]: +(?<dest>.*)").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(())
}
/// Handler for errors and warnings.
pub struct Diagnostics {
/// Whether or not warnings should be errors (set by SPEC_DENY_WARNINGS
/// environment variable).
deny_warnings: bool,
/// Number of messages generated.
count: u32,
}
impl Diagnostics {
fn new() -> Diagnostics {
let deny_warnings = std::env::var("SPEC_DENY_WARNINGS").as_deref() == Ok("1");
Diagnostics {
deny_warnings,
count: 0,
}
}
/// Displays a warning or error (depending on whether warnings are denied).
///
/// Usually you want the [`warn_or_err!`] macro.
fn warn_or_err(&mut self, args: fmt::Arguments<'_>) {
if self.deny_warnings {
eprintln!("error: {args}");
} else {
eprintln!("warning: {args}");
}
self.count += 1;
}
}
/// Displays a warning or error (depending on whether warnings are denied).
#[macro_export]
macro_rules! warn_or_err {
($diag:expr, $($arg:tt)*) => {
$diag.warn_or_err(format_args!($($arg)*));
};
}
/// Displays a message for an internal error, and immediately exits.
#[macro_export]
macro_rules! bug {
($($arg:tt)*) => {
eprintln!("mdbook-spec internal error: {}", format_args!($($arg)*));
std::process::exit(1);
};
}
pub struct Spec {
/// Path to the rust-lang/rust git repository (set by SPEC_RUST_ROOT
/// environment variable).
rust_root: Option<PathBuf>,
}
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 rust_root = rust_root.or_else(|| std::env::var_os("SPEC_RUST_ROOT").map(PathBuf::from));
Ok(Spec { rust_root })
}
/// Converts link reference definitions that point to a rule to the correct link.
///
/// For example:
/// ```markdown
/// See [this rule].
///
/// [this rule]: expr.array
/// ```
///
/// This will convert the `[this rule]` definition to point to the actual link.
fn rule_link_references(&self, chapter: &Chapter, rules: &Rules) -> String {
let current_path = chapter.path.as_ref().unwrap().parent().unwrap();
MD_LINK_REFERENCE_DEFINITION
.replace_all(&chapter.content, |caps: &Captures<'_>| {
let dest = &caps["dest"];
if let Some((_source_path, path)) = rules.def_paths.get(dest) {
let label = &caps["label"];
let relative = pathdiff::diff_paths(path, current_path).unwrap();
// Adjust paths for Windows.
let relative = relative.display().to_string().replace('\\', "/");
format!("[{label}]: {relative}#r-{dest}")
} else {
caps.get(0).unwrap().as_str().to_string()
}
})
.to_string()
}
/// 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
)
}
}
/// 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 mut diag = Diagnostics::new();
if diag.deny_warnings && self.rust_root.is_none() {
bail!("error: SPEC_RUST_ROOT environment variable must be set");
}
let grammar = grammar::load_grammar(&book, &mut diag);
let rules = self.collect_rules(&book, &mut diag);
let tests = self.collect_tests(&rules);
let summary_table = test_links::make_summary_table(&book, &tests, &rules);
let git_ref = match git_ref(&self.rust_root) {
Ok(s) => s,
Err(e) => {
warn_or_err!(&mut diag, "{e:?}");
"master".into()
}
};
book.for_each_mut(|item| {
let BookItem::Chapter(ch) = item else {
return;
};
if ch.is_draft_chapter() {
return;
}
ch.content = admonitions::admonitions(&ch, &mut diag);
ch.content = self.rule_link_references(&ch, &rules);
ch.content = self.auto_link_references(&ch, &rules);
ch.content = self.render_rule_definitions(&ch.content, &tests, &git_ref);
if ch.name == "Test summary" {
ch.content = ch.content.replace("{{summary-table}}", &summary_table);
}
if grammar::is_summary(ch) {
ch.content = grammar::insert_summary(&grammar, &ch, &mut diag);
}
ch.content = grammar::insert_grammar(&grammar, &ch, &mut diag);
});
// Final pass will resolve everything as a std link (or error if the
// link is unknown).
std_links::std_links(&mut book, &mut diag);
if diag.count > 0 {
if diag.deny_warnings {
eprintln!("mdbook-spec exiting due to {} errors", diag.count);
std::process::exit(1);
}
eprintln!("mdbook-spec generated {} warnings", diag.count);
}
Ok(book)
}
}
fn line_from_range<'a>(contents: &'a str, range: &Range<usize>) -> &'a str {
assert!(range.start < contents.len());
let mut start_index = 0;
for line in contents.lines() {
let end_index = start_index + line.len();
if range.start >= start_index && range.start <= end_index {
return line;
}
start_index = end_index + 1;
}
panic!("did not find line {range:?} in contents");
}