diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7a316d..3e5a10a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,8 @@ jobs: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v10.3.0 - run: uv python install 3.15 + # the terminal tests drive bash, zsh and pwsh on a pseudo-terminal; the runner has no zsh + - run: sudo apt-get install -y -qq zsh - run: uv run --with pytest pytest -q powershell-help: diff --git a/README.ko.md b/README.ko.md index c29de06..87b0775 100644 --- a/README.ko.md +++ b/README.ko.md @@ -457,6 +457,31 @@ cdg term clear # 기록 모두 지우기 마지막 줄바꿈 포함) 돌아가고, install이 만든 파일은 그때 만든 폴더와 함께 지워집니다. 데이터 폴더의 훅 사본도 지워집니다. 기록된 명령은 `cdg term clear`로 지울 때까지 남습니다. +##### 다른 명령을 실행하는 프로그램 + +[hanfix](https://github.com/NaNIsaac/hanfix)의 `hf`(한글 자판으로 친 직전 명령을 고쳐 다시 실행)처럼 +명령 줄을 대신 실행하는 프로그램은, 그대로 두면 통째로 기록되고 그 안에서 실행한 명령이 터미널 대신 +기록용 파이프를 받게 됩니다. 이런 프로그램은 두 단계로 훅과 함께 쓸 수 있습니다. + +1. 불러올 때 자기 명령 이름을 `CODIGEST_RUNNERS`에 더합니다. 이 이름으로 시작하는 줄은 기록하지 + 않습니다. 덮어쓰지 말고 더해야 시작 파일에서 codigest와 어느 쪽을 먼저 불러와도 됩니다. + - PowerShell: `$global:CODIGEST_RUNNERS += 'hf'` + - bash/zsh: `CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} hf"` (공백으로 나눈 셸 변수) +2. 명령 줄을 실행할 때 `__cdg_run`이 정의되어 있으면 그것으로 실행합니다(실행하는 그때 찾습니다). + 그 줄에 훅의 규칙을 그대로 적용하므로, 터미널이 필요한 프로그램은 터미널을 받고 나머지는 그 줄로 + 기록됩니다. + - PowerShell: `if (Get-Command __cdg_run -CommandType Function -ErrorAction Ignore) { . __cdg_run $line } else { Invoke-Expression $line }` + - bash/zsh: `if typeset -f __cdg_run >/dev/null; then __cdg_run "$line"; else eval "$line"; fi` + +2단계를 하지 않는 프로그램도 `CODIGEST_RUNNERS`에 넣을 수 있습니다. 그러면 그 프로그램이 실행하는 +명령은 터미널을 받고 기록되지 않습니다. [thefuck](https://github.com/nvbn/thefuck)이 그런 예입니다: +`$global:CODIGEST_RUNNERS += 'fuck'` (bash/zsh: `CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} fuck"`). + +PowerShell에서는 다른 `PSConsoleHostReadLine`(VS Code의 셸 통합, atuin)과 함께 동작합니다. 줄을 읽을 +때 그것을 불러 입력한 그대로의 줄을 보게 하고, 돌려받은 줄만 감쌉니다. 그런 프로그램이 훅보다 나중에 +불러와지면, 그것이 줄을 직접 읽더라도(atuin처럼) 훅은 셸이 입력을 기다리는 동안 다시 앞에 섭니다. +그 직후의 첫 줄은 기록되지 않습니다. + 프로필은 패키지 안의 파일이 아니라, `cdg term install`이 데이터 폴더(기록 옆의 `shell/`)에 둔 훅 사본을 불러옵니다. codigest를 업데이트한 뒤에는 `cdg term install`을 다시 실행해 사본을 갱신하세요. 알아 둘 점: @@ -471,6 +496,8 @@ cdg term clear # 기록 모두 지우기 - PowerShell 훅은 원격 세션, 중첩 프롬프트, 디버거에서는 줄을 감싸지 않습니다. - bash 훅은 DEBUG 트랩을 씁니다. 첫 프롬프트 전에 설정된 트랩(starship, bash-preexec)은 그대로 두지만, 그 뒤에 새로 설정한 DEBUG 트랩은 훅을 덮어씁니다. +- bash에서 VS Code의 셸 통합을 훅보다 나중에 불러오면 기록되지 않습니다 + ([#52](https://github.com/NaNIsaac/codigest/issues/52)). ### MCP 도구 diff --git a/README.md b/README.md index a80be73..f698cde 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,34 @@ normally) and appear in your history as typed. Programs that need a real termina any folder made for it, as are the hook copies in the data folder. Captured commands stay until `cdg term clear`. +##### Programs that run other commands + +A program that runs a command line for you, such as `hf` from +[hanfix](https://github.com/NaNIsaac/hanfix) (it re-runs the previous command typed with the Korean +layout on), would otherwise be captured as a whole, and the command it runs would get the capture +pipe instead of the terminal. Such a program can work with the hook in two steps: + +1. Add its command names to `CODIGEST_RUNNERS` when it loads; the hook does not capture a line that + starts with one of them. Append, so the order in which the startup file loads it and codigest + does not matter: + - PowerShell: `$global:CODIGEST_RUNNERS += 'hf'` + - bash/zsh: `CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} hf"` (a shell variable, space-separated) +2. Run each command line through `__cdg_run` when it is defined, looked up when the line runs. It + applies the hook's own rules to that line: a program that needs the terminal keeps it, anything + else is captured under that line. + - PowerShell: `if (Get-Command __cdg_run -CommandType Function -ErrorAction Ignore) { . __cdg_run $line } else { Invoke-Expression $line }` + - bash/zsh: `if typeset -f __cdg_run >/dev/null; then __cdg_run "$line"; else eval "$line"; fi` + +A program that does not do step 2 can still be listed in `CODIGEST_RUNNERS`: what it runs then keeps +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"`). + +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 +waits for input, also when that one reads the line itself (atuin does); the first line after it +is not captured. + The profile loads a copy of the hook that `cdg term install` puts in the data folder (`shell/` next to the captures), not the file inside the package. After updating codigest, run `cdg term install` again to refresh it. Limitations: @@ -495,6 +523,8 @@ Limitations: normally. In bash, neither are lines that start with a subshell `( ... )`. - In bash the hook uses the DEBUG trap and keeps one set before the first prompt (starship, bash-preexec); a DEBUG trap set later replaces it. +- In bash, VS Code's shell integration loaded after the hook stops the capture + ([#52](https://github.com/NaNIsaac/codigest/issues/52)). - Long output is bounded: the PowerShell hook keeps the first and last 2,000 lines, and a bash or zsh capture file over 4 MB is read 1 MB from each end. The store then keeps 64 KB. - In PowerShell, a command counts as failed when its exit code is non-zero or an error reached diff --git a/src/codigest/shell/hook.ps1 b/src/codigest/shell/hook.ps1 index a9f6ebe..df78359 100644 --- a/src/codigest/shell/hook.ps1 +++ b/src/codigest/shell/hook.ps1 @@ -46,7 +46,7 @@ function global:__cdg { return } # Dot-sourced: these locals live in the caller's scope, hence the prefix and cleanup. - $__cdgLine = $global:__cdgLine + $__cdgCommand = $global:__cdgLine # Left as is so the line can read it; a native command stores a new boxed int, # so a reference change means one ran, even with the same exit code. $__cdgPrevExit = $global:LASTEXITCODE @@ -76,7 +76,7 @@ function global:__cdg { if ($global:__cdgTail.Count) { $__cdgText += $global:__cdgTail | Out-String } try { $__cdgJson = @{ - command = $__cdgLine + command = $__cdgCommand output = $__cdgText exit_code = $__cdgExit cwd = $PWD.ProviderPath @@ -105,7 +105,7 @@ function global:__cdg { [Exception]::new("exit $__cdgExit"), 'CodigestExit', 'NotSpecified', $null)) $ErrorActionPreference = $__cdgPref } - Remove-Variable __cdgPref, __cdgBlock, __cdgLine, __cdgPrevExit, __cdgUnbuf, __cdgStart, __cdgText, __cdgExit, __cdgJson, __cdgBytes, __cdgPsi, __cdgProc -ErrorAction Ignore + Remove-Variable __cdgPref, __cdgBlock, __cdgCommand, __cdgPrevExit, __cdgUnbuf, __cdgStart, __cdgText, __cdgExit, __cdgJson, __cdgBytes, __cdgPsi, __cdgProc -ErrorAction Ignore } } @@ -148,22 +148,70 @@ function global:__cdgRestoreHistory { } | Add-History } -# Replaces PSReadLine's own PSConsoleHostReadLine, which the host calls for every prompt. -# PSReadLine draws and stores the line as typed; only the string returned to the engine -# is wrapped, so nothing changes on screen. +# Whether a line is captured. A program that runs other command lines (hanfix's hf) lists +# its names in $global:CODIGEST_RUNNERS: its own line is not captured, so it keeps the +# terminal, and it hands each line it runs to __cdg_run, which decides for that line. +function global:__cdgWants([string]$line) { + $line.Trim() -and $line -notmatch '^\s' -and $line -notmatch $global:__cdgSkip -and + -not ($env:CODIGEST_CAP_SKIP -and $line -match $env:CODIGEST_CAP_SKIP) -and -not $env:CODIGEST_NOCAP -and + ($line.Trim() -split '\s+', 2)[0] -notin @($global:CODIGEST_RUNNERS) +} + +# Runs a command line for a runner, captured as if it had been typed. Dot-source it +# (`. __cdg_run $line`) so the line runs in the runner's scope, as Invoke-Expression would. +function global:__cdg_run([string]$__cdgRunLine) { + $__cdgRunBlock = [scriptblock]::Create($__cdgRunLine) + if ($null -eq $global:__cdgHead -and (__cdgWants $__cdgRunLine)) { + $global:__cdgLine = $__cdgRunLine + . __cdg $__cdgRunBlock + } + else { . $__cdgRunBlock } +} + +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 + [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext) } +} + +# Another PSConsoleHostReadLine (atuin, a terminal's shell integration) is called for the line +# and gets it as typed; codigest wraps only what it returns. PSReadLine's own is not one: it +# would lose the last status. Loading the hook again keeps the one found the first time. +$__cdgTop = Get-Item function:PSConsoleHostReadLine -ErrorAction Ignore +if ($__cdgTop -and $__cdgTop.Module.Name -ne 'PSReadLine' -and $__cdgTop.Definition -notmatch 'codigest-readline') { + $global:__cdgPrev = $__cdgTop.ScriptBlock +} +Remove-Variable __cdgTop + +# Replaces PSConsoleHostReadLine, which the host calls for every prompt. PSReadLine draws and +# stores the line as typed; only the string returned to the engine is wrapped, so nothing +# changes on screen. function global:PSConsoleHostReadLine { + # codigest-readline (marks this function as codigest's) # First statement: any script run resets $?, and PSReadLine shows it in the prompt. $__cdgOk = $? Microsoft.PowerShell.Core\Set-StrictMode -Off + # Called back by the function this one calls: read the line and nothing more. + if ($global:__cdgReading) { return __cdgReadLine $global:__cdgOk } __cdgRestoreHistory - $line = try { [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $__cdgOk) } - catch [Management.Automation.MethodException] { # PSReadLine < 2.2 has no status argument - [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext) } + # A program that loaded later (VS Code's shell integration) wrapped this function and calls + # it for the line. Put this one back on top, around that one, for the next line; this line + # goes to it as typed and is not captured. + if ((Get-Item function:PSConsoleHostReadLine).Definition -notmatch 'codigest-readline') { + $global:__cdgPrev = (Get-Item function:PSConsoleHostReadLine).ScriptBlock + 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 - $wrap = $local -and $line.Trim() -and $line -notmatch '^\s' -and $line -notmatch $global:__cdgSkip -and - -not ($env:CODIGEST_CAP_SKIP -and $line -match $env:CODIGEST_CAP_SKIP) -and -not $env:CODIGEST_NOCAP + $wrap = $local -and (__cdgWants $line) if ($wrap) { $tokens = $null; $errs = $null # Single-line form first; the newline form survives a trailing `# comment`. @@ -178,6 +226,23 @@ function global:PSConsoleHostReadLine { } $line } +$global:__cdgSelf = (Get-Item function:PSConsoleHostReadLine).ScriptBlock + +# A program that loads later and never calls the function it replaced (atuin reads the line +# itself) would stop the capture for good. While PSReadLine waits for a line, put this one back +# on top around that one; from the next prompt on, codigest is called again. -SupportEvent +# keeps the subscription out of Get-Job, and the flag keeps a reload from adding a second one. +if (-not $global:__cdgIdle) { + $global:__cdgIdle = $true + Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -SupportEvent -Action { + $top = Get-Item function:PSConsoleHostReadLine -ErrorAction Ignore + if ($top -and $top.Module.Name -ne 'PSReadLine' -and $top.Definition -notmatch 'codigest-readline') { + __cdgRestoreHistory + $global:__cdgPrev = $top.ScriptBlock + Set-Item function:global:PSConsoleHostReadLine $global:__cdgSelf + } + } +} # Lets `cdg term` tell whether this shell is hooked. $env:CODIGEST_HOOK = '1' diff --git a/src/codigest/shell/hook.sh b/src/codigest/shell/hook.sh index 405184a..15fd12c 100644 --- a/src/codigest/shell/hook.sh +++ b/src/codigest/shell/hook.sh @@ -60,6 +60,11 @@ __cdg_wants() { [[ $1 =~ $__cdg_skip ]] && return 1 [[ -n ${CODIGEST_CAP_SKIP-} && $1 =~ $CODIGEST_CAP_SKIP ]] && return 1 [[ $1 =~ $__cdg_commit && ! $1 =~ $__cdg_msg ]] && return 1 + # A program that runs other command lines (hanfix's hf) lists its names in + # CODIGEST_RUNNERS: its own line is not captured, so it keeps the terminal, and it + # hands each line it runs to __cdg_run, which decides for that line. + local first=${1%%[[:space:]]*} + [[ " ${CODIGEST_RUNNERS-} " == *" $first "* ]] && return 1 # The recorded line would show `!!`-style history expansion unexpanded. if [ "$__cdg_shell" = zsh ]; then [[ -o banghist && $1 == *!* ]] && return 1 else [[ $- == *H* && $1 == *!* ]] && return 1; fi @@ -127,6 +132,17 @@ __cdg_post() { return $s } +# Runs a command line for a runner, captured as if it had been typed. The DEBUG trap and +# preexec do not run for the commands inside a function, so the capture starts here; the +# next prompt ends it as usual. +__cdg_run() { + if [ -z "$__cdg_file" ] && __cdg_wants "$1"; then + __cdg_open "$1" + __cdg_start + fi + eval "$1" +} + __cdg_file= __cdg_w= __cdg_busy= if [ "$__cdg_shell" = zsh ]; then zmodload zsh/zselect 2>/dev/null diff --git a/tests/test_hook_terminal.py b/tests/test_hook_terminal.py new file mode 100644 index 0000000..1ab41eb --- /dev/null +++ b/tests/test_hook_terminal.py @@ -0,0 +1,350 @@ +"""The capture hooks in an interactive shell on a pseudo-terminal. + +A line the hooks capture gives the programs it runs a pipe for stdout. A runner (a program that +runs other command lines, like hanfix's hf) lists itself in CODIGEST_RUNNERS: its own line keeps +the terminal, and each line it hands to __cdg_run is captured or not by the hook's own rules. +""" + +import json +import os +import shutil +import subprocess +import sys +import threading +import time +from pathlib import Path + +import pytest + +SHELL_DIR = Path(__file__).parents[1] / "src" / "codigest" / "shell" +PROBE = """import os, sys +print("out-" + sys.argv[2], flush=True) +with open(sys.argv[1], "a") as f: + f.write(f"{sys.argv[2]} {os.isatty(1)}\\n") +""" +# Stands in for `cdg term ingest`: one JSON line per captured command. +FAKE_CDG = """import json, sys +args = sys.argv[1:] +files = [a.split("=", 1)[1] for a in args if a.startswith("--file=")] +if files: + command, _, output = open(files[0], "rb").read().decode().partition("\\0") +else: + record = json.loads(sys.stdin.buffer.read().decode("utf-8-sig")) + command, output = record["command"], record["output"] +with open(sys.argv[0] + ".log", "a", encoding="utf-8") as f: + f.write(json.dumps({"command": command, "output": output}) + "\\n") +""" +WINDOWS = os.name == "nt" + + +class Pty: + """An interactive program on a pseudo-terminal: stdlib pty on POSIX, ConPTY on Windows.""" + + def __init__(self, argv, env): + self.output = [] + if WINDOWS: + winpty = pytest.importorskip("winpty", reason="pywinpty is needed for a ConPTY") + self.proc = winpty.PtyProcess.spawn(argv, env=env, dimensions=(50, 250)) + self._write = self.proc.write + self._read = lambda: self.proc.read(4096) + else: + import fcntl + import pty + import struct + import termios + + pid, self.fd = pty.fork() + if pid == 0: + os.execvpe(argv[0], argv, env) + self.pid = pid + fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 250, 0, 0)) + self._write = lambda text: os.write(self.fd, text.encode()) + self._read = lambda: os.read(self.fd, 4096).decode(errors="replace") + threading.Thread(target=self._pump, daemon=True).start() + + def _pump(self): + while True: + try: + chunk = self._read() + except EOFError, OSError: + return + if not chunk: + return + self.output.append(chunk) + # PSReadLine on POSIX waits for the cursor position report a terminal sends; ConPTY answers itself + if not WINDOWS: + for _ in range(chunk.count("\x1b[6n")): + self._write("\x1b[1;1R") + + def type_lines(self, lines, pause): + for line in lines: + time.sleep(pause) + self._write(line + "\r") + + def wait(self, timeout): + deadline = time.time() + timeout + if WINDOWS: + while self.proc.isalive() and time.time() < deadline: + time.sleep(0.2) + self.proc.terminate(force=True) + return + while time.time() < deadline: + if os.waitpid(self.pid, os.WNOHANG)[0]: + return + time.sleep(0.2) + os.kill(self.pid, 9) + os.waitpid(self.pid, 0) + + +class Session: + """A sandbox: fake cdg on PATH, the probe, and what both recorded.""" + + def __init__(self, tmp_path, to_path=str): + self.tmp = tmp_path + self.to_path = to_path + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "fake_cdg.py" + fake.write_text(FAKE_CDG, encoding="utf-8") + self.ingest_log = Path(f"{fake}.log") + # The hooks only load when cdg is on PATH. + script = bin_dir / "cdg" + script.write_text( + f'#!/bin/sh\nexec "{to_path(sys.executable)}" "{to_path(fake)}" "$@"\n', + encoding="utf-8", + ) + script.chmod(0o755) + (bin_dir / "cdg.cmd").write_text(f'@"{sys.executable}" "{fake}" %*\r\n', encoding="utf-8") + self.probe_out = tmp_path / "probe.txt" + probe = tmp_path / "probe.py" + probe.write_text(PROBE, encoding="utf-8") + self.probe = f"'{to_path(sys.executable)}' '{to_path(probe)}' '{to_path(self.probe_out)}'" + self.env = dict(os.environ) + self.env.update( + PATH=f"{bin_dir}{os.pathsep}{self.env['PATH']}", + HOME=str(tmp_path), + TERM="xterm-256color", + CODIGEST_CAP_SKIP="keeptty", + ) + for name in ("CODIGEST_NOCAP", "CODIGEST_RUNNERS", "CODIGEST_HOOK", "TERM_PROGRAM"): + self.env.pop(name, None) + + def run(self, argv, lines, pause): + term = Pty(argv, self.env) + term.type_lines(lines, pause) + term.wait(40) + time.sleep(1) # ingest runs in the background + return "".join(term.output) + + def ttys(self): + words = ( + self.probe_out.read_text(encoding="utf-8").split() if self.probe_out.exists() else [] + ) + return dict(zip(words[::2], words[1::2], strict=True)) + + def captured(self): + if not self.ingest_log.exists(): + return [] + return [ + json.loads(line) for line in self.ingest_log.read_text(encoding="utf-8").splitlines() + ] + + +def need(exe): + path = shutil.which(exe) + if not path: + pytest.skip(f"{exe} not installed") + return path + + +def vscode_script(shell): + code = shutil.which("code") + if not code: + pytest.skip("VS Code not installed") + out = subprocess.run( + [code, "--locate-shell-integration-path", shell], + check=False, + capture_output=True, + text=True, + shell=WINDOWS, + ) + path = Path(out.stdout.strip()) + if not path.is_file(): + pytest.skip(f"VS Code has no shell integration script for {shell}") + return path + + +def reported_lines(screen): + """The command lines a terminal's shell integration reported (OSC 633 ; E ; [; nonce]).""" + return [part.split("\x07")[0].split(";")[0] for part in screen.split("\x1b]633;E;")[1:]] + + +def pwsh_argv(pwsh, s): + # Set before the first prompt, so PSReadLine neither reads nor writes the user's history file. + setup = ( + f"Set-PSReadLineOption -HistorySavePath '{s.tmp / 'history.txt'}' -HistorySaveStyle SaveNothing " + "-PredictionSource History" + ) + return [pwsh, "-NoProfile", "-NoLogo", "-NoExit", "-Command", setup] + + +def pwsh_lines(s, hook_first, hook_times=1): + probe = f"& {s.probe}" + runner = [ + f'function runit {{ {probe} runner; . __cdg_run "{probe} keeptty"; . __cdg_run "{probe} captured" }}', + "$global:CODIGEST_RUNNERS += 'runit'", + ] + hook = [f". '{SHELL_DIR / 'hook.ps1'}'"] * hook_times + return [ + f"function wrapped {{ {probe} wrapped }}", + *(hook + runner if hook_first else runner + hook), + "wrapped", + "runit", + ] + + +def history_lines(s): + hist = s.tmp / "hist.txt" + return hist, [ + f"[Microsoft.PowerShell.PSConsoleReadLine]::GetHistoryItems().CommandLine | Set-Content '{hist}'", + f"(Get-History).CommandLine | Add-Content '{hist}'", + f"\"prev=$($null -eq $global:__cdgPrev)\" | Add-Content '{hist}'", + ] + + +ALL_TTYS = {"wrapped": "False", "runner": "True", "keeptty": "True", "captured": "False"} + + +@pytest.mark.parametrize("hook_first", [True, False], ids=["hook-then-runner", "runner-then-hook"]) +def test_pwsh_runner_keeps_the_terminal_and_hands_lines_to_cdg_run(tmp_path, hook_first): + pwsh = need("pwsh") + s = Session(tmp_path) + hist, report = history_lines(s) + screen = s.run(pwsh_argv(pwsh, s), [*pwsh_lines(s, hook_first), *report, "exit"], pause=2) + assert s.ttys() == ALL_TTYS, screen[-2000:] + commands = [c["command"] for c in s.captured()] + assert "wrapped" in commands and f"& {s.probe} captured" in commands, commands + assert "runit" not in commands and f"& {s.probe} keeptty" not in commands, commands + assert any("out-captured" in c["output"] for c in s.captured()) + lines = hist.read_text(encoding="utf-8").splitlines() + # PSReadLine and the engine history both keep the lines as typed, once each + assert lines.count("wrapped") == 2 and lines.count("runit") == 2, lines + assert not any(line.startswith(". __cdg") for line in lines), lines + assert lines[-1] == "prev=True", lines + + +def test_pwsh_hook_loaded_twice_chains_nothing(tmp_path): + pwsh = need("pwsh") + s = Session(tmp_path) + hist, report = history_lines(s) + s.run(pwsh_argv(pwsh, s), [*pwsh_lines(s, True, hook_times=2), *report, "exit"], pause=2) + assert s.ttys() == ALL_TTYS + assert [c["command"] for c in s.captured()].count("wrapped") == 1 + lines = hist.read_text(encoding="utf-8").splitlines() + assert lines.count("wrapped") == 2 and lines[-1] == "prev=True", lines + + +def test_pwsh_readline_replaced_later_without_calling_codigest(tmp_path): + pwsh = need("pwsh") + s = Session(tmp_path) + hist, report = history_lines(s) + *setup, wrapped, runit = pwsh_lines(s, True) + # atuin's PSConsoleHostReadLine reads the line itself and never calls the one it replaced + other = ( + "function global:PSConsoleHostReadLine { " + "[Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $true) }" + ) + lines = [*setup, other, "$null", wrapped, runit, *report, "exit"] + screen = s.run(pwsh_argv(pwsh, s), lines, pause=2) + assert s.ttys() == ALL_TTYS, screen[-2000:] + assert "wrapped" in [c["command"] for c in s.captured()] + lines = hist.read_text(encoding="utf-8").splitlines() + assert not any(line.startswith(". __cdg") for line in lines), lines + assert lines.count("wrapped") == 2 and lines[-1] == "prev=False", lines + + +def test_pwsh_vscode_integration_sees_the_typed_line(tmp_path): + pwsh = need("pwsh") + script = vscode_script("pwsh") + s = Session(tmp_path) + lines = [ + f"function wrapped {{ & {s.probe} wrapped }}", + f". '{SHELL_DIR / 'hook.ps1'}'", + f". '{script}'", # VS Code loads its integration after the profile + "$null", # the first line after it puts codigest back on top, uncaptured + "wrapped", + "exit", + ] + screen = s.run(pwsh_argv(pwsh, s), lines, pause=2) + reported = reported_lines(screen) + assert "wrapped" in reported, reported + assert not any(r.startswith(". __cdg") for r in reported), reported + assert s.ttys() == {"wrapped": "False"} + assert "wrapped" in [c["command"] for c in s.captured()] + + +def posix_shell(shell): + exe = need(shell) + if ( + WINDOWS + and subprocess.run([exe, "-c", "exit 0"], check=False, capture_output=True).returncode + ): + pytest.skip(f"{shell} does not run here") + to_path = str + if WINDOWS: # Git Bash + + def to_path(p): + return subprocess.check_output([exe, "-c", f"cygpath -u '{p}'"], text=True).strip() + + argv = [exe, "--norc", "-i"] if shell == "bash" else [exe, "-f", "-i"] + return argv, to_path + + +@pytest.mark.parametrize("hook_first", [True, False], ids=["hook-then-runner", "runner-then-hook"]) +@pytest.mark.parametrize("shell", ["bash", "zsh"]) +def test_sh_runner_keeps_the_terminal_and_hands_lines_to_cdg_run(tmp_path, shell, hook_first): + argv, to_path = posix_shell(shell) + s = Session(tmp_path, to_path) + runner = [ + f'runit() {{ {s.probe} runner; __cdg_run "{s.probe} keeptty"; __cdg_run "{s.probe} captured"; }}', + 'CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} runit"', + ] + hook = [f". '{to_path(SHELL_DIR / 'hook.sh')}'"] + jobs = tmp_path / "jobs.txt" + lines = [ + f"wrapped() {{ {s.probe} wrapped; }}", + *(hook + runner if hook_first else runner + hook), + "wrapped", + "runit", + # the capture __cdg_run opened inside a function left no job behind, and the shell goes on + f"jobs > '{to_path(jobs)}'", + f"{s.probe} after", + "exit", + ] + screen = s.run(argv, lines, pause=1.5) + assert s.ttys() == {**ALL_TTYS, "after": "False"}, screen[-2000:] + records = {c["command"]: c["output"] for c in s.captured()} + assert "out-captured" in records.get(f"{s.probe} captured", ""), records + assert "runit" not in records and f"{s.probe} keeptty" not in records, records + assert jobs.read_text(encoding="utf-8").strip() == "" + + +@pytest.mark.xfail( + strict=True, reason="#52: VS Code runs the chained DEBUG trap only at the Enter handler" +) +def test_bash_vscode_integration_sees_the_typed_line(tmp_path): + argv, to_path = posix_shell("bash") + script = vscode_script("bash") + s = Session(tmp_path, to_path) + lines = [ + f"wrapped() {{ {s.probe} wrapped; }}", + f". '{to_path(SHELL_DIR / 'hook.sh')}'", + f". '{to_path(script)}'", + "true", + "wrapped", + "exit", + ] + screen = s.run(argv, lines, pause=1.5) + assert "wrapped" in reported_lines(screen), reported_lines(screen) + assert s.ttys() == {"wrapped": "False"} + assert "wrapped" in [c["command"] for c in s.captured()]