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 Ok(file_index) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Self::build_file_index(content)))
141 else {
142 log::error!("Panic while indexing {}: skipping", path.display());
143 return;
144 };
145
146 let old_dependents = {
148 let index = self.workspace_index.read().await;
149 index.get_dependents(path)
150 };
151
152 {
154 let mut index = self.workspace_index.write().await;
155 index.update_file(path, file_index);
156 }
157
158 let new_dependents = {
160 let index = self.workspace_index.read().await;
161 index.get_dependents(path)
162 };
163
164 let mut affected: std::collections::HashSet<PathBuf> = old_dependents.into_iter().collect();
166 affected.extend(new_dependents);
167
168 for dep_path in affected {
169 if self.relint_tx.send(dep_path.clone()).await.is_err() {
170 log::warn!("Failed to send re-lint request for {}", dep_path.display());
171 }
172 }
173 }
174
175 pub(super) fn build_file_index(content: &str) -> FileIndex {
177 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
178 let mut file_index = FileIndex::new();
179
180 for (line_num, line_info) in ctx.lines.iter().enumerate() {
182 if let Some(heading) = &line_info.heading {
183 let auto_anchor = AnchorStyle::GitHub.generate_fragment(&heading.text);
184 let is_setext = matches!(
185 heading.style,
186 crate::lint_context::types::HeadingStyle::Setext1
187 | crate::lint_context::types::HeadingStyle::Setext2
188 );
189
190 file_index.add_heading(HeadingIndex {
191 text: heading.text.clone(),
192 auto_anchor,
193 custom_anchor: heading.custom_id.clone(),
194 line: line_num + 1, is_setext,
196 });
197 }
198 }
199
200 let links = extract_cross_file_links(&ctx);
203 for link in links.relative {
204 file_index.add_cross_file_link(link);
205 }
206 for link in links.root_relative {
207 file_index.add_root_relative_link(link);
208 }
209
210 file_index
211 }
212
213 async fn handle_file_deleted(&self, path: &Path) {
215 let dependents = {
220 let index = self.workspace_index.read().await;
221 index.get_dependents(path)
222 };
223
224 {
226 let mut index = self.workspace_index.write().await;
227 index.remove_file(path);
228 }
229
230 for dep_path in dependents {
232 if self.relint_tx.send(dep_path.clone()).await.is_err() {
233 log::warn!("Failed to send re-lint request for {}", dep_path.display());
234 }
235 }
236 }
237
238 async fn full_rescan(&mut self) {
240 self.pending.clear();
242
243 let roots = self.workspace_roots.read().await.clone();
245 let (options, excludes) = {
246 let config = self.rumdl_config.read().await;
247 (
248 index_walk_options(&config),
249 ExcludeMatchers::new(&config.global.exclude),
250 )
251 };
252 for (pattern, error) in &excludes.invalid {
253 log::warn!("Invalid exclude pattern '{pattern}': {error}");
254 }
255 let files = scan_markdown_files(&roots, options, excludes).await;
256 let total = files.len();
257
258 {
263 let current: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
264 let removed = self.workspace_index.write().await.retain_only(¤t);
265 if removed > 0 {
266 log::info!("Workspace rescan evicted {removed} stale index entries");
267 }
268 }
269
270 if total == 0 {
271 *self.index_state.write().await = IndexState::Ready;
272 return;
273 }
274
275 *self.index_state.write().await = IndexState::Building {
277 progress: 0.0,
278 files_indexed: 0,
279 total_files: total,
280 };
281
282 self.report_progress_begin(total).await;
284
285 for (i, path) in files.iter().enumerate() {
287 if let Ok(content) = tokio::fs::read_to_string(path).await {
288 let file_index = Self::build_file_index(&content);
289
290 let mut index = self.workspace_index.write().await;
291 index.update_file(path, file_index);
292 }
293
294 if i % 10 == 0 || i == total - 1 {
296 let progress = ((i + 1) as f32 / total as f32) * 100.0;
297 *self.index_state.write().await = IndexState::Building {
298 progress,
299 files_indexed: i + 1,
300 total_files: total,
301 };
302 self.report_progress_update(i + 1, total).await;
303 }
304 }
305
306 *self.index_state.write().await = IndexState::Ready;
308 self.report_progress_done().await;
309
310 log::info!("Workspace indexing complete: {total} files indexed");
311 }
312
313 async fn report_progress_begin(&self, total: usize) {
315 let token = NumberOrString::String("rumdl-index".to_string());
316
317 if self
319 .client
320 .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams { token: token.clone() })
321 .await
322 .is_err()
323 {
324 log::debug!("Client does not support work done progress");
325 return;
326 }
327
328 self.client
330 .send_notification::<notification::Progress>(ProgressParams {
331 token,
332 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin {
333 title: "Indexing workspace".to_string(),
334 cancellable: Some(false),
335 message: Some(format!("Scanning {total} markdown files...")),
336 percentage: Some(0),
337 })),
338 })
339 .await;
340 }
341
342 async fn report_progress_update(&self, indexed: usize, total: usize) {
344 let token = NumberOrString::String("rumdl-index".to_string());
345 let percentage = ((indexed as f32 / total as f32) * 100.0) as u32;
346
347 self.client
348 .send_notification::<notification::Progress>(ProgressParams {
349 token,
350 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(WorkDoneProgressReport {
351 cancellable: Some(false),
352 message: Some(format!("Indexed {indexed}/{total} files")),
353 percentage: Some(percentage),
354 })),
355 })
356 .await;
357 }
358
359 async fn report_progress_done(&self) {
361 let token = NumberOrString::String("rumdl-index".to_string());
362
363 self.client
364 .send_notification::<notification::Progress>(ProgressParams {
365 token,
366 value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
367 message: Some("Indexing complete".to_string()),
368 })),
369 })
370 .await;
371 }
372}
373
374async fn scan_markdown_files(
381 roots: &[PathBuf],
382 options: MarkdownWalkOptions,
383 excludes: ExcludeMatchers,
384) -> Vec<PathBuf> {
385 let roots = roots.to_vec();
386 tokio::task::spawn_blocking(move || collect_markdown_files(&roots, &options, &excludes))
387 .await
388 .unwrap_or_else(|e| {
389 log::warn!("Workspace scan task failed: {e}");
390 Vec::new()
391 })
392}
393
394fn collect_markdown_files(
398 roots: &[PathBuf],
399 options: &MarkdownWalkOptions,
400 excludes: &ExcludeMatchers,
401) -> Vec<PathBuf> {
402 let mut files = Vec::new();
403
404 for root in roots {
405 for result in crate::discovery::markdown_walk_builder(root, options).build() {
406 match result {
407 Ok(entry) => {
408 let path = entry.path();
409 if entry.file_type().is_some_and(|t| t.is_file())
410 && let Some(ext) = path.extension()
411 && is_markdown_extension(ext)
412 && !excluded_relative_to_root(excludes, path, root)
413 {
414 files.push(path.to_path_buf());
415 }
416 }
417 Err(e) => log::warn!("Error scanning {}: {}", root.display(), e),
418 }
419 }
420 }
421
422 files
423}
424
425fn excluded_relative_to_root(excludes: &ExcludeMatchers, path: &Path, root: &Path) -> bool {
428 if excludes.is_empty() {
429 return false;
430 }
431 path_relative_to(path, root).is_some_and(|rel| excludes.is_match(&rel))
432}
433
434pub(super) fn path_is_ignored_for_index(
453 roots: &[PathBuf],
454 path: &Path,
455 options: &MarkdownWalkOptions,
456 excludes: &ExcludeMatchers,
457) -> bool {
458 let Some(root) = roots
461 .iter()
462 .filter(|r| path.starts_with(r))
463 .max_by_key(|r| r.components().count())
464 else {
465 return false;
466 };
467
468 if options.skip_vendor_dirs
473 && let Ok(rel) = path.strip_prefix(root)
474 && rel.components().any(
475 |c| matches!(c, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target"),
476 )
477 {
478 return true;
479 }
480
481 if excluded_relative_to_root(excludes, path, root) {
483 return true;
484 }
485
486 let target = path.to_path_buf();
487 let mut builder = crate::discovery::markdown_walk_builder(root, options);
488 builder.filter_entry(move |entry| target.starts_with(entry.path()));
494 for entry in builder.build().flatten() {
495 if entry.path() == path {
496 return false;
497 }
498 }
499 true
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn test_build_file_index() {
508 let content = r#"
509# Main Heading
510
511Some text.
512
513## Sub Heading {#sub}
514
515More text with [link](./other.md#section).
516"#;
517
518 let index = IndexWorker::build_file_index(content);
519
520 assert_eq!(index.headings.len(), 2);
521 assert_eq!(index.headings[0].text, "Main Heading");
522 assert!(index.headings[0].custom_anchor.is_none());
523
524 assert_eq!(index.headings[1].text, "Sub Heading");
526 assert_eq!(index.headings[1].custom_anchor, Some("sub".to_string()));
527
528 assert_eq!(index.cross_file_links.len(), 1);
529 assert_eq!(index.cross_file_links[0].target_path, "./other.md");
530 assert_eq!(index.cross_file_links[0].fragment, "section");
531 }
532
533 #[test]
534 fn test_build_file_index_column_positions() {
535 let content = "See [link](./file.md) here.\n";
537
538 let index = IndexWorker::build_file_index(content);
539
540 assert_eq!(index.cross_file_links.len(), 1);
541 assert_eq!(index.cross_file_links[0].target_path, "./file.md");
542 assert_eq!(index.cross_file_links[0].line, 1);
543 assert_eq!(index.cross_file_links[0].column, 12);
545 }
546
547 #[test]
548 fn test_build_file_index_multiple_links() {
549 let content = "First [a](./a.md) and [b](./b.md#section) links.\n";
550
551 let index = IndexWorker::build_file_index(content);
552
553 assert_eq!(index.cross_file_links.len(), 2);
554
555 assert_eq!(index.cross_file_links[0].target_path, "./a.md");
557 assert_eq!(index.cross_file_links[0].column, 11);
558
559 assert_eq!(index.cross_file_links[1].target_path, "./b.md");
561 assert_eq!(index.cross_file_links[1].fragment, "section");
562 assert_eq!(index.cross_file_links[1].column, 27);
563 }
564
565 #[test]
566 fn test_collect_markdown_files_respects_gitignore() {
567 use std::fs;
568
569 let dir = tempfile::tempdir().unwrap();
570 let root = dir.path();
571
572 fs::write(root.join("README.md"), "# Readme\n").unwrap();
574 fs::write(root.join(".gitignore"), "build/\nignored.md\n").unwrap();
575 fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
576 fs::create_dir(root.join("build")).unwrap();
577 fs::write(root.join("build").join("generated.md"), "# Generated\n").unwrap();
578
579 fs::create_dir(root.join("node_modules")).unwrap();
581 fs::write(root.join("node_modules").join("dep.md"), "# Dep\n").unwrap();
582
583 let mut files = collect_markdown_files(
584 &[root.to_path_buf()],
585 &index_walk_options(&Config::default()),
586 &ExcludeMatchers::new(&[]),
587 );
588 files.sort();
589
590 let names: Vec<String> = files
591 .iter()
592 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
593 .collect();
594
595 assert_eq!(names, vec!["README.md".to_string()]);
596 }
597
598 #[test]
599 fn test_collect_markdown_files_applies_config_excludes() {
600 use std::fs;
601
602 let dir = tempfile::tempdir().unwrap();
603 let root = dir.path();
604
605 fs::write(root.join("README.md"), "# Readme\n").unwrap();
606 fs::create_dir(root.join("drafts")).unwrap();
607 fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
608
609 let excludes = ExcludeMatchers::new(&["drafts".to_string()]);
612 let names: Vec<String> = collect_markdown_files(
613 &[root.to_path_buf()],
614 &index_walk_options(&Config::default()),
615 &excludes,
616 )
617 .iter()
618 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
619 .collect();
620
621 assert_eq!(names, vec!["README.md".to_string()]);
622 }
623
624 #[test]
625 fn test_collect_markdown_files_can_disable_gitignore() {
626 use std::fs;
627
628 let dir = tempfile::tempdir().unwrap();
629 let root = dir.path();
630
631 fs::write(root.join(".gitignore"), "ignored.md\n").unwrap();
632 fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
633
634 let mut config = Config::default();
635 config.global.respect_gitignore = false;
636 let names: Vec<String> = collect_markdown_files(
637 &[root.to_path_buf()],
638 &index_walk_options(&config),
639 &ExcludeMatchers::new(&[]),
640 )
641 .iter()
642 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
643 .collect();
644
645 assert_eq!(names, vec!["ignored.md".to_string()]);
646 }
647
648 #[test]
649 fn test_collect_markdown_files_includes_hidden_files() {
650 use std::fs;
651
652 let dir = tempfile::tempdir().unwrap();
653 let root = dir.path();
654
655 fs::create_dir(root.join(".github")).unwrap();
656 fs::write(root.join(".github").join("PULL_REQUEST_TEMPLATE.md"), "# PR\n").unwrap();
657 fs::write(root.join("README.md"), "# Readme\n").unwrap();
658
659 let mut names: Vec<String> = collect_markdown_files(
660 &[root.to_path_buf()],
661 &index_walk_options(&Config::default()),
662 &ExcludeMatchers::new(&[]),
663 )
664 .iter()
665 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
666 .collect();
667 names.sort();
668
669 assert_eq!(
671 names,
672 vec!["PULL_REQUEST_TEMPLATE.md".to_string(), "README.md".to_string()]
673 );
674 }
675
676 #[test]
677 fn test_collect_markdown_files_finds_nested_markdown() {
678 use std::fs;
679
680 let dir = tempfile::tempdir().unwrap();
681 let root = dir.path();
682
683 fs::write(root.join("top.md"), "# Top\n").unwrap();
684 fs::create_dir(root.join("docs")).unwrap();
685 fs::write(root.join("docs").join("guide.markdown"), "# Guide\n").unwrap();
686 fs::write(root.join("docs").join("notes.txt"), "not markdown\n").unwrap();
687
688 let mut names: Vec<String> = collect_markdown_files(
689 &[root.to_path_buf()],
690 &index_walk_options(&Config::default()),
691 &ExcludeMatchers::new(&[]),
692 )
693 .iter()
694 .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
695 .collect();
696 names.sort();
697
698 assert_eq!(names, vec!["guide.markdown".to_string(), "top.md".to_string()]);
699 }
700
701 #[test]
702 fn test_path_is_ignored_for_index() {
703 use std::fs;
704
705 let dir = tempfile::tempdir().unwrap();
706 let root = dir.path().to_path_buf();
707 fs::write(root.join(".gitignore"), "build/\ndraft.md\n").unwrap();
708
709 fs::write(root.join("README.md"), "").unwrap();
712 fs::write(root.join("draft.md"), "").unwrap();
713 fs::write(root.join(".hidden.md"), "").unwrap();
714 fs::create_dir(root.join("docs")).unwrap();
715 fs::write(root.join("docs").join("guide.md"), "").unwrap();
716 fs::create_dir(root.join("build")).unwrap();
717 fs::write(root.join("build").join("out.md"), "").unwrap();
718
719 let roots = vec![root.clone()];
720 let options = index_walk_options(&Config::default());
721 let no_excludes = ExcludeMatchers::new(&[]);
722
723 assert!(!path_is_ignored_for_index(
725 &roots,
726 &root.join("README.md"),
727 &options,
728 &no_excludes
729 ));
730 assert!(!path_is_ignored_for_index(
731 &roots,
732 &root.join("docs/guide.md"),
733 &options,
734 &no_excludes
735 ));
736
737 assert!(path_is_ignored_for_index(
739 &roots,
740 &root.join("draft.md"),
741 &options,
742 &no_excludes
743 ));
744 assert!(path_is_ignored_for_index(
745 &roots,
746 &root.join("build/out.md"),
747 &options,
748 &no_excludes
749 ));
750
751 assert!(!path_is_ignored_for_index(
753 &roots,
754 &root.join(".hidden.md"),
755 &options,
756 &no_excludes
757 ));
758
759 assert!(path_is_ignored_for_index(
762 &roots,
763 &root.join("node_modules/dep.md"),
764 &options,
765 &no_excludes
766 ));
767 assert!(path_is_ignored_for_index(
768 &roots,
769 &root.join("target/doc.md"),
770 &options,
771 &no_excludes
772 ));
773
774 let excludes = ExcludeMatchers::new(&["docs".to_string()]);
776 assert!(path_is_ignored_for_index(
777 &roots,
778 &root.join("docs/guide.md"),
779 &options,
780 &excludes
781 ));
782 assert!(!path_is_ignored_for_index(
783 &roots,
784 &root.join("README.md"),
785 &options,
786 &excludes
787 ));
788
789 let outside = dir.path().parent().unwrap().join("elsewhere.md");
791 assert!(!path_is_ignored_for_index(&roots, &outside, &options, &no_excludes));
792 }
793
794 #[test]
795 fn test_path_is_ignored_for_index_honors_nested_gitignore() {
796 use std::fs;
797
798 let dir = tempfile::tempdir().unwrap();
799 let root = dir.path().to_path_buf();
800 fs::create_dir(root.join("docs")).unwrap();
801 fs::write(root.join("docs").join(".gitignore"), "generated.md\n").unwrap();
802 fs::write(root.join("docs").join("generated.md"), "").unwrap();
803 fs::write(root.join("docs").join("manual.md"), "").unwrap();
804
805 let roots = vec![root.clone()];
806 let options = index_walk_options(&Config::default());
807 let no_excludes = ExcludeMatchers::new(&[]);
808
809 assert!(path_is_ignored_for_index(
810 &roots,
811 &root.join("docs/generated.md"),
812 &options,
813 &no_excludes
814 ));
815 assert!(!path_is_ignored_for_index(
816 &roots,
817 &root.join("docs/manual.md"),
818 &options,
819 &no_excludes
820 ));
821 }
822
823 #[test]
824 fn test_path_is_ignored_for_index_workspace_under_target_dir() {
825 use std::fs;
826
827 let dir = tempfile::tempdir().unwrap();
830 let root = dir.path().join("target").join("my-docs");
831 fs::create_dir_all(&root).unwrap();
832 fs::write(root.join("README.md"), "").unwrap();
833 fs::create_dir(root.join("target")).unwrap();
834 fs::write(root.join("target").join("out.md"), "").unwrap();
835
836 let roots = vec![root.clone()];
837 let options = index_walk_options(&Config::default());
838 let no_excludes = ExcludeMatchers::new(&[]);
839
840 assert!(!path_is_ignored_for_index(
843 &roots,
844 &root.join("README.md"),
845 &options,
846 &no_excludes
847 ));
848 assert!(path_is_ignored_for_index(
850 &roots,
851 &root.join("target/out.md"),
852 &options,
853 &no_excludes
854 ));
855 }
856}