1use 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
22const SHOWN: usize = 50;
24
25#[derive(Component, Clone, Debug, PartialEq, Eq)]
52pub struct CompletionSource {
53 pub trigger: CompletionTrigger,
55 pub candidates: Vec<Candidate>,
57}
58
59#[derive(Clone, Debug, PartialEq, Eq)]
61pub enum CompletionTrigger {
62 Marker(char),
66 Arg {
69 command: String,
71 position: usize,
73 },
74}
75
76impl CompletionSource {
77 pub fn marker(marker: char) -> Self {
79 Self::new(CompletionTrigger::Marker(marker))
80 }
81
82 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 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 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 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 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
141const VIEW_MARKERS: [char; 2] = ['/', '@'];
143
144#[derive(Clone, Copy, PartialEq, Eq, Debug)]
146pub enum CompletionKind {
147 Command,
149 Arg(usize),
152 Path,
154 Marker(char),
156}
157
158#[derive(Clone, Debug, Default, PartialEq, Eq)]
160pub struct Candidate {
161 pub text: String,
163 pub detail: String,
165}
166
167#[derive(Clone, Debug, PartialEq, Eq)]
170pub struct Completion {
171 pub kind: CompletionKind,
173 pub start: usize,
176 pub query: String,
178 pub items: Vec<Candidate>,
180 pub selected: usize,
182 complete: bool,
184}
185
186impl Completion {
187 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 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 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#[derive(Resource, Default)]
226pub(crate) struct FileIndex {
227 paths: Arc<Vec<String>>,
228 walking: bool,
230 fresh: bool,
232 watch: Option<Entity>,
234}
235
236#[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 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 (self.walking, self.fresh) = (true, true);
274 }
275}
276
277fn 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
307fn 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#[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 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
367fn 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
378pub(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
388pub(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 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
473fn 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
500pub(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
536pub(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
565fn match_at(query: &str, text: &str) -> Option<usize> {
567 text.to_lowercase().find(&query.to_lowercase())
568}
569
570#[cfg(test)]
571mod tests;