1

I would like to take a string that contains positional argument markers (not named), supply it with an array (not hash) of values, and have it evaluated.

The use case as an example would be somewhat like ARGV.

For example,

# given:
string = "echo $1 ; echo $@"
values = ["hello", "world"]

# expected result:
"echo hello ; echo hello world"

The below function is the best I could come up with:

def evaluate_args(string, arguments)
  return string unless arguments.is_a? Array and !arguments.empty?

  # Create a variable that can replace $@ with all arguments, and quote
  # arguments that had "more than one word" originally
  all_arguments = arguments.map{|a| a =~ /\s/ ? "\"#{a}\"" : a}.join ' '

  # Replace all $1 - $9 with their respective argument ($1 ==> arguments[0])
  string.gsub!(/\$(\d)/) { arguments[$1.to_i - 1] }

  # Replace $@ or $* with all arguments
  string.gsub!(/\$[*|@]/, all_arguments)

  return string
end

And it seems to me like it can and should be simpler.

I was hoping to find something that is closer to the Kernel.sprintf method of doing things - like "string with %{marker}" % {marker: 'value'}

So, although this issue is almost solved for me (I think), I would love to know if there is something I missed that can make it more elegant.

4
  • Why not just use Kernel.sprintf? Commented Nov 29, 2015 at 18:16
  • How can sprintf be utilized to do this? I do not want the string to be evaluated just to be "echo %s %s %s" - it needs to allow the "user" to write it more explicitly, and to request specific arguments (by position) from this array. Commented Nov 29, 2015 at 18:20
  • Kernel.sprintf supports positional arguments: %1$s is equivalent to your $1. Commented Nov 29, 2015 at 18:34
  • Good to know, but if this is the most minimal syntax, it is not suitable for my case. The string I am evaluating is in fact a script provided by a user. So I want to make it as natural as possible and as similar as possible to a shell script. I dont mind using %1 instead of $1 or even %{1} if it helps to shorten this function above. Commented Nov 29, 2015 at 18:45

2 Answers 2

2

It seems like you're trying to reproduce Bash-style variable expansion, which is an extremely complex problem. At the very least, though, you can simplify your code in two ways:

  1. Use Kernel.sprintf's built in positional argument feature. The below code does this by substituting e.g. $1 with the sprintf equivalent %1$s.
  2. Use Shellwords from the standard library to escape arguments with spaces etc.
require 'shellwords'

def evaluate_args(string, arguments)
  return string unless arguments.is_a? Array and !arguments.empty?
  tmpl = string.gsub(/\$(\d+)/, '%\1$s')
  (tmpl % arguments).gsub(/\$[*@]/, arguments.shelljoin)
end

string = "echo $1 ; echo $@"
values = ["hello", "world"]

puts evaluate_args(string, values)
# => echo hello ; echo hello world

If you didn't have the $* requirement I'd suggest just dropping the Bash-like format and just using sprintf, since it covers everything else you mentioned. Even so, you could further simplify things by using sprintf formatting for everything else:

def evaluate_args(string, arguments)
  return string unless arguments.is_a? Array and !arguments.empty?
  string.gsub('%@', arguments.shelljoin) % arguments
end

string = "echo %1$s ; echo %@"
values = ["hello", "world"]

puts evaluate_args(string, values)
# => echo hello ; echo hello world

Edit

If you want to use %{1} with sprintf you could turn the input array into a hash where the integer indexes are turned into symbol keys, e.g. ["hello", "world"] becomes { :"1" => "hello", :"2" => "world" }:

require "shellwords"

def evaluate_args(string, arguments)
  return string unless arguments.is_a? Array and !arguments.empty?
  string % {
    :* => arguments.shelljoin,
    **arguments.map.with_index {|val,idx| [ :"#{idx + 1}", val ] }.to_h
  }
end

string = "echo %{1} ; echo %{*}"
values = ["hello", "world"]

puts evaluate_args(string, values)
# => echo hello ; echo hello world
Sign up to request clarification or add additional context in comments.

4 Comments

Thanks. But %1$s is too much of a compromise, shelljoin has some undesired side effects (like its handling of single quotes), and converting $1 to %1$s just so it can be used directly in sprintf doesn't seem to contribute much in shortening the code or making it more readable I think. But no result is also a result, at least I see my approach is not that far from optimal in my use case.
(was too late to edit the previous comment) - as for the edit, %{1} was a compromise I was willing to take if it means I could use some one liner to evaluate it, like the str % hash syntax. Since this approach does not shorten the code, it is less intuitive in my case than continuing to use $1
I've edited my code with a simplified solution. It's a one-liner—keeping in mind that "it's a one-liner" is generally as much a reason to reject a solution as much as one to accept it—but I've split it onto multiple lines for readability.
Yeah - thanks for all the efforts Jordan. My goal was not to reduce the size of the code just for the sake of reducing it. I thought maybe I missed some solution (I have been surprised before) that will keep it shorter without jeopardizing its readability. I will accept the answer, but for now will probably keep the same code I started with.
1
string = "echo $1 ; echo $@ ; echo $2 ; echo $cat"
values = ["hello", "World War II"]

vals = values.map { |s| s.include?(' ') ? "\"#{s}\"" : s }
  #=> ["hello", "\"World War II\""]
all_vals = vals.join(' ')
  #=> "hello \"World War II\"" 
string.gsub(/\$\d+|\$[@*]/) { |s| s[/\$\d/] ? vals[s[1..-1].to_i-1] : all_vals }
  #=> "echo hello ; echo hello \"World War II\" ; echo \"World War II\" ; echo $cat" $cat" 

2 Comments

This is nice, even if only for its educational value. In order to support quoting of ["values with several words"], it should probably be string.gsub(/\$\d+|\$[@*]/) { |s| s[/\$\d/] ? values[s[1..-1].to_i-1] : values.map{|a| a =~ /\s/ ? "\"#{a}\"" : a}.join(' ') }
I missed the needed to quote strings with spaces, but that is really tangential to the question. Did a wee edit. Thanks for the compliment, I think.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.