Files
lush/issues/10-interactive-command-execution.md
Cormac Shannon 41b2095ed9 Rewrite issue #10 for scripts+REPL, add issue #12, add interactive command tests
Redesign issue #10: bare-word commands now work in both scripts and
the REPL via a parser-level heuristic (identifier + non-exception-list
token → shell command). Add runtime fallback for string-arg syntax
(echo "hello"), double-dash flag handling, and classification examples.

Add issue #12 for path-based command execution (./script, /bin/ls, ~/bin/deploy).

Add testes/lush/commands-interactive.lua as a design playground covering
result table structure, exit codes, commands inside Lua blocks, _ behaviour,
runtime fallback, Lua variable shadowing, and interleaved Lua/shell.
2026-03-01 19:35:09 +00:00

11 KiB

Issue #10 — Interactive command execution

Status: open

The two modes

Lush has two distinct ways to run external commands:

  1. Captured — backtick syntax, pipes stdout/stderr, returns a table:

    local r = `ls -l`
    r.code    -- exit code
    r.stdout  -- captured stdout
    r.stderr  -- captured stderr
    
  2. Interactive — bare command (no backticks), inherits the terminal, the process owns stdin/stdout/stderr directly:

    > ls -l              -- output goes straight to terminal
    > vim foo.lua         -- full tty control, works properly
    > ssh user@host       -- interactive session
    

This mirrors how traditional shells work: commands run interactively by default, and you explicitly capture output when you want it (in bash: var=$(cmd); in lush: var = `cmd`).

Syntax — bare-word fallback

If a line fails to parse as valid Lua, try to interpret it as an interactive shell command. This works in both the REPL and scripts — bare commands can appear anywhere a Lua statement can, including inside if/for/while/do/function blocks.

> vim foo.lua        -- not valid Lua → runs as shell command
> ls -la             -- not valid Lua → runs as shell command
> local x = 1        -- valid Lua → runs as Lua
> x = `ls`.stdout    -- valid Lua → runs as Lua (captured)
-- deploy.lua
local env = os.getenv("ENV") or "staging"
print("deploying to " .. env)
ssh deploy@prod ./restart.sh
ls -la /var/log
if _.code ~= 0 then
  print("deploy failed")
  os.exit(1)
end

Bare commands also work inside Lua blocks:

do
  ls
  ssh site.com
  btop
  ls -lha /
  print("hello world")
end
ls
print("hello world again")

Parser-level detection

The bare-word fallback is implemented in the parser, not as a post-hoc retry. This allows bare commands inside Lua blocks and preserves single-chunk compilation for scripts.

The heuristic

When the parser sees an identifier (not a keyword) at statement position, it peeks at the next token. If the next token is in the exception list, the statement is parsed as Lua. If not, it's a shell command.

The exception list (tokens that can follow an identifier in a valid Lua statement):

