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, parsed with the file's flavor.
140        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        // Get old dependents before updating
149        let old_dependents = {
150            let index = self.workspace_index.read().await;
151            index.get_dependents(path)
152        };
153
154        // Update the index
155        {
156            let mut index = self.workspace_index.write().await;
157            index.update_file(path, file_index);
158        }
159
160        // Get new dependents after updating
161        let new_dependents = {
162            let index = self.workspace_index.read().await;
163            index.get_dependents(path)
164        };
165
166        // Request re-lint of affected files (union of old and new dependents)
167        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    /// Build a FileIndex from content, parsing with the file's Markdown flavor so
178    /// the index (anchors, cross-file links, and the symbols built from it) matches
179    /// what diagnostics and the document outline see.
180    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        // Extract headings from the content
185        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, // 1-indexed
199                    is_setext,
200                });
201            }
202        }
203
204        // Extract cross-file links using the shared utility
205        // This ensures consistent position tracking with MD057
206        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    /// Handle a file deletion
218    async fn handle_file_deleted(&self, path: &Path) {
219        // Remove pending update for this file
220        // (self.pending is not accessible here directly, but FileDeleted is handled immediately)
221
222        // Get dependents before removing
223        let dependents = {
224            let index = self.workspace_index.read().await;
225            index.get_dependents(path)
226        };
227
228        // Remove from index
229        {
230            let mut index = self.workspace_index.write().await;
231            index.remove_file(path);
232        }
233
234        // Request re-lint of dependent files (they now have broken links)
235        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    /// Perform a full rescan of the workspace
243    async fn full_rescan(&mut self) {
244        // Clear pending updates
245        self.pending.clear();
246
247        // Find all markdown files in workspace roots
248        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        // Evict entries the scan no longer discovers (deleted files, newly
263        // excluded or gitignored ones) so navigation and completions stop
264        // surfacing them. An explicitly opened excluded file is re-indexed on
265        // its next did_open/did_change, which deliberately bypasses discovery.
266        {
267            let current: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
268            let removed = self.workspace_index.write().await.retain_only(&current);
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        // Set initial building state
280        *self.index_state.write().await = IndexState::Building {
281            progress: 0.0,
282            files_indexed: 0,
283            total_files: total,
284        };
285
286        // Report progress start
287        self.report_progress_begin(total).await;
288
289        // Index each file
290        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            // Report progress every 10 files or at end
300            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        // Mark as ready
312        *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    /// Report progress begin via LSP
319    async fn report_progress_begin(&self, total: usize) {
320        let token = NumberOrString::String("rumdl-index".to_string());
321
322        // Request progress token creation
323        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        // Send begin notification
334        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    /// Report progress update via LSP
348    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    /// Report progress done via LSP
365    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
379/// Scan workspace roots for markdown files
380///
381/// Applies the shared discovery semantics (gitignore handling per config,
382/// `.markdownlintignore`, hidden files included, vendor dirs skipped) plus
383/// the config `exclude` patterns. Runs the (synchronous) filesystem walk on
384/// a blocking thread.
385async 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
399/// Collect markdown files from the given roots, respecting ignore files and
400/// config `exclude` patterns (matched relative to each root, like the CLI
401/// matches them relative to the project root).
402fn 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
430/// Whether `path` matches the config `exclude` patterns, matched against its
431/// root-relative form. Paths that cannot be relativized are not excluded.
432fn 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
439/// Whether `path` should be excluded from the workspace index based on the same
440/// ignore rules used by the full scan ([`collect_markdown_files`]).
441///
442/// Used to keep filesystem-watch events (`did_change_watched_files`) from
443/// reintroducing generated/ignored files that the full scan skips. Files the
444/// user explicitly opens or edits bypass this check, since the active document
445/// must stay indexed for in-file anchor completion.
446///
447/// Determines ignore status by walking from the containing workspace root down
448/// the chain of directories leading to `path`, using the shared
449/// [`index_walk_builder`] configuration. Descent is pruned to that single chain,
450/// so the walk applies the same ignore rules the full scan would (including an
451/// ignored ancestor directory or a hidden entry) without traversing the tree. If
452/// the walk does not yield `path`, the file must not enter the index.
453///
454/// `node_modules`/`target` are also checked directly so the predicate works even
455/// for paths that do not exist on disk. The file must exist for the walk to
456/// observe it, which holds for the create/change watch events that use this.
457pub(super) fn path_is_ignored_for_index(
458    roots: &[PathBuf],
459    path: &Path,
460    options: &MarkdownWalkOptions,
461    excludes: &ExcludeMatchers,
462) -> bool {
463    // Use the deepest workspace root that contains the file so nested roots
464    // resolve their own ignore files. Paths outside every root aren't filtered.
465    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    // Check vendor directories only below the workspace root, so a workspace
474    // located under a directory of that name is not wholly excluded. Checked
475    // directly (not via the walk) so the predicate also works for paths that
476    // do not exist on disk.
477    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    // Config exclude patterns, matched root-relative like the full scan.
487    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    // Only descend into directories that lead to `target`; everything else is
494    // pruned. `target.starts_with(entry)` holds for `target` and its ancestors.
495    // Note: this replaces the vendor-dir filter set by the walk options
496    // (`WalkBuilder::filter_entry` overwrites the previous predicate); the
497    // direct component check above covers vendor dirs for this walk.
498    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        // HeadingInfo.text has the custom ID stripped; the custom_id is stored separately
530        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        // `# -8<- [start:x]` is a heading in Standard markdown but a MkDocs snippet
541        // marker. The index must parse with the file's flavor so anchors, cross-file
542        // navigation, and workspace symbols all agree with the document outline.
543        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        // Verify that column positions are correct (fix for issue #234)
560        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        // "See [link](" = 11 chars, so column 12 is where "./file.md" starts
568        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        // First link: "First [a](" = 10 chars, column 11
580        assert_eq!(index.cross_file_links[0].target_path, "./a.md");
581        assert_eq!(index.cross_file_links[0].column, 11);
582
583        // Second link: "First [a](./a.md) and [b](" = 26 chars, column 27
584        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        // A tracked markdown file and a build-output one that .gitignore excludes.
597        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        // Dependency/output dirs are skipped even when not gitignored.
604        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        // A bare directory pattern must exclude the directory's contents,
634        // matching CLI behavior.
635        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        // Hidden files lint in the CLI, so the index must cover them too.
694        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        // The check walks the file's directory, so the files must exist (as they
734        // do for create/change watch events).
735        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        // Tracked files are not ignored.
748        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        // Gitignored file and file inside a gitignored directory.
762        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        // Hidden files are indexed, matching the CLI which lints them.
776        assert!(!path_is_ignored_for_index(
777            &roots,
778            &root.join(".hidden.md"),
779            &options,
780            &no_excludes
781        ));
782
783        // Dependency/output dirs are always skipped, even without a gitignore rule
784        // and without the file existing.
785        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        // Config exclude patterns are honored, matched root-relative.
799        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        // Paths outside every workspace root are not filtered.
814        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        // A workspace whose own path contains a `target` component must not have
852        // all of its files treated as ignored.
853        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        // Files directly under the workspace are indexed despite the `target`
865        // ancestor in the absolute path.
866        assert!(!path_is_ignored_for_index(
867            &roots,
868            &root.join("README.md"),
869            &options,
870            &no_excludes
871        ));
872        // A `target` directory *inside* the workspace is still excluded.
873        assert!(path_is_ignored_for_index(
874            &roots,
875            &root.join("target/out.md"),
876            &options,
877            &no_excludes
878        ));
879    }
880}