Skip to main content

rumdl_lib/lsp/
index_worker.rs

1//! Background worker for workspace index management
2//!
3//! This module provides a background task that manages the workspace index
4//! for cross-file analysis. It handles debouncing rapid file updates and
5//! efficiently updates the index without blocking the main LSP server.
6
7use 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
23/// Walk options for workspace indexing, derived from the resolved config.
24///
25/// Mirrors CLI discovery (gitignore handling driven by
26/// `global.respect_gitignore`, hidden files included, `.markdownlintignore`
27/// honored) with one deliberate divergence: `.git`/`node_modules`/`target`
28/// are always skipped as an editor-performance safety net, even when not
29/// gitignored.
30pub(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
37/// Background worker for managing the workspace index
38///
39/// Receives updates via a channel and maintains the workspace index
40/// with debouncing to avoid excessive re-indexing during rapid edits.
41pub struct IndexWorker {
42    /// Receiver for index update messages
43    rx: mpsc::Receiver<IndexUpdate>,
44    /// The workspace index being maintained
45    workspace_index: Arc<RwLock<WorkspaceIndex>>,
46    /// Current state of the index (building/ready/error)
47    index_state: Arc<RwLock<IndexState>>,
48    /// LSP client for progress reporting
49    client: Client,
50    /// Workspace root folders
51    workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
52    /// Debouncing: path -> (content, last_update_time)
53    pending: HashMap<PathBuf, (String, Instant)>,
54    /// Debounce duration
55    debounce_duration: Duration,
56    /// Sender to request re-linting of files (back to server)
57    relint_tx: mpsc::Sender<PathBuf>,
58    /// Resolved rumdl configuration; drives walk options and excludes for
59    /// workspace scans so the index covers the same files the CLI lints.
60    rumdl_config: Arc<RwLock<Config>>,
61}
62
63impl IndexWorker {
64    /// Create a new index worker
65    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    /// Run the index worker event loop
88    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                // Receive updates from main server
94                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                // Process debounced updates periodically
113                _ = debounce_interval.tick() => {
114                    self.process_pending_updates().await;
115                }
116            }
117        }
118    }
119
120    /// Process pending updates that have been debounced long enough
121    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    /// Update a single file in the index
138    async fn update_single_file(&self, path: &Path, content: &str) {
139        // Build FileIndex using LintContext
140        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        // Get old dependents before updating
147        let old_dependents = {
148            let index = self.workspace_index.read().await;
149            index.get_dependents(path)
150        };
151
152        // Update the index
153        {
154            let mut index = self.workspace_index.write().await;
155            index.update_file(path, file_index);
156        }
157
158        // Get new dependents after updating
159        let new_dependents = {
160            let index = self.workspace_index.read().await;
161            index.get_dependents(path)
162        };
163
164        // Request re-lint of affected files (union of old and new dependents)
165        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    /// Build a FileIndex from content
176    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        // Extract headings from the content
181        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, // 1-indexed
195                    is_setext,
196                });
197            }
198        }
199
200        // Extract cross-file links using the shared utility
201        // This ensures consistent position tracking with MD057
202        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    /// Handle a file deletion
214    async fn handle_file_deleted(&self, path: &Path) {
215        // Remove pending update for this file
216        // (self.pending is not accessible here directly, but FileDeleted is handled immediately)
217
218        // Get dependents before removing
219        let dependents = {
220            let index = self.workspace_index.read().await;
221            index.get_dependents(path)
222        };
223
224        // Remove from index
225        {
226            let mut index = self.workspace_index.write().await;
227            index.remove_file(path);
228        }
229
230        // Request re-lint of dependent files (they now have broken links)
231        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    /// Perform a full rescan of the workspace
239    async fn full_rescan(&mut self) {
240        // Clear pending updates
241        self.pending.clear();
242
243        // Find all markdown files in workspace roots
244        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        // Evict entries the scan no longer discovers (deleted files, newly
259        // excluded or gitignored ones) so navigation and completions stop
260        // surfacing them. An explicitly opened excluded file is re-indexed on
261        // its next did_open/did_change, which deliberately bypasses discovery.
262        {
263            let current: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
264            let removed = self.workspace_index.write().await.retain_only(&current);
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        // Set initial building state
276        *self.index_state.write().await = IndexState::Building {
277            progress: 0.0,
278            files_indexed: 0,
279            total_files: total,
280        };
281
282        // Report progress start
283        self.report_progress_begin(total).await;
284
285        // Index each file
286        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            // Report progress every 10 files or at end
295            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        // Mark as ready
307        *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    /// Report progress begin via LSP
314    async fn report_progress_begin(&self, total: usize) {
315        let token = NumberOrString::String("rumdl-index".to_string());
316
317        // Request progress token creation
318        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        // Send begin notification
329        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    /// Report progress update via LSP
343    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    /// Report progress done via LSP
360    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
374/// Scan workspace roots for markdown files
375///
376/// Applies the shared discovery semantics (gitignore handling per config,
377/// `.markdownlintignore`, hidden files included, vendor dirs skipped) plus
378/// the config `exclude` patterns. Runs the (synchronous) filesystem walk on
379/// a blocking thread.
380async 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
394/// Collect markdown files from the given roots, respecting ignore files and
395/// config `exclude` patterns (matched relative to each root, like the CLI
396/// matches them relative to the project root).
397fn 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
425/// Whether `path` matches the config `exclude` patterns, matched against its
426/// root-relative form. Paths that cannot be relativized are not excluded.
427fn 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
434/// Whether `path` should be excluded from the workspace index based on the same
435/// ignore rules used by the full scan ([`collect_markdown_files`]).
436///
437/// Used to keep filesystem-watch events (`did_change_watched_files`) from
438/// reintroducing generated/ignored files that the full scan skips. Files the
439/// user explicitly opens or edits bypass this check, since the active document
440/// must stay indexed for in-file anchor completion.
441///
442/// Determines ignore status by walking from the containing workspace root down
443/// the chain of directories leading to `path`, using the shared
444/// [`index_walk_builder`] configuration. Descent is pruned to that single chain,
445/// so the walk applies the same ignore rules the full scan would (including an
446/// ignored ancestor directory or a hidden entry) without traversing the tree. If
447/// the walk does not yield `path`, the file must not enter the index.
448///
449/// `node_modules`/`target` are also checked directly so the predicate works even
450/// for paths that do not exist on disk. The file must exist for the walk to
451/// observe it, which holds for the create/change watch events that use this.
452pub(super) fn path_is_ignored_for_index(
453    roots: &[PathBuf],
454    path: &Path,
455    options: &MarkdownWalkOptions,
456    excludes: &ExcludeMatchers,
457) -> bool {
458    // Use the deepest workspace root that contains the file so nested roots
459    // resolve their own ignore files. Paths outside every root aren't filtered.
460    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    // Check vendor directories only below the workspace root, so a workspace
469    // located under a directory of that name is not wholly excluded. Checked
470    // directly (not via the walk) so the predicate also works for paths that
471    // do not exist on disk.
472    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    // Config exclude patterns, matched root-relative like the full scan.
482    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    // Only descend into directories that lead to `target`; everything else is
489    // pruned. `target.starts_with(entry)` holds for `target` and its ancestors.
490    // Note: this replaces the vendor-dir filter set by the walk options
491    // (`WalkBuilder::filter_entry` overwrites the previous predicate); the
492    // direct component check above covers vendor dirs for this walk.
493    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        // HeadingInfo.text has the custom ID stripped; the custom_id is stored separately
525        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        // Verify that column positions are correct (fix for issue #234)
536        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        // "See [link](" = 11 chars, so column 12 is where "./file.md" starts
544        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        // First link: "First [a](" = 10 chars, column 11
556        assert_eq!(index.cross_file_links[0].target_path, "./a.md");
557        assert_eq!(index.cross_file_links[0].column, 11);
558
559        // Second link: "First [a](./a.md) and [b](" = 26 chars, column 27
560        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        // A tracked markdown file and a build-output one that .gitignore excludes.
573        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        // Dependency/output dirs are skipped even when not gitignored.
580        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        // A bare directory pattern must exclude the directory's contents,
610        // matching CLI behavior.
611        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        // Hidden files lint in the CLI, so the index must cover them too.
670        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        // The check walks the file's directory, so the files must exist (as they
710        // do for create/change watch events).
711        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        // Tracked files are not ignored.
724        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        // Gitignored file and file inside a gitignored directory.
738        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        // Hidden files are indexed, matching the CLI which lints them.
752        assert!(!path_is_ignored_for_index(
753            &roots,
754            &root.join(".hidden.md"),
755            &options,
756            &no_excludes
757        ));
758
759        // Dependency/output dirs are always skipped, even without a gitignore rule
760        // and without the file existing.
761        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        // Config exclude patterns are honored, matched root-relative.
775        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        // Paths outside every workspace root are not filtered.
790        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        // A workspace whose own path contains a `target` component must not have
828        // all of its files treated as ignored.
829        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        // Files directly under the workspace are indexed despite the `target`
841        // ancestor in the absolute path.
842        assert!(!path_is_ignored_for_index(
843            &roots,
844            &root.join("README.md"),
845            &options,
846            &no_excludes
847        ));
848        // A `target` directory *inside* the workspace is still excluded.
849        assert!(path_is_ignored_for_index(
850            &roots,
851            &root.join("target/out.md"),
852            &options,
853            &no_excludes
854        ));
855    }
856}