Files
lush/issues/27-subcommand-syntax.md
Cormac Shannon 08df164692 Add issues #25, #26, #27; update #7 and #22 statuses
- #25: $VAR expansion in commands
- #26: Shell abbreviations and user-extensible builtins
- #27: $(cmd) subcommand syntax
- #22: Updated with implementation details (in progress)
- #7: Standardize status label
2026-03-15 19:33:19 +00:00

4.0 KiB

27 — Subcommand syntax in commands

Status: open

Problem

Running a command inside another command is unnecessarily verbose:

`ls ${`pwd`.stdout}`

The inner backtick returns a result table ({code, stdout, stderr}), so .stdout is required to extract the string. This is clunky compared to other shells.

Proposed syntax: $(cmd)

Use $() for inline subcommands, consistent with the existing $ interpolation family:

`ls $(pwd)`
!echo $(whoami)
`tar -czf $(date +%F).tar.gz src/`

This is consistent with existing lush syntax:

Syntax Context Meaning
$VAR Lua code getenv("VAR")
${expr} command body interpolate Lua expression
$(cmd) command body run subcommand, insert stdout

Comparison with other shells

Shell Syntax
bash $(cmd)
fish (cmd)
lush current `ls ${`pwd`.stdout}`
lush proposed `ls $(pwd)`

Behavior

$(cmd) runs a shell command (same as backtick) and inserts its stdout into the outer command, with trailing newline stripped.

`ls $(pwd)`                     -- list files in pwd's output
!echo $(whoami)@$(hostname)     -- multiple subcommands
`echo $(ls $(pwd))`             -- nested: inner runs first

$(cmd) is not for Lua expressions — that's what ${expr} is for. $() runs a shell command; ${} evaluates Lua.

Nesting

Nested subcommands are supported:

`echo $(ls $(pwd))`

This works naturally because $(cmd) enters command parsing, which can itself contain $().

Syntax clash analysis

Currently in read_command_body() (llex.c:508), $ followed by anything other than { saves a literal $. Adding ( as a second trigger alongside { is a minimal change. No conflicts:

  • $VAR in command mode is currently a literal $ + VAR (see issue #25) — not affected
  • ${expr} continues to work unchanged
  • Literal $( in commands is not meaningful today (falls through as literal text)

Implementation sketch

Lexer (llex.c)

In read_command_body(), extend the $ case to also trigger on (. This starts a new command body parse (not a Lua expression like ${}):

case '$': {
  next(ls);
  if (ls->current == '{') {
    next(ls);  /* skip '{' */
    /* existing ${expr} interpolation path */
    seminfo->ts = luaX_newstring(ls, luaZ_buffer(ls->buff),
                                     luaZ_bufflen(ls->buff));
    ls->saved_cmd_mode = ls->cmd_mode;
    return interactive ? TK_INTERACTIVE : TK_COMMAND;
  }
  else if (ls->current == '(') {
    next(ls);  /* skip '(' */
    /* subcommand: start a new command parse, closed by ')' */
    seminfo->ts = luaX_newstring(ls, luaZ_buffer(ls->buff),
                                     luaZ_bufflen(ls->buff));
    ls->saved_cmd_mode = ls->cmd_mode;
    /* signal parser that this is a subcommand, not a Lua expr */
    ...
    return interactive ? TK_INTERACTIVE : TK_COMMAND;
  }
  else {
    save(ls, '$');
  }
  break;
}

The $() body is parsed as a command (like backtick), terminated by ) instead of `. The result is run via lushCmd_command and .stdout is extracted with trailing newline stripped.

Parser (lparser.c)

The parser needs to distinguish $() from ${}:

  • ${expr} → parse Lua expression, tostring() the result (existing behavior)
  • $(cmd) → parse as a command (like backtick), run it, extract .stdout, strip trailing \n

Lexer state (llex.h)

Track whether the current interpolation is a subcommand ($() or expression (${) so the parser knows which path to take.

Files affected

File Changes
llex.h Add field to LexState to distinguish $() from ${}
llex.c Extend $ case in read_command_body(), handle ) as command terminator
lparser.c Add subcommand path: parse as command, extract .stdout
  • Issue #25 — $VAR expansion in commands (also touches $ handling in read_command_body())