From 68a5273094e0d2b8082c42d44d30179f1380c7e7 Mon Sep 17 00:00:00 2001 From: Cormac Shannon <> Date: Fri, 20 Mar 2026 22:58:01 +0000 Subject: [PATCH] Add shell aliases via lush.aliases table (issue #28) Aliases rewrite the command string before pipeline splitting, so alias values may contain pipes (e.g. lush.aliases.foo = "echo hi |"). Expansion happens once per dispatch to prevent recursion. --- lcmd.c | 70 +++++++++++++++++++++++++++++++++++ testes/lush/lushlib.lua | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/lcmd.c b/lcmd.c index 733bdbe9..ab4ba0b3 100644 --- a/lcmd.c +++ b/lcmd.c @@ -897,6 +897,67 @@ static int try_builtin (lua_State *L, ParsedArgs *pa) { } +/* ===== alias expansion ===== */ + +/* +** Extract the first whitespace-delimited word from cmd, look it up in +** lush.aliases. If found, replace the command string on the Lua stack +** (position 1) with alias_value + remaining_text, and update *cmd. +** Expansion happens on the raw string, before pipeline splitting, +** so alias values may contain pipes. +** Returns 1 if expanded, 0 if no alias. +*/ +static int expand_alias (lua_State *L, const char **cmd) { + const char *s = *cmd; + const char *rest; + size_t wordlen; + const char *alias; + size_t alias_len, rest_len, total; + char *expanded; + + /* skip leading whitespace */ + while (*s == ' ' || *s == '\t') s++; + if (*s == '\0') return 0; + + /* find end of first word */ + rest = s; + while (*rest && *rest != ' ' && *rest != '\t') rest++; + wordlen = (size_t)(rest - s); + + /* look up in lush.aliases */ + lua_rawgeti(L, LUA_REGISTRYINDEX, LUA_RIDX_LUSH); + lua_getfield(L, -1, "aliases"); + lua_pushlstring(L, s, wordlen); + lua_gettable(L, -2); + + if (!lua_isstring(L, -1)) { + lua_pop(L, 3); + return 0; + } + + alias = lua_tolstring(L, -1, &alias_len); + rest_len = strlen(rest); /* includes leading space if any */ + + total = alias_len + rest_len; + expanded = (char *)malloc(total + 1); + if (expanded == NULL) { + lua_pop(L, 3); + return 0; + } + memcpy(expanded, alias, alias_len); + memcpy(expanded + alias_len, rest, rest_len); + expanded[total] = '\0'; + lua_pop(L, 3); + + /* replace command on Lua stack */ + lua_pushstring(L, expanded); + lua_replace(L, 1); + free(expanded); + *cmd = lua_tostring(L, 1); + return 1; +} + + /* ===== lushCmd_command ===== */ int lushCmd_command (lua_State *L) { @@ -910,6 +971,9 @@ int lushCmd_command (lua_State *L) { DynBuf buf_out, buf_err; struct sigaction sa_old, sa_new; + /* expand alias before pipeline splitting */ + expand_alias(L, &cmd); + /* try to split into pipeline stages */ if (split_pipeline(cmd, stages, &nstages) != 0) return luaL_error(L, "invalid pipeline syntax in command"); @@ -1076,6 +1140,9 @@ int lushCmd_interactive (lua_State *L) { struct sigaction sa_old_pipe, sa_old_int, sa_old_quit, sa_new; DynBuf buf_out, buf_err; + /* expand alias before pipeline splitting */ + expand_alias(L, &cmd); + /* try to split into pipeline stages */ if (split_pipeline(cmd, stages, &nstages) != 0) return luaL_error(L, "invalid pipeline syntax in command"); @@ -1217,6 +1284,9 @@ static const luaL_Reg lushlib[] = { LUAMOD_API int luaopen_lush (lua_State *L) { luaL_newlib(L, lushlib); + /* create aliases subtable */ + lua_createtable(L, 0, 4); + lua_setfield(L, -2, "aliases"); /* 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"); diff --git a/testes/lush/lushlib.lua b/testes/lush/lushlib.lua index 49c56577..6d6ecbcb 100644 --- a/testes/lush/lushlib.lua +++ b/testes/lush/lushlib.lua @@ -93,6 +93,87 @@ do end +-- === lush.aliases exists and is a table === + +do + assert(type(lush.aliases) == "table", "lush.aliases missing") + -- iterable (pairs works on it) + local count = 0 + for k, v in pairs(lush.aliases) do count = count + 1 end + assert(count == 0, "aliases should start empty") +end + + +-- === simple alias === + +do + lush.aliases.myecho = "echo hello" + local r = `myecho` + assert(r.code == 0, "alias failed") + assert(r.stdout == "hello\n", "expected 'hello\\n', got: " .. r.stdout) + lush.aliases.myecho = nil +end + + +-- === alias with extra args === + +do + lush.aliases.myecho = "echo hello" + local r = `myecho world` + assert(r.code == 0, "alias with args failed") + assert(r.stdout == "hello world\n", + "expected 'hello world\\n', got: " .. r.stdout) + lush.aliases.myecho = nil +end + + +-- === alias does not recurse === + +do + lush.aliases.echo = "echo aliased" + local r = `echo extra` + assert(r.code == 0, "aliased echo failed") + assert(r.stdout == "aliased extra\n", + "expected 'aliased extra\\n', got: " .. r.stdout) + lush.aliases.echo = nil +end + + +-- === alias removed === + +do + lush.aliases.myecho = "echo hello" + lush.aliases.myecho = nil + -- myecho should now be a normal (nonexistent) command + local r = `myecho` + assert(r.code ~= 0, "removed alias should not work") +end + + +-- === alias with pipe in value === + +do + lush.aliases.grephello = "echo hello | grep" + local r = `grephello hello` + assert(r.code == 0, "alias with pipe failed") + assert(r.stdout == "hello\n", + "expected 'hello\\n', got: " .. r.stdout) + lush.aliases.grephello = nil +end + + +-- === alias works with lush.command === + +do + lush.aliases.myecho = "echo via-command" + local r = lush.command("myecho") + assert(r.code == 0) + assert(r.stdout == "via-command\n", + "expected 'via-command\\n', got: " .. r.stdout) + lush.aliases.myecho = nil +end + + -- === OP_LUSH still works (backtick, $VAR, !cmd syntax) === do