|
1 | 1 | //! Completion: suggest xref targets, attributes, and include paths |
2 | 2 |
|
| 3 | +use std::path::Path; |
| 4 | + |
3 | 5 | use tower_lsp::lsp_types::{ |
4 | | - CompletionItem, CompletionItemKind, CompletionItemLabelDetails, Position, Url, |
| 6 | + Command, CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionTextEdit, |
| 7 | + Position, Range, TextEdit, Url, |
5 | 8 | }; |
6 | 9 |
|
7 | 10 | use crate::state::{DocumentState, Workspace}; |
@@ -77,9 +80,8 @@ pub fn compute_completions( |
77 | 80 | CompletionContext::AttributeDefinition { prefix } => { |
78 | 81 | Some(complete_attribute_definitions(&prefix)) |
79 | 82 | } |
80 | | - CompletionContext::IncludePath { prefix: _ } => { |
81 | | - // Include path completion requires filesystem access - skip for MVP |
82 | | - Some(vec![]) |
| 83 | + CompletionContext::IncludePath { prefix } => { |
| 84 | + Some(complete_include_paths(doc_uri, &prefix, position)) |
83 | 85 | } |
84 | 86 | CompletionContext::None => None, |
85 | 87 | } |
@@ -258,6 +260,124 @@ fn complete_attribute_definitions(prefix: &str) -> Vec<CompletionItem> { |
258 | 260 | .collect() |
259 | 261 | } |
260 | 262 |
|
| 263 | +// TODO(nlopes): add support for skipping anything in .gitignore files, which would be |
| 264 | +// more robust than a hardcoded list of skip dirs |
| 265 | +/// Directories to skip during include path completion |
| 266 | +const SKIP_DIRS: &[&str] = &[".git", ".svn", ".hg", "target", "node_modules", ".build"]; |
| 267 | + |
| 268 | +/// `AsciiDoc` file extensions (prioritized in sort order) |
| 269 | +const ADOC_EXTENSIONS: &[&str] = &["adoc", "asciidoc", "ad", "asc"]; |
| 270 | + |
| 271 | +/// Complete include paths by listing files and directories on the filesystem |
| 272 | +fn complete_include_paths(doc_uri: &Url, prefix: &str, position: Position) -> Vec<CompletionItem> { |
| 273 | + let Ok(doc_path) = doc_uri.to_file_path() else { |
| 274 | + return vec![]; |
| 275 | + }; |
| 276 | + let Some(doc_dir) = doc_path.parent() else { |
| 277 | + return vec![]; |
| 278 | + }; |
| 279 | + |
| 280 | + // Split prefix into directory part and name filter |
| 281 | + // e.g. "chapters/ch" → dir_part="chapters/", filter="ch" |
| 282 | + // e.g. "ch" → dir_part="", filter="ch" |
| 283 | + // e.g. "chapters/" → dir_part="chapters/", filter="" |
| 284 | + let (dir_part, filter) = match prefix.rfind('/') { |
| 285 | + Some(pos) => (&prefix[..=pos], &prefix[pos + 1..]), |
| 286 | + None => ("", prefix), |
| 287 | + }; |
| 288 | + |
| 289 | + let search_dir = if dir_part.is_empty() { |
| 290 | + doc_dir.to_path_buf() |
| 291 | + } else { |
| 292 | + doc_dir.join(dir_part) |
| 293 | + }; |
| 294 | + |
| 295 | + let Ok(entries) = std::fs::read_dir(&search_dir) else { |
| 296 | + return vec![]; |
| 297 | + }; |
| 298 | + |
| 299 | + // The text edit range covers the entire prefix (everything after `include::`) |
| 300 | + let prefix_len = u32::try_from(prefix.len()).unwrap_or(0); |
| 301 | + let edit_range = Range { |
| 302 | + start: Position { |
| 303 | + line: position.line, |
| 304 | + character: position.character - prefix_len, |
| 305 | + }, |
| 306 | + end: position, |
| 307 | + }; |
| 308 | + |
| 309 | + let filter_lower = filter.to_lowercase(); |
| 310 | + let mut items = Vec::new(); |
| 311 | + |
| 312 | + for entry in entries.flatten() { |
| 313 | + let name = entry.file_name(); |
| 314 | + let name_str = name.to_string_lossy(); |
| 315 | + |
| 316 | + // Skip hidden entries |
| 317 | + if name_str.starts_with('.') { |
| 318 | + continue; |
| 319 | + } |
| 320 | + |
| 321 | + let is_dir = entry.file_type().is_ok_and(|ft| ft.is_dir()); |
| 322 | + |
| 323 | + // Skip known non-useful directories |
| 324 | + if is_dir && SKIP_DIRS.contains(&name_str.as_ref()) { |
| 325 | + continue; |
| 326 | + } |
| 327 | + |
| 328 | + // Apply filter |
| 329 | + if !name_str.to_lowercase().starts_with(&filter_lower) { |
| 330 | + continue; |
| 331 | + } |
| 332 | + |
| 333 | + if is_dir { |
| 334 | + let new_text = format!("{dir_part}{name_str}/"); |
| 335 | + items.push(CompletionItem { |
| 336 | + label: format!("{name_str}/"), |
| 337 | + kind: Some(CompletionItemKind::FOLDER), |
| 338 | + text_edit: Some(CompletionTextEdit::Edit(TextEdit { |
| 339 | + range: edit_range, |
| 340 | + new_text, |
| 341 | + })), |
| 342 | + sort_text: Some(format!("0{name_str}")), |
| 343 | + command: Some(Command { |
| 344 | + title: "Trigger Suggest".to_string(), |
| 345 | + command: "editor.action.triggerSuggest".to_string(), |
| 346 | + arguments: None, |
| 347 | + }), |
| 348 | + ..Default::default() |
| 349 | + }); |
| 350 | + } else { |
| 351 | + let is_adoc = Path::new(&*name_str) |
| 352 | + .extension() |
| 353 | + .and_then(|e| e.to_str()) |
| 354 | + .is_some_and(|ext| ADOC_EXTENSIONS.contains(&ext)); |
| 355 | + let new_text = format!("{dir_part}{name_str}[]"); |
| 356 | + items.push(CompletionItem { |
| 357 | + label: name_str.to_string(), |
| 358 | + kind: Some(CompletionItemKind::FILE), |
| 359 | + text_edit: Some(CompletionTextEdit::Edit(TextEdit { |
| 360 | + range: edit_range, |
| 361 | + new_text, |
| 362 | + })), |
| 363 | + sort_text: Some(format!("{}{name_str}", if is_adoc { "1" } else { "2" })), |
| 364 | + label_details: if is_adoc { |
| 365 | + Some(CompletionItemLabelDetails { |
| 366 | + detail: Some(" AsciiDoc".to_string()), |
| 367 | + description: None, |
| 368 | + }) |
| 369 | + } else { |
| 370 | + None |
| 371 | + }, |
| 372 | + ..Default::default() |
| 373 | + }); |
| 374 | + } |
| 375 | + } |
| 376 | + |
| 377 | + items.sort_by(|a, b| a.sort_text.cmp(&b.sort_text)); |
| 378 | + items |
| 379 | +} |
| 380 | + |
261 | 381 | #[cfg(test)] |
262 | 382 | mod tests { |
263 | 383 | use super::*; |
@@ -393,4 +513,163 @@ mod tests { |
393 | 513 | assert!(items.len() >= 2); |
394 | 514 | Ok(()) |
395 | 515 | } |
| 516 | + |
| 517 | + fn setup_include_test_dir( |
| 518 | + suffix: &str, |
| 519 | + ) -> Result<(std::path::PathBuf, Url), Box<dyn std::error::Error>> { |
| 520 | + let tmp = std::env::temp_dir().join(format!("acdc_lsp_test_include_completion_{suffix}")); |
| 521 | + let _ = std::fs::remove_dir_all(&tmp); |
| 522 | + std::fs::create_dir_all(tmp.join("chapters"))?; |
| 523 | + std::fs::create_dir_all(tmp.join(".git"))?; |
| 524 | + std::fs::create_dir_all(tmp.join("target"))?; |
| 525 | + |
| 526 | + std::fs::write(tmp.join("intro.adoc"), "= Intro\n")?; |
| 527 | + std::fs::write(tmp.join("appendix.asciidoc"), "= Appendix\n")?; |
| 528 | + std::fs::write(tmp.join("data.csv"), "a,b,c\n")?; |
| 529 | + std::fs::write(tmp.join("chapters/chapter-01.adoc"), "= Ch 1\n")?; |
| 530 | + std::fs::write(tmp.join("chapters/chapter-02.adoc"), "= Ch 2\n")?; |
| 531 | + |
| 532 | + let doc_uri = Url::from_file_path(tmp.join("main.adoc")).map_err(|()| "bad path")?; |
| 533 | + Ok((tmp, doc_uri)) |
| 534 | + } |
| 535 | + |
| 536 | + /// Helper: position simulating cursor right after `include::{prefix}` |
| 537 | + fn pos_for_prefix(prefix: &str) -> Position { |
| 538 | + let col = u32::try_from("include::".len() + prefix.len()).unwrap_or(0); |
| 539 | + Position { |
| 540 | + line: 0, |
| 541 | + character: col, |
| 542 | + } |
| 543 | + } |
| 544 | + |
| 545 | + /// Helper: extract `new_text` from a `CompletionItem`'s `text_edit` |
| 546 | + fn edit_text(item: &CompletionItem) -> Option<&str> { |
| 547 | + match &item.text_edit { |
| 548 | + Some(CompletionTextEdit::Edit(te)) => Some(&te.new_text), |
| 549 | + _ => None, |
| 550 | + } |
| 551 | + } |
| 552 | + |
| 553 | + #[test] |
| 554 | + fn test_complete_include_paths_lists_files_and_dirs() -> Result<(), Box<dyn std::error::Error>> |
| 555 | + { |
| 556 | + let (tmp, doc_uri) = setup_include_test_dir("list")?; |
| 557 | + |
| 558 | + let items = complete_include_paths(&doc_uri, "", pos_for_prefix("")); |
| 559 | + let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect(); |
| 560 | + |
| 561 | + assert!(labels.contains(&"intro.adoc"), "missing intro.adoc"); |
| 562 | + assert!( |
| 563 | + labels.contains(&"appendix.asciidoc"), |
| 564 | + "missing appendix.asciidoc" |
| 565 | + ); |
| 566 | + assert!(labels.contains(&"data.csv"), "missing data.csv"); |
| 567 | + assert!(labels.contains(&"chapters/"), "missing chapters/"); |
| 568 | + |
| 569 | + // Hidden dirs and skip dirs should be excluded |
| 570 | + assert!(!labels.contains(&".git/"), ".git should be hidden"); |
| 571 | + assert!(!labels.contains(&"target/"), "target should be skipped"); |
| 572 | + |
| 573 | + let _ = std::fs::remove_dir_all(&tmp); |
| 574 | + Ok(()) |
| 575 | + } |
| 576 | + |
| 577 | + #[test] |
| 578 | + fn test_complete_include_paths_subdirectory() -> Result<(), Box<dyn std::error::Error>> { |
| 579 | + let (tmp, doc_uri) = setup_include_test_dir("subdir")?; |
| 580 | + |
| 581 | + let prefix = "chapters/"; |
| 582 | + let items = complete_include_paths(&doc_uri, prefix, pos_for_prefix(prefix)); |
| 583 | + let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect(); |
| 584 | + |
| 585 | + assert!(labels.contains(&"chapter-01.adoc")); |
| 586 | + assert!(labels.contains(&"chapter-02.adoc")); |
| 587 | + assert_eq!(items.len(), 2); |
| 588 | + |
| 589 | + // Verify text_edit replaces the full prefix with the complete path |
| 590 | + let ch1 = items |
| 591 | + .iter() |
| 592 | + .find(|i| i.label == "chapter-01.adoc") |
| 593 | + .ok_or("chapter-01.adoc not found")?; |
| 594 | + assert_eq!(edit_text(ch1), Some("chapters/chapter-01.adoc[]")); |
| 595 | + |
| 596 | + let _ = std::fs::remove_dir_all(&tmp); |
| 597 | + Ok(()) |
| 598 | + } |
| 599 | + |
| 600 | + #[test] |
| 601 | + fn test_complete_include_paths_filter() -> Result<(), Box<dyn std::error::Error>> { |
| 602 | + let (tmp, doc_uri) = setup_include_test_dir("filter")?; |
| 603 | + |
| 604 | + let prefix = "int"; |
| 605 | + let items = complete_include_paths(&doc_uri, prefix, pos_for_prefix(prefix)); |
| 606 | + assert_eq!(items.len(), 1); |
| 607 | + let first = items.first().ok_or("expected at least one item")?; |
| 608 | + assert_eq!(first.label, "intro.adoc"); |
| 609 | + assert_eq!(edit_text(first), Some("intro.adoc[]")); |
| 610 | + |
| 611 | + let _ = std::fs::remove_dir_all(&tmp); |
| 612 | + Ok(()) |
| 613 | + } |
| 614 | + |
| 615 | + #[test] |
| 616 | + fn test_complete_include_paths_nonexistent_dir() -> Result<(), Box<dyn std::error::Error>> { |
| 617 | + let doc_uri = Url::parse("file:///nonexistent/dir/doc.adoc")?; |
| 618 | + let items = complete_include_paths(&doc_uri, "", pos_for_prefix("")); |
| 619 | + assert!(items.is_empty()); |
| 620 | + Ok(()) |
| 621 | + } |
| 622 | + |
| 623 | + #[test] |
| 624 | + fn test_complete_include_paths_adoc_sorted_first() -> Result<(), Box<dyn std::error::Error>> { |
| 625 | + let (tmp, doc_uri) = setup_include_test_dir("sort")?; |
| 626 | + |
| 627 | + let items = complete_include_paths(&doc_uri, "", pos_for_prefix("")); |
| 628 | + // Directories (sort "0...") come first, then adoc files ("1..."), then others ("2...") |
| 629 | + let first_file = items |
| 630 | + .iter() |
| 631 | + .find(|i| i.kind == Some(CompletionItemKind::FILE)) |
| 632 | + .ok_or("expected at least one file")?; |
| 633 | + assert!( |
| 634 | + first_file |
| 635 | + .sort_text |
| 636 | + .as_ref() |
| 637 | + .is_some_and(|s| s.starts_with('1')), |
| 638 | + "first file should be an adoc file (sort prefix '1')" |
| 639 | + ); |
| 640 | + |
| 641 | + // data.csv should have sort prefix '2' |
| 642 | + let csv = items |
| 643 | + .iter() |
| 644 | + .find(|i| i.label == "data.csv") |
| 645 | + .ok_or("data.csv not found")?; |
| 646 | + assert!(csv.sort_text.as_ref().is_some_and(|s| s.starts_with('2'))); |
| 647 | + |
| 648 | + let _ = std::fs::remove_dir_all(&tmp); |
| 649 | + Ok(()) |
| 650 | + } |
| 651 | + |
| 652 | + #[test] |
| 653 | + fn test_complete_include_paths_dir_retriggers() -> Result<(), Box<dyn std::error::Error>> { |
| 654 | + let (tmp, doc_uri) = setup_include_test_dir("retrigger")?; |
| 655 | + |
| 656 | + let items = complete_include_paths(&doc_uri, "", pos_for_prefix("")); |
| 657 | + let dir_item = items |
| 658 | + .iter() |
| 659 | + .find(|i| i.label == "chapters/") |
| 660 | + .ok_or("chapters/ not found")?; |
| 661 | + let command = dir_item |
| 662 | + .command |
| 663 | + .as_ref() |
| 664 | + .ok_or("directory items should have a retrigger command")?; |
| 665 | + assert_eq!(command.command, "editor.action.triggerSuggest"); |
| 666 | + assert_eq!( |
| 667 | + edit_text(dir_item), |
| 668 | + Some("chapters/"), |
| 669 | + "directory edit text should include trailing slash" |
| 670 | + ); |
| 671 | + |
| 672 | + let _ = std::fs::remove_dir_all(&tmp); |
| 673 | + Ok(()) |
| 674 | + } |
396 | 675 | } |
0 commit comments