Skip to main content

calcli/domain/
completion.rs

1//! Identifier completion for the input field.
2//!
3//! Pure helpers that list the names the user can complete to and find the
4//! identifier under the caret. No styling and no I/O; the TUI drives
5//! [`ratada::autocomplete`] with these. The built-in name lists live in
6//! [`crate::domain::highlight`], the single source shared with the syntax
7//! highlighter, so a new function or constant needs adding in only one place.
8
9use std::ops::Range;
10
11use crate::domain::highlight::{self, ANS, CONSTANTS, FUNCTIONS};
12use crate::domain::variables::VariableStore;
13
14/// The completion candidates for the current session: the defined variables
15/// first (most relevant while typing), then the built-in functions, the
16/// constants and the `ans` keyword. A variable that shadows a built-in name is
17/// listed once.
18pub fn candidates(variables: &VariableStore) -> Vec<String> {
19    let mut names: Vec<String> = Vec::new();
20    for (name, _) in variables.iter() {
21        push_unique(&mut names, name);
22    }
23    for name in FUNCTIONS {
24        push_unique(&mut names, name);
25    }
26    for name in CONSTANTS {
27        push_unique(&mut names, name);
28    }
29    push_unique(&mut names, ANS);
30    names
31}
32
33/// Appends `name` unless it is already present, keeping the list order stable.
34fn push_unique(names: &mut Vec<String>, name: &str) {
35    if !names.iter().any(|existing| existing == name) {
36        names.push(name.to_string());
37    }
38}
39
40/// The character range of the identifier being typed immediately before
41/// `cursor`, as an index into `input.chars()`. This is the prefix to match
42/// against. Empty (`cursor..cursor`) when the run before the caret is not a
43/// valid identifier, so a caret after an operator, a space or a bare number
44/// (an identifier cannot start with a digit) offers nothing to complete.
45pub fn identifier_before(input: &str, cursor: usize) -> Range<usize> {
46    let chars: Vec<char> = input.chars().collect();
47    let end = cursor.min(chars.len());
48    let start = walk_back(&chars, end);
49    if starts_identifier(&chars, start, end) {
50        start..end
51    } else {
52        end..end
53    }
54}
55
56/// The character range of the whole identifier the `cursor` sits in or at the
57/// end of, spanning both directions. This is the span an accepted suggestion
58/// replaces, so completing in the middle of a word rewrites all of it rather
59/// than leaving its tail behind. Empty when the caret is not in an identifier.
60pub fn identifier_at(input: &str, cursor: usize) -> Range<usize> {
61    let chars: Vec<char> = input.chars().collect();
62    let caret = cursor.min(chars.len());
63    let start = walk_back(&chars, caret);
64    let mut end = caret;
65    while end < chars.len() && highlight::is_identifier_part(chars[end]) {
66        end += 1;
67    }
68    if starts_identifier(&chars, start, end) {
69        start..end
70    } else {
71        caret..caret
72    }
73}
74
75/// The start of the run of identifier characters ending at `end`.
76fn walk_back(chars: &[char], end: usize) -> usize {
77    let mut start = end;
78    while start > 0 && highlight::is_identifier_part(chars[start - 1]) {
79        start -= 1;
80    }
81    start
82}
83
84/// Whether `chars[start..end]` is a non-empty run that begins an identifier
85/// (its first character is a letter or `_`, never a digit).
86fn starts_identifier(chars: &[char], start: usize, end: usize) -> bool {
87    start < end && highlight::is_identifier_start(chars[start])
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::domain::quantity::Quantity;
94
95    fn vars(names: &[&str]) -> VariableStore {
96        VariableStore::from_pairs(
97            names
98                .iter()
99                .map(|name| (name.to_string(), Quantity::dimensionless(1.0))),
100        )
101    }
102
103    #[test]
104    fn candidates_list_variables_first_then_the_builtins() {
105        let candidates = candidates(&vars(&["radius"]));
106        assert_eq!(candidates.first().map(String::as_str), Some("radius"));
107        assert!(candidates.iter().any(|name| name == "sin"));
108        assert!(candidates.iter().any(|name| name == "pi"));
109        assert_eq!(candidates.last().map(String::as_str), Some("ans"));
110    }
111
112    #[test]
113    fn a_variable_shadowing_a_builtin_is_listed_once() {
114        let candidates = candidates(&vars(&["min"]));
115        assert_eq!(candidates.iter().filter(|name| *name == "min").count(), 1,);
116    }
117
118    #[test]
119    fn identifier_before_spans_the_word_ending_at_the_caret() {
120        assert_eq!(identifier_before("sin", 3), 0..3);
121        // The caret in the middle of a word stops at the caret.
122        assert_eq!(identifier_before("sinh", 2), 0..2);
123        // Only the trailing identifier run counts, not an earlier one.
124        assert_eq!(identifier_before("2*co", 4), 2..4);
125    }
126
127    #[test]
128    fn identifier_before_is_empty_after_a_non_identifier_character() {
129        assert_eq!(identifier_before("2+", 2), 2..2);
130        assert_eq!(identifier_before("sin(", 4), 4..4);
131        assert_eq!(identifier_before("", 0), 0..0);
132    }
133
134    #[test]
135    fn identifier_before_ignores_a_bare_number() {
136        // A digit cannot start an identifier, so a number offers nothing.
137        assert_eq!(identifier_before("2", 1), 1..1);
138        assert_eq!(identifier_before("1+2", 3), 3..3);
139    }
140
141    #[test]
142    fn identifier_at_spans_the_whole_word_around_the_caret() {
143        // The caret in the middle of `sinh` still spans all of it, so an
144        // accepted suggestion replaces the whole word, not just its head.
145        assert_eq!(identifier_at("sinh", 2), 0..4);
146        assert_eq!(identifier_at("2*cos", 4), 2..5);
147        // Not in an identifier: nothing to replace.
148        assert_eq!(identifier_at("2+", 2), 2..2);
149        assert_eq!(identifier_at("42", 1), 1..1);
150    }
151}