Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,13 +240,12 @@ divergence rather than pretend the two models are identical.

- **mrpc:** `MetadataDerivation` supports `@composite`, `@reifyName`,
`@reifyAnnot` (single/optional/multi), `@infer`, `@rpcMethodMetadata`,
`@rpcParamMetadata` (`src/mrpc/derive/MetadataDerivation.scala`). There is no
way to reify a param's position, its by-name/repeated/has-default-value
flags, its Scala-level default value, the number of parameter lists, or a
plain "is this annotated with `T`" boolean; and there is no relaxation
control for the completeness/arity checks `MetadataDerivation` already
enforces (it always aborts on an arity mismatch — see
`test/mrpc/meta/MetadataCompileErrorSuite.scala`).
`@rpcParamMetadata`, **and (2026-08-08) `@isAnnotated[A]` / `@reifyParamListCount`**
(`src/mrpc/derive/MetadataDerivation.scala`). There is still no way to reify
a param's position, its by-name/repeated/has-default-value flags, or its
Scala-level default value; and there is still no relaxation control for the
completeness/arity checks `MetadataDerivation` already enforces (it always
aborts on an arity mismatch — see `test/mrpc/meta/MetadataCompileErrorSuite.scala`).
- **commons:** `@reifyPosition`, `@reifyFlags`, `@reifyDefaultValue`,
`@reifyParamListCount`, `@isAnnotated[T]` reify exactly this information
(`meta/metaAnnotations.scala:244-280`); `@checked` makes an `@infer` implicit
Expand All @@ -256,7 +255,12 @@ divergence rather than pretend the two models are identical.
- **Why:** v1's metadata steering vocabulary covers what the current test
fixtures (`MultiCollectionSuite`, `CompositeMetadataSuite`, `MetadataSuite`)
need; the richer reflective surface was deferred.
- **Parity treatment:** not ported.
- **Parity treatment:** `@isAnnotated[A]` (plain presence check, method/param
level only) and `@reifyParamListCount` (method level only, via
`Tuple.Size[op.ParamLists]`) are implemented and covered by
`test/mrpc/meta/IsAnnotatedAndParamListCountSuite.scala`. `@reifyPosition`,
`@reifyFlags`, `@reifyDefaultValue`, `@checked`, `@allowIncomplete` remain
not ported.

## D17 — No `@auxiliary`/`@annotated`/`@notAnnotated` filters, no `Fallback[T]`/`MacroInstances`

Expand Down
9 changes: 9 additions & 0 deletions src/mrpc/annotation/reify.scala
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,12 @@ private[mrpc] object reifyName:
* [[scala.annotation.StaticAnnotation]]. Mirrors commons `reifyAnnot`.
*/
final class reifyAnnot extends MetaAnnotation

/**
* Steers a `Boolean` metadata param to hold whether the RPC element carries an annotation of type `A`
* — a plain yes/no, unlike `@reifyAnnot`'s single/optional/multi extraction. Mirrors commons `isAnnotated[A]`.
*/
final class isAnnotated[A] extends MetaAnnotation

/** Steers an `Int` metadata param to hold the current method's parameter-list count. Method-level only. */
final class reifyParamListCount extends MetaAnnotation
55 changes: 53 additions & 2 deletions src/mrpc/derive/MetadataDerivation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ package derive
import halotukozak.commons.*
import halotukozak.made.*

import scala.annotation.Annotation
import scala.annotation.{tailrec, Annotation}
import scala.quoted.{Expr, Quotes, Type}

/**
Expand Down Expand Up @@ -148,10 +148,13 @@ private[mrpc] object MetadataDerivation:
inline ctx match
case ctx: Context.Method => rpcParamMetadata[e.Type, e.Label](arity(e))(using ctx)
else inline if e.hasAnnotation[halotukozak.mrpc.annotation.infer] then compiletime.summonInline[e.Type]
else inline if e.hasAnnotation[halotukozak.mrpc.annotation.reifyParamListCount] then reifyParamListCount
else inline if e.hasAnnotation[halotukozak.mrpc.annotation.isAnnotated[?]] then isAnnotated[e.Metadata]
else
compiletime.error(
"metadata param '" + compiletime.constValue[e.Label] + "' has no recognized steering annotation " +
"(@composite/@reifyName/@reifyAnnot/@infer/@rpcMethodMetadata/@rpcParamMetadata)",
"(@composite/@reifyName/@reifyAnnot/@infer/@rpcMethodMetadata/@rpcParamMetadata/" +
"@isAnnotated/@reifyParamListCount)",
)

/**
Expand Down Expand Up @@ -287,6 +290,54 @@ private[mrpc] object MetadataDerivation:
"' requires exactly one match; got " + other.size,
)

/** `@reifyParamListCount`: the current method's number of parameter lists. Method-level only. */
inline private def reifyParamListCount(using ctx: Context): Int = inline ctx match
case ctx: Context.Method => compiletime.constValue[Tuple.Size[ctx.op.ParamLists]]
case _ => compiletime.error("@reifyParamListCount is only valid at the method level")

