armature icon indicating copy to clipboard operation
armature copied to clipboard

Multiple arg types is kinda messed up

Open xendk opened this issue 1 year ago • 6 comments

Trying to use the new multiple arg methods, I'm running into problems that might kill the multiple args idea. Take this:

      route context do |r, response, session|
        r.on "snapshot", String do |_, snapshot_id|
          snapshot = @project.snapshots[snapshot_id]

Which results in Error: expected argument #1 to 'Appocular::Storage::YAML(Appocular::Snapshot)#[]' to be String, not (Bool | String).

A bit of testing shows that the type of the block parameters is actually the union of the types returned by the called #match? methods (but only the ones that's actually called). Turns out that Tuple#map is just a macro that constructs a new tuple by inlining the block code. But the return type of self#[] would be the union of the types of the union, which in turn makes the return type of our block in on to the union of the return types of the match? methods.

Of course one can do as(Whatever) in one's on blocks, but I think it kinda ruins the DSL.

One solution that might work is making the multiple arg #is/#on into macros that unroll into nested single argument calls. That ought to make the compiler capable of pinpointing the exact type at each level, but I don't know if such a macro is possible.

What's your thoughts?

xendk avatar May 08 '24 19:05 xendk

I feel like there's a way to do this because this works as we would expect:

tuple = {1, "hello", nil}
value = tuple[0]
pp typeof(value) # => Int32
value = tuple[1]
pp typeof(value) # => String
value = tuple[2]
pp typeof(value) # => Nil

But that could just be because I'm using numeric literals because passing a variable gives the union:

tuple = {1, "hello", nil}
tuple.size.times do |i|
  value = tuple[i]
  pp typeof(value)
end

(Int32 | String | Nil)
(Int32 | String | Nil)
(Int32 | String | Nil)

The weird thing,to me, is that the code that Tuple#map generates calls them with numeric literals:

Tuple.new(
  (yield self[0]),

  (yield self[1]),

  (yield self[2]),
)

So it seems like it would work, but I think because the variable yielded to the block is the union of all of the types, the block result becomes a union as well. So maybe we need to sort this out with a macro rather than a method.

jgaskins avatar May 12 '24 18:05 jgaskins

With a bit of indirection and screwing around with macros, I was able to get this to work in a quick proof-of-concept:

require "uuid"

Request.new("/foo/bar").on "foo", String do |foo, string|
  pp foo: typeof(foo), string: typeof(string)
end

class Request
  property path : String

  def initialize(@path)
  end

  def on(*segments)
    on segments do |captures|
      yield captures
    end
  end

  def on(segments : Tuple(*T)) forall T
    {% begin %}
      captures = {
        {% for i in 0...T.size %}
          begin
            matcher = segments[{{i}}]
            if (match = %r(\A/?[^/]+).match path.sub(%r(\A/), "")) && (%result{i} = match?(match[0], matcher))
              self.path = path.sub(%r(\A/?#{match[0]}), "")
              %result{i}
            end
          end,
        {% end %}
      }

      if captures.any?(&.nil?)
        return
      else
        yield({
          {% for i in 0...T.size %}
            captures[{{i}}].not_nil!,
          {% end %}
        })
      end
      {% debug %}
    {% end %}
  end

  def match?(segment : String, matcher)
    matcher === segment
  end

  {% for type in %w[Int UInt] %}
    {% for size in %w[8 16 32 64 128] %}
      def match?(segment : String, matcher : {{type.id}}{{size.id}}.class)
        segment.to_{{type[0..0].downcase.id}}{{size.id}}?
      end
    {% end %}
  {% end %}

  def match?(segment : String, matcher : Symbol | String.class)
    segment
  end

  def match?(segment : String, matcher : UUID.class)
    UUID.parse? segment
  end

  def match?(segment : String, matcher : Regex)
    matcher.match segment
  end
end

It doesn't work completely yet (some variables are bleeding over for subsequent matchers), but the types are being constrained properly.

jgaskins avatar May 12 '24 18:05 jgaskins

Got it, it was because I was setting self.path inside the macro. This works because I'm setting it as a local variable and only setting self.path if and when we're about to yield:

require "uuid"

%w[
  /foo/bar
  /posts/c31b6980-e0fb-439a-af0b-b13b8224fec5
].each do |path|
  pp r = Request.new(path)

  r.on "foo", String do |foo, string|
    pp({ {"foo", String} => {foo: typeof(foo), string: typeof(string)} })
  end

  r.on "posts", UUID do |posts, post_id|
    pp({ {"posts", UUID} => {posts: typeof(posts), post_id: typeof(post_id)} })
  end
end

class Request
  # ...

  def on(segments : Tuple(*T)) forall T
    {% begin %}
      path = self.path
      captures = {
        {% for i in 0...T.size %}
          begin
            %matcher{i} = segments[{{i}}]
            if (%match{i} = %r(\A/?[^/]+).match path.sub(%r(\A/), "")) && (%result{i} = match?(%match{i}[0], %matcher{i}))
              path = path.sub(%r(\A/?#{%match{i}[0]}), "")
              %result{i}
            end
          end,
        {% end %}
      }

      if captures.any?(&.nil?)
        return
      else
        self.path = path
        yield({
          {% for i in 0...T.size %}
            captures[{{i}}].not_nil!,
          {% end %}
        })
      end
    {% end %}
  end

  # ...
end

jgaskins avatar May 12 '24 18:05 jgaskins

Trying to hack it into the code I get:

Showing last frame. Use --error-trace for full trace.

There was a problem expanding macro 'macro_139768752891200'

Code in src/route.cr:130:9

 130 | {% begin %}
       ^
Called macro defined in src/route.cr:130:9

 130 | {% begin %}

Which expanded to:

 > 12 |             
 > 13 |               begin
 > 14 |                 __temp_2400 = segments[1]
                                              ^
Error: index out of bounds for Tuple(Tuple(String, String)) (1 not in -1..0)

On the first multiple arg test, which I don't get. The double tuple (hehe) seems wrong to me, but how it ends up there?

xendk avatar May 12 '24 19:05 xendk

I got it to compile but it does fail a couple specs:

  1) Armature::Route matches requests to dynamic routes using symbols
     Failure/Error: match.should eq "bar"

       Expected: "bar"
            got: {"bar"}

     # spec/route_spec.cr:99

  2) Armature::Route matches requests to dynamic routes with named types
     Failure/Error: match.should be_a Int64

       Expected {123} (Tuple(Int64 | Regex::MatchData | String | UUID)) to be a Int64

     # spec/route_spec.cr:144

I think this one is because it's yielding a tuple containing a single value. When your block accepts 2 arguments, the compiler knows to splat the arguments, but it's ambiguous with a 1-element tuple.

jgaskins avatar May 12 '24 23:05 jgaskins

I got it working in #5 by splatting the tuple explicitly like we were doing before. 😄

jgaskins avatar May 12 '24 23:05 jgaskins