Skip to main content

rig_tui/
complete.rs

1//! Completion in the input.
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use bevy_ecs::prelude::*;
8use bevy_ecs::system::SystemParam;
9use bevy_log::warn;
10use bevy_tasks::{IoTaskPool, TaskPool};
11
12use super::editor::{Editor, MainEditor};
13use super::view::TuiView;
14use rig_ecs::tasks::{Running, Wake};
15use rig_harness::commands::SlashCommand;
16use rig_harness::services::ProjectRoot;
17use rig_harness::watch::{FileChanges, FileWatch};
18use rig_harness::workspace::ProjectWorkspace;
19use rig_tools::blocking;
20use rig_tools::workspace::{Listing, Local, Workspace};
21
22/// Items a completion lists.
23const SHOWN: usize = 50;
24
25/// Candidates a plugin adds to completion, on an entity of its own: for
26/// a word starting with a marker anywhere in the input, such as `#` for
27/// issues ([`CompletionSource::marker`]), or for an argument of a slash
28/// command, such as `/model`'s models ([`CompletionSource::arg`]). The
29/// view itself completes `/` at the start to a command name and `@` to a
30/// path of the [`ProjectRoot`] (walked off the main thread, honouring
31/// `.gitignore`, until a [`FileWatch`] of the root sees a change).
32///
33/// A candidate matches when it contains the word typed, ignoring case; the
34/// earlier the match, the higher it ranks. Several sources for one trigger
35/// add up; keep one current by inserting it again, such as when a lookup
36/// of what is typed ([`InputContext::typed`]) finished: an open list
37/// follows.
38///
39/// ```
40/// use rig_harness::prelude::*;
41/// use rig_tui::CompletionSource;
42///
43/// fn offer(mut commands: Commands) {
44///     let branches = CompletionSource::arg("checkout").candidate("main", "").candidate("dev", "2 ahead");
45///     let issues = CompletionSource::marker('#').candidate("142", "Crash on empty input");
46///     commands.spawn_batch([branches, issues]);
47/// }
48/// ```
49///
50/// [`InputContext::typed`]: crate::InputContext::typed
51#[derive(Component, Clone, Debug, PartialEq, Eq)]
52pub struct CompletionSource {
53    /// What it completes.
54    pub trigger: CompletionTrigger,
55    /// The candidates, in the order they are listed when they match alike.
56    pub candidates: Vec<Candidate>,
57}
58
59/// What a [`CompletionSource`] completes.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub enum CompletionTrigger {
62    /// A word starting with this marker anywhere in the input, such as
63    /// `#`. `/` and `@` are the view's own (commands and paths): a source
64    /// with either completes nothing.
65    Marker(char),
66    /// The argument at `position` (from 0) of the slash command `command`
67    /// (with or without its `/`).
68    Arg {
69        /// The command.
70        command: String,
71        /// The argument, from 0 for the first word after the command.
72        position: usize,
73    },
74}
75
76impl CompletionSource {
77    /// No candidates yet for a word starting with `marker`.
78    pub fn marker(marker: char) -> Self {
79        Self::new(CompletionTrigger::Marker(marker))
80    }
81
82    /// No candidates yet for `command`'s first argument.
83    pub fn arg(command: impl Into<String>) -> Self {
84        Self::new(CompletionTrigger::Arg {
85            command: command.into(),
86            position: 0,
87        })
88    }
89
90    fn new(trigger: CompletionTrigger) -> Self {
91        Self {
92            trigger,
93            candidates: Vec::new(),
94        }
95    }
96
97    /// The same, completing the argument at `position` (from 0) instead;
98    /// a marker's source is left as it is.
99    pub fn at(mut self, at: usize) -> Self {
100        if let CompletionTrigger::Arg { position, .. } = &mut self.trigger {
101            *position = at;
102        }
103        self
104    }
105
106    /// The same, also offering `text`, described by `detail` (may be
107    /// empty).
108    pub fn candidate(mut self, text: impl Into<String>, detail: impl Into<String>) -> Self {
109        self.candidates.push(Candidate {
110            text: text.into(),
111            detail: detail.into(),
112        });
113        self
114    }
115
116    /// Whether it completes the token `kind` of `command` (without its
117    /// `/`).
118    fn is_for(&self, kind: CompletionKind, command: &str) -> bool {
119        match (&self.trigger, kind) {
120            (CompletionTrigger::Marker(marker), CompletionKind::Marker(typed)) => *marker == typed,
121            (
122                CompletionTrigger::Arg {
123                    command: own,
124                    position,
125                },
126                CompletionKind::Arg(typed),
127            ) => own.trim_start_matches('/') == command && *position == typed,
128            _ => false,
129        }
130    }
131
132    /// The marker it adds to those [`token`] knows, if any.
133    fn marker_char(&self) -> Option<char> {
134        match self.trigger {
135            CompletionTrigger::Marker(marker) if !VIEW_MARKERS.contains(&marker) => Some(marker),
136            _ => None,
137        }
138    }
139}
140
141/// The markers the view completes itself: commands and paths.
142const VIEW_MARKERS: [char; 2] = ['/', '@'];
143
144/// What a [`Completion`] completes.
145#[derive(Clone, Copy, PartialEq, Eq, Debug)]
146pub enum CompletionKind {
147    /// A command name after a leading `/`.
148    Command,
149    /// The argument at this position (from 0) of the command at the
150    /// start, `/name`.
151    Arg(usize),
152    /// A path after `@`.
153    Path,
154    /// A word after a [`CompletionSource`]'s marker.
155    Marker(char),
156}
157
158/// One candidate of a completion.
159#[derive(Clone, Debug, Default, PartialEq, Eq)]
160pub struct Candidate {
161    /// What is shown and inserted, without the marker (`/`, `@`, `#`).
162    pub text: String,
163    /// A description shown after it.
164    pub detail: String,
165}
166
167/// The open completion list of the input, as
168/// [`InputContext::completion`](crate::InputContext::completion) gives it.
169#[derive(Clone, Debug, PartialEq, Eq)]
170pub struct Completion {
171    /// What it completes.
172    pub kind: CompletionKind,
173    /// The byte offset in the input of the token it completes: its
174    /// marker, or an argument's first character.
175    pub start: usize,
176    /// The text typed after the marker, or the argument typed.
177    pub query: String,
178    /// What it lists, best first.
179    pub items: Vec<Candidate>,
180    /// The position of the item Tab and Enter put in, among `items`.
181    pub selected: usize,
182    /// Whether the items came from a finished walk.
183    complete: bool,
184}
185
186impl Completion {
187    /// What accepting the selected item puts in place of the token: the
188    /// marker, the item, and a space unless it is a directory.
189    pub(crate) fn chosen(&self) -> Option<String> {
190        let item = self.items.get(self.selected)?;
191        let space = if item.text.ends_with('/') { "" } else { " " };
192        let marker = self.kind.marker().map(String::from).unwrap_or_default();
193        Some(format!("{marker}{}{space}", item.text))
194    }
195
196    /// Whether the token already is the selected item exactly, so Enter
197    /// sends rather than completes.
198    pub(crate) fn is_exact(&self) -> bool {
199        self.items
200            .get(self.selected)
201            .is_some_and(|item| item.text == self.query)
202    }
203
204    pub(crate) fn select(&mut self, step: isize) {
205        let count = self.items.len();
206        if count > 0 {
207            self.selected = self.selected.saturating_add_signed(step).min(count - 1);
208        }
209    }
210}
211
212impl CompletionKind {
213    /// What the token starts with; an argument starts with no marker.
214    pub fn marker(self) -> Option<char> {
215        match self {
216            Self::Command => Some('/'),
217            Self::Arg(_) => None,
218            Self::Path => Some('@'),
219            Self::Marker(marker) => Some(marker),
220        }
221    }
222}
223
224/// The project's paths for `@` completion.
225#[derive(Resource, Default)]
226pub(crate) struct FileIndex {
227    paths: Arc<Vec<String>>,
228    /// Whether a walk runs, to end as [`WalkedPaths`].
229    walking: bool,
230    /// Whether the paths are the project's as its watch last saw it.
231    fresh: bool,
232    /// The watch of the project's root, from the first `@` on.
233    watch: Option<Entity>,
234}
235
236/// The paths a walk of the project found, on the walk's entity.
237#[derive(Component)]
238pub(crate) struct WalkedPaths(Arc<Vec<String>>);
239
240impl From<Vec<String>> for WalkedPaths {
241    fn from(paths: Vec<String>) -> Self {
242        Self(Arc::new(paths))
243    }
244}
245
246impl FileIndex {
247    /// Watches `root`, then starts a walk of it in `workspace` unless one
248    /// runs or nothing changed since the last.
249    fn refresh(
250        &mut self,
251        wake: &Wake,
252        (workspace, root): (Arc<dyn Workspace>, PathBuf),
253        commands: &mut Commands,
254    ) {
255        if self.watch.is_none() {
256            let watch = (Name::new("rig-tui: @ paths"), FileWatch::tree(&root));
257            self.watch = Some(commands.spawn(watch).id());
258        }
259        if self.walking || self.fresh {
260            return;
261        }
262        let walk = blocking(move || Ok(walk(&*workspace, &root)));
263        let walk = async move {
264            walk.await.unwrap_or_else(|failure| {
265                warn!("could not list the project's files: {failure}");
266                Vec::new()
267            })
268        };
269        let pool = IoTaskPool::get_or_init(TaskPool::default);
270        let walking = Running::spawn_into::<WalkedPaths, _>(pool, wake, walk);
271        commands.spawn((Name::new("rig-tui: @ paths walk"), walking));
272        // A change seen from here on makes the walk stale.
273        (self.walking, self.fresh) = (true, true);
274    }
275}
276
277/// The files under `root` in `workspace` and the directories that hold
278/// them, relative to it, directories ending in `/`, skipping what
279/// `.gitignore` and hidden names leave out.
280fn walk(workspace: &dyn Workspace, root: &Path) -> Vec<String> {
281    let files = match workspace.files(root, None, Listing::Visible) {
282        Ok(files) => files,
283        Err(failure) => {
284            warn!("could not list the project's files: {failure}");
285            return Vec::new();
286        }
287    };
288    let text = |path: &Path| path.to_string_lossy().replace('\\', "/");
289    let mut paths = Vec::new();
290    let mut directories = HashSet::new();
291    for file in &files {
292        let Ok(relative) = file.strip_prefix(root) else {
293            continue;
294        };
295        let mut above: Vec<&Path> = relative.ancestors().skip(1).collect();
296        above.retain(|directory| !directory.as_os_str().is_empty());
297        for directory in above.into_iter().rev() {
298            if directories.insert(directory) {
299                paths.push(format!("{}/", text(directory)));
300            }
301        }
302        paths.push(text(relative));
303    }
304    paths
305}
306
307/// The `/command`, argument, `@path` or marked token that ends at the
308/// cursor, if any, given the markers sources add; an argument's with the
309/// command's name and its position.
310fn token<'a>(
311    text: &'a str,
312    cursor: usize,
313    markers: &[char],
314) -> Option<(CompletionKind, usize, &'a str, &'a str)> {
315    let before = text.get(..cursor)?;
316    let start = before
317        .char_indices()
318        .rfind(|(_, character)| character.is_whitespace())
319        .map_or(0, |(index, character)| index + character.len_utf8());
320    let word = before.get(start..)?;
321    if let Some(query) = word.strip_prefix('/')
322        && start == 0
323    {
324        return Some((CompletionKind::Command, start, query, ""));
325    }
326    if let Some(query) = word.strip_prefix('@') {
327        return Some((CompletionKind::Path, start, query, ""));
328    }
329    if let Some(marker) = word.chars().next().filter(|first| markers.contains(first)) {
330        let query = word.get(marker.len_utf8()..)?;
331        return Some((CompletionKind::Marker(marker), start, query, ""));
332    }
333    let command = text.strip_prefix('/')?.split_whitespace().next()?;
334    let position = before
335        .get(..start)?
336        .split_whitespace()
337        .count()
338        .checked_sub(1)?;
339    Some((CompletionKind::Arg(position), start, word, command))
340}
341
342/// What completion lists: the slash commands, and the candidates plugins
343/// add ([`CompletionSource`]).
344#[derive(SystemParam)]
345pub(crate) struct Sources<'w, 's> {
346    commands: Query<'w, 's, (&'static Name, &'static SlashCommand)>,
347    added: Query<'w, 's, &'static CompletionSource>,
348    workspace: Option<Res<'w, ProjectWorkspace>>,
349    root: Option<Res<'w, ProjectRoot>>,
350}
351
352impl Sources<'_, '_> {
353    /// Where `@` paths are: the app's workspace, else this machine, and the
354    /// project's root, else the working directory.
355    fn project(&self) -> (Arc<dyn Workspace>, PathBuf) {
356        let workspace = self.workspace.as_ref();
357        let workspace =
358            workspace.map_or_else(|| Arc::new(Local) as Arc<dyn Workspace>, |it| it.0.clone());
359        let root = self.root.as_ref();
360        (
361            workspace,
362            root.map_or_else(|| PathBuf::from("."), |root| root.0.clone()),
363        )
364    }
365}
366
367/// The markers `sources` add.
368fn markers(sources: &Query<&CompletionSource>) -> Vec<char> {
369    let mut markers: Vec<char> = sources
370        .iter()
371        .filter_map(CompletionSource::marker_char)
372        .collect();
373    markers.sort_unstable();
374    markers.dedup();
375    markers
376}
377
378/// The token at the cursor of `input` that completion completes, and what
379/// is typed of it after its marker.
380pub(crate) fn typed(
381    input: &Editor,
382    sources: &Query<&CompletionSource>,
383) -> Option<(CompletionKind, String)> {
384    let (kind, _, query, _) = token(input.text(), input.cursor(), &markers(sources))?;
385    Some((kind, query.to_owned()))
386}
387
388/// Opens, updates or closes the completion after the input, the paths or
389/// the sources changed (`renewed`: an open list is listed again). A list
390/// closed with Esc stays closed until the token moves.
391pub(crate) fn update(
392    view: &mut TuiView,
393    input: &Editor,
394    index: &mut FileIndex,
395    (sources, renewed): (&Sources, bool),
396    wake: &Wake,
397    commands: &mut Commands,
398) {
399    let found = token(input.text(), input.cursor(), &markers(&sources.added));
400    let Some((kind, start, query, command)) = found else {
401        view.completion = None;
402        view.dismissed = None;
403        return;
404    };
405    if view.dismissed == Some(start) {
406        view.completion = None;
407        return;
408    }
409    view.dismissed = None;
410    if kind == CompletionKind::Path {
411        index.refresh(wake, sources.project(), commands);
412    }
413    let complete = !index.walking;
414    if let Some(open) = &view.completion
415        && open.kind == kind
416        && open.start == start
417        && open.query == query
418        && (kind != CompletionKind::Path || open.complete == complete)
419        && !renewed
420    {
421        return;
422    }
423    let items = match kind {
424        CompletionKind::Command => {
425            let mut ranked: Vec<(usize, &str, &SlashCommand)> = sources
426                .commands
427                .iter()
428                .filter_map(|(name, command)| {
429                    let name = name.as_str().trim_start_matches('/');
430                    Some((match_at(query, name)?, name, command))
431                })
432                .collect();
433            ranked.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
434            ranked
435                .into_iter()
436                .map(|(_, name, command)| Candidate {
437                    text: name.to_owned(),
438                    detail: command.help.clone(),
439                })
440                .collect()
441        }
442        CompletionKind::Arg(_) | CompletionKind::Marker(_) => {
443            let candidates = sources
444                .added
445                .iter()
446                .filter(|source| source.is_for(kind, command))
447                .flat_map(|source| &source.candidates);
448            let mut ranked: Vec<(usize, &Candidate)> = candidates
449                .filter_map(|item| Some((match_at(query, &item.text)?, item)))
450                .collect();
451            ranked.sort_by_key(|(at, _)| *at);
452            ranked.into_iter().map(|(_, item)| item.clone()).collect()
453        }
454        CompletionKind::Path => path_items(&index.paths, query),
455    };
456    let query = query.to_owned();
457    // A list listed again keeps the item selected, where it still is.
458    let selected = (view.completion.as_ref())
459        .filter(|open| open.kind == kind && open.start == start)
460        .and_then(|open| open.items.get(open.selected))
461        .and_then(|was| items.iter().position(|item| item == was))
462        .unwrap_or(0);
463    view.completion = (!items.is_empty()).then_some(Completion {
464        kind,
465        start,
466        query,
467        items,
468        selected,
469        complete,
470    });
471}
472
473/// The best [`SHOWN`] paths for `query`: those whose file name matches
474/// before those where only a directory does, then by where it matches,
475/// shallower paths first.
476fn path_items(paths: &[String], query: &str) -> Vec<Candidate> {
477    let mut ranked: Vec<((bool, usize, usize), &String)> = paths
478        .iter()
479        .filter_map(|path| {
480            let trimmed = path.trim_end_matches('/');
481            let name = trimmed.rsplit('/').next().unwrap_or(trimmed);
482            let (in_directory, at) = match match_at(query, name) {
483                Some(at) => (false, at),
484                None => (true, match_at(query, path)?),
485            };
486            Some(((in_directory, at, trimmed.matches('/').count()), path))
487        })
488        .collect();
489    ranked.sort();
490    ranked
491        .into_iter()
492        .take(SHOWN)
493        .map(|(_, path)| Candidate {
494            text: path.clone(),
495            detail: String::new(),
496        })
497        .collect()
498}
499
500/// Walks the project again once its watch saw a change while an `@` path
501/// is typed, and takes a finished walk to update the completion with it.
502pub(crate) fn receive_paths(
503    mut index: ResMut<FileIndex>,
504    mut changes: MessageReader<FileChanges>,
505    walked: Option<Single<(Entity, &WalkedPaths)>>,
506    mut view: ResMut<TuiView>,
507    input: Query<&Editor, With<MainEditor>>,
508    sources: Sources,
509    (wake, mut commands): (Res<Wake>, Commands),
510) {
511    let Ok(input) = input.single() else {
512        return;
513    };
514    let watch = index.watch;
515    let changed = changes.read().any(|change| Some(change.watch) == watch);
516    if changed {
517        index.fresh = false;
518    }
519    let received = walked.map(|walked| walked.into_inner());
520    if let Some((walk, WalkedPaths(paths))) = received {
521        (index.paths, index.walking) = (paths.clone(), false);
522        commands.entity(walk).despawn();
523    }
524    if changed || received.is_some() {
525        update(
526            &mut view,
527            input,
528            &mut index,
529            (&sources, false),
530            &wake,
531            &mut commands,
532        );
533    }
534}
535
536/// Brings the completion up to date with the input once it changed, by a
537/// key or a plugin, and lists it again once a plugin's sources changed.
538pub(crate) fn follow_input(
539    mut index: ResMut<FileIndex>,
540    mut view: ResMut<TuiView>,
541    input: Query<Ref<Editor>, With<MainEditor>>,
542    sources: Sources,
543    (changed, mut removed): (
544        Query<(), Changed<CompletionSource>>,
545        RemovedComponents<CompletionSource>,
546    ),
547    (wake, mut commands): (Res<Wake>, Commands),
548) {
549    let renewed = !changed.is_empty() || removed.read().count() > 0;
550    let Ok(input) = input.single() else {
551        return;
552    };
553    if input.is_changed() || renewed {
554        update(
555            &mut view,
556            &input,
557            &mut index,
558            (&sources, renewed),
559            &wake,
560            &mut commands,
561        );
562    }
563}
564
565/// Where `query` first appears in `text`, ignoring case, or `None`.
566fn match_at(query: &str, text: &str) -> Option<usize> {
567    text.to_lowercase().find(&query.to_lowercase())
568}
569
570#[cfg(test)]
571mod tests;