Trying to locate the proper class in a macro

Context

In Lucky, we say that all actions must return a Lucky::TextResponse, and if your action doesn’t, it’ll raise a compile-time error telling you.

The Issue

Related: https://github.com/luckyframework/lucky/issues/460#issuecomment-474616588

The error that’s raised is called from

  private def handle_response(_response : T) forall T
    {%
      raise <<-ERROR
      #{@type} returned #{T}, but it must return a Lucky::Response.
      Try this...
        ▸ Make sure to use a method like `render`, `redirect`, or `json` at the end of your action.
        ▸ If you are using a conditional, make sure all branches return a Lucky::Response.
      ERROR
    %}
  end

But in this case, @type will actually return the last class listed in your directory of Actions.

Using this image, the actual error is in Home::Index, but @type returns SignUps::New.

Is there a way to somehow mark where this error is actually coming from? Or are there other macro variables available that may give insight in to return Home::Index?

Any way that you can provide reduced code that reproduces the issue? I tried this:

module Foo
  def foo
    response = bar
    handle_response(response)
  end

  private def handle_response(_response : String)
    puts "OKAY!"
  end

  private def handle_response(_response : T) forall T
    {%
      raise "#{@type} returned #{T}, but it must return a String."
    %}
  end
end

abstract class Base
end

class Bar < Base
  include Foo

  def bar
    "hello"
  end
end

class Baz < Base
  include Foo

  def bar
    1
  end
end

(Bar.new || Baz.new).foo

It works fine.

I’m not sure if this is exactly the same or not, but here’s my go at a minimal:

https://play.crystal-lang.org/#/r/8pv9

I have a feeling there’s a lot more going on just because of how Lucky works. I can try and make a minimal lucky app if that helps

This example just shows that it seems to always be the last one (which could get hairy if you have 100 classes…)
Edit: https://play.crystal-lang.org/#/r/8pvb

Oh, I see what’s going on. The thing is that response = call will be evaluated on the abstract type and so the type of response is Int32 | String. The handle_response provides an overload for Int32 and another for T. The compiler doesn’t discards type from a union when an overload matches, so T here will be Int32 | String. And that will trigger for the base type and the error will be executed on all types, but only the first one (according to some ordering) will trigger the error, and so you get it on Four.

One way to solve it:

  • Add a dummy {% @type %} on run. This will make it be a macro method, meaning that you will get a different method on each type, not one in the abstract type. It’s hacky but it works.

I’m thinking of other ways but I don’t know…

A proper solution might be to have a way to mark methods as “macro methods” like we used in the past. But it’s still pretty low-level.

1 Like

So like this: https://play.crystal-lang.org/#/r/8pvc

But why not make call abstract and force a return type there? The compiler will do the work for you.

This comes from the macro methods

get "/whatever" do
  # must return Lucky::TextResponse here
end

I’ll have to dig in more to see if we can just let the compiler handle it. We might just have a missing type that will fix it all. I didn’t know throwing macro code in to a method makes that method a macro. I’ll play around with your suggest there and see where that takes me. Thanks!

We definitely can use an abstract method and I thinks that what we ended up doing, but we’d like a better error message so people know what they need to do to fix it. If they see they need to return a Lucky::Response they may not know what to do.

1 Like

What I’d really love to see (and have proposed earlier but didn’t push that hard on) is a way to give custom messages that the compiler can use. But that’s probably whole different discussion.

I’d love something like

@[CrystalReturnError("message to give when return type doesn't match")]
def render : Lucky::Response
end

I have no idea how or if that would work, but something like that would be incredible

2 Likes

That only happens when you mention @type inside a macro. The rationale behind that is that the method needs to be specialized depending on the @type so we make a separate instance for it, never reusing it if it’s, say, an abstract type and the method is not overridden in subclasses.

That’s why I suggested adding a dummy {% @type %}… but I suggest a good comment like “this makes Crystal generate this method separately for each different type”.

3 Likes

Thanks for your help @asterite!! I hope this works