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
Binary file modified assets/demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/showcase-aurora.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/showcase-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/showcase-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/tutorial.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion examples/demo.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
: SC set type_speed 40
: SC set cmd_wait 80
: SC set cr_delay 10
: SC set prompt "$ "

# ── Scene: demo ───────────────────────────────
: SC scene demo
Expand Down
2 changes: 1 addition & 1 deletion scriptcast/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ def cli(
out_dir = Path(output_dir) if output_dir else in_path.parent
out_dir.mkdir(parents=True, exist_ok=True)
resolved_shell = shell or _default_shell()
theme_path = _resolve_theme(theme) if theme else None
theme_path = _resolve_theme(theme or "aurora")

config = build_config(
script_path=in_path if suffix != ".cast" else None,
Expand Down
2 changes: 1 addition & 1 deletion scriptcast/assets/themes/aurora.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@
: SC set theme-shadow-radius 20
: SC set theme-shadow-offset-y 21
: SC set theme-shadow-offset-x 0
: SC set prompt "\x1b[92m> \x1b[0m"
: SC set prompt $'\x1b[92m> \x1b[0m'
1 change: 1 addition & 0 deletions scriptcast/assets/themes/dark.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
: SC set theme-shadow-radius 20
: SC set theme-shadow-offset-y 21
: SC set theme-shadow-offset-x 0
: SC set prompt $'\x1b[92m> \x1b[0m'
1 change: 1 addition & 0 deletions scriptcast/assets/themes/light.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
: SC set theme-shadow-radius 20
: SC set theme-shadow-offset-y 21
: SC set theme-shadow-offset-x 0
: SC set prompt $'\x1b[92m> \x1b[0m'
10 changes: 8 additions & 2 deletions scriptcast/recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from .config import ScriptcastConfig
from .directives import ScEvent, build_directives
from .shell import get_adapter
from .shell import ShellAdapter, get_adapter

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -89,10 +89,16 @@ def _postprocess(
raw_text: str,
trace_prefix: str = "+",
directive_prefix: str = "SC",
adapter: ShellAdapter | None = None,
) -> str:
"""Convert raw .log text to JSONL .sc body (no header line)."""
directives = build_directives(directive_prefix, trace_prefix)
events = _parse_raw(raw_text, trace_prefix, directive_prefix)
if adapter is not None:
events = [
ScEvent(e.ts, e.type, adapter.unescape_xtrace(e.text)) if e.type == "dir" else e
for e in events
]
for d in directives:
events = d.post(events)
return _serialise(events)
Expand Down Expand Up @@ -184,7 +190,7 @@ def record(
xtrace_path = sc_path.with_suffix('.xtrace')
xtrace_path.write_text(raw_text)
logger.info("Saved: %s", xtrace_path)
clean_text = _postprocess(raw_text, config.trace_prefix, config.directive_prefix)
clean_text = _postprocess(raw_text, config.trace_prefix, config.directive_prefix, adapter)
logger.debug("Post-processed to %d events", clean_text.count("\n"))

header = json.dumps({
Expand Down
6 changes: 6 additions & 0 deletions scriptcast/shell/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ def name(self) -> str: ...
def tracing_preamble(self, trace_prefix: str) -> str:
"""Return shell code to enable tracing with the given prefix."""
...

def unescape_xtrace(self, text: str) -> str:
"""Decode shell-specific quoting in a directive text from xtrace output.
Default implementation is identity (correct for bash).
"""
return text
61 changes: 61 additions & 0 deletions scriptcast/shell/zsh.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,63 @@
# scriptcast/shell/zsh.py
import re

from .adapter import ShellAdapter

_ANSI_C_RE = re.compile(r"\$'((?:[^'\\]|\\.)*)'")
_SIMPLE_ESCAPES = {
'n': '\n', 'r': '\r', 't': '\t', 'a': '\a',
'b': '\b', 'f': '\f', 'v': '\v', "'": "'", '\\': '\\',
}


def _decode_ansi_c_body(body: str) -> str:
"""Decode the interior of a $'...' ANSI-C quoted string as produced by zsh xtrace."""
out: list[str] = []
i = 0
while i < len(body):
ch = body[i]
if ch != '\\' or i + 1 >= len(body):
out.append(ch)
i += 1
continue
nxt = body[i + 1]
if nxt in _SIMPLE_ESCAPES:
out.append(_SIMPLE_ESCAPES[nxt])
i += 2
elif nxt in ('e', 'E'):
out.append('\x1b')
i += 2
elif nxt in ('C', 'c') and i + 3 < len(body) and body[i + 2] == '-':
# \C-X → Ctrl+X; e.g. \C-[ → chr(91-64) = chr(27) = ESC
code = ord(body[i + 3].upper()) - 64
if 0 <= code <= 127:
out.append(chr(code))
i += 4
else:
out.append('\\')
out.append(nxt)
i += 2
elif nxt == 'x':
hex_str = body[i + 2:i + 4]
if len(hex_str) == 2 and all(c in '0123456789abcdefABCDEF' for c in hex_str):
out.append(chr(int(hex_str, 16)))
i += 4
else:
out.append('\\')
out.append(nxt)
i += 2
elif nxt in '01234567':
j = i + 1
while j < len(body) and j < i + 4 and body[j] in '01234567':
j += 1
out.append(chr(int(body[i + 1:j], 8)))
i = j
else:
out.append('\\')
out.append(nxt)
i += 2
return ''.join(out)


class ZshAdapter(ShellAdapter):
@property
Expand All @@ -9,3 +66,7 @@ def name(self) -> str:

def tracing_preamble(self, trace_prefix: str) -> str:
return f'PS4="{trace_prefix} "\nsetopt xtrace\n'

def unescape_xtrace(self, text: str) -> str:
"""Expand $'...' ANSI-C spans in zsh xtrace directive text."""
return _ANSI_C_RE.sub(lambda m: _decode_ansi_c_body(m.group(1)), text)
2 changes: 1 addition & 1 deletion tests/test_directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def test_sc_event_fields():
def test_sc_event_is_frozen():
e = ScEvent(ts=1.0, type="cmd", text="echo hi")
with pytest.raises(dataclasses.FrozenInstanceError):
e.ts = 2.0 # type: ignore[misc]
e.ts = 2.0


def test_directive_pre_passthrough():
Expand Down
53 changes: 53 additions & 0 deletions tests/test_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import json
import logging
import shutil
from unittest.mock import MagicMock

import pytest

from scriptcast.config import ScriptcastConfig
from scriptcast.directives import ScEvent
Expand Down Expand Up @@ -460,3 +463,53 @@ def test_record_no_xtrace_log_by_default(tmp_path):
xtrace_path = tmp_path / "demo.xtrace"
assert not xtrace_path.exists()


def test_postprocess_applies_unescape_to_dir_events():
"""adapter.unescape_xtrace is called on dir events, not cmd or out events."""
adapter = MagicMock()
adapter.unescape_xtrace.side_effect = lambda t: t.replace("BEFORE", "AFTER")

raw = (
"1.0 + : SC scene main\n"
"1.1 + echo hi\n"
"1.2 hello\n"
)
_postprocess(raw, adapter=adapter)

# called once for the dir event "scene main", not for cmd or out
assert adapter.unescape_xtrace.call_count == 1
adapter.unescape_xtrace.assert_called_with("scene main")


def test_postprocess_unescape_transforms_dir_text():
"""unescape result is used as the directive text in the .sc output."""
adapter = MagicMock()
adapter.unescape_xtrace.return_value = "set prompt \x1b[92m> \x1b[0m"

raw = "1.0 + : SC set prompt $'\\C-[[92m> \\C-[[0m'\n"
sc_body = _postprocess(raw, adapter=adapter)

events = [json.loads(line) for line in sc_body.strip().splitlines()]
assert events[0][1] == "dir"
assert events[0][2] == "set prompt \x1b[92m> \x1b[0m"


def test_record_zsh_prompt_esc_bytes(tmp_path):
"""zsh $'...' prompt survives record → .sc with correct ESC bytes."""
zsh = shutil.which("zsh")
if zsh is None:
pytest.skip("zsh not available")

script = tmp_path / "t.sh"
# $'\x1b[92m> \x1b[0m' expands to ESC bytes; zsh xtrace uses $'\C-[...'
script.write_text(": SC set prompt $'\\x1b[92m> \\x1b[0m'\n")
sc_path = tmp_path / "t.sc"
record(script, sc_path, ScriptcastConfig(), zsh)

lines = sc_path.read_text().splitlines()
dir_events = [
json.loads(ln) for ln in lines[1:]
if json.loads(ln)[1] == "dir" and json.loads(ln)[2].startswith("set prompt")
]
assert dir_events, "no 'set prompt' dir event found in .sc"
assert dir_events[0][2] == "set prompt \x1b[92m> \x1b[0m"
60 changes: 60 additions & 0 deletions tests/test_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,86 @@
def test_get_bash():
assert isinstance(get_adapter("bash"), BashAdapter)


def test_get_zsh():
assert isinstance(get_adapter("zsh"), ZshAdapter)


def test_get_full_path():
assert isinstance(get_adapter("/bin/bash"), BashAdapter)


def test_get_unsupported_raises():
with pytest.raises(ValueError, match="Unsupported shell"):
get_adapter("fish")


def test_bash_preamble_contains_set_x():
p = BashAdapter().tracing_preamble("+")
assert "set -x" in p
assert 'PS4="+ "' in p


def test_bash_preamble_custom_prefix():
p = BashAdapter().tracing_preamble(">>")
assert 'PS4=">> "' in p


def test_zsh_preamble_contains_xtrace():
p = ZshAdapter().tracing_preamble("+")
assert "setopt xtrace" in p
assert 'PS4="+ "' in p


def test_bash_unescape_xtrace_is_identity():
adapter = BashAdapter()
text = "set prompt $'\\C-[[92m> \\C-[[0m'"
assert adapter.unescape_xtrace(text) == text


def test_zsh_unescape_ctrl_bracket_to_esc():
# \C-[ is zsh's notation for ESC (Ctrl+[, chr 27)
result = ZshAdapter().unescape_xtrace("set prompt $'\\C-[[92m> \\C-[[0m'")
assert result == "set prompt \x1b[92m> \x1b[0m"


def test_zsh_unescape_octal():
result = ZshAdapter().unescape_xtrace("set prompt $'\\033[92m> \\033[0m'")
assert result == "set prompt \x1b[92m> \x1b[0m"


def test_zsh_unescape_hex():
result = ZshAdapter().unescape_xtrace("set prompt $'\\x1b[92m> \\x1b[0m'")
assert result == "set prompt \x1b[92m> \x1b[0m"


def test_zsh_unescape_escape_letter():
result = ZshAdapter().unescape_xtrace("set prompt $'\\e[92m> \\e[0m'")
assert result == "set prompt \x1b[92m> \x1b[0m"


def test_zsh_unescape_standard_escapes():
result = ZshAdapter().unescape_xtrace("$'\\n\\t\\r\\\\'")
assert result == "\n\t\r\\"


def test_zsh_unescape_no_dollar_quote_unchanged():
text = "set prompt '\\033[92m> \\033[0m'"
assert ZshAdapter().unescape_xtrace(text) == text


def test_zsh_unescape_multiple_spans():
result = ZshAdapter().unescape_xtrace("$'\\C-['foo$'\\C-['")
assert result == "\x1bfoo\x1b"


def test_zsh_unescape_unknown_escape_passthrough():
# \q is not a known escape — passes through unchanged
result = ZshAdapter().unescape_xtrace("$'\\q'")
assert result == "\\q"


def test_zsh_unescape_ctrl_out_of_range_passthrough():
# \C-? would give chr(-1) — should passthrough, not crash
result = ZshAdapter().unescape_xtrace("$'\\C-?'")
assert result == "\\C-?"
Loading