Skip to content

Commit 3bc9a4f

Browse files
nlopesclaude
andcommitted
feat(lsp): add include path completion
Filesystem traversal completion for `include::` directives. Suggests files and directories as the user types the path, with AsciiDoc files prioritized. Selecting a directory re-triggers completion for continued path navigation. Uses text_edit with explicit ranges to avoid duplicate path insertion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 90d21d3 commit 3bc9a4f

File tree

2 files changed

+287
-4
lines changed

2 files changed

+287
-4
lines changed

acdc-lsp/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Include path completion** — filesystem traversal completion for `include::`
13+
directives. Suggests files and directories as the user types the path, with
14+
AsciiDoc files prioritized. Selecting a directory re-triggers completion for
15+
continued path navigation.
1216
- **Automatic link updates on file rename** — when an AsciiDoc file is renamed
1317
or moved in the editor, all cross-file references (xrefs, includes) across the
1418
workspace are automatically updated (`workspace/willRenameFiles`). Also scans

acdc-lsp/src/capabilities/completion.rs

Lines changed: 283 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
//! Completion: suggest xref targets, attributes, and include paths
22
3+
use std::path::Path;
4+
35
use tower_lsp::lsp_types::{
4-
CompletionItem, CompletionItemKind, CompletionItemLabelDetails, Position, Url,
6+
Command, CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionTextEdit,
7+
Position, Range, TextEdit, Url,
58
};
69

710
use crate::state::{DocumentState, Workspace};
@@ -77,9 +80,8 @@ pub fn compute_completions(
7780
CompletionContext::AttributeDefinition { prefix } => {
7881
Some(complete_attribute_definitions(&prefix))
7982
}
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))
8385
}
8486
CompletionContext::None => None,
8587
}
@@ -258,6 +260,124 @@ fn complete_attribute_definitions(prefix: &str) -> Vec<CompletionItem> {
258260
.collect()
259261
}
260262

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+
261381
#[cfg(test)]
262382
mod tests {
263383
use super::*;
@@ -393,4 +513,163 @@ mod tests {
393513
assert!(items.len() >= 2);
394514
Ok(())
395515
}
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+
}
396675
}

0 commit comments

Comments
 (0)