crystal icon indicating copy to clipboard operation
crystal copied to clipboard

Improve the DX of the `has_constant?` macro method with namespaced types/constants

Open Blacksmoke16 opened this issue 2 years ago • 10 comments

Discussion

The .has_constant? method allows determining if a type has a specific constant, which could be an actual constant, or another type. E.g.

{{ Int32.has_constant? "MAX" }} # => true
{{ IO.has_constant? "Memory" }} # => true
{{ @top_level.has_constant? "STDOUT" }} # => true

However if you try to check if a type exists within another type, it'll always return false:

require "compress/gzip"

{{ @top_level.has_constant? "Compress::Gzip::Reader" }} # => false

where you have to do:

require "compress/gzip"

{{ @top_level.has_constant?("Compress") && Compress.has_constant?("Gzip") && Compress::Gzip.has_constant?("Reader") }} # => true

I propose that this method be made so that it handles this case as you'd expect. Split on :: and perform the same has_constant? check on each part of the path such that @top_level.has_constant? "Compress::Gzip::Reader" returns true.

Blacksmoke16 avatar Jan 16 '23 19:01 Blacksmoke16

This sort of makes sense given that they're not technically constants, wouldn't you use parse_type in this context instead?

devnote-dev avatar Jan 16 '23 19:01 devnote-dev

Maybe if there was a parse_type? method that returns a NilLiteral if the type/constant doesn't exist instead of raising since you can't rescue exceptions in macro land. But that probably deserves its own conversation/issue.

This one is just about removing the need to check each part of the path manually and instead have the method itself handle it.

Blacksmoke16 avatar Jan 16 '23 19:01 Blacksmoke16

It would also be nice if these methods accepted a Path.

asterite avatar Jan 17 '23 01:01 asterite

It would also be nice if these methods accepted a Path.

How would that work? Wouldn't it just always raise if the path doesn't exist?

Blacksmoke16 avatar Jan 17 '23 02:01 Blacksmoke16

It would work like Ruby's defined?.

Did you know you could do this in Ruby?

$ irb
irb(main):001:0> defined? Foo
=> nil
irb(main):002:0> Foo
Traceback (most recent call last):
        4: from /usr/bin/irb:23:in `<main>'
        3: from /usr/bin/irb:23:in `load'
        2: from /Library/Ruby/Gems/2.6.0/gems/irb-1.0.0/exe/irb:11:in `<top (required)>'
        1: from (irb):2
NameError (uninitialized constant Foo)
irb(main):003:0> class Foo
irb(main):004:1> end
=> nil
irb(main):005:0> defined? Foo
=> "constant"

So defined? is special in Ruby because it doesn't evaluate the call argument before checking the constant... because otherwise it would always raise (what you said.) Instead, it probably just takes the name of the constant.

It might look unintuitive, maybe... but the syntax you get is the best. So in Crystal you could check has_constant? Math, has_constant? Foo::Bar etc. and it would be very natural. Plus there's no way to accidentally write something like Foo:::Bar (oops, three colons) because that would be a syntax error.

I think I didn't realize about having it that way when it was introduced.

asterite avatar Jan 17 '23 11:01 asterite

parse_type always succeeds as long as the argument is syntactically valid, there is no need for a nilable variant.

Instead, try the following:

{{ parse_type("::#{"Compress::Gzip::Reader"}").resolve? }}
{{ parse_type("::#{"Compress"}::#{"Gzip::Reader"}").resolve? }}

From here one may want to have the ability to concatenate type names at the AST level instead (basically, expose the path_lookup.cr functionality directly):

{%
  t = parse_type("::Compress")       # => ::Compress
  t = t.concat(parse_type("Gzip"))   # => ::Compress::Gzip
  t = t.concat(parse_type("Reader")) # => ::Compress::Gzip::Reader
  t.resolve?                         # non-nil if `compress/zip` is required
%}

How would that work? Wouldn't it just always raise if the path doesn't exist?

By passing the type name as a macro argument or via parse_type before going to has_constant?, like @top_level.has_constant?(parse_type("Compress::Gzip::Reader")). Paths that don't resolve to a type raise only within a macro context. Alternatively, as suggested above, the macro interpreter could simply not visit the argument to has_constant? before evaluating the return value.

HertzDevil avatar Jan 17 '23 11:01 HertzDevil

It would also be nice if these methods accepted a Path.

👍 Yeah, let's do this. Accepting a StringLiteral probably still makes sense, though so we can proceed with #12966 for the enhanced functionality for strings. Eventually, I think we should drop accepting a SymbolLiteral. There's no point in having that when you can use a Path.

straight-shoota avatar Jan 17 '23 12:01 straight-shoota

Shall I create another issue for that? Or can reuse this one and I can update the PR to not close it when merged.

Blacksmoke16 avatar Jan 18 '23 00:01 Blacksmoke16

Better have two separate PRs. But we can share the issue, I suppose.

straight-shoota avatar Jan 18 '23 01:01 straight-shoota

#12966 was eventually reverted in #13106 due to https://github.com/crystal-lang/crystal/issues/13095.

We need an alternative API for the proposed changes to #has_constant? and #constant, probably as different methods. I suppose it's similar to parse_type("Foo").resolve in the context of a type, so this could be an angle where we could dock at.

straight-shoota avatar Feb 23 '23 09:02 straight-shoota