diff --git a/ruby/Gemfile b/ruby/Gemfile index 1d2f6b5..b969649 100644 --- a/ruby/Gemfile +++ b/ruby/Gemfile @@ -7,4 +7,3 @@ gem "parser", "~> 3.3.10" gem "rake", "~> 13.0" gem "reline", "~> 0.6" gem "standard", "~> 1.52.0", require: false -gem "wordexp", "~> 0.2" diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock index 2dfefe6..88cc954 100644 --- a/ruby/Gemfile.lock +++ b/ruby/Gemfile.lock @@ -92,7 +92,6 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) - wordexp (0.2.2) PLATFORMS arm64-darwin-21 @@ -103,13 +102,12 @@ PLATFORMS DEPENDENCIES guard (~> 2.19) - guard-rake + guard-rake (~> 1.0) minitest (~> 6.0) parser (~> 3.3.10) rake (~> 13.0) reline (~> 0.6) standard (~> 1.52.0) - wordexp (~> 0.2) BUNDLED WITH 4.0.3 diff --git a/ruby/shell/repl.rb b/ruby/shell/repl.rb index 1136ec3..94bf78c 100644 --- a/ruby/shell/repl.rb +++ b/ruby/shell/repl.rb @@ -3,30 +3,32 @@ begin rescue LoadError require "reline" end -require "wordexp" require "shell/builtins" require "shell/colours" require "shell/job_control" require "shell/logger" +require "shell/word_expander" module Shell class REPL include Colours - attr_reader :builtins, :job_control, :logger, :options + attr_reader :builtins, :job_control, :logger, :options, :word_expander attr_accessor :precmd_hook - def initialize(builtins: nil, job_control: nil, logger: nil) + def initialize(builtins: nil, job_control: nil, logger: nil, word_expander: nil) logger ||= Logger.instance job_control ||= JobControl.new(logger: logger) builtins ||= Builtins.new(job_control: job_control) + word_expander ||= WordExpander.new @builtins = builtins @job_control = job_control @logger = logger @options = {} + @word_expander = word_expander end def start(options: nil) @@ -48,7 +50,7 @@ module Shell return 0 if line.strip.empty? # no input, no-op logger.verbose "Processing command: #{line.inspect}" - args = Wordexp.expand(line) + args = word_expander.expand(line) cmd = args.shift logger.verbose "Parsed command: #{cmd} #{args.inspect}" if builtins.builtin?(cmd) diff --git a/ruby/shell/word_expander.rb b/ruby/shell/word_expander.rb new file mode 100644 index 0000000..aaf30a3 --- /dev/null +++ b/ruby/shell/word_expander.rb @@ -0,0 +1,93 @@ +require "shellwords" + +module Shell + class WordExpander + ENV_VAR_REGEX = /\$(?:\{([^}]+)\}|(\w+)\b)/ + + # Splits the given line into multiple words, performing the following transformations: + # + # - Splits into words taking quoting and backslash escaping into account + # - Expands environment variables using $NAME and ${NAME} syntax + # - Tilde expansion, which means that ~ is expanded to $HOME + # - Glob expansion on files and directories + def expand(line) + shellsplit(line) + .map do |word| + word + .gsub(ENV_VAR_REGEX) do + name = Regexp.last_match(2) || Regexp.last_match(1) + ENV.fetch(name) + end + # TODO: expand globs + end + end + + # Lifted directly from Ruby 4.0.0. + # + # Splits a string into an array of tokens in the same way the UNIX + # Bourne shell does. + # + # argv = Shellwords.split('here are "two words"') + # argv #=> ["here", "are", "two words"] + # + # +line+ must not contain NUL characters because of nature of + # +exec+ system call. + # + # Note, however, that this is not a command line parser. Shell + # metacharacters except for the single and double quotes and + # backslash are not treated as such. + # + # argv = Shellwords.split('ruby my_prog.rb | less') + # argv #=> ["ruby", "my_prog.rb", "|", "less"] + # + # String#shellsplit is a shortcut for this function. + # + # argv = 'here are "two words"'.shellsplit + # argv #=> ["here", "are", "two words"] + def shellsplit(line) + words = [] + field = "".dup + at_word_start = true + found_glob_char = false + line.scan(/\G\s*(?>([^\0\s\\'"]+)|'([^\0']*)'|"((?:[^\0"\\]|\\[^\0])*)"|(\\[^\0]?)|(\S))(\s|\z)?/m) do + |word, sq, dq, esc, garbage, sep| + if garbage + b = $~.begin(0) + line = $~[0] + line = "..." + line if b > 0 + raise ArgumentError, "#{(garbage == "\0") ? "Nul character" : "Unmatched quote"} at #{b}: #{line}" + end + # 2.2.3 Double-Quotes: + # + # The shall retain its special meaning as an + # escape character only when followed by one of the following + # characters when considered special: + # + # $ ` " \ + field << (word || sq || (dq && dq.gsub(/\\([$`"\\\n])/, '\\1')) || esc.gsub(/\\(.)/, '\\1')) + found_glob_char = word && word =~ /[*?\[]/ # must be unquoted + # Expand tildes at the beginning of unquoted words. + if word && at_word_start + field.sub!(/^~/, Dir.home) + end + at_word_start = false + if sep + if found_glob_char + glob_words = expand_globs(field) + words += (glob_words.empty? ? [field] : glob_words) + else + words << field + end + field = "".dup + at_word_start = true + found_glob_char = false + end + end + words + end + + def expand_globs(word) + Dir.glob(word) + end + end +end diff --git a/ruby/test/shell_test.rb b/ruby/test/shell_test.rb index 4256b37..59daea6 100644 --- a/ruby/test/shell_test.rb +++ b/ruby/test/shell_test.rb @@ -19,7 +19,8 @@ class ShellTest < Minitest::Test def test_expands_environment_variables assert_equal Dir.home, `#{A1_PATH} -c 'echo $HOME'`.chomp - assert system("#{A1_PATH} -c 'echo $HOME' >/dev/null") + assert_equal Dir.home, `#{A1_PATH} -c 'echo ${HOME}'`.chomp + assert_equal "#{Dir.home} #{Dir.home}", `#{A1_PATH} -c 'echo ${HOME} ${HOME}'`.chomp end def test_fails_on_unknown_variables