#![allow(clippy::needless_raw_string_hashes)]
use crate::ShellType;
use crate::config::CompiledConfig;
use crate::plugin::PluginManager;
#[derive(Debug)]
pub struct ShellIntegration {
shell_type: ShellType,
config: CompiledConfig,
plugins: PluginManager,
}
impl ShellIntegration {
#[must_use]
pub fn new(shell_type: ShellType, config: CompiledConfig) -> Self {
let mut plugins = PluginManager::new();
for plugin_name in &config.plugins_enabled {
let _ = plugins.load(plugin_name);
}
Self {
shell_type,
config,
plugins,
}
}
#[must_use]
pub fn generate(&self) -> String {
let mut output = String::with_capacity(4096);
output.push_str(&self.generate_header());
output.push_str(&self.generate_env_exports());
output.push_str(&self.generate_aliases());
output.push_str(&self.generate_prompt());
output.push_str(&self.generate_completion_setup());
output.push_str(&self.generate_shell_options());
output.push_str(&self.generate_history_config());
output.push_str(&self.generate_keybindings());
output.push_str(&self.plugins.shell_init(self.shell_type));
output.push_str(&self.generate_footer());
output
}
fn generate_header(&self) -> String {
let shell_name = match self.shell_type {
ShellType::Zsh => "zsh",
ShellType::Bash => "bash",
};
format!(
"# pzsh shell integration for {shell_name}\n\
# Generated by pzsh v{}\n\
# Source with: eval \"$(pzsh init {shell_name})\"\n\n",
env!("CARGO_PKG_VERSION")
)
}
fn generate_env_exports(&self) -> String {
let mut output = String::from("# Environment variables\n");
for (key, value) in &self.config.env {
match self.shell_type {
ShellType::Zsh | ShellType::Bash => {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
output.push_str(&format!("export {key}=\"{escaped}\"\n"));
}
}
}
output.push('\n');
output
}
fn generate_aliases(&self) -> String {
let mut output = String::from("# Aliases\n");
for (name, expansion) in &self.config.aliases {
let escaped = expansion.replace('\'', "'\\''");
output.push_str(&format!("alias {name}='{escaped}'\n"));
}
for (name, expansion) in self.plugins.all_aliases() {
let escaped = expansion.replace('\'', "'\\''");
output.push_str(&format!("alias {name}='{escaped}'\n"));
}
output.push('\n');
output
}
fn generate_prompt(&self) -> String {
match self.shell_type {
ShellType::Zsh => self.generate_zsh_prompt(),
ShellType::Bash => self.generate_bash_prompt(),
}
}
fn generate_zsh_prompt(&self) -> String {
let colors_enabled = self.config.colors_enabled;
let mut output = String::from("# Prompt configuration\n");
output.push_str("setopt PROMPT_SUBST\n");
if colors_enabled {
output.push_str("autoload -U colors && colors\n\n");
output.push_str(
r#"# Git status (cached, fast)
__pzsh_git_info() {
local branch
branch=$(git symbolic-ref --short HEAD 2>/dev/null) || return
local dirty=""
[[ -n $(git status --porcelain 2>/dev/null) ]] && dirty="*"
if [[ -n "$dirty" ]]; then
echo "%F{yellow}($branch$dirty)%f"
else
echo "%F{green}($branch)%f"
fi
}
"#,
);
output.push_str("PROMPT='%F{green}%B%n%b%f@%F{blue}%B%m%b%f %F{cyan}%~%f $(__pzsh_git_info) %F{white}%B%#%b%f '\n");
} else {
output.push_str("PROMPT='%n@%m %~ %# '\n");
}
output.push('\n');
output
}
fn generate_bash_prompt(&self) -> String {
let colors_enabled = self.config.colors_enabled;
let mut output = String::from("# Prompt configuration\n");
if colors_enabled {
output.push_str(
r#"# Git status (cached, fast)
__pzsh_git_info() {
local branch
branch=$(git symbolic-ref --short HEAD 2>/dev/null) || return
local dirty=""
[[ -n $(git status --porcelain 2>/dev/null) ]] && dirty="*"
if [[ -n "$dirty" ]]; then
echo -e "\033[33m($branch$dirty)\033[0m"
else
echo -e "\033[32m($branch)\033[0m"
fi
}
"#,
);
output.push_str(r"PS1='\[\033[1;32m\]\u\[\033[0m\]@\[\033[1;34m\]\h\[\033[0m\] \[\033[36m\]\w\[\033[0m\] $(__pzsh_git_info) \[\033[1;37m\]\\$\[\033[0m\] '
");
} else {
output.push_str("PS1='\\u@\\h \\w \\$ '\n");
}
output.push('\n');
output
}
fn generate_completion_setup(&self) -> String {
match self.shell_type {
ShellType::Zsh => self.generate_zsh_completion(),
ShellType::Bash => self.generate_bash_completion(),
}
}
fn generate_zsh_completion(&self) -> String {
let colors_enabled = self.config.colors_enabled;
let mut output = String::from(
r#"# Completion system - oh-my-zsh compatible
# Ensure system completions are in fpath
[[ -d /usr/share/zsh/site-functions ]] && fpath=(/usr/share/zsh/site-functions $fpath)
[[ -d /usr/share/zsh/functions/Completion/Unix ]] && fpath=(/usr/share/zsh/functions/Completion/Unix $fpath)
[[ -d /usr/share/zsh/functions/Completion/Linux ]] && fpath=(/usr/share/zsh/functions/Completion/Linux $fpath)
[[ -d /usr/local/share/zsh/site-functions ]] && fpath=(/usr/local/share/zsh/site-functions $fpath)
[[ -d ~/.zsh/completions ]] && fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit
compinit -C -d "${ZDOTDIR:-$HOME}/.zcompdump"
# Load bashcompinit for bash completion compatibility
autoload -Uz bashcompinit && bashcompinit
# Completion styling (oh-my-zsh defaults)
zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' 'r:|=*' 'l:|=* r:|=*'
zstyle ':completion:*' special-dirs true
zstyle ':completion:*' squeeze-slashes true
"#,
);
if colors_enabled {
output.push_str(
r#"# Colored completions
zstyle ':completion:*' list-colors "${(s.:.)LS_COLORS}"
zstyle ':completion:*:descriptions' format '%F{yellow}-- %d --%f'
zstyle ':completion:*:corrections' format '%F{green}-- %d (errors: %e) --%f'
zstyle ':completion:*:messages' format '%F{purple}-- %d --%f'
zstyle ':completion:*:warnings' format '%F{red}-- no matches found --%f'
zstyle ':completion:*:*:kill:*:processes' list-colors '=(#b) #([0-9]#)*=0=01;31'
"#,
);
} else {
output.push_str(
r#"# Plain completions (no colors)
zstyle ':completion:*:descriptions' format '-- %d --'
zstyle ':completion:*:corrections' format '-- %d (errors: %e) --'
zstyle ':completion:*:messages' format '-- %d --'
zstyle ':completion:*:warnings' format '-- no matches found --'
"#,
);
}
output.push_str(
r#"zstyle ':completion:*' group-name ''
# Fuzzy matching (typo tolerance)
zstyle ':completion:*' completer _complete _match _approximate
zstyle ':completion:*:match:*' original only
zstyle ':completion:*:approximate:*' max-errors 2 numeric
# Process completion
zstyle ':completion:*:*:kill:*' menu yes select
zstyle ':completion:*:kill:*' force-list always
# Git completion enhancements
zstyle ':completion:*:*:git:*' script ~/.zsh/completions/_git 2>/dev/null
zstyle ':completion:*:git-checkout:*' sort false
zstyle ':completion:*:git:*' tag-order 'common-commands' 'all-commands'
# Directory completion
zstyle ':completion:*' list-dirs-first true
zstyle ':completion:*:cd:*' ignore-parents parent pwd
# SSH/SCP/RSYNC completion from known_hosts
zstyle ':completion:*:(ssh|scp|rsync):*' hosts $(
cat ~/.ssh/known_hosts 2>/dev/null | grep -v '^[|#]' | cut -d' ' -f1 | cut -d',' -f1 | sort -u
)
# Caching for expensive completions
zstyle ':completion:*' use-cache on
zstyle ':completion:*' cache-path "${ZDOTDIR:-$HOME}/.zcompcache"
"#,
);
output
}
fn generate_bash_completion(&self) -> String {
let colors_enabled = self.config.colors_enabled;
let mut output = String::from(
r#"# Completion system - oh-my-zsh compatible
if [[ -f /etc/bash_completion ]]; then
. /etc/bash_completion
elif [[ -f /usr/share/bash-completion/bash_completion ]]; then
. /usr/share/bash-completion/bash_completion
elif [[ -f /usr/local/etc/bash_completion ]]; then
. /usr/local/etc/bash_completion
fi
# Homebrew completions (macOS)
if type brew &>/dev/null && [[ -f "$(brew --prefix)/etc/bash_completion" ]]; then
. "$(brew --prefix)/etc/bash_completion"
fi
# Completion settings (oh-my-zsh style)
bind 'set completion-ignore-case on' # Case-insensitive
bind 'set completion-map-case on' # Treat - and _ as equivalent
bind 'set show-all-if-ambiguous on' # Show completions on first tab
bind 'set show-all-if-unmodified on' # Show completions if no partial completion
bind 'set mark-symlinked-directories on' # Add trailing slash to symlinked dirs
bind 'set completion-prefix-display-length 3' # Show common prefix length
"#,
);
if colors_enabled {
output.push_str(
r#"# Colored completions
bind 'set colored-stats on' # Color files by type
bind 'set colored-completion-prefix on' # Color common prefix
bind 'set visible-stats on' # Show file type indicators
"#,
);
}
output.push_str(
r#"# Menu completion (like zsh)
bind 'set menu-complete-display-prefix on'
bind 'TAB:menu-complete' # Tab cycles through completions
bind '"\e[Z": menu-complete-backward' # Shift-Tab cycles backward
"#,
);
output
}
fn generate_shell_options(&self) -> String {
match self.shell_type {
ShellType::Zsh => r#"# Shell options (oh-my-zsh defaults)
setopt AUTO_CD # cd by typing directory name
setopt AUTO_PUSHD # Push dirs to stack automatically
setopt PUSHD_IGNORE_DUPS # No duplicates in dir stack
setopt PUSHD_SILENT # Don't print dir stack
setopt CORRECT # Command spell correction
setopt CDABLE_VARS # cd to named directories
setopt EXTENDED_GLOB # Extended globbing (#, ~, ^)
setopt NO_CASE_GLOB # Case-insensitive globbing
setopt NUMERIC_GLOB_SORT # Sort numerically when globbing
setopt NO_BEEP # No beep on error
setopt INTERACTIVE_COMMENTS # Allow comments in interactive shell
setopt MULTIOS # Multiple redirections
setopt NO_FLOW_CONTROL # Disable Ctrl-S/Ctrl-Q flow control
"#
.to_string(),
ShellType::Bash => r#"# Shell options
shopt -s autocd # cd by typing directory name
shopt -s cdspell # Autocorrect cd typos
shopt -s dirspell # Autocorrect directory typos
shopt -s globstar # ** recursive glob
shopt -s nocaseglob # Case-insensitive globbing
shopt -s dotglob # Include dotfiles in globbing
shopt -s extglob # Extended pattern matching
"#
.to_string(),
}
}
fn generate_history_config(&self) -> String {
match self.shell_type {
ShellType::Zsh => r#"# History configuration
HISTSIZE=50000
SAVEHIST=50000
HISTFILE="${ZDOTDIR:-$HOME}/.zsh_history"
setopt EXTENDED_HISTORY
setopt HIST_EXPIRE_DUPS_FIRST
setopt HIST_IGNORE_DUPS
setopt HIST_IGNORE_SPACE
setopt HIST_VERIFY
setopt SHARE_HISTORY
setopt INC_APPEND_HISTORY
"#
.to_string(),
ShellType::Bash => r#"# History configuration
HISTSIZE=50000
HISTFILESIZE=100000
HISTCONTROL=ignoreboth:erasedups
shopt -s histappend
PROMPT_COMMAND="history -a;$PROMPT_COMMAND"
"#
.to_string(),
}
}
fn generate_keybindings(&self) -> String {
match self.shell_type {
ShellType::Zsh => r"# Key bindings
bindkey -e # Emacs mode
# Up/Down arrow: search history based on what's typed (oh-my-zsh style)
autoload -U up-line-or-beginning-search down-line-or-beginning-search
zle -N up-line-or-beginning-search
zle -N down-line-or-beginning-search
bindkey '^[[A' up-line-or-beginning-search # Up arrow
bindkey '^[[B' down-line-or-beginning-search # Down arrow
bindkey '^[OA' up-line-or-beginning-search # Up arrow (alternate)
bindkey '^[OB' down-line-or-beginning-search # Down arrow (alternate)
# Search
bindkey '^R' history-incremental-search-backward
bindkey '^S' history-incremental-search-forward
bindkey '^P' up-line-or-beginning-search # Ctrl-P
bindkey '^N' down-line-or-beginning-search # Ctrl-N
# Word navigation
bindkey '^[[1;5C' forward-word # Ctrl+Right
bindkey '^[[1;5D' backward-word # Ctrl+Left
bindkey '^[f' forward-word # Alt+f
bindkey '^[b' backward-word # Alt+b
# Line navigation
bindkey '^[[H' beginning-of-line # Home
bindkey '^[[F' end-of-line # End
bindkey '^A' beginning-of-line # Ctrl+A
bindkey '^E' end-of-line # Ctrl+E
# Editing
bindkey '^[[3~' delete-char # Delete
bindkey '^?' backward-delete-char # Backspace
bindkey '^W' backward-kill-word # Ctrl+W
bindkey '^U' backward-kill-line # Ctrl+U
bindkey '^K' kill-line # Ctrl+K
bindkey '^Y' yank # Ctrl+Y
"
.to_string(),
ShellType::Bash => r#"# Key bindings
# History search with up/down arrows (oh-my-zsh style)
bind '"\e[A": history-search-backward' # Up arrow
bind '"\e[B": history-search-forward' # Down arrow
bind '"\eOA": history-search-backward' # Up arrow (alternate)
bind '"\eOB": history-search-forward' # Down arrow (alternate)
# Search
bind '"\C-r": reverse-search-history'
bind '"\C-s": forward-search-history'
bind '"\C-p": history-search-backward' # Ctrl-P
bind '"\C-n": history-search-forward' # Ctrl-N
# Word navigation
bind '"\e[1;5C": forward-word' # Ctrl+Right
bind '"\e[1;5D": backward-word' # Ctrl+Left
bind '"\ef": forward-word' # Alt+f
bind '"\eb": backward-word' # Alt+b
# Line navigation
bind '"\e[H": beginning-of-line' # Home
bind '"\e[F": end-of-line' # End
bind '"\C-a": beginning-of-line' # Ctrl+A
bind '"\C-e": end-of-line' # Ctrl+E
# Editing
bind '"\e[3~": delete-char' # Delete
bind '"\C-d": delete-char' # Ctrl+D
bind '"\C-h": backward-delete-char' # Ctrl+H (backspace)
bind '"\C-w": backward-kill-word' # Ctrl+W
bind '"\C-u": unix-line-discard' # Ctrl+U
bind '"\C-k": kill-line' # Ctrl+K
bind '"\C-y": yank' # Ctrl+Y
"#
.to_string(),
}
}
#[allow(clippy::unused_self)]
fn generate_footer(&self) -> String {
"# pzsh loaded in <10ms\n\
# Run 'pzsh status' to verify\n"
.to_string()
}
}
#[must_use]
pub fn generate_init(shell_type: ShellType, config: CompiledConfig) -> String {
ShellIntegration::new(shell_type, config).generate()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> CompiledConfig {
let mut config = CompiledConfig::default();
config.colors_enabled = true;
config
.aliases
.insert("ll".to_string(), "ls -la".to_string());
config
.aliases
.insert("gs".to_string(), "git status".to_string());
config.env.insert("EDITOR".to_string(), "vim".to_string());
config.plugins_enabled = vec!["git".to_string()];
config
}
#[test]
fn test_zsh_init_contains_header() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("pzsh shell integration for zsh"));
assert!(output.contains("eval \"$(pzsh init zsh)\""));
}
#[test]
fn test_zsh_init_exports_env_vars() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("export EDITOR=\"vim\""));
}
#[test]
fn test_zsh_init_defines_aliases() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("alias ll='ls -la'"));
assert!(output.contains("alias gs='git status'"));
}
#[test]
fn test_zsh_init_includes_plugin_aliases() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("alias g='git'"));
}
#[test]
fn test_zsh_init_sets_colored_prompt() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("PROMPT="));
assert!(output.contains("%F{green}"));
assert!(output.contains("__pzsh_git_info"));
}
#[test]
fn test_zsh_init_configures_completion() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("autoload -Uz compinit"));
assert!(output.contains("compinit -C"));
assert!(output.contains("zstyle ':completion:*'"));
}
#[test]
fn test_zsh_init_configures_history() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("HISTSIZE=50000"));
assert!(output.contains("SAVEHIST=50000"));
assert!(output.contains("setopt SHARE_HISTORY"));
}
#[test]
fn test_zsh_init_sets_keybindings() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("bindkey -e"));
assert!(output.contains("up-line-or-beginning-search"));
assert!(output.contains("'^R'"));
}
#[test]
fn test_zsh_init_no_colors() {
let mut config = test_config();
config.colors_enabled = false;
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("PROMPT='%n@%m %~ %# '"));
assert!(!output.contains("%F{green}"));
}
#[test]
fn test_bash_init_contains_header() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("pzsh shell integration for bash"));
}
#[test]
fn test_bash_init_exports_env_vars() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("export EDITOR=\"vim\""));
}
#[test]
fn test_bash_init_defines_aliases() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("alias ll='ls -la'"));
}
#[test]
fn test_bash_init_sets_colored_prompt() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("PS1="));
assert!(output.contains("\\033["));
assert!(output.contains("__pzsh_git_info"));
}
#[test]
fn test_bash_init_configures_history() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("HISTSIZE=50000"));
assert!(output.contains("HISTCONTROL=ignoreboth"));
assert!(output.contains("shopt -s histappend"));
}
#[test]
fn test_bash_init_sets_keybindings() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("history-search-backward"));
assert!(output.contains("history-search-forward"));
assert!(output.contains("reverse-search-history"));
assert!(output.contains("forward-word"));
assert!(output.contains("backward-word"));
assert!(output.contains("beginning-of-line"));
assert!(output.contains("end-of-line"));
assert!(output.contains("delete-char"));
assert!(output.contains("backward-kill-word"));
assert!(output.contains("kill-line"));
assert!(output.contains("yank"));
}
#[test]
fn test_bash_init_configures_completion() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("bash_completion") || output.contains("bash-completion"));
assert!(output.contains("completion-ignore-case"));
assert!(output.contains("completion-map-case"));
assert!(output.contains("show-all-if-ambiguous"));
assert!(output.contains("colored-stats"));
assert!(output.contains("colored-completion-prefix"));
assert!(output.contains("menu-complete"));
}
#[test]
fn test_bash_init_configures_shell_options() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("shopt -s autocd"));
assert!(output.contains("shopt -s cdspell"));
assert!(output.contains("shopt -s globstar"));
assert!(output.contains("shopt -s nocaseglob"));
assert!(output.contains("shopt -s extglob"));
}
#[test]
fn test_bash_init_no_colors() {
let mut config = test_config();
config.colors_enabled = false;
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("PS1='\\u@\\h \\w \\$ '"));
assert!(!output.contains("\\033[32m"));
}
#[test]
fn test_bash_init_includes_plugin_aliases() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("alias g='git'"));
}
#[test]
fn test_bash_completion_homebrew_support() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("brew --prefix") || output.contains("Homebrew"));
}
#[test]
fn test_alias_with_quotes_escaped() {
let mut config = CompiledConfig::default();
config
.aliases
.insert("test".to_string(), "echo 'hello world'".to_string());
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("alias test='echo '\\''hello world'\\'''"));
}
#[test]
fn test_env_with_quotes_escaped() {
let mut config = CompiledConfig::default();
config
.env
.insert("MSG".to_string(), "say \"hello\"".to_string());
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("export MSG=\"say \\\"hello\\\"\""));
}
#[test]
fn test_generation_is_fast() {
let config = test_config();
let start = std::time::Instant::now();
for _ in 0..1000 {
let _ = generate_init(ShellType::Zsh, config.clone());
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(100),
"Generation too slow: {:?}",
elapsed
);
}
#[test]
fn test_output_size_reasonable() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(
output.len() < 10240,
"Output too large: {} bytes",
output.len()
);
}
#[test]
fn test_output_is_valid_shell_syntax() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
let open_braces: Vec<_> = output.match_indices("${").collect();
for (pos, _) in open_braces {
let rest = &output[pos..];
assert!(rest.contains('}'), "Unclosed ${{ at position {pos}");
}
assert!(!output.contains(";;"), "Should not have empty commands");
}
#[test]
fn test_deterministic_output() {
let config = test_config();
let output1 = generate_init(ShellType::Zsh, config.clone());
let output2 = generate_init(ShellType::Zsh, config);
assert!(output1.contains("# pzsh shell integration"));
assert!(output2.contains("# pzsh shell integration"));
}
}