diff --git a/async.zsh b/async.zsh index 6dee111..2cb7f83 100644 --- a/async.zsh +++ b/async.zsh @@ -15,12 +15,23 @@ typeset -g ASYNC_DEBUG=${ASYNC_DEBUG:-0} # Execute commands that can manipulate the environment inside the async worker. Return output via callback. _async_eval() { local ASYNC_JOB_NAME - # Rename job to _async_eval and redirect all eval output to cat running - # in _async_job. Here, stdout and stderr are not separated for - # simplicity, this could be improved in the future. - { - eval "$@" - } &> >(ASYNC_JOB_NAME=[async/eval] _async_job 'command -p cat') + local ret tmp + + if ! zmodload -F zsh/files b:zf_rm 2>/dev/null; then + ASYNC_JOB_NAME=[async/eval] _async_job "return 1" + return + fi + + if ! tmp=$(command mktemp "${TMPDIR:-/tmp}/zsh-async-eval.XXXXXXXXXX" 2>/dev/null); then + ASYNC_JOB_NAME=[async/eval] _async_job "return 1" + return + fi + + eval "$@" >| "$tmp" 2>&1 + ret=$? + + ASYNC_JOB_NAME=[async/eval] _async_job "command -p cat ${(q)tmp}; return $ret" + zf_rm -f -- "$tmp" } # Wrapper for jobs executed by the async worker, gives output in parseable format with execution time diff --git a/pure.zsh b/pure.zsh index 6df6b98..29ccfce 100644 --- a/pure.zsh +++ b/pure.zsh @@ -205,19 +205,36 @@ prompt_pure_preprompt_render() { prompt_pure_precustom fi - # Expand the prompt for future comparison. - local expanded_prompt - expanded_prompt="${(S%%)PROMPT}" + # Build a fingerprint from all dynamic prompt components to detect changes + # without expanding PROMPT (which forks a subshell when dimmed path is on). + local -a prompt_fingerprint_parts=( + "${psvar[12]}" + "${psvar[13]}" + "${psvar[14]}" + "${psvar[15]}" + "${psvar[16]}" + "${psvar[17]}" + "${psvar[18]}" + "${psvar[19]}" + "${psvar[20]}" + "${psvar[21]}" + "${psvar[22]}" + "${psvar[23]}" + "${prompt_pure_state[prompt]}" + "${prompt_pure_git_branch_color}" + "${PWD}" + ) + local prompt_fingerprint="${(pj:|:)${(@qqq)prompt_fingerprint_parts}}" if [[ $1 == precmd ]]; then # Initial newline, for spaciousness. print - elif [[ $prompt_pure_last_prompt != $expanded_prompt ]]; then + elif [[ $prompt_pure_last_prompt != $prompt_fingerprint ]]; then # Redraw the prompt. prompt_pure_reset_prompt fi - typeset -g prompt_pure_last_prompt=$expanded_prompt + typeset -g prompt_pure_last_prompt=$prompt_fingerprint } prompt_pure_precmd() { @@ -450,7 +467,7 @@ prompt_pure_async_git_arrows() { } prompt_pure_async_git_stash() { - git rev-list --walk-reflogs --count refs/stash + command git rev-list --walk-reflogs --count refs/stash } prompt_pure_check_node_version() { @@ -486,8 +503,37 @@ prompt_pure_async_renice() { fi } +prompt_pure_async_worker_sync() { + setopt localoptions noshwordsplit + + local sync_token=$1 target_pwd=$2 has_git_dir=$3 git_dir=$4 has_git_work_tree=$5 git_work_tree=$6 + + if ! builtin cd -q "$target_pwd"; then + builtin cd -q / + unset GIT_DIR GIT_WORK_TREE + print -r -- "prompt_pure_worker_sync:$sync_token:1" + return 1 + fi + + if (( has_git_dir )); then + export GIT_DIR=$git_dir + else + unset GIT_DIR + fi + + if (( has_git_work_tree )); then + export GIT_WORK_TREE=$git_work_tree + else + unset GIT_WORK_TREE + fi + + print -r -- "prompt_pure_worker_sync:$sync_token:0" +} + prompt_pure_clear_git_state() { unset prompt_pure_git_dirty prompt_pure_git_last_dirty_check_timestamp prompt_pure_git_arrows prompt_pure_git_stash prompt_pure_git_fetch_pattern + typeset -gA prompt_pure_worker_env=() + typeset -gA prompt_pure_worker_env_pending=() typeset -gA prompt_pure_vcs_info prompt_pure_vcs_info[branch]= prompt_pure_vcs_info[top]= @@ -545,15 +591,35 @@ prompt_pure_async_tasks() { return fi - # Update the current working directory of the async worker. - # If any call fails (dead worker), bail out. The callback's recovery - # mechanism handles restarting; continuing here would print errors - # to stderr because the callback gets unregistered during recovery. - async_worker_eval "prompt_pure" builtin cd -q $PWD || return - - # Sync git environment variables to the async worker. - async_worker_eval "prompt_pure" "${${GIT_DIR:+export GIT_DIR=${(q)GIT_DIR}}:-unset GIT_DIR}" || return - async_worker_eval "prompt_pure" "${${GIT_WORK_TREE:+export GIT_WORK_TREE=${(q)GIT_WORK_TREE}}:-unset GIT_WORK_TREE}" || return + # Sync working directory and git environment variables to the async worker. + # Skip if nothing changed since last sync (common case: running commands in same dir). + # Uses an associative array to avoid scalar globals triggering AUTO_NAME_DIRS. + typeset -gA prompt_pure_worker_env + typeset -gA prompt_pure_worker_env_pending + local cur_git_dir=${GIT_DIR-__unset__} + local cur_git_work_tree=${GIT_WORK_TREE-__unset__} + if [[ $PWD != ${prompt_pure_worker_env[pwd]-} || + $cur_git_dir != ${prompt_pure_worker_env[git_dir]-} || + $cur_git_work_tree != ${prompt_pure_worker_env[git_work_tree]-} ]]; then + (( ${#prompt_pure_worker_env_pending} )) && return + prompt_pure_clear_git_state + async_flush_jobs "prompt_pure" + typeset -gi prompt_pure_worker_sync_token + (( prompt_pure_worker_sync_token++ )) + local sync_token=$prompt_pure_worker_sync_token + prompt_pure_worker_env_pending[pwd]=$PWD + prompt_pure_worker_env_pending[git_dir]=$cur_git_dir + prompt_pure_worker_env_pending[git_work_tree]=$cur_git_work_tree + prompt_pure_worker_env_pending[token]=$sync_token + async_worker_eval "prompt_pure" \ + prompt_pure_async_worker_sync $sync_token "$PWD" ${+GIT_DIR} "${GIT_DIR-}" ${+GIT_WORK_TREE} "${GIT_WORK_TREE-}" || { + if [[ ${prompt_pure_worker_env_pending[token]-} == $sync_token ]]; then + typeset -gA prompt_pure_worker_env_pending=() + fi + return + } + return + fi typeset -gA prompt_pure_vcs_info @@ -640,6 +706,12 @@ prompt_pure_async_callback() { return fi + case $job in + prompt_pure_async_vcs_info|prompt_pure_async_git_aliases|prompt_pure_async_git_dirty|prompt_pure_async_git_fetch|prompt_pure_async_git_arrows|prompt_pure_async_git_stash) + [[ ${prompt_pure_worker_env[pwd]-} == $PWD ]] || return + ;; + esac + case $job in \[async]) # Handle all the errors that could indicate a crashed @@ -651,6 +723,8 @@ prompt_pure_async_callback() { # and defer the restart? typeset -g prompt_pure_async_inited=0 async_stop_worker prompt_pure + typeset -gA prompt_pure_worker_env=() + typeset -gA prompt_pure_worker_env_pending=() if prompt_pure_async_init; then prompt_pure_async_tasks # Restart all tasks. else @@ -664,10 +738,34 @@ prompt_pure_async_callback() { fi ;; \[async/eval]) - if (( code )); then + typeset -gA prompt_pure_worker_env_pending + local worker_sync_output=${(M)${(f)output}:#prompt_pure_worker_sync:*} + local -a worker_sync_result + worker_sync_result=("${(@s.:.)worker_sync_output}") + if [[ -n $worker_sync_output ]] && + (( ${#prompt_pure_worker_env_pending} )); then + [[ $worker_sync_result[2] == ${prompt_pure_worker_env_pending[token]-} ]] || return + local worker_sync_status=$worker_sync_result[3] + if (( worker_sync_status )); then + prompt_pure_clear_git_state + do_render=1 + next_pending=0 + else + typeset -gA prompt_pure_worker_env + prompt_pure_worker_env[pwd]=$prompt_pure_worker_env_pending[pwd] + prompt_pure_worker_env[git_dir]=$prompt_pure_worker_env_pending[git_dir] + prompt_pure_worker_env[git_work_tree]=$prompt_pure_worker_env_pending[git_work_tree] + typeset -gA prompt_pure_worker_env_pending=() + prompt_pure_async_tasks + fi + elif (( code )); then # Looks like async_worker_eval failed, # rerun async tasks just in case. - prompt_pure_async_tasks + typeset -gA prompt_pure_worker_env=() + typeset -gA prompt_pure_worker_env_pending=() + prompt_pure_clear_git_state + do_render=1 + next_pending=0 fi ;; prompt_pure_async_vcs_info) diff --git a/tests/async-worker-failure.zsh b/tests/async-worker-failure.zsh index 60467da..7f4777a 100644 --- a/tests/async-worker-failure.zsh +++ b/tests/async-worker-failure.zsh @@ -2,10 +2,19 @@ source "${0:A:h}/test-helper.zsh" set +e +zmodload -F zsh/files b:zf_rm b:zf_mkdir main() { test_no_infinite_recursion_on_worker_failure || return test_worker_startup_failure_clears_git_state || return + test_worker_sync_clears_stale_git_state_before_returning || return + test_worker_sync_cd_failure_is_not_cached || return + test_worker_sync_success_queues_git_job || return + test_worker_sync_eval_failure_clears_pending_state || return + test_worker_sync_records_pending_before_eval || return + test_worker_sync_enqueue_failure_preserves_new_pending_state || return + test_async_eval_setup_failure_reports_callback || return + test_real_worker_sync_reports_encoded_status || return test_callback_no_recursion_on_worker_failure || return test_callback_failed_recovery_clears_git_state || return test_callback_recovery_calls_tasks_on_success || return @@ -14,6 +23,20 @@ main() { print -- "async-worker-failure tests passed" } +assert_git_state_empty() { + local message=$1 + + assert_empty "${prompt_pure_vcs_info[branch]-}" "branch should be cleared $message" || return + assert_empty "${prompt_pure_vcs_info[top]-}" "top-level should be cleared $message" || return + assert_empty "${prompt_pure_vcs_info[action]-}" "action should be cleared $message" || return + assert_empty "${prompt_pure_vcs_info[pwd]-}" "pwd should be cleared $message" || return + assert_empty "${prompt_pure_git_dirty-}" "dirty marker should be cleared $message" || return + assert_empty "${prompt_pure_git_last_dirty_check_timestamp-}" "cached dirty timestamp should be cleared $message" || return + assert_empty "${prompt_pure_git_arrows-}" "arrows should be cleared $message" || return + assert_empty "${prompt_pure_git_stash-}" "stash should be cleared $message" || return + assert_empty "${prompt_pure_git_fetch_pattern-}" "fetch pattern should be cleared $message" || return +} + test_no_infinite_recursion_on_worker_failure() { # Simulate async_start_worker always failing (e.g. zpty permission denied). async_start_worker() { return 1 } @@ -46,19 +69,313 @@ test_worker_startup_failure_clears_git_state() { prompt_pure_async_tasks || : - assert_empty "${prompt_pure_vcs_info[branch]-}" "branch should be cleared when worker fails to start" || return - assert_empty "${prompt_pure_vcs_info[top]-}" "top-level should be cleared when worker fails to start" || return - assert_empty "${prompt_pure_vcs_info[action]-}" "action should be cleared when worker fails to start" || return - assert_empty "${prompt_pure_vcs_info[pwd]-}" "pwd should be cleared when worker fails to start" || return - assert_empty "${prompt_pure_git_dirty-}" "dirty marker should be cleared when worker fails to start" || return - assert_empty "${prompt_pure_git_last_dirty_check_timestamp-}" "cached dirty timestamp should be cleared when worker fails to start" || return - assert_empty "${prompt_pure_git_arrows-}" "arrows should be cleared when worker fails to start" || return - assert_empty "${prompt_pure_git_stash-}" "stash should be cleared when worker fails to start" || return - assert_empty "${prompt_pure_git_fetch_pattern-}" "fetch pattern should be cleared when worker fails to start" || return + assert_git_state_empty "when worker fails to start" || return unfunction async_start_worker } +test_worker_sync_clears_stale_git_state_before_returning() { + local saved_pwd=$PWD + local tmpdir=$PWD/.ai-temporary/worker-sync-clear-$$ + zf_mkdir -p "$tmpdir" + + async_worker_eval() { + return 0 + } + async_flush_jobs() { + return 0 + } + async_job() { + return 1 + } + + typeset -g prompt_pure_async_inited=1 + typeset -gA prompt_pure_worker_env=(pwd /tmp/repo git_dir __unset__ git_work_tree __unset__) + typeset -gA prompt_pure_worker_env_pending=() + typeset -gA prompt_pure_vcs_info=(branch main top /tmp/repo action rebase pwd /tmp/repo) + typeset -g prompt_pure_git_dirty="*" + typeset -g prompt_pure_git_last_dirty_check_timestamp=1 + typeset -g prompt_pure_git_arrows="⇡" + typeset -g prompt_pure_git_stash=1 + typeset -g prompt_pure_git_fetch_pattern="pull|fetch" + + builtin cd -q "$tmpdir" + + prompt_pure_async_tasks || : + + builtin cd -q "$saved_pwd" + zf_rm -rf -- "$saved_pwd/.ai-temporary" + + assert_git_state_empty "before returning for worker sync" || return + + unfunction async_worker_eval async_flush_jobs async_job +} + +test_worker_sync_cd_failure_is_not_cached() { + local saved_pwd=$PWD + local tmpdir=$PWD/.ai-temporary/worker-sync-$$ + zf_mkdir -p "$tmpdir" + local -a worker_eval_command + + async_worker_eval() { + local worker=$1; shift + worker_eval_command=("$@") + return 0 + } + local async_flush_jobs_called=0 + async_flush_jobs() { + async_flush_jobs_called=1 + return 0 + } + local git_job_called=0 + local render_called=0 + async_job() { + git_job_called=1 + return 1 + } + prompt_pure_preprompt_render() { + render_called=1 + } + + typeset -g prompt_pure_async_inited=1 + typeset -gA prompt_pure_worker_env=() + typeset -gA prompt_pure_worker_env_pending=() + + builtin cd -q "$tmpdir" + zf_rm -rf -- "$tmpdir" + + prompt_pure_async_tasks || : + + builtin cd -q "$saved_pwd" + zf_rm -rf -- "$saved_pwd/.ai-temporary" + + assert_empty "${prompt_pure_worker_env[pwd]-}" "worker sync should not be cached before eval callback" || return + assert_equal 1 $async_flush_jobs_called "old git jobs should be flushed before worker sync" || return + assert_equal 0 $git_job_called "git jobs should wait for worker sync callback" || return + + local sync_output + sync_output=$("${(@)worker_eval_command}" 2>&1) + builtin cd -q "$saved_pwd" + prompt_pure_async_callback '[async/eval]' 0 "$sync_output" 0 "" 1 + + assert_empty "${prompt_pure_worker_env[pwd]-}" "failed worker cd should not be cached as synced" || return + assert_equal 0 $git_job_called "git jobs should not run when worker cd fails" || return + assert_equal 1 $render_called "failed worker sync should render even when stale results are pending" || return + + prompt_pure_async_callback prompt_pure_async_vcs_info 0 "pwd ${(q)PWD} branch stale top /tmp/repo action rebase" 0 "" 0 + + assert_empty "${prompt_pure_vcs_info[branch]-}" "stale vcs info should not repopulate branch after failed worker sync" || return + assert_empty "${prompt_pure_vcs_info[top]-}" "stale vcs info should not repopulate top-level after failed worker sync" || return + + prompt_pure_preprompt_render() { + : + } + unfunction async_worker_eval async_flush_jobs async_job +} + +test_worker_sync_success_queues_git_job() { + local -a worker_eval_command + + async_worker_eval() { + local worker=$1; shift + worker_eval_command=("$@") + return 0 + } + local async_flush_jobs_called=0 + async_flush_jobs() { + async_flush_jobs_called=1 + return 0 + } + local git_job_called=0 + async_job() { + git_job_called=1 + return 0 + } + + typeset -g prompt_pure_async_inited=1 + typeset -gA prompt_pure_worker_env=() + typeset -gA prompt_pure_worker_env_pending=() + typeset -gA prompt_pure_vcs_info=(branch "" top "" action "" pwd "") + + prompt_pure_async_tasks || : + + assert_empty "${prompt_pure_worker_env[pwd]-}" "worker sync should not be cached before eval callback" || return + assert_equal 1 $async_flush_jobs_called "old git jobs should be flushed before worker sync" || return + assert_equal 0 $git_job_called "git jobs should wait for worker sync callback" || return + + prompt_pure_async_callback '[async/eval]' 0 "renice output" 0 "" 0 + + assert_empty "${prompt_pure_worker_env[pwd]-}" "unrelated eval callback should not cache worker sync" || return + assert_equal 0 $git_job_called "unrelated eval callback should not queue git jobs" || return + + local sync_output + sync_output=$("${(@)worker_eval_command}" 2>&1) + prompt_pure_async_callback '[async/eval]' 0 "$sync_output" 0 "" 0 + + assert_equal "$PWD" "${prompt_pure_worker_env[pwd]-}" "successful worker sync should be cached" || return + assert_equal 1 $git_job_called "git jobs should run after worker sync succeeds" || return + + unfunction async_worker_eval async_flush_jobs async_job +} + +test_worker_sync_eval_failure_clears_pending_state() { + local async_worker_eval_called=0 + async_worker_eval() { + (( async_worker_eval_called++ )) + (( async_worker_eval_called == 1 )) + } + async_flush_jobs() { + return 0 + } + async_job() { + return 0 + } + + typeset -g prompt_pure_async_inited=1 + typeset -gA prompt_pure_worker_env=() + typeset -gA prompt_pure_worker_env_pending=() + + prompt_pure_async_tasks || : + + assert_equal "$PWD" "${prompt_pure_worker_env_pending[pwd]-}" "worker sync should be pending before eval failure" || return + + prompt_pure_async_callback '[async/eval]' 1 "" 0 "" 0 + + assert_equal 1 $async_worker_eval_called "non-marker eval failure should not immediately retry worker sync" || return + assert_empty "${prompt_pure_worker_env_pending[pwd]-}" "non-marker eval failure should clear pending sync state" || return + + unfunction async_worker_eval async_flush_jobs async_job +} + +test_worker_sync_records_pending_before_eval() { + async_worker_eval() { + prompt_pure_async_callback '[async/eval]' 0 "prompt_pure_worker_sync:${prompt_pure_worker_env_pending[token]}:0" 0 "" 0 + return 0 + } + async_flush_jobs() { + return 0 + } + local git_job_called=0 + async_job() { + git_job_called=1 + return 0 + } + + typeset -g prompt_pure_async_inited=1 + typeset -gA prompt_pure_worker_env=() + typeset -gA prompt_pure_worker_env_pending=() + typeset -gA prompt_pure_vcs_info=(branch "" top "" action "" pwd "") + + prompt_pure_async_tasks || : + + assert_empty "${prompt_pure_worker_env_pending[pwd]-}" "sync callback should not leave pending state behind" || return + assert_equal "$PWD" "${prompt_pure_worker_env[pwd]-}" "sync callback should cache worker sync even if it arrives during async_worker_eval" || return + assert_equal 1 $git_job_called "git jobs should run after immediate worker sync callback" || return + + unfunction async_worker_eval async_flush_jobs async_job +} + +test_worker_sync_enqueue_failure_preserves_new_pending_state() { + async_worker_eval() { + typeset -gA prompt_pure_worker_env_pending=(pwd "$PWD" git_dir __unset__ git_work_tree __unset__ token 999) + return 1 + } + async_flush_jobs() { + return 0 + } + async_job() { + return 0 + } + + typeset -g prompt_pure_async_inited=1 + typeset -gA prompt_pure_worker_env=() + typeset -gA prompt_pure_worker_env_pending=() + + prompt_pure_async_tasks || : + + assert_equal 999 "${prompt_pure_worker_env_pending[token]-}" "enqueue failure should not clear newer pending sync state" || return + + unfunction async_worker_eval async_flush_jobs async_job +} + +test_async_eval_setup_failure_reports_callback() { + typeset -g ASYNC_INIT_DONE=0 + source ./async.zsh + + local callback_job callback_code + local original_tmpdir=${TMPDIR-} + local missing_tmpdir=$PWD/.ai-temporary/missing-tmpdir-$$ + zf_rm -rf -- "$missing_tmpdir" + + typeset -g TMPDIR=$missing_tmpdir + + _async_job() { + local jobname=${ASYNC_JOB_NAME:-$1} + callback_job=$jobname + callback_code=1 + } + + _async_eval "print -r -- unreachable" + + if [[ -n $original_tmpdir ]]; then + typeset -g TMPDIR=$original_tmpdir + else + unset TMPDIR + fi + + assert_equal "[async/eval]" "$callback_job" "async eval setup failure should report through async/eval" || return + assert_equal 1 "$callback_code" "async eval setup failure should report failure status" || return + + unfunction _async_job +} + +test_real_worker_sync_reports_encoded_status() { + typeset -g ASYNC_INIT_DONE=0 + source ./async.zsh + typeset -gA ASYNC_PTYS=() + typeset -gA ASYNC_CALLBACKS=() + typeset -gA ASYNC_PROCESS_BUFFER=() + + local worker=prompt_pure_worker_sync_test + async_stop_worker $worker >/dev/null 2>&1 || : + async_start_worker $worker -u || return + + local saved_pwd=$PWD + local tmpdir=$PWD/.ai-temporary/worker-sync-real-$$ + zf_mkdir -p "$tmpdir" + zf_rm -rf -- "$tmpdir" + + local callback_job callback_code callback_output + real_worker_callback() { + callback_job=$1 + callback_code=$2 + callback_output=$3 + } + + async_worker_eval $worker prompt_pure_async_worker_sync 1 "$tmpdir" 0 "" 0 "" || return + + local index + for index in {1..50}; do + async_process_results $worker real_worker_callback direct >/dev/null 2>&1 + [[ $callback_job == '[async/eval]' ]] && break + sleep 0.01 + done + + async_stop_worker $worker >/dev/null 2>&1 || : + builtin cd -q "$saved_pwd" + + assert_equal "[async/eval]" "$callback_job" "real worker sync should report through async/eval" || return + assert_equal 1 "$callback_code" "real async/eval wrapper should preserve eval status" || return + + if [[ $callback_output != *"prompt_pure_worker_sync:1:1"* ]]; then + print -u2 -- "Assertion failed: real worker sync should encode failed cd status in output" + print -u2 -- "Actual: $callback_output" + return 1 + fi + + unfunction real_worker_callback +} + test_callback_no_recursion_on_worker_failure() { # Simulate async_start_worker always failing. async_start_worker() { return 1 } @@ -104,15 +421,7 @@ test_callback_failed_recovery_clears_git_state() { # Use next_pending=1 because a worker crash may be reported while buffered output remains. prompt_pure_async_callback '[async]' 3 '' 0 'worker crashed' 1 - assert_empty "${prompt_pure_vcs_info[branch]-}" "branch should be cleared when worker recovery fails" || return - assert_empty "${prompt_pure_vcs_info[top]-}" "top-level should be cleared when worker recovery fails" || return - assert_empty "${prompt_pure_vcs_info[action]-}" "action should be cleared when worker recovery fails" || return - assert_empty "${prompt_pure_vcs_info[pwd]-}" "pwd should be cleared when worker recovery fails" || return - assert_empty "${prompt_pure_git_dirty-}" "dirty marker should be cleared when worker recovery fails" || return - assert_empty "${prompt_pure_git_last_dirty_check_timestamp-}" "cached dirty timestamp should be cleared when worker recovery fails" || return - assert_empty "${prompt_pure_git_arrows-}" "arrows should be cleared when worker recovery fails" || return - assert_empty "${prompt_pure_git_stash-}" "stash should be cleared when worker recovery fails" || return - assert_empty "${prompt_pure_git_fetch_pattern-}" "fetch pattern should be cleared when worker recovery fails" || return + assert_git_state_empty "when worker recovery fails" || return assert_equal 1 $render_called "prompt should be rendered after worker recovery clears git state" || return local async_job_called=0 diff --git a/tests/benchmark.zsh b/tests/benchmark.zsh new file mode 100644 index 0000000..fd3d9d1 --- /dev/null +++ b/tests/benchmark.zsh @@ -0,0 +1,114 @@ +#!/usr/bin/env zsh +# Benchmark Pure's synchronous hot path. +# Usage: zsh tests/benchmark.zsh [--dimmed] + +setopt noshwordsplit +zmodload zsh/datetime + +cd -- "${0:A:h}/.." + +# Stub async machinery so we only measure synchronous code. +# Note: with async_worker_eval stubbed, the worker-sync caching in +# prompt_pure_async_tasks always measures the cache-hit path after +# the first iteration. +async() { : } +async_init() { : } +async_start_worker() { : } +async_stop_worker() { : } +async_register_callback() { : } +async_worker_eval() { : } +async_flush_jobs() { : } +async_job() { : } + +# Apply dimmed path separator if requested. +local dimmed=0 +if [[ $1 == '--dimmed' ]]; then + dimmed=1 + zstyle ':prompt:pure:path:separator' dim yes +fi + +# Disable title setting to avoid escape sequences in output. +zstyle ':prompt:pure:title' show no + +# Source Pure (suppresses output from setup). +source ./pure.zsh >/dev/null 2>&1 + +# Simulate a populated prompt state (as if inside a git repo with all features). +typeset -gA prompt_pure_vcs_info=( + branch 'main' + top "$PWD" + action '' + pwd "$PWD" +) +typeset -g prompt_pure_git_dirty='*' +typeset -g prompt_pure_git_arrows='⇣⇡' +typeset -g prompt_pure_git_stash='1' +typeset -g prompt_pure_cmd_exec_time='42s' +typeset -g prompt_pure_cmd_timestamp=$EPOCHSECONDS + +_bench() { + local name=$1 iters=$2 + shift 2 + + local -a times + local i t0 t1 + + # Warmup (5 iterations). + for (( i = 0; i < 5; i++ )); do + eval "$@" >/dev/null 2>&1 + done + + for (( i = 0; i < iters; i++ )); do + t0=$EPOCHREALTIME + eval "$@" >/dev/null 2>&1 + t1=$EPOCHREALTIME + integer usec=$(( (t1 - t0) * 1000000 )) + times+=( $usec ) + done + + # Sort using zsh numeric ordering. + local -a sorted=( "${(@no)times}" ) + local n=$#sorted + local min=$sorted[1] + local max=$sorted[$n] + local med=$sorted[$(( n / 2 + 1 ))] + integer p95_idx=$(( n * 95 / 100 + 1 )) + local p95=$sorted[$p95_idx] + local sum=0 + for t in $times; do (( sum += t )); done + local avg=$(( sum / n )) + + printf " %-40s min=%5dµs med=%5dµs p95=%5dµs max=%5dµs avg=%5dµs\n" \ + "$name" $min $med $p95 $max $avg +} + +local iters=200 + +print "Pure prompt benchmark (${iters} iterations)" +if (( dimmed )); then + print " Mode: dimmed path separator ENABLED" +else + print " Mode: default (no dimmed path)" +fi +print + +_bench "prompt_pure_set_colors" $iters \ + 'prompt_pure_set_colors' + +_bench "prompt_pure_set_path_separator" $iters \ + 'prompt_pure_set_path_separator' + +_bench "prompt_pure_preprompt_render (precmd)" $iters \ + 'prompt_pure_preprompt_render precmd' + +_bench "\${(S%%)PROMPT} expansion" $iters \ + 'local x="${(S%%)PROMPT}"' + +_bench "prompt_pure_async_tasks (sync part)" $iters \ + 'prompt_pure_async_tasks' + +_bench "prompt_pure_precmd (full)" $iters \ + 'prompt_pure_precmd' + +print +print "Done." diff --git a/tests/prompt-fingerprint.zsh b/tests/prompt-fingerprint.zsh new file mode 100644 index 0000000..8fcac84 --- /dev/null +++ b/tests/prompt-fingerprint.zsh @@ -0,0 +1,60 @@ +#!/usr/bin/env zsh + +set -euo pipefail + +cd -- "${0:A:h}/.." + +set +e +set +u +zstyle ':prompt:pure:title' show no +source ./pure.zsh >/dev/null 2>&1 + +assert_equal() { + local expected=$1 + local actual=$2 + local message=$3 + + if [[ $expected != $actual ]]; then + print -u2 -- "Assertion failed: $message" + print -u2 -- "Expected: $expected" + print -u2 -- "Actual: $actual" + return 1 + fi +} + +main() { + typeset -gA prompt_pure_state=( + prompt '❯' + ) + typeset -gA prompt_pure_vcs_info=( + branch '' + action '' + ) + typeset -g prompt_pure_git_branch_color=242 + typeset -g prompt_pure_precustom_step=0 + typeset -g prompt_pure_reset_prompt_count=0 + + prompt_pure_precustom() { + if (( prompt_pure_precustom_step == 0 )); then + psvar[22]='a|b' + psvar[23]=c + else + psvar[22]=a + psvar[23]='b|c' + fi + } + + prompt_pure_reset_prompt() { + (( ++prompt_pure_reset_prompt_count )) + } + + prompt_pure_preprompt_render precmd + (( prompt_pure_precustom_step++ )) + prompt_pure_preprompt_render + + assert_equal 1 $prompt_pure_reset_prompt_count "prompt fingerprint should distinguish separators inside custom prompt parts" || return + + print -- "prompt-fingerprint tests passed" +} + +main "$@" diff --git a/tests/test.zsh b/tests/test.zsh index a895036..7bb7533 100644 --- a/tests/test.zsh +++ b/tests/test.zsh @@ -6,7 +6,7 @@ cd -- "${0:A:h}" local test_file for test_file in *.zsh; do - if [[ $test_file == test.zsh || $test_file == test-helper.zsh ]]; then + if [[ $test_file == test.zsh || $test_file == test-helper.zsh || $test_file == benchmark.zsh ]]; then continue fi