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::MarkdownFlavor;
17use crate::lint_context::LintContext;
18use crate::lsp::types::{IndexState, IndexUpdate};
19use crate::utils::anchor_styles::AnchorStyle;
20use crate::workspace_index::{FileIndex, HeadingIndex, WorkspaceIndex, extract_cross_file_links};
21
22const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown", "mdx", "mkd", "mkdn", "mdown", "mdwn", "qmd", "rmd"];
24
25#[inline]
27fn is_markdown_extension(ext: &std::ffi::OsStr) -> bool {
28 ext.to_str()
29 .is_some_and(|s| MARKDOWN_EXTENSIONS.contains(&s.to_lowercase().as_str()))
30}
31
32pub struct IndexWorker {
37 rx: mpsc::Receiver<IndexUpdate>,
39 workspace_index: Arc<RwLock<WorkspaceIndex>>,
41 index_state: Arc<RwLock<IndexState>>,
43 client: Client,
45 workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
47 pending: HashMap<PathBuf, (String, Instant)>,
49 debounce_duration: Duration,
51 relint_tx: mpsc::Sender<PathBuf>,
53}
54
55impl IndexWorker {
56 pub fn new(
58 rx: mpsc::Receiver<IndexUpdate>,
59 workspace_index: Arc<RwLock<WorkspaceIndex>>,
60 index_state: Arc<RwLock<IndexState>>,
61 client: Client,
62 workspace_roots: Arc<RwLock<Vec<PathBuf>>>,
63 relint_tx: mpsc::Sender<PathBuf>,
64 ) -> Self {
65 Self {
66 rx,
67 workspace_index,
68 index_state,
69 client,
70 workspace_roots,
71 pending: HashMap::new(),
72 debounce_duration: Duration::from_millis(100),
73 relint_tx,
74 }
75 }
76
77 pub async fn run(mut self) {
79 let mut debounce_interval = tokio::time::interval(Duration::from_millis(50));
80
81 loop {
82 tokio::select! {
83 msg = self.rx.recv() => {
85 match msg {
86 Some(IndexUpdate::FileChanged { path, content }) => {
87 self.pending.insert(path, (content, Instant::now()));
88 }
89 Some(IndexUpdate::FileDeleted { path }) => {
90 self.handle_file_deleted(&path).await;
91 }
92 Some(IndexUpdate::FullRescan) => {
93 self.full_rescan().await;
94 }
95 Some(IndexUpdate::Shutdown) | None => {
96 log::info!("Index worker shutting down");
97 break;
98 }
99 }
100 }
101
102 _ = debounce_interval.tick() => {
104 self.process_pending_updates().await;
105 }
106 }
107 }
108 }
109
110 async fn process_pending_updates(&mut self) {
112 let now = Instant::now();
113 let ready: Vec<_> = self
114 .pending
115 .iter()
116 .filter(|(_, (_, time))| now.duration_since(*time) >= self.debounce_duration)
117 .map(|(path, _)| path.clone())
118 .collect();
119
120 for path in ready {
121 if let Some((content, _)) = self.pending.remove(&path) {
122 self.update_single_file(&path, &content).await;
123 }
124 }
125 }
126
127 async fn update_single_file(&self, path: &Path, content: &str) {
129 let file_index =
131 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Self::build_file_index(content))) {
132 Ok(index) => index,
133 Err(_) => {
134 log::error!("Panic while indexing {}: skipping", path.display());
135 return;
136 }
137 };
138
139 let old_dependents = {
141 let index = self.workspace_index.read().await;
142 index.get_dependents(path)
143 };
144
145 {
147 let mut index = self.workspace_index.write().await;
148 index.update_file(path, file_index);
149 }
150
151 let new_dependents = {
153 let index = self.workspace_index.read().await;
154 index.get_dependents(path)
155 };
156
157 let mut affected: std::collections::HashSet<PathBuf> = old_dependents.into_iter().collect();
159 affected.extend(new_dependents);
160
161 for dep_path in affected {
162 if self.relint_tx.send(dep_path.clone()).await.is_err() {
163 log::warn!("Failed to send re-lint request for {}", dep_path.display());
164 }
165 }
166 }
167
168 fn build_file_index(content: &str) -> FileIndex {
170 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
171 let mut file_index = FileIndex::new();
172
173 for (line_num, line_info) in ctx.lines.iter().enumerate() {
175 if let Some(heading) = &line_info.heading {
176 let auto_anchor = AnchorStyle::GitHub.generate_fragment(&heading.text);
177
178 file_index.add_heading(HeadingIndex {
179 text: heading.text.clone(),
180 auto_anchor,
181 custom_anchor: heading.custom_id.clone(),
182 line: line_num + 1, });
184 }
185 }
186
187 for link in extract_cross_file_links(&ctx) {
190 file_index.add_cross_file_link(link);
191 }
192
193 file_index
194 }
195
196 async fn handle_file_deleted(&self, path: &Path) {
198 let dependents = {
203 let index = self.workspace_index.read().await;
204 index.get_dependents(path)
205 };
206
207 {
209 let mut index = self.workspace_index.write().await;
210 index.remove_file(path);
211 }
212
213 for dep_path in dependents {
215 if self.relint_tx.send(dep_path.clone()).await.is_err() {
216 log::warn!("Failed to send re-lint request for {}", dep_path.display());
217 }
218 }
219 }
220
221 async fn full_rescan(&mut self) {
223 self.pending.clear();
225
226 let roots = self.workspace_roots.read().await.clone();
228 let files = scan_markdown_files(&roots).await;
229 let total = files.len();
230
231 if total == 0 {
232 *self.index_state.write().await = IndexState::Ready;
233 return;
234 }
235
236 *self.index_state.write().await = IndexState::Building {
238 progress: 0.0,
239 files_indexed: 0,
240 total_files: total,
241 };
242
243 self.report_progress_begin(total).await;
245
246 for (i, path) in files.iter().enumerate() {
248 if let Ok(content) = tokio::fs::read_to_string(path).await {
249 let file_index = Self::build_file_index(&content);
250
251 let mut index = self.workspace_index.write().await;
252 index.update_file(path, file_index);
253 }
254
255 if i % 10 == 0 || i == total - 1 {
257 let progress = ((i + 1) as f32 / total as f32) * 100.0;
258 *self.index_state.write().await = IndexState::Building {
259 progress,
260 files_indexed: i + 1,
261 total_files: total,
262 };
263 self.report_progress_update(i + 1, total).await;
264 }
265 }
266
267 *self.index_state.write().await = IndexState::Ready;
269 self.report_progress_done().await;
270
271 log::info!("Workspace indexing complete: {total} files indexed");
272 }
273
274 async fn report_progress_begin(&self, total: usize) {
276 let token = NumberOrString::String("rumdl-index".to_string());
277
278 if self
280 .client
281 .send_request::<request::WorkDoneProgressCreate>(WorkDoneProgressCreateParams { token: token.clone() })
282 .await
283 .is_err()
284 {
285 log::debug!("Client does not support work done progress");
286 return;
287 }
288
289 self.client
291 .send_notification::<notification::Progress>(ProgressParams {
292 token,
293 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin {
294 title: "Indexing workspace".to_string(),
295 cancellable: Some(false),
296 message: Some(format!("Scanning {total} markdown files...")),
297 percentage: Some(0),
298 })),
299 })
300 .await;
301 }
302
303 async fn report_progress_update(&self, indexed: usize, total: usize) {
305 let token = NumberOrString::String("rumdl-index".to_string());
306 let percentage = ((indexed as f32 / total as f32) * 100.0) as u32;
307
308 self.client
309 .send_notification::<notification::Progress>(ProgressParams {
310 token,
311 value: ProgressParamsValue::WorkDone(WorkDoneProgress::Report(WorkDoneProgressReport {
312 cancellable: Some(false),
313 message: Some(format!("Indexed {indexed}/{total} files")),
314 percentage: Some(percentage),
315 })),
316 })
317 .await;
318 }
319
320 async fn report_progress_done(&self) {
322 let token = NumberOrString::String("rumdl-index".to_string());
323
324 self.client
325 .send_notification::<notification::Progress>(ProgressParams {
326 token,
327 value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(WorkDoneProgressEnd {
328 message: Some("Indexing complete".to_string()),
329 })),
330 })
331 .await;
332 }
333}
334
335async fn scan_markdown_files(roots: &[PathBuf]) -> Vec<PathBuf> {
337 let mut files = Vec::new();
338
339 for root in roots {
340 if let Err(e) = collect_markdown_files_recursive(root, &mut files).await {
341 log::warn!("Error scanning {}: {}", root.display(), e);
342 }
343 }
344
345 files
346}
347
348async fn collect_markdown_files_recursive(dir: &PathBuf, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
350 let mut entries = tokio::fs::read_dir(dir).await?;
351
352 while let Some(entry) = entries.next_entry().await? {
353 let path = entry.path();
354 let file_type = entry.file_type().await?;
355
356 if file_type.is_dir() {
357 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
359 if !name.starts_with('.') && name != "node_modules" && name != "target" {
360 Box::pin(collect_markdown_files_recursive(&path, files)).await?;
361 }
362 } else if file_type.is_file()
363 && let Some(ext) = path.extension()
364 && is_markdown_extension(ext)
365 {
366 files.push(path);
367 }
368 }
369
370 Ok(())
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn test_build_file_index() {
379 let content = r#"
380# Main Heading
381
382Some text.
383
384## Sub Heading {#sub}
385
386More text with [link](./other.md#section).
387"#;
388
389 let index = IndexWorker::build_file_index(content);
390
391 assert_eq!(index.headings.len(), 2);
392 assert_eq!(index.headings[0].text, "Main Heading");
393 assert!(index.headings[0].custom_anchor.is_none());
394
395 assert_eq!(index.headings[1].text, "Sub Heading");
397 assert_eq!(index.headings[1].custom_anchor, Some("sub".to_string()));
398
399 assert_eq!(index.cross_file_links.len(), 1);
400 assert_eq!(index.cross_file_links[0].target_path, "./other.md");
401 assert_eq!(index.cross_file_links[0].fragment, "section");
402 }
403
404 #[test]
405 fn test_build_file_index_column_positions() {
406 let content = "See [link](./file.md) here.\n";
408
409 let index = IndexWorker::build_file_index(content);
410
411 assert_eq!(index.cross_file_links.len(), 1);
412 assert_eq!(index.cross_file_links[0].target_path, "./file.md");
413 assert_eq!(index.cross_file_links[0].line, 1);
414 assert_eq!(index.cross_file_links[0].column, 12);
416 }
417
418 #[test]
419 fn test_build_file_index_multiple_links() {
420 let content = "First [a](./a.md) and [b](./b.md#section) links.\n";
421
422 let index = IndexWorker::build_file_index(content);
423
424 assert_eq!(index.cross_file_links.len(), 2);
425
426 assert_eq!(index.cross_file_links[0].target_path, "./a.md");
428 assert_eq!(index.cross_file_links[0].column, 11);
429
430 assert_eq!(index.cross_file_links[1].target_path, "./b.md");
432 assert_eq!(index.cross_file_links[1].fragment, "section");
433 assert_eq!(index.cross_file_links[1].column, 27);
434 }
435}