A use case for macro annotated
, precisely, is eliminating the need for additional DSL powered by the language itself. For example, we could simply write:
annotation Flags
macro annotated(target, *args, **opts)
{% unless target.is_a?(EnumDef) %}
{% raise "can only apply @[Flags] to enums" %}
{% end %}
end
end
Then we could allow annotations to include or use an implementation for target
, taking advantage of the fact that multiple hooks are all run and never override each other (so that we could still have our own annotated
hook):
module AnnotationHelper
macro target(*kinds)
macro annotated(target, *args, **opts)
\{% kinds = {{ kinds }} %}
\{% if target.is_a?(ClassDef) && !kinds.includes?(:class) %}
\{% raise "expected one of: #{kinds.map(&.id).join(", ").id}, got ClassDef but no :class" %}
\{% elsif target.is_a?(EnumDef) && !kinds.includes?(:enum) %}
\{% raise "expected one of: #{kinds.map(&.id).join(", ").id}, got EnumDef but no :enum" %}
\{% elsif target.is_a?(Def) && !kinds.includes?(:method) %}
\{% raise "expected one of: #{kinds.map(&.id).join(", ").id}, got Def but no :method" %}
\{% end %}
end
end
end
annotation Flags
include AnnotationHelper
target :enum
# less terse, compiler still needs to permit macro expansions in annotation scope
AnnotationHelper.target :enum
end
The contents of the annotated
hook should be interpolated after target
in the same scope. One of my use cases is to automatically generate overloads to circumvent Crystal’s current overload ordering behaviour:
# :nodoc:
annotation ExpandPrecRnd
end
module Math
@[ExpandPrecRnd]
def sqrt(value : BigFloatR, *, precision : Int = BigFloatR.default_precision, mode : BigFloatR::RoundingMode = BigFloatR::RoundingMode.default)
BigFloatR.new(precision: precision) { |mpfr| LibMPFR.sqrt(mpfr, value, mode) }
end
{% for a_def in @type.methods %}
{% if a_def.annotation(ExpandPrecRnd) %}
def {{ a_def.name }}({{ a_def.args[0...a_def.splat_index].splat }})
{{ a_def.name }}(
{{ a_def.args[0...a_def.splat_index].map(&.name).splat }},
precision: BigFloatR.default_precision,
mode: BigFloatR::RoundingMode.default,
)
end
{% end %}
{% end %}
# the above generates:
# ```
# def sqrt(value : BigFloatR)
# sqrt(value,
# precision: BigFloatR.default_precision,
# mode: BigFloatR::RoundingMode.default,
# )
# end
# ```
# because the original def is _less_ specific than the `def sqrt(value)`
# overload in stdlib (the one without parameter restrictions)
end
This approach is rather imperative, as that inline macro must be copied in every scope that uses ExpandPrecRand
. An annotated
hook would make this declarative:
annotation ExpandPrecRnd
macro annotated(target, *args, **opts)
# extra error handling (e.g. `args.empty?`, `target.is_a?(Def)`)
def {{ target.name }}({{ target.args[0...target.splat_index].splat }})
{{ target.name }}(
{{ target.args[0...target.splat_index].map(&.name).splat }},
precision: BigFloatR.default_precision,
mode: BigFloatR::RoundingMode.default,
)
end
end
end
module Math
@[ExpandPrecRnd]
def sqrt(value : BigFloatR, *, precision : Int = BigFloatR.default_precision, mode : BigFloatR::RoundingMode = BigFloatR::RoundingMode.default)
BigFloatR.new(precision: precision) { |mpfr| LibMPFR.sqrt(mpfr, value, mode) }
end
# automatically generates the same def as the previous snippet
end
I am not a fan of declaring the annotation parameters right next to the annotation name, since to me that implies each annotation may only have one “overload”. Likewise, I don’t think we should impose a one-to-one correspondence from annotations to “real” Crystal types; this prevents users from mapping different annotations to the same type for e.g. performance reasons, but also this correspondence can probably be implemented in 100% user code if we allow user macros in annotations instead.
Slightly related to the last point is that you can already define methods on annotation metaclasses:
annotation A
end
def A.foo(x, y)
x + y
end
A.foo(1, 2) # => 3