diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 224c9e2..6164cc8 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -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 @@ -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` diff --git a/src/mrpc/annotation/reify.scala b/src/mrpc/annotation/reify.scala index 253868b..c06be89 100644 --- a/src/mrpc/annotation/reify.scala +++ b/src/mrpc/annotation/reify.scala @@ -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 diff --git a/src/mrpc/derive/MetadataDerivation.scala b/src/mrpc/derive/MetadataDerivation.scala index 88b6901..b4754f5 100644 --- a/src/mrpc/derive/MetadataDerivation.scala +++ b/src/mrpc/derive/MetadataDerivation.scala @@ -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} /** @@ -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)", ) /** @@ -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. diff --git a/test/mrpc/meta/IsAnnotatedAndParamListCountSuite.scala b/test/mrpc/meta/IsAnnotatedAndParamListCountSuite.scala new file mode 100644 index 0000000..09f4cb8 --- /dev/null +++ b/test/mrpc/meta/IsAnnotatedAndParamListCountSuite.scala @@ -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)