Skip to main content

steel_core/command/brigadier/
suggestion.rs

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