Ruby Gotchas: parsing modifiers

Have you ever come across this Ruby quirk?

foo if foo=1

You might expect it to return 1, but what it actually does is fail with the message NameError: undefined local variable or method `foo' for main:Object

There is a reason, and it makes a certain kind of sense. Allow me to explain.

To start: Ruby reads scripts from left-to-right, top-to-bottom.

Consider the interpreter. When it sees a word like foo it has to decide whether it’s a local variable or a function call (it already knows it’s not a keyword, constant, instance variable, global variable, etc. because of syntax rules.) If we’re in a method, the initial set of local variables are the method parameters; otherwise it’s empty. Thereafter, local variables are added whenever the interpreter sees an assignment (e.g. foo = 1)

Now back to the code: let’s step through an approximation of how the Ruby interpreter sees it.

  1.   parsed: []
      vars:   []
      code:   foo if foo=1
      cursor:^
    

    Since vars is empty, foo can’t be a variable, therefore it must be a function.

  2.   parsed: [function('foo')]
      vars:   []
      code:   foo if foo=1
      cursor:    ^

    if is a keyword, which is allowed as a modifier after a function call.

  3.   parsed: [function('foo'), modifier('if',...)]
      vars:   []
      code:   foo if foo=1
      cursor:       ^

    foo= is an assignment, so we can add foo to the list of variables.

  4.   parsed: [function('foo'), modifier('if',assign('foo',...))]
      vars:   ['foo']
      code:   foo if foo=1
      cursor:           ^

    The right-hand side of the assignment is an integer literal.

  5.   parsed: [function('foo'), modifier('if',assign('foo',1))]
      vars:   ['foo']
      code:   foo if foo=1
      cursor:             ^

    We’ve hit the end of the input; we’re done.

If I were to convert that ‘parsed’ input into an unambiguous canonical form, according to standard precedence and actual execution order, it might look something like this:

tmp = (foo = 1)
if tmp
  foo()
end

We can verify that the interpreter is reading that foo as a function by actually creating said function, and demonstrating that it’s being executed:

def foo()
  :bar
end

foo if foo=1
#=> :bar

To “fix” it we have to tell the parser it’s a variable, by assigning beforehand. We could do this the ugly way:

foo = nil
foo if foo=1

...but a better way would be to be more explicit. Assignment in a condition is a bit dodgy (and Ruby even spits out a warning saying “found = in conditional, should be ==” – a strong hint that this is something to avoid), and in this case in particular, the intention of the line is a bit unclear. We can simultaneously fix it and make it more prosaic, without adding too much verbosity, thus:

foo=1
foo if foo

As a rule of thumb, only use simple predicates in modifiers.

* except for function arguments, of course

Matthew Kerwin

Published
Modified
License
CC BY-SA 4.0
Tags
development, gotcha, ruby, software

Comments powered by Disqus