diff --git a/README.md b/README.md index a01edf1..ca52db0 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ Better host completion for ssh in Zsh. [![asciicast](https://asciinema.org/a/381405.svg)](https://asciinema.org/a/381405) - [zsh-ssh](#zsh-ssh) - - [Installation](#installation) - - [Zinit](#zinit) - - [Antigen](#antigen) - - [Oh My Zsh](#oh-my-zsh) - - [Sheldon](#sheldon) - - [Manual (Git Clone)](#manual-git-clone) - - [Usage](#usage) - - [SSH Config Example](#ssh-config-example) + - [Installation](#installation) + - [Zinit](#zinit) + - [Antigen](#antigen) + - [Oh My Zsh](#oh-my-zsh) + - [Sheldon](#sheldon) + - [Manual (Git Clone)](#manual-git-clone) + - [Usage](#usage) + - [SSH Config Example](#ssh-config-example) ## Installation @@ -34,15 +34,15 @@ antigen bundle sunlei/zsh-ssh 1. Clone this repository into `$ZSH_CUSTOM/plugins` (by default `~/.oh-my-zsh/custom/plugins`) - ```shell - git clone https://github.com/sunlei/zsh-ssh ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-ssh - ``` + ```shell + git clone https://github.com/sunlei/zsh-ssh ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-ssh + ``` 2. Add the plugin to the list of plugins for Oh My Zsh to load (inside `~/.zshrc`): - ```shell - plugins=(zsh-ssh $plugins) - ``` + ```shell + plugins=(zsh-ssh $plugins) + ``` 3. Start a new terminal session. @@ -50,10 +50,10 @@ antigen bundle sunlei/zsh-ssh 1. Add this config to `~/.config/sheldon/plugins.toml` - ```toml - [plugins.zsh-ssh] - github = 'sunlei/zsh-ssh' - ``` + ```toml + [plugins.zsh-ssh] + github = 'sunlei/zsh-ssh' + ``` 2. Run `sheldon lock` to install the plugin. @@ -63,15 +63,15 @@ antigen bundle sunlei/zsh-ssh 1. Clone this repository somewhere on your machine. For example: `~/.zsh/zsh-ssh`. - ```shell - git clone https://github.com/sunlei/zsh-ssh ~/.zsh/zsh-ssh - ``` + ```shell + git clone https://github.com/sunlei/zsh-ssh ~/.zsh/zsh-ssh + ``` 2. Add the following to your `.zshrc`: - ```shell - source ~/.zsh/zsh-ssh/zsh-ssh.zsh - ``` + ```shell + source ~/.zsh/zsh-ssh/zsh-ssh.zsh + ``` 3. Start a new terminal session. @@ -95,3 +95,17 @@ Host Development-Host IdentityFile ~/.ssh/development-host #_Desc For Development ``` + +Include files are also supported. For example, your main config can include separate files: + +~/.ssh/config + +```text +Include ~/.ssh/config.d/company.ssh_config +Include ~/.ssh/config.d/home.ssh_config +Include ~/.ssh/config.d/work.ssh_config + +# OR + +Include ~/.ssh/config.d/*.ssh_config +``` diff --git a/zsh-ssh.zsh b/zsh-ssh.zsh index c402950..134d408 100644 --- a/zsh-ssh.zsh +++ b/zsh-ssh.zsh @@ -9,6 +9,8 @@ setopt no_beep # don't beep zstyle ':completion:*:ssh:*' hosts off # disable built-in hosts completion SSH_CONFIG_FILE="${SSH_CONFIG_FILE:-$HOME/.ssh/config}" +typeset -gi _zsh_ssh_parse_depth=0 +typeset -gA _zsh_ssh_seen_config_files # Parse the file and handle the include directive. _parse_config_file() { @@ -16,56 +18,83 @@ _parse_config_file() { setopt localoptions rematchpcre unsetopt nomatch + local input_path="$1" + local config_file_path + local line raw_path expanded include_file_path + local -a include_paths + # Resolve the full path of the input config file - local config_file_path=$(realpath "$1") - - # Read the file line by line - while IFS= read -r line || [[ -n "$line" ]]; do - # Match lines starting with 'Include' - if [[ $line =~ ^[Ii]nclude[[:space:]=]+(.*) ]] && (( $#match > 0 )); then - # Split the rest of the line into individual paths - local include_paths=(${(z)match[1]}) - - for raw_path in "${include_paths[@]}"; do - # Expand ~ and environment variables in the path - eval "local expanded=\${(e)raw_path}" - # local expanded="${raw_path/#~/$HOME}" - - # If path is relative, resolve it relative to the current config file - if [[ "$expanded" != /* ]]; then - if [[ "$expanded" == ~* ]]; then + config_file_path=$(realpath "$input_path" 2>/dev/null) || return 0 + + # If previous parse was interrupted, reset stale global state. + if (( _zsh_ssh_parse_depth <= 0 )); then + _zsh_ssh_parse_depth=0 + unset _zsh_ssh_seen_config_files + typeset -gA _zsh_ssh_seen_config_files + fi + + (( _zsh_ssh_parse_depth++ )) + { + if [[ -n "${_zsh_ssh_seen_config_files[$config_file_path]}" ]]; then + return 0 + fi + _zsh_ssh_seen_config_files[$config_file_path]=1 + + # Read the file line by line + while IFS= read -r line || [[ -n "$line" ]]; do + # Match lines starting with 'Include' + if [[ $line =~ ^[[:space:]]*[Ii][Nn][Cc][Ll][Uu][Dd][Ee][[:space:]=]+(.*) ]] && (( $#match > 0 )); then + # Split the rest of the line into individual paths + include_paths=(${(z)match[1]}) + + for raw_path in "${include_paths[@]}"; do + # Expand ~ and environment variables in the path + expanded="${(e)raw_path}" + + # Expand a literal leading ~ before testing whether the path is relative. + if [[ $expanded == '~'* ]]; then expanded="${expanded/#\~/$HOME}" - else - expanded="$(dirname "$config_file_path")/$expanded" fi - fi - # Expand wildcards (e.g. *.conf) and loop over each matched file - for include_file_path in $~expanded; do - if [[ -f "$include_file_path" ]]; then - # Separate includes with a blank line (for readability) - echo "" - # Recursively parse included files - _parse_config_file "$include_file_path" + # If path is relative, resolve it relative to the current config file + if [[ "$expanded" != /* ]]; then + expanded="$(dirname "$config_file_path")/$expanded" fi + + # Expand wildcards (e.g. *.conf) and loop over each matched file + for include_file_path in ${~expanded}(N); do + if [[ -f "$include_file_path" ]]; then + # Separate includes with a blank line (for readability) + echo "" + # Recursively parse included files + _parse_config_file "$include_file_path" + fi + done done - done - else - # Print normal (non-Include) lines - echo "$line" + else + # Print normal (non-Include) lines + echo "$line" + fi + done < "$config_file_path" + } always { + (( _zsh_ssh_parse_depth-- )) + if (( _zsh_ssh_parse_depth <= 0 )); then + _zsh_ssh_parse_depth=0 + unset _zsh_ssh_seen_config_files + typeset -gA _zsh_ssh_seen_config_files fi - done < "$config_file_path" + } } _ssh_host_list() { local ssh_config host_list - ssh_config=$(_parse_config_file $SSH_CONFIG_FILE) - ssh_config=$(echo $ssh_config | command grep -v -E "^\s*#[^_]") + ssh_config=$(_parse_config_file "$SSH_CONFIG_FILE") + ssh_config=$(printf "%s\n" "$ssh_config" | command grep -v -E "^\s*#[^_]") # Ensure blank line before each Host/Match block for AWK paragraph mode (RS="") - ssh_config=$(echo $ssh_config | command awk '/^[[:space:]]*[Hh]ost[[:space:]]|^[[:space:]]*[Mm]atch[[:space:]]/{print ""} {print}') + ssh_config=$(printf "%s\n" "$ssh_config" | command awk '/^[[:space:]]*[Hh]ost[[:space:]]|^[[:space:]]*[Mm]atch[[:space:]]/{print ""} {print}') - host_list=$(echo $ssh_config | command awk ' + host_list=$(printf "%s\n" "$ssh_config" | command awk ' function join(array, start, end, sep, result, i) { # https://www.gnu.org/software/gawk/manual/html_node/Join-Function.html if (sep == "") @@ -183,7 +212,7 @@ _ssh_host_list() { fi host_list=$(printf "%s\n" "$host_list" | command sort -u) - echo $host_list + printf "%s\n" "$host_list" } @@ -201,9 +230,9 @@ Alias|->|Hostname|User|Desc ─────|──|────────|────|──── " - host_list="${header}\n${host_list}" + host_list="${header}"$'\n'"${host_list}" - echo $host_list | command column -t -s '|' + printf "%s\n" "$host_list" | command column -t -s '|' } _set_lbuffer() {