use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use bevy_ecs::prelude::*;
use bevy_ecs::system::SystemParam;
use bevy_log::warn;
use bevy_tasks::{IoTaskPool, TaskPool};
use super::editor::{Editor, MainEditor};
use super::view::TuiView;
use rig_ecs::tasks::{Running, Wake};
use rig_harness::commands::SlashCommand;
use rig_harness::services::ProjectRoot;
use rig_harness::watch::{FileChanges, FileWatch};
use rig_harness::workspace::ProjectWorkspace;
use rig_tools::blocking;
use rig_tools::workspace::{Listing, Local, Workspace};
const SHOWN: usize = 50;
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub struct CompletionSource {
pub trigger: CompletionTrigger,
pub candidates: Vec<Candidate>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CompletionTrigger {
Marker(char),
Arg {
command: String,
position: usize,
},
}
impl CompletionSource {
pub fn marker(marker: char) -> Self {
Self::new(CompletionTrigger::Marker(marker))
}
pub fn arg(command: impl Into<String>) -> Self {
Self::new(CompletionTrigger::Arg {
command: command.into(),
position: 0,
})
}
fn new(trigger: CompletionTrigger) -> Self {
Self {
trigger,
candidates: Vec::new(),
}
}
pub fn at(mut self, at: usize) -> Self {
if let CompletionTrigger::Arg { position, .. } = &mut self.trigger {
*position = at;
}
self
}
pub fn candidate(mut self, text: impl Into<String>, detail: impl Into<String>) -> Self {
self.candidates.push(Candidate {
text: text.into(),
detail: detail.into(),
});
self
}
fn is_for(&self, kind: CompletionKind, command: &str) -> bool {
match (&self.trigger, kind) {
(CompletionTrigger::Marker(marker), CompletionKind::Marker(typed)) => *marker == typed,
(
CompletionTrigger::Arg {
command: own,
position,
},
CompletionKind::Arg(typed),
) => own.trim_start_matches('/') == command && *position == typed,
_ => false,
}
}
fn marker_char(&self) -> Option<char> {
match self.trigger {
CompletionTrigger::Marker(marker) if !VIEW_MARKERS.contains(&marker) => Some(marker),
_ => None,
}
}
}
const VIEW_MARKERS: [char; 2] = ['/', '@'];
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CompletionKind {
Command,
Arg(usize),
Path,
Marker(char),
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Candidate {
pub text: String,
pub detail: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Completion {
pub kind: CompletionKind,
pub start: usize,
pub query: String,
pub items: Vec<Candidate>,
pub selected: usize,
complete: bool,
}
impl Completion {
pub(crate) fn chosen(&self) -> Option<String> {
let item = self.items.get(self.selected)?;
let space = if item.text.ends_with('/') { "" } else { " " };
let marker = self.kind.marker().map(String::from).unwrap_or_default();
Some(format!("{marker}{}{space}", item.text))
}
pub(crate) fn is_exact(&self) -> bool {
self.items
.get(self.selected)
.is_some_and(|item| item.text == self.query)
}
pub(crate) fn select(&mut self, step: isize) {
let count = self.items.len();
if count > 0 {
self.selected = self.selected.saturating_add_signed(step).min(count - 1);
}
}
}
impl CompletionKind {
pub fn marker(self) -> Option<char> {
match self {
Self::Command => Some('/'),
Self::Arg(_) => None,
Self::Path => Some('@'),
Self::Marker(marker) => Some(marker),
}
}
}
#[derive(Resource, Default)]
pub(crate) struct FileIndex {
paths: Arc<Vec<String>>,
walking: bool,
fresh: bool,
watch: Option<Entity>,
}
#[derive(Component)]
pub(crate) struct WalkedPaths(Arc<Vec<String>>);
impl From<Vec<String>> for WalkedPaths {
fn from(paths: Vec<String>) -> Self {
Self(Arc::new(paths))
}
}
impl FileIndex {
fn refresh(
&mut self,
wake: &Wake,
(workspace, root): (Arc<dyn Workspace>, PathBuf),
commands: &mut Commands,
) {
if self.watch.is_none() {
let watch = (Name::new("rig-tui: @ paths"), FileWatch::tree(&root));
self.watch = Some(commands.spawn(watch).id());
}
if self.walking || self.fresh {
return;
}
let walk = blocking(move || Ok(walk(&*workspace, &root)));
let walk = async move {
walk.await.unwrap_or_else(|failure| {
warn!("could not list the project's files: {failure}");
Vec::new()
})
};
let pool = IoTaskPool::get_or_init(TaskPool::default);
let walking = Running::spawn_into::<WalkedPaths, _>(pool, wake, walk);
commands.spawn((Name::new("rig-tui: @ paths walk"), walking));
(self.walking, self.fresh) = (true, true);
}
}
fn walk(workspace: &dyn Workspace, root: &Path) -> Vec<String> {
let files = match workspace.files(root, None, Listing::Visible) {
Ok(files) => files,
Err(failure) => {
warn!("could not list the project's files: {failure}");
return Vec::new();
}
};
let text = |path: &Path| path.to_string_lossy().replace('\\', "/");
let mut paths = Vec::new();
let mut directories = HashSet::new();
for file in &files {
let Ok(relative) = file.strip_prefix(root) else {
continue;
};
let mut above: Vec<&Path> = relative.ancestors().skip(1).collect();
above.retain(|directory| !directory.as_os_str().is_empty());
for directory in above.into_iter().rev() {
if directories.insert(directory) {
paths.push(format!("{}/", text(directory)));
}
}
paths.push(text(relative));
}
paths
}
fn token<'a>(
text: &'a str,
cursor: usize,
markers: &[char],
) -> Option<(CompletionKind, usize, &'a str, &'a str)> {
let before = text.get(..cursor)?;
let start = before
.char_indices()
.rfind(|(_, character)| character.is_whitespace())
.map_or(0, |(index, character)| index + character.len_utf8());
let word = before.get(start..)?;
if let Some(query) = word.strip_prefix('/')
&& start == 0
{
return Some((CompletionKind::Command, start, query, ""));
}
if let Some(query) = word.strip_prefix('@') {
return Some((CompletionKind::Path, start, query, ""));
}
if let Some(marker) = word.chars().next().filter(|first| markers.contains(first)) {
let query = word.get(marker.len_utf8()..)?;
return Some((CompletionKind::Marker(marker), start, query, ""));
}
let command = text.strip_prefix('/')?.split_whitespace().next()?;
let position = before
.get(..start)?
.split_whitespace()
.count()
.checked_sub(1)?;
Some((CompletionKind::Arg(position), start, word, command))
}
#[derive(SystemParam)]
pub(crate) struct Sources<'w, 's> {
commands: Query<'w, 's, (&'static Name, &'static SlashCommand)>,
added: Query<'w, 's, &'static CompletionSource>,
workspace: Option<Res<'w, ProjectWorkspace>>,
root: Option<Res<'w, ProjectRoot>>,
}
impl Sources<'_, '_> {
fn project(&self) -> (Arc<dyn Workspace>, PathBuf) {
let workspace = self.workspace.as_ref();
let workspace =
workspace.map_or_else(|| Arc::new(Local) as Arc<dyn Workspace>, |it| it.0.clone());
let root = self.root.as_ref();
(
workspace,
root.map_or_else(|| PathBuf::from("."), |root| root.0.clone()),
)
}
}
fn markers(sources: &Query<&CompletionSource>) -> Vec<char> {
let mut markers: Vec<char> = sources
.iter()
.filter_map(CompletionSource::marker_char)
.collect();
markers.sort_unstable();
markers.dedup();
markers
}
pub(crate) fn typed(
input: &Editor,
sources: &Query<&CompletionSource>,
) -> Option<(CompletionKind, String)> {
let (kind, _, query, _) = token(input.text(), input.cursor(), &markers(sources))?;
Some((kind, query.to_owned()))
}
pub(crate) fn update(
view: &mut TuiView,
input: &Editor,
index: &mut FileIndex,
(sources, renewed): (&Sources, bool),
wake: &Wake,
commands: &mut Commands,
) {
let found = token(input.text(), input.cursor(), &markers(&sources.added));
let Some((kind, start, query, command)) = found else {
view.completion = None;
view.dismissed = None;
return;
};
if view.dismissed == Some(start) {
view.completion = None;
return;
}
view.dismissed = None;
if kind == CompletionKind::Path {
index.refresh(wake, sources.project(), commands);
}
let complete = !index.walking;
if let Some(open) = &view.completion
&& open.kind == kind
&& open.start == start
&& open.query == query
&& (kind != CompletionKind::Path || open.complete == complete)
&& !renewed
{
return;
}
let items = match kind {
CompletionKind::Command => {
let mut ranked: Vec<(usize, &str, &SlashCommand)> = sources
.commands
.iter()
.filter_map(|(name, command)| {
let name = name.as_str().trim_start_matches('/');
Some((match_at(query, name)?, name, command))
})
.collect();
ranked.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
ranked
.into_iter()
.map(|(_, name, command)| Candidate {
text: name.to_owned(),
detail: command.help.clone(),
})
.collect()
}
CompletionKind::Arg(_) | CompletionKind::Marker(_) => {
let candidates = sources
.added
.iter()
.filter(|source| source.is_for(kind, command))
.flat_map(|source| &source.candidates);
let mut ranked: Vec<(usize, &Candidate)> = candidates
.filter_map(|item| Some((match_at(query, &item.text)?, item)))
.collect();
ranked.sort_by_key(|(at, _)| *at);
ranked.into_iter().map(|(_, item)| item.clone()).collect()
}
CompletionKind::Path => path_items(&index.paths, query),
};
let query = query.to_owned();
let selected = (view.completion.as_ref())
.filter(|open| open.kind == kind && open.start == start)
.and_then(|open| open.items.get(open.selected))
.and_then(|was| items.iter().position(|item| item == was))
.unwrap_or(0);
view.completion = (!items.is_empty()).then_some(Completion {
kind,
start,
query,
items,
selected,
complete,
});
}
fn path_items(paths: &[String], query: &str) -> Vec<Candidate> {
let mut ranked: Vec<((bool, usize, usize), &String)> = paths
.iter()
.filter_map(|path| {
let trimmed = path.trim_end_matches('/');
let name = trimmed.rsplit('/').next().unwrap_or(trimmed);
let (in_directory, at) = match match_at(query, name) {
Some(at) => (false, at),
None => (true, match_at(query, path)?),
};
Some(((in_directory, at, trimmed.matches('/').count()), path))
})
.collect();
ranked.sort();
ranked
.into_iter()
.take(SHOWN)
.map(|(_, path)| Candidate {
text: path.clone(),
detail: String::new(),
})
.collect()
}
pub(crate) fn receive_paths(
mut index: ResMut<FileIndex>,
mut changes: MessageReader<FileChanges>,
walked: Option<Single<(Entity, &WalkedPaths)>>,
mut view: ResMut<TuiView>,
input: Query<&Editor, With<MainEditor>>,
sources: Sources,
(wake, mut commands): (Res<Wake>, Commands),
) {
let Ok(input) = input.single() else {
return;
};
let watch = index.watch;
let changed = changes.read().any(|change| Some(change.watch) == watch);
if changed {
index.fresh = false;
}
let received = walked.map(|walked| walked.into_inner());
if let Some((walk, WalkedPaths(paths))) = received {
(index.paths, index.walking) = (paths.clone(), false);
commands.entity(walk).despawn();
}
if changed || received.is_some() {
update(
&mut view,
input,
&mut index,
(&sources, false),
&wake,
&mut commands,
);
}
}
pub(crate) fn follow_input(
mut index: ResMut<FileIndex>,
mut view: ResMut<TuiView>,
input: Query<Ref<Editor>, With<MainEditor>>,
sources: Sources,
(changed, mut removed): (
Query<(), Changed<CompletionSource>>,
RemovedComponents<CompletionSource>,
),
(wake, mut commands): (Res<Wake>, Commands),
) {
let renewed = !changed.is_empty() || removed.read().count() > 0;
let Ok(input) = input.single() else {
return;
};
if input.is_changed() || renewed {
update(
&mut view,
&input,
&mut index,
(&sources, renewed),
&wake,
&mut commands,
);
}
}
fn match_at(query: &str, text: &str) -> Option<usize> {
text.to_lowercase().find(&query.to_lowercase())
}
#[cfg(test)]
mod tests;