Skip to content

Decorators macros

Ken Gorab edited this page May 30, 2024 · 5 revisions

Abra already has decorators to some extent used throughout the prelude and standard library, to denote bindings to external functions (@CBinding), functions which are implemented natively within the compiler (@Intrinsic), as well as functions which do not yet have an implementation (@Stub, although that one will dwindle in usage once I fully implement all standard library methods).

Definition and usage

It would be useful to have some kind of way of defining decorators in user-space, as well as providing some manner of type-safety around them. For example, the @CBinding decorator above could be declared like

decorator CBinding {
  name: String
}

and would need to be imported like any other named declaration before it can be used:

import CBinding from "./wherever"

@CBinding(name: "fopen")
export func fopen(...)

Much like types, these can be constructed using a constructor/initializer style and named arguments can be passed. However, due to the complexities involved, it may be necessary to ensure that these arguments can only be static constant values. This is especially necessary for the @CBinding decorator, which requires static introspection at compile-time. For example, the following code should fail to typecheck

import CBinding from "./wherever"

val fopen = "fopen"

@CBinding(name: fopen)
//              ^^^^^
// Error: non-constant expression used as decorator argument
export func fopen(...)

This would mean that the only valid expressions for decorator arguments are:

  • string literals ("hello")
  • numeric literals (123, -123, 0.0123)
  • boolean literals (true, false)
  • tuple literals ((0, true), (-123, ("a", true)))
  • arrays of any valid such expressions (["a", "b"], [[1, 2], [3]], [(0, true), (1, false)])

Scoping

There's nothing preventing someone from using the @CBinding decorator on a type, for example:

import CBinding from "./whatever"

@CBinding(name: "FILE")
export type File { ... }

Ideally, this should result in a typechecker error and there should be some way to indicate when defining a decorator the ways in which it is intended to be used. One way to do this would be to leverage the concept of traits and have special-purpose traits pre-defined which represent the various valid locations that decorators could belong.

trait FunctionDecorator {}
trait TypeDecorator {}
trait FieldDecorator {}
trait EnumDecorator {}
trait EnumVariantDecorator {}
...

Then, when defining the @CBinding decorator, it can conform to the FunctionDecorator trait:

decorator CBinding : FunctionDecorator {
  name: String
}

These traits are special traits that would be recognized by the compiler, and allow the user to have more fine-grained control over how decorators are meant to be used. For example, the following code would now properly fail to typecheck:

import CBinding from "./whatever"

@CBinding(name: "FILE")
export type File { ... }
//     ^^^^
// Error, decorator @CBinding cannot be applied to a type

Macros?

Another potential application of decorators is to allow for compile-time code transformation/generation. A good example of this would be serializing/deserializing to/from json. Consider the following example:

json module
export enum JsonValue {
  Null
  Boolean(value: Bool)
  String(value: String)
  Number(value: Float)
  Array(items: JsonValue[])
  Object(items: Map<String, JsonValue>)
}

export trait JsonSerialize {
  func toJson(self): JsonValue
}

export trait JsonDeserialize {
  func fromJson(json: JsonValue): Self
}
import JsonSerialize, JsonDeserialize, JsonValue from "json"

type Person : JsonSerialize, JsonDeserialize {
  firstName: String
  lastName: String

  trait func toJson(self): JsonValue = JsonValue.Object(...)
  trait func fromJson(json: JsonValue): Self = Person(...)
}

There's a lot of boilerplate that needs to be written in order to facilitate json serialization/deserialization, most of which can be fairly easily determined at compile-time based on the structure of the type. Imagine such a decorator existed, what would it need to do?

// `json` module
decorator Jsonify : TypeDecorator {
  ???
}

// user-defined module
import Jsonify from "json"

@Jsonify
type Person {
  firstName: String
  lastName: String
}

There are several things that this macro would need to do:

  1. Add conformances of Person to the JsonSerialize and JsonDeserialize traits
  2. Generate the appropriate toJson and fromJson methods to satisfy those traits
  3. Verify that the fields of the type also satisfy those traits, or else surface a typechecker error

