The Crystal Programming Language Forum

How to prevent an argument from being passed as a named argument

The Problem

Given the following method definition:

def tag(name, **attr)
end

Calling the method crashes if :name key appears in the **attr argument. For instance, the following fails:

# @link https://play.crystal-lang.org/#/r/89f9

tag "meta", name: "some-name", content: "some-content"
# => Error: argument 'name' already specified

Understandably, the above method call is the same as:

tag name: "meta", name: "some-name", content: "some-content"

Hence, the error is that :name key is passed more than once.

To avoid this, an external name can be used in the method definition, thus:

def tag(ext name, **attr)
end

After which this compiles OK:

tag "meta", name: "some-name", content: "some-content"

But the problem recurs if :ext key appears in the **attr argument:

# @link https://play.crystal-lang.org/#/r/89fc

tag "meta", ext: "some-name", content: "some-content"
# => Error: argument 'ext' already specified

The Hack

My current solution is to use an external name which is less likely to be used in the **attr argument in a method call.

I chose a double underscore, and it works OK. (Single underscore fails with Error: unexpected token: UNDERSCORE)

def tag(__ name, **attr)
end

The problem with this is that if you introduce another parameter, you are forced to use something like a triple underscore as an external name, since you cannot duplicate the double underscore.

This doesn’t work:

# @link https://play.crystal-lang.org/#/r/89ep

def tag(__ name, __ other, **attr)
end
# => Error: duplicated argument external name: __

This works:

# Note that `other` uses a *triple* underscore
# as an external name
def tag(__ name, ___ other, **attr) 
end 

The Question

Is there a way to specify in a method definition that a parameter name should never be allowed to be passed as a named argument in a method call?

If the answer is “No”…

The Proposal

It would be great, I think, to have a way to disallow a parameter’s name from being passed as a named argument.

I’m aware single underscores are used in other cases when you really do not need to use a variable name (I’m not sure what this is called: a throwaway variable?). For example:

a, _ = {1, 2}
puts a # => 1

I would propose a single underscore, when used as a method’s external name, should prevent both the parameter’s name and it’s external name (the single underscore) from being passed as a named parameter.

This also means the single underscore should be allowed to be used more than once. Something like this:

def tag(_ name, _ other, **attr)
end

# This should error
tag name: "meta", other: "hello", a: "a", b: "b"

# This should error
tag _: "meta", _: "hello", a: "a", b: "b"

# This should work
tag "meta", "hello", a: "a", b: "b"

I would appreciate help, alternatives and feedback.

Happy holidays, Crystalites! :smile:

In latest python you can do:

def foo(name, //, **attr)

The // means anything before it can’t be passed as a named argument.

Crystal should probably implement the same thing, maybe with the same syntax…

What’s the reason for having essentially two different arguments of the same name?

In my specific use case, the #tag method builds an HTML element with the given parameters. For instance, calling tag :meta, charset: "utf-8" returns <meta charset='utf-8'>.

See https://github.com/GrottoPress/markout/blob/1a4d0ef11dcb254831f7d36c4c2da63a8258dadf/src/markout.cr#L67

The problem is, you cannot determine beforehand what keys to pass in the double-splat argument.

Any valid HTML attribute could be passed, including :name, which is also the name for the first parameter of the #tag method.

For example, the following fails without the workaround I’ve already detailed above:

tag :meta, name: "author", content: "John Doe"
# => <meta name="author" content="John Doe">

That sounds good to me. It seems to be a good counterpart of the “anything after a single-splat argument can only be passed as a named argument” that Crystal currently has.

It’s probably better than my proposal because it forces any such arguments to the beginning, which is where it makes sense they should be.

Also because python adopted it… :wink: