use super::string_context::{complete_path_in_string, detect_string_context};
use crate::fuzzy::fuzzy_match;
use reedline::{Completer, CompletionResult, Span, Suggestion};
use std::collections::HashMap;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
struct CompletionCache {
token: String,
prefix: String,
suffix: String,
completions: Vec<String>,
timestamp: Instant,
}
#[derive(Debug, PartialEq, Eq)]
struct CompletionContext {
token: String,
prefix: String,
suffix: String,
}
fn completion_context(line: &str, cursor_pos: usize, token: &str) -> Option<CompletionContext> {
let cursor_pos = cursor_pos.min(line.len());
if !line.is_char_boundary(cursor_pos) {
return None;
}
let token_start = cursor_pos.checked_sub(token.len())?;
if !line.is_char_boundary(token_start) || &line[token_start..cursor_pos] != token {
return None;
}
Some(CompletionContext {
token: token.to_owned(),
prefix: line[..token_start].to_owned(),
suffix: line[cursor_pos..].to_owned(),
})
}
#[derive(Debug, PartialEq)]
struct NamespaceToken {
package: String,
partial: String,
triple_colon: bool,
start_pos: usize,
}
fn parse_namespace_token(line: &str, cursor_pos: usize) -> Option<NamespaceToken> {
let before_cursor = &line[..cursor_pos.min(line.len())];
let partial: String = before_cursor
.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_')
.collect::<String>()
.chars()
.rev()
.collect();
let before_partial = &before_cursor[..before_cursor.len() - partial.len()];
let (triple_colon, before_colons) = if let Some(rest) = before_partial.strip_suffix(":::") {
(true, rest)
} else {
let rest = before_partial.strip_suffix("::")?;
(false, rest)
};
let package: String = before_colons
.chars()
.rev()
.take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_')
.collect::<String>()
.chars()
.rev()
.collect();
if package.is_empty() {
return None;
}
if package.chars().next()?.is_ascii_digit() {
return None;
}
let colon_len = if triple_colon { 3 } else { 2 };
let start_pos = before_colons.len() - package.len();
if start_pos + package.len() + colon_len + partial.len() > cursor_pos {
return None;
}
Some(NamespaceToken {
package,
partial,
triple_colon,
start_pos,
})
}
struct NamespaceExportCache {
exports: Vec<String>,
timestamp: Instant,
}
struct NamespaceFuzzyCache {
input: String,
start_pos: usize,
suggestions: Vec<Suggestion>,
timestamp: Instant,
}
fn needs_backtick_quoting(name: &str) -> bool {
if name.is_empty() {
return false;
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '.' {
return true;
}
if first == '.'
&& let Some(second) = chars.next()
{
if second.is_ascii_digit() {
return true;
}
if !second.is_ascii_alphanumeric() && second != '.' && second != '_' {
return true;
}
}
for c in chars {
if !c.is_ascii_alphanumeric() && c != '.' && c != '_' {
return true;
}
}
false
}
fn has_special_suffix(s: &str) -> bool {
s.ends_with("::")
|| s.ends_with("<-")
|| s.ends_with("<<-")
|| s.ends_with("()")
|| s.ends_with('(')
|| s.ends_with('/')
|| s.ends_with(" = ")
|| s.ends_with("=")
}
#[derive(Debug, PartialEq)]
struct LibraryContext {
partial: String,
start_pos: usize,
}
fn detect_library_context(
line: &str,
cursor_pos: usize,
func_names: &[String],
) -> Option<LibraryContext> {
let pos = cursor_pos.min(line.len());
if !line.is_char_boundary(pos) {
return None;
}
let before_cursor = &line[..pos];
let mut paren_depth = 0;
let mut last_open_paren_pos = None;
for (i, c) in before_cursor.char_indices().rev() {
match c {
')' => paren_depth += 1,
'(' => {
if paren_depth == 0 {
last_open_paren_pos = Some(i);
break;
}
paren_depth -= 1;
}
_ => {}
}
}
let open_pos = last_open_paren_pos?;
let before_paren = before_cursor[..open_pos].trim_end();
let func_name = before_paren
.rsplit(|c: char| !c.is_alphanumeric() && c != '_' && c != '.' && c != ':')
.next()?;
if func_name.is_empty() {
return None;
}
let func_start = before_paren.len() - func_name.len();
if func_start > 0 {
let preceding_char = before_paren[..func_start].chars().next_back();
if matches!(preceding_char, Some('$' | '@')) {
return None;
}
}
if !func_names.iter().any(|f| f == func_name) {
return None;
}
let after_paren = &before_cursor[open_pos + 1..];
if after_paren.contains(',') {
return None;
}
let trimmed = after_paren.trim_start();
if trimmed.starts_with('"') || trimmed.starts_with('\'') {
return None;
}
let partial: String = trimmed
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_')
.collect();
let whitespace_len = after_paren.len() - trimmed.len();
let start_pos = open_pos + 1 + whitespace_len;
Some(LibraryContext { partial, start_pos })
}
pub struct RCompleter {
timeout_ms: u64,
debounce_ms: u64,
auto_paren_limit: usize,
cache: Option<CompletionCache>,
fuzzy_namespace: bool,
package_functions: Vec<String>,
static_formals: arf_harp::completion::StaticFormalsPolicy,
namespace_cache: HashMap<String, NamespaceExportCache>,
namespace_fuzzy_cache: Option<NamespaceFuzzyCache>,
}
impl RCompleter {
pub fn new() -> Self {
RCompleter {
timeout_ms: 50,
debounce_ms: 100,
auto_paren_limit: 50,
cache: None,
fuzzy_namespace: false,
package_functions: vec!["library".to_string(), "require".to_string()],
static_formals: arf_harp::completion::StaticFormalsPolicy::off(),
namespace_cache: HashMap::new(),
namespace_fuzzy_cache: None,
}
}
pub fn with_settings_full_and_static_formals(
timeout_ms: u64,
debounce_ms: u64,
auto_paren_limit: usize,
fuzzy_namespace: bool,
package_functions: Vec<String>,
static_formals: arf_harp::completion::StaticFormalsPolicy,
) -> Self {
RCompleter {
timeout_ms,
debounce_ms,
auto_paren_limit,
cache: None,
fuzzy_namespace,
package_functions,
static_formals,
namespace_cache: HashMap::new(),
namespace_fuzzy_cache: None,
}
}
fn is_prefix_extension(&self, context: &CompletionContext) -> bool {
if let Some(cache) = &self.cache {
if cache.prefix != context.prefix || cache.suffix != context.suffix {
return false;
}
let Some(extension) = context.token.strip_prefix(cache.token.as_str()) else {
return false;
};
!extension.is_empty()
&& extension
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '_')
} else {
false
}
}
fn should_use_cache(&self, context: &CompletionContext) -> bool {
if let Some(cache) = &self.cache {
if cache.completions.is_empty() {
return false;
}
if context.token.is_empty() || cache.token.is_empty() {
return false;
}
if cache.prefix != context.prefix || cache.suffix != context.suffix {
return false;
}
if context.token.contains("::") || cache.token.contains("::") {
return false;
}
if cache.token == context.token {
cache.timestamp.elapsed() < Duration::from_millis(self.debounce_ms)
} else {
self.is_prefix_extension(context)
}
} else {
false
}
}
fn filter_cached(&self, token: &str) -> Vec<String> {
if let Some(cache) = &self.cache {
cache
.completions
.iter()
.filter(|c| c.starts_with(token))
.cloned()
.collect()
} else {
vec![]
}
}
fn invalidate_cache(&mut self) {
self.cache = None;
self.namespace_fuzzy_cache = None;
}
}
impl Default for RCompleter {
fn default() -> Self {
Self::new()
}
}
impl Completer for RCompleter {
fn complete(&mut self, line: &str, pos: usize) -> CompletionResult {
CompletionResult::fresh(self.complete_impl(line, pos))
}
}
impl RCompleter {
fn complete_impl(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
if let Some(ctx) = detect_string_context(line, pos) {
return complete_path_in_string(line, pos, &ctx);
}
if self.fuzzy_namespace
&& let Some(ns_token) = parse_namespace_token(line, pos)
{
let input = &line[ns_token.start_pos..pos];
if self.is_namespace_fuzzy_cache_hit(input, ns_token.start_pos) {
return self
.namespace_fuzzy_cache
.as_ref()
.unwrap()
.suggestions
.clone();
}
let suggestions = self.complete_namespace_fuzzy(&ns_token, pos);
self.namespace_fuzzy_cache = Some(NamespaceFuzzyCache {
input: input.to_string(),
start_pos: ns_token.start_pos,
suggestions: suggestions.clone(),
timestamp: Instant::now(),
});
return suggestions;
}
if self.fuzzy_namespace
&& let Some(lib_ctx) = detect_library_context(line, pos, &self.package_functions)
{
return self.complete_library_fuzzy(&lib_ctx, pos);
}
let token = arf_harp::completion::get_token(line, pos).unwrap_or_default();
let context = completion_context(line, pos, &token);
let completions = if context
.as_ref()
.is_some_and(|context| self.should_use_cache(context))
{
self.filter_cached(&token)
} else {
let fresh = match arf_harp::completion::get_completions_with_policy(
line,
pos,
self.timeout_ms,
&self.static_formals,
) {
Ok(c) => c,
Err(_) => {
self.invalidate_cache();
return vec![];
}
};
if !fresh.is_empty()
&& !token.is_empty()
&& let Some(context) = context
{
self.cache = Some(CompletionCache {
token: context.token,
prefix: context.prefix,
suffix: context.suffix,
completions: fresh.clone(),
timestamp: Instant::now(),
});
}
fresh
};
if completions.is_empty() {
return vec![];
}
let filtered: Vec<String> = completions
.into_iter()
.filter(|c| c.starts_with(&token))
.collect();
if filtered.is_empty() {
return vec![];
}
let is_function = if self.auto_paren_limit > 0 {
self.check_function_types(&filtered)
} else {
vec![false; filtered.len()]
};
let match_len = token.len();
filtered
.into_iter()
.zip(is_function)
.filter(|(c, is_func)| {
c != &token || (*is_func && !has_special_suffix(c))
})
.map(|(c, is_func)| {
let start = pos - token.len();
let indices = if match_len > 0 {
Some((0..match_len).collect())
} else {
None
};
let (value, extra_info) = if is_func && !has_special_suffix(&c) {
(format!("{}()", c), Some("function".to_string()))
} else {
(c, None)
};
Suggestion {
value,
display_override: None,
description: extra_info,
extra: None,
span: Span { start, end: pos },
append_whitespace: false,
style: None,
match_indices: indices,
}
})
.collect()
}
}
impl RCompleter {
fn check_function_types(&self, completions: &[String]) -> Vec<bool> {
let check_count = completions.len().min(self.auto_paren_limit);
if check_count == 0 {
return vec![false; completions.len()];
}
let names_to_check: Vec<&str> = completions[..check_count]
.iter()
.map(|s| {
if has_special_suffix(s) {
""
} else {
s.as_str()
}
})
.collect();
let checked = arf_harp::completion::check_if_functions(&names_to_check).unwrap_or_default();
let mut result = checked;
result.resize(completions.len(), false);
result
}
const NAMESPACE_CACHE_DURATION: Duration = Duration::from_secs(300);
fn namespace_cache_key(pkg: &str, triple_colon: bool) -> String {
if triple_colon {
format!("{}:::", pkg)
} else {
format!("{}::", pkg)
}
}
fn is_namespace_fuzzy_cache_hit(&self, input: &str, start_pos: usize) -> bool {
if let Some(cache) = &self.namespace_fuzzy_cache {
cache.input == input
&& cache.start_pos == start_pos
&& cache.timestamp.elapsed() < Duration::from_millis(self.debounce_ms)
} else {
false
}
}
fn store_namespace_exports(&mut self, pkg: &str, triple_colon: bool, exports: Vec<String>) {
let cache_key = Self::namespace_cache_key(pkg, triple_colon);
if exports.is_empty() {
self.namespace_cache.remove(&cache_key);
return;
}
let ttl = Self::NAMESPACE_CACHE_DURATION;
self.namespace_cache
.retain(|_, v| v.timestamp.elapsed() < ttl);
self.namespace_cache.insert(
cache_key,
NamespaceExportCache {
exports,
timestamp: Instant::now(),
},
);
}
fn ensure_namespace_cached(&mut self, pkg: &str, triple_colon: bool) {
let cache_key = Self::namespace_cache_key(pkg, triple_colon);
if let Some(entry) = self.namespace_cache.get(&cache_key)
&& entry.timestamp.elapsed() < Self::NAMESPACE_CACHE_DURATION
{
return;
}
let exports =
arf_harp::completion::get_namespace_exports(pkg, triple_colon).unwrap_or_default();
self.store_namespace_exports(pkg, triple_colon, exports);
}
fn complete_namespace_fuzzy(
&mut self,
ns_token: &NamespaceToken,
pos: usize,
) -> Vec<Suggestion> {
self.ensure_namespace_cached(&ns_token.package, ns_token.triple_colon);
let cache_key = Self::namespace_cache_key(&ns_token.package, ns_token.triple_colon);
let exports = match self.namespace_cache.get(&cache_key) {
Some(entry) if !entry.exports.is_empty() => &entry.exports,
_ => return vec![],
};
let colons = if ns_token.triple_colon { ":::" } else { "::" };
let prefix = format!("{}{}", ns_token.package, colons);
let prefix_len = prefix.len();
let matched: Vec<(String, Option<Vec<usize>>, u32)> = if ns_token.partial.is_empty() {
let mut all: Vec<_> = exports.iter().map(|e| (e.clone(), None, 0u32)).collect();
all.sort_by(|a, b| a.0.cmp(&b.0));
all
} else {
let mut results: Vec<_> = exports
.iter()
.filter_map(|export| {
fuzzy_match(&ns_token.partial, export).map(|m| {
let indices: Vec<usize> =
m.indices.iter().map(|i| i + prefix_len).collect();
(export.clone(), Some(indices), m.score)
})
})
.collect();
results.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
results
};
if matched.is_empty() {
return vec![];
}
let matched: Vec<_> = matched
.into_iter()
.filter(|(export, _, _)| !export.contains('`'))
.collect();
if matched.is_empty() {
return vec![];
}
let qualified: Vec<(String, Option<Vec<usize>>)> = matched
.iter()
.map(|(export, indices, _)| {
if needs_backtick_quoting(export) {
let value = format!("{}`{}`", prefix, export);
let adjusted = indices
.as_ref()
.map(|idxs| idxs.iter().map(|&i| i + 1).collect());
(value, adjusted)
} else {
let value = format!("{}{}", prefix, export);
(value, indices.clone())
}
})
.collect();
let check_names: Vec<&str> = qualified
.iter()
.take(self.auto_paren_limit)
.map(|(name, _)| name.as_str())
.collect();
let is_function =
arf_harp::completion::check_if_functions(&check_names).unwrap_or_default();
let span = Span {
start: ns_token.start_pos,
end: pos,
};
qualified
.into_iter()
.zip(matched.iter())
.enumerate()
.map(|(i, ((base_value, indices), (export, _, _)))| {
let is_func = is_function.get(i).copied().unwrap_or(false);
let (value, extra_info) = if is_func && !has_special_suffix(export) {
(format!("{}()", base_value), Some("function".to_string()))
} else {
(base_value, None)
};
Suggestion {
value,
display_override: None,
description: extra_info,
extra: None,
span,
append_whitespace: false,
style: None,
match_indices: indices,
}
})
.collect()
}
fn complete_library_fuzzy(&self, lib_ctx: &LibraryContext, pos: usize) -> Vec<Suggestion> {
let packages = match arf_harp::completion::get_installed_packages() {
Ok(pkgs) => pkgs,
Err(_) => return vec![],
};
let span = Span {
start: lib_ctx.start_pos,
end: pos,
};
if lib_ctx.partial.is_empty() {
let mut sorted = packages;
sorted.sort();
return sorted
.into_iter()
.map(|pkg| Suggestion {
value: pkg,
display_override: None,
description: None,
extra: None,
span,
append_whitespace: false,
style: None,
match_indices: None,
})
.collect();
}
let mut results: Vec<_> = packages
.iter()
.filter_map(|pkg| {
fuzzy_match(&lib_ctx.partial, pkg).map(|m| (pkg.clone(), m.indices, m.score))
})
.collect();
results.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
results
.into_iter()
.map(|(pkg, indices, _)| Suggestion {
value: pkg,
display_override: None,
description: None,
extra: None,
span,
append_whitespace: false,
style: None,
match_indices: Some(indices),
})
.collect()
}
}
#[cfg(test)]
mod tests;