Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,25 @@ cdg term clear # 기록 모두 지우기
명령은 터미널을 받고 기록되지 않습니다. [thefuck](https://github.com/nvbn/thefuck)이 그런 예입니다:
`$global:CODIGEST_RUNNERS += 'fuck'` (bash/zsh: `CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} fuck"`).

입력한 줄을 실행 전에 바꾸는 방법도 있습니다. Enter를 누르면 훅이 `CODIGEST_REWRITERS`에 있는 함수에
그 줄을 차례로 넘기고, 처음으로 맡은 함수가 돌려준 줄을 실행합니다. 그 줄은 직접 입력한 줄과 똑같이
다뤄집니다. 셸 히스토리(PowerShell에서는 PSReadLine과 엔진 히스토리)에 남는 것도 그 줄이고, 기록할지는
훅의 규칙이 정하며, `$?`도 그 줄의 것입니다. `hf`만 입력한 줄은 이렇게 동작할 수 있고, 더 긴 줄 안의
`hf`는 위의 두 단계를 씁니다.

- PowerShell: `$global:CODIGEST_REWRITERS += 'name'`. 함수는 줄을 받아 `$null`(맡지 않음), 새 줄,
또는 먼저 묻는 `@{ Line = <새 줄>; Confirm = $true }`를 돌려줍니다.
- bash/zsh: `CODIGEST_REWRITERS="${CODIGEST_REWRITERS-} name"`. 함수는 줄을 받아 `REPLY`에 새 줄을
넣고 0(먼저 물으려면 2)을 돌려주거나, 맡지 않으면 1을 돌려줍니다.
- 물을 때는 새 줄과 `[Enter / Ctrl+C]`를 보여 줍니다. Enter면 실행하고, 다른 키면 버립니다(아무것도
실행하지 않고 히스토리에도 넣지 않음).
- 함수가 실패하거나, 다른 값을 돌려주거나, 문법이 틀린 줄을 주면 입력한 줄을 그대로 실행하고 메시지도
내지 않습니다. 200 ms가 지나서 나온 결과도 버리고 입력한 줄을 실행합니다. 시간 제한은 아닙니다. 훅은
함수가 끝날 때까지 기다리므로, 멈춘 함수는 Enter를 막습니다.
- 함수가 히스토리에 직접 넣으면 안 됩니다.
- 앞에 공백을 두고 입력한 줄은 새 줄에도 공백이 붙어 기록되지 않습니다.
- PowerShell 원격 세션, 중첩 프롬프트, 디버거에서는 쓰지 않습니다.

PowerShell에서는 다른 `PSConsoleHostReadLine`(VS Code의 셸 통합, atuin)과 함께 동작합니다. 줄을 읽을
때 그것을 불러 입력한 그대로의 줄을 보게 하고, 돌려받은 줄만 감쌉니다. 그런 프로그램이 훅보다 나중에
불러와지면, 그것이 줄을 직접 읽더라도(atuin처럼) 훅은 셸이 입력을 기다리는 동안 다시 앞에 섭니다.
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,25 @@ A program that does not do step 2 can still be listed in `CODIGEST_RUNNERS`: wha
the terminal and is not captured. [thefuck](https://github.com/nvbn/thefuck) is one:
`$global:CODIGEST_RUNNERS += 'fuck'` (bash/zsh: `CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} fuck"`).

A program can instead replace the typed line before it runs: when Enter is pressed, the hook calls
each function listed in `CODIGEST_REWRITERS` with the line, and the first one that claims it gives
the line to run. That line then goes on as if typed: it is the one in the shell history (PSReadLine's
and the engine's in PowerShell), the hook's rules decide whether it is captured, and `$?` is its own.
`hf` typed alone can work this way; `hf` inside a longer line still uses the steps above.

- PowerShell: `$global:CODIGEST_REWRITERS += 'name'`. The function gets the line and returns
`$null` (not its line), the new line, or `@{ Line = <new>; Confirm = $true }` to ask first.
- bash/zsh: `CODIGEST_REWRITERS="${CODIGEST_REWRITERS-} name"`. The function gets the line, sets
`REPLY` to the new line and returns 0, or 2 to ask first, or returns 1 when it is not its line.
- When asked, the hook shows the new line with `[Enter / Ctrl+C]`: Enter runs it, any other key
drops it (nothing runs, nothing is added to history).
- A function that fails, returns anything else, or gives a line that does not parse leaves the line
as typed, without a message. So does a result that comes after more than 200 ms: it is discarded.
This is not a timeout: the hook waits for the function to return, so one that hangs blocks Enter.
- The function should not add to history itself.
- A line typed with a leading space keeps it, so it stays out of the capture.
- Not in a PowerShell remote session, nested prompt or the debugger.

In PowerShell the hook works with another `PSConsoleHostReadLine` (VS Code's shell integration,
atuin): it calls that one for the line, so it sees the line as typed, and only wraps what it
returns. When one is loaded after the hook, the hook puts itself back on top while the shell
Expand Down
106 changes: 99 additions & 7 deletions src/codigest/shell/hook.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ filter global:__cdgRecord {
}
}

# Sets $? to false without printing anything or touching $Error.
function global:__cdgFalse {
[CmdletBinding()]
param()
$ErrorActionPreference = 'Ignore'
$PSCmdlet.WriteError([Management.Automation.ErrorRecord]::new(
[Exception]::new('previous command failed'), 'CodigestStatus', 'NotSpecified', $null))
}

function global:__cdg {
# Advanced function so a failed command can surface as `$? -eq $false` for prompt themes.
[CmdletBinding()]
Expand All @@ -57,7 +66,11 @@ function global:__cdg {
$global:__cdgTail = [Collections.Generic.Queue[object]]::new()
$global:__cdgDropped = 0
$global:__cdgFailed = $false
# The statements above reset $?; a typed line that reads it must see the previous command's.
$__cdgFailedBefore = $global:__cdgLastOk -eq $false
$global:__cdgLastOk = $null
try {
if ($__cdgFailedBefore) { __cdgFalse }
. $__cdgBlock *>&1 | __cdgRecord
}
catch {
Expand Down Expand Up @@ -105,7 +118,7 @@ function global:__cdg {
[Exception]::new("exit $__cdgExit"), 'CodigestExit', 'NotSpecified', $null))
$ErrorActionPreference = $__cdgPref
}
Remove-Variable __cdgPref, __cdgBlock, __cdgCommand, __cdgPrevExit, __cdgUnbuf, __cdgStart, __cdgText, __cdgExit, __cdgJson, __cdgBytes, __cdgPsi, __cdgProc -ErrorAction Ignore
Remove-Variable __cdgFailedBefore, __cdgPref, __cdgBlock, __cdgCommand, __cdgPrevExit, __cdgUnbuf, __cdgStart, __cdgText, __cdgExit, __cdgJson, __cdgBytes, __cdgPsi, __cdgProc -ErrorAction Ignore
}
}

Expand Down Expand Up @@ -168,6 +181,72 @@ function global:__cdg_run([string]$__cdgRunLine) {
else { . $__cdgRunBlock }
}

# A program that turns a typed line into another one before it runs (hanfix's hf: the previous
# command, fixed) lists a function in $global:CODIGEST_REWRITERS. It gets the line and returns $null
# (not its line), the new line, or @{ Line = <new>; Confirm = $true } to ask first. The new line then
# goes on as if typed: histories, capture rules, $?. A rewriter that fails, returns anything else or
# answers after more than 200 ms leaves the line as typed (not a timeout: it is waited for), and it
# does not touch history itself.
function global:__cdgRewrite([string]$line) {
# Also turns a rewriter's non-terminating error into one the catch sees.
$ErrorActionPreference = 'Stop'
foreach ($name in @($global:CODIGEST_REWRITERS).Where({ $_ })) {
$clock = [Diagnostics.Stopwatch]::StartNew()
try { $new = & $name $line } catch { return $line }
if ($clock.ElapsedMilliseconds -gt 200) { return $line }
if ($null -eq $new) { continue }
$ask = $new -is [Collections.IDictionary] -and $new.Confirm
if ($new -is [Collections.IDictionary]) { $new = $new.Line }
if ($new -isnot [string] -or -not $new.Trim()) { return $line }
$tokens = $null; $errs = $null
[void][Management.Automation.Language.Parser]::ParseInput($new, [ref]$tokens, [ref]$errs)
if ($errs) { return $line }
# A line typed with a leading space stays out of the capture.
if ($line -match '^\s' -and $new -notmatch '^\s') { $new = " $new" }
if ($ask -and -not (__cdgAsk $new)) { return '' }
return $new
}
$line
}

# Enter runs the line; any other key, Ctrl+C included, drops it.
function global:__cdgAsk([string]$line) {
Write-Host "$line [Enter / Ctrl+C] " -ForegroundColor Green -NoNewline
$ctrlC = [Console]::TreatControlCAsInput
try {
[Console]::TreatControlCAsInput = $true
$key = [Console]::ReadKey($true)
}
finally {
[Console]::TreatControlCAsInput = $ctrlC
Write-Host ''
}
$key.Key -eq 'Enter' -and -not $key.Modifiers
}

# PSReadLine adds a line to its history before it returns it, while a rewriter may still replace it.
# So while the read-line below reads, the handler holds the line back, and the read-line adds the line
# that runs. The handler set before (PSReadLine's own leaves out lines that look like secrets) still
# decides for that line. Loading the hook again keeps the chain as it is.
if (-not $global:__cdgHistory) {
$global:__cdgHistory = $true
$global:__cdgHistoryPrev = (Get-PSReadLineOption).AddToHistoryHandler
Set-PSReadLineOption -AddToHistoryHandler {
param([string]$line)
if ($global:__cdgDefer) {
$global:__cdgDeferred = $true
return $false
}
$prev = $global:__cdgHistoryPrev
# PSReadLine's own is a Func<string, object>, one set by a profile a script block. An error
# here would bring PSReadLine down, so a failing one keeps the line, as no handler would.
try {
if ($prev -is [Delegate]) { $prev.Invoke($line) } elseif ($prev) { & $prev $line } else { $true }
}
catch { $true }
}
}

function global:__cdgReadLine([bool]$ok) {
try { [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $ok) }
catch [Management.Automation.MethodException] { # PSReadLine < 2.2 has no status argument
Expand Down Expand Up @@ -202,15 +281,27 @@ function global:PSConsoleHostReadLine {
Set-Item function:global:PSConsoleHostReadLine $global:__cdgSelf
return __cdgReadLine $__cdgOk
}
$line = if ($global:__cdgPrev) {
$global:__cdgOk = $__cdgOk
$global:__cdgReading = $true
try { & $global:__cdgPrev } finally { $global:__cdgReading = $false }
}
else { __cdgReadLine $__cdgOk }
# Not in a remote session, a nested prompt or the debugger: there the line is for
# another runspace or is a debugger command (c, s, v), and must reach it as typed.
$local = -not $host.Runspace.RunspaceIsRemote -and -not $NestedPromptLevel -and -not $PSDebugContext
$rewrite = $local -and @($global:CODIGEST_REWRITERS).Where({ $_ }).Count
$global:__cdgDeferred = $false
$global:__cdgDefer = $rewrite
try {
$line = if ($global:__cdgPrev) {
$global:__cdgOk = $__cdgOk
$global:__cdgReading = $true
try { & $global:__cdgPrev } finally { $global:__cdgReading = $false }
}
else { __cdgReadLine $__cdgOk }
}
finally { $global:__cdgDefer = $false }
if ($rewrite) {
$line = __cdgRewrite $line
if ($global:__cdgDeferred -and $line) {
try { [Microsoft.PowerShell.PSConsoleReadLine]::AddToHistory($line) } catch { }
}
}
$wrap = $local -and (__cdgWants $line)
if ($wrap) {
$tokens = $null; $errs = $null
Expand All @@ -220,6 +311,7 @@ function global:PSConsoleHostReadLine {
[void][Management.Automation.Language.Parser]::ParseInput($w, [ref]$tokens, [ref]$errs)
if (-not $errs) {
$global:__cdgLine = $line
$global:__cdgLastOk = $__cdgOk
return $w
}
}
Expand Down
65 changes: 63 additions & 2 deletions src/codigest/shell/hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,47 @@ __cdg_wants() {
if [ "$__cdg_shell" = zsh ]; then [[ -o banghist && $1 == *!* ]] && return 1
else [[ $- == *H* && $1 == *!* ]] && return 1; fi
# Incomplete input (open loop, quote...) goes through untouched to the continuation prompt.
__cdg_parses "$1"
}

__cdg_parses() {
if [ "$__cdg_shell" = zsh ]; then zsh -f -n -c "$1" 2>/dev/null
else BASH_ENV= "$BASH" -n -c "$1" 2>/dev/null; fi
}

# A program that turns a typed line into another one before it runs (hanfix's hf: the previous
# command, fixed) lists a function in CODIGEST_REWRITERS. It is called with the line, sets REPLY to
# the new line and returns 0 (run it) or 2 (ask first), or returns 1 (not its line). The new line then
# goes on as if typed: history, capture rules, $?. A rewriter that fails, returns anything else or
# answers after more than 200 ms leaves the line as typed (not a timeout: it is waited for), and it
# does not touch history itself.
# Returns 0 with the line to run in __cdg_new (empty: dropped at the question).
__cdg_rewrite() {
local name REPLY s t0
local -a names
[ -n "${CODIGEST_REWRITERS-}" ] && __cdg_parses "$1" || return 1
if [ "$__cdg_shell" = zsh ]; then eval 'names=(${=CODIGEST_REWRITERS})'
else read -ra names <<< "$CODIGEST_REWRITERS"; fi
for name in "${names[@]}"; do
REPLY=
__cdg_now; t0=$__cdg_t
"$name" "$1" </dev/null >/dev/null 2>&1
s=$?
__cdg_now
(( s == 1 )) && continue
(( s == 0 || s == 2 )) && [[ -n ${REPLY//[[:space:]]/} ]] || return 1
if [ "$__cdg_shell" = zsh ]; then (( (__cdg_t - t0) * 1000 > 200 )) && return 1
elif [ -n "${EPOCHREALTIME-}" ]; then (( ${__cdg_t/[.,]/} - ${t0/[.,]/} > 200000 )) && return 1; fi
__cdg_parses "$REPLY" || return 1
# A line typed with a leading space stays out of the capture.
[[ $1 == [[:space:]]* && $REPLY != [[:space:]]* ]] && REPLY=" $REPLY"
__cdg_new=$REPLY
(( s == 2 )) && ! __cdg_ask "$REPLY" && __cdg_new=
return 0
done
return 1
}

# Opens the private file and the tee that copies into it. bash calls this on Enter,
# outside any pipeline: a process substitution made while bash sets up a pipeline
# joins that job, which then waits for tee while tee waits for the shell.
Expand Down Expand Up @@ -148,9 +185,20 @@ if [ "$__cdg_shell" = zsh ]; then
zmodload zsh/zselect 2>/dev/null
__cdg_accept() {
__cdg_line=
[[ -z $PREBUFFER ]] && __cdg_wants "$BUFFER" && __cdg_line=$BUFFER
if [[ -z $PREBUFFER ]]; then
if __cdg_rewrite "$BUFFER"; then BUFFER=$__cdg_new; CURSOR=$#BUFFER; fi
__cdg_wants "$BUFFER" && __cdg_line=$BUFFER
fi
zle __cdg_orig_accept
}
# Enter runs the line; any other key, Ctrl+C included, drops it.
__cdg_ask() {
local k
zle -M "$1 [Enter / Ctrl+C]"
read -k 1 k
zle -M ''
[[ $k == $'\n' || $k == $'\r' ]]
}
__cdg_preexec() {
[ -n "$__cdg_line" ] || return 0
__cdg_open "$__cdg_line"
Expand All @@ -171,10 +219,23 @@ else
# recorded without its output.
__cdg_skip+='|^[[:space:]]*\(([^(]|$)'
__cdg_mark() {
if [ -z "${__cdg_cont-}" ] && __cdg_wants "$READLINE_LINE"; then __cdg_open "$READLINE_LINE"; fi
if [ -z "${__cdg_cont-}" ]; then
# bash puts the accepted line, the new one, in its history.
if __cdg_rewrite "$READLINE_LINE"; then READLINE_LINE=$__cdg_new READLINE_POINT=${#__cdg_new}; fi
__cdg_wants "$READLINE_LINE" && __cdg_open "$READLINE_LINE"
fi
# Any further Enter before the next prompt is a continuation line.
__cdg_cont=1
}
# Enter runs the line; any other key drops it, and Ctrl+C makes readline drop it.
__cdg_ask() {
local k
printf '\n\033[32m%s\033[0m [Enter / Ctrl+C] ' "$1"
IFS= read -rsn1 k
local s=$?
printf '\n'
(( s == 0 )) && [[ -z $k || $k == $'\r' ]]
}
# Runs before every simple command. The first one of a recorded line starts the
# capture; reaching PROMPT_COMMAND first means the line had none.
__cdg_debug() {
Expand Down
Loading
Loading