Add issue #14: prefix-based interactive commands via !

This commit is contained in:
Cormac Shannon
2026-03-02 21:16:06 +00:00
parent 0d23741891
commit 75098da240

View File

@@ -0,0 +1,139 @@
# Issue #14 — Prefix-based interactive command execution
**Status:** open
**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:
```lua
{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 |