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 and, for absolute patterns, its absolute path. A path
432/// that cannot be relativized is excluded only by an absolute pattern.
433fn excluded_relative_to_root(excludes: &ExcludeMatchers, path: &Path, root: &Path) -> bool {
434    if excludes.is_empty() {
435        return false;
436    }
437    excludes.excludes_file(path_relative_to(path, root).as_deref(), path)
438}
439
440/// Whether `path` should be excluded from the workspace index based on the same
441/// ignore rules used by the full scan ([`collect_markdown_files`]).
442///
443/// Used to keep filesystem-watch events (`did_change_watched_files`) from
444/// reintroducing generated/ignored files that the full scan skips. Files the
445/// user explicitly opens or edits bypass this check, since the active document
446/// must stay indexed for in-file anchor completion.
447///
448/// Determines ignore status by walking from the containing workspace root down
449/// the chain of directories leading to `path`, using the shared
450/// [`index_walk_builder`] configuration. Descent is pruned to that single chain,
451/// so the walk applies the same ignore rules the full scan would (including an
452/// ignored ancestor directory or a hidden entry) without traversing the tree. If
453/// the walk does not yield `path`, the file must not enter the index.
454///
455/// `node_modules`/`target` are also checked directly so the predicate works even
456/// for paths that do not exist on disk. The file must exist for the walk to
457/// observe it, which holds for the create/change watch events that use this.
458pub(super) fn path_is_ignored_for_index(
459    roots: &[PathBuf],
460    path: &Path,
461    options: &MarkdownWalkOptions,
462    excludes: &ExcludeMatchers,
463) -> bool {
464    // Use the deepest workspace root that contains the file so nested roots
465    // resolve their own ignore files. Paths outside every root aren't filtered.
466    let Some(root) = roots
467        .iter()
468        .filter(|r| path.starts_with(r))
469        .max_by_key(|r| r.components().count())
470    else {
471        return false;
472    };
473
474    // Check vendor directories only below the workspace root, so a workspace
475    // located under a directory of that name is not wholly excluded. Checked
476    // directly (not via the walk) so the predicate also works for paths that
477    // do not exist on disk.
478    if options.skip_vendor_dirs
479        && let Ok(rel) = path.strip_prefix(root)
480        && rel.components().any(
481            |c| matches!(c, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target"),
482        )
483    {
484        return true;
485    }
486
487    // Config exclude patterns, matched root-relative like the full scan.
488    if excluded_relative_to_root(excludes, path, root) {
489        return true;
490    }
491
492    let target = path.to_path_buf();
493    let mut builder = crate::discovery::markdown_walk_builder(root, options);
494    // Only descend into directories that lead to `target`; everything else is
495    // pruned. `target.starts_with(entry)` holds for `target` and its ancestors.
496    // Note: this replaces the vendor-dir filter set by the walk options
497    // (`WalkBuilder::filter_entry` overwrites the previous predicate); the
498    // direct component check above covers vendor dirs for this walk.
499    builder.filter_entry(move |entry| target.starts_with(entry.path()));
500    for entry in builder.build().flatten() {
501        if entry.path() == path {
502            return false;
503        }
504    }
505    true
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn test_build_file_index() {
514        let content = r#"
515# Main Heading
516
517Some text.
518
519## Sub Heading {#sub}
520
521More text with [link](./other.md#section).
522"#;
523
524        let index = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::default());
525
526        assert_eq!(index.headings.len(), 2);
527        assert_eq!(index.headings[0].text, "Main Heading");
528        assert!(index.headings[0].custom_anchor.is_none());
529
530        // HeadingInfo.text has the custom ID stripped; the custom_id is stored separately
531        assert_eq!(index.headings[1].text, "Sub Heading");
532        assert_eq!(index.headings[1].custom_anchor, Some("sub".to_string()));
533
534        assert_eq!(index.cross_file_links.len(), 1);
535        assert_eq!(index.cross_file_links[0].target_path, "./other.md");
536        assert_eq!(index.cross_file_links[0].fragment, "section");
537    }
538
539    #[test]
540    fn test_build_file_index_respects_flavor() {
541        // `# -8<- [start:x]` is a heading in Standard markdown but a MkDocs snippet
542        // marker. The index must parse with the file's flavor so anchors, cross-file
543        // navigation, and workspace symbols all agree with the document outline.
544        let content = "# Real\n\n# -8<- [start:section]\n";
545
546        let standard = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::Standard);
547        assert_eq!(
548            standard.headings.len(),
549            2,
550            "Standard treats the snippet line as a heading"
551        );
552
553        let mkdocs = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::MkDocs);
554        assert_eq!(mkdocs.headings.len(), 1, "MkDocs excludes the snippet marker");
555        assert_eq!(mkdocs.headings[0].text, "Real");
556    }
557
558    #[test]
559    fn test_build_file_index_column_positions() {
560        // Verify that column positions are correct (fix for issue #234)
561        let content = "See [link](./file.md) here.\n";
562
563        let index = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::default());
564
565        assert_eq!(index.cross_file_links.len(), 1);
566        assert_eq!(index.cross_file_links[0].target_path, "./file.md");
567        assert_eq!(index.cross_file_links[0].line, 1);
568        // "See [link](" = 11 chars, so column 12 is where "./file.md" starts
569        assert_eq!(index.cross_file_links[0].column, 12);
570    }
571
572    #[test]
573    fn test_build_file_index_multiple_links() {
574        let content = "First [a](./a.md) and [b](./b.md#section) links.\n";
575
576        let index = IndexWorker::build_file_index(content, crate::config::MarkdownFlavor::default());
577
578        assert_eq!(index.cross_file_links.len(), 2);
579
580        // First link: "First [a](" = 10 chars, column 11
581        assert_eq!(index.cross_file_links[0].target_path, "./a.md");
582        assert_eq!(index.cross_file_links[0].column, 11);
583
584        // Second link: "First [a](./a.md) and [b](" = 26 chars, column 27
585        assert_eq!(index.cross_file_links[1].target_path, "./b.md");
586        assert_eq!(index.cross_file_links[1].fragment, "section");
587        assert_eq!(index.cross_file_links[1].column, 27);
588    }
589
590    #[test]
591    fn test_collect_markdown_files_respects_gitignore() {
592        use std::fs;
593
594        let dir = tempfile::tempdir().unwrap();
595        let root = dir.path();
596
597        // A tracked markdown file and a build-output one that .gitignore excludes.
598        fs::write(root.join("README.md"), "# Readme\n").unwrap();
599        fs::write(root.join(".gitignore"), "build/\nignored.md\n").unwrap();
600        fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
601        fs::create_dir(root.join("build")).unwrap();
602        fs::write(root.join("build").join("generated.md"), "# Generated\n").unwrap();
603
604        // Dependency/output dirs are skipped even when not gitignored.
605        fs::create_dir(root.join("node_modules")).unwrap();
606        fs::write(root.join("node_modules").join("dep.md"), "# Dep\n").unwrap();
607
608        let mut files = collect_markdown_files(
609            &[root.to_path_buf()],
610            &index_walk_options(&Config::default()),
611            &ExcludeMatchers::new(&[]),
612        );
613        files.sort();
614
615        let names: Vec<String> = files
616            .iter()
617            .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
618            .collect();
619
620        assert_eq!(names, vec!["README.md".to_string()]);
621    }
622
623    #[test]
624    fn test_collect_markdown_files_applies_config_excludes() {
625        use std::fs;
626
627        let dir = tempfile::tempdir().unwrap();
628        let root = dir.path();
629
630        fs::write(root.join("README.md"), "# Readme\n").unwrap();
631        fs::create_dir(root.join("drafts")).unwrap();
632        fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
633
634        // A bare directory pattern must exclude the directory's contents,
635        // matching CLI behavior.
636        let excludes = ExcludeMatchers::new(&["drafts".to_string()]);
637        let names: Vec<String> = collect_markdown_files(
638            &[root.to_path_buf()],
639            &index_walk_options(&Config::default()),
640            &excludes,
641        )
642        .iter()
643        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
644        .collect();
645
646        assert_eq!(names, vec!["README.md".to_string()]);
647    }
648
649    #[test]
650    fn test_collect_markdown_files_honors_absolute_exclude_patterns() {
651        use std::fs;
652
653        let dir = tempfile::tempdir().unwrap();
654        // Canonicalize the way production does, so the pattern built below has
655        // the shape an expanded `~` produces (on Windows that means no verbatim
656        // `\\?\` prefix, which would match nothing).
657        let root = crate::discovery::canonicalize_for_matching(dir.path()).unwrap();
658
659        fs::write(root.join("README.md"), "# Readme\n").unwrap();
660        fs::create_dir(root.join("drafts")).unwrap();
661        fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
662
663        // An absolute pattern - what a `~/...` pattern expands to - must
664        // exclude in the workspace scan just as it does in the CLI walk.
665        let pattern = format!("{}/drafts", root.to_string_lossy().replace('\\', "/"));
666        let names: Vec<String> = collect_markdown_files(
667            std::slice::from_ref(&root),
668            &index_walk_options(&Config::default()),
669            &ExcludeMatchers::new(&[pattern]),
670        )
671        .iter()
672        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
673        .collect();
674
675        assert_eq!(names, vec!["README.md".to_string()]);
676    }
677
678    #[test]
679    fn test_collect_markdown_files_can_disable_gitignore() {
680        use std::fs;
681
682        let dir = tempfile::tempdir().unwrap();
683        let root = dir.path();
684
685        fs::write(root.join(".gitignore"), "ignored.md\n").unwrap();
686        fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
687
688        let mut config = Config::default();
689        config.global.respect_gitignore = false;
690        let names: Vec<String> = collect_markdown_files(
691            &[root.to_path_buf()],
692            &index_walk_options(&config),
693            &ExcludeMatchers::new(&[]),
694        )
695        .iter()
696        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
697        .collect();
698
699        assert_eq!(names, vec!["ignored.md".to_string()]);
700    }
701
702    #[test]
703    fn test_collect_markdown_files_includes_hidden_files() {
704        use std::fs;
705
706        let dir = tempfile::tempdir().unwrap();
707        let root = dir.path();
708
709        fs::create_dir(root.join(".github")).unwrap();
710        fs::write(root.join(".github").join("PULL_REQUEST_TEMPLATE.md"), "# PR\n").unwrap();
711        fs::write(root.join("README.md"), "# Readme\n").unwrap();
712
713        let mut names: Vec<String> = collect_markdown_files(
714            &[root.to_path_buf()],
715            &index_walk_options(&Config::default()),
716            &ExcludeMatchers::new(&[]),
717        )
718        .iter()
719        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
720        .collect();
721        names.sort();
722
723        // Hidden files lint in the CLI, so the index must cover them too.
724        assert_eq!(
725            names,
726            vec!["PULL_REQUEST_TEMPLATE.md".to_string(), "README.md".to_string()]
727        );
728    }
729
730    #[test]
731    fn test_collect_markdown_files_finds_nested_markdown() {
732        use std::fs;
733
734        let dir = tempfile::tempdir().unwrap();
735        let root = dir.path();
736
737        fs::write(root.join("top.md"), "# Top\n").unwrap();
738        fs::create_dir(root.join("docs")).unwrap();
739        fs::write(root.join("docs").join("guide.markdown"), "# Guide\n").unwrap();
740        fs::write(root.join("docs").join("notes.txt"), "not markdown\n").unwrap();
741
742        let mut names: Vec<String> = collect_markdown_files(
743            &[root.to_path_buf()],
744            &index_walk_options(&Config::default()),
745            &ExcludeMatchers::new(&[]),
746        )
747        .iter()
748        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
749        .collect();
750        names.sort();
751
752        assert_eq!(names, vec!["guide.markdown".to_string(), "top.md".to_string()]);
753    }
754
755    #[test]
756    fn test_path_is_ignored_for_index() {
757        use std::fs;
758
759        let dir = tempfile::tempdir().unwrap();
760        let root = dir.path().to_path_buf();
761        fs::write(root.join(".gitignore"), "build/\ndraft.md\n").unwrap();
762
763        // The check walks the file's directory, so the files must exist (as they
764        // do for create/change watch events).
765        fs::write(root.join("README.md"), "").unwrap();
766        fs::write(root.join("draft.md"), "").unwrap();
767        fs::write(root.join(".hidden.md"), "").unwrap();
768        fs::create_dir(root.join("docs")).unwrap();
769        fs::write(root.join("docs").join("guide.md"), "").unwrap();
770        fs::create_dir(root.join("build")).unwrap();
771        fs::write(root.join("build").join("out.md"), "").unwrap();
772
773        let roots = vec![root.clone()];
774        let options = index_walk_options(&Config::default());
775        let no_excludes = ExcludeMatchers::new(&[]);
776
777        // Tracked files are not ignored.
778        assert!(!path_is_ignored_for_index(
779            &roots,
780            &root.join("README.md"),
781            &options,
782            &no_excludes
783        ));
784        assert!(!path_is_ignored_for_index(
785            &roots,
786            &root.join("docs/guide.md"),
787            &options,
788            &no_excludes
789        ));
790
791        // Gitignored file and file inside a gitignored directory.
792        assert!(path_is_ignored_for_index(
793            &roots,
794            &root.join("draft.md"),
795            &options,
796            &no_excludes
797        ));
798        assert!(path_is_ignored_for_index(
799            &roots,
800            &root.join("build/out.md"),
801            &options,
802            &no_excludes
803        ));
804
805        // Hidden files are indexed, matching the CLI which lints them.
806        assert!(!path_is_ignored_for_index(
807            &roots,
808            &root.join(".hidden.md"),
809            &options,
810            &no_excludes
811        ));
812
813        // Dependency/output dirs are always skipped, even without a gitignore rule
814        // and without the file existing.
815        assert!(path_is_ignored_for_index(
816            &roots,
817            &root.join("node_modules/dep.md"),
818            &options,
819            &no_excludes
820        ));
821        assert!(path_is_ignored_for_index(
822            &roots,
823            &root.join("target/doc.md"),
824            &options,
825            &no_excludes
826        ));
827
828        // Config exclude patterns are honored, matched root-relative.
829        let excludes = ExcludeMatchers::new(&["docs".to_string()]);
830        assert!(path_is_ignored_for_index(
831            &roots,
832            &root.join("docs/guide.md"),
833            &options,
834            &excludes
835        ));
836        assert!(!path_is_ignored_for_index(
837            &roots,
838            &root.join("README.md"),
839            &options,
840            &excludes
841        ));
842
843        // Paths outside every workspace root are not filtered.
844        let outside = dir.path().parent().unwrap().join("elsewhere.md");
845        assert!(!path_is_ignored_for_index(&roots, &outside, &options, &no_excludes));
846    }
847
848    #[test]
849    fn test_path_is_ignored_for_index_honors_nested_gitignore() {
850        use std::fs;
851
852        let dir = tempfile::tempdir().unwrap();
853        let root = dir.path().to_path_buf();
854        fs::create_dir(root.join("docs")).unwrap();
855        fs::write(root.join("docs").join(".gitignore"), "generated.md\n").unwrap();
856        fs::write(root.join("docs").join("generated.md"), "").unwrap();
857        fs::write(root.join("docs").join("manual.md"), "").unwrap();
858
859        let roots = vec![root.clone()];
860        let options = index_walk_options(&Config::default());
861        let no_excludes = ExcludeMatchers::new(&[]);
862
863        assert!(path_is_ignored_for_index(
864            &roots,
865            &root.join("docs/generated.md"),
866            &options,
867            &no_excludes
868        ));
869        assert!(!path_is_ignored_for_index(
870            &roots,
871            &root.join("docs/manual.md"),
872            &options,
873            &no_excludes
874        ));
875    }
876
877    #[test]
878    fn test_path_is_ignored_for_index_workspace_under_target_dir() {
879        use std::fs;
880
881        // A workspace whose own path contains a `target` component must not have
882        // all of its files treated as ignored.
883        let dir = tempfile::tempdir().unwrap();
884        let root = dir.path().join("target").join("my-docs");
885        fs::create_dir_all(&root).unwrap();
886        fs::write(root.join("README.md"), "").unwrap();
887        fs::create_dir(root.join("target")).unwrap();
888        fs::write(root.join("target").join("out.md"), "").unwrap();
889
890        let roots = vec![root.clone()];
891        let options = index_walk_options(&Config::default());
892        let no_excludes = ExcludeMatchers::new(&[]);
893
894        // Files directly under the workspace are indexed despite the `target`
895        // ancestor in the absolute path.
896        assert!(!path_is_ignored_for_index(
897            &roots,
898            &root.join("README.md"),
899            &options,
900            &no_excludes
901        ));
902        // A `target` directory *inside* the workspace is still excluded.
903        assert!(path_is_ignored_for_index(
904            &roots,
905            &root.join("target/out.md"),
906            &options,
907            &no_excludes
908        ));
909    }
910}