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, is_markdown_extension, path_relative_to};
use crate::lsp::server::DocumentEntry;
use crate::lsp::types::{IndexState, IndexUpdate, RelintRequest};
use crate::rule::{CrossFileScope, 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>> {
crate::rules::all_rules(config)
.into_iter()
.filter(|rule| rule.cross_file_scope() == CrossFileScope::Workspace)
.collect()
}
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>,
rumdl_config: Arc<RwLock<Config>>,
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) rumdl_config: Arc<RwLock<Config>>,
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,
rumdl_config,
documents,
} = shared;
Self {
rx,
workspace_index,
index_state,
client,
workspace_roots,
pending: HashMap::new(),
debounce_duration: Duration::from_millis(100),
relint_tx,
rumdl_config,
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 rules = {
let config = self.rumdl_config.read().await;
cross_file_rules(&config)
};
for path in ready {
if let Some(pending) = self.pending.remove(&path) {
self.update_single_file(&path, &pending.content, &rules).await;
}
}
}
async fn update_single_file(&self, path: &Path, content: &str, rules: &[Box<dyn Rule>]) {
let flavor = self.rumdl_config.read().await.get_flavor_for_file(path);
let Ok(file_index) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Self::build_file_index(content, rules, flavor, Some(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 (options, excludes, rules) = {
let config = self.rumdl_config.read().await;
(
index_walk_options(&config),
ExcludeMatchers::new(&config.global.exclude),
cross_file_rules(&config),
)
};
for (pattern, error) in &excludes.invalid {
log::warn!("Invalid exclude pattern '{pattern}': {error}");
}
let mut files = scan_markdown_files(&roots, options, 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;
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 flavor = self.rumdl_config.read().await.get_flavor_for_file(path);
let file_index = Self::build_file_index(&content, &rules, flavor, Some(path));
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,
excludes: ExcludeMatchers,
) -> Vec<PathBuf> {
let roots = roots.to_vec();
tokio::task::spawn_blocking(move || collect_markdown_files(&roots, &options, &excludes))
.await
.unwrap_or_else(|e| {
log::warn!("Workspace scan task failed: {e}");
Vec::new()
})
}
fn collect_markdown_files(
roots: &[PathBuf],
options: &MarkdownWalkOptions,
excludes: &ExcludeMatchers,
) -> Vec<PathBuf> {
let mut files = Vec::new();
for root in roots {
for result in crate::discovery::markdown_walk_builder(root, options).build() {
match result {
Ok(entry) => {
let path = entry.path();
if entry.file_type().is_some_and(|t| t.is_file())
&& let Some(ext) = path.extension()
&& is_markdown_extension(ext)
&& !excluded_relative_to_root(excludes, path, root)
{
files.push(path.to_path_buf());
}
}
Err(e) => log::warn!("Error scanning {}: {}", root.display(), e),
}
}
}
files
}
fn excluded_relative_to_root(excludes: &ExcludeMatchers, path: &Path, root: &Path) -> bool {
if excludes.is_empty() {
return false;
}
excludes.excludes_file(path_relative_to(path, root).as_deref(), path)
}
pub(super) fn path_is_ignored_for_index(
roots: &[PathBuf],
path: &Path,
options: &MarkdownWalkOptions,
excludes: &ExcludeMatchers,
) -> bool {
let Some(root) = roots
.iter()
.filter(|r| path.starts_with(r))
.max_by_key(|r| r.components().count())
else {
return false;
};
if options.skip_vendor_dirs
&& let Ok(rel) = path.strip_prefix(root)
&& rel.components().any(
|c| matches!(c, std::path::Component::Normal(name) if name == ".git" || name == "node_modules" || name == "target"),
)
{
return true;
}
if excluded_relative_to_root(excludes, path, root) {
return true;
}
let target = path.to_path_buf();
let mut builder = crate::discovery::markdown_walk_builder(root, options);
builder.filter_entry(move |entry| target.starts_with(entry.path()));
for entry in builder.build().flatten() {
if entry.path() == path {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
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 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_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));
}
}