From 650e38328cae03e6e3b133c73b36a564057ac784 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 7 Feb 2026 14:08:07 -0800 Subject: [PATCH] Share quote parsing across shell parsers --- ruby/shell/quote_cursor.rb | 64 ++++++++++++++++++++++++++++ ruby/shell/string_parser.rb | 83 +++++-------------------------------- ruby/shell/word_expander.rb | 53 +++++++++-------------- 3 files changed, 94 insertions(+), 106 deletions(-) create mode 100644 ruby/shell/quote_cursor.rb diff --git a/ruby/shell/quote_cursor.rb b/ruby/shell/quote_cursor.rb new file mode 100644 index 0000000..320578c --- /dev/null +++ b/ruby/shell/quote_cursor.rb @@ -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 diff --git a/ruby/shell/string_parser.rb b/ruby/shell/string_parser.rb index d2fce2d..0379b1a 100644 --- a/ruby/shell/string_parser.rb +++ b/ruby/shell/string_parser.rb @@ -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 $(...)" diff --git a/ruby/shell/word_expander.rb b/ruby/shell/word_expander.rb index f9af5d9..d387abd 100644 --- a/ruby/shell/word_expander.rb +++ b/ruby/shell/word_expander.rb @@ -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