initiative_core/command/
mod.rs

1/// All of the classes needed to implement a new command or token type.
2#[cfg(not(feature = "integration-tests"))]
3mod prelude;
4#[cfg(feature = "integration-tests")]
5pub mod prelude;
6
7mod about;
8
9mod token;
10
11use std::fmt::{self, Write};
12use std::iter;
13use std::pin::Pin;
14
15use crate::app::{
16    AppMeta, Autocomplete, AutocompleteSuggestion, CommandMatches, ContextAwareParse, Runnable,
17};
18use initiative_macros::CommandList;
19
20use token::{FuzzyMatch, Token, TokenMatch};
21
22use async_stream::stream;
23use async_trait::async_trait;
24use futures::prelude::*;
25
26pub trait Command {
27    /// Return a single Token representing the command's syntax. If multiple commands are possible,
28    /// Token::Or can be used as a wrapper to cover the options.
29    fn token(&self) -> Token;
30
31    /// Convert a matched token into a suggestion to be displayed to the user. Note that this
32    /// method is not async; any metadata that may be needed for the autocomplete should be fetched
33    /// during the match_input step of the token and embedded in the match_meta property of the
34    /// TokenMatch object.
35    fn autocomplete(&self, fuzzy_match: FuzzyMatch, input: &str) -> Option<AutocompleteSuggestion>;
36
37    /// Get the priority of the command with a given input. See CommandPriority for details.
38    fn get_priority(&self, token_match: &TokenMatch) -> Option<CommandPriority>;
39
40    /// Run the command represented by a matched token, returning the success or failure output to
41    /// be displayed to the user.
42    #[cfg_attr(feature = "integration-tests", expect(async_fn_in_trait))]
43    async fn run(&self, token_match: TokenMatch, app_meta: &mut AppMeta) -> Result<String, String>;
44
45    /// Get the canonical form of the provided token match. Return None if the match is invalid.
46    fn get_canonical_form_of(&self, token_match: &TokenMatch) -> Option<String>;
47
48    /// A helper function to roughly provide Command::autocomplete(Command::token().match_input()),
49    /// except that that wouldn't compile for all sorts of exciting reasons.
50    fn parse_autocomplete<'a>(
51        &'a self,
52        input: &'a str,
53        app_meta: &'a AppMeta,
54    ) -> Pin<Box<dyn Stream<Item = AutocompleteSuggestion> + 'a>> {
55        Box::pin(stream! {
56            let token = self.token();
57            for await token_match in token.match_input(input, app_meta) {
58                if !matches!(token_match, FuzzyMatch::Overflow(..)) {
59                    if let Some(suggestion) = self.autocomplete(token_match, input) {
60                        yield suggestion;
61                    }
62                }
63            }
64        })
65    }
66}
67
68#[derive(Clone, CommandList, Debug)]
69enum CommandList {
70    About(about::About),
71}
72
73#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
74pub enum CommandPriority {
75    /// There should be no more than one canonical command per input, distinguished by a unique
76    /// prefix. The canonical command will always run if matched. If fuzzy matches also exist, they
77    /// will be indicated after the output of the canonical command.
78    Canonical,
79
80    /// There may be multiple fuzzy matches for a given input. If no canonical command exists AND
81    /// only one fuzzy match is found, that match will run. If multiple fuzzy matches are found,
82    /// the user will be prompted which canonical form they wish to run.
83    Fuzzy,
84}
85
86/// Interfaces with the legacy command traits.
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct TransitionalCommand {
89    canonical: String,
90}
91
92impl TransitionalCommand {
93    pub fn new<S>(canonical: S) -> Self
94    where
95        S: AsRef<str>,
96    {
97        Self {
98            canonical: canonical.as_ref().to_string(),
99        }
100    }
101}
102
103#[async_trait(?Send)]
104impl Runnable for TransitionalCommand {
105    async fn run(self, _input: &str, app_meta: &mut AppMeta) -> Result<String, String> {
106        run(&self.canonical, app_meta).await
107    }
108}
109
110#[async_trait(?Send)]
111impl ContextAwareParse for TransitionalCommand {
112    async fn parse_input(input: &str, app_meta: &AppMeta) -> CommandMatches<Self> {
113        let mut command_matches = CommandMatches::default();
114
115        let commands_tokens: Vec<(&CommandList, Token)> = CommandList::get_all()
116            .iter()
117            .map(|c| (c, c.token()))
118            .collect();
119
120        {
121            let mut match_streams = stream::SelectAll::default();
122
123            // Indexing the array avoids lifetime issues that would occur with an iterator
124            #[expect(clippy::needless_range_loop)]
125            for i in 0..commands_tokens.len() {
126                match_streams.push(
127                    stream::repeat(commands_tokens[i].0)
128                        .zip(commands_tokens[i].1.match_input(input, app_meta)),
129                );
130            }
131
132            while let Some((command, fuzzy_match)) = match_streams.next().await {
133                if let FuzzyMatch::Exact(token_match) = fuzzy_match {
134                    if let Some(priority) = command.get_priority(&token_match) {
135                        if let Some(canonical) = command.get_canonical_form_of(&token_match) {
136                            if priority == CommandPriority::Canonical {
137                                command_matches.push_canonical(Self { canonical });
138                            } else {
139                                command_matches.push_fuzzy(Self { canonical });
140                            }
141                        }
142                    }
143                }
144            }
145        }
146
147        command_matches
148    }
149}
150
151#[async_trait(?Send)]
152impl Autocomplete for TransitionalCommand {
153    async fn autocomplete(input: &str, app_meta: &AppMeta) -> Vec<AutocompleteSuggestion> {
154        autocomplete(input, app_meta).await
155    }
156}
157
158impl fmt::Display for TransitionalCommand {
159    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
160        write!(f, "{}", self.canonical)
161    }
162}
163
164pub async fn autocomplete(input: &str, app_meta: &AppMeta) -> Vec<AutocompleteSuggestion> {
165    let mut suggestions: Vec<_> = stream::select_all(
166        CommandList::get_all()
167            .iter()
168            .map(|c| c.parse_autocomplete(input, app_meta)),
169    )
170    .collect()
171    .await;
172
173    suggestions.sort();
174    suggestions.truncate(10);
175    suggestions
176}
177
178pub async fn run(input: &str, app_meta: &mut AppMeta) -> Result<String, String> {
179    // The only reason this vec exists is to ensure that the Tokens referenced by TokenMatch et al
180    // outlive their references.
181    let commands_tokens: Vec<(&CommandList, Token)> = CommandList::get_all()
182        .iter()
183        .map(|c| (c, c.token()))
184        .collect();
185
186    let mut token_matches: Vec<(&CommandList, CommandPriority, TokenMatch)> = Vec::new();
187
188    {
189        let mut match_streams = stream::SelectAll::default();
190
191        // Indexing the array avoids lifetime issues that would occur with an iterator
192        #[expect(clippy::needless_range_loop)]
193        for i in 0..commands_tokens.len() {
194            match_streams.push(
195                stream::repeat(commands_tokens[i].0)
196                    .zip(commands_tokens[i].1.match_input_exact(input, app_meta)),
197            );
198        }
199
200        while let Some((command, token_match)) = match_streams.next().await {
201            if let Some(priority) = command.get_priority(&token_match) {
202                token_matches.push((command, priority, token_match));
203            }
204        }
205    }
206
207    token_matches.sort_by_key(|&(_, command_priority, _)| command_priority);
208
209    match token_matches.len() {
210        0 => return Err(format!("Unknown command: \"{}\"", input)),
211        1 => {
212            let (command, _, token_match) = token_matches.pop().unwrap();
213            return command.run(token_match, app_meta).await;
214        }
215        _ => {} // continue
216    }
217
218    if token_matches[0].1 == CommandPriority::Canonical {
219        assert_ne!(token_matches[1].1, CommandPriority::Canonical);
220
221        let (command, _, token_match) = token_matches.remove(0);
222        let result = command.run(token_match, app_meta).await;
223
224        let mut iter = token_matches
225            .iter()
226            .take_while(|(_, command_priority, _)| command_priority == &CommandPriority::Fuzzy)
227            .peekable();
228
229        if iter.peek().is_none() {
230            result
231        } else {
232            let f = |s| {
233                iter
234                    .filter_map(|(command, _, token_match)| command.get_canonical_form_of(token_match))
235                    .fold(
236                        format!("{}\n\n! There are other possible interpretations of this command. Did you mean:\n", s),
237                        |mut s, c| { write!(s, "\n* `{}`", c).unwrap(); s }
238                    )
239            };
240
241            match result {
242                Ok(s) => Ok(f(s)),
243                Err(s) => Err(f(s)),
244            }
245        }
246    } else {
247        let first_token_match = token_matches.remove(0);
248
249        let mut iter =
250            iter::once(&first_token_match)
251                .chain(token_matches.iter().take_while(|(_, command_priority, _)| {
252                    command_priority == &first_token_match.1
253                }))
254                .filter_map(|(command, _, token_match)| command.get_canonical_form_of(token_match))
255                .peekable();
256
257        if iter.peek().is_none() {
258            Err(format!("Unknown command: \"{}\"", input))
259        } else {
260            Err(iter.fold(
261                "There are several possible interpretations of this command. Did you mean:\n"
262                    .to_string(),
263                |mut s, c| {
264                    write!(s, "\n* `{}`", c).unwrap();
265                    s
266                },
267            ))
268        }
269    }
270}