testsAndMisc/linux_configuration/zsh/calc-live.zsh

267 lines
11 KiB
Bash
Raw Normal View History

# ============================================================================
# calc-live.zsh — live "calculator as you type" for the zsh prompt
# ----------------------------------------------------------------------------
# Open a terminal and just type a math expression. The result appears, greyed,
# right after what you typed, updating on every keystroke. No "calc", no Enter.
#
# 2+2 => 2+2 = 4
# (5+3)/2 => (5+3)/2 = 4
# 1/3 => 1/3 = 0.333333333333
# sqrt(2)*3 => sqrt(2)*3 = 4.24264068712
# sin(pi/2) => sin(pi/2) = 1
# 2^10 => 2^10 = 1024 (^ means power, calculator-style)
#
# Pressing Enter on a pure-math line prints "<expr> = <result>" instead of
# trying to run it as a command (so no "command not found"). History keeps the
# bare expression, not the print command.
#
# Only pure-math lines trigger this; normal commands (ls, git, cd ..) are left
# completely alone and never fork the evaluator.
#
# Richer math (units, hex, percentages): prefix the line with "=". These go to
# qalc (if installed) and update live too, via a warmed qalc coprocess:
# =5 ft to cm -> 5 ft to cm = 152.4 cm
# =255 to hex -> 255 to hex = 0xFF
# =0xff + 1 -> 0xff + 1 = 256
# =20% of 80 -> 20% of 80 = 16
# (The first "=" of a session spends ~120 ms warming qalc; after that ~1 ms.)
#
# Engine: a safe AST-based evaluator in the sibling calc_eval.py (~6 ms/eval),
# run via python3. It cannot import modules, touch files, or run arbitrary code,
# and a runaway expression (e.g. 9**9**9) is bounded by a power-exponent guard +
# CPU limit + timeout so it can never freeze the prompt.
#
# Disable for a session: export CALC_LIVE_OFF=1 (then open a new shell)
# Remove permanently: delete this file.
#
# NOTE: this widget manages POSTDISPLAY/region_highlight. If you ever enable
# the zsh-autosuggestions plugin (which also owns POSTDISPLAY), they will fight;
# revisit this then.
# ============================================================================
# Opt-out switch and a one-time guard against double loading.
[[ -n ${CALC_LIVE_OFF:-} ]] && return
[[ -n ${_CALC_LOADED:-} ]] && return
typeset -g _CALC_LOADED=1
# --- Pick the evaluator binary (prefer the fast system python over a shim) ---
if [[ -x /usr/bin/python3 ]]; then
typeset -g _CALC_PY_BIN=/usr/bin/python3
else
typeset -g _CALC_PY_BIN=${commands[python3]:-}
fi
typeset -g _CALC_TIMEOUT_BIN=${commands[timeout]:-}
# The safe AST evaluator lives under python_pkg/ so the repository's Python
# tooling (ruff, mypy, pylint, bandit, 100%-coverage tests) applies to it.
# Resolve it at source time: ${0:A} follows the oh-my-zsh symlink back to the
# real file in the repo; :h:h:h walks zsh/ -> linux_configuration/ -> repo root.
# ($0 inside a function would name the function, so capture it here.)
typeset -gr _CALC_EVAL_PY=${0:A:h:h:h}/python_pkg/live_calc/calc_eval.py
# Optional richer engine. qalc (libqalculate) handles units, hex, percentages
# and natural language, but its ~120 ms cold start is too slow to run on every
# keystroke, so it is used only for "="-prefixed lines, evaluated on Enter.
typeset -g _CALC_QALC_BIN=${commands[qalc]:-}
# No usable evaluator -> do nothing rather than half-installing.
[[ -n $_CALC_PY_BIN && -r $_CALC_EVAL_PY ]] || return
# --- Evaluate one expression; echoes the result, or nothing on failure -------
# Runs the standalone calc_eval.py. The expression is passed as argv[1]; the
# script reads it, evaluates it under CPU + wall-clock limits, and writes the
# formatted result (or nothing on any error / unsafe input / overflow / timeout).
_calc_eval() {
emulate -L zsh
local expr=$1 out
# -S skips site init for a faster start; the outer `timeout` is a backstop in
# case the script's own limits are unavailable on this platform.
if [[ -n $_CALC_TIMEOUT_BIN ]]; then
out=$("$_CALC_TIMEOUT_BIN" -k 0.1 0.5 "$_CALC_PY_BIN" -S "$_CALC_EVAL_PY" "$expr" 2>/dev/null)
else
out=$("$_CALC_PY_BIN" -S "$_CALC_EVAL_PY" "$expr" 2>/dev/null)
fi
print -r -- "$out"
}
# --- qalc (richer engine) for "="-prefixed lines: units, hex, percentages ----
# qalc cold start is ~120 ms, far too slow per keystroke; but a warmed qalc
# coprocess answers in ~1 ms, fast enough to evaluate live in the redraw hook.
# So we keep ONE persistent qalc per shell, started lazily on first "=" line.
#
# IMPORTANT: the coprocess lives in the main shell. Its evaluator must NOT be
# called via $(...) (that forks a subshell and loses the pipe) — it sets the
# global _CALC_QRESULT instead.
typeset -g _CALC_QCO_UP=0 # 1 once the qalc coprocess is running
typeset -gi _CALC_QSEQ=0 # unique-sentinel counter (prevents desync)
typeset -g _CALC_QEXPR= # stripped expression from the last "=" line
typeset -g _CALC_QRESULT= # result from the last _calc_qalc_eval
# If the line is "=<expr>", set _CALC_QEXPR to the stripped <expr> and return 0.
_calc_qalc_line() {
emulate -L zsh
setopt local_options extended_glob
local s=$1
_CALC_QEXPR=
[[ $s == (#s)[[:space:]]#=* ]] || return 1
s=${s##[[:space:]]#=[[:space:]]#} # drop leading spaces, the "=", trailing spaces
[[ -n $s ]] || return 1
_CALC_QEXPR=$s
return 0
}
# Start / stop the persistent qalc coprocess (no job-control chatter).
_calc_qco_start() {
setopt local_options no_monitor no_notify
coproc "$_CALC_QALC_BIN" -t 2>/dev/null
_CALC_QCO_PID=$!
disown %+ 2>/dev/null
_CALC_QCO_UP=1
}
_calc_qco_stop() {
[[ ${_CALC_QCO_UP:-0} == 1 ]] || return 0
kill "${_CALC_QCO_PID:-0}" 2>/dev/null
_CALC_QCO_UP=0
}
# Evaluate _CALC_QEXPR (arg) via the coprocess; sets _CALC_QRESULT ("" on fail).
_calc_qalc_eval() {
emulate -L zsh
setopt local_options extended_glob no_monitor no_notify
_CALC_QRESULT=
[[ -n $_CALC_QALC_BIN ]] || return 0
local expr=${1// of / * } # qalc mis-parses "A% of B"
(( _CALC_QCO_UP )) || _calc_qco_start
(( _CALC_QSEQ++ ))
local sentinel="909090909${_CALC_QSEQ}" # unique per call, echoes itself
if ! { print -p -- "$expr" && print -p -- "$sentinel" } 2>/dev/null; then
_calc_qco_stop; _calc_qco_start # pipe broke -> restart once
{ print -p -- "$expr" && print -p -- "$sentinel" } 2>/dev/null || return 0
fi
local line result='' saw=0
integer guard=0
while (( guard++ < 100 )) && read -rp -t 0.8 line 2>/dev/null; do
line=${line//$'\e'\[[0-9;?]##[a-zA-Z]/} # strip color escapes
line=${line##[[:space:]]##}; line=${line%%[[:space:]]##}
[[ -z $line || $line == '>'* ]] && continue # blank / echoed input
[[ $line == *$sentinel* ]] && { saw=1; break; } # our sentinel result
result=$line
done
(( saw )) || { _CALC_QCO_UP=0; return 0 } # timed out/wedged -> reset
# qalc echoes the input back when it cannot evaluate (e.g. "1 / 0").
[[ -n $result && ${result// /} != ${expr// /} ]] && _CALC_QRESULT=$result
return 0
}
# --- Decide whether a line is a pure math expression worth evaluating --------
# Conservative on purpose: must contain a digit and an operator (or a function
# call), and consist only of math tokens. This keeps real commands untouched.
_calc_is_expr() {
emulate -L zsh
setopt local_options extended_glob
local s=${1//[[:space:]]/}
[[ -n $s ]] || return 1
# Neutralise known function/constant words (longest first) so the remaining
# text can be charset-checked as pure numeric/operator soup.
local t=$s w
for w in factorial asin acos atan sqrt sin cos tan log2 log ln exp \
floor ceil gcd deg rad min max tau pi e; do
t=${t//$w/0}
done
# Only digits, operators, parens, dot, comma may remain.
# (Parens are backslash-escaped so the parser does not read them as a group.)
[[ $t == [-0-9.,+*/%^\(\)]## ]] || return 1
# Require a digit, and either an operator or a function call.
[[ $s == *[0-9]* ]] || return 1
[[ $s == *[-+*/%^]* || $s == *[a-z]*\(* ]] || return 1
return 0
}
# --- ZLE plumbing ------------------------------------------------------------
typeset -g _CALC_LAST_BUFFER=$'\0' # sentinel so the first redraw computes
typeset -g _CALC_LAST_RESULT=
# Drop our preview text and our highlight entry (leaving any others intact).
_calc_clear() {
POSTDISPLAY=
region_highlight=( "${(@)region_highlight:#*memo=calc*}" )
}
# Runs on every redraw; recomputes only when the buffer text actually changed.
_calc_preview() {
emulate -L zsh
[[ $BUFFER == $_CALC_LAST_BUFFER ]] && return
_CALC_LAST_BUFFER=$BUFFER
_CALC_LAST_RESULT=
_calc_clear
# "=<expr>" -> evaluate live with the warmed qalc coprocess (~1 ms).
if [[ -n $_CALC_QALC_BIN ]] && _calc_qalc_line "$BUFFER"; then
_calc_qalc_eval "$_CALC_QEXPR"
if [[ -n $_CALC_QRESULT ]]; then
_CALC_LAST_RESULT=$_CALC_QRESULT
POSTDISPLAY=" = $_CALC_QRESULT"
else
POSTDISPLAY=" …" # typed so far is not yet a complete expression
fi
region_highlight+=("${#BUFFER} $((${#BUFFER} + ${#POSTDISPLAY})) fg=242,memo=calc")
return
fi
_calc_is_expr "$BUFFER" || return
local r
r=$(_calc_eval "$BUFFER")
[[ -n $r && $r != $BUFFER ]] || return
_CALC_LAST_RESULT=$r
POSTDISPLAY=" = $r"
region_highlight+=("${#BUFFER} $((${#BUFFER} + ${#POSTDISPLAY})) fg=242,memo=calc")
}
# Result waiting to be printed to scrollback by the next precmd.
typeset -g _CALC_PENDING=
# Print the accepted calculation just before the next prompt is drawn, so it
# lands in scrollback exactly where command output would.
_calc_flush() {
if [[ -n $_CALC_PENDING ]]; then
print -r -- "$_CALC_PENDING"
_CALC_PENDING=
fi
}
# Enter on a pure-math line: record the result for printing, keep the bare
# expression in history, and execute nothing (so no "command not found").
_calc_accept_line() {
emulate -L zsh
if [[ -n $_CALC_QALC_BIN ]] && _calc_qalc_line "$BUFFER"; then
# "=<expr>" -> reuse the live coprocess result (recompute only if stale).
# Never execute a "=" line as a command, even if qalc returns nothing.
local r=$_CALC_LAST_RESULT
[[ -n $r && $BUFFER == $_CALC_LAST_BUFFER ]] || { _calc_qalc_eval "$_CALC_QEXPR"; r=$_CALC_QRESULT; }
print -s -- "$BUFFER" # history keeps what was typed
[[ -n $r ]] && _CALC_PENDING="$_CALC_QEXPR = $r"
BUFFER=
elif [[ -n $_CALC_LAST_RESULT && $BUFFER == $_CALC_LAST_BUFFER ]] \
&& _calc_is_expr "$BUFFER"; then
print -s -- "$BUFFER" # history keeps the expression
_CALC_PENDING="$BUFFER = $_CALC_LAST_RESULT" # _calc_flush prints it
BUFFER= # nothing runs
fi
# Clear our preview and reset state so nothing leaks into the next prompt.
_calc_clear
_CALC_LAST_BUFFER=$'\0'
_CALC_LAST_RESULT=
zle .accept-line
}
# Bind the live preview to the redraw hook and intercept Enter.
#
# zle-line-pre-redraw is unused in this setup, so we own it directly with
# "zle -N", which (unlike add-zle-hook-widget) takes effect at .zshrc source
# time. If you later add a plugin that also drives line-pre-redraw
# (zsh-autosuggestions, zsh-syntax-highlighting), switch to add-zle-hook-widget
# registered from a one-shot precmd so the hooks chain instead of clobbering.
autoload -Uz add-zsh-hook
add-zsh-hook precmd _calc_flush
add-zsh-hook zshexit _calc_qco_stop # kill the qalc coprocess on shell exit
zle -N zle-line-pre-redraw _calc_preview
zle -N accept-line _calc_accept_line