use anyhow::Result;
use log::{debug, info};
use rayon::prelude::*;
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::cache::BuildCache;
use crate::config::BuildConfig;
use crate::doctree::Doctree;
use crate::document::Document;
use crate::env;
use crate::env::dependencies as env_dependencies;
use crate::env::genindex as env_genindex;
use crate::env::metadata as env_metadata;
use crate::env::numbers as env_numbers;
use crate::env::py_domain as env_py_domain;
use crate::env::resolve as env_resolve;
use crate::env::std_domain as env_std;
use crate::env::toctree as env_toctree;
use crate::env::toctree::{ConsistencyLevel, ToctreeWarningKind};
use crate::env::BuildEnvironment;
use crate::error::{BuildErrorReport, BuildWarning, ErrorType, WarningType};
use crate::extensions::{ExtensionLoader, SphinxApp};
use crate::intersphinx::{self, HttpConfig, Intersphinx, LoadRequest, UreqFetcher};
use crate::matching;
use crate::parser::Parser;
use crate::utils;
use crate::utils::py_repr_str;
const DOCTREE_SUBDIR: &str = "doctrees";
const DOCTREE_MAGIC: &[u8; 4] = b"SUDT";
const DOCTREE_FORMAT_VERSION: u32 = 2;
const DOCTREE_HEADER_LEN: usize = DOCTREE_MAGIC.len() + std::mem::size_of::<u32>();
const DEFAULT_ROOT_DOC: &str = "index";
struct ReadResult {
docname: String,
document: Document,
doctree: Doctree,
read_time_us: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct BuildStats {
pub files_processed: usize,
pub files_skipped: usize,
pub build_time: Duration,
pub output_size_mb: f64,
pub cache_hits: usize,
pub errors: usize,
pub warnings: usize,
pub warning_details: Vec<BuildWarning>,
pub error_details: Vec<BuildErrorReport>,
}
pub struct SphinxBuilder {
config: BuildConfig,
source_dir: PathBuf,
output_dir: PathBuf,
cache: BuildCache,
parser: Parser,
parallel_jobs: usize,
incremental: bool,
warnings: Arc<Mutex<Vec<BuildWarning>>>,
errors: Arc<Mutex<Vec<BuildErrorReport>>>,
#[allow(dead_code)]
sphinx_app: Option<SphinxApp>,
#[allow(dead_code)]
extension_loader: ExtensionLoader,
env: BuildEnvironment,
resolved: Mutex<BTreeMap<String, String>>,
genindex: Mutex<Vec<env_genindex::IndexGroup>>,
py_modindex: Mutex<env_py_domain::PyModindex>,
intersphinx: Intersphinx,
}
const EXCLUDED_FROM_FINGERPRINT: [&str; 2] = ["fail_on_warning", "nitpicky"];
const DISCOVERY_SUFFIXES: [&str; 3] = ["rst", "md", "txt"];
fn is_default_source_suffix(path: &Path) -> bool {
path.extension().is_some_and(|extension| extension == "rst")
}
fn suffix_rank(path: &Path) -> Option<usize> {
let extension = path.extension()?.to_string_lossy();
DISCOVERY_SUFFIXES
.iter()
.position(|suffix| *suffix == extension)
}
fn config_fingerprint(config: &BuildConfig) -> Result<String> {
let mut value = serde_json::to_value(config)?;
if let Some(map) = value.as_object_mut() {
for key in EXCLUDED_FROM_FINGERPRINT {
map.remove(key);
}
}
Ok(blake3::hash(serde_json::to_string(&value)?.as_bytes())
.to_hex()
.to_string())
}
impl SphinxBuilder {
pub fn new(config: BuildConfig, source_dir: PathBuf, output_dir: PathBuf) -> Result<Self> {
let cache_dir = config
.doctree_dir
.clone()
.unwrap_or_else(|| output_dir.join(".sphinx-ultra-cache"));
let config_fingerprint = config_fingerprint(&config)?;
let cache = BuildCache::new(
cache_dir,
config.max_cache_size_mb,
config.cache_expiration_hours,
&config_fingerprint,
)?;
let env = BuildEnvironment::load(cache.cache_dir()).unwrap_or_default();
let source_dir = crate::utils::canonicalize_simplified(&source_dir).unwrap_or(source_dir);
let parser = Parser::new(&config)?.with_srcdir(source_dir.clone());
let parallel_jobs = config.parallel_jobs.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
});
let mut sphinx_app = SphinxApp::new(config.clone())?;
let mut extension_loader = ExtensionLoader::new()?;
for extension_name in &config.extensions {
match extension_loader.load_extension(extension_name) {
Ok(extension) => {
if let Err(e) = sphinx_app.add_extension(extension) {
log::warn!("Failed to add extension '{}': {}", extension_name, e);
}
}
Err(e) => {
log::warn!("Failed to load extension '{}': {}", extension_name, e);
}
}
}
Ok(Self {
config,
source_dir,
output_dir,
cache,
parser,
parallel_jobs,
incremental: false,
warnings: Arc::new(Mutex::new(Vec::new())),
errors: Arc::new(Mutex::new(Vec::new())),
sphinx_app: Some(sphinx_app),
extension_loader,
env,
resolved: Mutex::new(BTreeMap::new()),
genindex: Mutex::new(Vec::new()),
py_modindex: Mutex::new(env_py_domain::PyModindex::default()),
intersphinx: Intersphinx::default(),
})
}
fn load_intersphinx_inventories(&mut self) -> Result<()> {
if self.config.intersphinx_mapping.is_empty() {
return Ok(());
}
let http = HttpConfig {
tls_verify: self.config.tls_verify,
tls_cacerts: self.config.tls_cacerts.clone(),
user_agent: self.config.user_agent.clone(),
timeout: self.config.intersphinx_timeout,
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0);
let outcome = intersphinx::load_mappings(
&LoadRequest {
mapping: &self.config.intersphinx_mapping,
srcdir: &self.source_dir,
cache_dir: Some(self.cache.cache_dir().join(intersphinx::CACHE_DIR_NAME)),
cache_limit: self.config.intersphinx_cache_limit,
now,
http: &http,
},
&UreqFetcher,
)?;
for message in outcome.infos {
info!("{message}");
}
for message in outcome.warnings {
self.add_warning(BuildWarning::new(
PathBuf::new(),
None,
message,
WarningType::Other,
));
}
self.intersphinx = Intersphinx {
data: outcome.data,
disabled_reftypes: self
.config
.intersphinx_disabled_reftypes
.iter()
.cloned()
.collect(),
resolve_self: self.config.intersphinx_resolve_self.clone(),
};
Ok(())
}
pub fn set_parallel_jobs(&mut self, jobs: usize) {
self.parallel_jobs = jobs;
}
pub fn enable_incremental(&mut self) {
self.incremental = true;
}
pub fn fresh_env(&mut self) -> Result<()> {
self.cache.clear()?;
self.env = BuildEnvironment::default();
Ok(())
}
#[allow(dead_code)]
pub fn add_warning(&self, warning: BuildWarning) {
self.warnings.lock().unwrap().push(warning);
}
#[allow(dead_code)]
pub fn add_error(&self, error: BuildErrorReport) {
self.errors.lock().unwrap().push(error);
}
#[allow(dead_code)]
pub fn should_fail_on_warning(&self) -> bool {
self.config.fail_on_warning
}
pub async fn clean(&mut self) -> Result<()> {
if self.output_dir.exists() {
tokio::fs::remove_dir_all(&self.output_dir).await?;
}
self.cache.clear()?;
self.env = BuildEnvironment::default();
Ok(())
}
pub async fn build(&mut self) -> Result<BuildStats> {
let start_time = Instant::now();
info!("Starting build process...");
tokio::fs::create_dir_all(&self.output_dir).await?;
let source_files = self.discover_source_files().await?;
info!("Discovered {} source files", source_files.len());
self.load_intersphinx_inventories()?;
let mut env = std::mem::take(&mut self.env);
let to_read = self.plan_read(&env, &source_files);
let mut read_results = self.read_phase(&source_files, &to_read)?;
self.merge_phase(&mut env, &mut read_results);
self.resolve_phase(&mut env, &read_results);
self.env = env;
let files_skipped = read_results
.iter()
.filter(|result| result.read_time_us.is_none())
.count();
let (processed_docs, doctrees): (Vec<Document>, Vec<Doctree>) = read_results
.into_iter()
.map(|result| (result.document, result.doctree))
.unzip();
self.write_phase(&processed_docs);
if self.config.validate_directives {
self.validate_directives_and_roles(&processed_docs, &doctrees);
}
self.generate_indices(&processed_docs).await?;
self.copy_static_assets().await?;
self.generate_search_index(&processed_docs).await?;
let build_time = start_time.elapsed();
let output_size = utils::calculate_directory_size(&self.output_dir).await?;
let warnings = self.warnings.lock().unwrap();
let errors = self.errors.lock().unwrap();
let stats = BuildStats {
files_processed: processed_docs.len(),
files_skipped,
build_time,
output_size_mb: output_size as f64 / 1024.0 / 1024.0,
cache_hits: self.cache.hit_count(),
errors: errors.len(),
warnings: warnings.len(),
warning_details: warnings.clone(),
error_details: errors.clone(),
};
info!("Build completed in {:?}", build_time);
Ok(stats)
}
async fn discover_source_files(&self) -> Result<Vec<PathBuf>> {
let include_patterns = &self.config.include_patterns;
let exclude_patterns = &self.config.exclude_patterns;
let mut all_exclude_patterns = exclude_patterns.clone();
all_exclude_patterns.extend_from_slice(&[
"_build/**".to_string(),
"__pycache__/**".to_string(),
".git/**".to_string(),
".svn/**".to_string(),
".hg/**".to_string(),
".*/**".to_string(), "Thumbs.db".to_string(),
".DS_Store".to_string(),
]);
match matching::get_matching_files(
&self.source_dir,
include_patterns,
&all_exclude_patterns,
) {
Ok(files) => Ok(self.dedup_by_docname(
files
.into_iter()
.filter(|path| self.is_source_file(path))
.collect(),
)),
Err(e) => {
log::warn!(
"Pattern matching failed, falling back to simple discovery: {}",
e
);
let mut files = Vec::new();
self.discover_files_sync(&self.source_dir, &mut files)?;
Ok(self.dedup_by_docname(files))
}
}
}
fn dedup_by_docname(&self, files: Vec<PathBuf>) -> Vec<PathBuf> {
let mut by_docname: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
for path in &files {
by_docname
.entry(self.docname_of_path(path))
.or_default()
.push(path.clone());
}
let mut dropped: BTreeSet<PathBuf> = BTreeSet::new();
for (docname, mut candidates) in by_docname {
if candidates.len() < 2 {
continue;
}
candidates.sort_by(|a, b| {
suffix_rank(a)
.cmp(&suffix_rank(b))
.then_with(|| a.as_path().cmp(b.as_path()))
});
if candidates
.iter()
.filter(|path| is_default_source_suffix(path))
.count()
> 1
{
let listed: Vec<String> = candidates
.iter()
.map(|path| {
path.strip_prefix(&self.source_dir)
.unwrap_or(path)
.display()
.to_string()
})
.collect();
self.add_warning(BuildWarning::new(
PathBuf::new(),
None,
format!(
"multiple files found for the document \"{docname}\": {}\nUse {} for the build.",
listed.join(", "),
py_repr_str(&candidates[0].display().to_string()),
),
WarningType::Other,
));
}
dropped.extend(candidates.into_iter().skip(1));
}
if dropped.is_empty() {
return files;
}
files
.into_iter()
.filter(|path| !dropped.contains(path))
.collect()
}
fn discover_files_sync(&self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name() {
if name.to_string_lossy().starts_with('.')
|| name == "_build"
|| name == "__pycache__"
{
continue;
}
}
self.discover_files_sync(&path, files)?;
} else if self.is_source_file(&path) {
files.push(path);
}
}
Ok(())
}
fn is_source_file(&self, path: &Path) -> bool {
suffix_rank(path).is_some()
}
fn plan_read(&self, env: &BuildEnvironment, files: &[PathBuf]) -> BTreeSet<String> {
let sources: BTreeMap<String, PathBuf> = files
.iter()
.map(|path| (self.docname_of_path(path), path.clone()))
.collect();
let found: BTreeSet<String> = sources.keys().cloned().collect();
if !self.incremental {
debug!(
"Not an incremental build: reading all {} files",
found.len()
);
return found;
}
let outdated = env.get_outdated_files(
&found,
self.cache.config_changed(),
&env::FileTimes {
source_modified_us: &|docname| {
sources.get(docname).and_then(|path| modified_us(path))
},
doctree_exists: &|docname| self.doctree_path(docname).is_file(),
dependency_modified_us: &modified_us,
},
);
info!(
"updating environment: {} added, {} changed, {} removed",
outdated.added.len(),
outdated.changed.len(),
outdated.removed.len()
);
let mut to_read = outdated.to_read();
for removed in &outdated.removed {
for container in env.files_to_rebuild.get(removed).into_iter().flatten() {
if found.contains(container) {
to_read.insert(container.clone());
}
}
}
to_read
}
fn read_phase(&self, files: &[PathBuf], to_read: &BTreeSet<String>) -> Result<Vec<ReadResult>> {
info!(
"Processing {} files with {} parallel jobs",
files.len(),
self.parallel_jobs
);
let found_docs = Arc::new(
files
.iter()
.map(|path| self.docname_of_path(path))
.collect::<BTreeSet<String>>(),
);
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(self.parallel_jobs)
.build()?;
let results: Vec<(PathBuf, Result<ReadResult>)> = pool.install(|| {
files
.par_iter()
.map(|file_path| {
let docname = self.docname_of_path(file_path);
let outdated = to_read.contains(&docname);
(
file_path.clone(),
self.read_one_file(file_path, docname, &found_docs, outdated),
)
})
.collect()
});
let mut read_results = Vec::with_capacity(results.len());
for (file_path, result) in results {
match result {
Ok(read) => read_results.push(read),
Err(e) => {
self.errors.lock().unwrap().push(BuildErrorReport::new(
file_path,
None,
format!("{e:#}"),
ErrorType::ParseError,
));
}
}
}
Ok(read_results)
}
fn read_one_file(
&self,
file_path: &Path,
docname: String,
found_docs: &Arc<BTreeSet<String>>,
outdated: bool,
) -> Result<ReadResult> {
let relative_path = file_path.strip_prefix(&self.source_dir)?;
debug!("Processing file: {}", relative_path.display());
if !outdated && self.incremental {
if let Ok(file_mtime) = utils::get_file_mtime(file_path) {
let hit = self.cache.get_document_with(file_path, |cached| {
if cached.source_mtime < file_mtime || cached.html.is_empty() {
return None;
}
self.load_doctree(&docname)
});
if let Some((document, doctree)) = hit {
debug!("Using cached version of {}", relative_path.display());
return Ok(ReadResult {
docname,
document,
doctree,
read_time_us: None,
});
}
}
}
let content = std::fs::read_to_string(file_path)?;
let parsed =
self.parser
.parse_full(file_path, &content, &docname, Some(Arc::clone(found_docs)))?;
let mut document = parsed.document;
let rendered_html = format!(
"<html><body>{}</body></html>",
html_escape::encode_text(&document.content.to_string())
);
document.html = rendered_html;
if self.incremental {
self.cache.store_document(file_path, &document)?;
}
Ok(ReadResult {
docname,
document,
doctree: parsed.doctree,
read_time_us: Some(now_micros()),
})
}
fn merge_phase(&self, env: &mut BuildEnvironment, results: &mut [ReadResult]) {
env.root_doc = self
.config
.root_doc
.clone()
.unwrap_or_else(|| DEFAULT_ROOT_DOC.to_string());
let present: HashSet<&str> = results.iter().map(|r| r.docname.as_str()).collect();
let stale: Vec<String> = env
.all_docs
.keys()
.filter(|docname| !present.contains(docname.as_str()))
.cloned()
.collect();
for docname in stale {
env.clear_doc(&docname);
}
let mut ordered: Vec<usize> = (0..results.len()).collect();
ordered.sort_by(|a, b| results[*a].docname.cmp(&results[*b].docname));
let paths: HashMap<String, PathBuf> = results
.iter()
.map(|result| (result.docname.clone(), result.document.source_path.clone()))
.collect();
let doc2path = |docname: &str| -> PathBuf {
paths
.get(docname)
.cloned()
.unwrap_or_else(|| self.source_dir.join(format!("{docname}.rst")))
};
for index in ordered {
let result = &mut results[index];
let Some(read_time_us) = result.read_time_us else {
continue;
};
let docname = result.docname.clone();
let docname = docname.as_str();
env.clear_doc(docname);
env.all_docs.insert(docname.to_string(), read_time_us);
let title = env_toctree::document_title(&result.doctree);
env.longtitles.insert(docname.to_string(), title.clone());
env.titles.insert(docname.to_string(), title);
env.metadata.insert(
docname.to_string(),
env_metadata::document_metadata(&result.doctree),
);
env_dependencies::process_doc(
env,
docname,
&result.doctree,
&self.source_dir,
&result.document.registry.dependencies,
);
let included: std::collections::BTreeSet<String> =
result.document.registry.included.iter().cloned().collect();
if !included.is_empty() {
env.included.insert(docname.to_string(), included);
}
let (toc, num_entries) = env_toctree::build_toc(&result.doctree, docname);
for toctree in env_toctree::toctree_copies(&toc) {
env_toctree::note_toctree(env, docname, toctree);
}
env.tocs.insert(docname.to_string(), toc);
env.toc_num_entries.insert(docname.to_string(), num_entries);
if result.document.toctrees.iter().any(|toctree| {
toctree
.warnings
.iter()
.any(|warning| warning.kind == ToctreeWarningKind::MissingDocument)
}) {
env.reread_always.insert(docname.to_string());
}
self.report_parse_warnings(&result.document, &result.doctree);
let mut index_warnings = Vec::new();
env_genindex::process_doc(
env,
docname,
&mut result.doctree,
&result.document.source_path,
&mut index_warnings,
);
for warning in index_warnings {
self.add_warning(warning);
}
let mut std_warnings = Vec::new();
env_std::process_doc(
env,
&env_std::DocumentSource {
docname,
doctree: &result.doctree,
registry: &result.document.registry,
path: &result.document.source_path,
},
&doc2path,
&mut std_warnings,
);
for warning in std_warnings {
self.add_warning(warning);
}
if let Err(e) = self.store_doctree(docname, &result.doctree) {
let _ = std::fs::remove_file(self.doctree_path(docname));
self.errors.lock().unwrap().push(BuildErrorReport::new(
result.document.source_path.clone(),
None,
format!("{e:#}"),
ErrorType::Other,
));
}
}
}
fn report_parse_warnings(&self, document: &Document, doctree: &Doctree) {
let mut ordered: Vec<BuildWarning> = Vec::new();
for toctree in &document.toctrees {
for warning in &toctree.warnings {
let warning_type = match warning.kind {
ToctreeWarningKind::MissingDocument => WarningType::MissingToctreeRef,
ToctreeWarningKind::EmptyGlob | ToctreeWarningKind::PatternError => {
WarningType::EmptyToctree
}
ToctreeWarningKind::DuplicateEntry => WarningType::Other,
};
let source_path = doctree
.sources
.get(warning.source as usize)
.map(PathBuf::from)
.unwrap_or_else(|| document.source_path.clone());
ordered.push(
BuildWarning::new(
source_path,
Some(warning.line as usize),
warning.message.clone(),
warning_type,
)
.with_category(warning.category.clone()),
);
}
}
for warning in &document.registry.log_warnings {
let source_path = doctree
.sources
.get(warning.source as usize)
.map(|path| PathBuf::from(warning.rendered_path(path)))
.unwrap_or_else(|| document.source_path.clone());
ordered.push(BuildWarning::new(
source_path,
Some(warning.line as usize),
warning.message.clone(),
WarningType::Other,
));
}
let mut warnings = self.warnings.lock().unwrap();
warnings.extend(ordered);
}
fn resolve_phase(&self, env: &mut BuildEnvironment, results: &[ReadResult]) {
info!("Resolving build environment");
let sources: HashMap<&str, &Path> = results
.iter()
.map(|result| {
(
result.docname.as_str(),
result.document.source_path.as_path(),
)
})
.collect();
self.number_phase(env, results);
let orphan_candidate = |docname: &str| {
sources
.get(docname)
.is_none_or(|path| is_default_source_suffix(path))
};
for message in env_toctree::check_consistency(env, &orphan_candidate) {
let source = sources
.get(message.docname.as_str())
.map(|path| path.to_path_buf())
.unwrap_or_else(|| PathBuf::from(&message.docname));
match message.level {
ConsistencyLevel::Warning => self.warnings.lock().unwrap().push(
BuildWarning::new(source, None, message.message, WarningType::OrphanedDocument)
.with_category(message.category),
),
ConsistencyLevel::Info => info!("{}: {}", source.display(), message.message),
}
}
self.xref_phase(env, results);
self.genindex_phase(env, &sources);
self.py_modindex_phase(env);
if let Err(e) = env.save(self.cache.cache_dir()) {
log::warn!(
"Could not save the build environment to {}: {e:#} — this build's \
output is complete, but the next one will start from scratch",
self.cache.cache_dir().display()
);
}
}
fn genindex_phase(&self, env: &BuildEnvironment, sources: &HashMap<&str, &Path>) {
let rel_uri = |_docname: &str| Some(String::new());
let mut messages = Vec::new();
let groups = env_genindex::create_index(env, &rel_uri, &mut messages);
for message in messages {
let source = sources
.get(message.docname.as_str())
.map(|path| path.to_path_buf())
.unwrap_or_else(|| PathBuf::from(&message.docname));
self.add_warning(message.into_warning(&source));
}
*self.genindex.lock().unwrap() = groups;
}
fn py_modindex_phase(&self, env: &BuildEnvironment) {
*self.py_modindex.lock().unwrap() =
env_py_domain::generate_modindex(&env.py, &self.config.modindex_common_prefix);
}
fn xref_phase(&self, env: &BuildEnvironment, results: &[ReadResult]) {
let in_memory: HashMap<&str, &Doctree> = results
.iter()
.map(|result| (result.docname.as_str(), &result.doctree))
.collect();
let load_doctree = |docname: &str| -> Option<Cow<'_, Doctree>> {
match in_memory.get(docname) {
Some(doctree) => Some(Cow::Borrowed(*doctree)),
None => self.load_doctree(docname).map(Cow::Owned),
}
};
let relative_uri = |_from: &str, _to: &str| String::new();
let resolver = env_resolve::Resolver {
env,
numfig: self.config.numfig,
numfig_format: &self.config.numfig_format,
doctree: &load_doctree,
relative_uri: &relative_uri,
intersphinx: &self.intersphinx,
};
let nitpick = env_resolve::NitpickConfig {
nitpicky: self.config.nitpicky,
ignore: &self.config.nitpick_ignore,
ignore_regex: &self.config.nitpick_ignore_regex,
};
let mut ordered: Vec<&ReadResult> = results.iter().collect();
ordered.sort_by(|a, b| a.docname.cmp(&b.docname));
self.resolved.lock().unwrap().clear();
let mut unresolvable_domain_refs = 0usize;
for result in ordered {
let mut doctree = result.doctree.clone();
let resolution = env_resolve::resolve_document(
&resolver,
&nitpick,
&result.docname,
&mut doctree,
&result.document.source_path,
);
unresolvable_domain_refs += resolution.unresolvable_domain_refs;
for warning in resolution.warnings {
self.add_warning(warning);
}
self.resolved
.lock()
.unwrap()
.insert(result.docname.clone(), doctree.root.pformat());
}
if unresolvable_domain_refs > 0 {
info!(
"{unresolvable_domain_refs} cross-domain reference(s) not validated \
(domain not implemented until M5)"
);
}
}
fn number_phase(&self, env: &mut BuildEnvironment, results: &[ReadResult]) {
let in_memory: HashMap<&str, &Doctree> = results
.iter()
.map(|result| (result.docname.as_str(), &result.doctree))
.collect();
let load_doctree = |docname: &str| -> Option<std::borrow::Cow<'_, Doctree>> {
match in_memory.get(docname) {
Some(doctree) => Some(std::borrow::Cow::Borrowed(*doctree)),
None => self.load_doctree(docname).map(std::borrow::Cow::Owned),
}
};
let sections = env_numbers::assign_section_numbers(env, &load_doctree);
for warning in sections.warnings {
self.report_numbering_warning(&warning, results);
}
let figures = env_numbers::assign_figure_numbers(
env,
self.config.numfig,
self.config.numfig_secnum_depth,
&load_doctree,
);
debug!(
"Numbering: {} document(s) with changed section numbers, {} with changed figure numbers",
sections.changed.len(),
figures.len()
);
}
fn report_numbering_warning(
&self,
warning: &env_numbers::NumberingWarning,
results: &[ReadResult],
) {
let result = results
.iter()
.find(|result| result.docname == warning.docname);
let document = result.map(|result| &result.document);
let toctree = document.and_then(|document| document.toctrees.get(warning.toctree_index));
let source = result
.and_then(|result| {
let toctree = toctree?;
result
.doctree
.sources
.get(toctree.source as usize)
.map(PathBuf::from)
})
.or_else(|| document.map(|document| document.source_path.clone()))
.unwrap_or_else(|| PathBuf::from(&warning.docname));
let line = toctree.map(|toctree| toctree.line as usize);
self.warnings.lock().unwrap().push(
BuildWarning::new(
source,
line,
warning.message.clone(),
WarningType::MissingToctreeRef,
)
.with_category(warning.category.clone()),
);
}
fn write_phase(&self, documents: &[Document]) {
documents.par_iter().for_each(|document| {
if let Err(e) = self.write_one(document) {
self.errors.lock().unwrap().push(BuildErrorReport::new(
document.source_path.clone(),
None,
format!("{e:#}"),
ErrorType::Other,
));
}
});
}
fn write_one(&self, document: &Document) -> Result<()> {
let output_path = self.get_output_path(&document.source_path)?;
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&output_path, &document.html)?;
Ok(())
}
fn doctree_path(&self, docname: &str) -> PathBuf {
let hash = blake3::hash(docname.as_bytes());
self.cache
.cache_dir()
.join(DOCTREE_SUBDIR)
.join(format!("{}.doctree", hash.to_hex()))
}
fn store_doctree(&self, docname: &str, doctree: &Doctree) -> Result<()> {
let path = self.doctree_path(docname);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let blob = crate::doctree::to_bincode(doctree);
let mut bytes = Vec::with_capacity(DOCTREE_HEADER_LEN + blob.len());
bytes.extend_from_slice(DOCTREE_MAGIC);
bytes.extend_from_slice(&DOCTREE_FORMAT_VERSION.to_le_bytes());
bytes.extend_from_slice(&blob);
std::fs::write(path, bytes)?;
Ok(())
}
fn load_doctree(&self, docname: &str) -> Option<Doctree> {
let path = self.doctree_path(docname);
let bytes = std::fs::read(&path).ok()?;
let Some(blob) = current_format_doctree(&bytes) else {
debug!(
"Ignoring doctree {} written in another format (re-reading {docname})",
path.display()
);
return None;
};
match crate::doctree::from_bincode(blob) {
Ok(doctree) => Some(doctree),
Err(e) => {
debug!(
"Ignoring unreadable doctree {}: {e:#} (re-reading {docname})",
path.display()
);
None
}
}
}
fn get_output_path(&self, source_path: &Path) -> Result<PathBuf> {
let relative_path = source_path.strip_prefix(&self.source_dir)?;
let mut output_path = self.output_dir.join(relative_path);
output_path.set_extension("html");
Ok(output_path)
}
async fn generate_indices(&self, _documents: &[Document]) -> Result<()> {
info!("Generating indices and cross-references");
Ok(())
}
async fn copy_static_assets(&self) -> Result<()> {
info!("Copying static assets");
let static_output_dir = self.output_dir.join("_static");
tokio::fs::create_dir_all(&static_output_dir).await?;
let exe_dir = std::env::current_exe()?
.parent()
.ok_or_else(|| anyhow::anyhow!("Could not determine executable directory"))?
.to_path_buf();
let possible_static_dirs = [
exe_dir.join("../static"), exe_dir.join("../../static"), exe_dir.join("../../../static"), Path::new("rust-builder/static").to_path_buf(), ];
let mut static_assets_copied = false;
for builtin_static_dir in &possible_static_dirs {
if builtin_static_dir.exists() {
debug!("Found static assets at: {:?}", builtin_static_dir);
for entry in std::fs::read_dir(builtin_static_dir)? {
let entry = entry?;
let file_path = entry.path();
if file_path.is_file() {
let file_name = file_path.file_name().unwrap();
let dest_path = static_output_dir.join(file_name);
tokio::fs::copy(&file_path, &dest_path).await?;
debug!("Copied static asset: {:?}", file_name);
}
}
static_assets_copied = true;
break;
}
}
if !static_assets_copied {
debug!("No built-in static assets found, creating basic ones");
self.create_default_static_assets(&static_output_dir)
.await?;
}
let static_dirs = [
self.source_dir.join("_static"),
self.source_dir.join("_templates"),
];
for static_dir in &static_dirs {
if static_dir.exists() {
let dest = self.output_dir.join(static_dir.file_name().unwrap());
utils::copy_dir_recursive(static_dir, &dest).await?;
debug!("Copied static directory: {:?}", static_dir);
}
}
Ok(())
}
async fn create_default_static_assets(&self, static_dir: &Path) -> Result<()> {
let pygments_css = include_str!("../static/pygments.css");
tokio::fs::write(static_dir.join("pygments.css"), pygments_css).await?;
let theme_css = include_str!("../static/theme.css");
tokio::fs::write(static_dir.join("theme.css"), theme_css).await?;
let jquery_js = include_str!("../static/jquery.js");
tokio::fs::write(static_dir.join("jquery.js"), jquery_js).await?;
let doctools_js = include_str!("../static/doctools.js");
tokio::fs::write(static_dir.join("doctools.js"), doctools_js).await?;
let sphinx_highlight_js = include_str!("../static/sphinx_highlight.js");
tokio::fs::write(static_dir.join("sphinx_highlight.js"), sphinx_highlight_js).await?;
debug!("Created default static assets");
Ok(())
}
fn docname_of_path(&self, path: &Path) -> String {
let relative = path.strip_prefix(&self.source_dir).unwrap_or(path);
relative
.with_extension("")
.to_string_lossy()
.replace('\\', "/")
}
fn validate_directives_and_roles(&self, processed_docs: &[Document], doctrees: &[Doctree]) {
use crate::directives::validation::{
DirectiveValidationResult, DirectiveValidationSystem, ParsedDirective, ParsedRole,
RoleValidationResult, SourceLocation,
};
use crate::document::DocumentContent;
debug_assert_eq!(processed_docs.len(), doctrees.len());
let results: Vec<(Vec<BuildWarning>, usize)> = processed_docs
.par_iter()
.zip(doctrees.par_iter())
.filter_map(|(doc, doctree)| {
if !matches!(&doc.content, DocumentContent::RestructuredText(_)) {
return None;
}
let mut warnings = Vec::new();
let mut unknown = 0usize;
let mut system = DirectiveValidationSystem::new();
let file_of = |source: u16| -> String {
doctree
.sources
.get(source as usize)
.cloned()
.unwrap_or_else(|| doc.source_path.display().to_string())
};
let directives: Vec<ParsedDirective> = doc
.directive_records
.iter()
.map(|r| ParsedDirective {
name: r.name.clone(),
arguments: r.arguments.clone(),
options: r.options.iter().cloned().collect(),
content: r.content.clone(),
location: SourceLocation {
file: file_of(r.source),
line: r.line as usize,
column: 0,
},
})
.collect();
let roles: Vec<ParsedRole> = doc
.role_records
.iter()
.map(|r| ParsedRole {
name: r.name.clone(),
target: r.target.clone(),
display_text: r.display.clone(),
location: SourceLocation {
file: file_of(r.source),
line: r.line as usize,
column: 0,
},
})
.collect();
for directive in &directives {
match system.validate_directive(directive) {
DirectiveValidationResult::Valid => {}
DirectiveValidationResult::Unknown => unknown += 1,
DirectiveValidationResult::Warning(msg)
| DirectiveValidationResult::Error(msg) => {
warnings.push(BuildWarning::new(
PathBuf::from(&directive.location.file),
Some(directive.location.line),
msg,
crate::error::WarningType::Other,
));
}
}
}
for role in &roles {
match system.validate_role(role) {
RoleValidationResult::Valid => {}
RoleValidationResult::Unknown => unknown += 1,
RoleValidationResult::Warning(msg) | RoleValidationResult::Error(msg) => {
warnings.push(BuildWarning::new(
PathBuf::from(&role.location.file),
Some(role.location.line),
msg,
crate::error::WarningType::Other,
));
}
}
}
Some((warnings, unknown))
})
.collect();
let mut unknown_total = 0usize;
for (warnings, unknown) in results {
unknown_total += unknown;
for warning in warnings {
self.add_warning(warning);
}
}
if unknown_total > 0 {
debug!(
"{} directive/role occurrence(s) had no validator and were not checked",
unknown_total
);
}
}
async fn generate_search_index(&self, _documents: &[Document]) -> Result<()> {
info!("Generating search index");
Ok(())
}
pub fn env(&self) -> &BuildEnvironment {
&self.env
}
pub fn snapshot_env(&self) -> serde_json::Value {
let mut snapshot = self.env.snapshot();
let resolved: serde_json::Map<String, serde_json::Value> = self
.resolved
.lock()
.unwrap()
.iter()
.map(|(docname, pformat)| (docname.clone(), serde_json::Value::String(pformat.clone())))
.collect();
if let Some(object) = snapshot.as_object_mut() {
object.insert(
"resolved_pformat".to_string(),
serde_json::Value::Object(resolved),
);
object.insert(
"genindex".to_string(),
env_genindex::snapshot(&self.genindex.lock().unwrap()),
);
object.insert(
"py_modindex".to_string(),
env_py_domain::modindex_snapshot(&self.py_modindex.lock().unwrap()),
);
}
snapshot
}
}
fn current_format_doctree(bytes: &[u8]) -> Option<&[u8]> {
let (header, blob) = bytes.split_at_checked(DOCTREE_HEADER_LEN)?;
if &header[..DOCTREE_MAGIC.len()] != DOCTREE_MAGIC {
return None;
}
let version = u32::from_le_bytes(header[DOCTREE_MAGIC.len()..].try_into().ok()?);
(version == DOCTREE_FORMAT_VERSION).then_some(blob)
}
fn modified_us(path: &Path) -> Option<u64> {
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
Some(
modified
.duration_since(UNIX_EPOCH)
.map(|since| since.as_micros() as u64)
.unwrap_or(0),
)
}
fn now_micros() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_project(source_dir: &Path) {
std::fs::create_dir_all(source_dir).unwrap();
std::fs::write(
source_dir.join("index.rst"),
"Index\n=====\n\n.. toctree::\n\n a\n",
)
.unwrap();
std::fs::write(source_dir.join("a.rst"), "A\n=\n\nBody.\n").unwrap();
}
fn build_incrementally(source_dir: &Path, output_dir: &Path) -> (BuildStats, SphinxBuilder) {
let mut builder = SphinxBuilder::new(
BuildConfig::default(),
source_dir.to_path_buf(),
output_dir.to_path_buf(),
)
.unwrap();
builder.enable_incremental();
let stats = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(builder.build())
.unwrap();
(stats, builder)
}
#[test]
fn operational_flags_are_excluded_from_the_fingerprint() {
let base = BuildConfig::default();
let baseline = config_fingerprint(&base).unwrap();
let mut warned = base.clone();
warned.fail_on_warning = true;
assert_eq!(config_fingerprint(&warned).unwrap(), baseline, "-W");
let mut nitpicky = base.clone();
nitpicky.nitpicky = true;
assert_eq!(config_fingerprint(&nitpicky).unwrap(), baseline, "-n");
let mut both = base.clone();
both.fail_on_warning = true;
both.nitpicky = true;
assert_eq!(config_fingerprint(&both).unwrap(), baseline, "-W -n");
}
#[test]
fn content_bearing_config_still_changes_the_fingerprint() {
let base = BuildConfig::default();
let baseline = config_fingerprint(&base).unwrap();
let mut tagged = base.clone();
tagged.tags = vec!["draft".to_string()];
assert_ne!(config_fingerprint(&tagged).unwrap(), baseline, "tags");
let mut encoded = base.clone();
encoded.source_encoding = "latin-1".to_string();
assert_ne!(
config_fingerprint(&encoded).unwrap(),
baseline,
"source_encoding"
);
let mut mismatched = base.clone();
mismatched.note_confval_type_mismatch("maximum_signature_line_length", "str");
assert_eq!(
config_fingerprint(&mismatched).unwrap(),
baseline,
"confval_type_mismatches"
);
let mut nitpick_ignore = base.clone();
nitpick_ignore.nitpick_ignore = vec![("ref".to_string(), "x".to_string())];
assert_ne!(
config_fingerprint(&nitpick_ignore).unwrap(),
baseline,
"nitpick_ignore is data, not an operational flag"
);
let mut numfig = base.clone();
numfig.numfig = true;
assert_ne!(config_fingerprint(&numfig).unwrap(), baseline, "numfig");
}
#[test]
fn multi_key_html_context_fingerprints_stably() {
let mut config = BuildConfig::default();
for key in ["a", "b", "c", "d", "e", "f", "g", "h"] {
config.html_context.insert(
key.to_string(),
serde_json::Value::String(key.to_uppercase()),
);
}
let first = config_fingerprint(&config).unwrap();
for _ in 0..8 {
assert_eq!(config_fingerprint(&config).unwrap(), first);
}
let mut changed = config.clone();
changed
.html_context
.insert("a".to_string(), serde_json::Value::String("Z".to_string()));
assert_ne!(config_fingerprint(&changed).unwrap(), first);
}
#[test]
fn include_records_replay_into_the_environment_and_suppress_the_orphan() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
std::fs::create_dir_all(&source_dir).unwrap();
std::fs::write(
source_dir.join("index.rst"),
"Index\n=====\n\n.. toctree::\n\n a\n",
)
.unwrap();
std::fs::write(
source_dir.join("a.rst"),
"A\n=\n\n.. include:: part.rst\n\n.. include:: snippet.txt\n",
)
.unwrap();
std::fs::write(source_dir.join("part.rst"), "part para\n").unwrap();
std::fs::write(source_dir.join("snippet.txt"), "plain snippet\n").unwrap();
std::fs::write(
source_dir.join("not_linked.rst"),
"Not Linked\n==========\n\nOrphan candidate.\n",
)
.unwrap();
let (stats, builder) = build_incrementally(&source_dir, &output_dir);
let src = crate::utils::canonicalize_simplified(&source_dir).unwrap();
assert_eq!(
builder.env.included.get("a"),
Some(&std::collections::BTreeSet::from(["part".to_string()])),
"only the docname-mapping include registers (snippet.txt maps to no docname)"
);
assert_eq!(
builder
.env
.dependencies
.get("a")
.map(|set| set.iter().cloned().collect::<Vec<_>>()),
Some(vec![src.join("part.rst"), src.join("snippet.txt")]),
"every opened include target is a dependency, the non-doc file too"
);
let orphan_warnings: Vec<String> = stats
.warning_details
.iter()
.filter(|w| w.message.contains("isn't included in any toctree"))
.map(|w| w.file.display().to_string())
.collect();
assert_eq!(
orphan_warnings.len(),
1,
"exactly the genuinely unlinked doc warns: {orphan_warnings:?}"
);
assert!(
orphan_warnings[0].ends_with("not_linked.rst"),
"{orphan_warnings:?}"
);
}
#[test]
fn every_read_document_persists_its_doctree() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
let (_stats, builder) = build_incrementally(&source_dir, &output_dir);
for docname in ["index", "a"] {
let path = builder.doctree_path(docname);
assert!(
path.is_file(),
"{docname}: no doctree at {}",
path.display()
);
let doctree = builder.load_doctree(docname).expect("doctree decodes");
assert_eq!(doctree.root.kind, crate::doctree::kinds::DOCUMENT);
}
assert!(
builder.cache.cache_dir().join("env.bin").is_file(),
"the resolve phase must persist the environment"
);
}
#[test]
fn cache_hit_whose_doctree_is_missing_is_treated_as_a_miss() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
let (first, builder) = build_incrementally(&source_dir, &output_dir);
assert_eq!(first.cache_hits, 0, "cold build cannot hit the cache");
let (warm, _) = build_incrementally(&source_dir, &output_dir);
assert_eq!(warm.cache_hits, 2);
std::fs::remove_file(builder.doctree_path("a")).unwrap();
let (degraded, rebuilt) = build_incrementally(&source_dir, &output_dir);
assert_eq!(
degraded.cache_hits, 1,
"a document whose doctree is gone is a cache miss, not a hit"
);
assert!(
rebuilt.doctree_path("a").is_file(),
"the re-read must persist the doctree it just produced"
);
assert_eq!(degraded.errors, 0);
let env = rebuilt.env();
assert_eq!(env.all_docs.len(), 2);
assert!(env.tocs.contains_key("a"));
}
#[test]
fn persisted_doctrees_carry_the_format_version_header() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
let (_stats, builder) = build_incrementally(&source_dir, &output_dir);
let bytes = std::fs::read(builder.doctree_path("index")).unwrap();
assert_eq!(
&bytes[..DOCTREE_MAGIC.len()],
DOCTREE_MAGIC,
"a persisted doctree must be self-identifying"
);
let version = u32::from_le_bytes(
bytes[DOCTREE_MAGIC.len()..DOCTREE_MAGIC.len() + 4]
.try_into()
.unwrap(),
);
assert_eq!(version, DOCTREE_FORMAT_VERSION);
assert_eq!(
builder.load_doctree("index").unwrap().root.kind,
crate::doctree::kinds::DOCUMENT,
"the header must not disturb the round trip"
);
}
#[test]
fn a_doctree_written_in_the_unversioned_format_is_treated_as_a_miss() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
let (_cold, builder) = build_incrementally(&source_dir, &output_dir);
let doctree = builder.load_doctree("index").expect("doctree decodes");
std::fs::write(
builder.doctree_path("index"),
crate::doctree::to_bincode(&doctree),
)
.unwrap();
assert!(
builder.load_doctree("index").is_none(),
"an unversioned blob must not be trusted"
);
let (stats, rebuilt) = build_incrementally(&source_dir, &output_dir);
assert_eq!(
stats.cache_hits, 1,
"the document whose doctree is stale must be re-read"
);
assert!(rebuilt.load_doctree("index").is_some());
}
#[test]
fn a_doctree_from_a_future_format_version_is_treated_as_a_miss() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
let (_cold, builder) = build_incrementally(&source_dir, &output_dir);
let doctree = builder.load_doctree("index").expect("doctree decodes");
let mut bytes = Vec::from(DOCTREE_MAGIC);
bytes.extend_from_slice(&(DOCTREE_FORMAT_VERSION + 1).to_le_bytes());
bytes.extend_from_slice(&crate::doctree::to_bincode(&doctree));
std::fs::write(builder.doctree_path("index"), bytes).unwrap();
assert!(builder.load_doctree("index").is_none());
}
#[test]
fn corrupt_doctree_file_is_treated_as_a_miss() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
let (_cold, builder) = build_incrementally(&source_dir, &output_dir);
std::fs::write(builder.doctree_path("index"), b"not a doctree").unwrap();
let (stats, rebuilt) = build_incrementally(&source_dir, &output_dir);
assert_eq!(stats.cache_hits, 1);
assert_eq!(stats.errors, 0);
assert!(rebuilt.load_doctree("index").is_some());
}
#[test]
fn a_build_whose_environment_cannot_be_saved_still_writes_its_output() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
let (_cold, builder) = build_incrementally(&source_dir, &output_dir);
let env_file = builder.cache.cache_dir().join("env.bin");
std::fs::remove_file(&env_file).unwrap();
std::fs::create_dir(&env_file).unwrap();
let (stats, rebuilt) = build_incrementally(&source_dir, &output_dir);
assert_eq!(stats.errors, 0, "an unsaveable environment is not an error");
assert!(
output_dir.join("index.html").is_file() && output_dir.join("a.html").is_file(),
"the pages this build produced are still written"
);
assert_eq!(
rebuilt.env().all_docs.len(),
2,
"the in-memory environment is complete; only its persistence failed"
);
}
#[test]
fn fresh_env_discards_the_loaded_environment_and_re_reads_everything() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
build_incrementally(&source_dir, &output_dir);
let mut builder = SphinxBuilder::new(
BuildConfig::default(),
source_dir.clone(),
output_dir.clone(),
)
.unwrap();
builder.enable_incremental();
assert_eq!(
builder.env().all_docs.len(),
2,
"the builder loads the saved environment"
);
builder.fresh_env().unwrap();
assert!(
builder.env().all_docs.is_empty(),
"-E starts from an empty environment, not the loaded one"
);
let stats = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(builder.build())
.unwrap();
assert_eq!(stats.cache_hits, 0, "every document is new again");
assert_eq!(stats.files_skipped, 0);
assert_eq!(builder.env().all_docs.len(), 2);
}
#[test]
fn a_non_incremental_build_reads_every_document() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
build_incrementally(&source_dir, &output_dir);
let mut builder = SphinxBuilder::new(
BuildConfig::default(),
source_dir.clone(),
output_dir.clone(),
)
.unwrap();
let stats = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(builder.build())
.unwrap();
assert_eq!(stats.cache_hits, 0);
assert_eq!(stats.files_skipped, 0, "nothing was skipped: all was read");
assert!(output_dir.join("index.html").is_file() && output_dir.join("a.html").is_file());
}
#[test]
fn cache_hit_still_writes_output_and_fills_the_environment() {
let tmp = TempDir::new().unwrap();
let source_dir = tmp.path().join("source");
let output_dir = tmp.path().join("build");
write_project(&source_dir);
let (_cold, _) = build_incrementally(&source_dir, &output_dir);
std::fs::remove_file(output_dir.join("index.html")).unwrap();
std::fs::remove_file(output_dir.join("a.html")).unwrap();
let (warm, builder) = build_incrementally(&source_dir, &output_dir);
assert_eq!(warm.cache_hits, 2);
assert!(output_dir.join("index.html").is_file());
assert!(output_dir.join("a.html").is_file());
assert_eq!(
builder.env().toctree_includes.get("index"),
Some(&vec!["a".to_string()]),
"a fully cached build still rebuilds the environment from the \
persisted doctrees"
);
}
}