Skip to content

Commit

Permalink
Tab completion and syntax checking (#261)
Browse files Browse the repository at this point in the history
  • Loading branch information
kernelPanic0x authored Oct 24, 2024
1 parent 1195b8a commit 83cbe0e
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 77 deletions.
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ homepage = "http://magic-wormhole.io/"
repository = "https://github.com/magic-wormhole/magic-wormhole.rs/tree/main/cli"
license = "EUPL-1.2"

rust-version = "1.75"
rust-version = "1.81"
edition = "2021"

[workspace.dependencies]
Expand All @@ -38,7 +38,6 @@ futures = "0.3.12"
hex = "0.4.2"
hkdf = "0.12.2"
indicatif = "0.17.0"
log = "0.4.13"
noise-protocol = "0.2"
noise-rust-crypto = "0.6.0-rc.1"
number_prefix = "0.4.0"
Expand Down Expand Up @@ -107,6 +106,8 @@ zxcvbn = { workspace = true, optional = true }

tracing = { workspace = true, features = ["log", "log-always"] }

fuzzt = { version = "0.3.1", optional = true }

# Transit dependencies


Expand Down Expand Up @@ -162,7 +163,7 @@ transit = [
"dep:async-trait",
]
forwarding = ["transit", "dep:rmp-serde"]
default = ["transit", "transfer"]
default = ["transit", "transfer", "fuzzy-complete"]
all = ["default", "forwarding"]

# TLS implementations for websocket connections via async-tungstenite
Expand All @@ -173,6 +174,7 @@ native-tls = ["async-tungstenite/async-native-tls"]
# By enabling this option you are opting out of semver stability.
experimental-transfer-v2 = []
experimental = ["experimental-transfer-v2"]
fuzzy-complete = ["fuzzt"]

[profile.release]
overflow-checks = true
Expand Down
2 changes: 1 addition & 1 deletion cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ clap_complete = { workspace = true }
env_logger = { workspace = true }
console = { workspace = true }
indicatif = { workspace = true }
dialoguer = { workspace = true }
dialoguer = { workspace = true, features = ["completion"] }
color-eyre = { workspace = true }
number_prefix = { workspace = true }
ctrlc = { workspace = true }
Expand Down
31 changes: 31 additions & 0 deletions cli/src/completer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
use std::sync::LazyLock;

use color_eyre::eyre;
use dialoguer::{Completion, Input};
use magic_wormhole::wordlist::{default_wordlist, Wordlist};

static WORDLIST: LazyLock<Wordlist> = LazyLock::new(|| default_wordlist(2));

struct CustomCompletion {}

impl CustomCompletion {
pub fn default() -> Self {
CustomCompletion {}
}
}

impl Completion for CustomCompletion {
fn get(&self, input: &str) -> Option<String> {
WORDLIST.get_completions(input).first().cloned()
}
}

pub fn enter_code() -> eyre::Result<String> {
let custom_completion = CustomCompletion::default();

Input::new()
.with_prompt("Wormhole Code")
.completion_with(&custom_completion)
.interact_text()
.map_err(From::from)
}
11 changes: 2 additions & 9 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![allow(clippy::too_many_arguments)]
mod completer;
mod util;

use std::{
Expand All @@ -9,6 +10,7 @@ use std::{
use async_std::sync::Arc;
use clap::{Args, CommandFactory, Parser, Subcommand};
use color_eyre::{eyre, eyre::Context};
use completer::enter_code;
use console::{style, Term};
use futures::{future::Either, Future, FutureExt};
use indicatif::{MultiProgress, ProgressBar};
Expand Down Expand Up @@ -750,15 +752,6 @@ fn create_progress_handler(pb: ProgressBar) -> impl FnMut(u64, u64) {
}
}

fn enter_code() -> eyre::Result<String> {
use dialoguer::Input;

Input::new()
.with_prompt("Enter code")
.interact_text()
.map_err(From::from)
}

fn print_welcome(term: &mut Term, welcome: Option<&str>) -> eyre::Result<()> {
if let Some(welcome) = &welcome {
writeln!(term, "Got welcome from server: {}", welcome)?;
Expand Down
4 changes: 3 additions & 1 deletion src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ pub mod rendezvous;
mod server_messages;
#[cfg(test)]
mod test;
mod wordlist;

/// Module for wormhole code generation and completion.
pub mod wordlist;

use serde_derive::{Deserialize, Serialize};
use std::{borrow::Cow, str::FromStr};
Expand Down
169 changes: 106 additions & 63 deletions src/core/wordlist.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
///! Wordlist generation and wormhole code utilities
use rand::{rngs::OsRng, seq::SliceRandom};
use serde_json::{self, Value};
use std::fmt;

use super::Password;

/// Represents a list of words used to generate and complete wormhole codes.
/// A wormhole code is a sequence of words used for secure communication or identification.
#[derive(PartialEq)]
pub struct Wordlist {
pub num_words: usize,
/// Number of words in a wormhole code
num_words: usize,
/// Odd and even wordlist
words: Vec<Vec<String>>,
}

Expand All @@ -18,45 +23,67 @@ impl fmt::Debug for Wordlist {

impl Wordlist {
#[cfg(test)]
#[doc(hidden)]
pub fn new(num_words: usize, words: Vec<Vec<String>>) -> Wordlist {
Wordlist { num_words, words }
}

#[allow(dead_code)] // TODO make this API public one day
/// Completes a wormhole code
///
/// Completion can be done either with fuzzy search (approximate string matching)
/// or simple `starts_with` matching.
pub fn get_completions(&self, prefix: &str) -> Vec<String> {
let count_dashes = prefix.matches('-').count();
let mut completions = Vec::new();
let words = &self.words[count_dashes % self.words.len()];

let last_partial_word = prefix.split('-').last();
let lp = if let Some(w) = last_partial_word {
w.len()
} else {
0
};

for word in words {
let mut suffix: String = prefix.to_owned();
if word.starts_with(last_partial_word.unwrap()) {
if lp == 0 {
suffix.push_str(word);
} else {
let p = prefix.len() - lp;
suffix.truncate(p);
suffix.push_str(word);
let words = self.get_wordlist(prefix);

let (prefix_without_last, last_partial) = prefix.rsplit_once('-').unwrap_or(("", prefix));

#[cfg(feature = "fuzzy-complete")]
let matches = self.fuzzy_complete(last_partial, words);
#[cfg(not(feature = "fuzzy-complete"))]
let matches = self.normal_complete(last_partial, words);

matches
.into_iter()
.map(|word| {
let mut completion = String::new();
completion.push_str(prefix_without_last);
if !prefix_without_last.is_empty() {
completion.push('-');
}
completion.push_str(&word);
completion
})
.collect()
}

if count_dashes + 1 < self.num_words {
suffix.push('-');
}
fn get_wordlist(&self, prefix: &str) -> &Vec<String> {
let count_dashes = prefix.matches('-').count();
&self.words[count_dashes % self.words.len()]
}

completions.push(suffix);
}
}
completions.sort();
completions
#[cfg(feature = "fuzzy-complete")]
fn fuzzy_complete(&self, partial: &str, words: &[String]) -> Vec<String> {
// We use Jaro-Winkler algorithm because it emphasizes the beginning of a word
use fuzzt::algorithms::JaroWinkler;

let words = words.iter().map(|w| w.as_str()).collect::<Vec<&str>>();

fuzzt::get_top_n(partial, &words, None, None, None, Some(&JaroWinkler))
.into_iter()
.map(|s| s.to_string())
.collect()
}

#[allow(unused)]
fn normal_complete(&self, partial: &str, words: &[String]) -> Vec<String> {
words
.iter()
.filter(|word| word.starts_with(partial))
.cloned()
.collect()
}

/// Choose wormhole code word
pub fn choose_words(&self) -> Password {
let mut rng = OsRng;
let components: Vec<String> = self
Expand Down Expand Up @@ -106,6 +133,7 @@ fn load_pgpwords() -> Vec<Vec<String>> {
vec![even_words, odd_words]
}

/// Construct Wordlist struct with given number of words in a wormhole code
pub fn default_wordlist(num_words: usize) -> Wordlist {
Wordlist {
num_words,
Expand Down Expand Up @@ -155,8 +183,9 @@ mod test {
];

let w = Wordlist::new(2, words);
assert_eq!(w.get_completions(""), vec!["green-", "purple-", "yellow-"]);
assert_eq!(w.get_completions("pur"), vec!["purple-"]);
assert_eq!(w.get_completions(""), Vec::<String>::new());
assert_eq!(w.get_completions("9"), Vec::<String>::new());
assert_eq!(w.get_completions("pur"), vec!["purple"]);
assert_eq!(w.get_completions("blu"), Vec::<String>::new());
assert_eq!(w.get_completions("purple-sa"), vec!["purple-sausages"]);
}
Expand Down Expand Up @@ -197,45 +226,59 @@ mod test {
}

#[test]
fn test_default_completions() {
let w = default_wordlist(2);
let c = w.get_completions("ar");
assert_eq!(c.len(), 2);
assert!(c.contains(&String::from("article-")));
assert!(c.contains(&String::from("armistice-")));
#[cfg(feature = "fuzzy-complete")]
fn test_wormhole_code_fuzzy_completions() {
let list = default_wordlist(2);

assert_eq!(list.get_completions("22"), Vec::<String>::new());
assert_eq!(list.get_completions("22-"), Vec::<String>::new());

let c = w.get_completions("armis");
assert_eq!(c.len(), 1);
assert!(c.contains(&String::from("armistice-")));
// Invalid wormhole code check
assert_eq!(list.get_completions("trj"), Vec::<String>::new());

let c = w.get_completions("armistice-");
assert_eq!(c.len(), 256);
assert_eq!(
list.get_completions("22-chisel"),
["22-chisel", "22-chairlift", "22-christmas"]
);

let c = w.get_completions("armistice-ba");
assert_eq!(
c,
vec![
"armistice-baboon",
"armistice-backfield",
"armistice-backward",
"armistice-banjo",
]
list.get_completions("22-chle"),
["22-chisel", "22-chatter", "22-checkup"]
);

let w = default_wordlist(3);
let c = w.get_completions("armistice-ba");
assert_eq!(list.get_completions("22-chisel-tba"), ["22-chisel-tobacco"]);
}

#[test]
#[cfg(feature = "fuzzy-complete")]
fn test_completion_fuzzy() {
let wl = default_wordlist(2);
let list = wl.get_wordlist("22-");

assert_eq!(wl.fuzzy_complete("chck", list), ["checkup", "choking"]);
assert_eq!(wl.fuzzy_complete("checkp", list), ["checkup"]);
assert_eq!(
c,
vec![
"armistice-baboon-",
"armistice-backfield-",
"armistice-backward-",
"armistice-banjo-",
]
wl.fuzzy_complete("checkup", list),
["checkup", "lockup", "cleanup"]
);
}

let w = default_wordlist(4);
let c = w.get_completions("armistice-baboon");
assert_eq!(c, vec!["armistice-baboon-"]);
#[test]
fn test_completion_normal() {
let wl = default_wordlist(2);
let list = wl.get_wordlist("22-");

assert_eq!(wl.normal_complete("che", list), ["checkup"]);
}

#[test]
fn test_full_wormhole_completion() {
let wl = default_wordlist(2);

assert_eq!(wl.get_completions("22-chec").first().unwrap(), "22-checkup");
assert_eq!(
wl.get_completions("22-checkup-t").first().unwrap(),
"22-checkup-tobacco"
);
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
#[macro_use]
mod util;
mod core;
pub use core::wordlist;
#[cfg(feature = "forwarding")]
pub mod forwarding;
#[cfg(feature = "transfer")]
Expand Down

0 comments on commit 83cbe0e

Please sign in to comment.