Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Completions with special characters (e.g. '"&$`) are quoted #27

Merged
merged 3 commits into from
Sep 9, 2024
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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ jobs:
./target/${{ matrix.target }}/release/posh-tabcomplete.exe

- name: Upload artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: |
Expand Down
19 changes: 14 additions & 5 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ readme = "README.md"
rust-version = "1.78.0"

[dependencies]
clap = { version = "4.5.16", features = ["derive"] }
itertools = "0.12"
clap = { version = "4.5.17", features = ["derive"] }
itertools = "0.13"
log = "0.4"
nu-cli = "0.97.1"
nu-command = "0.97.1"
Expand Down
59 changes: 59 additions & 0 deletions src/completer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
use crate::nu_util;
use reedline::{Completer, Suggestion};
use std::path::Path;

pub fn get_suggestions(nu_file_data: &[u8], pwd: &Path, arg_str: &str) -> Vec<String> {
let mut completer = nu_util::extern_completer(pwd, nu_file_data);
let suggestions = completer.complete(arg_str, arg_str.len());
let suggestion_strings: Vec<String> = suggestions.iter().map(get_suggestion_string).collect();
suggestion_strings
}

fn get_suggestion_string(suggestion: &Suggestion) -> String {
let arg = suggestion.value.clone().to_string();
if arg.len() > 1 && arg.starts_with('`') && arg.ends_with('`') {
// replace the start and end backticks with single quotes
// also replace all single quotes with double single quote
// e.g. completion of `git add ` with a file with a space in it
let replaced_single_quotes = &arg[1..arg.len() - 1].replace('\'', "''");
return format!("'{replaced_single_quotes}'",);
}

// for example, `git ch` should be `checkout`, not `git checkout`
if suggestion.span.start == 0 {
return arg.split(' ').last().unwrap().to_string();
}

// if the arg contains a special character, wrap it in single quotes
if !arg.is_empty()
&& !arg.starts_with('\'')
&& (arg.contains(' ')
|| arg.contains('\"')
|| arg.contains('\'')
|| arg.contains('`')
|| arg.contains('&')
|| arg.contains('$'))
{
let replaced_arg = arg.replace('\'', "''");
return format!("'{replaced_arg}'");
}

arg.to_string()
}

#[cfg(test)]
mod tests {
use crate::DEFAULT_CONFIG_DATA;

use super::*;

#[test]
fn test_get_suggestions() {
let nu_file_data: &[u8] = DEFAULT_CONFIG_DATA;
let pwd = std::env::current_dir().unwrap();
let arg_str = "git checkou";
let expected = vec!["checkout".to_string()];
let suggestions = get_suggestions(nu_file_data, &pwd, arg_str);
assert_eq!(suggestions, expected);
}
}
24 changes: 4 additions & 20 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
extern crate log;
use args::RootArgs;
use clap::Parser;
use completer::get_suggestions;
use itertools::Itertools;
use posh_tabcomplete::TABCOMPLETE_FILE;
use reedline::Completer;
use regex::Regex;
use std::{
collections::HashSet,
Expand All @@ -15,6 +15,7 @@ use std::{
use crate::args::{CompleteArgs, TabCompleteSubCommand};

mod args;
mod completer;
mod nu_util;
use std::str;

Expand All @@ -32,7 +33,7 @@ pub fn run_with_args(root_args: &RootArgs) -> Result<(), std::io::Error> {
Ok(())
}

static DEFAULT_CONFIG_DATA: &[u8] = include_bytes!("../resource/completions.nu");
pub static DEFAULT_CONFIG_DATA: &[u8] = include_bytes!("../resource/completions.nu");
fn complete(root_args: &RootArgs, complete_args: &CompleteArgs) -> Result<(), std::io::Error> {
let arg_str = &complete_args.args_str;
let pwd = env::current_dir()?;
Expand All @@ -43,28 +44,11 @@ fn complete(root_args: &RootArgs, complete_args: &CompleteArgs) -> Result<(), st
} else {
string_from_files.as_bytes()
};
let mut completer = nu_util::extern_completer(&pwd, nu_file_data);
let suggestions = completer.complete(arg_str, arg_str.len());
let suggestion_strings: Vec<String> = suggestions
.iter()
.map(|x| maybe_process_path(&x.value.clone().to_string()))
.collect();

let suggestion_strings: Vec<String> = get_suggestions(nu_file_data, &pwd, arg_str);
println!("{}", suggestion_strings.join("\n"));
Ok(())
}

fn maybe_process_path(arg: &str) -> String {
if arg.len() > 1 && arg.starts_with('`') && arg.ends_with('`') {
// replace the start and end backticks with single quotes
// also replace all single quotes with double single quote
let replaced_single_quotes = &arg[1..arg.len() - 1].replace('\'', "''");
return format!("'{replaced_single_quotes}'",);
}

arg.split(' ').last().unwrap().to_string()
}

fn get_string_from_files(root_args: &RootArgs) -> String {
match &root_args.file_override {
Some(file_override) => fs::read_to_string(file_override)
Expand Down
105 changes: 103 additions & 2 deletions tests/test_complete_npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,107 @@ pub use testenv::*;
#[apply(shell_to_use)]
fn test_npm_run(shell: &str) {
let lines = util::run_command(shell, "Invoke-TabComplete 'npm run '");
assert_that!(lines).contains("script1".to_string());
assert_that!(lines).contains("script2".to_string());
assert_eq!(lines, vec!["script1", "script2"]);
}

#[apply(shell_to_use)]
fn test_npm_run_space(shell: &str) -> Result<(), std::io::Error> {
let test_env = TestEnv::new(shell).create_package_json(
r#"
{
"scripts": {
"with space": "echo hello world",
}
}
"#,
)?;

let lines = util::run_with_test_env(&test_env, "Invoke-TabComplete 'npm run with'");
assert_eq!(&lines, &["'with space'".to_string()]);
Ok(())
}

#[apply(shell_to_use)]
fn test_npm_run_single_quotes(shell: &str) -> Result<(), std::io::Error> {
let test_env = TestEnv::new(shell).create_package_json(
r#"
{
"scripts": {
"withsingle'quote": "echo hello world",
}
}
"#,
)?;

let lines = util::run_with_test_env(&test_env, "Invoke-TabComplete 'npm run with'");
assert_that!(&lines).equals_iterator(&["'withsingle''quote'".to_string()].iter());
Ok(())
}

#[apply(shell_to_use)]
fn test_npm_run_double_quotes(shell: &str) -> Result<(), std::io::Error> {
let test_env = TestEnv::new(shell).create_package_json(
r#"
{
"scripts": {
"withdouble\"quote": "echo hello world",
}
}
"#,
)?;

let lines = util::run_with_test_env(&test_env, "Invoke-TabComplete 'npm run with'");
assert_that!(&lines).equals_iterator(&[r#"'withdouble"quote'"#.to_string()].iter());
Ok(())
}

#[apply(shell_to_use)]
fn test_npm_run_backticks(shell: &str) -> Result<(), std::io::Error> {
let test_env = TestEnv::new(shell).create_package_json(
r#"
{
"scripts": {
"with`backtick": "echo hello world",
}
}
"#,
)?;

let lines = util::run_with_test_env(&test_env, "Invoke-TabComplete 'npm run with'");
assert_that!(&lines).equals_iterator(&[r#"'with`backtick'"#.to_string()].iter());
Ok(())
}

#[apply(shell_to_use)]
fn test_npm_run_ampersand(shell: &str) -> Result<(), std::io::Error> {
let test_env = TestEnv::new(shell).create_package_json(
r#"
{
"scripts": {
"with&ampersand": "echo hello world",
}
}
"#,
)?;

let lines = util::run_with_test_env(&test_env, "Invoke-TabComplete 'npm run with'");
assert_that!(&lines).equals_iterator(&[r#"'with&ampersand'"#.to_string()].iter());
Ok(())
}

#[apply(shell_to_use)]
fn test_npm_run_dollar(shell: &str) -> Result<(), std::io::Error> {
let test_env = TestEnv::new(shell).create_package_json(
r#"
{
"scripts": {
"with$dollar": "echo hello world",
}
}
"#,
)?;

let lines = util::run_with_test_env(&test_env, "Invoke-TabComplete 'npm run with'");
assert_that!(&lines).equals_iterator(&[r#"'with$dollar'"#.to_string()].iter());
Ok(())
}
46 changes: 28 additions & 18 deletions tests/testenv/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ impl TestEnv {

let (temp_dir, profile_path) =
create_working_dir(profile_prefix_data).expect("create successful");

let package_json_path = temp_dir.path().join("package.json");
let package_json_str = r#"
{
"name": "my-app",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"script1": "node index.js",
"script2": "echo \"Error: no test specified\" && exit 1"
}
}
"#;
create_package_json(&package_json_path, package_json_str)
.expect("Able to create package json");

TestEnv {
shell: shell.to_string(),
temp_dir,
Expand Down Expand Up @@ -74,7 +90,7 @@ impl TestEnv {
}

pub fn run_with_profile(&self, command: &str) -> Result<Output, io::Error> {
let root = self.temp_dir.path();
let root: &Path = self.temp_dir.path();
let file_contents = format!(". {}\n{command}", self.profile_path.to_str().unwrap());
let file_path = root.join("file.ps1");
fs::File::create(&file_path)?.write_all(file_contents.as_bytes())?;
Expand All @@ -97,6 +113,12 @@ impl TestEnv {

Ok(output)
}

pub fn create_package_json(self, package_json_contents: &str) -> Result<TestEnv, io::Error> {
let package_json_path = self.temp_dir.path().join("package.json");
create_package_json(&package_json_path, package_json_contents)?;
Ok(self)
}
}

fn prepend_to_path_var(path: &Path) -> OsString {
Expand Down Expand Up @@ -145,30 +167,18 @@ fn create_working_dir(profile_prefix_data: Vec<&str>) -> Result<(TempDir, PathBu
run_git(&["checkout", "-b", "testbranch23"]);
run_git(&["remote", "add", "origin", "[email protected]"]);

let package_json_path = root.join("package.json");
create_package_json(&package_json_path)?;

profile_path
};

Ok((temp_dir, profile_path))
}

fn create_package_json(package_json_path: &Path) -> Result<(), io::Error> {
let package_json_str = r#"
{
"name": "my-app",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"script1": "node index.js",
"script2": "echo \"Error: no test specified\" && exit 1"
}
}
"#;

fn create_package_json(
package_json_path: &Path,
package_json_contents: &str,
) -> Result<(), io::Error> {
let mut file = fs::File::create(package_json_path)?;
file.write_all(package_json_str.as_bytes())?;
file.write_all(package_json_contents.as_bytes())?;

Ok(())
}
Expand Down
4 changes: 4 additions & 0 deletions tests/testenv/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ use crate::TestEnv;

pub fn run_command(shell: &str, command: &str) -> Vec<String> {
let testenv = TestEnv::new(shell);
run_with_test_env(&testenv, command)
}

pub fn run_with_test_env(testenv: &TestEnv, command: &str) -> Vec<String> {
let res = testenv.run_with_profile(command).unwrap();
res.stdout.lines().map(|x| x.unwrap()).collect()
}
Loading