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;
17#[cfg(test)]
18use crate::config::MarkdownFlavor;
19use crate::discovery::{ExcludeMatchers, MarkdownWalkOptions, MarkdownWorkspaceScan};
20use crate::lsp::server::{ConfigResolver, DocumentEntry};
21use crate::lsp::types::{IndexState, IndexUpdate, RelintRequest};
22use crate::rule::Rule;
23use crate::workspace_index::{FileIndex, WorkspaceIndex};
24
25/// Walk options for workspace indexing, derived from the resolved config.
26///
27/// Mirrors CLI discovery (gitignore handling driven by
28/// `global.respect_gitignore`, hidden files included, `.markdownlintignore`
29/// honored) with one deliberate divergence: `.git`/`node_modules`/`target`
30/// are always skipped as an editor-performance safety net, even when not
31/// gitignored.
32pub(super) fn index_walk_options(config: &Config) -> MarkdownWalkOptions {
33    MarkdownWalkOptions {
34        respect_gitignore: config.global.respect_gitignore,
35        skip_vendor_dirs: true,
36    }
37}
38
39/// The rules that contribute to the cross-file index, built from the resolved
40/// config so each one indexes with the settings the workspace configured.
41///
42/// Deliberately not filtered by the enabled-rule set: the index is what
43/// navigation, completion and rename read, so disabling a rule's diagnostics is
44/// not a request to lose heading anchors in the editor.
45///
46/// The membership is `CrossFileScope::Workspace`, but the scope is a method on
47/// a constructed rule, so deriving it means building all of them and discarding
48/// all but these two. The index resolves its rules once per directory, and once
49/// per file under `.editorconfig`, which made that discard the dominant cost of
50/// a scan. `cross_file_rules_match_the_workspace_scope` pins the list against
51/// the scope every rule declares, so a third one cannot join unnoticed.
52pub(super) fn cross_file_rules(config: &Config) -> Vec<Box<dyn Rule>> {
53    vec![
54        crate::rules::MD051LinkFragments::from_config(config),
55        crate::rules::MD057ExistingRelativeLinks::from_config(config),
56    ]
57}
58
59/// The configuration-derived objects needed to interpret one indexed file.
60/// Kept together so cached rules cannot accidentally be paired with a flavor
61/// from another configuration scope.
62struct IndexConfiguration {
63    config: Config,
64    rules: Vec<Box<dyn Rule>>,
65}
66
67impl IndexConfiguration {
68    fn new(config: Config) -> Self {
69        let rules = cross_file_rules(&config);
70        Self { config, rules }
71    }
72
73    fn build_file_index(&self, content: &str, path: &Path) -> FileIndex {
74        crate::build_file_index_only_with_config(
75            content,
76            &self.rules,
77            self.config.get_flavor_for_file(path),
78            Some(path.to_path_buf()),
79            &self.config,
80        )
81    }
82}
83
84/// A file update waiting out its debounce window.
85struct PendingUpdate {
86    /// The content to index once the window closes.
87    content: String,
88    /// When the update was queued, which starts the window.
89    queued_at: Instant,
90}
91
92/// Background worker for managing the workspace index
93///
94/// Receives updates via a channel and maintains the workspace index
95/// with debouncing to avoid excessive re-indexing during rapid edits.
96pub struct IndexWorker {
97    /// Receiver for index update messages
98    rx: mpsc::Receiver<IndexUpdate>,
99    /// The workspace index being maintained
100    workspace_index: Arc<RwLock<WorkspaceIndex>>,
101    /// Current state of the index (building/ready/error)
102    index_state: Arc<RwLock<IndexState>>,
103    /// LSP client for progress reporting
104    client: Client,
105    /// Workspace root folders
106    workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
107    /// Debouncing: path -> the update waiting out its window
108    pending: HashMap<PathBuf, PendingUpdate>,
109    /// Debounce duration
110    debounce_duration: Duration,
111    /// Sender to request re-linting of files (back to server)
112    relint_tx: mpsc::Sender<RelintRequest>,
113    /// Shared per-file configuration policy used by diagnostics.
114    config_resolver: ConfigResolver,
115    /// The server's document store, so a scan of the workspace indexes what an
116    /// editor is showing rather than what was last written to disk.
117    documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
118}
119
120/// The state an index worker shares with the server that spawned it.
121///
122/// Each handle is the server's own, so the worker reads what the editor is
123/// currently working with rather than a copy taken at startup.
124pub(crate) struct SharedIndexState {
125    pub(crate) workspace_index: Arc<RwLock<WorkspaceIndex>>,
126    pub(crate) index_state: Arc<RwLock<IndexState>>,
127    pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
128    pub(crate) config_resolver: ConfigResolver,
129    pub(crate) documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
130}
131
132impl IndexWorker {
133    /// Create a new index worker
134    pub(crate) fn new(
135        rx: mpsc::Receiver<IndexUpdate>,
136        client: Client,
137        relint_tx: mpsc::Sender<RelintRequest>,
138        shared: SharedIndexState,
139    ) -> Self {
140        let SharedIndexState {
141            workspace_index,
142            index_state,
143            workspace_roots,
144            config_resolver,
145            documents,
146        } = shared;
147        Self {
148            rx,
149            workspace_index,
150            index_state,
151            client,
152            workspace_roots,
153            pending: HashMap::new(),
154            debounce_duration: Duration::from_millis(100),
155            relint_tx,
156            config_resolver,
157            documents,
158        }
159    }
160
161    /// Run the index worker event loop
162    pub async fn run(mut self) {
163        let mut debounce_interval = tokio::time::interval(Duration::from_millis(50));
164
165        loop {
166            tokio::select! {
167                // Receive updates from main server
168                msg = self.rx.recv() => {
169                    match msg {
170                        Some(IndexUpdate::FileChanged { path, content }) => {
171                            self.pending.insert(path, PendingUpdate {
172                                content,
173                                queued_at: Instant::now(),
174                            });
175                        }
176                        Some(IndexUpdate::FileRemoved { path }) => {
177                            // A change is debounced and a removal is not, so an
178                            // update queued moments earlier is still waiting
179                            // here. Flushing it afterwards would put the path
180                            // back in an index that no longer covers it, and
181                            // nothing would take it out again.
182                            self.pending.remove(&path);
183                            self.handle_file_removed(&path).await;
184                        }
185                        Some(IndexUpdate::FullRescan) => {
186                            self.full_rescan().await;
187                        }
188                        Some(IndexUpdate::Shutdown) | None => {
189                            log::info!("Index worker shutting down");
190                            break;
191                        }
192                    }
193                }
194
195                // Process debounced updates periodically
196                _ = debounce_interval.tick() => {
197                    self.process_pending_updates().await;
198                }
199            }
200        }
201    }
202
203    /// Process pending updates that have been debounced long enough
204    async fn process_pending_updates(&mut self) {
205        let now = Instant::now();
206        let ready: Vec<_> = self
207            .pending
208            .iter()
209            .filter(|(_, pending)| now.duration_since(pending.queued_at) >= self.debounce_duration)
210            .map(|(path, _)| path.clone())
211            .collect();
212
213        if ready.is_empty() {
214            return;
215        }
216
217        let mut directory_configs: HashMap<PathBuf, IndexConfiguration> = HashMap::new();
218        for path in ready {
219            if let Some(pending) = self.pending.remove(&path) {
220                let directory = path.parent().unwrap_or(&path);
221                if let Some(index_config) = directory_configs.get(directory) {
222                    self.update_single_file(&path, &pending.content, index_config).await;
223                    continue;
224                }
225
226                let config = self.config_resolver.resolve_effective_config_for_file(&path).await;
227                let index_config = IndexConfiguration::new(config);
228                self.update_single_file(&path, &pending.content, &index_config).await;
229                directory_configs.insert(directory.to_path_buf(), index_config);
230            }
231        }
232    }
233
234    /// Update a single file in the index
235    async fn update_single_file(&self, path: &Path, content: &str, index_config: &IndexConfiguration) {
236        let Ok(file_index) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
237            index_config.build_file_index(content, path)
238        })) else {
239            log::error!("Panic while indexing {}: skipping", path.display());
240            return;
241        };
242
243        // What the index held for this file, so the update can answer whether it
244        // changed anything a cross-file check reads. Typing in a paragraph
245        // rewrites the entry with the same links and anchors, and re-linting
246        // every open file that links here on each pause in typing would cost a
247        // full lint per file for an answer that cannot have changed.
248        let previous = {
249            let index = self.workspace_index.read().await;
250            index.get_file(path).cloned()
251        };
252        let changed = previous
253            .as_ref()
254            .is_none_or(|previous| previous.extracted_data_differs(&file_index));
255        // Whether a link is involved on either side, so this file is worth
256        // re-linting itself. A link removed is as much a change as one added:
257        // the diagnostic it produced is on screen until something recomputes it.
258        let links_involved = !file_index.cross_file_links.is_empty()
259            || previous.is_some_and(|previous| !previous.cross_file_links.is_empty());
260
261        // Get old dependents before updating
262        let old_dependents = {
263            let index = self.workspace_index.read().await;
264            index.get_dependents(path)
265        };
266
267        // Update the index
268        {
269            let mut index = self.workspace_index.write().await;
270            index.update_file(path, file_index);
271        }
272
273        if !changed {
274            return;
275        }
276
277        // Get new dependents after updating
278        let new_dependents = {
279            let index = self.workspace_index.read().await;
280            index.get_dependents(path)
281        };
282
283        // Request re-lint of affected files (union of old and new dependents)
284        let mut affected: std::collections::HashSet<PathBuf> = old_dependents.into_iter().collect();
285        affected.extend(new_dependents);
286
287        // The file itself: its own cross-file diagnostics were computed against
288        // the entry this update just replaced, which for the document being
289        // typed in is the one the editor holds.
290        if links_involved {
291            affected.insert(path.to_path_buf());
292        }
293
294        for dep_path in affected {
295            self.request_relint(RelintRequest::File(dep_path)).await;
296        }
297    }
298
299    /// Ask the server to publish a document's diagnostics again.
300    ///
301    /// A closed channel means the server is gone, which happens on shutdown and
302    /// is not worth a warning; the request has nowhere useful to arrive.
303    async fn request_relint(&self, request: RelintRequest) {
304        if self.relint_tx.send(request).await.is_err() {
305            log::debug!("Re-lint channel closed; skipping re-lint request");
306        }
307    }
308
309    /// Build a FileIndex from content, parsing with the file's Markdown flavor so
310    /// the index (anchors, cross-file links, and the symbols built from it) matches
311    /// what diagnostics and the document outline see.
312    ///
313    /// The rules themselves say what a file contributes, through the same builder
314    /// the CLI uses, so the editor and the command line agree on which anchors
315    /// exist. Hand-rolling it here made them disagree: anchors were always
316    /// generated GitHub-style whatever the flavor and never deduplicated, HTML
317    /// and attribute anchors were missing entirely, and the inline-disable state
318    /// cross-file checks honor was never exported, so a `<!-- rumdl-disable -->`
319    /// held in the editor while the CLI honored it.
320    ///
321    /// Build `rules` with [`cross_file_rules`].
322    #[cfg(test)]
323    pub(super) fn build_file_index(
324        content: &str,
325        rules: &[Box<dyn Rule>],
326        flavor: MarkdownFlavor,
327        path: Option<&Path>,
328    ) -> FileIndex {
329        crate::build_file_index_only(content, rules, flavor, path.map(Path::to_path_buf))
330    }
331
332    /// Drop a file from the index, whether it was deleted or stopped being one
333    /// the index covers.
334    async fn handle_file_removed(&self, path: &Path) {
335        // Get dependents before removing
336        let dependents = {
337            let index = self.workspace_index.read().await;
338            index.get_dependents(path)
339        };
340
341        // Remove from index
342        {
343            let mut index = self.workspace_index.write().await;
344            index.remove_file(path);
345        }
346
347        // Request re-lint of dependent files (they now have broken links)
348        for dep_path in dependents {
349            self.request_relint(RelintRequest::File(dep_path)).await;
350        }
351    }
352
353    /// The content of every document an editor holds, keyed by the path it
354    /// indexes under.
355    ///
356    /// The index is keyed by path, so it answers with one version of a file
357    /// however many URI spellings name it, which is what the update messages
358    /// keyed by path already assume.
359    async fn open_buffers(&self) -> HashMap<PathBuf, String> {
360        self.documents
361            .read()
362            .await
363            .iter()
364            .filter(|(_, entry)| !entry.from_disk)
365            .filter_map(|(uri, entry)| Some((crate::lsp::resolve_uri(uri)?, entry.content.clone())))
366            .collect()
367    }
368
369    /// Perform a full rescan of the workspace
370    async fn full_rescan(&mut self) {
371        // Every waiting update is about to be superseded: a scan reads the
372        // filesystem for the disk-originated ones and the editor's own buffer
373        // for the rest, both of which are at least as new as what is waiting
374        // here, because a document is stored before its update is queued.
375        self.pending.clear();
376
377        // File selection remains a workspace-level decision. Once selected,
378        // each document is interpreted with its own effective configuration.
379        let roots = self.workspace_roots.read().await.clone();
380        let config = self.config_resolver.workspace_config().await;
381        let options = index_walk_options(&config);
382        let includes = config.global.include.clone();
383        let excludes = ExcludeMatchers::new(&config.global.exclude);
384        for (pattern, error) in &excludes.invalid {
385            log::warn!("Invalid exclude pattern '{pattern}': {error}");
386        }
387        let mut files = scan_markdown_files(&roots, options, includes, excludes).await;
388
389        // A document an editor holds belongs in the index whatever discovery says
390        // about it, because opening one indexes it: a scan that dropped it would
391        // be the rescan taking it back out. Its content comes from the buffer as
392        // well, since the filesystem holds the last save, and answering
393        // cross-file questions from that describes a version of the file the
394        // editor stopped showing.
395        let open_buffers = self.open_buffers().await;
396        let mut current: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
397        for path in open_buffers.keys() {
398            // Except where the file is gone, which no buffer speaks for: a
399            // rename reaches the server as a deletion of the old path, and the
400            // document can still be open under it when this runs. Asked as
401            // whether a file is there rather than whether anything is, because a
402            // directory that took the name answers the weaker question and the
403            // scan would never hand such a path back.
404            if tokio::fs::metadata(path).await.is_ok_and(|meta| meta.is_file()) && current.insert(path.clone()) {
405                files.push(path.clone());
406            }
407        }
408        let total = files.len();
409
410        // Evict entries the scan no longer covers (deleted files, newly excluded
411        // or gitignored ones) so navigation and completions stop surfacing them.
412        {
413            let removed = self.workspace_index.write().await.retain_only(&current);
414            if removed > 0 {
415                log::info!("Workspace rescan evicted {removed} stale index entries");
416            }
417        }
418
419        if total == 0 {
420            *self.index_state.write().await = IndexState::Ready;
421            self.request_relint(RelintRequest::AllOpen).await;
422            return;
423        }
424
425        // Set initial building state
426        *self.index_state.write().await = IndexState::Building {
427            progress: 0.0,
428            files_indexed: 0,
429            total_files: total,
430        };
431
432        // Report progress start
433        self.report_progress_begin(total).await;
434
435        // Files in one directory share every setting that can affect the
436        // workspace index. `.editorconfig` can vary lint-only settings between
437        // neighbors, but it cannot change Markdown flavor or either
438        // workspace-scoped rule, an invariant pinned by an integration test.
439        // Cache by directory so a large scan constructs those rules once.
440        let mut directory_configs: HashMap<PathBuf, IndexConfiguration> = HashMap::new();
441
442        // Index each file, an open document from the buffer read above.
443        for (i, path) in files.iter().enumerate() {
444            let content = match open_buffers.get(path) {
445                Some(buffer) => Some(buffer.clone()),
446                None => crate::lsp::read_markdown_lossy(path).await.ok(),
447            };
448            if let Some(content) = content {
449                let directory = path.parent().unwrap_or(path);
450                let file_index = if let Some(index_config) = directory_configs.get(directory) {
451                    index_config.build_file_index(&content, path)
452                } else {
453                    let config = self.config_resolver.resolve_effective_config_for_file(path).await;
454                    let index_config = IndexConfiguration::new(config);
455                    let file_index = index_config.build_file_index(&content, path);
456                    directory_configs.insert(directory.to_path_buf(), index_config);
457                    file_index
458                };
459
460                let mut index = self.workspace_index.write().await;
461                index.update_file(path, file_index);
462            }
463
464            // Report progress every 10 files or at end
465            if i % 10 == 0 || i == total - 1 {
466                let progress = ((i + 1) as f32 / total as f32) * 100.0;
467                *self.index_state.write().await = IndexState::Building {
468                    progress,
469                    files_indexed: i + 1,
470                    total_files: total,
471                };
472                self.report_progress_update(i + 1, total).await;
473            }
474        }
475
476        // Mark as ready
477        *self.index_state.write().await = IndexState::Ready;
478        self.report_progress_done().await;
479
480        log::info!("Workspace indexing complete: {total} files indexed");
481
482        // Every document opened while the scan ran was linted with cross-file
483        // checks skipped, because those are gated on the index being ready.
484        // Nothing else recomputes them, so without this the editor shows an
485        // incomplete answer until the file is edited.
486        self.request_relint(RelintRequest::AllOpen).await;
487    }
488
489    /// Report progress begin via LSP
490    async fn report_progress_begin(&self, total: usize) {
491        let token = NumberOrString::String("rumdl-index".to_string());
492
493        // Request progress token creation
494        if self
495            .client
496            .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams { token: token.clone() })
497            .await
498            .is_err()
499        {
500            log::debug!("Client does not support work done progress");
501            return;
502        }
503
504        // Send begin notification
505        self.client
506            .send_notification::<notification::Progress>(ProgressParams {
507                token,
508                value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin {
509                    title: "Indexing workspace".to_string(),
510                    cancellable: Some(false),
511                    message: Some(format!("Scanning {total} markdown files...")),
512                    percentage: Some(0),
513                })),
514            })
515            .await;
516    }
517
518    /// Report progress update via LSP
519    async fn report_progress_update(&self, indexed: usize, total: usize) {
520        let token = NumberOrString::String("rumdl-index".to_string());
521        let percentage = ((indexed as f32 / total as f32) * 100.0) as u32;
522
523        self.client
524            .send_notification::<notification::Progress>(ProgressParams {
525                token,
526                value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(WorkDoneProgressReport {
527                    cancellable: Some(false),
528                    message: Some(format!("Indexed {indexed}/{total} files")),
529                    percentage: Some(percentage),
530                })),
531            })
532            .await;
533    }
534
535    /// Report progress done via LSP
536    async fn report_progress_done(&self) {
537        let token = NumberOrString::String("rumdl-index".to_string());
538
539        self.client
540            .send_notification::<notification::Progress>(ProgressParams {
541                token,
542                value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
543                    message: Some("Indexing complete".to_string()),
544                })),
545            })
546            .await;
547    }
548}
549
550/// Scan workspace roots for markdown files
551///
552/// Applies the shared discovery semantics (gitignore handling per config,
553/// `.markdownlintignore`, hidden files included, vendor dirs skipped) plus
554/// config `include` and `exclude` patterns. Runs the (synchronous) filesystem
555/// walk on a blocking thread.
556async fn scan_markdown_files(
557    roots: &[PathBuf],
558    options: MarkdownWalkOptions,
559    includes: Vec<String>,
560    excludes: ExcludeMatchers,
561) -> Vec<PathBuf> {
562    let roots = roots.to_vec();
563    tokio::task::spawn_blocking(move || collect_markdown_files(&roots, &options, &includes, &excludes))
564        .await
565        .unwrap_or_else(|e| {
566            log::warn!("Workspace scan task failed: {e}");
567            Vec::new()
568        })
569}
570
571/// Collect the files selected by the production workspace-index configuration.
572fn collect_markdown_files(
573    roots: &[PathBuf],
574    options: &MarkdownWalkOptions,
575    includes: &[String],
576    excludes: &ExcludeMatchers,
577) -> Vec<PathBuf> {
578    MarkdownWorkspaceScan::new(options, includes, excludes).collect(roots)
579}
580
581/// Whether `path` should be excluded from the workspace index based on the
582/// production full-scan configuration.
583///
584/// Used to keep filesystem-watch events (`did_change_watched_files`) from
585/// reintroducing generated/ignored files that the full scan skips. Files the
586/// user explicitly opens or edits bypass this check, since the active document
587/// must stay indexed for in-file anchor completion.
588///
589/// Determines ignore status by walking from the containing workspace root down
590/// the chain of directories leading to `path`, using the shared
591/// [`index_walk_options`] configuration. Descent is pruned to that single chain,
592/// so the walk applies the same ignore rules the full scan would (including an
593/// ignored ancestor directory or a hidden entry) without traversing the tree. If
594/// the walk does not yield `path`, the file must not enter the index.
595///
596/// `node_modules`/`target` are also checked directly so the predicate works even
597/// for paths that do not exist on disk. The file must exist for the walk to
598/// observe it, which holds for the create/change watch events that use this.
599pub(super) fn path_is_ignored_for_index(
600    roots: &[PathBuf],
601    path: &Path,
602    options: &MarkdownWalkOptions,
603    includes: &[String],
604    excludes: &ExcludeMatchers,
605) -> bool {
606    MarkdownWorkspaceScan::new(options, includes, excludes).path_is_ignored(roots, path)
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use crate::rule::CrossFileScope;
613
614    /// Index `content` the way the worker does, with the default configuration.
615    fn build_index(content: &str, flavor: MarkdownFlavor) -> FileIndex {
616        let rules = cross_file_rules(&Config::default());
617        IndexWorker::build_file_index(content, &rules, flavor, None)
618    }
619
620    /// `cross_file_rules` names its members rather than deriving them, so this
621    /// is what keeps the list honest: a rule that starts declaring
622    /// `CrossFileScope::Workspace` fails here until the index builds it too,
623    /// and one that stops declaring it fails until the index drops it.
624    #[test]
625    fn cross_file_rules_match_the_workspace_scope() {
626        let config = Config::default();
627        let names = |rules: &[Box<dyn Rule>]| rules.iter().map(|rule| rule.name().to_string()).collect::<Vec<_>>();
628
629        let declared = crate::rules::all_rules(&config)
630            .into_iter()
631            .filter(|rule| rule.cross_file_scope() == CrossFileScope::Workspace)
632            .collect::<Vec<_>>();
633
634        assert!(
635            !declared.is_empty(),
636            "control: the scope must be reachable, or this test says nothing"
637        );
638        assert_eq!(names(&declared), names(&cross_file_rules(&config)));
639    }
640
641    #[test]
642    fn test_build_file_index() {
643        let content = r#"
644# Main Heading
645
646Some text.
647
648## Sub Heading {#sub}
649
650More text with [link](./other.md#section).
651"#;
652
653        let index = build_index(content, MarkdownFlavor::default());
654
655        assert_eq!(index.headings.len(), 2);
656        assert_eq!(index.headings[0].text, "Main Heading");
657        assert!(index.headings[0].custom_anchor.is_none());
658
659        // HeadingInfo.text has the custom ID stripped; the custom_id is stored separately
660        assert_eq!(index.headings[1].text, "Sub Heading");
661        assert_eq!(index.headings[1].custom_anchor, Some("sub".to_string()));
662
663        assert_eq!(index.cross_file_links.len(), 1);
664        assert_eq!(index.cross_file_links[0].target_path, "./other.md");
665        assert_eq!(index.cross_file_links[0].fragment, "section");
666    }
667
668    #[test]
669    fn test_build_file_index_respects_flavor() {
670        // `# -8<- [start:x]` is a heading in Standard markdown but a MkDocs snippet
671        // marker. The index must parse with the file's flavor so anchors, cross-file
672        // navigation, and workspace symbols all agree with the document outline.
673        let content = "# Real\n\n# -8<- [start:section]\n";
674
675        let standard = build_index(content, MarkdownFlavor::Standard);
676        assert_eq!(
677            standard.headings.len(),
678            2,
679            "Standard treats the snippet line as a heading"
680        );
681
682        let mkdocs = build_index(content, MarkdownFlavor::MkDocs);
683        assert_eq!(mkdocs.headings.len(), 1, "MkDocs excludes the snippet marker");
684        assert_eq!(mkdocs.headings[0].text, "Real");
685    }
686
687    #[test]
688    fn test_build_file_index_column_positions() {
689        // Verify that column positions are correct (fix for issue #234)
690        let content = "See [link](./file.md) here.\n";
691
692        let index = build_index(content, MarkdownFlavor::default());
693
694        assert_eq!(index.cross_file_links.len(), 1);
695        assert_eq!(index.cross_file_links[0].target_path, "./file.md");
696        assert_eq!(index.cross_file_links[0].line, 1);
697        // "See [link](" = 11 chars, so column 12 is where "./file.md" starts
698        assert_eq!(index.cross_file_links[0].column, 12);
699    }
700
701    #[test]
702    fn test_build_file_index_multiple_links() {
703        let content = "First [a](./a.md) and [b](./b.md#section) links.\n";
704
705        let index = build_index(content, MarkdownFlavor::default());
706
707        assert_eq!(index.cross_file_links.len(), 2);
708
709        let find = |target: &str| {
710            index
711                .cross_file_links
712                .iter()
713                .find(|link| link.target_path == target)
714                .unwrap_or_else(|| panic!("no indexed link to {target}: {:?}", index.cross_file_links))
715        };
716
717        // Only MD057 indexes a link with no fragment, and it points at the
718        // destination: "First [a](" = 10 chars, column 11.
719        assert_eq!(find("./a.md").column, 11);
720
721        // MD051 indexes a link that carries one and points at the link itself,
722        // where its cross-file diagnostic belongs: "First [a](./a.md) and " = 22
723        // chars, column 23. It contributes first, so its position is the one kept.
724        let fragment_link = find("./b.md");
725        assert_eq!(fragment_link.fragment, "section");
726        assert_eq!(fragment_link.column, 23);
727    }
728
729    #[test]
730    fn test_collect_markdown_files_respects_gitignore() {
731        use std::fs;
732
733        let dir = tempfile::tempdir().unwrap();
734        let root = dir.path();
735
736        // A tracked markdown file and a build-output one that .gitignore excludes.
737        fs::write(root.join("README.md"), "# Readme\n").unwrap();
738        fs::write(root.join(".gitignore"), "build/\nignored.md\n").unwrap();
739        fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
740        fs::create_dir(root.join("build")).unwrap();
741        fs::write(root.join("build").join("generated.md"), "# Generated\n").unwrap();
742
743        // Dependency/output dirs are skipped even when not gitignored.
744        fs::create_dir(root.join("node_modules")).unwrap();
745        fs::write(root.join("node_modules").join("dep.md"), "# Dep\n").unwrap();
746
747        let mut files = collect_markdown_files(
748            &[root.to_path_buf()],
749            &index_walk_options(&Config::default()),
750            &[],
751            &ExcludeMatchers::new(&[]),
752        );
753        files.sort();
754
755        let names: Vec<String> = files
756            .iter()
757            .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
758            .collect();
759
760        assert_eq!(names, vec!["README.md".to_string()]);
761    }
762
763    #[test]
764    fn test_collect_markdown_files_applies_config_excludes() {
765        use std::fs;
766
767        let dir = tempfile::tempdir().unwrap();
768        let root = dir.path();
769
770        fs::write(root.join("README.md"), "# Readme\n").unwrap();
771        fs::create_dir(root.join("drafts")).unwrap();
772        fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
773
774        // A bare directory pattern must exclude the directory's contents,
775        // matching CLI behavior.
776        let excludes = ExcludeMatchers::new(&["drafts".to_string()]);
777        let names: Vec<String> = collect_markdown_files(
778            &[root.to_path_buf()],
779            &index_walk_options(&Config::default()),
780            &[],
781            &excludes,
782        )
783        .iter()
784        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
785        .collect();
786
787        assert_eq!(names, vec!["README.md".to_string()]);
788    }
789
790    #[test]
791    fn test_collect_markdown_files_honors_absolute_exclude_patterns() {
792        use std::fs;
793
794        let dir = tempfile::tempdir().unwrap();
795        // Canonicalize the way production does, so the pattern built below has
796        // the shape an expanded `~` produces (on Windows that means no verbatim
797        // `\\?\` prefix, which would match nothing).
798        let root = crate::discovery::canonicalize_for_matching(dir.path()).unwrap();
799
800        fs::write(root.join("README.md"), "# Readme\n").unwrap();
801        fs::create_dir(root.join("drafts")).unwrap();
802        fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
803
804        // An absolute pattern - what a `~/...` pattern expands to - must
805        // exclude in the workspace scan just as it does in the CLI walk.
806        let pattern = format!("{}/drafts", root.to_string_lossy().replace('\\', "/"));
807        let names: Vec<String> = collect_markdown_files(
808            std::slice::from_ref(&root),
809            &index_walk_options(&Config::default()),
810            &[],
811            &ExcludeMatchers::new(&[pattern]),
812        )
813        .iter()
814        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
815        .collect();
816
817        assert_eq!(names, vec!["README.md".to_string()]);
818    }
819
820    #[test]
821    fn test_collect_markdown_files_can_disable_gitignore() {
822        use std::fs;
823
824        let dir = tempfile::tempdir().unwrap();
825        let root = dir.path();
826
827        fs::write(root.join(".gitignore"), "ignored.md\n").unwrap();
828        fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
829
830        let mut config = Config::default();
831        config.global.respect_gitignore = false;
832        let names: Vec<String> = collect_markdown_files(
833            &[root.to_path_buf()],
834            &index_walk_options(&config),
835            &[],
836            &ExcludeMatchers::new(&[]),
837        )
838        .iter()
839        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
840        .collect();
841
842        assert_eq!(names, vec!["ignored.md".to_string()]);
843    }
844
845    #[test]
846    fn test_collect_markdown_files_includes_hidden_files() {
847        use std::fs;
848
849        let dir = tempfile::tempdir().unwrap();
850        let root = dir.path();
851
852        fs::create_dir(root.join(".github")).unwrap();
853        fs::write(root.join(".github").join("PULL_REQUEST_TEMPLATE.md"), "# PR\n").unwrap();
854        fs::write(root.join("README.md"), "# Readme\n").unwrap();
855
856        let mut names: Vec<String> = collect_markdown_files(
857            &[root.to_path_buf()],
858            &index_walk_options(&Config::default()),
859            &[],
860            &ExcludeMatchers::new(&[]),
861        )
862        .iter()
863        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
864        .collect();
865        names.sort();
866
867        // Hidden files lint in the CLI, so the index must cover them too.
868        assert_eq!(
869            names,
870            vec!["PULL_REQUEST_TEMPLATE.md".to_string(), "README.md".to_string()]
871        );
872    }
873
874    #[test]
875    fn test_collect_markdown_files_finds_nested_markdown() {
876        use std::fs;
877
878        let dir = tempfile::tempdir().unwrap();
879        let root = dir.path();
880
881        fs::write(root.join("top.md"), "# Top\n").unwrap();
882        fs::create_dir(root.join("docs")).unwrap();
883        fs::write(root.join("docs").join("guide.markdown"), "# Guide\n").unwrap();
884        fs::write(root.join("docs").join("notes.txt"), "not markdown\n").unwrap();
885
886        let mut names: Vec<String> = collect_markdown_files(
887            &[root.to_path_buf()],
888            &index_walk_options(&Config::default()),
889            &[],
890            &ExcludeMatchers::new(&[]),
891        )
892        .iter()
893        .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
894        .collect();
895        names.sort();
896
897        assert_eq!(names, vec!["guide.markdown".to_string(), "top.md".to_string()]);
898    }
899
900    #[test]
901    fn test_workspace_index_applies_includes_to_scan_and_watch_events() {
902        use std::fs;
903
904        let dir = tempfile::tempdir().unwrap();
905        let root = dir.path().to_path_buf();
906        fs::create_dir(root.join("docs")).unwrap();
907        fs::create_dir(root.join("templates")).unwrap();
908        fs::write(root.join("README.md"), "# Readme\n").unwrap();
909        fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
910        fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
911
912        let roots = vec![root.clone()];
913        let options = index_walk_options(&Config::default());
914        let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
915        let excludes = ExcludeMatchers::new(&[]);
916
917        // The test creates these names itself, so normalizing separators
918        // unconditionally is safe and keeps one expected value for every platform.
919        let names: Vec<String> = collect_markdown_files(&roots, &options, &includes, &excludes)
920            .iter()
921            .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
922            .collect();
923        assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
924
925        assert!(path_is_ignored_for_index(
926            &roots,
927            &root.join("README.md"),
928            &options,
929            &includes,
930            &excludes
931        ));
932        assert!(!path_is_ignored_for_index(
933            &roots,
934            &root.join("templates/page.md.jinja"),
935            &options,
936            &includes,
937            &excludes
938        ));
939    }
940
941    #[test]
942    fn test_path_is_ignored_for_index() {
943        use std::fs;
944
945        let dir = tempfile::tempdir().unwrap();
946        let root = dir.path().to_path_buf();
947        fs::write(root.join(".gitignore"), "build/\ndraft.md\n").unwrap();
948
949        // The check walks the file's directory, so the files must exist (as they
950        // do for create/change watch events).
951        fs::write(root.join("README.md"), "").unwrap();
952        fs::write(root.join("draft.md"), "").unwrap();
953        fs::write(root.join(".hidden.md"), "").unwrap();
954        fs::create_dir(root.join("docs")).unwrap();
955        fs::write(root.join("docs").join("guide.md"), "").unwrap();
956        fs::create_dir(root.join("build")).unwrap();
957        fs::write(root.join("build").join("out.md"), "").unwrap();
958
959        let roots = vec![root.clone()];
960        let options = index_walk_options(&Config::default());
961        let no_excludes = ExcludeMatchers::new(&[]);
962
963        // Tracked files are not ignored.
964        assert!(!path_is_ignored_for_index(
965            &roots,
966            &root.join("README.md"),
967            &options,
968            &[],
969            &no_excludes
970        ));
971        assert!(!path_is_ignored_for_index(
972            &roots,
973            &root.join("docs/guide.md"),
974            &options,
975            &[],
976            &no_excludes
977        ));
978
979        // Gitignored file and file inside a gitignored directory.
980        assert!(path_is_ignored_for_index(
981            &roots,
982            &root.join("draft.md"),
983            &options,
984            &[],
985            &no_excludes
986        ));
987        assert!(path_is_ignored_for_index(
988            &roots,
989            &root.join("build/out.md"),
990            &options,
991            &[],
992            &no_excludes
993        ));
994
995        // Hidden files are indexed, matching the CLI which lints them.
996        assert!(!path_is_ignored_for_index(
997            &roots,
998            &root.join(".hidden.md"),
999            &options,
1000            &[],
1001            &no_excludes
1002        ));
1003
1004        // Dependency/output dirs are always skipped, even without a gitignore rule
1005        // and without the file existing.
1006        assert!(path_is_ignored_for_index(
1007            &roots,
1008            &root.join("node_modules/dep.md"),
1009            &options,
1010            &[],
1011            &no_excludes
1012        ));
1013        assert!(path_is_ignored_for_index(
1014            &roots,
1015            &root.join("target/doc.md"),
1016            &options,
1017            &[],
1018            &no_excludes
1019        ));
1020
1021        // Config exclude patterns are honored, matched root-relative.
1022        let excludes = ExcludeMatchers::new(&["docs".to_string()]);
1023        assert!(path_is_ignored_for_index(
1024            &roots,
1025            &root.join("docs/guide.md"),
1026            &options,
1027            &[],
1028            &excludes
1029        ));
1030        assert!(!path_is_ignored_for_index(
1031            &roots,
1032            &root.join("README.md"),
1033            &options,
1034            &[],
1035            &excludes
1036        ));
1037
1038        // Paths outside every workspace root are not filtered.
1039        let outside = dir.path().parent().unwrap().join("elsewhere.md");
1040        assert!(!path_is_ignored_for_index(
1041            &roots,
1042            &outside,
1043            &options,
1044            &[],
1045            &no_excludes
1046        ));
1047    }
1048
1049    #[test]
1050    fn test_path_is_ignored_for_index_honors_nested_gitignore() {
1051        use std::fs;
1052
1053        let dir = tempfile::tempdir().unwrap();
1054        let root = dir.path().to_path_buf();
1055        fs::create_dir(root.join("docs")).unwrap();
1056        fs::write(root.join("docs").join(".gitignore"), "generated.md\n").unwrap();
1057        fs::write(root.join("docs").join("generated.md"), "").unwrap();
1058        fs::write(root.join("docs").join("manual.md"), "").unwrap();
1059
1060        let roots = vec![root.clone()];
1061        let options = index_walk_options(&Config::default());
1062        let no_excludes = ExcludeMatchers::new(&[]);
1063
1064        assert!(path_is_ignored_for_index(
1065            &roots,
1066            &root.join("docs/generated.md"),
1067            &options,
1068            &[],
1069            &no_excludes
1070        ));
1071        assert!(!path_is_ignored_for_index(
1072            &roots,
1073            &root.join("docs/manual.md"),
1074            &options,
1075            &[],
1076            &no_excludes
1077        ));
1078    }
1079
1080    #[test]
1081    fn test_path_is_ignored_for_index_workspace_under_target_dir() {
1082        use std::fs;
1083
1084        // A workspace whose own path contains a `target` component must not have
1085        // all of its files treated as ignored.
1086        let dir = tempfile::tempdir().unwrap();
1087        let root = dir.path().join("target").join("my-docs");
1088        fs::create_dir_all(&root).unwrap();
1089        fs::write(root.join("README.md"), "").unwrap();
1090        fs::create_dir(root.join("target")).unwrap();
1091        fs::write(root.join("target").join("out.md"), "").unwrap();
1092
1093        let roots = vec![root.clone()];
1094        let options = index_walk_options(&Config::default());
1095        let no_excludes = ExcludeMatchers::new(&[]);
1096
1097        // Files directly under the workspace are indexed despite the `target`
1098        // ancestor in the absolute path.
1099        assert!(!path_is_ignored_for_index(
1100            &roots,
1101            &root.join("README.md"),
1102            &options,
1103            &[],
1104            &no_excludes
1105        ));
1106        // A `target` directory *inside* the workspace is still excluded.
1107        assert!(path_is_ignored_for_index(
1108            &roots,
1109            &root.join("target/out.md"),
1110            &options,
1111            &[],
1112            &no_excludes
1113        ));
1114    }
1115
1116    /// The guard deciding whether an index update is worth re-linting for.
1117    ///
1118    /// Every keystroke rewrites the typed file's entry, so answering "changed"
1119    /// for a prose edit would lint every file linking here on each pause in
1120    /// typing, for an answer that cannot have moved.
1121    #[test]
1122    fn test_extracted_data_differs_ignores_a_prose_only_edit() {
1123        let before = build_index(
1124            "# Guide\n\nProse.\n\nSee [other](./other.md#section).\n",
1125            MarkdownFlavor::default(),
1126        );
1127        let after = build_index(
1128            "# Guide\n\nProse, now with a clause typed into it.\n\nSee [other](./other.md#section).\n",
1129            MarkdownFlavor::default(),
1130        );
1131
1132        // Control: the two really are different documents, so a `false` here is
1133        // the guard answering rather than the test comparing a value to itself.
1134        assert_ne!(before.content_hash, after.content_hash);
1135        assert!(!before.extracted_data_differs(&after));
1136    }
1137
1138    #[test]
1139    fn test_extracted_data_differs_reports_a_renamed_heading() {
1140        // What a file's dependents read: rename the anchor they link to and
1141        // their diagnostics change, with no event in their own documents.
1142        let before = build_index("# Setup\n", MarkdownFlavor::default());
1143        let after = build_index("# Installation\n", MarkdownFlavor::default());
1144
1145        assert!(before.extracted_data_differs(&after));
1146    }
1147
1148    #[test]
1149    fn test_extracted_data_differs_reports_a_new_link() {
1150        // What the typed file itself reads: a link just written has no
1151        // diagnostic yet, and nothing but this update will ask for one.
1152        let before = build_index("# Guide\n\nProse.\n", MarkdownFlavor::default());
1153        let after = build_index("# Guide\n\nSee [other](./other.md#nope).\n", MarkdownFlavor::default());
1154
1155        assert!(before.extracted_data_differs(&after));
1156    }
1157}