Files
lush/issues/14-prefix-interactive-commands.md
Cormac Shannon db858b3f68 Implement prefix-based interactive commands via ! (issue #14)
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=""}.
2026-03-02 22:06:29 +00:00

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) from print("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 as echo("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 (not for 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:

  1. Record the current source position (character after !)
  2. Extract raw text to end-of-line from the source buffer
  3. The parser emits bytecode equivalent to _ = __interactive("raw line text")
  4. 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_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()
  • 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 foo works — 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