Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
28 changes: 27 additions & 1 deletion src/commands/config_shell.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use std::fs;

use crate::shell::{
render_shell_init, render_shell_install_block, shell_rc_path, upsert_managed_block,
render_fish_completion, render_fish_function, render_shell_install_block, shell_command_names,
shell_rc_path, upsert_managed_block,
};
use crate::types::ShellKind;
use crate::{Error, Result};
Expand All @@ -12,6 +13,7 @@ use crate::{Error, Result};
///
/// Returns an error if the shell is missing.
pub fn run_shell_init(command_name: &str, shell: Option<ShellKind>) -> Result<()> {
use crate::shell::render_shell_init;
let shell = shell.ok_or(Error::ShellRequired)?;

print!("{}", render_shell_init(command_name, shell));
Expand All @@ -29,6 +31,11 @@ pub fn run_shell_install(command_name: &str, shell: Option<ShellKind>) -> Result
Some(shell) => shell,
None => ShellKind::detect()?,
};

if shell == ShellKind::Fish {
return install_fish(command_name);
}

let rc_path = shell_rc_path(shell)?;
let block = render_shell_install_block(command_name, shell);
let existing = match fs::read_to_string(&rc_path) {
Expand All @@ -45,3 +52,22 @@ pub fn run_shell_install(command_name: &str, shell: Option<ShellKind>) -> Result
println!("installed shell integration in {}", rc_path.display());
Ok(())
}

