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#[derive(Debug, Clone)]
20struct ToctreeEntry {
21 target: String,
23 line: usize,
25 is_glob: bool,
28}
29
30fn 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 let cache_dir = config
93 .doctree_dir
94 .clone()
95 .unwrap_or_else(|| output_dir.join(".sphinx-ultra-cache"));
96 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 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 let mut sphinx_app = SphinxApp::new(config.clone())?;
123 let mut extension_loader = ExtensionLoader::new()?;
124
125 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 pub fn fresh_env(&self) -> Result<()> {
164 self.cache.clear()
165 }
166
167 #[allow(dead_code)]
169 pub fn add_warning(&self, warning: BuildWarning) {
170 self.warnings.lock().unwrap().push(warning);
171 }
172
173 #[allow(dead_code)]
175 pub fn add_error(&self, error: BuildErrorReport) {
176 self.errors.lock().unwrap().push(error);
177 }
178
179 #[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 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 tokio::fs::create_dir_all(&self.output_dir).await?;
201
202 let source_files = self.discover_source_files().await?;
204 info!("Discovered {} source files", source_files.len());
205
206 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 let processed_docs = self
215 .process_files_parallel(&source_files, &dependency_graph)
216 .await?;
217
218 self.validate_documents(&processed_docs, &source_files)
220 .await?;
221
222 if self.config.validate_directives {
224 self.validate_directives_and_roles(&processed_docs);
225 }
226
227 if self.config.nitpicky {
231 self.validate_cross_references(&processed_docs)?;
232 }
233
234 self.generate_indices(&processed_docs).await?;
236
237 self.copy_static_assets().await?;
239
240 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, 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 let include_patterns = &self.config.include_patterns;
268 let exclude_patterns = &self.config.exclude_patterns;
269
270 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(), "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 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 let mut files = Vec::new();
301 self.discover_files_sync(&self.source_dir, &mut files)?;
302 Ok(files)
303 }
304 }
305 }
306
307 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 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 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 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 let pool = rayon::ThreadPoolBuilder::new()
369 .num_threads(self.parallel_jobs)
370 .build()?;
371
372 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 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 let content = std::fs::read_to_string(file_path)?;
423 let mut document = self.parser.parse(file_path, &content)?;
424
425 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 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 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 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 Ok(())
461 }
462
463 async fn copy_static_assets(&self) -> Result<()> {
464 info!("Copying static assets");
465
466 let static_output_dir = self.output_dir.join("_static");
468 tokio::fs::create_dir_all(&static_output_dir).await?;
469
470 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 let possible_static_dirs = [
478 exe_dir.join("../static"), exe_dir.join("../../static"), exe_dir.join("../../../static"), Path::new("rust-builder/static").to_path_buf(), ];
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 self.create_default_static_assets(&static_output_dir)
507 .await?;
508 }
509
510 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 let pygments_css = include_str!("../static/pygments.css");
530 tokio::fs::write(static_dir.join("pygments.css"), pygments_css).await?;
531
532 let theme_css = include_str!("../static/theme.css");
534 tokio::fs::write(static_dir.join("theme.css"), theme_css).await?;
535
536 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 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 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 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 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 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 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 let normalize_label = |label: &str| label.trim().to_lowercase();
727 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 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 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 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 ReferenceType::Custom(_) => {}
818 }
819 }
820 }
821
822 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 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 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; }
897 if trimmed.starts_with(':') {
898 continue; }
900
901 let target = match (trimmed.rfind('<'), trimmed.ends_with('>')) {
903 (Some(pos), true) => trimmed[pos + 1..trimmed.len() - 1].trim(),
904 _ => trimmed,
905 };
906
907 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 Ok(())
931 }
932}