Skip to main content

r/repl/
completer.rs

1//! Tab completion for R builtin functions, keywords, and named parameters.
2//!
3//! Provides completions from four sources:
4//! 1. R keywords (if, for, function, etc.)
5//! 2. R literal constants (TRUE, FALSE, NULL, NA, etc.)
6//! 3. All registered builtin function names
7//! 4. Named parameters when inside a function call (e.g. `runif(n = 10, m` → `min =`)
8
9use reedline::{Completer, Span, Suggestion};
10
11use crate::interpreter::builtins::{find_builtin, BUILTIN_REGISTRY};
12
13pub struct RCompleter {
14    names: Vec<String>,
15}
16
17impl Default for RCompleter {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl RCompleter {
24    pub fn new() -> Self {
25        let mut names: Vec<String> = Vec::new();
26
27        // R keywords
28        for kw in &[
29            "if",
30            "else",
31            "for",
32            "while",
33            "repeat",
34            "function",
35            "return",
36            "next",
37            "break",
38            "in",
39            "TRUE",
40            "FALSE",
41            "NULL",
42            "NA",
43            "NA_integer_",
44            "NA_real_",
45            "NA_complex_",
46            "NA_character_",
47            "Inf",
48            "NaN",
49        ] {
50            names.push((*kw).to_string());
51        }
52
53        // Collect all builtin names from the registry
54        for descriptor in BUILTIN_REGISTRY {
55            for name in std::iter::once(descriptor.name).chain(descriptor.aliases.iter().copied()) {
56                // Skip operator-style names like "+", "-", "==", etc.
57                if name
58                    .chars()
59                    .next()
60                    .is_some_and(|c| c.is_alphabetic() || c == '.')
61                {
62                    names.push(name.to_string());
63                }
64            }
65        }
66
67        names.sort();
68        names.dedup();
69
70        Self { names }
71    }
72}
73
74/// Try to find the enclosing function name for the cursor position.
75///
76/// Walks backward from `pos` through `line`, tracking parenthesis depth.
77/// When we find an unmatched `(`, the identifier before it is the function name.
78/// Returns `None` if the cursor is not inside a function call.
79fn find_enclosing_function(line: &str) -> Option<&str> {
80    let bytes = line.as_bytes();
81    let mut depth: i32 = 0;
82
83    let mut i = bytes.len();
84    while i > 0 {
85        i -= 1;
86        match bytes[i] {
87            b')' => depth += 1,
88            b'(' => {
89                if depth == 0 {
90                    // Found an unmatched ( — extract the function name before it
91                    let before = &line[..i];
92                    let name_start = before
93                        .rfind(|c: char| !c.is_alphanumeric() && c != '.' && c != '_')
94                        .map(|j| j + 1)
95                        .unwrap_or(0);
96                    let name = &before[name_start..i];
97                    if !name.is_empty() {
98                        return Some(name);
99                    }
100                    return None;
101                }
102                depth -= 1;
103            }
104            _ => {}
105        }
106    }
107    None
108}
109
110impl Completer for RCompleter {
111    fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
112        let line_to_pos = &line[..pos];
113        let word_start = line_to_pos
114            .rfind(|c: char| !c.is_alphanumeric() && c != '.' && c != '_')
115            .map(|i| i + 1)
116            .unwrap_or(0);
117
118        let prefix = &line[word_start..pos];
119
120        // Check if we're inside a function call — if so, offer parameter completions.
121        if let Some(func_name) = find_enclosing_function(line_to_pos) {
122            if let Some(descriptor) = find_builtin(func_name) {
123                if !descriptor.formals.is_empty() {
124                    // Also extract parameter names from @param docs for builtins
125                    // that have empty formals but have doc params (variadic functions)
126                    let param_suggestions = complete_params(
127                        descriptor.formals,
128                        descriptor.doc,
129                        prefix,
130                        word_start,
131                        pos,
132                    );
133                    if !param_suggestions.is_empty() {
134                        return param_suggestions;
135                    }
136                } else {
137                    // For variadic builtins, try extracting params from docs
138                    let doc_params = extract_doc_params(descriptor.doc);
139                    if !doc_params.is_empty() {
140                        let param_suggestions =
141                            complete_params_from_strings(&doc_params, prefix, word_start, pos);
142                        if !param_suggestions.is_empty() {
143                            return param_suggestions;
144                        }
145                    }
146                }
147            }
148        }
149
150        if prefix.is_empty() {
151            return vec![];
152        }
153
154        self.names
155            .iter()
156            .filter(|name| name.starts_with(prefix) && name.as_str() != prefix)
157            .map(|name| Suggestion {
158                value: name.clone(),
159                description: None,
160                style: None,
161                extra: None,
162                span: Span::new(word_start, pos),
163                append_whitespace: false,
164                display_override: None,
165                match_indices: None,
166            })
167            .collect()
168    }
169}
170
171fn complete_params(
172    formals: &[&str],
173    doc: &str,
174    prefix: &str,
175    word_start: usize,
176    pos: usize,
177) -> Vec<Suggestion> {
178    // Combine formal names with any extra doc params
179    let mut param_names: Vec<&str> = formals.to_vec();
180
181    // Also add params from doc that aren't in formals (e.g. "..." params)
182    for line in doc.lines() {
183        let trimmed = line.trim();
184        if let Some(rest) = trimmed.strip_prefix("@param ") {
185            if let Some(name) = rest.split_whitespace().next() {
186                if name != "..." && !param_names.contains(&name) {
187                    param_names.push(name);
188                }
189            }
190        }
191    }
192
193    param_names
194        .iter()
195        .filter(|name| {
196            if prefix.is_empty() {
197                true
198            } else {
199                name.starts_with(prefix) && **name != prefix
200            }
201        })
202        .map(|name| Suggestion {
203            value: format!("{name} = "),
204            description: None,
205            style: None,
206            extra: None,
207            span: Span::new(word_start, pos),
208            append_whitespace: false,
209            display_override: None,
210            match_indices: None,
211        })
212        .collect()
213}
214
215fn complete_params_from_strings(
216    params: &[String],
217    prefix: &str,
218    word_start: usize,
219    pos: usize,
220) -> Vec<Suggestion> {
221    params
222        .iter()
223        .filter(|name| {
224            if prefix.is_empty() {
225                true
226            } else {
227                name.starts_with(prefix) && name.as_str() != prefix
228            }
229        })
230        .map(|name| Suggestion {
231            value: format!("{name} = "),
232            description: None,
233            style: None,
234            extra: None,
235            span: Span::new(word_start, pos),
236            append_whitespace: false,
237            display_override: None,
238            match_indices: None,
239        })
240        .collect()
241}
242
243/// Extract parameter names from @param doc lines (for variadic builtins).
244fn extract_doc_params(doc: &str) -> Vec<String> {
245    doc.lines()
246        .filter_map(|line| {
247            let trimmed = line.trim();
248            let rest = trimmed.strip_prefix("@param ")?;
249            let name = rest.split_whitespace().next()?;
250            if name == "..." {
251                None
252            } else {
253                Some(name.to_string())
254            }
255        })
256        .collect()
257}