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

Add Zsh Completion Script Generation and Unit Tests #1182

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions docs/_includes/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -3933,6 +3933,13 @@ source ./extra/shell/makers-completion.bash

It will enable auto completion for the **makers** executable.

<a name="usage-task-completion-zsh"></a>
#### Zsh Task Completion
By executing `cargo make --completion zsh` the necesary components will be created to enable task autocompletion. A restart of the shell is needed, or `source ~/.zshrc`
Then it will be possible to list the tasks defined in Makefile.toml using tab: `cargo make <tab>`



<a name="usage-shell-completion-fig"></a>
#### Fig / Amazon CodeWhisperer for command line

Expand Down
1 change: 1 addition & 0 deletions docs/_includes/nav.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
* [Shell Completion](#usage-shell-completion)
* [Bash](#usage-shell-completion-bash)
* [zsh](#usage-shell-completion-zsh)
* [Zsh Task Completion](usage-task-completion-zsh)
* [Fig / Amazon CodeWhisperer for command line](#usage-shell-completion-fig)
* [Global Configuration](#cargo-make-global-config)
* [Makefile Definition](#descriptor-definition)
Expand Down
27 changes: 27 additions & 0 deletions src/lib/cli_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
#[path = "cli_parser_test.rs"]
mod cli_parser_test;

use crate::completion::generate_completions;

use crate::cli::{
AUTHOR, DEFAULT_LOG_LEVEL, DEFAULT_OUTPUT_FORMAT, DEFAULT_TASK_NAME, DESCRIPTION, VERSION,
};
Expand Down Expand Up @@ -42,6 +44,11 @@ fn get_args(
None => None,
};

cli_args.completion = match cli_parsed.get_first_value("completion") {
Some(value) => Some(value.to_string()),
None => None,
};

cli_args.cwd = cli_parsed.get_first_value("cwd");

let default_log_level = match global_config.log_level {
Expand Down Expand Up @@ -214,6 +221,17 @@ fn add_arguments(spec: CliSpec, default_task_name: &str, default_log_level: &str
"PROFILE".to_string(),
)),
})
.add_argument(Argument {
name: "completion".to_string(),
key: vec!["--completion".to_string()],
argument_occurrence: ArgumentOccurrence::Single,
value_type: ArgumentValueType::Single,
default_value: None,
help: Some(ArgumentHelp::TextAndParam(
"Will enable completion for the defined tasks for a given shell".to_string(),
"COMPLETION".to_string(),
)),
})
.add_argument(Argument {
name: "cwd".to_string(),
key: vec!["--cwd".to_string()],
Expand Down Expand Up @@ -482,6 +500,15 @@ pub fn parse_args(
let version_text = cliparser::version(&spec);
println!("{}", version_text);
Err(CargoMakeError::ExitCode(std::process::ExitCode::SUCCESS))
} else if let Some(shell) = cli_parsed.get_first_value("completion") {
// Call the function to generate completions
if let Err(e) = generate_completions(&shell) {
error!("Error generating completions: {}", e);
return Err(CargoMakeError::ExitCode(std::process::ExitCode::FAILURE));
}
Err(crate::error::CargoMakeError::ExitCode(
std::process::ExitCode::SUCCESS,
))
} else {
Ok(get_args(
&cli_parsed,
Expand Down
10 changes: 10 additions & 0 deletions src/lib/cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fn run_makefile_not_found() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: None,
env: None,
env_file: None,
Expand Down Expand Up @@ -57,6 +58,7 @@ fn run_empty_task() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: None,
env: None,
env_file: None,
Expand Down Expand Up @@ -96,6 +98,7 @@ fn print_empty_task() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: None,
env: None,
env_file: None,
Expand Down Expand Up @@ -135,6 +138,7 @@ fn list_empty_task() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: None,
env: None,
env_file: None,
Expand Down Expand Up @@ -174,6 +178,7 @@ fn run_file_and_task() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: None,
env: None,
env_file: None,
Expand Down Expand Up @@ -216,6 +221,7 @@ fn run_cwd_with_file() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: Some("..".to_string()),
env: None,
env_file: None,
Expand Down Expand Up @@ -256,6 +262,7 @@ fn run_file_not_go_to_project_root() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: None,
env: None,
env_file: None,
Expand Down Expand Up @@ -296,6 +303,7 @@ fn run_cwd_go_to_project_root_current_dir() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: None,
env: None,
env_file: None,
Expand Down Expand Up @@ -339,6 +347,7 @@ fn run_cwd_go_to_project_root_child_dir() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: None,
env: None,
env_file: None,
Expand Down Expand Up @@ -382,6 +391,7 @@ fn run_cwd_task_not_found() {
profile: None,
log_level: "error".to_string(),
disable_color: true,
completion: None,
cwd: Some("..".to_string()),
env: None,
env_file: None,
Expand Down
112 changes: 112 additions & 0 deletions src/lib/completion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use log::{error, info};
use std::io::BufRead;
use std::path::Path;
use std::{fs, io};

/// # Completions Module
///
/// This module handles the generation of shell completion scripts for the `cargo-make` tool.
///
/// ## Functionality
/// - `generate_completion_zsh`: Generates a Zsh completion script, creates the necessary directory,
/// and prompts for overwriting existing files.
///
/// ## Improvements to Consider
/// 1. **Modularity**: Separate the completion logic into different modules for different shells
/// (e.g., Zsh, Bash, Fish) to improve code organization.
/// 2. **Cross-Platform Support**: Abstract the completion generation into a trait or interface
/// to facilitate adding support for other shell types.
/// 3. **Enhanced Error Handling**: Provide more informative error messages for file operations.
/// 4. **User Input Handling**: Ensure user input is trimmed and handled correctly.
/// 5. **Testing**: Implement unit tests to verify the correct behavior of completion generation functions.

#[cfg(test)]
#[path = "completion_test.rs"]
mod completion_test;

pub fn generate_completions(shell: &str) -> Result<(), Box<dyn std::error::Error>> {
match shell {
"zsh" => {
generate_completion_zsh(None)?; // Use the `?` operator to propagate errors
Ok(()) // Return Ok if no error occurred
}
_ => {
// Return an error for unsupported shell
Err(Box::from(format!(
"Unsupported shell for completion: {}",
shell
)))
}
}
}

// Modify the function to accept an optional input stream
fn generate_completion_zsh(
input: Option<&mut dyn io::Read>,
) -> Result<(), Box<dyn std::error::Error>> {
let home_dir = std::env::var("HOME")?;
let zfunc_dir = format!("{}/.zfunc", home_dir);
let completion_file = format!("{}/_cargo-make", zfunc_dir);

if !Path::new(&zfunc_dir).exists() {
if let Err(e) = fs::create_dir_all(&zfunc_dir) {
error!("Failed to create directory {}: {}", zfunc_dir, e);
return Err(Box::new(e));
}
info!("Created directory: {}", zfunc_dir);
}

if Path::new(&completion_file).exists() {
let mut input_str = String::new();
let reader: Box<dyn io::Read> = match input {
Some(input) => Box::new(input),
None => Box::new(io::stdin()),
};

// Create a BufReader to read from the provided input or stdin
let mut buf_reader = io::BufReader::new(reader);
println!(
"File {} already exists. Overwrite? (y/n): ",
completion_file
);
buf_reader.read_line(&mut input_str)?;

if input_str.trim().to_lowercase() != "y" {
println!("Aborted overwriting the file.");
return Ok(());
}
}

let completion_script = r#"
#compdef cargo make cargo-make

_cargo_make() {
local tasks
local makefile="Makefile.toml"

if [[ ! -f $makefile ]]; then
return 1
fi

tasks=($(awk -F'[\\[\\.\\]]' '/^\[tasks/ {print $3}' "$makefile"))

if [[ ${#tasks[@]} -eq 0 ]]; then
return 1
fi

_describe -t tasks 'cargo-make tasks' tasks
}

_cargo_make "$@"
"#;

fs::write(&completion_file, completion_script)?;
println!("\nWrote tasks completion script to: {}", completion_file);

println!("To enable Zsh completion, add the following lines to your ~/.zshrc:\n");
println!(" fpath=(~/.zfunc $fpath)");
println!(" autoload -Uz compinit && compinit");
println!("\nThen, restart your terminal or run 'source ~/.zshrc'.");

Ok(())
}
103 changes: 103 additions & 0 deletions src/lib/completion_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
use std::fs;
use std::io::Cursor;
use std::path::Path;

mod tests {
use crate::completion::generate_completion_zsh;

use super::*;

// Function to clean up test environment by removing the completion file
fn cleanup() {
let home_dir = std::env::var("HOME").expect("Failed to get HOME");
let completion_file = format!("{}/.zfunc/_cargo-make", home_dir);
println!("\n\n\n\n{}\n\n\n\n", completion_file);

if Path::new(&completion_file).exists() {
fs::remove_file(&completion_file).expect("Failed to clean up test file");
}
}

#[test]
954alberto marked this conversation as resolved.
Show resolved Hide resolved
#[ignore]
fn test_generate_completion_zsh_overwrite_prompt_yes() {
cleanup(); // Clean up before the test

let input = b"y\n"; // Simulate user input of 'y'
let mut reader = Cursor::new(input);

let result = generate_completion_zsh(Some(&mut reader));
assert!(result.is_ok());
}

#[test]
#[ignore]
fn test_generate_completion_zsh_overwrite_prompt_no() {
cleanup(); // Clean up before the test
let input = b"n\n"; // Simulate user input of 'n'
let mut reader = Cursor::new(input);

let result = generate_completion_zsh(Some(&mut reader));
assert!(result.is_ok());
}

#[test]
#[ignore]
fn test_generate_completion_zsh_creates_directory() {
cleanup(); // Clean up before the test

let input = b"y\n"; // Simulate user input of 'y'
let mut reader = Cursor::new(input);

let result = generate_completion_zsh(Some(&mut reader));
assert!(result.is_ok(), "Should succeed in generating completions");

// Check if the directory was created
let home_dir = std::env::var("HOME").expect("Failed to get HOME");
let zfunc_dir = format!("{}/.zfunc", home_dir);
assert!(
Path::new(&zfunc_dir).exists(),
"The zfunc directory should exist"
);
}

#[test]
#[ignore]
fn test_generate_completion_zsh_creates_file() {
cleanup(); // Clean up before the test

let input = b"y\n"; // Simulate user input of 'y'
let mut reader = Cursor::new(input);

let result = generate_completion_zsh(Some(&mut reader));
assert!(result.is_ok(), "Should succeed in generating completions");

// Check if the completion file was created
let home_dir = std::env::var("HOME").expect("Failed to get HOME");
let completion_file = format!("{}/.zfunc/_cargo-make", home_dir);
assert!(
Path::new(&completion_file).exists(),
"The completion file should exist"
);
}

#[test]
#[ignore]
fn test_generate_completion_zsh_overwrite_prompt() {
cleanup(); // Clean up before the test

// Create the directory and file first
let input = b"y\n"; // Simulate user input of 'y'
let mut reader = Cursor::new(input);

generate_completion_zsh(Some(&mut reader))
.expect("Should succeed in generating completions");

// Simulate user input for overwrite.
let input = b"y\n"; // Simulate user input of 'y' again
let mut reader = Cursor::new(input);

let result = generate_completion_zsh(Some(&mut reader));
assert!(result.is_ok(), "Should handle overwrite prompt gracefully");
}
}
1 change: 1 addition & 0 deletions src/lib/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub mod cli;
pub mod cli_commands;
pub mod cli_parser;
mod command;
pub mod completion;
mod condition;
pub mod config;
mod descriptor;
Expand Down
3 changes: 3 additions & 0 deletions src/lib/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ pub struct CliArgs {
pub log_level: String,
/// Disables colorful output
pub disable_color: bool,
/// Task completion for given shell
pub completion: Option<String>,
/// Current working directory
pub cwd: Option<String>,
/// Environment variables
Expand Down Expand Up @@ -141,6 +143,7 @@ impl CliArgs {
profile: None,
log_level: "info".to_string(),
disable_color: false,
completion: None,
cwd: None,
env: None,
env_file: None,
Expand Down