Token Lua meaning
( function call: f(...)
string literal function call: f "..."
{ function call: f {...}
. field access: t.field
: method call: obj:method()
[ index: t[k]
= assignment: x = ...
, multi-assignment: x, y = ...

This is exhaustive — every valid Lua statement starting with an identifier must have one of these tokens second. Anything else (another identifier, -, /, +, a keyword, EOF) means the line cannot be valid Lua, so it's safe to treat as a shell command. No valid Lua syntax is stolen.

No newline awareness needed for detection

The heuristic doesn't need to distinguish same-line vs different-line tokens. A bare identifier like ls on its own line is followed by whatever comes next — an identifier, keyword, or EOF — none of which are in the exception list. So ls is correctly detected as a shell command.

Multi-line Lua function calls are preserved because their continuation tokens are in the exception list:

f
("hello")    -- f + ( → exception list → Lua ✓

f
"hello"      -- f + string → exception list → Lua ✓

f
{1, 2, 3}   -- f + { → exception list → Lua ✓

Newline awareness for argument capture

Once the parser detects a shell command, it needs to know where the arguments end. Shell commands are newline-terminated — the parser extracts the raw text of the rest of the line from the source buffer (rather than reconstructing from tokens). This preserves the exact argument string, including characters the Lua lexer can't tokenize (like @ in ssh user@host).

The parser emits bytecode equivalent to _ = __interactive("raw line text").

Double-dash flags (--)

-- starts a comment in Lua. This conflicts with double-dash flags in shell commands: git --version, ls --color=auto, grep --include="*.lua".

The problem: when the parser detects a shell command and peeks at the next token, the lexer may encounter -- and consume the rest of the line as a comment. The argument text is lost before the parser can capture it.

The solution: the parser must record the source buffer position before peeking at the next token. When the heuristic determines "shell command," the parser extracts the raw text starting from the saved position (the character right after the first identifier), scanning to the end of the line in the source buffer. This bypasses the lexer entirely for the argument portion — --version is captured as raw text, not interpreted as a comment.

This means the lexer's position may be ahead of the raw-captured text (it already skipped the "comment"). The parser must advance the lexer to the correct position (next line) after extracting the raw command.

Classification examples

Shell — identifier + non-exception token

Input Next token Result
ls -la - shell
ls next statement / EOF shell
git status status (identifier) shell
git commit -m "fix bug" commit (identifier) shell
cd /tmp / shell
grep -r "pattern" . - shell
docker compose up -d compose (identifier) shell
tar xzf archive.tar.gz xzf (identifier) shell
sudo ls ls (identifier) shell
ssh user@host lexer error on @ → raw capture shell
curl https://example.com https (identifier) shell
git --version -- (lexer sees comment) shell (raw source capture)
ls --color=auto /tmp -- (lexer sees comment) shell (raw source capture)
nonexistent_cmd next statement / EOF shell (runs, exits 127)

Lua — identifier + exception-list token

Input Next token Result
print("hello") ( Lua
print "hello" string literal Lua
f {1, 2, 3} { Lua
io.write("x") . Lua
obj:method() : Lua
t[1] = 5 [ Lua
x = 5 = Lua
x, y = 1, 2 , Lua

Lua — keywords (heuristic doesn't apply)

Input Result
local x = 1 Lua
if x then ... end Lua
for i = 1, 10 do ... end Lua
return x Lua
while true do ... end Lua

Lua syntax + runtime fallback to shell

Lua's syntactic sugar means echo "hello" parses as echo("hello") — the string literal is in the exception list, so the parser treats it as Lua. A runtime fallback catches the error:

  1. Lua executes the call → "attempt to call a nil value"
  2. Extract the undefined name, look it up in PATH
  3. Found → run as interactive shell command, assign result to _
  4. Not found → report the original Lua error
Input Parses as Runtime
echo "hello" echo("hello") echo nil → PATH → found → shell
grep "pattern" grep("pattern") grep nil → PATH → found → shell
man "ls" man("ls") man nil → PATH → found → shell
pirnt "hello" pirnt("hello") pirnt nil → PATH → not found → Lua error

Bare expressions (already invalid Lua)

Input Next token Notes
x -y - shell — x - y as a bare statement is a syntax error in Lua anyway
a + b + shell — same; a probably not in PATH → exits 127

The fallback chain

  1. Parser heuristic — identifier + non-exception token at statement position → emit _ = __interactive("line") bytecode. Handles most bare commands during compilation. Works in both REPL and scripts, inside any block.

  2. Normal Lua execution — if the parser accepted the line as Lua, execute it. If OK → done.

  3. Runtime "attempt to call a nil value" — extract the undefined name, check PATH. Found → re-execute as _ = __interactive("line"). Not found → report the original Lua error. Catches the echo "hello" edge case.

  4. Post-hoc fallback (REPL only) — if the lexer itself errors before the parser heuristic can run (e.g., unusual characters not after an identifier), try the raw input line as a shell command.

Return value and _

Interactive commands can't capture stdout/stderr (the process owns the terminal). However, the exit code is still useful. After an interactive command completes, lush assigns a result table to the global _:

> ls -l
(output appears directly in terminal)
> _.code
0

The table has the same shape as a captured command result, but with empty stdout/stderr:

{code = <exit_code>, stdout = "", stderr = ""}

This keeps the interface consistent — _ always has .code, .stdout, .stderr, whether the command was interactive or captured. Code that only checks .code works with both.

Why _?

  • _ is largely unused in Lua beyond the community convention of throwaway variables in unpacking (local _, b = f())
  • This convention is safe here: backtick commands return a hashmap table, not a sequence, so local _, x = \cmd`isn't meaningful anyway (you'd use`cmd`.stdout`)
  • Bash uses $? for the last exit code — _ serves a similar role

Runtime implementation

Add luaB_interactive to lcmd.c, registered as __interactive global:

  • fork() without creating any pipes
  • Child: restore SIG_DFL for SIGINT/SIGQUIT, then execvp() (inherits parent's stdin/stdout/stderr)
  • Parent: ignore SIGINT/SIGQUIT (so Ctrl-C goes to child, not lush), then waitpid() and return result table
  • Reuses existing parse_argv() for command string tokenization

Add luaB_command_exists to lcmd.c — PATH lookup utility:

  • Takes a command name, searches each directory in PATH
  • Returns 1 (found) or 0 (not found)
  • Used by the runtime fallback (step 3) to decide whether to attempt a shell command or report a Lua error

Open questions

  • Should _ be set after captured (backtick) commands too? (So _.code always reflects the most recent command regardless of mode.)
  • Should the bare-word fallback support ${} interpolation? (Probably not — the line isn't parsed by the lexer at all, it's just a raw string.)
  • Job control: should Ctrl-Z suspend the child and return to lush? Requires tcsetpgrp() / process group work. Defer to a later issue.
  • Path-based commands (./script.sh, /usr/bin/foo, ~/bin/script) — first token isn't an identifier, so the parser heuristic doesn't apply. See issue #12.

Files touched

File Description
lcmd.c Add luaB_interactive (fork/exec without pipes), luaB_command_exists (PATH lookup)
lcmd.h Declare new functions
linit.c Register __interactive and __command_exists globals in opencommand()
llex.c Track line boundaries so parser can extract raw source text for shell command arguments
lparser.c Bare-word heuristic in statement parsing: detect shell commands via exception list, emit __interactive calls with raw source text
lua.c Runtime fallback: catch "attempt to call a nil value" in docall, check PATH, re-execute as shell