Improve the DX of the `has_constant?` macro method with namespaced types/constants
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.
This sort of makes sense given that they're not technically constants, wouldn't you use parse_type in this context instead?
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.
It would also be nice if these methods accepted a Path.
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?
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.
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.
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.
Shall I create another issue for that? Or can reuse this one and I can update the PR to not close it when merged.
Better have two separate PRs. But we can share the issue, I suppose.
#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.