Skip to main content

perl_lsp_file_completion/
lib.rs

1#![warn(missing_docs)]
2//! Secure string-literal file path completion.
3//!
4//! This microcrate isolates bounded filesystem traversal and path sanitization
5//! for completion providers that want to offer file suggestions without owning
6//! the security policy themselves.
7
8use perl_lsp_completion_item::{CompletionItem, CompletionItemKind};
9
10/// Minimal request context for file-path completion.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FileCompletionContext {
13    /// The raw path prefix already typed by the user.
14    pub prefix: String,
15    /// Byte offset where the prefix starts.
16    pub prefix_start: usize,
17    /// Current byte offset of the cursor.
18    pub position: usize,
19}
20
21impl FileCompletionContext {
22    /// Create a new file completion context.
23    #[must_use]
24    pub fn new(prefix: impl Into<String>, prefix_start: usize, position: usize) -> Self {
25        Self { prefix: prefix.into(), prefix_start, position }
26    }
27}
28
29/// Produce secure file-path completion items.
30#[must_use]
31#[cfg(not(target_arch = "wasm32"))]
32pub fn complete_file_paths(
33    context: &FileCompletionContext,
34    is_cancelled: &dyn Fn() -> bool,
35) -> Vec<CompletionItem> {
36    use perl_path_security::{
37        build_completion_path, is_hidden_or_forbidden_entry_name, is_safe_completion_filename,
38        resolve_completion_base_directory, sanitize_completion_path_input,
39        split_completion_path_components,
40    };
41    use walkdir::WalkDir;
42
43    if is_cancelled() {
44        return Vec::new();
45    }
46
47    let prefix = context.prefix.trim();
48    if prefix.len() > 1024 {
49        return Vec::new();
50    }
51
52    let Some(safe_prefix) = sanitize_completion_path_input(prefix) else {
53        return Vec::new();
54    };
55
56    let (dir_part, file_part) = split_completion_path_components(&safe_prefix);
57    let Some(base_dir) = resolve_completion_base_directory(&dir_part) else {
58        return Vec::new();
59    };
60
61    let mut completions = Vec::new();
62    let mut entries_examined = 0usize;
63
64    for entry in
65        WalkDir::new(&base_dir).max_depth(1).follow_links(false).into_iter().filter_entry(|entry| {
66            !is_hidden_or_forbidden_entry_name(entry.file_name().to_string_lossy().as_ref())
67        })
68    {
69        if is_cancelled() {
70            break;
71        }
72
73        entries_examined += 1;
74        if entries_examined > 200 {
75            break;
76        }
77
78        let Ok(entry) = entry else {
79            continue;
80        };
81
82        if entry.path() == base_dir {
83            continue;
84        }
85
86        let Some(file_name) = entry.file_name().to_str() else {
87            continue;
88        };
89
90        if !file_name.starts_with(&file_part) || !is_safe_completion_filename(file_name) {
91            continue;
92        }
93
94        let completion_path =
95            build_completion_path(&dir_part, file_name, entry.file_type().is_dir());
96        let (detail, documentation) = file_completion_metadata(&entry);
97        completions.push(CompletionItem {
98            label: completion_path.clone(),
99            kind: CompletionItemKind::File,
100            detail: Some(detail),
101            documentation,
102            insert_text: Some(completion_path.clone()),
103            sort_text: Some(format!("1_{completion_path}")),
104            filter_text: Some(completion_path.clone()),
105            additional_edits: Vec::new(),
106            text_edit_range: Some((context.prefix_start, context.position)),
107            commit_characters: None,
108        });
109
110        if completions.len() >= 50 {
111            break;
112        }
113    }
114
115    completions
116}
117
118/// Produce secure file-path completion items.
119#[must_use]
120#[cfg(target_arch = "wasm32")]
121pub fn complete_file_paths(
122    _context: &FileCompletionContext,
123    _is_cancelled: &dyn Fn() -> bool,
124) -> Vec<CompletionItem> {
125    Vec::new()
126}
127
128#[cfg(not(target_arch = "wasm32"))]
129fn file_completion_metadata(entry: &walkdir::DirEntry) -> (String, Option<String>) {
130    let file_type = entry.file_type();
131    if file_type.is_dir() {
132        ("directory".to_string(), Some("Directory".to_string()))
133    } else if file_type.is_file() {
134        let extension = entry.path().extension().and_then(|ext| ext.to_str()).unwrap_or("");
135        let file_type_desc = match extension.to_ascii_lowercase().as_str() {
136            "pl" | "pm" | "t" => "Perl file",
137            "rs" => "Rust source file",
138            "js" => "JavaScript file",
139            "py" => "Python file",
140            "txt" => "Text file",
141            "md" => "Markdown file",
142            "json" => "JSON file",
143            "yaml" | "yml" => "YAML file",
144            "toml" => "TOML file",
145            _ => "file",
146        };
147        (file_type_desc.to_string(), None)
148    } else {
149        ("file".to_string(), None)
150    }
151}