I'm imagining a mechanism for the decorator-specific traits to have potentially-overrideable methods in order to allow for "macro"-like behavior, but I want to do it in as type-safe a way as I can. There are a few options.

Using strings

There could be a method on the TypeDecorator trait which would allow for augmentations to the type.

decorator Jsonify : TypeDecorator {
  trait func augment(self, meta: TypeMetadata): Result<String, DecoratorError> {
    val lines: String[] = []
    for field in meta.fields {
      if !field.typeMeta.conformsTo("JsonSerialize") return Err(DecoratorError(...))
      lines.push("items[\"${field.name}\"] = self.${field.name}.toJson()")
    }

    "extend ${meta.typeName} : JsonSerialize {" +
    "  trait func toJson(self): JsonValue {"+
    "    val items: Map<String, JsonValue> = {}" +
    lines.join("\n") +
    "  return Json.Object(items)" +
    "  }" +
    "}"
  }
}

Using enums

The main idea here is that, rather than providing a String which basically gets injected into the source file, there could be a well-defined list of "augmentation operations" that decorator methods could return. Then, the engine that is executing the macros can apply those augmentations in a well-defined way. For example:

decorator Json : TypeDecorator {
  func augmentTypeDeclaration(self, typeMeta: TypeMetadata): Result<Augmentation[], DecoratorError> {
    val augmentations: Augmentation[] = []
    val jsonModuleImportName = "json_${randomString()}"
    agumentations.push(Augmentation.ModuleAugmentation(ModuleAugmentation.AddImport(module: "json", alias: jsonModuleImportName)))

    augmentations.push(Augmentation.TypeAugmentation(TypeAugmentation.AddTraitConformance("$jsonModuleImportName.JsonSerialize")))
    augmentations.push(Augmentation.TypeAugmentation(TypeAugmentation.AddTraitConformance("$jsonModuleImportName.JsonDeserialize")))

    Ok(augmentations)
  }

  func augmentTypeDefinition(self, typeMeta: TypeMetadata): Result<Augmentation[], DecoratorError> {
    val augmentations: Augmentation[] = []
    val jsonModuleImportName = "json_${randomString()}"
    agumentations.push(Augmentation.ModuleAugmentation(ModuleAugmentation.AddImport(module: "json", alias: jsonModuleImportName)))

    val toJsonMethodBuilder = FunctionBuilder(name: "toJson", isInstanceMethod: true, returnType: "$jsonModuleImportName.JsonValue")
    toJsonMethodBuilder.addCode("val items: Map<String, $jsonModuleImportName.JsonValue> = {}")
    for field in typeMeta.fields {
      if !field.typeMetadata.conformsToTrait("$jsonModuleImportName.JsonSerialize") {
        return Err(DecoratorError(
          location: field.location,
          message: "Field ${field.name} has type ${field.typeMetadata.repr()}, which does not conform to the JsonSerialize trait"
        ))
      }
      val name = if field.getDecorator<JsonName>("JsonName") |dec| dec.name else field.name
      toJsonMethodBuilder.addCode("items[$name] = self.$name.toJson()")
    }
    toJsonMethodBuilder.addCode("return $jsonModuleImportName.JsonValue.Object(items)")
    augmentations.push(Augmentation.TypeAugmentation(TypeAugmentation.AddInstanceMethod(toJsonMethodBuilder.build())))

    val fromJsonMethodBuilder = FunctionBuilder(name: "fromJson", isInstanceMethod: false, returnType: "Self")
    fromJsonMethodBuilder.addParameter(name: "json", typeDesc: "$jsonModuleImportName.JsonValue")
    // ...
    augmentations.push(Augmentation.TypeAugmentation(TypeAugmentation.AddStaticMethod(fromJsonMethodBuilder.build())))

    Ok(augmentations)
  }
}

There's a lot more room to maneuver in this space, but also it's a lot more difficult to get it right.