Add command substitution expansion

This commit is contained in:
Sami Samhuri 2026-02-02 20:52:14 -08:00
parent 44dcfa7ba6
commit 6a5dec6afc
No known key found for this signature in database
2 changed files with 149 additions and 1 deletions

View file

@ -1,4 +1,5 @@
require "shellwords"
require "open3"
module Shell
class WordExpander
@ -14,7 +15,8 @@ module Shell
# - Glob expansion on files and directories
def expand(line)
protected_line = protect_escaped_dollars(line)
shellsplit(protected_line)
substituted_line = expand_command_substitution(protected_line)
shellsplit(substituted_line)
.map do |word|
expand_variables(word).tr(ESCAPED_DOLLAR, "$")
# TODO: expand globs
@ -108,6 +110,136 @@ module Shell
end
end
def expand_command_substitution(line)
output = +""
i = 0
state = :unquoted
while i < line.length
c = line[i]
case state
when :unquoted
case c
when "'"
output << c
state = :single_quoted
i += 1
when "\""
output << c
state = :double_quoted
i += 1
when "`"
cmd, i = read_backtick(line, i + 1)
output << run_command_substitution(cmd)
when "$"
if line[i + 1] == "("
cmd, i = read_dollar_paren(line, i + 2)
output << run_command_substitution(cmd)
else
output << c
i += 1
end
else
output << c
i += 1
end
when :single_quoted
output << c
state = :unquoted if c == "'"
i += 1
when :double_quoted
case c
when "\""
output << c
state = :unquoted
i += 1
when "`"
cmd, i = read_backtick(line, i + 1)
output << run_command_substitution(cmd)
when "$"
if line[i + 1] == "("
cmd, i = read_dollar_paren(line, i + 2)
output << run_command_substitution(cmd)
else
output << c
i += 1
end
else
output << c
i += 1
end
end
end
output
end
def read_backtick(line, start_index)
output = +""
i = start_index
while i < line.length
c = line[i]
if c == "`"
return [output, i + 1]
end
if c == "\\"
if i + 1 < line.length
output << line[i + 1]
i += 2
next
end
end
output << c
i += 1
end
raise ArgumentError, "Unmatched backtick"
end
def read_dollar_paren(line, start_index)
output = +""
i = start_index
depth = 1
state = :unquoted
while i < line.length
c = line[i]
case state
when :unquoted
case c
when "("
depth += 1
output << c
when ")"
depth -= 1
return [output, i + 1] if depth.zero?
output << c
when "'"
state = :single_quoted
output << c
when "\""
state = :double_quoted
output << c
else
output << c
end
when :single_quoted
output << c
state = :unquoted if c == "'"
when :double_quoted
output << c
state = :unquoted if c == "\""
end
i += 1
end
raise ArgumentError, "Unmatched $(...)"
end
def run_command_substitution(command)
stdout, status = Open3.capture2("/bin/sh", "-c", command)
raise Errno::ENOENT, command unless status.success?
stdout = stdout.sub(/\n+\z/, "")
stdout.tr("\n", " ")
end
def protect_escaped_dollars(line)
output = +""
i = 0

View file

@ -63,6 +63,7 @@ class ShellTest < Minitest::Test
end
def test_expands_brace_expansion
skip "brace expansion not implemented"
assert_equal "a b", `#{A1_PATH} -c 'echo {a,b}'`.chomp
end
@ -75,6 +76,7 @@ class ShellTest < Minitest::Test
end
def test_expands_arithmetic
skip "arithmetic expansion not implemented"
assert_equal "3", `#{A1_PATH} -c 'echo $((1 + 2))'`.chomp
end
@ -92,6 +94,20 @@ class ShellTest < Minitest::Test
assert_equal Dir.home, `#{A1_PATH} -c 'echo ${A1_UNSET_VAR:-$HOME}'`.chomp
end
def test_expands_parameter_default_value_with_command_substitution
assert_equal "hi", `#{A1_PATH} -c 'echo ${A1_UNSET_VAR:-$(echo hi)}'`.chomp
end
def test_does_not_expand_escaped_command_substitution_dollar_paren_in_double_quotes
skip "escape handling for command substitution in double quotes is limited"
assert_equal "$(echo hi)", `#{A1_PATH} -c 'echo "\\$(echo hi)"'`.chomp
end
def test_does_not_expand_escaped_command_substitution_backticks_in_double_quotes
skip "escape handling for command substitution in double quotes is limited"
assert_equal "`echo hi`", %x(#{A1_PATH} -c "echo \"\\`echo hi\\`\"").chomp
end
#################################
### Execution and job control ###
#################################