Skip to main content

sphinx_ultra/
builder.rs

1use anyhow::Result;
2use log::{debug, info};
3use rayon::prelude::*;
4use std::collections::{HashMap, HashSet};
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9use crate::cache::BuildCache;
10use crate::config::BuildConfig;
11use crate::document::Document;
12use crate::error::{BuildErrorReport, BuildWarning, ErrorType};
13use crate::extensions::{ExtensionLoader, SphinxApp};
14use crate::matching;
15use crate::parser::Parser;
16use crate::utils;
17
18/// A single toctree entry with its real source position.
19#[derive(Debug, Clone)]
20struct ToctreeEntry {
21    /// The target as written (title stripped, angle-bracket target extracted).
22    target: String,
23    /// 1-based line number of the entry in its source file.
24    line: usize,
25    /// True when the containing toctree has `:glob:` and the target contains
26    /// glob metacharacters.
27    is_glob: bool,
28}
29
30/// Resolve a toctree target against the document that references it, the way
31/// Sphinx does: a leading `/` means source-root-relative, anything else is
32/// relative to the referencing document's directory. `.`/`..` segments are
33/// normalized.
34fn resolve_docname(target: &str, referencing_doc: &str) -> String {
35    let (base, target) = if let Some(stripped) = target.strip_prefix('/') {
36        ("", stripped)
37    } else {
38        (
39            referencing_doc
40                .rsplit_once('/')
41                .map(|(d, _)| d)
42                .unwrap_or(""),
43            target,
44        )
45    };
46
47    let mut segments: Vec<&str> = Vec::new();
48    for seg in base.split('/').chain(target.split('/')) {
49        match seg {
50            "" | "." => {}
51            ".." => {
52                segments.pop();
53            }
54            s => segments.push(s),
55        }
56    }
57    segments.join("/")
58}
59
60#[derive(Debug, Clone)]
61pub struct BuildStats {
62    pub files_processed: usize,
63    pub files_skipped: usize,
64    pub build_time: Duration,
65    pub output_size_mb: f64,
66    pub cache_hits: usize,
67    pub errors: usize,
68    pub warnings: usize,
69    pub warning_details: Vec<BuildWarning>,
70    pub error_details: Vec<BuildErrorReport>,
71}
72
73pub struct SphinxBuilder {
74    config: BuildConfig,
75    source_dir: PathBuf,
76    output_dir: PathBuf,
77    cache: BuildCache,
78    parser: Parser,
79    parallel_jobs: usize,
80    incremental: bool,
81    warnings: Arc<Mutex<Vec<BuildWarning>>>,
82    errors: Arc<Mutex<Vec<BuildErrorReport>>>,
83    #[allow(dead_code)]
84    sphinx_app: Option<SphinxApp>,
85    #[allow(dead_code)]
86    extension_loader: ExtensionLoader,
87}
88
89impl SphinxBuilder {
90    pub fn new(config: BuildConfig, source_dir: PathBuf, output_dir: PathBuf) -> Result<Self> {
91        // -d/doctree_dir relocates the cache (sphinx-build's doctree dir).
92        let cache_dir = config
93            .doctree_dir
94            .clone()
95            .unwrap_or_else(|| output_dir.join(".sphinx-ultra-cache"));
96        // Any config change invalidates cached documents (they were rendered
97        // under the old configuration).
98        let config_fingerprint = blake3::hash(serde_json::to_string(&config)?.as_bytes())
99            .to_hex()
100            .to_string();
101        let cache = BuildCache::new(
102            cache_dir,
103            config.max_cache_size_mb,
104            config.cache_expiration_hours,
105            &config_fingerprint,
106        )?;
107
108        // Canonicalize source_dir so it matches the canonicalized absolute paths
109        // returned by matching::get_matching_files; without this, relative
110        // --source paths (including the default ".") fail strip_prefix later.
111        let source_dir = source_dir.canonicalize().unwrap_or(source_dir);
112
113        let parser = Parser::new(&config)?;
114
115        let parallel_jobs = config.parallel_jobs.unwrap_or_else(|| {
116            std::thread::available_parallelism()
117                .map(|n| n.get())
118                .unwrap_or(4)
119        });
120
121        // Initialize Sphinx app with extensions
122        let mut sphinx_app = SphinxApp::new(config.clone())?;
123        let mut extension_loader = ExtensionLoader::new()?;
124
125        // Load configured extensions
126        for extension_name in &config.extensions {
127            match extension_loader.load_extension(extension_name) {
128                Ok(extension) => {
129                    if let Err(e) = sphinx_app.add_extension(extension) {
130                        log::warn!("Failed to add extension '{}': {}", extension_name, e);
131                    }
132                }
133                Err(e) => {
134                    log::warn!("Failed to load extension '{}': {}", extension_name, e);
135                }
136            }
137        }
138
139        Ok(Self {
140            config,
141            source_dir,
142            output_dir,
143            cache,
144            parser,
145            parallel_jobs,
146            incremental: false,
147            warnings: Arc::new(Mutex::new(Vec::new())),
148            errors: Arc::new(Mutex::new(Vec::new())),
149            sphinx_app: Some(sphinx_app),
150            extension_loader,
151        })
152    }
153
154    pub fn set_parallel_jobs(&mut self, jobs: usize) {
155        self.parallel_jobs = jobs;
156    }
157
158    pub fn enable_incremental(&mut self) {
159        self.incremental = true;
160    }
161
162    /// Discard the saved environment before building (sphinx-build `-E`).
163    pub fn fresh_env(&self) -> Result<()> {
164        self.cache.clear()
165    }
166
167    /// Add a warning to the collection
168    #[allow(dead_code)]
169    pub fn add_warning(&self, warning: BuildWarning) {
170        self.warnings.lock().unwrap().push(warning);
171    }
172
173    /// Add an error to the collection
174    #[allow(dead_code)]
175    pub fn add_error(&self, error: BuildErrorReport) {
176        self.errors.lock().unwrap().push(error);
177    }
178
179    /// Check if warnings should be treated as errors
180    #[allow(dead_code)]
181    pub fn should_fail_on_warning(&self) -> bool {
182        self.config.fail_on_warning
183    }
184
185    pub async fn clean(&self) -> Result<()> {
186        if self.output_dir.exists() {
187            tokio::fs::remove_dir_all(&self.output_dir).await?;
188        }
189        // A clean build must not reuse documents cached before the clean
190        // (the on-disk cache lived inside the output dir we just removed).
191        self.cache.clear()?;
192        Ok(())
193    }
194
195    pub async fn build(&self) -> Result<BuildStats> {
196        let start_time = Instant::now();
197        info!("Starting build process...");
198
199        // Ensure output directory exists
200        tokio::fs::create_dir_all(&self.output_dir).await?;
201
202        // Discover all source files
203        let source_files = self.discover_source_files().await?;
204        info!("Discovered {} source files", source_files.len());
205
206        // Build dependency graph
207        let dependency_graph = self.build_dependency_graph(&source_files).await?;
208        debug!(
209            "Built dependency graph with {} nodes",
210            dependency_graph.len()
211        );
212
213        // Process files in dependency order
214        let processed_docs = self
215            .process_files_parallel(&source_files, &dependency_graph)
216            .await?;
217
218        // Validate documents and collect warnings/errors
219        self.validate_documents(&processed_docs, &source_files)
220            .await?;
221
222        // Directive/role validation runs in every build unless disabled
223        if self.config.validate_directives {
224            self.validate_directives_and_roles(&processed_docs);
225        }
226
227        // Cross-reference validation is opt-in (-n/nitpicky): its heuristics
228        // still false-positive on refs we cannot resolve yet (intersphinx,
229        // python objects before the M5 sidecar).
230        if self.config.nitpicky {
231            self.validate_cross_references(&processed_docs)?;
232        }
233
234        // Generate cross-references and indices
235        self.generate_indices(&processed_docs).await?;
236
237        // Copy static assets
238        self.copy_static_assets().await?;
239
240        // Generate sitemap and search index
241        self.generate_search_index(&processed_docs).await?;
242
243        let build_time = start_time.elapsed();
244        let output_size = utils::calculate_directory_size(&self.output_dir).await?;
245
246        let warnings = self.warnings.lock().unwrap();
247        let errors = self.errors.lock().unwrap();
248
249        let stats = BuildStats {
250            files_processed: processed_docs.len(),
251            files_skipped: 0, // TODO: Track skipped files
252            build_time,
253            output_size_mb: output_size as f64 / 1024.0 / 1024.0,
254            cache_hits: self.cache.hit_count(),
255            errors: errors.len(),
256            warnings: warnings.len(),
257            warning_details: warnings.clone(),
258            error_details: errors.clone(),
259        };
260
261        info!("Build completed in {:?}", build_time);
262        Ok(stats)
263    }
264
265    async fn discover_source_files(&self) -> Result<Vec<PathBuf>> {
266        // Use pattern-based file discovery like Sphinx
267        let include_patterns = &self.config.include_patterns;
268        let exclude_patterns = &self.config.exclude_patterns;
269
270        // Add built-in exclude patterns for common build artifacts and hidden files
271        let mut all_exclude_patterns = exclude_patterns.clone();
272        all_exclude_patterns.extend_from_slice(&[
273            "_build/**".to_string(),
274            "__pycache__/**".to_string(),
275            ".git/**".to_string(),
276            ".svn/**".to_string(),
277            ".hg/**".to_string(),
278            ".*/**".to_string(), // Skip all hidden directories
279            "Thumbs.db".to_string(),
280            ".DS_Store".to_string(),
281        ]);
282
283        match matching::get_matching_files(
284            &self.source_dir,
285            include_patterns,
286            &all_exclude_patterns,
287        ) {
288            // Sphinx's Project.discover keeps only files with a configured
289            // source suffix, regardless of include_patterns
290            Ok(files) => Ok(files
291                .into_iter()
292                .filter(|path| self.is_source_file(path))
293                .collect()),
294            Err(e) => {
295                log::warn!(
296                    "Pattern matching failed, falling back to simple discovery: {}",
297                    e
298                );
299                // Fallback to old method if pattern matching fails
300                let mut files = Vec::new();
301                self.discover_files_sync(&self.source_dir, &mut files)?;
302                Ok(files)
303            }
304        }
305    }
306
307    /// Fallback file discovery for when pattern matching fails
308    fn discover_files_sync(&self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
309        for entry in std::fs::read_dir(dir)? {
310            let entry = entry?;
311            let path = entry.path();
312
313            if path.is_dir() {
314                // Skip hidden directories and build artifacts
315                if let Some(name) = path.file_name() {
316                    if name.to_string_lossy().starts_with('.')
317                        || name == "_build"
318                        || name == "__pycache__"
319                    {
320                        continue;
321                    }
322                }
323
324                self.discover_files_sync(&path, files)?;
325            } else if self.is_source_file(&path) {
326                files.push(path);
327            }
328        }
329        Ok(())
330    }
331
332    /// Fallback method to check if a file is a source file (used as backup)
333    fn is_source_file(&self, path: &Path) -> bool {
334        if let Some(ext) = path.extension() {
335            matches!(ext.to_string_lossy().as_ref(), "rst" | "md" | "txt")
336        } else {
337            false
338        }
339    }
340
341    async fn build_dependency_graph(
342        &self,
343        files: &[PathBuf],
344    ) -> Result<HashMap<PathBuf, Vec<PathBuf>>> {
345        let mut graph = HashMap::new();
346
347        // For now, simple implementation - process files in alphabetical order
348        // TODO: Parse files to find actual dependencies (includes, references, etc.)
349        for file in files {
350            graph.insert(file.clone(), Vec::new());
351        }
352
353        Ok(graph)
354    }
355
356    async fn process_files_parallel(
357        &self,
358        files: &[PathBuf],
359        _dependency_graph: &HashMap<PathBuf, Vec<PathBuf>>,
360    ) -> Result<Vec<Document>> {
361        info!(
362            "Processing {} files with {} parallel jobs",
363            files.len(),
364            self.parallel_jobs
365        );
366
367        // Configure rayon thread pool
368        let pool = rayon::ThreadPoolBuilder::new()
369            .num_threads(self.parallel_jobs)
370            .build()?;
371
372        // One file failing must not abort the build: failures become
373        // BuildErrorReports (and a non-zero exit) while the rest continue.
374        let results: Vec<(PathBuf, Result<Document>)> = pool.install(|| {
375            files
376                .par_iter()
377                .map(|file_path| (file_path.clone(), self.process_single_file(file_path)))
378                .collect()
379        });
380
381        let mut documents = Vec::with_capacity(results.len());
382        for (file_path, result) in results {
383            match result {
384                Ok(document) => documents.push(document),
385                Err(e) => {
386                    self.errors.lock().unwrap().push(BuildErrorReport::new(
387                        file_path,
388                        None,
389                        format!("{e:#}"),
390                        ErrorType::ParseError,
391                    ));
392                }
393            }
394        }
395
396        Ok(documents)
397    }
398
399    fn process_single_file(&self, file_path: &Path) -> Result<Document> {
400        let relative_path = file_path.strip_prefix(&self.source_dir)?;
401        debug!("Processing file: {}", relative_path.display());
402
403        // Check cache if incremental build is enabled. A cache hit still
404        // writes the rendered output — skipping the write is how cached pages
405        // went missing from the output tree.
406        if self.incremental {
407            if let Ok(cached_doc) = self.cache.get_document(file_path) {
408                let file_mtime = utils::get_file_mtime(file_path)?;
409                if cached_doc.source_mtime >= file_mtime && !cached_doc.html.is_empty() {
410                    debug!("Using cached version of {}", relative_path.display());
411                    let output_path = self.get_output_path(file_path)?;
412                    if let Some(parent) = output_path.parent() {
413                        std::fs::create_dir_all(parent)?;
414                    }
415                    std::fs::write(&output_path, &cached_doc.html)?;
416                    return Ok(cached_doc);
417                }
418            }
419        }
420
421        // Read and parse the file
422        let content = std::fs::read_to_string(file_path)?;
423        let mut document = self.parser.parse(file_path, &content)?;
424
425        // Simple document rendering (placeholder)
426        let rendered_html = format!(
427            "<html><body>{}</body></html>",
428            html_escape::encode_text(&document.content.to_string())
429        );
430        document.html = rendered_html;
431
432        // Write output file
433        let output_path = self.get_output_path(file_path)?;
434        if let Some(parent) = output_path.parent() {
435            std::fs::create_dir_all(parent)?;
436        }
437        std::fs::write(&output_path, &document.html)?;
438
439        // Cache the document
440        if self.incremental {
441            self.cache.store_document(file_path, &document)?;
442        }
443
444        Ok(document)
445    }
446
447    fn get_output_path(&self, source_path: &Path) -> Result<PathBuf> {
448        let relative_path = source_path.strip_prefix(&self.source_dir)?;
449        let mut output_path = self.output_dir.join(relative_path);
450
451        // Change extension to .html
452        output_path.set_extension("html");
453
454        Ok(output_path)
455    }
456
457    async fn generate_indices(&self, _documents: &[Document]) -> Result<()> {
458        info!("Generating indices and cross-references");
459        // TODO: Implement index generation
460        Ok(())
461    }
462
463    async fn copy_static_assets(&self) -> Result<()> {
464        info!("Copying static assets");
465
466        // Create _static directory
467        let static_output_dir = self.output_dir.join("_static");
468        tokio::fs::create_dir_all(&static_output_dir).await?;
469
470        // Copy built-in static assets - use relative path from binary location
471        let exe_dir = std::env::current_exe()?
472            .parent()
473            .ok_or_else(|| anyhow::anyhow!("Could not determine executable directory"))?
474            .to_path_buf();
475
476        // Try multiple possible locations for static assets
477        let possible_static_dirs = [
478            exe_dir.join("../static"),                      // Release build
479            exe_dir.join("../../static"),                   // Debug build
480            exe_dir.join("../../../static"),                // Deep build
481            Path::new("rust-builder/static").to_path_buf(), // Local development
482        ];
483
484        let mut static_assets_copied = false;
485        for builtin_static_dir in &possible_static_dirs {
486            if builtin_static_dir.exists() {
487                debug!("Found static assets at: {:?}", builtin_static_dir);
488                for entry in std::fs::read_dir(builtin_static_dir)? {
489                    let entry = entry?;
490                    let file_path = entry.path();
491                    if file_path.is_file() {
492                        let file_name = file_path.file_name().unwrap();
493                        let dest_path = static_output_dir.join(file_name);
494                        tokio::fs::copy(&file_path, &dest_path).await?;
495                        debug!("Copied static asset: {:?}", file_name);
496                    }
497                }
498                static_assets_copied = true;
499                break;
500            }
501        }
502
503        if !static_assets_copied {
504            debug!("No built-in static assets found, creating basic ones");
505            // Create minimal CSS files if not found
506            self.create_default_static_assets(&static_output_dir)
507                .await?;
508        }
509
510        // Copy project-specific static assets
511        let static_dirs = [
512            self.source_dir.join("_static"),
513            self.source_dir.join("_templates"),
514        ];
515
516        for static_dir in &static_dirs {
517            if static_dir.exists() {
518                let dest = self.output_dir.join(static_dir.file_name().unwrap());
519                utils::copy_dir_recursive(static_dir, &dest).await?;
520                debug!("Copied static directory: {:?}", static_dir);
521            }
522        }
523
524        Ok(())
525    }
526
527    async fn create_default_static_assets(&self, static_dir: &Path) -> Result<()> {
528        // Create basic pygments.css
529        let pygments_css = include_str!("../static/pygments.css");
530        tokio::fs::write(static_dir.join("pygments.css"), pygments_css).await?;
531
532        // Create basic theme.css
533        let theme_css = include_str!("../static/theme.css");
534        tokio::fs::write(static_dir.join("theme.css"), theme_css).await?;
535
536        // Create basic JavaScript files
537        let jquery_js = include_str!("../static/jquery.js");
538        tokio::fs::write(static_dir.join("jquery.js"), jquery_js).await?;
539
540        let doctools_js = include_str!("../static/doctools.js");
541        tokio::fs::write(static_dir.join("doctools.js"), doctools_js).await?;
542
543        let sphinx_highlight_js = include_str!("../static/sphinx_highlight.js");
544        tokio::fs::write(static_dir.join("sphinx_highlight.js"), sphinx_highlight_js).await?;
545
546        debug!("Created default static assets");
547        Ok(())
548    }
549
550    /// Root-relative docname (no extension, forward slashes) for a document.
551    fn docname_of(&self, doc: &Document) -> String {
552        let relative = doc
553            .source_path
554            .strip_prefix(&self.source_dir)
555            .unwrap_or(&doc.source_path);
556        relative
557            .with_extension("")
558            .to_string_lossy()
559            .replace('\\', "/")
560    }
561
562    async fn validate_documents(
563        &self,
564        processed_docs: &[Document],
565        _source_files: &[PathBuf],
566    ) -> Result<()> {
567        info!("Validating documents and checking for warnings...");
568
569        let mut all_documents = HashSet::new();
570        let mut toctree_refs: Vec<(PathBuf, String, ToctreeEntry)> = Vec::new();
571
572        for doc in processed_docs {
573            let docname = self.docname_of(doc);
574            for entry in self.extract_toctree_references(doc) {
575                toctree_refs.push((doc.source_path.clone(), docname.clone(), entry));
576            }
577            all_documents.insert(docname);
578        }
579
580        // Resolve every entry the way Sphinx does and warn on the misses.
581        let mut referenced: HashSet<String> = HashSet::new();
582        for (source_file, referencing_doc, entry) in &toctree_refs {
583            let resolved = resolve_docname(&entry.target, referencing_doc);
584
585            if entry.is_glob {
586                let matches: Vec<String> = all_documents
587                    .iter()
588                    .filter(|d| matching::pattern_match(d, &resolved).unwrap_or(false))
589                    .cloned()
590                    .collect();
591                if matches.is_empty() {
592                    self.warnings
593                        .lock()
594                        .unwrap()
595                        .push(BuildWarning::toctree_glob_no_match(
596                            source_file.clone(),
597                            Some(entry.line),
598                            &entry.target,
599                        ));
600                } else {
601                    referenced.extend(matches);
602                }
603            } else if all_documents.contains(&resolved) {
604                referenced.insert(resolved);
605            } else {
606                self.warnings
607                    .lock()
608                    .unwrap()
609                    .push(BuildWarning::missing_toctree_ref(
610                        source_file.clone(),
611                        Some(entry.line),
612                        &resolved,
613                    ));
614            }
615        }
616
617        // Orphan check: exact membership of the resolved reference set.
618        for doc in processed_docs {
619            let docname = self.docname_of(doc);
620            if docname == "index" {
621                continue;
622            }
623            if !referenced.contains(&docname) {
624                let warning = BuildWarning::orphaned_document(doc.source_path.clone());
625                self.warnings.lock().unwrap().push(warning);
626            }
627        }
628
629        let warning_count = self.warnings.lock().unwrap().len();
630        info!("Validation completed. Found {} warnings", warning_count);
631
632        Ok(())
633    }
634
635    /// Run the directive/role validation system over every RST document.
636    ///
637    /// Findings surface as build *warnings* (so `-W`/`-w` govern promotion);
638    /// `Unknown` results stay silent — the built-in validators cover a
639    /// fraction of real Sphinx, and reporting the rest would drown every
640    /// real project in noise.
641    fn validate_directives_and_roles(&self, processed_docs: &[Document]) {
642        use crate::directives::validation::{
643            DirectiveRoleParser, DirectiveValidationResult, DirectiveValidationSystem,
644            RoleValidationResult,
645        };
646        use crate::document::DocumentContent;
647
648        let results: Vec<(Vec<BuildWarning>, usize)> = processed_docs
649            .par_iter()
650            .filter_map(|doc| {
651                let raw = match &doc.content {
652                    DocumentContent::RestructuredText(rst) => &rst.raw,
653                    _ => return None,
654                };
655
656                let mut warnings = Vec::new();
657                let mut unknown = 0usize;
658                // Statistics make validate_* take &mut self, so each document
659                // gets its own (cheap) system instance for the parallel pass.
660                let mut system = DirectiveValidationSystem::new();
661                let parser = DirectiveRoleParser::new(doc.source_path.display().to_string());
662                let (directives, roles) = parser.parse_content(raw);
663
664                for directive in &directives {
665                    match system.validate_directive(directive) {
666                        DirectiveValidationResult::Valid => {}
667                        DirectiveValidationResult::Unknown => unknown += 1,
668                        DirectiveValidationResult::Warning(msg)
669                        | DirectiveValidationResult::Error(msg) => {
670                            warnings.push(BuildWarning::new(
671                                doc.source_path.clone(),
672                                Some(directive.location.line),
673                                msg,
674                                crate::error::WarningType::Other,
675                            ));
676                        }
677                    }
678                }
679
680                for role in &roles {
681                    match system.validate_role(role) {
682                        RoleValidationResult::Valid => {}
683                        RoleValidationResult::Unknown => unknown += 1,
684                        RoleValidationResult::Warning(msg) | RoleValidationResult::Error(msg) => {
685                            warnings.push(BuildWarning::new(
686                                doc.source_path.clone(),
687                                Some(role.location.line),
688                                msg,
689                                crate::error::WarningType::Other,
690                            ));
691                        }
692                    }
693                }
694
695                Some((warnings, unknown))
696            })
697            .collect();
698
699        let mut unknown_total = 0usize;
700        for (warnings, unknown) in results {
701            unknown_total += unknown;
702            for warning in warnings {
703                self.add_warning(warning);
704            }
705        }
706        if unknown_total > 0 {
707            debug!(
708                "{} directive/role occurrence(s) had no validator and were not checked",
709                unknown_total
710            );
711        }
712    }
713
714    /// Nitpicky cross-reference validation (`-n`): resolve every `:doc:` and
715    /// `:ref:` against the documents and labels this build actually produced,
716    /// via the domain registry. Python-domain references are counted but not
717    /// validated (no object inventory until the M5 sidecar) — silently
718    /// reporting them broken would false-positive on every third-party ref.
719    fn validate_cross_references(&self, processed_docs: &[Document]) -> Result<()> {
720        use crate::document::DocumentContent;
721        use crate::domains::parser::ReferenceParser;
722        use crate::domains::rst::RstDomain;
723        use crate::domains::{DomainRegistry, ReferenceType};
724
725        // docutils label matching is case-insensitive: normalize both sides.
726        let normalize_label = |label: &str| label.trim().to_lowercase();
727        // `.. _label:` and `.. _label: target` both define `label`.
728        let label_regex = regex::Regex::new(r"^\.\.\s+_([^:]+):").expect("static regex");
729
730        let mut rst_domain = RstDomain::new();
731        for doc in processed_docs {
732            let docname = self.docname_of(doc);
733            let location = crate::domains::ReferenceLocation {
734                docname: docname.clone(),
735                lineno: None,
736                column: None,
737                source_path: Some(doc.source_path.display().to_string()),
738            };
739            rst_domain.register_document(docname.clone(), doc.title.clone(), location.clone())?;
740
741            // Explicit `.. _label:` targets from the raw source (the prototype
742            // parser has no target nodes until M2).
743            if let DocumentContent::RestructuredText(rst) = &doc.content {
744                for (idx, line) in rst.raw.lines().enumerate() {
745                    if let Some(cap) = label_regex.captures(line) {
746                        let label = normalize_label(&cap[1]);
747                        rst_domain.register_label(
748                            label,
749                            "section".to_string(),
750                            None,
751                            docname.clone(),
752                            crate::domains::ReferenceLocation {
753                                lineno: Some(idx + 1),
754                                ..location.clone()
755                            },
756                        )?;
757                    }
758                }
759            }
760
761            // Section anchors double as :ref: targets (autosectionlabel-style;
762            // better than false-positives on every section reference).
763            let mut stack: Vec<&crate::document::TocEntry> = doc.toc.iter().collect();
764            while let Some(entry) = stack.pop() {
765                stack.extend(entry.children.iter());
766                rst_domain.register_section(
767                    normalize_label(&entry.anchor),
768                    entry.title.clone(),
769                    docname.clone(),
770                    crate::domains::ReferenceLocation {
771                        lineno: Some(entry.line_number),
772                        ..location.clone()
773                    },
774                )?;
775            }
776        }
777
778        let mut registry = DomainRegistry::new();
779        registry.register_domain(Box::new(rst_domain))?;
780
781        let reference_parser = ReferenceParser::new();
782        let mut python_refs = 0usize;
783        for doc in processed_docs {
784            let raw = match &doc.content {
785                DocumentContent::RestructuredText(rst) => &rst.raw,
786                _ => continue,
787            };
788            let docname = self.docname_of(doc);
789            let refs = reference_parser.parse_content(
790                raw,
791                &docname,
792                Some(doc.source_path.display().to_string()),
793            );
794            for mut reference in refs {
795                if reference.is_external {
796                    continue;
797                }
798                match reference.ref_type {
799                    ReferenceType::Document => {
800                        // :doc: targets resolve like toctree entries: leading
801                        // `/` is source-root-relative, else current-doc-relative.
802                        reference.target = resolve_docname(&reference.target, &docname);
803                        registry.add_cross_reference(reference);
804                    }
805                    ReferenceType::Section => {
806                        reference.target = normalize_label(&reference.target);
807                        registry.add_cross_reference(reference);
808                    }
809                    ReferenceType::Function
810                    | ReferenceType::Class
811                    | ReferenceType::Module
812                    | ReferenceType::Method
813                    | ReferenceType::Attribute
814                    | ReferenceType::Data
815                    | ReferenceType::Exception => python_refs += 1,
816                    // numref/envvar/option and friends: no resolver yet.
817                    ReferenceType::Custom(_) => {}
818                }
819            }
820        }
821
822        // Validate exactly once; stats/broken helpers re-validate internally.
823        for result in registry.validate_all_references() {
824            if result.is_valid {
825                continue;
826            }
827            let reference = &result.reference;
828            let message = match reference.ref_type {
829                ReferenceType::Document => {
830                    format!("unknown document: '{}'", reference.target)
831                }
832                ReferenceType::Section => {
833                    format!("undefined label: '{}'", reference.target)
834                }
835                _ => continue,
836            };
837            let file = reference
838                .source_location
839                .source_path
840                .as_deref()
841                .map(PathBuf::from)
842                .unwrap_or_else(|| PathBuf::from(&reference.source_location.docname));
843            self.add_warning(BuildWarning::new(
844                file,
845                reference.source_location.lineno,
846                message,
847                crate::error::WarningType::BrokenCrossReference,
848            ));
849        }
850
851        if python_refs > 0 {
852            info!(
853                "{} python-domain reference(s) not validated (no object inventory until M5)",
854                python_refs
855            );
856        }
857
858        Ok(())
859    }
860
861    /// Extract toctree entries with their real line numbers by re-scanning the
862    /// raw source from each toctree directive's position (the parsed directive
863    /// content has lost its line offsets).
864    fn extract_toctree_references(&self, doc: &Document) -> Vec<ToctreeEntry> {
865        use crate::document::DocumentContent;
866
867        let mut entries = Vec::new();
868
869        let rst_content = match &doc.content {
870            DocumentContent::RestructuredText(rst) => rst,
871            _ => return entries,
872        };
873
874        let raw_lines: Vec<&str> = rst_content.raw.lines().collect();
875
876        for node in &rst_content.ast {
877            let (options, directive_line) = match node {
878                crate::document::RstNode::Directive {
879                    name,
880                    options,
881                    line,
882                    ..
883                } if name == "toctree" => (options, *line),
884                _ => continue,
885            };
886            let glob_enabled = options.contains_key("glob");
887
888            // Scan the block following the `.. toctree::` marker line.
889            for (idx, raw_line) in raw_lines.iter().enumerate().skip(directive_line) {
890                let trimmed = raw_line.trim();
891                if trimmed.is_empty() {
892                    continue;
893                }
894                if !raw_line.starts_with(' ') && !raw_line.starts_with('\t') {
895                    break; // dedent ends the directive block
896                }
897                if trimmed.starts_with(':') {
898                    continue; // option line (docnames cannot start with ':')
899                }
900
901                // `Title <target>` form: the angle brackets carry the target.
902                let target = match (trimmed.rfind('<'), trimmed.ends_with('>')) {
903                    (Some(pos), true) => trimmed[pos + 1..trimmed.len() - 1].trim(),
904                    _ => trimmed,
905                };
906
907                // External URLs and the `self` keyword are valid entries that
908                // do not reference source documents.
909                if target.starts_with("http://")
910                    || target.starts_with("https://")
911                    || target == "self"
912                {
913                    continue;
914                }
915
916                entries.push(ToctreeEntry {
917                    target: target.to_string(),
918                    line: idx + 1,
919                    is_glob: glob_enabled && target.contains(['*', '?', '[']),
920                });
921            }
922        }
923
924        entries
925    }
926
927    async fn generate_search_index(&self, _documents: &[Document]) -> Result<()> {
928        info!("Generating search index");
929        // TODO: Implement search index generation
930        Ok(())
931    }
932}