User commands run in forked children like external commands, so they support piping, redirection, and capture seamlessly.
3.7 KiB
30 — User-defined commands should behave like real shell commands
Status: open
Related: #26 (user builtins), #06 (piping), #15 (builtins)
Problem
User-defined builtins registered via lush.builtins.mycmd = function(name, arg, ...) end are called directly as Lua functions in the parent process. This means:
- No stdout/stderr capture —
print()inside the function writes directly to the terminal, not into the{code, stdout, stderr}result table. Backtick invocation returnsnilinstead of a result table. - No pipe support — piping to/from a user builtin fails because
exec_pipeline()forks children that callexecvp(), which can't find the function as an external command. - No redirection support — for the same reason,
>,>>,2>etc. won't work.
Current behavior:
$ pat hello -- prints "hello", returns nil (no result table)
$ `pat hello` -- prints "hello", returns nil (no .stdout)
$ !pat hello | nvim -- "pat: No such file or directory"
In bash, user-defined functions work exactly like external commands at the call site — they can be piped, redirected, and captured. Lush should match this.
Proposal
Restructure the lush table
Separate built-in commands from user-defined commands:
lush.builtins— reserved for C builtins (cd,exec,umask) that must run in-process because they modify parent process state. Read-only / not user-extensible.lush.commands— user-defined commands. These are Lua functions that behave like shell commands: they run in a forked subprocess, their stdout/stderr are captured, and they work with pipes and redirection.
Dispatch order: alias expansion → lush.commands → lush.builtins → $PATH lookup.
Fork user commands into a subprocess
When dispatching a lush.commands entry:
- Set up stdout/stderr capture pipes (same as external command execution)
fork()- Child: wire stdout/stderr to the pipe write ends, call the Lua function,
_exit()with the return code - Parent: read captured output,
waitpid(), build and return the{code, stdout, stderr}result table
This makes user commands compatible with exec_pipeline() — each pipeline stage already forks a child, so the child just needs to check lush.commands before falling through to execvp().
Important caveat: because user commands run in a forked child, they cannot modify parent Lua state. Setting a global variable inside a lush.commands function will not be visible after the command returns. This matches how bash functions behave when used in a pipeline (bash also forks subshells for pipeline stages). This trade-off should be clearly documented.
Return protocol
User commands should follow the same return convention as C builtins:
- Return a table
{code=int, stdout=string, stderr=string}, OR - Return an integer exit code (0 = success), OR
- Return nothing (implies exit code 0)
The forked child captures whatever the function print()s as stdout, and uses the return value for the exit code.
Files
| File | Change |
|---|---|
lcmd.c |
Split try_builtin() into try_builtin() (in-process, lush.builtins only) and try_user_command() (fork+capture, lush.commands); add lush.commands check in pipeline child processes |
lbuiltin.c |
Register C builtins under lush.builtins; create empty lush.commands table for user use |
Open questions
- Should user commands receive stdin naturally (child inherits the pipe fd,
io.read()works) or as a string argument? Leaning toward natural stdin inheritance — matches bash and the child's stdin is already wired. - Naming:
lush.commandsvslush.functionsvslush.cmds?commandsis clearest.