| #![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; |
| |
| pub mod grammar; |
| 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() |
| }); |
| |
| /// 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 |
| ) |
| } |
| |
| /// Converts blockquotes with special headers into admonitions. |
| /// |
| /// The blockquote should look something like: |
| /// |
| /// ```markdown |
| /// > [!WARNING] |
| /// > ... |
| /// ``` |
| /// |
| /// This will add a `<div class="alert alert-warning">` around the |
| /// blockquote so that it can be styled differently, and injects an icon. |
| /// The actual styling needs to be added in the `reference.css` CSS file. |
| fn admonitions(&self, chapter: &Chapter, diag: &mut Diagnostics) -> 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]; |
| if lower.starts_with("edition-") { |
| let edition = &lower[8..]; |
| return format!("{space}<div class=\"alert alert-edition\">\n\ |
| \n\ |
| {space}> <p class=\"alert-title\">\ |
| <span class=\"alert-title-edition\">{edition}</span> Edition differences</p>\n\ |
| {space} >\n\ |
| {blockquote}\n\ |
| \n\ |
| {space}</div>\n"); |
| } |
| |
| // These icons are from GitHub, MIT License, see https://github.com/primer/octicons |
| let svg = match lower.as_str() { |
| "note" => "<path d=\"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z\"></path>", |
| "warning" => "<path d=\"M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z\"></path>", |
| _ => { |
| warn_or_err!( |
| diag, |
| "admonition `{lower}` in {:?} is incorrect or not yet supported", |
| chapter.path.as_ref().unwrap() |
| ); |
| "" |
| } |
| }; |
| format!( |
| "{space}<div class=\"alert alert-{lower}\">\n\ |
| \n\ |
| {space}> <p class=\"alert-title\">\ |
| <svg viewBox=\"0 0 16 16\" width=\"18\" height=\"18\">\ |
| {svg}\ |
| </svg>{term}</p>\n\ |
| {space} >\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 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 = self.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"); |
| } |