1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use tokio::sync::{RwLock, mpsc};
13use tower_lsp::Client;
14use tower_lsp::lsp_types::*;
15
16use crate::config::{Config, MarkdownFlavor};
17use crate::discovery::{ExcludeMatchers, MarkdownWalkOptions, is_markdown_extension, path_relative_to};
18use crate::lint_context::LintContext;
19use crate::lsp::types::{IndexState, IndexUpdate};
20use crate::utils::anchor_styles::AnchorStyle;
21use crate::workspace_index::{FileIndex, HeadingIndex, WorkspaceIndex, extract_cross_file_links};
22
23pub(super) fn index_walk_options(config: &Config) -> MarkdownWalkOptions {
31 MarkdownWalkOptions {
32 respect_gitignore: config.global.respect_gitignore,
33 skip_vendor_dirs: true,
34 }
35}
36
37pub struct IndexWorker {
42 rx: mpsc::Receiver<IndexUpdate>,
44 workspace_index: Arc<RwLock<WorkspaceIndex>>,
46 index_state: Arc<RwLock<IndexState>>,
48 client: Client,
50 workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
52 pending: HashMap<PathBuf, (String, Instant)>,
54 debounce_duration: Duration,
56 relint_tx: mpsc::Sender<PathBuf>,
58 rumdl_config: Arc<RwLock<Config>>,
61}
62
63impl IndexWorker {
64 pub fn new(
66 rx: mpsc::Receiver<IndexUpdate>,
67 workspace_index: Arc<RwLock<WorkspaceIndex>>,
68 index_state: Arc<RwLock<IndexState>>,
69 client: Client,
70 workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
71 relint_tx: mpsc::Sender<PathBuf>,
72 rumdl_config: Arc<RwLock<Config>>,
73 ) -> Self {
74 Self {
75 rx,
76 workspace_index,
77 index_state,
78 client,
79 workspace_roots,
80 pending: HashMap::new(),
81 debounce_duration: Duration::from_millis(100),
82 relint_tx,
83 rumdl_config,
84 }
85 }
86
87 pub async fn run(mut self) {
89 let mut debounce_interval = tokio::time::interval(Duration::from_millis(50));
90
91 loop {
92 tokio::select! {
93 msg = self.rx.recv() => {
95 match msg {
96 Some(IndexUpdate::FileChanged { path, content }) => {
97 self.pending.insert(path, (content, Instant::now()));
98 }
99 Some(IndexUpdate::FileDeleted { path }) => {
100 self.handle_file_deleted(&path).await;
101 }
102 Some(IndexUpdate::FullRescan) => {
103 self.full_rescan().await;
104 }
105 Some(IndexUpdate::Shutdown) | None => {
106 log::info!("Index worker shutting down");
107 break;
108 }
109 }
110 }
111
112 _ = debounce_interval.tick() => {
114 self.process_pending_updates().await;
115 }
116 }
117 }
118 }
119
120 async fn process_pending_updates(&mut self) {
122 let now = Instant::now();
123 let ready: Vec<_> = self
124 .pending
125 .iter()
126 .filter(|(_, (_, time))| now.duration_since(*time) >= self.debounce_duration)
127 .map(|(path, _)| path.clone())
128 .collect();
129
130 for path in ready {
131 if let Some((content, _)) = self.pending.remove(&path) {
132 self.update_single_file(&path, &content).await;
133 }
134 }
135 }
136
137 async fn update_single_file(&self, path: &Path, content: &str) {
139 let flavor = self.rumdl_config.read().await.get_flavor_for_file(path);
141 let Ok(file_index) =
142 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Self::build_file_index(content, flavor)))
143 else {
144 log::error!("Panic while indexing {}: skipping", path.display());
145 return;
146 };
147
148 let old_dependents = {
150 let index = self.workspace_index.read().await;
151 index.get_dependents(path)
152 };
153
154 {
156 let mut index = self.workspace_index.write().await;
157 index.update_file(path, file_index);
158 }
159
160 let new_dependents = {
162 let index = self.workspace_index.read().await;
163 index.get_dependents(path)
164 };
165
166 let mut affected: std::collections::HashSet<PathBuf> = old_dependents.into_iter().collect();
168 affected.extend(new_dependents);
169
170 for dep_path in affected {
171 if self.relint_tx.send(dep_path.clone()).await.is_err() {
172 log::warn!("Failed to send re-lint request for {}", dep_path.display());
173 }
174 }
175 }
176
177 pub(super) fn build_file_index(content: &str, flavor: MarkdownFlavor) -> FileIndex {
181 let ctx = LintContext::new(content, flavor, None);
182 let mut file_index = FileIndex::new();
183
184 for (line_num, line_info) in ctx.lines.iter().enumerate() {
186 if let Some(heading) = &line_info.heading {
187 let auto_anchor = AnchorStyle::GitHub.generate_fragment(&heading.text);
188 let is_setext = matches!(
189 heading.style,
190 crate::lint_context::types::HeadingStyle::Setext1
191 | crate::lint_context::types::HeadingStyle::Setext2
192 );
193
194 file_index.add_heading(HeadingIndex {
195 text: heading.text.clone(),
196 auto_anchor,
197 custom_anchor: heading.custom_id.clone(),
198 line: line_num + 1, is_setext,
200 });
201 }
202 }
203
204 let links = extract_cross_file_links(&ctx);
207 for link in links.relative {
208 file_index.add_cross_file_link(link);
209 }
210 for link in links.root_relative {
211 file_index.add_root_relative_link(link);
212 }
213
214 file_index
215 }
216
217 async fn handle_file_deleted(&self, path: &Path) {
219 let dependents = {
224 let index = self.workspace_index.read().await;
225 index.get_dependents(path)
226 };
227
228 {
230 let mut index = self.workspace_index.write().await;
231 index.remove_file(path);
232 }
233
234 for dep_path in dependents {
236 if self.relint_tx.send(dep_path.clone()).await.is_err() {
237 log::warn!("Failed to send re-lint request for {}", dep_path.display());
238 }
239 }
240 }
241
242 async fn full_rescan(&mut self) {
244 self.pending.clear();
246
247 let roots = self.workspace_roots.read().await.clone();
249 let (options, excludes) = {
250 let config = self.rumdl_config.read().await;
251 (
252 index_walk_options(&config),
253 ExcludeMatchers::new(&config.global.exclude),
254 )
255 };
256 for (pattern, error) in &excludes.invalid {
257 log::warn!("Invalid exclude pattern '{pattern}': {error}");
258 }
259 let files = scan_markdown_files(&roots, options, excludes).await;
260 let total = files.len();
261
262 {
267 let current: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
268 let removed = self.workspace_index.write().await.retain_only(¤t);
269 if removed > 0 {
270 log::info!("Workspace rescan evicted {removed} stale index entries");
271 }
272 }
273
274 if total == 0 {
275 *self.index_state.write().await = IndexState::Ready;
276 return;
277 }
278
279 *self.index_state.write().await = IndexState::Building {
281 progress: 0.0,
282 files_indexed: 0,
283 total_files: total,
284 };
285
286 self.report_progress_begin(total).await;
288
289 for (i, path) in files.iter().enumerate() {
291 if let Ok(content) = tokio::fs::read_to_string(path).await {
292 let flavor = self.rumdl_config.read().await.get_flavor_for_file(path);
293 let file_index = Self::build_file_index(&content, flavor);
294
295 let mut index = self.workspace_index.write().await;
296 index.update_file(path, file_index);
297 }
298
299 if i % 10 == 0 || i == total - 1 {
301 let progress = ((i + 1) as f32 / total as f32) * 100.0;
302 *self.index_state.write().await = IndexState::Building {
303 progress,
304 files_indexed: i + 1,
305 total_files: total,
306 };
307 self.report_progress_update(i + 1, total).await;
308 }
309 }
310
311 *self.index_state.write().await = IndexState::Ready;
313 self.report_progress_done().await;
314
315 log::info!("Workspace indexing complete: {total} files indexed");
316 }
317
318 async fn report_progress_begin(&self, total: usize) {
320 let token = NumberOrString::String("rumdl-index".to_string());
321
322 if self
324 .client
325 .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams { token: token.clone() })
326 .await
327 .is_err()
328 {
329 log::debug!("Client does not support work done progress");
330 return;
331 }
332
333 self.client
335 .send_notification::<notification::Progress>(ProgressParams {
336 token,
337 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin {
338 title: "Indexing workspace".to_string(),
339 cancellable: Some(false),
340 message: Some(format!("Scanning {total} markdown files...")),
341 percentage: Some(0),
342 })),
343 })
344 .await;
345 }
346
347 async fn report_progress_update(&self, indexed: usize, total: usize) {
349 let token = NumberOrString::String("rumdl-index".to_string());
350 let percentage = ((indexed as f32 / total as f32) * 100.0) as u32;
351
352 self.client
353 .send_notification::<notification::Progress>(ProgressParams {
354 token,
355 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(WorkDoneProgressReport {
356 cancellable: Some(false),
357 message: Some(format!("Indexed {indexed}/{total} files")),
358 percentage: Some(percentage),
359 })),
360 })
361 .await;
362 }
363
364 async fn report_progress_done(&self) {
366 let token = NumberOrString::String("rumdl-index".to_string());
367
368 self.client
369 .send_notification::<notification::Progress>(ProgressParams {
370 token,
371 value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
372 message: Some("Indexing complete".to_string()),
373 })),
374 })
375 .await;
376 }
377}
378
379async fn scan_markdown_files(
386 roots: &[PathBuf],
387 options: MarkdownWalkOptions,
388 excludes: ExcludeMatchers,
389) -> Vec<PathBuf> {
390 let roots = roots.to_vec();
391 tokio::task::spawn_blocking(move || collect_markdown_files(&roots, &options, &excludes))
392 .await
393 .unwrap_or_else(|e| {
394 log::warn!("Workspace scan task failed: {e}");
395 Vec::new()
396 })
397}
398
399fn collect_markdown_files(
403 roots: &[PathBuf],
404 options: &MarkdownWalkOptions,
405 excludes: &ExcludeMatchers,
406) -> Vec<PathBuf> {
407 let mut files = Vec::new();
408
409 for root in roots {
410 for result in crate::discovery::markdown_walk_builder(root, options).build() {
411 match result {
412 Ok(entry) => {
413 let path = entry.path();
414 if entry.file_type().is_some_and(|t| t.is_file())
415 && let Some(ext) = path.extension()
416 && is_markdown_extension(ext)
417 && !excluded_relative_to_root(excludes, path, root)
418 {
419 files.push(path.to_path_buf());
420 }
421 }
422 Err(e) => log::warn!("Error scanning {}: {}", root.display(), e),
423 }
424 }
425 }
426
427 files
428}
429
430fn excluded_relative_to_root(excludes: &ExcludeMatchers, path: &Path, root: &Path) -> bool {
433 if excludes.is_empty() {
434 return false;
435 }
436 path_relative_to(path, root).is_some_and(|rel| excludes.is_match(&rel))
437}
438
439pub(super) fn path_is_ignored_for_index(
458 roots: &[PathBuf],
459 path: &Path,
460 options: &MarkdownWalkOptions,
461 excludes: &ExcludeMatchers,
462) -> bool {
463 let Some(root) = roots
466 .iter()
467 .filter(|r| path.starts_with(r))
468 .max_by_key(|r| r.components().count())
469 else {
470 return false;
471 };
472
473 if options.skip_vendor_dirs
478 && let Ok(rel) = path.strip_prefix(root)
479 && rel.components().any(
480 |c| matches!(c, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target"),
481 )
482 {
483 return true;
484 }
485
486 if excluded_relative_to_root(excludes, path, root) {
488 return true;
489 }
490
491 let target = path.to_path_buf();
492 let mut builder = crate::discovery::markdown_walk_builder(root, options);
493 builder.filter_entry(move |entry| target.starts_with(entry.path()));
499 for entry in builder.build().flatten() {
500 if entry.path() == path {
501 return false;
502 }
503 }
504 true
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_build_file_index() {
513 let content = r#"
514# Main Heading
515
516Some text.
517
518## Sub Heading {#sub}
519
520More text with [link](./other.md#section).
521"#;
522
523 let index = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::default());
524
525 assert_eq!(index.headings.len(), 2);
526 assert_eq!(index.headings[0].text, "Main Heading");
527 assert!(index.headings[0].custom_anchor.is_none());
528
529 assert_eq!(index.headings[1].text, "Sub Heading");
531 assert_eq!(index.headings[1].custom_anchor, Some("sub".to_string()));
532
533 assert_eq!(index.cross_file_links.len(), 1);
534 assert_eq!(index.cross_file_links[0].target_path, "./other.md");
535 assert_eq!(index.cross_file_links[0].fragment, "section");
536 }
537
538 #[test]
539 fn test_build_file_index_respects_flavor() {
540 let content = "# Real\n\n# -8<- [start:section]\n";
544
545 let standard = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::Standard);
546 assert_eq!(
547 standard.headings.len(),
548 2,
549 "Standard treats the snippet line as a heading"
550 );
551
552 let mkdocs = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::MkDocs);
553 assert_eq!(mkdocs.headings.len(), 1, "MkDocs excludes the snippet marker");
554 assert_eq!(mkdocs.headings[0].text, "Real");
555 }
556
557 #[test]
558 fn test_build_file_index_column_positions() {
559 let content = "See [link](./file.md) here.\n";
561
562 let index = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::default());
563
564 assert_eq!(index.cross_file_links.len(), 1);
565 assert_eq!(index.cross_file_links[0].target_path, "./file.md");
566 assert_eq!(index.cross_file_links[0].line, 1);
567 assert_eq!(index.cross_file_links[0].column, 12);
569 }
570
571 #[test]
572 fn test_build_file_index_multiple_links() {
573 let content = "First [a](./a.md) and [b](./b.md#section) links.\n";
574
575 let index = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::default());
576
577 assert_eq!(index.cross_file_links.len(), 2);
578
579 assert_eq!(index.cross_file_links[0].target_path, "./a.md");
581 assert_eq!(index.cross_file_links[0].column, 11);
582
583 assert_eq!(index.cross_file_links[1].target_path, "./b.md");
585 assert_eq!(index.cross_file_links[1].fragment, "section");
586 assert_eq!(index.cross_file_links[1].column, 27);
587 }
588
589 #[test]
590 fn test_collect_markdown_files_respects_gitignore() {
591 use std::fs;
592
593 let dir = tempfile::tempdir().unwrap();
594 let root = dir.path();
595
596 fs::write(root.join("README.md"), "# Readme\n").unwrap();
598 fs::write(root.join(".gitignore"), "build/\nignored.md\n").unwrap();
599 fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
600 fs::create_dir(root.join("build")).unwrap();
601 fs::write(root.join("build").join("generated.md"), "# Generated\n").unwrap();
602
603 fs::create_dir(root.join("node_modules")).unwrap();
605 fs::write(root.join("node_modules").join("dep.md"), "# Dep\n").unwrap();
606
607 let mut files = collect_markdown_files(
608 &[root.to_path_buf()],
609 &index_walk_options(&Config::default()),
610 &ExcludeMatchers::new(&[]),
611 );
612 files.sort();
613
614 let names: Vec<String> = files
615 .iter()
616 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
617 .collect();
618
619 assert_eq!(names, vec!["README.md".to_string()]);
620 }
621
622 #[test]
623 fn test_collect_markdown_files_applies_config_excludes() {
624 use std::fs;
625
626 let dir = tempfile::tempdir().unwrap();
627 let root = dir.path();
628
629 fs::write(root.join("README.md"), "# Readme\n").unwrap();
630 fs::create_dir(root.join("drafts")).unwrap();
631 fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
632
633 let excludes = ExcludeMatchers::new(&["drafts".to_string()]);
636 let names: Vec<String> = collect_markdown_files(
637 &[root.to_path_buf()],
638 &index_walk_options(&Config::default()),
639 &excludes,
640 )
641 .iter()
642 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
643 .collect();
644
645 assert_eq!(names, vec!["README.md".to_string()]);
646 }
647
648 #[test]
649 fn test_collect_markdown_files_can_disable_gitignore() {
650 use std::fs;
651
652 let dir = tempfile::tempdir().unwrap();
653 let root = dir.path();
654
655 fs::write(root.join(".gitignore"), "ignored.md\n").unwrap();
656 fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
657
658 let mut config = Config::default();
659 config.global.respect_gitignore = false;
660 let names: Vec<String> = collect_markdown_files(
661 &[root.to_path_buf()],
662 &index_walk_options(&config),
663 &ExcludeMatchers::new(&[]),
664 )
665 .iter()
666 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
667 .collect();
668
669 assert_eq!(names, vec!["ignored.md".to_string()]);
670 }
671
672 #[test]
673 fn test_collect_markdown_files_includes_hidden_files() {
674 use std::fs;
675
676 let dir = tempfile::tempdir().unwrap();
677 let root = dir.path();
678
679 fs::create_dir(root.join(".github")).unwrap();
680 fs::write(root.join(".github").join("PULL_REQUEST_TEMPLATE.md"), "# PR\n").unwrap();
681 fs::write(root.join("README.md"), "# Readme\n").unwrap();
682
683 let mut names: Vec<String> = collect_markdown_files(
684 &[root.to_path_buf()],
685 &index_walk_options(&Config::default()),
686 &ExcludeMatchers::new(&[]),
687 )
688 .iter()
689 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
690 .collect();
691 names.sort();
692
693 assert_eq!(
695 names,
696 vec!["PULL_REQUEST_TEMPLATE.md".to_string(), "README.md".to_string()]
697 );
698 }
699
700 #[test]
701 fn test_collect_markdown_files_finds_nested_markdown() {
702 use std::fs;
703
704 let dir = tempfile::tempdir().unwrap();
705 let root = dir.path();
706
707 fs::write(root.join("top.md"), "# Top\n").unwrap();
708 fs::create_dir(root.join("docs")).unwrap();
709 fs::write(root.join("docs").join("guide.markdown"), "# Guide\n").unwrap();
710 fs::write(root.join("docs").join("notes.txt"), "not markdown\n").unwrap();
711
712 let mut names: Vec<String> = collect_markdown_files(
713 &[root.to_path_buf()],
714 &index_walk_options(&Config::default()),
715 &ExcludeMatchers::new(&[]),
716 )
717 .iter()
718 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
719 .collect();
720 names.sort();
721
722 assert_eq!(names, vec!["guide.markdown".to_string(), "top.md".to_string()]);
723 }
724
725 #[test]
726 fn test_path_is_ignored_for_index() {
727 use std::fs;
728
729 let dir = tempfile::tempdir().unwrap();
730 let root = dir.path().to_path_buf();
731 fs::write(root.join(".gitignore"), "build/\ndraft.md\n").unwrap();
732
733 fs::write(root.join("README.md"), "").unwrap();
736 fs::write(root.join("draft.md"), "").unwrap();
737 fs::write(root.join(".hidden.md"), "").unwrap();
738 fs::create_dir(root.join("docs")).unwrap();
739 fs::write(root.join("docs").join("guide.md"), "").unwrap();
740 fs::create_dir(root.join("build")).unwrap();
741 fs::write(root.join("build").join("out.md"), "").unwrap();
742
743 let roots = vec![root.clone()];
744 let options = index_walk_options(&Config::default());
745 let no_excludes = ExcludeMatchers::new(&[]);
746
747 assert!(!path_is_ignored_for_index(
749 &roots,
750 &root.join("README.md"),
751 &options,
752 &no_excludes
753 ));
754 assert!(!path_is_ignored_for_index(
755 &roots,
756 &root.join("docs/guide.md"),
757 &options,
758 &no_excludes
759 ));
760
761 assert!(path_is_ignored_for_index(
763 &roots,
764 &root.join("draft.md"),
765 &options,
766 &no_excludes
767 ));
768 assert!(path_is_ignored_for_index(
769 &roots,
770 &root.join("build/out.md"),
771 &options,
772 &no_excludes
773 ));
774
775 assert!(!path_is_ignored_for_index(
777 &roots,
778 &root.join(".hidden.md"),
779 &options,
780 &no_excludes
781 ));
782
783 assert!(path_is_ignored_for_index(
786 &roots,
787 &root.join("node_modules/dep.md"),
788 &options,
789 &no_excludes
790 ));
791 assert!(path_is_ignored_for_index(
792 &roots,
793 &root.join("target/doc.md"),
794 &options,
795 &no_excludes
796 ));
797
798 let excludes = ExcludeMatchers::new(&["docs".to_string()]);
800 assert!(path_is_ignored_for_index(
801 &roots,
802 &root.join("docs/guide.md"),
803 &options,
804 &excludes
805 ));
806 assert!(!path_is_ignored_for_index(
807 &roots,
808 &root.join("README.md"),
809 &options,
810 &excludes
811 ));
812
813 let outside = dir.path().parent().unwrap().join("elsewhere.md");
815 assert!(!path_is_ignored_for_index(&roots, &outside, &options, &no_excludes));
816 }
817
818 #[test]
819 fn test_path_is_ignored_for_index_honors_nested_gitignore() {
820 use std::fs;
821
822 let dir = tempfile::tempdir().unwrap();
823 let root = dir.path().to_path_buf();
824 fs::create_dir(root.join("docs")).unwrap();
825 fs::write(root.join("docs").join(".gitignore"), "generated.md\n").unwrap();
826 fs::write(root.join("docs").join("generated.md"), "").unwrap();
827 fs::write(root.join("docs").join("manual.md"), "").unwrap();
828
829 let roots = vec![root.clone()];
830 let options = index_walk_options(&Config::default());
831 let no_excludes = ExcludeMatchers::new(&[]);
832
833 assert!(path_is_ignored_for_index(
834 &roots,
835 &root.join("docs/generated.md"),
836 &options,
837 &no_excludes
838 ));
839 assert!(!path_is_ignored_for_index(
840 &roots,
841 &root.join("docs/manual.md"),
842 &options,
843 &no_excludes
844 ));
845 }
846
847 #[test]
848 fn test_path_is_ignored_for_index_workspace_under_target_dir() {
849 use std::fs;
850
851 let dir = tempfile::tempdir().unwrap();
854 let root = dir.path().join("target").join("my-docs");
855 fs::create_dir_all(&root).unwrap();
856 fs::write(root.join("README.md"), "").unwrap();
857 fs::create_dir(root.join("target")).unwrap();
858 fs::write(root.join("target").join("out.md"), "").unwrap();
859
860 let roots = vec![root.clone()];
861 let options = index_walk_options(&Config::default());
862 let no_excludes = ExcludeMatchers::new(&[]);
863
864 assert!(!path_is_ignored_for_index(
867 &roots,
868 &root.join("README.md"),
869 &options,
870 &no_excludes
871 ));
872 assert!(path_is_ignored_for_index(
874 &roots,
875 &root.join("target/out.md"),
876 &options,
877 &no_excludes
878 ));
879 }
880}