diff --git a/.gitmodules b/.gitmodules index 0c50500..8ad405e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,6 @@ [submodule "zsh-plugins/zsh-autosuggestions"] path = zsh-plugins/zsh-autosuggestions url = git@github.com:zsh-users/zsh-autosuggestions.git +[submodule "zsh-plugins/zsh-defer"] + path = zsh-plugins/zsh-defer + url = https://github.com/romkatv/zsh-defer.git diff --git a/CLAUDE.md b/CLAUDE.md index e89472b..78b8f7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,10 +11,13 @@ Personal Zsh configuration repository: shell environment, dotfiles, aliases, fun ### Loading Chain 1. `~/.zshrc` sources `load.zsh` (added by `install.sh`) 2. `load.zsh` orchestrates everything: - - Locale, zsh options, plugins (git submodules), theme + - Locale, zsh options, `path-ethic` plugin, theme (synchronous — needed at startup) - Sources all `include/` files: exports, aliases, functions, keybindings, completions, history, prompt - - **`zsh-syntax-highlighting` MUST be loaded LAST** (after all other plugins and keybindings) + - **Deferred plugins** (via `zsh-defer`, interactive sessions only): `zsh-history-substring-search`, `zsh-autosuggestions`, `zsh-syntax-highlighting` — these load after the prompt appears + - **Non-interactive fallback**: deferred plugins load synchronously when `[[ ! -o interactive ]]` (e.g., tests) + - **`zsh-syntax-highlighting` MUST be loaded LAST** (after all other plugins and keybindings, even when deferred) - **`fpath` must include `zsh-completions/src` BEFORE `autoload`** + - **History-substring-search keybindings** are deferred in `load.zsh` (not in `include/keybindings`) because they depend on the deferred plugin ### Key Directories - **`include/`** — Modular shell config files sourced by `load.zsh` @@ -27,10 +30,14 @@ Personal Zsh configuration repository: shell environment, dotfiles, aliases, fun ## Commands ```bash -./install.sh # Full setup: submodules, symlinks, dirs, .zshrc, neovim +./install.sh # Full setup: submodules, symlinks, dirs, .zshrc, neovim, zwc compilation make test # Run tests (also: ./tests/run_tests.sh) make update_submodules # Update all git submodules -reload! # Alias to re-source ~/.zshrc (use after editing include/ files) +make compile # Compile zsh files to .zwc bytecode +make clean_zwc # Remove all .zwc bytecode files +reload! # Alias: recompiles .zwc files, then re-sources ~/.zshrc +zsh_bench # Benchmark shell startup time (see scripts/zsh_bench) +zsh_bench -n 20 -o # 20 iterations, save results to .benchmarks/ ``` To run a single test file directly: `zsh tests/my_test.test.sh` (but prefer `make test` — the runner sets up the sandbox environment via `zsh-scriptest`). @@ -73,7 +80,13 @@ When modifying `include/aliases`: - Other env vars: `include/exports` ### Scripts as CLI Commands -Files in `scripts/` are on `$PATH` and act as standalone commands. Notable: `y` and `n` (yarn/npm wrappers), `docker_cleanup`, `git_config_hook`. New scripts placed here are automatically available as commands. +Files in `scripts/` are on `$PATH` and act as standalone commands. Notable: `y` and `n` (yarn/npm wrappers), `docker_cleanup`, `git_config_hook`, `zsh_bench` (startup benchmarking). New scripts placed here are automatically available as commands. + +### Performance +- **Startup time**: Optimized via `compinit` caching, `.zwc` bytecode compilation, and `zsh-defer` plugin deferral. Use `zsh_bench` to measure impact of changes. +- **compinit caching** (`include/completions`): The `.zcompdump` file is only regenerated when stale (>24h) or missing. `compinit -C` is used otherwise. +- **`.zwc` bytecode**: Zsh auto-prefers `file.zwc` over `file` when sourcing (if `.zwc` is newer). Stale `.zwc` files are harmless — zsh falls back to the source. `reload!` and `install.sh` recompile automatically. Some files (aliases, exports, functions) fail `zcompile` silently — this is expected. +- **`zsh-defer`**: Only used in interactive sessions. Keybindings that depend on deferred plugins must also be deferred — see `load.zsh` for the pattern. ### Cross-Platform Changes must work on both macOS and Linux. Use platform checks (`$OSTYPE`) when behavior differs (see `__profile_git_file_timestamp` for an example). diff --git a/Makefile b/Makefile index 6bbfd34..dc4e85a 100644 --- a/Makefile +++ b/Makefile @@ -5,3 +5,13 @@ test: update_submodules: git submodule update --recursive --remote + +compile: + @echo "Compiling zsh files to zwc bytecode..." + @zsh -c 'for f in $(BASEDIR)/load.zsh $(BASEDIR)/include/*(.) $(BASEDIR)/scripts/lib.zsh; do [[ $$f == *.zwc ]] && continue; zcompile $$f 2>/dev/null; done' + @echo "Done." + +clean_zwc: + @echo "Removing zwc bytecode files..." + @find $(BASEDIR) -name "*.zwc" -delete + @echo "Done." diff --git a/dotfiles/.gitignore_global b/dotfiles/.gitignore_global index 67fafae..90b1090 100644 --- a/dotfiles/.gitignore_global +++ b/dotfiles/.gitignore_global @@ -64,3 +64,7 @@ vendor/ *.log *.jfr +.benchmarks/ + +# Zsh bytecode compiled files +*.zwc diff --git a/include/aliases b/include/aliases index ddb5a20..f1e9741 100644 --- a/include/aliases +++ b/include/aliases @@ -2,7 +2,7 @@ # profile self update alias update_profile="git -C $SHA1N_PROFILE_HOME pull --recurse-submodules origin $(git -C $SHA1N_PROFILE_HOME branch --show-current)" -alias reload!='source $HOME/.zshrc' +alias reload!='for f in $SHA1N_PROFILE_HOME/load.zsh $SHA1N_PROFILE_HOME/include/*(.) $SHA1N_PROFILE_HOME/scripts/lib.zsh; do [[ $f == *.zwc ]] && continue; zcompile $f 2>/dev/null; done; source $HOME/.zshrc' # # directory shortcuts # diff --git a/include/completions b/include/completions index 5793d6d..ff3d059 100644 --- a/include/completions +++ b/include/completions @@ -6,5 +6,10 @@ zstyle ':completion:*:default' list-colors \ "di=1;36" "ln=1;35" "so=32" "pi=33" "ex=31" "bd=34;46" "cd=34;43" \ "su=30;41" "sg=30;46" "tw=30;42" "ow=30;43" -autoload -U compinit && compinit +autoload -U compinit +if [[ -n ~/.zcompdump(#qN.mh+24) ]] || [[ ! -f ~/.zcompdump ]]; then + compinit +else + compinit -C +fi autoload -U +X bashcompinit && bashcompinit diff --git a/include/keybindings b/include/keybindings index bc21df9..b0a02e0 100644 --- a/include/keybindings +++ b/include/keybindings @@ -1,8 +1,8 @@ #!/usr/bin/env zsh -# history-substring-search key bindings -bindkey "^[[A" history-substring-search-up -bindkey "^[[B" history-substring-search-down +# history-substring-search key bindings are deferred in load.zsh +# (they depend on the zsh-history-substring-search plugin which is also deferred) + # line navigation bindkey "^[[H" beginning-of-line bindkey "^[[F" end-of-line diff --git a/install.sh b/install.sh index 5d1f096..a86d157 100755 --- a/install.sh +++ b/install.sh @@ -103,4 +103,11 @@ validate_shell_rc_file && install_source_command setup_neovim +__profile_log_info "compiling zsh files to bytecode..." +for f in "$SHA1N_PROFILE_HOME"/load.zsh "$SHA1N_PROFILE_HOME"/include/*(.) "$SHA1N_PROFILE_HOME"/scripts/lib.zsh; do + [[ "$f" == *.zwc ]] && continue + zcompile "$f" 2>/dev/null +done +__profile_log_success "bytecode compilation complete" + __profile_log_info "done!" diff --git a/load.zsh b/load.zsh index 87c52c8..03b1d5d 100644 --- a/load.zsh +++ b/load.zsh @@ -12,8 +12,17 @@ export LC_CTYPE="en_US.UTF-8" # plugins fpath=($SHA1N_PROFILE_HOME/zsh-plugins/zsh-completions/src $fpath) source $SHA1N_PROFILE_HOME/zsh-plugins/path-ethic/path-ethic.plugin.zsh -source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-history-substring-search/zsh-history-substring-search.zsh -source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-autosuggestions/zsh-autosuggestions.zsh + +# zsh-defer for deferring non-critical interactive plugins +if [[ -o interactive ]]; then + source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-defer/zsh-defer.plugin.zsh + zsh-defer source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-history-substring-search/zsh-history-substring-search.zsh + zsh-defer source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-autosuggestions/zsh-autosuggestions.zsh +else + source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-history-substring-search/zsh-history-substring-search.zsh + source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-autosuggestions/zsh-autosuggestions.zsh +fi + # theme source $SHA1N_PROFILE_HOME/zsh-theme/agnoster-zsh-theme/agnoster.zsh-theme @@ -25,4 +34,15 @@ source $SHA1N_PROFILE_HOME/include/completions source $SHA1N_PROFILE_HOME/include/history source $SHA1N_PROFILE_HOME/include/prompt -source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh +# deferred keybindings that depend on history-substring-search +if [[ -o interactive ]]; then + zsh-defer bindkey "^[[A" history-substring-search-up + zsh-defer bindkey "^[[B" history-substring-search-down +fi + +# zsh-syntax-highlighting MUST be loaded LAST +if [[ -o interactive ]]; then + zsh-defer source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh +else + source $SHA1N_PROFILE_HOME/zsh-plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh +fi diff --git a/scripts/zsh_bench b/scripts/zsh_bench new file mode 100755 index 0000000..cd6eb3b --- /dev/null +++ b/scripts/zsh_bench @@ -0,0 +1,81 @@ +#!/usr/bin/env zsh + +# zsh_bench - Measure zsh interactive shell startup time +# +# Usage: zsh_bench [-n iterations] [-o] +# -n Number of iterations (default: 10) +# -o Save results to .benchmarks/ + +zmodload zsh/datetime + +local iterations=10 +local save=false +local script_dir="${${(%):-%x}:a:h}" +local repo_dir="${script_dir:h}" +local benchmarks_dir="$repo_dir/.benchmarks" + +while getopts "n:o" opt; do + case $opt in + n) iterations=$OPTARG ;; + o) save=true ;; + \?) echo "Usage: zsh_bench [-n iterations] [-o]" >&2; return 1 ;; + esac +done + +echo "Running $iterations iterations...\n" + +local -a times +for i in $(seq 1 $iterations); do + local start=$EPOCHREALTIME + zsh -i -c exit 2>/dev/null + local end=$EPOCHREALTIME + local elapsed=$(( (end - start) * 1000 )) + times+=($elapsed) + printf " #%2d %6.1f ms\n" "$i" "$elapsed" +done + +# Sort times +local -a sorted +sorted=(${(on)times}) + +local min=${sorted[1]} +local max=${sorted[-1]} +local median +local mid=$(( iterations / 2 )) +if (( iterations % 2 == 0 )); then + median=$(( (sorted[mid] + sorted[mid + 1]) / 2.0 )) +else + median=${sorted[mid + 1]} +fi + +local sum=0 +for t in "${times[@]}"; do + sum=$(( sum + t )) +done +local avg=$(( sum / iterations )) + +echo "" +echo "Results:" +echo " min: $(printf '%6.1f' $min) ms" +echo " max: $(printf '%6.1f' $max) ms" +echo " avg: $(printf '%6.1f' $avg) ms" +echo " median: $(printf '%6.1f' $median) ms" + +if $save; then + mkdir -p "$benchmarks_dir" + local timestamp=$(date +%Y%m%d_%H%M%S) + local outfile="$benchmarks_dir/${timestamp}.txt" + { + echo "zsh_bench — $iterations iterations — $(date)" + echo "" + for i in $(seq 1 $iterations); do + printf " #%2d %6.1f ms\n" "$i" "${times[$i]}" + done + echo "" + echo "min: $(printf '%6.1f' $min) ms" + echo "max: $(printf '%6.1f' $max) ms" + echo "avg: $(printf '%6.1f' $avg) ms" + echo "median: $(printf '%6.1f' $median) ms" + } > "$outfile" + echo "\nResults saved to $outfile" +fi diff --git a/zsh-plugins/zsh-defer b/zsh-plugins/zsh-defer new file mode 160000 index 0000000..53a26e2 --- /dev/null +++ b/zsh-plugins/zsh-defer @@ -0,0 +1 @@ +Subproject commit 53a26e287fbbe2dcebb3aa1801546c6de32416fa