From 973dd90d6515be834216e987bae3b560ae35beb6 Mon Sep 17 00:00:00 2001 From: Cormac Shannon <> Date: Sat, 21 Mar 2026 19:34:29 +0000 Subject: [PATCH] Add user-defined commands via lush.commands table (issue #30) User commands run in forked children like external commands, so they support piping, redirection, and capture seamlessly. --- issues/30-user-builtin-shell-integration.md | 69 +++++++++++++++++++++ lcmd.c | 57 +++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 issues/30-user-builtin-shell-integration.md diff --git a/issues/30-user-builtin-shell-integration.md b/issues/30-user-builtin-shell-integration.md new file mode 100644 index 00000000..3c267cd3 --- /dev/null +++ b/issues/30-user-builtin-shell-integration.md @@ -0,0 +1,69 @@ +# 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: + +1. **No stdout/stderr capture** — `print()` inside the function writes directly to the terminal, not into the `{code, stdout, stderr}` result table. Backtick invocation returns `nil` instead of a result table. +2. **No pipe support** — piping to/from a user builtin fails because `exec_pipeline()` forks children that call `execvp()`, which can't find the function as an external command. +3. **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: + +1. Set up stdout/stderr capture pipes (same as external command execution) +2. `fork()` +3. **Child**: wire stdout/stderr to the pipe write ends, call the Lua function, `_exit()` with the return code +4. **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.commands` vs `lush.functions` vs `lush.cmds`? `commands` is clearest. diff --git a/lcmd.c b/lcmd.c index 339e84bb..08cae4c9 100644 --- a/lcmd.c +++ b/lcmd.c @@ -607,6 +607,57 @@ static void read_pipes (int fd_out, int fd_err, } +/* ===== user command dispatch (runs in forked child) ===== */ + +/* +** Look up argv[0] in lush.commands. If found, call the function with +** all argv strings, extract an exit code, and _exit(). This must only +** be called inside a fork()ed child process. +** Returns 0 if not a user command (caller should fall through to execvp). +*/ +static int exec_user_command (lua_State *L, ParsedArgs *pa) { + int i, code = 0; + lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_LUSH); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return 0; + } + if (lua_getfield(L, -1, "commands") != LUA_TTABLE) { + lua_pop(L, 2); + return 0; + } + if (lua_getfield(L, -1, pa->argv[0]) != LUA_TFUNCTION) { + lua_pop(L, 3); + return 0; + } + lua_remove(L, -2); /* remove commands table */ + lua_remove(L, -2); /* remove lush table */ + for (i = 0; i < pa->argc; i++) + lua_pushstring(L, pa->argv[i]); + if (lua_pcall(L, pa->argc, 1, 0) != LUA_OK) { + /* write error to stderr and exit with failure */ + const char *err = lua_tostring(L, -1); + if (err) { + (void)write(STDERR_FILENO, err, strlen(err)); + (void)write(STDERR_FILENO, "\n", 1); + } + _exit(1); + } + /* extract exit code from return value */ + if (lua_isinteger(L, -1)) { + code = (int)lua_tointeger(L, -1); + } else if (lua_istable(L, -1)) { + if (lua_getfield(L, -1, "code") == LUA_TNUMBER) + code = (int)lua_tointeger(L, -1); + lua_pop(L, 1); + } + fflush(stdout); + fflush(stderr); + _exit(code); + return 1; /* unreachable */ +} + + /* ===== pipeline execution ===== */ /* @@ -797,6 +848,7 @@ static int exec_pipeline (lua_State *L, char **stages, int nstages, close(err_pipe[0]); close(err_pipe[1]); } + exec_user_command(L, &pa[i]); execvp(pa[i].argv[0], pa[i].argv); /* exec failed */ @@ -1050,6 +1102,7 @@ int lushCmd_command (lua_State *L) { sa_new.sa_handler = SIG_DFL; sigaction(SIGPIPE, &sa_new, NULL); + exec_user_command(L, &pa); execvp(pa.argv[0], pa.argv); /* exec failed — write error to stderr and exit */ @@ -1201,6 +1254,7 @@ int lushCmd_interactive (lua_State *L) { sigaction(SIGINT, &sa_new, NULL); sigaction(SIGQUIT, &sa_new, NULL); + exec_user_command(L, &pa); execvp(pa.argv[0], pa.argv); /* exec failed */ @@ -1287,6 +1341,9 @@ LUAMOD_API int luaopen_lush (lua_State *L) { /* create aliases subtable */ lua_createtable(L, 0, 4); lua_setfield(L, -2, "aliases"); + /* create commands subtable for user-defined commands */ + lua_createtable(L, 0, 4); + lua_setfield(L, -2, "commands"); /* intern function name strings for OP_LUSH VM access */ lushname[LUSH_OP_COMMAND] = luaS_new(L, "command"); lushname[LUSH_OP_INTERACTIVE] = luaS_new(L, "interactive");