Alistair Tweed asked an interesting question on the Ruby Australia Slack, which I thought deserved a more permanent home over being banished to Slack’s archives:
Can anyone help me with getting user input when piping code to Ruby?
The command I’m using is:cat hello | ruby
The
hello
file contains the following code:#!/usr/bin/env ruby # frozen_string_literal: true name = gets&.strip puts "Hello, #{name || 'World'}!"
When using
gets
, the output isHello, World!
When usingSTDIN.read
, the output isHello, !
The problem is that the script doesn’t stop to allow the user to type in a value. Any ideas?
I do not think I had previously considered running a Ruby program in this way, but regardless of whether I ever would or not, I was curious about how this question could be answered.
All the examples below will be for the Bash shell unless specified.
Why don’t you just…?
First, though, let’s just address some potential “why don’t you just…?” questions around the use of piping in this scenario.
Yes, we could just change the command to pass the file directly to ruby
, and manually type in a name when gets
prompts for it:
$ ruby hello
Mario
Hello, Mario!
We could also use process substitution to allow us to provide a name as an argument when running a command:
$ ruby hello <(echo Mario)
Hello, Mario!
The codebase, as it stands, only really gives unexpected results when we ignore the input prompt (ie just press [Enter]
):
$ ruby hello
<press Enter>
Hello, !
Pressing [Enter]
sends an empty string (""
), not nil
, and since ""
is a truthy value in Ruby, when name || 'World'
gets evaluated, name
gets output.
This could be fixed by changing final line of the code to something like:
puts "Hello, #{name.empty? ? 'World' : name}!"
This allows the program to fall back to its default value when input is not provided:
$ ruby hello
<press Enter>
Hello, World!
But! We are going to consider all of the above as out of scope for answering this question, and we are going to take the use of a pipe as an unchangeable (hard) requirement.
Down the Pipe
Back to the problem at hand. Running the original command, we get the following:
$ cat hello | ruby
Hello, World!
The codebase is being passed over to ruby
, so that we end up with a command that looks something like:
ruby <code>
We want to be able to capture a reference to that code being passed over via the pipe, so that we can inject arguments into it, and create a command that looks something like:
ruby <code> [arguments]
xargs
Whenever I want to do something potentially complex with piped-in arguments, I tend to reach for the xargs utility first. Let’s see what we can do with it.
We will start with attempting to get the most basic command running first (without any arguments), follow the trail of errors until it works, add in arguments, then rinse and repeat.
First let’s just try passing the code through xargs and see what happens:
$ cat hello | xargs ruby
/../bin/ruby: No such file or directory -- #!/usr/bin/env (LoadError)
It looks like we are only passing the first line of the file over to Ruby, rather than the entire file.
This would seem to indicate that we have a separator problem: xargs is not a line-oriented tool, but also separates on spaces.
Looking back at the file, it’s first line is:
#!/usr/bin/env ruby
xargs has separated on the space between env
and ruby
, resulting in the error above. Fortunately, using the -0
flag deals with this problem, so let’s add it in:
$ cat hello | xargs -0 ruby
/../bin/ruby: No such file or directory -- #!/usr/bin/env ruby (LoadError)
# frozen_string_literal: true
name = gets&.strip
puts "Hello, #{name || 'World'}!"
Okay, it looks like the whole file is being passed through this time, but we are still getting the same error. It would seem that perhaps ruby
is not evaluating the code it is getting passed.
We can fix this by adding ruby
’s -e
flag:
$ cat hello | xargs -0 ruby -e
Hello, World!
Great! We now have the default case working with xargs!
But, we still need a reference to the Ruby code being piped through so that we can then give it arguments.
To do that, we can use xargs’ -I
option, and name the variable however we want. Let’s call it rubycode
:
$ cat hello | xargs -0I rubycode ruby -e rubycode
Hello, World!
So far, so good. Now, what happens when we use process substitution to provide a name argument…?
$ cat hello | xargs -0I rubycode ruby -e rubycode <(echo Mario)
Hello, Mario!
Looks like we have ourselves a working command! Ship it! :shipit:
!xargs
Looking back at the command, I cannot help but think that using xargs is overkill for this kind of problem. The pipe is passing the code through as standard input (stdin
), we use xargs to “catch” it, assign it to the rubycode
variable, and then pass that variable on to ruby
.
Surely we can do the same thing with just plain bash code, right? Let’s give it a try by using command substitution to capture the output of a redirection of stdin
into a rubycode
variable.
As we did with xargs, we can then use that variable in the Ruby command:
$ cat hello | rubycode=$(< /dev/stdin); ruby -e "$rubycode" <(echo Mario)
[No output]
Hmmm…getting no output here is unexpected.
And, indeed, it would seem that I have neglected to group these commands together in (
parentheses)
, so that the redirection that gets assigned to the rubycode
variable can be applied to the other command when it gets referenced in the ruby
command:
$ cat hello | (rubycode=$(< /dev/stdin); ruby -e "$rubycode" <(echo Mario))
Hello, Mario!
And we are back to working again! But, we can probably make this a little bit more compact without sacrificing readability.
Let’s get rid of the intermediate rubycode
variable, which removes a command, meaning no need for any grouping:
$ cat hello | ruby -e "$(< /dev/stdin)" <(echo Mario)
Hello, Mario!
Success! At this point, I think I would consider the yak shaved, and leave it at that.
There are two other minor refactors I can think of, which we will go through below for completeness’ sake (said very loosely: “completeness” as in “all I could think of right now”, as I am sure there are more ways to do many of the commands in this post), but I personally think they sacrifice readability.
Bonus Shave
The cat
command, if no arguments are provided, copies the contents of what it receives from standard input to standard output (stdout
). This means that we do not need to grab a reference to stdin
and redirect it, but can instead just use cat
in the ruby
command:
$ cat hello | ruby -e "$(cat)" <(echo Mario)
Hello, Mario!
If you use Z Shell (zsh
), then you have the option of tapping directly into the mnemonics for Unix file descriptors for stdin
(0
), stdout
(1
), and standard error (stderr
, 2
):
$ cat hello | (ruby -e "$(<&0)" <(echo Mario))
Hello, Mario!
Blunt Razors
Our supply of yak shaving cream is depleted, and our razors are now blunt.
Even if you never plan to run Ruby programs in the ways outlined above, hopefully, like me, you were able to learn a bit more about shell programming!
Got any better commands that would make a shaven yak happier? Leave them in the comments!
Leave a comment