// `A` is read off the metadata param's own @isAnnotated[A] annotation, not its declared type
// (always Boolean) — unlike @reifyAnnot, whose arity comes from the slot's type.
inline private def isAnnotated[SlotMeta <: Tuple](using ctx: Context): Boolean = inline ctx match
case ctx: Context.Method => isAnnotatedForMethod[SlotMeta, ctx.op.Metadata]
case ctx: Context.Param => isAnnotatedForParam[SlotMeta, ctx.underlying.Metadata]
case _: Context.Trait => compiletime.error("@isAnnotated is not valid at the trait level")

inline private def isAnnotatedForMethod[SlotMeta <: Tuple, TargetMeta <: Tuple]: Boolean =
${ isAnnotatedImpl[SlotMeta, TargetMeta] }
inline private def isAnnotatedForParam[SlotMeta <: Tuple, TargetMeta <: Tuple]: Boolean =
${ isAnnotatedImpl[SlotMeta, TargetMeta] }

private def isAnnotatedImpl[SlotMeta <: Tuple: Type, TargetMeta <: Tuple: Type](using quotes: Quotes): Expr[Boolean] =
import quotes.reflect.*

val isAnnotatedSym = Symbol.classSymbol("halotukozak.mrpc.annotation.isAnnotated")

@tailrec
def findTarget[Tup <: Tuple: Type](using Quotes): TypeRepr =
Type.of[Tup] match
case '[EmptyTuple] =>
report.errorAndAbort("@isAnnotated[A]: no @isAnnotated annotation found on the metadata param's type chain")
case '[t *: ts] =>
TypeRepr.of[t] match
case AnnotatedType(_, annot) if annot.tpe.typeSymbol == isAnnotatedSym =>
annot.tpe match
case AppliedType(_, targ :: Nil) => targ
case other => report.errorAndAbort(s"@isAnnotated[A]: could not resolve A from $other")
case _ => findTarget[ts]

val target = findTarget[SlotMeta]

@tailrec
def isPresent[Tup <: Tuple: Type](using Quotes): Boolean =
Type.of[Tup] match
case '[EmptyTuple] => false
case '[t *: ts] =>
TypeRepr.of[t] match
case AnnotatedType(_, annot) if annot.tpe <:< target => true
case _ => isPresent[ts]

Expr(isPresent[TargetMeta])

/**
* `@rpcParamMetadata`: projects over the op's `inputElems` (declaration order). Same arity shaping as
* [[rpcMethodMetadata]]; `Map` slots are keyed by paramName.
Expand Down
37 changes: 37 additions & 0 deletions test/mrpc/meta/IsAnnotatedAndParamListCountSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package halotukozak.mrpc.meta

import halotukozak.mrpc.annotation.{isAnnotated, multi, reifyName, reifyParamListCount, rpcMethodMetadata, rpcName}
import halotukozak.mrpc.derive.SampleApi.SampleApi

/**
* DIVERGENCES.md D16: `@isAnnotated[A]` (plain presence check, unlike `@reifyAnnot`'s arity-shaped
* extraction) and `@reifyParamListCount` (method-level parameter-list count).
*/
final case class MethodFlags[T](
@reifyName name: String,
@isAnnotated[rpcName] hasRpcNameOverride: Boolean,
@reifyParamListCount paramListCount: Int,
)

final case class TraitFlags[T](
@rpcMethodMetadata @multi methods: List[MethodFlags[?]],
)
object TraitFlags extends RpcMetadataCompanion[TraitFlags]

class IsAnnotatedAndParamListCountSuite extends munit.FunSuite:

test("@isAnnotated[rpcName] is true only for the method carrying @rpcName"):
val md = TraitFlags.materialize[SampleApi]
val flagged = md.methods.filter(_.hasRpcNameOverride).map(_.name)
assertEquals(flagged, List("findRenamed"))

test("@isAnnotated[rpcName] is false for every other method"):
val md = TraitFlags.materialize[SampleApi]
val notFlagged = md.methods.filterNot(_.hasRpcNameOverride).map(_.name).toSet
assertEquals(notFlagged, Set("ping", "increment", "find", "users", "lookup", "combine", "echoBool"))

test("@reifyParamListCount is 2 for combine(a)(b, c) and 1 for a single-list method"):
val md = TraitFlags.materialize[SampleApi]
assertEquals(md.methods.find(_.name == "combine").get.paramListCount, 2)
assertEquals(md.methods.find(_.name == "find").get.paramListCount, 1)
assertEquals(md.methods.find(_.name == "increment").get.paramListCount, 1)