The Crystal Programming Language Forum

[RFC] Annotations metadata declaration/DSL

I recall that when annotations were first announced, the bodies of the annotation declaration were intentionally left blank for future expansion. Seeing as they’ve become much more useful and prevalent since then (JSON::Serializable, Athena routing, etc.), I think now would be a great time to start adding some more compiler verification metadata to them.

Judging from the discussion in this issue and from how other languages handle annotations, I think there are two main candidates for metadata:

  • Target (class, method, lib, etc.)
  • Parameters

This does raise the question of syntax. Should Crystal go down the path of Java with its meta-annotations or should it start doing compiler DSLs (á la Kotlin contracts)?

# meta-annotation
@[Target(:class, :method)]
annotation Foo
  str : String
  num : Int32 = 10
end

# DSL
annotation Foo
  target :class, :method
  
  param str : String
  param num : Int32 = 10
end

(Maybe types need to be specified as their ASTNode cousins: StringStringLiteral, Int32NumberLiteral?)

This might also open the door for processing logic to be put in the body as another macro:

annotation Bar
  target :ivar

  # possible syntax for unnamed parameters?
  params(TypeNode)

  macro annotated(target)
    {% if target.type != nil %}
      def {{ target.name }}: {{ params[0] }}
        # ...
      end
    {% end %}
  end
end

Personally I love the DSL route, I also like the idea of handling all annotation logic inside of the annotation itself with something like that annotated macro. It just makes more sense than having to write a separate macro outside of the annotation that looks for annotations.

I kind of like how PHP handles it

/**
 * @Annotation
 * @Target({"PROPERTY","METHOD"})
 */
final class SomeAnn
{
    /**
     * @var string
     */
    public $name;

    public function __construct(array $values)
    {
        if (!isset($values['value']) || !\is_string($values['value'])) {
            throw new RuntimeException(sprintf('"value" must be a string.'));
        }

        $this->name = $values['value'];
    }
}

Or the crystal version would be like

annotation SomeAnn

  getter name : String

  # Initialize is optional, gets passed a Tuple and NamedTuple supplied
  def initialize(positional_args, named_args)
    # Can do some custom stuff here.  Otherwise properties must be defined as named arguments.
  ned
end

One good way I came up with to handle annotations currently is to define a class/struct that represents the annotation. Then you can iterate over the annotations doing like MyAnnClass.new {{ann.named_args.double_splat}}; which handles required properties, and default values etc.

Excessive annotation syntax infiltrating the language will slowly diminish Crystal’s beautiful syntax.

I think we could declare it like annotation Foo(args) because when you use it it’s exactly like a call, minus the block. For example:

module JSON
  annotation Field(*, key = nil, ignore = false, root = nil, converter = nil, presence = false, emit_null = false)
end

Type restrictions should be HashLiteral, StringLiteral, etc., because annotations operate on AST nodes. However, I’m not sure how useful this is because it’s hard to specify the types of keys and values. Or for example converter can be a type, a method, etc.

Then I don’t know about the annotated hook, we’d have to think of real use cases.

At the very least, I think it would be really helpful for documentation. I recently implemented DB::Serializable for crystal-db and I noticed that the only way to really describe the possible parameters for the annotation is through manually listing them inside a doc comment. Personally, I think it would’ve been much cleaner to do something like

module DB
  annotation Field(ignore : BoolLiteral = false, key : StringLiteral | NilLiteral = nil, converter : TypeNode | NilLiteral = nil)
end

and have crystal doc create some kind of documentation for it. At that point, I see no reason not to typecheck.

2 Likes