fn install_fish(command_name: &str) -> Result<()> {
let base = crate::shell::fish_config_dir()?;
let functions_dir = base.join("functions");
let completions_dir = base.join("completions");
fs::create_dir_all(&functions_dir)?;
fs::create_dir_all(&completions_dir)?;

for name in shell_command_names(command_name) {
let func_path = functions_dir.join(format!("{name}.fish"));
fs::write(&func_path, render_fish_function(name))?;
println!("installed {}", func_path.display());

let comp_path = completions_dir.join(format!("{name}.fish"));
fs::write(&comp_path, render_fish_completion(name))?;
println!("installed {}", comp_path.display());
}
Ok(())
}
2 changes: 1 addition & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ pub enum Error {
UnsupportedShell(String),

/// A shell argument is required for shell-init generation.
#[error("error: shell name required\nhint: use one of: bash, zsh")]
#[error("error: shell name required\nhint: use one of: bash, zsh, fish")]
ShellRequired,

/// The current shell could not be inferred from `$SHELL`.
Expand Down
41 changes: 27 additions & 14 deletions src/shell/init.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::types::ShellKind;

use super::DIRECTIVE_FILE_ENV_VAR;
use super::{DIRECTIVE_FILE_ENV_VAR, shell_command_names};

/// Render shell initialization code for the chosen shell.
#[must_use]
Expand All @@ -13,37 +13,50 @@ pub fn render_shell_init(command_name: &str, shell: ShellKind) -> String {
format!(
"# jj-navi shell integration for {shell}\n{command_blocks}",
shell = shell.as_str(),
command_blocks = command_blocks,
)
}

fn shell_command_names(primary: &str) -> [&str; 2] {
if primary == "nv" {
["nv", "navi"]
} else {
["navi", "nv"]
fn render_command_init(command_name: &str, shell: ShellKind) -> String {
match shell {
ShellKind::Bash | ShellKind::Zsh => render_posix_command_init(command_name, shell),
ShellKind::Fish => render_fish_function(command_name),
}
}

fn render_command_init(command_name: &str, shell: ShellKind) -> String {
let source_cmd = match shell {
ShellKind::Bash | ShellKind::Zsh => "source",
};
fn render_posix_command_init(command_name: &str, shell: ShellKind) -> String {
let directive_env = DIRECTIVE_FILE_ENV_VAR;
let completion = render_completion_init(command_name, shell);
let completion = render_posix_completion_init(command_name, shell);

format!(
"if command -v {command_name} >/dev/null 2>&1; then\n {command_name}() {{\n if [[ -n \"${{COMPLETE:-}}\" ]]; then\n command {command_name} \"$@\"\n return\n fi\n\n local directive_file exit_code=0 source_exit_code=0\n directive_file=\"$(mktemp)\"\n {directive_env}=\"$directive_file\" command {command_name} \"$@\" || exit_code=$?\n if [[ -s \"$directive_file\" ]]; then\n {source_cmd} \"$directive_file\"\n source_exit_code=$?\n if [[ $exit_code -eq 0 ]]; then\n exit_code=$source_exit_code\n fi\n fi\n rm -f \"$directive_file\"\n return \"$exit_code\"\n }}\n\n{completion}fi\n",
"if command -v {command_name} >/dev/null 2>&1; then\n {command_name}() {{\n if [[ -n \"${{COMPLETE:-}}\" ]]; then\n command {command_name} \"$@\"\n return\n fi\n\n local directive_file exit_code=0 source_exit_code=0\n directive_file=\"$(mktemp)\"\n {directive_env}=\"$directive_file\" command {command_name} \"$@\" || exit_code=$?\n if [[ -s \"$directive_file\" ]]; then\n source \"$directive_file\"\n source_exit_code=$?\n if [[ $exit_code -eq 0 ]]; then\n exit_code=$source_exit_code\n fi\n fi\n rm -f \"$directive_file\"\n return \"$exit_code\"\n }}\n\n{completion}fi\n",
)
}

fn render_completion_init(command_name: &str, shell: ShellKind) -> String {
fn render_posix_completion_init(command_name: &str, shell: ShellKind) -> String {
match shell {
ShellKind::Bash => format!(
" _{command_name}_lazy_complete() {{\n if ! declare -F _clap_complete_{command_name} >/dev/null; then\n eval \"$(COMPLETE=bash command {command_name} 2>/dev/null)\" || return\n fi\n _clap_complete_{command_name} \"$@\"\n }}\n\n complete -o nospace -o bashdefault -F _{command_name}_lazy_complete {command_name}\n",
),
ShellKind::Zsh => format!(
" _{command_name}_lazy_complete() {{\n if ! (( $+functions[_clap_dynamic_completer_{command_name}] )); then\n eval \"$(COMPLETE=zsh command {command_name} 2>/dev/null)\" || return\n fi\n _clap_dynamic_completer_{command_name} \"$@\"\n }}\n\n if (( $+functions[compdef] )); then\n compdef _{command_name}_lazy_complete {command_name}\n fi\n",
),
ShellKind::Fish => String::new(),
}
}

/// Render a fish function file for a single command.
#[must_use]
pub fn render_fish_function(command_name: &str) -> String {
let directive_env = DIRECTIVE_FILE_ENV_VAR;
format!(
"function {command_name}\n if set -q COMPLETE\n command {command_name} $argv\n return\n end\n set -l directive_file (mktemp)\n set -l exit_code 0\n set -lx {directive_env} $directive_file\n command {command_name} $argv\n or set exit_code $status\n if test -s $directive_file\n source $directive_file\n set -l source_exit $status\n if test $exit_code -eq 0\n set exit_code $source_exit\n end\n end\n rm -f $directive_file\n return $exit_code\nend\n",
)
}

/// Render a fish completion file for a single command.
#[must_use]
pub fn render_fish_completion(command_name: &str) -> String {
format!(
"complete --keep-order --exclusive --command {command_name} --arguments \"(env COMPLETE=fish command {command_name} -- (commandline --current-process --tokenize --cut-at-cursor) (commandline --current-token))\"\n"
)
}
36 changes: 31 additions & 5 deletions src/shell/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,31 @@ use super::{MANAGED_BLOCK_END, MANAGED_BLOCK_START, ManagedBlockState, inspect_m
/// Render the managed shell block inserted into a shell rc file.
#[must_use]
pub fn render_shell_install_block(command_name: &str, shell: ShellKind) -> String {
format!(
"{MANAGED_BLOCK_START}\neval \"$(command {command_name} config shell init {shell})\"\n{MANAGED_BLOCK_END}\n",
command_name = command_name,
let load_line = format!(
"eval \"$(command {command_name} config shell init {shell})\"",
shell = shell.as_str(),
)
);
format!("{MANAGED_BLOCK_START}\n{load_line}\n{MANAGED_BLOCK_END}\n")
}

pub(crate) fn shell_rc_path(shell: ShellKind) -> Result<PathBuf> {
if shell == ShellKind::Fish {
return Ok(fish_config_dir()?.join("functions/navi.fish"));
}
let home = std::env::var("HOME").map_err(|_| Error::HomeDirectory)?;
Ok(PathBuf::from(home).join(shell.rc_file_name()))
}

pub(crate) fn fish_config_dir() -> Result<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return Ok(PathBuf::from(xdg).join("fish"));
}
}
let home = std::env::var("HOME").map_err(|_| Error::HomeDirectory)?;
Ok(PathBuf::from(home).join(".config/fish"))
}

pub(crate) fn upsert_managed_block(existing: &str, block: &str, rc_path: &Path) -> Result<String> {
match inspect_managed_block(existing) {
ManagedBlockState::Present { start, end } => {
Expand Down Expand Up @@ -84,7 +97,7 @@ pub(crate) fn doctor_findings(command_name: &str) -> Result<Vec<DoctorFinding>>
DoctorFindingCode::UnsupportedShell,
format!("shell '{shell}' is not supported"),
None,
Some(String::from("supported shells: bash, zsh")),
Some(String::from("supported shells: bash, zsh, fish")),
)]);
}
Err(error) => return Err(error),
Expand All @@ -104,6 +117,19 @@ pub(crate) fn doctor_findings(command_name: &str) -> Result<Vec<DoctorFinding>>
Err(error) => return Err(error),
};

if shell == ShellKind::Fish {
if rc_path.exists() {
return Ok(vec![]);
}
return Ok(vec![shell_finding(
DoctorSeverity::Info,
DoctorFindingCode::ShellIntegrationMissing,
format!("fish function file {} does not exist", rc_path.display()),
Some(rc_path.display().to_string()),
Some(shell_install_hint(command_name, shell)),
)]);
}

let contents = match fs::read_to_string(&rc_path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Expand Down
12 changes: 10 additions & 2 deletions src/shell/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,16 @@ mod install;
mod managed_block;

pub use directive::{DIRECTIVE_FILE_ENV_VAR, escape_shell_single_quotes, write_cd_directive};
pub use init::render_shell_init;
pub use init::{render_fish_completion, render_fish_function, render_shell_init};
pub use install::render_shell_install_block;
pub(crate) use install::{doctor_findings, shell_rc_path, upsert_managed_block};
pub(crate) use install::{doctor_findings, fish_config_dir, shell_rc_path, upsert_managed_block};
pub use managed_block::{MANAGED_BLOCK_END, MANAGED_BLOCK_START};
pub(crate) use managed_block::{ManagedBlockState, inspect_managed_block};

pub(crate) fn shell_command_names(primary: &str) -> [&str; 2] {
if primary == "nv" {
["nv", "navi"]
} else {
["navi", "nv"]
}
}
5 changes: 5 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ pub enum ShellKind {
Bash,
/// Zsh shell.
Zsh,
/// Fish shell.
Fish,
}

impl ShellKind {
Expand All @@ -130,6 +132,7 @@ impl ShellKind {
match value {
"bash" => Ok(Self::Bash),
"zsh" => Ok(Self::Zsh),
"fish" => Ok(Self::Fish),
other => Err(Error::UnsupportedShell(other.to_owned())),
}
}
Expand All @@ -154,6 +157,7 @@ impl ShellKind {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
}
}

Expand All @@ -163,6 +167,7 @@ impl ShellKind {
match self {
Self::Bash => ".bashrc",
Self::Zsh => ".zshrc",
Self::Fish => ".config/fish/functions/navi.fish",
}
}
}
Expand Down
52 changes: 48 additions & 4 deletions tests/config_shell_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ use common::command;
#[test]
fn config_shell_init_rejects_unsupported_shell() {
command("navi")
.args(["config", "shell", "init", "fish"])
.args(["config", "shell", "init", "pwsh"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid value 'fish'"))
.stderr(predicate::str::contains("[possible values: bash, zsh]"));
.stderr(predicate::str::contains("invalid value 'pwsh'"))
.stderr(predicate::str::contains(
"[possible values: bash, zsh, fish]",
));
}

#[test]
Expand All @@ -21,7 +23,9 @@ fn config_shell_init_missing_shell_mentions_supported_values() {
.assert()
.failure()
.stderr(predicate::str::contains("shell name required"))
.stderr(predicate::str::contains("hint: use one of: bash, zsh"));
.stderr(predicate::str::contains(
"hint: use one of: bash, zsh, fish",
));
}

#[test]
Expand Down Expand Up @@ -98,6 +102,46 @@ fn config_shell_install_creates_zshrc_managed_block() {
assert!(contents.contains("eval \"$(command navi config shell init zsh)\""));
}

#[test]
fn config_shell_init_fish_emits_function_wrappers() {
command("navi")
.args(["config", "shell", "init", "fish"])
.assert()
.success()
.stdout(predicates::str::contains("function navi"))
.stdout(predicates::str::contains("function nv"))
.stdout(predicates::str::contains(
"set -lx NAVI_DIRECTIVE_FILE $directive_file",
));
}

#[test]
fn config_shell_install_creates_fish_functions_and_completions() {
let home = tempfile::TempDir::new().expect("temp home");
let navi_func = home.path().join(".config/fish/functions/navi.fish");
let nv_func = home.path().join(".config/fish/functions/nv.fish");
let navi_comp = home.path().join(".config/fish/completions/navi.fish");
let nv_comp = home.path().join(".config/fish/completions/nv.fish");

command("navi")
.env("HOME", home.path())
Comment thread
aeons marked this conversation as resolved.
.args(["config", "shell", "install", "--shell", "fish"])
.assert()
.success();

let navi_function = std::fs::read_to_string(navi_func).expect("read navi function");
assert!(navi_function.contains("function navi"));

let nv_function = std::fs::read_to_string(nv_func).expect("read nv function");
assert!(nv_function.contains("function nv"));

let navi_completions = std::fs::read_to_string(navi_comp).expect("read navi completions");
assert!(navi_completions.contains("complete --keep-order --exclusive --command navi"));

let nv_completions = std::fs::read_to_string(nv_comp).expect("read nv completions");
assert!(nv_completions.contains("complete --keep-order --exclusive --command nv"));
}

#[test]
fn config_shell_install_updates_managed_block_in_place() {
let home = tempfile::TempDir::new().expect("temp home");
Expand Down