Add !command syntax that runs commands with inherited terminal (no
capture). The lexer treats ! as a statement-starting token, reading
to end-of-line with the same interpolation/escape/pipe support as
backtick commands. The C function sets _ = {code=N, stdout="", stderr=""}.
5.3 KiB
Issue #14 — Prefix-based interactive command execution
Status: done Alternative to: #10, #12
Motivation
Issue #10 describes bare-word interactive commands via a parser heuristic. The architecture is complex:
- An 8-entry exception list to distinguish
ls -la(shell) fromprint("hello")(Lua) - Raw source buffer position tracking to work around
--being consumed as a Lua comment - A runtime fallback chain for edge cases like
echo "hello"parsing asecho("hello") - Issue #12 adding further parser rules for path-based commands (
./script,/bin/ls) - Four distinct fallback stages (parser heuristic → normal Lua → runtime retry → post-hoc REPL)
This issue proposes a simpler alternative: a ! prefix to explicitly mark interactive commands.
Syntax
A ! at statement position introduces an interactive command. Everything after ! to end-of-line is the command string.
> !ls -la
> !vim foo.lua
> !git commit -m "fix bug"
> !ssh user@host
> !./script.sh --deploy
> !/usr/bin/env python3
> !docker compose up -d
Why !?
!is not used in Lua syntax (notfor logical negation,~for bitwise NOT) — zero conflict at statement position- Familiar precedent: vim (
:!cmd), IPython/Jupyter (!cmd), ed/ex (!cmd) - Single character, minimal friction
Comparison with bare-word approach (#10)
| Bare-word (#10) | Prefix (#14) | |
|---|---|---|
| Typing | ls -la |
!ls -la |
| Parser complexity | High (exception list, raw source tracking, -- workaround) |
Minimal (! token → capture rest of line) |
| Runtime fallback | Required (echo "hello" edge case) |
Not needed |
| Path commands | Requires issue #12 | Works naturally (!./script.sh) |
-- flags |
Special handling (conflicts with Lua comments) | No conflict (raw capture after !) |
| Ambiguity | Subtle cases exist | None |
| Works in scripts | Yes (inside blocks) | Yes (inside blocks) |
The trade-off: one extra character per command, but dramatically simpler implementation with no edge cases.
Parser implementation
When the lexer sees ! at statement position:
- Record the current source position (character after
!) - Extract raw text to end-of-line from the source buffer
- The parser emits bytecode equivalent to
_ = __interactive("raw line text") - Advance the lexer past the consumed line
No heuristic, no exception list, no runtime fallback needed.
Return value and _
Interactive commands inherit the terminal — stdout/stderr are not captured. After the command completes, the result is assigned to _:
> !ls -l
(output appears directly in terminal)
> _.code
0
The table shape matches captured commands, but with blank stdout/stderr:
{code = <exit_code>, stdout = "", stderr = ""}
This keeps the interface consistent — code that checks .code works with both interactive and captured commands.
Runtime implementation
Shares core logic with backtick commands in lcmd.c:
- Reuses
parse_argv()for command string tokenization fork()without creating any pipes- Child: restore
SIG_DFLforSIGINT/SIGQUIT, thenexecvp()(inherits parent's stdin/stdout/stderr) - Parent: ignore
SIGINT/SIGQUIT(so Ctrl-C goes to child, not lush), thenwaitpid() - Build result table
{code=N, stdout="", stderr=""}and assign to_
Examples
-- basic usage
> !ls -la
> !git status
> !vim config.lua
> !make -j8
-- path-based commands (no special handling needed)
> !./configure --prefix=/usr
> !/usr/bin/env python3
-- double-dash flags (no comment conflict)
> !git --version
> !grep --include="*.lua" -r "pattern" .
-- check exit code
> !make test
> if _.code ~= 0 then print("tests failed") end
-- in scripts
!cargo build --release
if _.code ~= 0 then
print("build failed: " .. _.code)
os.exit(1)
end
What comes for free
- Interpolation: the command string passes through the same lexer pipeline as backtick strings, so
${expr}interpolation works without any additional handling. - Piping: the command string goes through
split_pipeline()(issue #06) before execution, so!ls -l | grep fooworks — earlier stages inherit the terminal for stderr, the last stage inherits it for stdout too.
Open questions
- Should
_also be set after captured (backtick) commands? - Could both approaches coexist?
!for the simple/reliable path, bare-word (#10) added later as sugar on top.
Files touched
| File | Change |
|---|---|
lcmd.c |
Add luaB_interactive (fork/exec without pipes) |
lcmd.h |
Declare new function |
linit.c |
Register __interactive global in opencommand() |
llex.c |
Recognise ! at statement position, extract raw line text |
lparser.c |
Emit _ = __interactive("raw text") when ! detected at statement position |