1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use tokio::sync::{RwLock, mpsc};
13use tower_lsp::Client;
14use tower_lsp::lsp_types::*;
15
16use crate::config::Config;
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
25pub(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
39pub(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
59struct 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
84struct PendingUpdate {
86 content: String,
88 queued_at: Instant,
90}
91
92pub struct IndexWorker {
97 rx: mpsc::Receiver<IndexUpdate>,
99 workspace_index: Arc<RwLock<WorkspaceIndex>>,
101 index_state: Arc<RwLock<IndexState>>,
103 client: Client,
105 workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
107 pending: HashMap<PathBuf, PendingUpdate>,
109 debounce_duration: Duration,
111 relint_tx: mpsc::Sender<RelintRequest>,
113 config_resolver: ConfigResolver,
115 documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
118}
119
120pub(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 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 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 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 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 _ = debounce_interval.tick() => {
197 self.process_pending_updates().await;
198 }
199 }
200 }
201 }
202
203 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 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 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 let links_involved = !file_index.cross_file_links.is_empty()
259 || previous.is_some_and(|previous| !previous.cross_file_links.is_empty());
260
261 let old_dependents = {
263 let index = self.workspace_index.read().await;
264 index.get_dependents(path)
265 };
266
267 {
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 let new_dependents = {
279 let index = self.workspace_index.read().await;
280 index.get_dependents(path)
281 };
282
283 let mut affected: std::collections::HashSet<PathBuf> = old_dependents.into_iter().collect();
285 affected.extend(new_dependents);
286
287 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 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 #[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 async fn handle_file_removed(&self, path: &Path) {
335 let dependents = {
337 let index = self.workspace_index.read().await;
338 index.get_dependents(path)
339 };
340
341 {
343 let mut index = self.workspace_index.write().await;
344 index.remove_file(path);
345 }
346
347 for dep_path in dependents {
349 self.request_relint(RelintRequest::File(dep_path)).await;
350 }
351 }
352
353 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 async fn full_rescan(&mut self) {
371 self.pending.clear();
376
377 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 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 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 {
413 let removed = self.workspace_index.write().await.retain_only(¤t);
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 *self.index_state.write().await = IndexState::Building {
427 progress: 0.0,
428 files_indexed: 0,
429 total_files: total,
430 };
431
432 self.report_progress_begin(total).await;
434
435 let mut directory_configs: HashMap<PathBuf, IndexConfiguration> = HashMap::new();
441
442 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 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 *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 self.request_relint(RelintRequest::AllOpen).await;
487 }
488
489 async fn report_progress_begin(&self, total: usize) {
491 let token = NumberOrString::String("rumdl-index".to_string());
492
493 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 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 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 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
550async 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
571fn 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
581pub(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 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 #[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 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 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 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 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 assert_eq!(find("./a.md").column, 11);
720
721 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 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 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 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 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 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 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 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 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 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 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 assert!(!path_is_ignored_for_index(
997 &roots,
998 &root.join(".hidden.md"),
999 &options,
1000 &[],
1001 &no_excludes
1002 ));
1003
1004 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 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 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 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 assert!(!path_is_ignored_for_index(
1100 &roots,
1101 &root.join("README.md"),
1102 &options,
1103 &[],
1104 &no_excludes
1105 ));
1106 assert!(path_is_ignored_for_index(
1108 &roots,
1109 &root.join("target/out.md"),
1110 &options,
1111 &[],
1112 &no_excludes
1113 ));
1114 }
1115
1116 #[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 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 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 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}