Share quote parsing across shell parsers

This commit is contained in:
Sami Samhuri 2026-02-07 14:08:07 -08:00
parent f5aa086aec
commit 650e38328c
No known key found for this signature in database
3 changed files with 94 additions and 106 deletions

View file

@ -0,0 +1,64 @@
module Shell
# Shared quote/escape state machine for parsers that walk shell-like strings.
class QuoteCursor
attr_reader :state
def initialize(state: :unquoted)
@state = state
end
def unquoted?
state == :unquoted
end
# Consumes one logical unit from line[index], which may be one character
# or an escape pair (e.g., \" or \\$), and updates internal quote state.
# Returns [segment, next_index].
def consume(line, index)
c = line[index]
case state
when :unquoted
consume_unquoted(line, index, c)
when :single_quoted
consume_single_quoted(index, c)
when :double_quoted
consume_double_quoted(line, index, c)
else
raise "Unknown state #{state}"
end
end
private
def consume_unquoted(line, index, c)
case c
when "'"
@state = :single_quoted
when "\""
@state = :double_quoted
when "\\"
if index + 1 < line.length
return [line[index, 2], index + 2]
end
end
[c, index + 1]
end
def consume_single_quoted(index, c)
@state = :unquoted if c == "'"
[c, index + 1]
end
def consume_double_quoted(line, index, c)
if c == "\\"
if index + 1 < line.length
return [line[index, 2], index + 2]
end
elsif c == "\""
@state = :unquoted
end
[c, index + 1]
end
end
end

View file

@ -1,17 +1,18 @@
require "shell/quote_cursor"
module Shell
class StringParser
class << self
def split_commands(line)
commands = []
command = +""
state = :unquoted
cursor = QuoteCursor.new
next_op = :always
i = 0
while i < line.length
c = line[i]
case state
when :unquoted
if cursor.unquoted?
case c
when ";"
commands << {command: command, op: next_op}
@ -30,40 +31,11 @@ module Shell
i += 2
next
end
when "'"
state = :single_quoted
when "\""
state = :double_quoted
when "\\"
state = :escaped
end
command << c
when :single_quoted
command << c
state = :unquoted if c == "'"
when :double_quoted
command << c
if c == "\\"
state = :double_quoted_escape
elsif c == "\""
state = :unquoted
end
when :double_quoted_escape
command << c
state = :double_quoted
when :escaped
command << c
state = :unquoted
else
raise "Unknown state #{state}"
end
i += 1
segment, i = cursor.consume(line, i)
command << segment
end
if next_op == :and && command.strip.empty?
@ -78,58 +50,23 @@ module Shell
output = +""
i = start_index
depth = 1
state = :unquoted
cursor = QuoteCursor.new
while i < line.length
c = line[i]
case state
when :unquoted
if cursor.unquoted?
case c
when "("
depth += 1
output << c
when ")"
depth -= 1
return [output, i + 1] if depth.zero?
output << c
when "'"
output << c
state = :single_quoted
when "\""
output << c
state = :double_quoted
when "\\"
output << c
if i + 1 < line.length
output << line[i + 1]
i += 1
end
else
output << c
end
when :single_quoted
output << c
state = :unquoted if c == "'"
when :double_quoted
if c == "\\"
output << c
if i + 1 < line.length
output << line[i + 1]
i += 1
end
else
output << c
state = :unquoted if c == "\""
end
else
raise "Unknown state #{state}"
end
i += 1
segment, i = cursor.consume(line, i)
output << segment
end
raise ArgumentError, "Unmatched $(...)"

View file

@ -1,5 +1,6 @@
require "shellwords"
require "open3"
require "shell/quote_cursor"
require "shell/string_parser"
module Shell
@ -116,20 +117,11 @@ module Shell
def expand_command_substitution(line)
output = +""
i = 0
state = :unquoted
cursor = QuoteCursor.new
while i < line.length
c = line[i]
case state
when :unquoted
if cursor.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 << escape_substitution_output(run_command_substitution(cmd), :unquoted)
@ -156,29 +148,20 @@ module Shell
output << ESCAPED_BACKTICK
i += 2
else
output << c
i += 1
segment, i = cursor.consume(line, i)
output << segment
end
else
output << c
i += 1
segment, i = cursor.consume(line, i)
output << segment
end
else
output << c
i += 1
segment, i = cursor.consume(line, i)
output << segment
end
when :single_quoted
output << c
state = :unquoted if c == "'"
i += 1
when :double_quoted
elsif cursor.state == :double_quoted
case c
when "\""
output << c
state = :unquoted
i += 1
when "\\"
if i + 1 < line.length
escaped = line[i + 1]
@ -190,8 +173,8 @@ module Shell
end
i += 2
else
output << c
i += 1
segment, i = cursor.consume(line, i)
output << segment
end
when "`"
cmd, i = read_backtick(line, i + 1)
@ -206,13 +189,17 @@ module Shell
output << escape_substitution_output(run_command_substitution(cmd), :double_quoted)
end
else
output << c
i += 1
segment, i = cursor.consume(line, i)
output << segment
end
else
output << c
i += 1
segment, i = cursor.consume(line, i)
output << segment
end
else
segment, i = cursor.consume(line, i)
output << segment
end
end
output