use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{RwLock, mpsc};
use tower_lsp::Client;
use tower_lsp::lsp_types::*;
use crate::config::{Config, MarkdownFlavor};
use crate::discovery::{ExcludeMatchers, MarkdownWalkOptions, MarkdownWorkspaceScan};
use crate::lsp::server::{ConfigResolver, DocumentEntry};
use crate::lsp::types::{IndexState, IndexUpdate, RelintRequest};
use crate::rule::Rule;
use crate::workspace_index::{FileIndex, WorkspaceIndex};
pub(super) fn index_walk_options(config: &Config) -> MarkdownWalkOptions {
MarkdownWalkOptions {
respect_gitignore: config.global.respect_gitignore,
skip_vendor_dirs: true,
}
}
pub(super) fn cross_file_rules(config: &Config) -> Vec<Box<dyn Rule>> {
vec![
crate::rules::MD051LinkFragments::from_config(config),
crate::rules::MD057ExistingRelativeLinks::from_config(config),
]
}
struct IndexConfiguration {
config: Config,
rules: Vec<Box<dyn Rule>>,
}
impl IndexConfiguration {
fn new(config: Config) -> Self {
let rules = cross_file_rules(&config);
Self { config, rules }
}
fn build_file_index(&self, content: &str, path: &Path) -> FileIndex {
IndexWorker::build_file_index(content, &self.rules, self.config.get_flavor_for_file(path), Some(path))
}
}
struct PendingUpdate {
content: String,
queued_at: Instant,
}
pub struct IndexWorker {
rx: mpsc::Receiver<IndexUpdate>,
workspace_index: Arc<RwLock<WorkspaceIndex>>,
index_state: Arc<RwLock<IndexState>>,
client: Client,
workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
pending: HashMap<PathBuf, PendingUpdate>,
debounce_duration: Duration,
relint_tx: mpsc::Sender<RelintRequest>,
config_resolver: ConfigResolver,
documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
}
pub(crate) struct SharedIndexState {
pub(crate) workspace_index: Arc<RwLock<WorkspaceIndex>>,
pub(crate) index_state: Arc<RwLock<IndexState>>,
pub(crate) workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
pub(crate) config_resolver: ConfigResolver,
pub(crate) documents: Arc<RwLock<HashMap<Url, DocumentEntry>>>,
}
impl IndexWorker {
pub(crate) fn new(
rx: mpsc::Receiver<IndexUpdate>,
client: Client,
relint_tx: mpsc::Sender<RelintRequest>,
shared: SharedIndexState,
) -> Self {
let SharedIndexState {
workspace_index,
index_state,
workspace_roots,
config_resolver,
documents,
} = shared;
Self {
rx,
workspace_index,
index_state,
client,
workspace_roots,
pending: HashMap::new(),
debounce_duration: Duration::from_millis(100),
relint_tx,
config_resolver,
documents,
}
}
pub async fn run(mut self) {
let mut debounce_interval = tokio::time::interval(Duration::from_millis(50));
loop {
tokio::select! {
msg = self.rx.recv() => {
match msg {
Some(IndexUpdate::FileChanged { path, content }) => {
self.pending.insert(path, PendingUpdate {
content,
queued_at: Instant::now(),
});
}
Some(IndexUpdate::FileRemoved { path }) => {
self.pending.remove(&path);
self.handle_file_removed(&path).await;
}
Some(IndexUpdate::FullRescan) => {
self.full_rescan().await;
}
Some(IndexUpdate::Shutdown) | None => {
log::info!("Index worker shutting down");
break;
}
}
}
_ = debounce_interval.tick() => {
self.process_pending_updates().await;
}
}
}
}
async fn process_pending_updates(&mut self) {
let now = Instant::now();
let ready: Vec<_> = self
.pending
.iter()
.filter(|(_, pending)| now.duration_since(pending.queued_at) >= self.debounce_duration)
.map(|(path, _)| path.clone())
.collect();
if ready.is_empty() {
return;
}
let mut directory_configs: HashMap<PathBuf, IndexConfiguration> = HashMap::new();
for path in ready {
if let Some(pending) = self.pending.remove(&path) {
let directory = path.parent().unwrap_or(&path);
if let Some(index_config) = directory_configs.get(directory) {
self.update_single_file(&path, &pending.content, index_config).await;
continue;
}
let config = self.config_resolver.resolve_effective_config_for_file(&path).await;
let index_config = IndexConfiguration::new(config);
self.update_single_file(&path, &pending.content, &index_config).await;
directory_configs.insert(directory.to_path_buf(), index_config);
}
}
}
async fn update_single_file(&self, path: &Path, content: &str, index_config: &IndexConfiguration) {
let Ok(file_index) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
index_config.build_file_index(content, path)
})) else {
log::error!("Panic while indexing {}: skipping", path.display());
return;
};
let previous = {
let index = self.workspace_index.read().await;
index.get_file(path).cloned()
};
let changed = previous
.as_ref()
.is_none_or(|previous| previous.extracted_data_differs(&file_index));
let links_involved = !file_index.cross_file_links.is_empty()
|| previous.is_some_and(|previous| !previous.cross_file_links.is_empty());
let old_dependents = {
let index = self.workspace_index.read().await;
index.get_dependents(path)
};
{
let mut index = self.workspace_index.write().await;
index.update_file(path, file_index);
}
if !changed {
return;
}
let new_dependents = {
let index = self.workspace_index.read().await;
index.get_dependents(path)
};
let mut affected: std::collections::HashSet<PathBuf> = old_dependents.into_iter().collect();
affected.extend(new_dependents);
if links_involved {
affected.insert(path.to_path_buf());
}
for dep_path in affected {
self.request_relint(RelintRequest::File(dep_path)).await;
}
}
async fn request_relint(&self, request: RelintRequest) {
if self.relint_tx.send(request).await.is_err() {
log::debug!("Re-lint channel closed; skipping re-lint request");
}
}
pub(super) fn build_file_index(
content: &str,
rules: &[Box<dyn Rule>],
flavor: MarkdownFlavor,
path: Option<&Path>,
) -> FileIndex {
crate::build_file_index_only(content, rules, flavor, path.map(Path::to_path_buf))
}
async fn handle_file_removed(&self, path: &Path) {
let dependents = {
let index = self.workspace_index.read().await;
index.get_dependents(path)
};
{
let mut index = self.workspace_index.write().await;
index.remove_file(path);
}
for dep_path in dependents {
self.request_relint(RelintRequest::File(dep_path)).await;
}
}
async fn open_buffers(&self) -> HashMap<PathBuf, String> {
self.documents
.read()
.await
.iter()
.filter(|(_, entry)| !entry.from_disk)
.filter_map(|(uri, entry)| Some((crate::lsp::resolve_uri(uri)?, entry.content.clone())))
.collect()
}
async fn full_rescan(&mut self) {
self.pending.clear();
let roots = self.workspace_roots.read().await.clone();
let config = self.config_resolver.workspace_config().await;
let options = index_walk_options(&config);
let includes = config.global.include.clone();
let excludes = ExcludeMatchers::new(&config.global.exclude);
for (pattern, error) in &excludes.invalid {
log::warn!("Invalid exclude pattern '{pattern}': {error}");
}
let mut files = scan_markdown_files(&roots, options, includes, excludes).await;
let open_buffers = self.open_buffers().await;
let mut current: std::collections::HashSet<PathBuf> = files.iter().cloned().collect();
for path in open_buffers.keys() {
if tokio::fs::metadata(path).await.is_ok_and(|meta| meta.is_file()) && current.insert(path.clone()) {
files.push(path.clone());
}
}
let total = files.len();
{
let removed = self.workspace_index.write().await.retain_only(¤t);
if removed > 0 {
log::info!("Workspace rescan evicted {removed} stale index entries");
}
}
if total == 0 {
*self.index_state.write().await = IndexState::Ready;
self.request_relint(RelintRequest::AllOpen).await;
return;
}
*self.index_state.write().await = IndexState::Building {
progress: 0.0,
files_indexed: 0,
total_files: total,
};
self.report_progress_begin(total).await;
let mut directory_configs: HashMap<PathBuf, IndexConfiguration> = HashMap::new();
for (i, path) in files.iter().enumerate() {
let content = match open_buffers.get(path) {
Some(buffer) => Some(buffer.clone()),
None => tokio::fs::read_to_string(path).await.ok(),
};
if let Some(content) = content {
let directory = path.parent().unwrap_or(path);
let file_index = if let Some(index_config) = directory_configs.get(directory) {
index_config.build_file_index(&content, path)
} else {
let config = self.config_resolver.resolve_effective_config_for_file(path).await;
let index_config = IndexConfiguration::new(config);
let file_index = index_config.build_file_index(&content, path);
directory_configs.insert(directory.to_path_buf(), index_config);
file_index
};
let mut index = self.workspace_index.write().await;
index.update_file(path, file_index);
}
if i % 10 == 0 || i == total - 1 {
let progress = ((i + 1) as f32 / total as f32) * 100.0;
*self.index_state.write().await = IndexState::Building {
progress,
files_indexed: i + 1,
total_files: total,
};
self.report_progress_update(i + 1, total).await;
}
}
*self.index_state.write().await = IndexState::Ready;
self.report_progress_done().await;
log::info!("Workspace indexing complete: {total} files indexed");
self.request_relint(RelintRequest::AllOpen).await;
}
async fn report_progress_begin(&self, total: usize) {
let token = NumberOrString::String("rumdl-index".to_string());
if self
.client
.send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams { token: token.clone() })
.await
.is_err()
{
log::debug!("Client does not support work done progress");
return;
}
self.client
.send_notification::<notification::Progress>(ProgressParams {
token,
value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin {
title: "Indexing workspace".to_string(),
cancellable: Some(false),
message: Some(format!("Scanning {total} markdown files...")),
percentage: Some(0),
})),
})
.await;
}
async fn report_progress_update(&self, indexed: usize, total: usize) {
let token = NumberOrString::String("rumdl-index".to_string());
let percentage = ((indexed as f32 / total as f32) * 100.0) as u32;
self.client
.send_notification::<notification::Progress>(ProgressParams {
token,
value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(WorkDoneProgressReport {
cancellable: Some(false),
message: Some(format!("Indexed {indexed}/{total} files")),
percentage: Some(percentage),
})),
})
.await;
}
async fn report_progress_done(&self) {
let token = NumberOrString::String("rumdl-index".to_string());
self.client
.send_notification::<notification::Progress>(ProgressParams {
token,
value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
message: Some("Indexing complete".to_string()),
})),
})
.await;
}
}
async fn scan_markdown_files(
roots: &[PathBuf],
options: MarkdownWalkOptions,
includes: Vec<String>,
excludes: ExcludeMatchers,
) -> Vec<PathBuf> {
let roots = roots.to_vec();
tokio::task::spawn_blocking(move || collect_markdown_files(&roots, &options, &includes, &excludes))
.await
.unwrap_or_else(|e| {
log::warn!("Workspace scan task failed: {e}");
Vec::new()
})
}
fn collect_markdown_files(
roots: &[PathBuf],
options: &MarkdownWalkOptions,
includes: &[String],
excludes: &ExcludeMatchers,
) -> Vec<PathBuf> {
MarkdownWorkspaceScan::new(options, includes, excludes).collect(roots)
}
pub(super) fn path_is_ignored_for_index(
roots: &[PathBuf],
path: &Path,
options: &MarkdownWalkOptions,
includes: &[String],
excludes: &ExcludeMatchers,
) -> bool {
MarkdownWorkspaceScan::new(options, includes, excludes).path_is_ignored(roots, path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rule::CrossFileScope;
fn build_index(content: &str, flavor: MarkdownFlavor) -> FileIndex {
let rules = cross_file_rules(&Config::default());
IndexWorker::build_file_index(content, &rules, flavor, None)
}
#[test]
fn cross_file_rules_match_the_workspace_scope() {
let config = Config::default();
let names = |rules: &[Box<dyn Rule>]| rules.iter().map(|rule| rule.name().to_string()).collect::<Vec<_>>();
let declared = crate::rules::all_rules(&config)
.into_iter()
.filter(|rule| rule.cross_file_scope() == CrossFileScope::Workspace)
.collect::<Vec<_>>();
assert!(
!declared.is_empty(),
"control: the scope must be reachable, or this test says nothing"
);
assert_eq!(names(&declared), names(&cross_file_rules(&config)));
}
#[test]
fn test_build_file_index() {
let content = r#"
# Main Heading
Some text.
## Sub Heading {#sub}
More text with [link](./other.md#section).
"#;
let index = build_index(content, MarkdownFlavor::default());
assert_eq!(index.headings.len(), 2);
assert_eq!(index.headings[0].text, "Main Heading");
assert!(index.headings[0].custom_anchor.is_none());
assert_eq!(index.headings[1].text, "Sub Heading");
assert_eq!(index.headings[1].custom_anchor, Some("sub".to_string()));
assert_eq!(index.cross_file_links.len(), 1);
assert_eq!(index.cross_file_links[0].target_path, "./other.md");
assert_eq!(index.cross_file_links[0].fragment, "section");
}
#[test]
fn test_build_file_index_respects_flavor() {
let content = "# Real\n\n# -8<- [start:section]\n";
let standard = build_index(content, MarkdownFlavor::Standard);
assert_eq!(
standard.headings.len(),
2,
"Standard treats the snippet line as a heading"
);
let mkdocs = build_index(content, MarkdownFlavor::MkDocs);
assert_eq!(mkdocs.headings.len(), 1, "MkDocs excludes the snippet marker");
assert_eq!(mkdocs.headings[0].text, "Real");
}
#[test]
fn test_build_file_index_column_positions() {
let content = "See [link](./file.md) here.\n";
let index = build_index(content, MarkdownFlavor::default());
assert_eq!(index.cross_file_links.len(), 1);
assert_eq!(index.cross_file_links[0].target_path, "./file.md");
assert_eq!(index.cross_file_links[0].line, 1);
assert_eq!(index.cross_file_links[0].column, 12);
}
#[test]
fn test_build_file_index_multiple_links() {
let content = "First [a](./a.md) and [b](./b.md#section) links.\n";
let index = build_index(content, MarkdownFlavor::default());
assert_eq!(index.cross_file_links.len(), 2);
let find = |target: &str| {
index
.cross_file_links
.iter()
.find(|link| link.target_path == target)
.unwrap_or_else(|| panic!("no indexed link to {target}: {:?}", index.cross_file_links))
};
assert_eq!(find("./a.md").column, 11);
let fragment_link = find("./b.md");
assert_eq!(fragment_link.fragment, "section");
assert_eq!(fragment_link.column, 23);
}
#[test]
fn test_collect_markdown_files_respects_gitignore() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("README.md"), "# Readme\n").unwrap();
fs::write(root.join(".gitignore"), "build/\nignored.md\n").unwrap();
fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
fs::create_dir(root.join("build")).unwrap();
fs::write(root.join("build").join("generated.md"), "# Generated\n").unwrap();
fs::create_dir(root.join("node_modules")).unwrap();
fs::write(root.join("node_modules").join("dep.md"), "# Dep\n").unwrap();
let mut files = collect_markdown_files(
&[root.to_path_buf()],
&index_walk_options(&Config::default()),
&[],
&ExcludeMatchers::new(&[]),
);
files.sort();
let names: Vec<String> = files
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["README.md".to_string()]);
}
#[test]
fn test_collect_markdown_files_applies_config_excludes() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("README.md"), "# Readme\n").unwrap();
fs::create_dir(root.join("drafts")).unwrap();
fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
let excludes = ExcludeMatchers::new(&["drafts".to_string()]);
let names: Vec<String> = collect_markdown_files(
&[root.to_path_buf()],
&index_walk_options(&Config::default()),
&[],
&excludes,
)
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["README.md".to_string()]);
}
#[test]
fn test_collect_markdown_files_honors_absolute_exclude_patterns() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = crate::discovery::canonicalize_for_matching(dir.path()).unwrap();
fs::write(root.join("README.md"), "# Readme\n").unwrap();
fs::create_dir(root.join("drafts")).unwrap();
fs::write(root.join("drafts").join("wip.md"), "# WIP\n").unwrap();
let pattern = format!("{}/drafts", root.to_string_lossy().replace('\\', "/"));
let names: Vec<String> = collect_markdown_files(
std::slice::from_ref(&root),
&index_walk_options(&Config::default()),
&[],
&ExcludeMatchers::new(&[pattern]),
)
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["README.md".to_string()]);
}
#[test]
fn test_collect_markdown_files_can_disable_gitignore() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join(".gitignore"), "ignored.md\n").unwrap();
fs::write(root.join("ignored.md"), "# Ignored\n").unwrap();
let mut config = Config::default();
config.global.respect_gitignore = false;
let names: Vec<String> = collect_markdown_files(
&[root.to_path_buf()],
&index_walk_options(&config),
&[],
&ExcludeMatchers::new(&[]),
)
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["ignored.md".to_string()]);
}
#[test]
fn test_collect_markdown_files_includes_hidden_files() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir(root.join(".github")).unwrap();
fs::write(root.join(".github").join("PULL_REQUEST_TEMPLATE.md"), "# PR\n").unwrap();
fs::write(root.join("README.md"), "# Readme\n").unwrap();
let mut names: Vec<String> = collect_markdown_files(
&[root.to_path_buf()],
&index_walk_options(&Config::default()),
&[],
&ExcludeMatchers::new(&[]),
)
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
names.sort();
assert_eq!(
names,
vec!["PULL_REQUEST_TEMPLATE.md".to_string(), "README.md".to_string()]
);
}
#[test]
fn test_collect_markdown_files_finds_nested_markdown() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("top.md"), "# Top\n").unwrap();
fs::create_dir(root.join("docs")).unwrap();
fs::write(root.join("docs").join("guide.markdown"), "# Guide\n").unwrap();
fs::write(root.join("docs").join("notes.txt"), "not markdown\n").unwrap();
let mut names: Vec<String> = collect_markdown_files(
&[root.to_path_buf()],
&index_walk_options(&Config::default()),
&[],
&ExcludeMatchers::new(&[]),
)
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
names.sort();
assert_eq!(names, vec!["guide.markdown".to_string(), "top.md".to_string()]);
}
#[test]
fn test_workspace_index_applies_includes_to_scan_and_watch_events() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
fs::create_dir(root.join("docs")).unwrap();
fs::create_dir(root.join("templates")).unwrap();
fs::write(root.join("README.md"), "# Readme\n").unwrap();
fs::write(root.join("docs/guide.md"), "# Guide\n").unwrap();
fs::write(root.join("templates/page.md.jinja"), "# Template\n").unwrap();
let roots = vec![root.clone()];
let options = index_walk_options(&Config::default());
let includes = vec!["docs/**".to_string(), "templates/**/*.md.jinja".to_string()];
let excludes = ExcludeMatchers::new(&[]);
let names: Vec<String> = collect_markdown_files(&roots, &options, &includes, &excludes)
.iter()
.map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"))
.collect();
assert_eq!(names, vec!["docs/guide.md", "templates/page.md.jinja"]);
assert!(path_is_ignored_for_index(
&roots,
&root.join("README.md"),
&options,
&includes,
&excludes
));
assert!(!path_is_ignored_for_index(
&roots,
&root.join("templates/page.md.jinja"),
&options,
&includes,
&excludes
));
}
#[test]
fn test_path_is_ignored_for_index() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
fs::write(root.join(".gitignore"), "build/\ndraft.md\n").unwrap();
fs::write(root.join("README.md"), "").unwrap();
fs::write(root.join("draft.md"), "").unwrap();
fs::write(root.join(".hidden.md"), "").unwrap();
fs::create_dir(root.join("docs")).unwrap();
fs::write(root.join("docs").join("guide.md"), "").unwrap();
fs::create_dir(root.join("build")).unwrap();
fs::write(root.join("build").join("out.md"), "").unwrap();
let roots = vec![root.clone()];
let options = index_walk_options(&Config::default());
let no_excludes = ExcludeMatchers::new(&[]);
assert!(!path_is_ignored_for_index(
&roots,
&root.join("README.md"),
&options,
&[],
&no_excludes
));
assert!(!path_is_ignored_for_index(
&roots,
&root.join("docs/guide.md"),
&options,
&[],
&no_excludes
));
assert!(path_is_ignored_for_index(
&roots,
&root.join("draft.md"),
&options,
&[],
&no_excludes
));
assert!(path_is_ignored_for_index(
&roots,
&root.join("build/out.md"),
&options,
&[],
&no_excludes
));
assert!(!path_is_ignored_for_index(
&roots,
&root.join(".hidden.md"),
&options,
&[],
&no_excludes
));
assert!(path_is_ignored_for_index(
&roots,
&root.join("node_modules/dep.md"),
&options,
&[],
&no_excludes
));
assert!(path_is_ignored_for_index(
&roots,
&root.join("target/doc.md"),
&options,
&[],
&no_excludes
));
let excludes = ExcludeMatchers::new(&["docs".to_string()]);
assert!(path_is_ignored_for_index(
&roots,
&root.join("docs/guide.md"),
&options,
&[],
&excludes
));
assert!(!path_is_ignored_for_index(
&roots,
&root.join("README.md"),
&options,
&[],
&excludes
));
let outside = dir.path().parent().unwrap().join("elsewhere.md");
assert!(!path_is_ignored_for_index(
&roots,
&outside,
&options,
&[],
&no_excludes
));
}
#[test]
fn test_path_is_ignored_for_index_honors_nested_gitignore() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
fs::create_dir(root.join("docs")).unwrap();
fs::write(root.join("docs").join(".gitignore"), "generated.md\n").unwrap();
fs::write(root.join("docs").join("generated.md"), "").unwrap();
fs::write(root.join("docs").join("manual.md"), "").unwrap();
let roots = vec![root.clone()];
let options = index_walk_options(&Config::default());
let no_excludes = ExcludeMatchers::new(&[]);
assert!(path_is_ignored_for_index(
&roots,
&root.join("docs/generated.md"),
&options,
&[],
&no_excludes
));
assert!(!path_is_ignored_for_index(
&roots,
&root.join("docs/manual.md"),
&options,
&[],
&no_excludes
));
}
#[test]
fn test_path_is_ignored_for_index_workspace_under_target_dir() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("target").join("my-docs");
fs::create_dir_all(&root).unwrap();
fs::write(root.join("README.md"), "").unwrap();
fs::create_dir(root.join("target")).unwrap();
fs::write(root.join("target").join("out.md"), "").unwrap();
let roots = vec![root.clone()];
let options = index_walk_options(&Config::default());
let no_excludes = ExcludeMatchers::new(&[]);
assert!(!path_is_ignored_for_index(
&roots,
&root.join("README.md"),
&options,
&[],
&no_excludes
));
assert!(path_is_ignored_for_index(
&roots,
&root.join("target/out.md"),
&options,
&[],
&no_excludes
));
}
#[test]
fn test_extracted_data_differs_ignores_a_prose_only_edit() {
let before = build_index(
"# Guide\n\nProse.\n\nSee [other](./other.md#section).\n",
MarkdownFlavor::default(),
);
let after = build_index(
"# Guide\n\nProse, now with a clause typed into it.\n\nSee [other](./other.md#section).\n",
MarkdownFlavor::default(),
);
assert_ne!(before.content_hash, after.content_hash);
assert!(!before.extracted_data_differs(&after));
}
#[test]
fn test_extracted_data_differs_reports_a_renamed_heading() {
let before = build_index("# Setup\n", MarkdownFlavor::default());
let after = build_index("# Installation\n", MarkdownFlavor::default());
assert!(before.extracted_data_differs(&after));
}
#[test]
fn test_extracted_data_differs_reports_a_new_link() {
let before = build_index("# Guide\n\nProse.\n", MarkdownFlavor::default());
let after = build_index("# Guide\n\nSee [other](./other.md#nope).\n", MarkdownFlavor::default());
assert!(before.extracted_data_differs(&after));
}
}