From b4bb357f590ca8b176a7c4a43a05fd33eedf262e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20M=C3=A9lois?= Date: Sun, 12 May 2024 10:57:09 +0200 Subject: [PATCH] Adds compile-time validation (#2) Adds a compiler-plugin that runs the smithy validators on smithy-models translated from `API` definitions found in the compiled code. This plugin is bundled in an artifact using assembly, as the build tools (SBT, scala-cli, ...) do not pull the transitive dependencies of compiler plugins. The bundled dependencies are, in particular, classgraph, smithy, smithy4s, smithy4s-deriving. --- .github/workflows/ci.yml | 4 +- README.md | 16 +- build.sbt | 57 +++++- .../src/main/resources/plugin.properties | 1 + .../compiler/Smithy4sDerivingCompiler.scala | 180 ++++++++++++++++++ .../META-INF/smithy/internals.smithy | 21 ++ .../main/resources/META-INF/smithy/manifest | 1 + .../deriving/internals/SourcePosition.scala | 37 ++++ .../copySourcePositionToMember.scala | 29 +++ .../smithy4s/deriving/internals/macros.scala | 106 ++++++++--- .../shared/src/main/scala/model.scala | 2 +- .../tests/jvm/src/test/scala/utils.test.scala | 30 ++- project/plugins.sbt | 1 + 13 files changed, 431 insertions(+), 54 deletions(-) create mode 100644 modules/compiler-plugin/src/main/resources/plugin.properties create mode 100644 modules/compiler-plugin/src/main/scala/smithy4s/deriving/compiler/Smithy4sDerivingCompiler.scala create mode 100644 modules/core/shared/src/main/resources/META-INF/smithy/internals.smithy create mode 100644 modules/core/shared/src/main/resources/META-INF/smithy/manifest create mode 100644 modules/core/shared/src/main/scala/smithy4s/deriving/internals/SourcePosition.scala create mode 100644 modules/core/shared/src/main/scala/smithy4s/deriving/internals/copySourcePositionToMember.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a7299b..62946b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,11 +76,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p modules/core/js/target modules/core/jvm/target project/target + run: mkdir -p modules/compiler-plugin/target modules/core/js/target modules/core/jvm/target modules/bundle/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar modules/core/js/target modules/core/jvm/target project/target + run: tar cf targets.tar modules/compiler-plugin/target modules/core/js/target modules/core/jvm/target modules/bundle/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/README.md b/README.md index 5b790a0..a353480 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,15 @@ Scala 3.4.1 or newer is required. SBT : ``` -"tech.neander" %% "smithy4s-deriving" % +libraryDependencies += "tech.neander" %% "smithy4s-deriving" % +addCompilerPlugin("tech.neander" %% "smithy4s-deriving-compiler" % ) ``` -The rest of the world : +scala-cli : ``` -"tech.neander::smithy4s-deriving:" +//> using dep "tech.neander::smithy4s-deriving:" +//> using plugin "tech.neander::smithy4s-deriving-compiler:" ``` You'll typically need the following imports to use the derivation : @@ -348,9 +350,15 @@ val instance = new Foo { } ``` +### Compile time validation + +The `smithy4s-deriving-compiler` permits the validation of API/Schema usage within the compilation cycle. At the time of writing this, the plugin works by looking up derived `API` instances and crawling through the schemas from there, which implies that standalone `Schema` instances that are not (transitively) tied to `API` instances are not validated at compile time. + + + ### Re-creating a smithy-model from the derived constructs (JVM only) -It is unfortunately impossible for `smithy4s-deriving` to validate, at compile time, the correct usage of the application of `@hints`. However, it is possible to automatically recreate a smithy model from the derived abstractions, and to run the smithy validators. One could use this in a unit test, for instance, to verify the correctness of their services according to the rules of smithy. +It is possible to automatically recreate a smithy model from the derived abstractions, and to run the smithy validators. One could use this in a unit test, for instance, to verify the correctness of their services according to the rules of smithy. See an example of how to do that [here](./modules/examples/jvm/src/main/scala/printSpecs.scala). diff --git a/build.sbt b/build.sbt index c9e9fb7..fe2882e 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,9 @@ +import org.eclipse.jgit.api.MergeCommand.FastForwardMode.Merge ThisBuild / tlBaseVersion := "0.0" // your current series x.y +ThisBuild / version := { + if (!sys.env.contains("CI")) "0.0.0-SNAPSHOT" + else (ThisBuild / version).value +} ThisBuild / organization := "tech.neander" ThisBuild / organizationName := "Neandertech" @@ -22,7 +27,7 @@ val smithyVersion = "1.47.0" val smithy4sVersion = "0.18.16" val alloyVersion = "0.3.7" -lazy val root = tlCrossRootProject.aggregate(core, examples, tests) +lazy val root = tlCrossRootProject.aggregate(core, examples, plugin, pluginBundle, tests) lazy val core = crossProject(JVMPlatform, JSPlatform) .in(file("modules/core")) @@ -34,6 +39,43 @@ lazy val core = crossProject(JVMPlatform, JSPlatform) ) ) +lazy val plugin = project + .in(file("modules/compiler-plugin")) + .dependsOn(core.jvm) + .enablePlugins(AssemblyPlugin) + .settings( + name := "smithy4s-deriving-compiler-plugin", + libraryDependencies ++= Seq( + "org.scala-lang" %% "scala3-compiler" % scalaVersion.value, + "io.github.classgraph" % "classgraph" % "4.8.172", + "com.disneystreaming.smithy4s" %% "smithy4s-dynamic" % smithy4sVersion, + "com.disneystreaming.alloy" % "alloy-core" % alloyVersion + ), + // ASSEMBLY + assembly / logLevel := Level.Debug, + assemblyPackageScala / assembleArtifact := false, + assembly / assemblyExcludedJars := { + val cp = (assembly / fullClasspath).value + cp.filter { x => x.data.getName.startsWith("scala3-") || x.data.getName.startsWith("jline") } + }, + assemblyMergeStrategy := { + case PathList("META-INF", "smithy", "manifest") => MergeStrategy.concat + case PathList("META-INF", "services", _) => MergeStrategy.concat + case PathList("plugin.properties") => MergeStrategy.last + case x => + val oldStrategy = (ThisBuild / assemblyMergeStrategy).value + oldStrategy(x) + } + ) + +lazy val pluginBundle = project + .in(file("modules/bundle")) + .enablePlugins(AssemblyPlugin) + .settings( + name := "smithy4s-deriving-compiler", + Compile / packageBin := (plugin / assembly).value + ) + lazy val tests = crossProject(JVMPlatform) .in(file("modules/tests")) .enablePlugins(NoPublishPlugin) @@ -53,15 +95,24 @@ lazy val examples = crossProject(JVMPlatform, JSPlatform) .enablePlugins(NoPublishPlugin) .settings( libraryDependencies ++= Seq( - "com.disneystreaming.alloy" % "alloy-core" % alloyVersion, "com.disneystreaming.smithy4s" %% "smithy4s-http4s" % smithy4sVersion, "com.disneystreaming.smithy4s" %% "smithy4s-dynamic" % smithy4sVersion, "org.http4s" %% "http4s-ember-client" % "0.23.26", "org.http4s" %% "http4s-ember-server" % "0.23.26" - ) + ), + autoCompilerPlugins := true, + Compile / fork := true, + Compile / scalacOptions += { + val pluginClasspath = + (plugin / Compile / fullClasspathAsJars).value.map(_.data.getAbsolutePath()).mkString(":") + s"""-Xplugin:$pluginClasspath""" + } ) .jvmSettings( libraryDependencies ++= Seq( "software.amazon.smithy" % "smithy-model" % smithyVersion ) ) + .jsSettings( + Test / fork := false + ) diff --git a/modules/compiler-plugin/src/main/resources/plugin.properties b/modules/compiler-plugin/src/main/resources/plugin.properties new file mode 100644 index 0000000..f101efb --- /dev/null +++ b/modules/compiler-plugin/src/main/resources/plugin.properties @@ -0,0 +1 @@ +pluginClass=smithy4s.deriving.compiler.Smithy4sDerivingCompiler diff --git a/modules/compiler-plugin/src/main/scala/smithy4s/deriving/compiler/Smithy4sDerivingCompiler.scala b/modules/compiler-plugin/src/main/scala/smithy4s/deriving/compiler/Smithy4sDerivingCompiler.scala new file mode 100644 index 0000000..a4db294 --- /dev/null +++ b/modules/compiler-plugin/src/main/scala/smithy4s/deriving/compiler/Smithy4sDerivingCompiler.scala @@ -0,0 +1,180 @@ +/* + * Copyright 2024 Neandertech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.deriving.compiler + +import dotty.tools.backend.jvm.GenBCode +import dotty.tools.dotc.CompilationUnit +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.plugins.PluginPhase +import dotty.tools.dotc.plugins.StandardPlugin +import dotty.tools.dotc.report +import dotty.tools.dotc.util.NoSourcePosition +import dotty.tools.dotc.util.Spans +import io.github.classgraph.ClassGraph +import io.github.classgraph.ClassRefTypeSignature +import smithy4s.Document +import smithy4s.deriving.internals.SourcePosition +import smithy4s.dynamic.DynamicSchemaIndex +import smithy4s.dynamic.NodeToDocument +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.ModelSerializer +import software.amazon.smithy.model.shapes.ShapeId as SmithyShapeId +import software.amazon.smithy.model.validation.Severity +import software.amazon.smithy.model.validation.ValidationEvent + +import java.net.URLClassLoader +import java.util.Optional +import scala.jdk.CollectionConverters.* +import scala.jdk.OptionConverters.* +import scala.util.control.NonFatal + +class Smithy4sDerivingCompiler extends StandardPlugin { + val name: String = "smithy4s-deriving-compiler" + override val description: String = "Runs smithy linting on derived constructs" + override def init(options: List[String]): List[PluginPhase] = + List(Smithy4sDerivingCompilerPhase()) +} + +class Smithy4sDerivingCompilerPhase() extends PluginPhase { + + override def phaseName: String = Smithy4sDerivingCompilerPhase.name + override val runsAfter = Set(GenBCode.name) + // Overriding `runOn` instead of `run` because the latter is run per compilation unit (files) + override def runOn(units: List[CompilationUnit])(using context: Context): List[CompilationUnit] = { + + val result = super.runOn(units) + + val compileClasspath = context.settings.classpath.value + val output = context.settings.outputDir.value.jpath + val urls = compileClasspath.split(":").map(new java.io.File(_).toURI().toURL()) + val allUrls = urls.appended(output.toUri().toURL()) + val classLoader = new URLClassLoader(allUrls, this.getClass().getClassLoader()) + + val scanResult = new ClassGraph() + .addClassLoader(classLoader) + .enableAllInfo() + .scan() + + try { + val apiClassInfo = scanResult.getClassInfo("smithy4s.deriving.API") + + val builder = scanResult + .getClassesImplementing("smithy4s.deriving.API") + .filter(info => !info.isAbstract()) + .asMap() + .asScala + .foldLeft(DynamicSchemaIndex.builder) { case (builder, (name, info)) => + try { + val cls = info.loadClass(true) + val clsLocation = cls.getProtectionDomain().getCodeSource().getLocation().toURI() + // checking that the class comes from the current compilation unit + if (clsLocation == output.toUri()) { + // Getting the outer class, with the assumption that it'll be the companion object + // of the class for which an API is derived + // TODO : add some more protections + val outer = info.getOuterClasses().get(0) + val givenAPIMethodInfo = outer + .getMethodInfo() + .asScala + .find { methodInfo => + val sig = methodInfo.getTypeSignature() + methodInfo.getParameterInfo().isEmpty && // looking for parameterless methods + sig != null && + sig.getResultType().isInstanceOf[ClassRefTypeSignature] && + sig.getResultType().asInstanceOf[ClassRefTypeSignature].getClassInfo() == apiClassInfo + } + + val companionConstructor = outer.getConstructorInfo().get(0).loadClassAndGetConstructor() + companionConstructor.setAccessible(true) + val companion = companionConstructor.newInstance() + val givenAPIMethod = givenAPIMethodInfo.get.loadClassAndGetMethod() + val api = givenAPIMethod.invoke(companion).asInstanceOf[smithy4s.deriving.API[?]] + builder.addService[api.Free] + } else { + builder + } + } catch { + case NonFatal(e) => + report.error(s"Error when loading ${info.getName()} ${e.getMessage()}") + e.printStackTrace() + builder + } + } + + val unvalidatedModel = builder.build().toSmithyModel + val node = ModelSerializer.builder().build().serialize(unvalidatedModel) + val assemblyResult = Model + .assembler(this.getClass().getClassLoader()) + .discoverModels(this.getClass().getClassLoader()) + .addDocumentNode(node) + .assemble() + + val events = assemblyResult.getValidationEvents().asScala + events.foreach(reportEvent(unvalidatedModel)) + } finally { + scanResult.close() + } + result + } + + private def reportEvent(model: Model)(event: ValidationEvent)(using context: Context): Unit = { + var message = event.getMessage() + + val reason = event.getSuppressionReason().orElse(null) + if (reason != null) { message += " (" + reason + ")" } + val hint = event.getHint().orElse(null); + if (hint != null) { message += " [" + hint + "]" } + + val formatted = String.format( + "%s: %s | %s", + event.getShapeId().map(_.toString).orElse("-"), + message, + event.getId() + ) + + val SourcePositionId = SmithyShapeId.fromParts(SourcePosition.id.namespace, SourcePosition.id.name) + val sourcePositionDecoder = Document.Decoder.fromSchema(SourcePosition.schema) + + val maybeSourcePos = event + .getShapeId() + .flatMap(model.getShape) + .flatMap(sourcePos => Optional.ofNullable(sourcePos.getAllTraits().get(SourcePositionId))) + .map(_.toNode()) + .map(NodeToDocument(_)) + .flatMap(sourcePositionDecoder.decode(_).toOption.toJava) + .toScala + + val scalaPosition = maybeSourcePos match { + case None => NoSourcePosition + case Some(pos) => + val sourceFile = context.getSource(pos.path) + dotty.tools.dotc.util.SourcePosition(sourceFile, Spans.Span(pos.start, pos.end)) + } + + event.getSeverity() match + case Severity.SUPPRESSED => report.inform(formatted, scalaPosition) + case Severity.NOTE => report.inform(formatted, scalaPosition) + case Severity.WARNING => report.warning(formatted, scalaPosition) + case Severity.DANGER => report.error(formatted, scalaPosition) + case Severity.ERROR => report.error(formatted, scalaPosition) + } + +} + +object Smithy4sDerivingCompilerPhase { + val name = "smithy4s-deriving-compiler-phase" +} diff --git a/modules/core/shared/src/main/resources/META-INF/smithy/internals.smithy b/modules/core/shared/src/main/resources/META-INF/smithy/internals.smithy new file mode 100644 index 0000000..cd38c83 --- /dev/null +++ b/modules/core/shared/src/main/resources/META-INF/smithy/internals.smithy @@ -0,0 +1,21 @@ +$version: "2" + +namespace smithy4s.deriving.internals + +@trait() +structure SourcePosition { + @required + path: String + @required + start: Integer + @required + startLine: Integer + @required + startColumn: Integer + @required + end: Integer + @required + endLine: Integer + @required + endColumn: Integer +} diff --git a/modules/core/shared/src/main/resources/META-INF/smithy/manifest b/modules/core/shared/src/main/resources/META-INF/smithy/manifest new file mode 100644 index 0000000..caa53bc --- /dev/null +++ b/modules/core/shared/src/main/resources/META-INF/smithy/manifest @@ -0,0 +1 @@ +internals.smithy diff --git a/modules/core/shared/src/main/scala/smithy4s/deriving/internals/SourcePosition.scala b/modules/core/shared/src/main/scala/smithy4s/deriving/internals/SourcePosition.scala new file mode 100644 index 0000000..f1b52c4 --- /dev/null +++ b/modules/core/shared/src/main/scala/smithy4s/deriving/internals/SourcePosition.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Neandertech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.deriving.internals + +import smithy4s.deriving.{*, given} +import smithy4s.schema.Schema +import smithy4s.ShapeTag +import smithy4s.ShapeId + +case class SourcePosition( + path: String, + start: Int, + startLine: Int, + startColumn: Int, + end: Int, + endLine: Int, + endColumn: Int +) derives Schema + +object SourcePosition extends ShapeTag.Companion[SourcePosition] { + val id: ShapeId = ShapeId("smithy4s.deriving.internals", "SourcePosition") + def schema: Schema[SourcePosition] = derived$Schema +} diff --git a/modules/core/shared/src/main/scala/smithy4s/deriving/internals/copySourcePositionToMember.scala b/modules/core/shared/src/main/scala/smithy4s/deriving/internals/copySourcePositionToMember.scala new file mode 100644 index 0000000..2e134f1 --- /dev/null +++ b/modules/core/shared/src/main/scala/smithy4s/deriving/internals/copySourcePositionToMember.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Neandertech + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package smithy4s.deriving.internals + +import smithy4s.schema.Schema + +private[deriving] object copyPositionToMember { + + def apply[A](schema: Schema[A]): Schema[A] = { + schema.hints.get(SourcePosition) match + case None => schema + case Some(position) => schema.addMemberHints(position) + } + +} diff --git a/modules/core/shared/src/main/scala/smithy4s/deriving/internals/macros.scala b/modules/core/shared/src/main/scala/smithy4s/deriving/internals/macros.scala index ed8712b..0ca82d6 100644 --- a/modules/core/shared/src/main/scala/smithy4s/deriving/internals/macros.scala +++ b/modules/core/shared/src/main/scala/smithy4s/deriving/internals/macros.scala @@ -44,7 +44,8 @@ def derivedSchemaImpl[T: Type](using q: Quotes): Expr[Schema[T]] = { report.errorAndAbort(s"Could not find a suitable Mirror") } - val docs = TypeRepr.of[T].classSymbol.flatMap(_.docstring).map(Docs.parse) + val cls = TypeRepr.of[T].classSymbol + val docs = cls.flatMap(_.docstring).map(Docs.parse) val ns = Expr(getNamespace[T]) ev match case '{ @@ -58,11 +59,13 @@ def derivedSchemaImpl[T: Type](using q: Quotes): Expr[Schema[T]] = { val name = stringFromSingleton[name] val elemSchemas = summonSchemas[T, elementTypes] val labels = stringsFromTupleOfSingletons[elementLabels] - val structHints = maybeAddDocs(hintsForType[T], docs.map(_.main)) + val structHints = hintsForType[T].maybeAddDocs(docs.map(_.main)).maybeAddPos(cls) val fieldDocs = docs.map(_.params).getOrElse(Map.empty) val fieldHints = fieldHintsMap[T](fieldDocs) + val fieldSymbols = fieldSymbolsMap[T] + val fieldHintsWithPos = fieldHints.map { case (k, v) => k -> v.maybeAddPos(fieldSymbols.get(k))} val defaults = defaultValues[T] - val fieldsExpr = fieldsExpression(elemSchemas, labels, fieldHints, defaults) + val fieldsExpr = fieldsExpression(elemSchemas, labels, fieldHintsWithPos, defaults) if (isSmithyWrapper[T]){ val smithyWrapper = TypeRepr.of[wrapper].typeSymbol.name @@ -103,7 +106,7 @@ def derivedSchemaImpl[T: Type](using q: Quotes): Expr[Schema[T]] = { } } => val maybeEnumSchemaExpr = enumSchemaExpression[T, elementTypes] - val shapeHints = maybeAddDocs(hintsForType[T], docs.map(_.main)) + val shapeHints = hintsForType[T].maybeAddDocs(docs.map(_.main)).maybeAddPos(cls) val name = stringFromSingleton[name] maybeEnumSchemaExpr match { case Some(enumSchemaExpr) => @@ -135,7 +138,7 @@ def derivedAPIImpl[T: Type, F[_]: Type]( val tpe = TypeRepr.of[T] val cls = tpe.classSymbol val serviceDocs: Option[String] = cls.flatMap(_.docstring).map(Docs.parse).map(_.main) - val serviceHints = maybeAddDocs(hintsForType[T], serviceDocs) + val serviceHints = hintsForType[T].maybeAddDocs(serviceDocs).maybeAddPos(cls) val methodDocs = cls.toList.flatMap(_.declarations.flatMap { sym => sym.docstring.map(docs => sym.name -> Docs.parse(docs)) }).toMap @@ -151,7 +154,13 @@ def derivedAPIImpl[T: Type, F[_]: Type]( } => val serviceNamespace = stringFromSingleton[ns] val serviceName = stringFromSingleton[label] - val opSchemas = operationSchemasExpression[operations, operationLabels, F](serviceNamespace, serviceName, methodDocs) + val methodSymbols = cls.toList.flatMap(_.declarations.map { + sym => sym.name -> sym + }).toMap + val methodParamSymbols = cls.toList.flatMap(_.declarations.map { + sym => sym.name -> sym.paramSymss.flatten // curried methods get caught by `InterfaceMirror` + }).toMap + val opSchemas = operationSchemasExpression[operations, operationLabels, F](serviceNamespace, serviceName, methodDocs, methodSymbols, methodParamSymbols) '{ new DynamicAPI[T] { type Effect[I, E, O, SI, SO] = F[O] @@ -248,12 +257,12 @@ private def extractAnnotationFromType[Annotations: Type](using Quotes): List[Exp report.errorAndAbort(s"got the annotations element ${tpe.show}") } -private def fieldsExpression[T: Type]( +private def fieldsExpression[T: Type](using Quotes)( schemaInstances: List[Expr[Schema[?]]], labels: List[String], hintsMap: Map[String, Expr[Hints]], defaultValues: Map[String, Expr[Any]] -)(using Quotes): Expr[Seq[smithy4s.schema.Field[T, ?]]] = +): Expr[Seq[smithy4s.schema.Field[T, ?]]] = Expr.ofSeq(schemaInstances.zipWithIndex.zip(labels).map { case (('{ $elem: Schema[t] }, index), label) => val indexExpr = Expr(index) val labelExpr = Expr(label) @@ -293,7 +302,7 @@ private def altsExpression[T: Type]( { case (t: tt) => t } } val alt = '{ - $elem.oneOf[T]($labelExpr, $inject)($project): smithy4s.schema.Alt[T, ?] + copyPositionToMember($elem).oneOf[T]($labelExpr, $inject)($project): smithy4s.schema.Alt[T, ?] } alt }) @@ -305,16 +314,16 @@ private def operationHints(annotations: List[Expr[Any]])(using Quotes): Expr[Hin } .getOrElse('{ Hints.empty }) -private def paramHintsMap(annotations: List[List[Expr[Any]]], labels: List[String], paramDocs: Map[String, String])(using - Quotes -): Map[String, Expr[Hints]] = { +private def paramHintsMap(using Quotes) + (annotations: List[List[Expr[Any]]], labels: List[String], paramDocs: Map[String, String], paramSymbols: List[quotes.reflect.Symbol]): Map[String, Expr[Hints]] = { annotations .zip(labels) - .map { case (paramAnnotations, paramName) => + .zipWithIndex + .map { case ((paramAnnotations, paramName), index) => val hintsExpr = paramAnnotations .collectFirst { case '{ $smithyAnnotation: HintsProvider } => '{ $smithyAnnotation.hints } } .getOrElse('{ Hints.empty }) - paramName -> maybeAddDocs(hintsExpr, paramDocs.get(paramName), member = true) + paramName -> hintsExpr.maybeAddDocs(paramDocs.get(paramName), member = true).maybeAddPos(Some(paramSymbols(index)), member = true) } .toMap } @@ -341,11 +350,34 @@ private def errorUnionRepr[U : Type](using quotes: Quotes) : quotes.reflect.Type } } -private def maybeAddDocs(expr: Expr[Hints], docs: Option[String], member: Boolean = false)(using Quotes): Expr[Hints] = { - docs match { - case Some(doc) if member => '{ $expr.addMemberHints(smithy.api.Documentation(${Expr(doc)}))} - case Some(doc) => '{ $expr.addTargetHints(smithy.api.Documentation(${Expr(doc)}))} - case None => expr +extension(expr: Expr[Hints]){ + + private[internals] def maybeAddDocs(using Quotes)(docs: Option[String], member: Boolean = false): Expr[Hints] = { + docs match { + case Some(doc) if member => '{ $expr.addMemberHints(smithy.api.Documentation(${Expr(doc)}))} + case Some(doc) => '{ $expr.addTargetHints(smithy.api.Documentation(${Expr(doc)}))} + case None => expr + } + } + + private[internals] def maybeAddPos(using Quotes)(symbol: Option[quotes.reflect.Symbol], member: Boolean = false) : Expr[Hints] = { + symbol.flatMap(_.pos) match { + case None => expr + case Some(pos) => + val sourceLoc = '{ + SourcePosition( + path = ${Expr(pos.sourceFile.path)}, + start = ${Expr(pos.start)}, + startLine = ${Expr(pos.startLine)}, + startColumn = ${Expr(pos.startColumn)}, + end = ${Expr(pos.end)}, + endLine = ${Expr(pos.endLine)}, + endColumn = ${Expr(pos.endColumn)} + ) : Hints.Binding + } + if (member) '{$expr.addMemberHints($sourceLoc)} + else '{$expr.addTargetHints($sourceLoc)} + } } } @@ -370,16 +402,30 @@ private def fieldHintsMap[T: Type](docs: Map[String, String])(using .map { sym => val hintsExprs = sym.annotations.flatMap(maybeSmithyAnnotation) val hintsExpr = Expr.ofSeq(hintsExprs) - sym.name -> maybeAddDocs('{ $hintsExpr.map(_.hints).fold(Hints.empty)(_ ++ _) }, docs.get(sym.name), member = true) + sym.name -> '{ $hintsExpr.map(_.hints).fold(Hints.empty)(_ ++ _) }.maybeAddDocs(docs.get(sym.name), member = true) } .toMap } -private def operationSchemasExpression[Ts: Type, OpLabels: Type, F[_]: Type]( +private def fieldSymbolsMap[T: Type](using Quotes) : Map[String, quotes.reflect.Symbol] = { + import quotes.reflect.* + TypeRepr + .of[T] + .typeSymbol + .primaryConstructor + .paramSymss + .flatten + .map { sym => sym.name -> sym } + .toMap +} + +private def operationSchemasExpression[Ts: Type, OpLabels: Type, F[_]: Type](using Quotes)( serviceNamespace: String, serviceName: String, - methodDocs: Map[String, Docs] -)(using Quotes): Expr[List[OperationSchema[?, ?, ?, ?, ?]]] = { + methodDocs: Map[String, Docs], + methodSymbols: Map[String, quotes.reflect.Symbol], + methodParamSymbols: Map[String, List[quotes.reflect.Symbol]] +): Expr[List[OperationSchema[?, ?, ?, ?, ?]]] = { val expressionList = typesFromTuple[Ts] .zip(stringsFromTupleOfSingletons[OpLabels]) .map { case ('[op], opName) => @@ -397,10 +443,12 @@ private def operationSchemasExpression[Ts: Type, OpLabels: Type, F[_]: Type]( val opAnnotations = extractAnnotationFromType[annotations] val opDocs = methodDocs.get(opName).map(_.main) val outputDocs = Expr(methodDocs.get(opName).flatMap(_.output)) - val opHints = maybeAddDocs(operationHints(opAnnotations), opDocs) + val methodSymbol = methodSymbols.get(opName) + val opHints = operationHints(opAnnotations).maybeAddDocs(opDocs).maybeAddPos(methodSymbol) val paramDocs = methodDocs.get(opName).map(_.params).getOrElse(Map.empty) + val paramSymbols = methodParamSymbols.get(opName).getOrElse(List.empty) val paramAnnotations = extractAnnotationsFromTuple[inputAnnotations] - val fieldHints = paramHintsMap(paramAnnotations, labels, paramDocs) + val fieldHints = paramHintsMap(paramAnnotations, labels, paramDocs, paramSymbols) val defaults = Map.empty[String, Expr[Any]] val fieldsExpr = fieldsExpression[inputTypes](paramSchemas, labels, fieldHints, defaults) val errorSchemas = summonSchemas[errorTypes, errorTypes] @@ -409,12 +457,13 @@ private def operationSchemasExpression[Ts: Type, OpLabels: Type, F[_]: Type]( val nestedNs = Expr(serviceNamespace + "." + uncapitalise(serviceName)) val opInputNameExpr = Expr(opName + "Input") val opOutputNameExpr = Expr(opName + "Output") + val inputSchemaHints = '{Hints(ShapeId("smithy.api", "input") -> Document.obj())}.maybeAddPos(methodSymbol) val inputSchema = '{ val fields = $fieldsExpr.toVector Schema .struct(fields)(seq => Tuple.fromArray(seq.toArray).asInstanceOf[inputTypes]) .withId($nestedNs, $opInputNameExpr) - .addHints(ShapeId("smithy.api", "input") -> Document.obj()) + .addHints($inputSchemaHints) } val outputSchema = '{ summonInline[Schema[outputType]].compile(wrapOutputSchema(ShapeId($nestedNs, $opOutputNameExpr), $outputDocs))} val opSchemaWithoutError = '{ @@ -578,11 +627,12 @@ private def enumValueExpression[Enum: Type](memberType: Type[?], index: Int)(usi val ev: Expr[Mirror.Of[mt]] = Expr.summon[Mirror.Of[mt]].getOrElse { report.errorAndAbort(s"Could not find a suitable Mirror for ${TypeRepr.of[mt].show}") } - val docs = TypeRepr.of[mt].termSymbol.docstring.map(Docs.parse) + val termSymbol = TypeRepr.of[mt].termSymbol + val docs = termSymbol.docstring.map(Docs.parse) ev match { case '{$mirror: Mirror.Singleton {type MirroredLabel = label}} => Some{ - val hintsExpr = maybeAddDocs(hintsFor(TypeRepr.of[mt].termSymbol), docs.map(_.main)) + val hintsExpr = hintsFor(TypeRepr.of[mt].termSymbol).maybeAddDocs(docs.map(_.main)).maybeAddPos(Some(termSymbol), member = true) val labelExpr = Expr(stringFromSingleton[label]) val indexExpr = Expr(index) '{ diff --git a/modules/examples/shared/src/main/scala/model.scala b/modules/examples/shared/src/main/scala/model.scala index 6aff760..37c4cc2 100644 --- a/modules/examples/shared/src/main/scala/model.scala +++ b/modules/examples/shared/src/main/scala/model.scala @@ -38,7 +38,7 @@ case class LocationNotRecognised(errorMessage: String) extends Throwable derives class HelloWorldService() derives API { @errors[LocationNotRecognised] - @hints(Http(method = "GET", uri = "/hello/{name}")) + @hints(Http(method = "GET", uri = "/hello/{name}"), Readonly()) def hello( @hints(HttpLabel()) name: String, @hints(HttpQuery("from")) from: Option[String] diff --git a/modules/tests/jvm/src/test/scala/utils.test.scala b/modules/tests/jvm/src/test/scala/utils.test.scala index 5bbbcfe..5593bf0 100644 --- a/modules/tests/jvm/src/test/scala/utils.test.scala +++ b/modules/tests/jvm/src/test/scala/utils.test.scala @@ -44,13 +44,16 @@ trait APISuite extends FunSuite { val api = API[A] val unvalidated = DynamicSchemaIndex.builder.addAll(Service[api.Free]).build().toSmithyModel val node = ModelSerializer.builder().build().serialize(unvalidated) - val validated = Model.assembler().addDocumentNode(node).assemble().unwrap() + val validated = Model.assembler().discoverModels().addDocumentNode(node).assemble().unwrap() + val filtered = ModelTransformer + .create() + .filterShapes(validated, _.getId().getNamespace() != "smithy4s.deriving.internals") val expectedAssembler = Model.assembler() modelStrings.zipWithIndex.foreach { case (string, index) => expectedAssembler.addUnparsedModel(s"expected$index.smithy", string) } val expected = expectedAssembler.assemble().unwrap() - assertEquals(ModelWrapper(validated), ModelWrapper(expected)) + assertEquals(ModelWrapper(filtered), ModelWrapper(expected)) } } @@ -58,13 +61,16 @@ trait SchemaSuite extends FunSuite { def checkSchema[A: Schema](modelStrings: String*)(using Location) = { val unvalidated = DynamicSchemaIndex.builder.addAll(Schema[A]).build().toSmithyModel val node = ModelSerializer.builder().build().serialize(unvalidated) - val validated = Model.assembler().addDocumentNode(node).assemble().unwrap() + val validated = Model.assembler().discoverModels().addDocumentNode(node).assemble().unwrap() + val filtered = ModelTransformer + .create() + .filterShapes(validated, _.getId().getNamespace() != "smithy4s.deriving.internals") val expectedAssembler = Model.assembler() modelStrings.zipWithIndex.foreach { case (string, index) => expectedAssembler.addUnparsedModel(s"expected$index.smithy", string) } val expected = expectedAssembler.assemble().unwrap() - assertEquals(ModelWrapper(validated), ModelWrapper(expected)) + assertEquals(ModelWrapper(filtered), ModelWrapper(expected)) } } @@ -77,8 +83,8 @@ class ModelWrapper(val model: Model) { override def equals(obj: Any): Boolean = obj match { case wrapper: ModelWrapper => - val one = reorderMetadata(reorderFields(model)) - val two = reorderMetadata(reorderFields(wrapper.model)) + val one = reorderMetadata(model) + val two = reorderMetadata(wrapper.model) val diff = ModelDiff .builder() .oldModel(one) @@ -147,15 +153,6 @@ class ModelWrapper(val model: Model) { builder.build() } - private val reorderFields: Model => Model = m => { - val structures = m.getStructureShapes().asScala.map { structShape => - val sortedMembers = - structShape.members().asScala.toList.sortBy(_.getMemberName()) - structShape.toBuilder().members(sortedMembers.asJava).build() - } - m.toBuilder().addShapes(structures.asJava).build() - } - private def update(model: Model): Model = { val filterSuppressions: Model => Model = m => new FilterSuppressions().transform( @@ -167,12 +164,13 @@ class ModelWrapper(val model: Model) { ) .build() ) - (filterSuppressions andThen reorderFields)(model) + (filterSuppressions)(model) } override def toString() = SmithyIdlModelSerializer .builder() + .metadataFilter(_ => false) .build() .serialize(update(model)) .asScala diff --git a/project/plugins.sbt b/project/plugins.sbt index d37002b..3e87002 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -4,3 +4,4 @@ addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.6.7") // Set me up for CI release, but don't touch my scalacOptions! addSbtPlugin("org.typelevel" % "sbt-typelevel-ci-release" % "0.6.7") addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.2.0")