Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/alan/src/core/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ impl SlashCommand {
}
}

impl AsRef<str> for SlashCommand {
fn as_ref(&self) -> &str {
self.into()
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
125 changes: 125 additions & 0 deletions crates/alan/src/core/completion/commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//! Slash-command completion.
//!
//! Candidates come from [`SlashCommand`] itself, so the popup cannot offer a
//! command that does not exist.

use super::{
Accept, CompletionBackend, CompletionItem, CompletionRequest, CompletionResult,
CompletionStatus, ranked_items,
};
use crate::core::SlashCommand;
use strum::IntoEnumIterator;

pub struct Commands {
commands: Vec<SlashCommand>,
}

impl Default for Commands {
fn default() -> Self {
Self {
commands: SlashCommand::iter().collect(),
}
}
}

impl CompletionBackend for Commands {
fn trigger(&self) -> char {
'/'
}

fn complete(&self, request: &CompletionRequest) -> Option<CompletionResult> {
// A command is the whole input, so it can only open the buffer. The
// range starts after the one-byte trigger, so 1 is column 0.
if request.row != 0 || request.range.start != 1 {
return None;
}

Some(CompletionResult {
range: request.range.clone(),
status: CompletionStatus::Ready,
items: ranked_items(&request.pattern, &self.commands, |command| CompletionItem {
display: format!("{} — {}", command.name(), command.description()),
replacement: command.as_ref().to_owned(),
accept: Accept::Complete,
}),
})
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::core::completion::CompletionController;

fn engine() -> CompletionController {
CompletionController::new(vec![Box::new(Commands::default())])
}

#[test]
fn a_lone_slash_lists_every_command() {
let mut engine = engine();
engine.sync("/", 1, 0);

assert!(engine.is_open());
assert_eq!(engine.item_count(), SlashCommand::iter().count());
}

#[test]
fn a_pattern_narrows_to_the_matching_commands() {
let mut engine = engine();
engine.sync("/he", 3, 0);

let items = engine.items(0, engine.item_count());
assert_eq!(items.len(), 1);
assert_eq!(items[0].replacement, "help");
}

/// Guards the name-to-variant lookup in [`describe`], without which the
/// item would fall back to a bare `/help`.
#[test]
fn an_item_is_displayed_with_its_description() {
let mut engine = engine();
engine.sync("/help", 5, 0);

assert_eq!(
engine.items(0, 1)[0].display,
format!("/help — {}", SlashCommand::Help.description())
);
}

/// Everywhere but the first column a `/` is a path separator.
#[test]
fn a_slash_inside_the_line_is_not_a_command() {
let mut engine = engine();
engine.sync("explain /usr", 12, 0);

assert!(!engine.is_open());
}

/// A command is the whole input, so a `/` opening a continuation line is
/// prose. Offering one there would submit the half-written prompt around it.
#[test]
fn a_slash_opening_a_later_line_is_not_a_command() {
let mut engine = engine();
engine.sync("/he", 3, 1);

assert!(!engine.is_open());
}

/// The trigger survives the replacement, so the item carries the bare name.
#[test]
fn accepting_replaces_the_name_and_keeps_the_slash() {
let mut engine = engine();
engine.sync("/he", 3, 0);

let (item, range) = engine.accept().unwrap();
assert_eq!(item.replacement, "help");
assert_eq!(range, 1..3);
assert_eq!(
item.accept,
Accept::Complete,
"a command is the whole input"
);
assert!(!engine.is_open());
}
}
64 changes: 42 additions & 22 deletions crates/alan/src/core/completion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
//! the line itself. Ranking is not their concern either: [`matcher`] orders
//! every backend the same way.

mod commands;
mod matcher;
mod paths;
mod token;

use super::Poll;
pub use commands::Commands;
pub use paths::Paths;
use std::collections::HashMap;
use std::ops::Range;
Expand All @@ -23,13 +25,22 @@ pub struct CompletionRequest {
pub pattern: String,
/// Bytes of the line the pattern occupies, which accepting overwrites.
pub range: Range<usize>,
/// Line of the buffer the token sits on, which is what tells a backend
pub row: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionItem {
pub display: String,
/// Text substituted for [`CompletionResult::range`].
pub replacement: String,
pub accept: Accept,
}

/// What accepting an item leaves the input in. Set by the backend that offered the item
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Accept {
Insert,
Complete,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -104,14 +115,15 @@ impl CompletionController {
}
}

/// Re-evaluate after every editor change.
pub fn sync(&mut self, line: &str, cursor: usize) {
self.active = self.claim(line, cursor);
/// Re-evaluate after every editor change. `line` is the one the cursor is
/// on and `row` is where it sits in the buffer.
pub fn sync(&mut self, line: &str, cursor: usize, row: usize) {
self.active = self.claim(line, cursor, row);
}

/// Any step failing means no completion applies here: no token under the
/// cursor, no backend for its trigger, or the backend declining.
fn claim(&mut self, line: &str, cursor: usize) -> Option<Active> {
fn claim(&mut self, line: &str, cursor: usize, row: usize) -> Option<Active> {
let token = token::at(line, cursor)?;
// Read before the backend is borrowed mutably.
let switching = self.active.as_ref().map(|active| active.trigger) != Some(token.trigger);
Expand All @@ -125,6 +137,7 @@ impl CompletionController {
let request = CompletionRequest {
pattern: line[token.range.clone()].to_owned(),
range: token.range,
row,
};

let result = backend.complete(&request)?;
Expand Down Expand Up @@ -183,11 +196,17 @@ impl CompletionController {
active.selected = (active.selected as isize + delta).clamp(0, max) as usize;
}

/// The highlighted item and the byte range of the line it overwrites.
pub fn accept(&mut self) -> Option<(CompletionItem, Range<usize>)> {
/// The item at [`Self::selected`], which is `None` when the popup has
/// nothing to offer.
pub fn selected_item(&self) -> Option<&CompletionItem> {
let active = self.active.as_ref()?;
let item = active.result.items.get(active.selected)?.clone();
let range = active.result.range.clone();
active.result.items.get(active.selected)
}

/// The selected item and the byte range of the line it overwrites.
pub fn accept(&mut self) -> Option<(CompletionItem, Range<usize>)> {
let item = self.selected_item()?.clone();
let range = self.active.as_ref()?.result.range.clone();
self.active = None;
Some((item, range))
}
Expand Down Expand Up @@ -231,9 +250,10 @@ impl CompletionController {
}

/// Shared so no backend can invent its own ordering.
fn ranked_items<F>(pattern: &str, candidates: &[String], item: F) -> Vec<CompletionItem>
fn ranked_items<C, F>(pattern: &str, candidates: &[C], item: F) -> Vec<CompletionItem>
where
F: Fn(&str) -> CompletionItem,
C: AsRef<str>,
F: Fn(&C) -> CompletionItem,
{
matcher::rank_all(pattern, candidates)
.into_iter()
Expand Down Expand Up @@ -265,7 +285,7 @@ mod tests {
#[test]
fn an_unclaimed_trigger_opens_nothing() {
let mut engine = engine(&["src/main.rs"]);
engine.sync("/help", 5);
engine.sync("#tag", 4, 0);

assert!(!engine.is_open());
}
Expand All @@ -282,7 +302,7 @@ mod tests {
#[test]
fn an_at_token_opens_completion_anywhere_in_the_line() {
let mut engine = engine(&["src/main.rs", "docs/"]);
engine.sync("explain @mai", 12);
engine.sync("explain @mai", 12, 0);

assert!(engine.is_open());
assert_eq!(displayed(&engine), ["src/main.rs"]);
Expand All @@ -291,16 +311,16 @@ mod tests {
#[test]
fn plain_text_closes_the_popup() {
let mut engine = engine(&["src/main.rs"]);
engine.sync("@src", 4);
engine.sync("hello", 5);
engine.sync("@src", 4, 0);
engine.sync("hello", 5, 0);

assert!(!engine.is_open());
}

#[test]
fn selection_stays_inside_the_items() {
let mut engine = engine(&["a.txt", "b.txt"]);
engine.sync("@", 1);
engine.sync("@", 1, 0);
assert_eq!(engine.item_count(), 2);

engine.move_selection(50);
Expand All @@ -313,7 +333,7 @@ mod tests {
#[test]
fn accepting_reports_the_range_it_overwrites() {
let mut engine = engine(&["src/main.rs"]);
engine.sync("explain @mai", 12);
engine.sync("explain @mai", 12, 0);

let (item, range) = engine.accept().unwrap();
assert_eq!(item.replacement, "src/main.rs");
Expand All @@ -326,7 +346,7 @@ mod tests {
#[test]
fn accepting_a_directory_closes_the_popup() {
let mut engine = engine(&["crates/", "crates/alan/"]);
engine.sync("@crat", 5);
engine.sync("@crat", 5, 0);

let (item, range) = engine.accept().unwrap();
assert_eq!(item.replacement, "crates/");
Expand All @@ -338,11 +358,11 @@ mod tests {
#[test]
fn typing_past_a_directory_reopens_the_popup() {
let mut engine = engine(&["crates/", "crates/alan/main.rs"]);
engine.sync("@crates/", 8);
engine.sync("@crates/", 8, 0);
engine.accept();
assert!(!engine.is_open());

engine.sync("@crates/m", 9);
engine.sync("@crates/m", 9, 0);

assert!(engine.is_open());
assert_eq!(displayed(&engine), ["crates/alan/main.rs"]);
Expand All @@ -351,7 +371,7 @@ mod tests {
#[test]
fn accepting_nothing_when_no_candidate_matched() {
let mut engine = engine(&["src/main.rs"]);
engine.sync("@zzz", 4);
engine.sync("@zzz", 4, 0);

assert_eq!(engine.item_count(), 0);
assert!(engine.accept().is_none());
Expand All @@ -360,7 +380,7 @@ mod tests {
#[test]
fn items_are_bounded_by_the_window_asked_for() {
let mut engine = engine(&["a.txt", "b.txt", "c.txt"]);
engine.sync("@", 1);
engine.sync("@", 1, 0);

assert_eq!(engine.items(0, 2).len(), 2);
assert_eq!(engine.items(2, 5).len(), 1);
Expand Down
8 changes: 5 additions & 3 deletions crates/alan/src/core/completion/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
//! index is served stale rather than waited on.

use super::{
CompletionBackend, CompletionItem, CompletionRequest, CompletionResult, CompletionStatus,
ranked_items,
Accept, CompletionBackend, CompletionItem, CompletionRequest, CompletionResult,
CompletionStatus, ranked_items,
};
use crate::core::Poll;
use futures_util::FutureExt;
Expand Down Expand Up @@ -96,6 +96,7 @@ impl CompletionBackend for Paths {
items: ranked_items(&request.pattern, &self.index, |path| CompletionItem {
display: path.to_owned(),
replacement: path.to_owned(),
accept: Accept::Insert,
}),
})
}
Expand Down Expand Up @@ -386,7 +387,7 @@ mod tests {

let mut completion = CompletionController::new(vec![Box::new(Paths::new(root.clone()))]);

completion.sync("@mai", 4);
completion.sync("@mai", 4, 0);
assert!(completion.is_open());
assert_eq!(completion.item_count(), 0);

Expand All @@ -413,6 +414,7 @@ mod tests {
CompletionRequest {
pattern: pattern.to_owned(),
range,
row: 0,
}
}

Expand Down
7 changes: 5 additions & 2 deletions crates/alan/src/core/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use super::action::{Command, ImageAttachment};
use super::chat::{ChatController, Entry};
use super::command::SlashCommand;
use super::completion::{CompletionController, Paths};
use super::completion::{Commands, CompletionController, Paths};
use super::login::{LoginController, LoginState};
use agent::Agent;
use llm::Usage;
Expand Down Expand Up @@ -76,7 +76,10 @@ impl Controller {
Self {
chat: ChatController::new(agent),
login: LoginController::new(providers, credentials),
completion: CompletionController::new(vec![Box::new(Paths::default())]),
completion: CompletionController::new(vec![
Box::new(Paths::default()),
Box::new(Commands::default()),
]),
overlay: Overlay::None,
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/alan/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ pub mod login;
pub use action::{Action, Command, ImageAttachment};
pub use chat::Entry;
pub use command::SlashCommand;
pub use completion::{CompletionController, CompletionItem, CompletionStatus};
pub use completion::{Accept, CompletionController, CompletionItem, CompletionStatus};
pub use controller::{Activity, Controller, Overlay, Poll};
pub use login::LoginState;
Loading