Skip to main content

steel_core/command/brigadier/
suggestion.rs

1//! Command completion suggestions and UTF-16 replacement ranges.
2
3use std::{cmp::Ordering, ops::Range};
4
5use text_components::TextComponent;
6use thiserror::Error;
7
8use super::StringRange;
9
10/// A suggestion range could not be mapped to valid UTF-8 boundaries.
11#[derive(Clone, Debug, Error, PartialEq, Eq)]
12pub(crate) enum SuggestionError {
13    /// The UTF-16 range is out of bounds or splits a supplementary character.
14    #[error(
15        "suggestion range {range:?} is invalid for input containing {input_length} UTF-16 code units"
16    )]
17    InvalidRange {
18        range: StringRange,
19        input_length: usize,
20    },
21    /// An expansion range does not contain the original suggestion range.
22    #[error("suggestion range {outer:?} does not encompass {inner:?}")]
23    NonEncompassingRange {
24        outer: StringRange,
25        inner: StringRange,
26    },
27}
28
29/// One replacement offered for a command input range.
30#[derive(Clone, Debug, PartialEq, Eq, Hash)]
31pub(crate) struct Suggestion {
32    range: StringRange,
33    text: Box<str>,
34    tooltip: Option<TextComponent>,
35    integer: Option<i32>,
36}
37
38impl Suggestion {
39    /// Creates a textual suggestion.
40    pub(crate) fn new(range: StringRange, text: impl Into<Box<str>>) -> Self {
41        Self {
42            range,
43            text: text.into(),
44            tooltip: None,
45            integer: None,
46        }
47    }
48
49    /// Creates a textual suggestion with a tooltip.
50    pub(crate) fn with_tooltip(
51        range: StringRange,
52        text: impl Into<Box<str>>,
53        tooltip: impl Into<TextComponent>,
54    ) -> Self {
55        Self {
56            range,
57            text: text.into(),
58            tooltip: Some(tooltip.into()),
59            integer: None,
60        }
61    }
62
63    fn integer(range: StringRange, value: i32) -> Self {
64        Self {
65            range,
66            text: value.to_string().into(),
67            tooltip: None,
68            integer: Some(value),
69        }
70    }
71
72    /// Returns the replacement range.
73    pub(crate) const fn range(&self) -> StringRange {
74        self.range
75    }
76
77    /// Returns the replacement text.
78    pub(crate) fn text(&self) -> &str {
79        &self.text
80    }
81
82    /// Returns the optional tooltip.
83    pub(crate) const fn tooltip(&self) -> Option<&TextComponent> {
84        self.tooltip.as_ref()
85    }
86
87    /// Applies this replacement to `input`.
88    pub(crate) fn apply(&self, input: &str) -> Result<String, SuggestionError> {
89        let range = Self::checked_byte_range(input, self.range)?;
90        let mut result = String::with_capacity(input.len() - range.len() + self.text.len());
91        result.push_str(&input[..range.start]);
92        result.push_str(&self.text);
93        result.push_str(&input[range.end..]);
94        Ok(result)
95    }
96
97    fn expand(&self, input: &str, range: StringRange) -> Result<Self, SuggestionError> {
98        if range.start() > self.range.start() || range.end() < self.range.end() {
99            return Err(SuggestionError::NonEncompassingRange {
100                outer: range,
101                inner: self.range,
102            });
103        }
104        if range == self.range {
105            return Ok(self.clone());
106        }
107
108        let prefix = Self::checked_byte_range(
109            input,
110            StringRange::between(range.start(), self.range.start()),
111        )?;
112        let suffix =
113            Self::checked_byte_range(input, StringRange::between(self.range.end(), range.end()))?;
114        let mut text = String::with_capacity(prefix.len() + self.text.len() + suffix.len());
115        text.push_str(&input[prefix]);
116        text.push_str(&self.text);
117        text.push_str(&input[suffix]);
118
119        Ok(Self {
120            range,
121            text: text.into_boxed_str(),
122            tooltip: self.tooltip.clone(),
123            // Brigadier's expansion returns a plain Suggestion, even when the
124            // original was an IntegerSuggestion.
125            integer: None,
126        })
127    }
128
129    fn checked_byte_range(
130        input: &str,
131        range: StringRange,
132    ) -> Result<Range<usize>, SuggestionError> {
133        range
134            .byte_range(input)
135            .ok_or(SuggestionError::InvalidRange {
136                range,
137                input_length: input.encode_utf16().count(),
138            })
139    }
140
141    fn compare_ignore_case(&self, other: &Self) -> Ordering {
142        match (self.integer, other.integer) {
143            (Some(first), Some(second)) => first.cmp(&second),
144            _ => self.text.to_lowercase().cmp(&other.text.to_lowercase()),
145        }
146    }
147}
148
149/// A sorted set of command completions sharing one replacement range.
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub(crate) struct Suggestions {
152    range: StringRange,
153    suggestions: Vec<Suggestion>,
154}
155
156impl Suggestions {
157    /// Creates suggestions that already share one range.
158    pub(crate) const fn new(range: StringRange, suggestions: Vec<Suggestion>) -> Self {
159        Self { range, suggestions }
160    }
161
162    /// Returns an empty suggestion set.
163    pub(crate) const fn empty() -> Self {
164        Self {
165            range: StringRange::at(0),
166            suggestions: Vec::new(),
167        }
168    }
169
170    /// Returns the common replacement range.
171    pub(crate) const fn range(&self) -> StringRange {
172        self.range
173    }
174
175    /// Returns the sorted suggestions.
176    pub(crate) fn list(&self) -> &[Suggestion] {
177        &self.suggestions
178    }
179
180    /// Returns whether there are no suggestions.
181    pub(crate) const fn is_empty(&self) -> bool {
182        self.suggestions.is_empty()
183    }
184
185    /// Merges multiple suggestion sets and expands them to one range.
186    pub(crate) fn merge(input: &str, suggestions: Vec<Self>) -> Result<Self, SuggestionError> {
187        let mut suggestions = suggestions.into_iter();
188        let Some(first) = suggestions.next() else {
189            return Ok(Self::empty());
190        };
191        let Some(second) = suggestions.next() else {
192            return Ok(first);
193        };
194
195        let mut merged = first.suggestions;
196        merged.extend(second.suggestions);
197        for suggestions in suggestions {
198            merged.extend(suggestions.suggestions);
199        }
200        Self::create(input, merged)
201    }
202
203    fn create(input: &str, suggestions: Vec<Suggestion>) -> Result<Self, SuggestionError> {
204        if suggestions.is_empty() {
205            return Ok(Self::empty());
206        }
207
208        let mut start = usize::MAX;
209        let mut end = 0;
210        for suggestion in &suggestions {
211            start = start.min(suggestion.range.start());
212            end = end.max(suggestion.range.end());
213        }
214        let range = StringRange::between(start, end);
215        let mut expanded = Vec::with_capacity(suggestions.len());
216        for suggestion in suggestions {
217            let suggestion = suggestion.expand(input, range)?;
218            if !expanded.contains(&suggestion) {
219                expanded.push(suggestion);
220            }
221        }
222        expanded.sort_by(Self::compare_suggestions);
223        Ok(Self::new(range, expanded))
224    }
225
226    fn compare_suggestions(first: &Suggestion, second: &Suggestion) -> Ordering {
227        first.compare_ignore_case(second)
228    }
229}
230
231/// Accumulates suggestions for one input suffix.
232pub(crate) struct SuggestionsBuilder<'input> {
233    input: &'input str,
234    start: usize,
235    byte_start: usize,
236    remaining_lowercase: String,
237    suggestions: Vec<Suggestion>,
238}
239
240impl<'input> SuggestionsBuilder<'input> {
241    /// Creates a builder at a UTF-16 input position.
242    pub(crate) fn new(input: &'input str, start: usize) -> Result<Self, SuggestionError> {
243        let range = StringRange::at(start);
244        let Some(byte_range) = range.byte_range(input) else {
245            return Err(SuggestionError::InvalidRange {
246                range,
247                input_length: input.encode_utf16().count(),
248            });
249        };
250        Ok(Self {
251            input,
252            start,
253            byte_start: byte_range.start,
254            remaining_lowercase: input[byte_range.start..].to_lowercase(),
255            suggestions: Vec::new(),
256        })
257    }
258
259    /// Returns the complete input.
260    pub(crate) const fn input(&self) -> &'input str {
261        self.input
262    }
263
264    /// Returns the UTF-16 replacement start.
265    pub(crate) const fn start(&self) -> usize {
266        self.start
267    }
268
269    /// Returns the suffix being replaced.
270    pub(crate) fn remaining(&self) -> &'input str {
271        &self.input[self.byte_start..]
272    }
273
274    /// Returns a lowercase copy of the suffix being replaced.
275    pub(crate) fn remaining_lowercase(&self) -> &str {
276        &self.remaining_lowercase
277    }
278
279    /// Adds a textual suggestion unless it is already the exact suffix.
280    pub(crate) fn suggest(&mut self, text: impl Into<Box<str>>) -> &mut Self {
281        let text = text.into();
282        if text.as_ref() != self.remaining() {
283            self.suggestions.push(Suggestion::new(self.range(), text));
284        }
285        self
286    }
287
288    /// Adds a textual suggestion with a tooltip.
289    pub(crate) fn suggest_with_tooltip(
290        &mut self,
291        text: impl Into<Box<str>>,
292        tooltip: impl Into<TextComponent>,
293    ) -> &mut Self {
294        let text = text.into();
295        if text.as_ref() != self.remaining() {
296            self.suggestions
297                .push(Suggestion::with_tooltip(self.range(), text, tooltip));
298        }
299        self
300    }
301
302    /// Adds an integer suggestion.
303    pub(crate) fn suggest_integer(&mut self, value: i32) -> &mut Self {
304        self.suggestions
305            .push(Suggestion::integer(self.range(), value));
306        self
307    }
308
309    /// Builds, deduplicates, and sorts the accumulated suggestions.
310    pub(crate) fn build(self) -> Result<Suggestions, SuggestionError> {
311        Suggestions::create(self.input, self.suggestions)
312    }
313
314    /// Creates an empty builder with the same input and start.
315    pub(crate) fn restart(&self) -> Result<Self, SuggestionError> {
316        Self::new(self.input, self.start)
317    }
318
319    fn range(&self) -> StringRange {
320        StringRange::between(self.start, self.input.encode_utf16().count())
321    }
322}