From 7dc37783d8674f0e9611bddf11018e96a7bb73e6 Mon Sep 17 00:00:00 2001 From: Andrea Mocci Date: Tue, 19 Nov 2024 14:15:46 +0100 Subject: [PATCH] Initial commit --- .gitignore | 34 +++ .java-version | 1 + .scalafmt.conf | 2 + README.md | 3 + build.sbt | 11 + project/build.properties | 1 + project/plugins.sbt | 1 + .../GenerativeFluentAssertionsDSL.scala | 247 ++++++++++++++++++ .../GenerativeFluentAssertionsDSLSyntax.scala | 70 +++++ .../ch/usi/si/msde/edsl/lecture10/Main.scala | 46 ++++ .../lecture10/ShouldHaveTypeProvider.scala | 87 ++++++ 11 files changed, 503 insertions(+) create mode 100644 .gitignore create mode 100644 .java-version create mode 100644 .scalafmt.conf create mode 100644 README.md create mode 100644 build.sbt create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 src/main/scala/ch/usi/si/msde/edsl/lecture10/GenerativeFluentAssertionsDSL.scala create mode 100644 src/main/scala/ch/usi/si/msde/edsl/lecture10/GenerativeFluentAssertionsDSLSyntax.scala create mode 100644 src/main/scala/ch/usi/si/msde/edsl/lecture10/Main.scala create mode 100644 src/main/scala/ch/usi/si/msde/edsl/lecture10/ShouldHaveTypeProvider.scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b04a101 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# macOS +.DS_Store + +# sbt specific +dist/* +target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ +project/local-plugins.sbt +project/project/ +project/target/ +.history +.ensime +.ensime_cache/ +.sbt-scripted/ +local.sbt + +# Bloop +.bsp + +# VS Code +.vscode/ + +# Metals +.bloop/ +.metals/ +metals.sbt + +# IDEA +.idea +.idea_modules +/.worksheet/ diff --git a/.java-version b/.java-version new file mode 100644 index 0000000..aabe6ec --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21 diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..da019b2 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,2 @@ +version = 3.7.14 +runner.dialect = scala3 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2143d2b --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +## Lecture 12 + +Code for Lecture 12. \ No newline at end of file diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..c832fca --- /dev/null +++ b/build.sbt @@ -0,0 +1,11 @@ +val scala3Version = "3.3.4" + +lazy val root = project + .in(file(".")) + .settings( + organization := "ch.usi.si.msde.edsl", + name := "lecture-10", + version := "2024.01", + scalaVersion := scala3Version, + libraryDependencies += "org.typelevel" %% "squants" % "1.8.3", + ) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..0b699c3 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.2 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..6b6db48 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") \ No newline at end of file diff --git a/src/main/scala/ch/usi/si/msde/edsl/lecture10/GenerativeFluentAssertionsDSL.scala b/src/main/scala/ch/usi/si/msde/edsl/lecture10/GenerativeFluentAssertionsDSL.scala new file mode 100644 index 0000000..c05ab73 --- /dev/null +++ b/src/main/scala/ch/usi/si/msde/edsl/lecture10/GenerativeFluentAssertionsDSL.scala @@ -0,0 +1,247 @@ +package ch.usi.si.msde.edsl.lecture10 + +import scala.quoted.* +import scala.annotation.meta.field +import scala.language.postfixOps + +/** This object defines the assertions generative DSL. + */ +object GenerativeFluentAssertionsDSL: + + /** Entrypoint. This macro method takes an expression composed of + * AssertionProperty expressions and rewrites them into actual assertions. + */ + inline def assertions(inline assertions: AssertionProperty): Unit = ${ + assertionsGenerativeImpl('assertions) + } + + /** Macro implementation. The method is recursive, each single expression is + * then separately rewritten into the generateAssertion method. The method + * returns a compiler error if the expression is not an AssertionProperty. + */ + private def assertionsGenerativeImpl(assertionsExpr: Expr[AssertionProperty])( + using Quotes + ): Expr[Unit] = + import quotes.reflect.* + + assertionsExpr match + // The expression is an AssertionProperty followed by some other expressions + case '{ + ${ assertion }: AssertionProperty + $rest + } => + '{ + // rewrite the assertion on top + ${ generateAssertion(assertion) } + // invoke this recursively on the remaining expressions. + ${ assertionsGenerativeImpl(rest) } + } + // The expression is a single AssertionProperty + case '{ + ${ assertion }: AssertionProperty + } => + generateAssertion(assertion) + // The expression is anything else (invalid) + case anyOther => + report.errorAndAbort( + "Invalid Expression, must be an assertion: " + anyOther.show, + anyOther + ) + + end assertionsGenerativeImpl + + /** Checks a single assertion property if it's a should be or a should have + * assertion. + */ + private def generateAssertion(assertionExpr: Expr[AssertionProperty])(using + Quotes + ): Expr[Unit] = + import quotes.reflect.* + + val generatedAssertion = assertionExpr match + // be assertion. Check the explicit selectDynamic to extract the property name + case '{ + (${ subjectExpr }: subjectType) should be.selectDynamic( + $propertyNameExpr + ) + } => + generateShouldBeAssertion[subjectType](subjectExpr, propertyNameExpr) + // The more complicated structure of property type provider construction in + // the should have assertion. Again, the applyDynamic is explicit. + // The quoted pattern also extracts: the type of the assertion subject, + // and the type of the property (actually... not exactly that. Why?) + case '{ + (${ typeProvider }: HavePropertyTypeProvider[subjectType]) + .applyDynamic($propertyNameExpr)($valueExpr: valueType) + .$asInstanceOf$[AssertionProperty] + } => + val subjectExpr = '{ $typeProvider.subject } + generateShouldHaveAssertion[subjectType, valueType]( + subjectExpr, + propertyNameExpr, + valueExpr + ) + case _ => + report.errorAndAbort( + "Invalid expression, must be a 'should be' or 'should have' assertion. ", + assertionExpr + ) + report.info(clean(generatedAssertion.show), assertionExpr) + generatedAssertion + end generateAssertion + + private def generateShouldBeAssertion[T]( + subject: Expr[T], + propertyNameExpr: Expr[String] + )(using Type[T])(using Quotes): Expr[Unit] = + import quotes.reflect.* + + val subjectTypeRepr = TypeRepr.of[T] + val propertyName = propertyNameExpr.valueOrAbort + val isPropertyName = s"is${propertyName.capitalize}" + + // Looks for the first Boolean field or arity-zero method named exactly as specified or in the isXXXX form. + val optionalValidSymbol = + findCandidateSymbol[T, Boolean](propertyName, isPropertyName) + + if optionalValidSymbol.isEmpty then + report.errorAndAbort( + s"Assertion Error: No field or arity-0 method with name ${propertyName} or ${isPropertyName} on type ${subjectTypeRepr.show}", + propertyNameExpr.asTerm.pos + ) + else + val validSymbol = optionalValidSymbol.get + val validSymbolNameExpr = Expr(validSymbol.toString) + // Constructs a 'select' tree, that is, a selection to the field or method + // of the subject. + val call = Select(subject.asTerm, validSymbol).asExprOf[Boolean] + + val assertion = '{ + assert( + $call, + s"${$subject} was not ${$propertyNameExpr} (${$validSymbolNameExpr} is false)" + ) + } + assertion + end if + end generateShouldBeAssertion + + private def generateShouldHaveAssertion[T, R]( + subjectExpr: Expr[T], + propertyNameExpr: Expr[String], + propertyValueExpr: Expr[R] + )(using Type[T], Type[R])(using Quotes): Expr[Unit] = + import quotes.reflect.* + + val subjectTypeRepr = TypeRepr.of[T] + val optionalCandidateSymbol = + findCandidateSymbol[T, R](propertyNameExpr.valueOrAbort) + + if optionalCandidateSymbol.isEmpty then + report.errorAndAbort( + s"Assertion Error: No field or arity-0 method with name ${propertyNameExpr.valueOrAbort} on type ${subjectTypeRepr.show}", + propertyNameExpr + ) + else + val candidateMethod = optionalCandidateSymbol.get + /* + * This version is slightly different than the one presented during + * the lecture. In this case we also generate a proper message for + * the assertion violation, so we need to declare and reuse a local + * variable, otherwise the same expression with the type provider needs + * to be duplicated and inserted multiple times. So we take the subjectExpr, + * which contains the type provider and selects the subject, and we + * save it to a local val 'subject'. Then we use the low-level AST + * API to retrieve the result: + * first we take '(subject), which is the expression corresponding + * to the subject variable, and we take the low-level term version; + * then, we construct the Select AST which corresponds to the method + * invocation; + * finally, we convert all to an expression back again, which can be + * spliced in the quote. + */ + val assertion = '{ + val subject = $subjectExpr + val expectedValue = $propertyValueExpr + lazy val result = ${ Select(('subject).asTerm, candidateMethod).asExpr } + assert( + result == expectedValue, + s"${subject} did not have ${$propertyNameExpr} equal" + + s" to ${expectedValue}, but it was equal to ${result}" + ) + } + report.info(assertion.show, subjectExpr.asTerm.pos) + assertion + end if + end generateShouldHaveAssertion + + /** Looks for candidate symbols with a given set of possible names on a type. + * + * Improved version w.r.t. the one presented during the lecture. + * + * @param T + * the type of the assertion subject (i.e., the object before the should) + * @param R + * the type of the value after 'have'. + * @param names + * the possible names to look for. + * @return + * optionally a symbol of an arity-0 method or val with name included in + * names, say foo, for which subject.foo(r: R) typechecks. + */ + private def findCandidateSymbol[T, R](names: String*)(using Type[T], Type[R])( + using Quotes + ): Option[quotes.reflect.Symbol] = + import quotes.reflect.* + + val subjectTypeRepr = TypeRepr.of[T] + + // Extracts (possibly) the field with a given name from T. + // If the field does not exist, it returns Symbol.noSymbol. + val possibleFieldSymbols = names.map: name => + subjectTypeRepr.typeSymbol.fieldMember(name) + + // Extracts the methods with a given name (why a list?) + // If no method exists, returns noSymbol. + val possibleMethodSymbols = names.map: name => + subjectTypeRepr.typeSymbol + .methodMember(name) + .headOption + .getOrElse(Symbol.noSymbol) + + // Filter all symbols that are not noSymbol, and that are of arity 0. + val possibleSymbols = (possibleFieldSymbols ++ possibleMethodSymbols) + .filter: symbol => + symbol != Symbol.noSymbol && symbol.paramSymss.isEmpty + .filter: symbol => + // This filters all methods for which the specified value is compatible + // with the return type of the method. For example, the method may take a + // trait A and the value could be of type R <: A. + val memberType = subjectTypeRepr.memberType(symbol).widen + val typeOfValue = TypeRepr.of[R].widen + typeOfValue <:< memberType + + possibleSymbols.find(symbol => + symbol != Symbol.noSymbol && symbol.paramSymss.isEmpty + ) + + end findCandidateSymbol + + /** Removes some common package names and other synthetic code from some code + * representation. + * + * @param codeRepresentation + * some string representing code. + * @return + * the code without common scala package names and other synthetic + * elements. + */ + def clean(codeRepresentation: String) = + codeRepresentation + .replaceAll("(_root_|scala|Predef)\\.", "") + .replaceAll("List\\.apply", "List") + .replaceAll("collection\\.immutable\\.", "") + .replaceAll("ch\\.usi\\.si\\.msde\\.edsl\\.lecture10\\.", "") + .replaceAll(" @annotation\\.unchecked\\.uncheckedVariance", "") + +end GenerativeFluentAssertionsDSL diff --git a/src/main/scala/ch/usi/si/msde/edsl/lecture10/GenerativeFluentAssertionsDSLSyntax.scala b/src/main/scala/ch/usi/si/msde/edsl/lecture10/GenerativeFluentAssertionsDSLSyntax.scala new file mode 100644 index 0000000..bd053e5 --- /dev/null +++ b/src/main/scala/ch/usi/si/msde/edsl/lecture10/GenerativeFluentAssertionsDSLSyntax.scala @@ -0,0 +1,70 @@ +package ch.usi.si.msde.edsl.lecture10 + +import scala.language.dynamics + +sealed trait AssertionProperty + +/** Represents (but with no implementation) the result of a call to + * be.selectDynamic(String), used to specify the property. The semantics of + * this method is implemented through macros - in the assertions method. + */ +class DynamicShouldBeProperty extends AssertionProperty with Dynamic: + def applyDynamic(fieldName: String)(foo: Any*) = ??? +end DynamicShouldBeProperty + +/** The class that implements the syntactic aspect of the type provider. It + * extends Selectable and implements applyDynamic with arity 1, so that any + * object of this type can be invoked with an arbitrary method with 1 argument, + * and a specific return type. + * + * Selectable types can be refined with a list of specific methods that are + * available and thus checked by the compiler. The macro in the should method + * generates a refinement of this type according to type T, so that - for any + * arity zero method or field in T - say foo: X, the type provider has a + * corresponding method def foo(arg: X): AssertionProperty. + */ +class HavePropertyTypeProvider[T](val subject: T) extends Selectable: + def applyDynamic(fieldName: String)(arg: Any): AssertionProperty = ??? +end HavePropertyTypeProvider + +/** A trait for each type of assertion (be or have). + */ +sealed trait AssertionDSLVerb +case object be extends AssertionDSLVerb with Dynamic: + def selectDynamic(fieldName: String): AssertionProperty = ??? +case object have extends AssertionDSLVerb + +/** Implicit class to add 'should' assertions to a generic type T. + * + * @param subject + * the object to which perform the assertion. + */ +extension [T](subject: T) + /** Specifies a 'be' assertion. The method is just a placeholder to fix the + * syntax, its semantics is performed by the assertions macro. + * + * @param verb + * The be word. + * @return + * a DynamicProperty object which allows to specify a further property + * name. + */ + def should(property: AssertionProperty) = ??? + + /** Specifies the 'have' assertion. + * + * The method is a type provider: it generates, through a macro, a refinement + * of the HavePropertyTypeProvider[T] (which is Selectable) which contains, + * for every arity-0 method or field named foo: R in T, a method in the type + * provider foo(value: R): AssertionProperty. + * + * The method semantics is then provided by the assertions macro. + * + * @param verbWord + * The have word. + * @return + * a type provider for properties of type T. + */ + transparent inline def should(inline verbWord: have.type) = ${ + ShouldHaveTypeProvider.generateShouldHaveTypeProvider[T]('subject) + } diff --git a/src/main/scala/ch/usi/si/msde/edsl/lecture10/Main.scala b/src/main/scala/ch/usi/si/msde/edsl/lecture10/Main.scala new file mode 100644 index 0000000..2005b6f --- /dev/null +++ b/src/main/scala/ch/usi/si/msde/edsl/lecture10/Main.scala @@ -0,0 +1,46 @@ +package ch.usi.si.msde.edsl.lecture10 + +// import scala.language.postfixOps + +import squants.mass.MassConversions.MassConversions +import squants.market.MoneyConversions.MoneyConversions + +import GenerativeFluentAssertionsDSL.* +import squants.mass.Mass +import squants.market.Money + +extension (value: Double) def i = Complex(0, value) + +case class Complex(re: Double, im: Double): + def +(other: Complex) = Complex(re + other.re, im + other.im) + def +(other: Double) = Complex(re + other, im) + +case class Person(firstName: String, lastName: String, age: Int): + def adult: Boolean = age >= 18 + +case class Box(label: String, weight: Mass, price: Money) + +@main def assertionsExample: Unit = + val person = Person("Andrea", "Mocci", 0x29) + val box = Box("aBox", 30.kg, 10.CHF) + + assertions: + /* be.property assertions */ + // assert(person.adult, ...) + person should be.adult + // assert(List().isEmpty, ...) + List().should(be.empty) + // assert(List(1,2,3).nonEmpty) + List(1, 2, 3) should be.nonEmpty + + /* should have assertions */ + // assert(List(1).head == 1, ...) + List(1) should have head 1 + // assert(box.weight == 30.kg, ...) + box should have weight 30.kg + // assert(person.age == 0x29, ...) + person should have age 0x29 + List(2, 3) should have tail List(3) + 3.i + 1 should have re 1.0 + +end assertionsExample diff --git a/src/main/scala/ch/usi/si/msde/edsl/lecture10/ShouldHaveTypeProvider.scala b/src/main/scala/ch/usi/si/msde/edsl/lecture10/ShouldHaveTypeProvider.scala new file mode 100644 index 0000000..cddb4c7 --- /dev/null +++ b/src/main/scala/ch/usi/si/msde/edsl/lecture10/ShouldHaveTypeProvider.scala @@ -0,0 +1,87 @@ +package ch.usi.si.msde.edsl.lecture10 + +import scala.quoted.* + +object ShouldHaveTypeProvider: + + /** The type provider for the should have assertion, which generates a refined + * structural type for type T. + */ + def generateShouldHaveTypeProvider[T](subject: Expr[T])(using Type[T])(using + Quotes + ): Expr[Any] = + import quotes.reflect.* + + val subjectTypeRepr = TypeRepr.of[T] + + /** Given a refinable current type, and the symbol of a arity-0 method or a + * field of type foo: X, generates a refinement containing a method with + * signature def foo(arg: X): AssertionProperty. + */ + def refineTypeBySymbol( + currentTypeRepr: TypeRepr, + symbol: Symbol + ): TypeRepr = + // The type of the field, or the return type of the method. + val fieldTypeRepr = subjectTypeRepr.memberType(symbol).widen + + // Generates the "type" of the method to be generated for the refinement. + // The first parameter is the list of arguments, the second is a function returning + // the type of arguments, and the third one is a function returning the return type + // of the method. + // In this case: arg is the name of the parameter; + // _ => List(fieldTypeRepr) returns a list with the type of arg; + // _ => TypeRepr.of[AssertionProperty] returns the (reflection) type of the method. + val methodType = MethodType(List("arg"))( + _ => List(fieldTypeRepr), + _ => TypeRepr.of[AssertionProperty] + ) + // returns the refinement of currentTypeRepr - + // symbol.name is the name of the method, + // methodType is its type. + val refinement = Refinement(currentTypeRepr, symbol.name, methodType) + refinement + + /** Refines a type according to a list of fields or methods of arity 0. + */ + def refineTypeBySymbols( + currentTypeRepr: TypeRepr, + fields: List[Symbol] + ): TypeRepr = + fields match + // this is pattern matching on list - like head :: rest + case symbol :: symbols => + refineTypeBySymbols( + refineTypeBySymbol(currentTypeRepr, symbol), + symbols + ) + // empty list case + case Nil => + currentTypeRepr + + val fields = subjectTypeRepr.typeSymbol.fieldMembers + val arityZeroMethods = subjectTypeRepr.typeSymbol.methodMembers + .filter: methodMember => + methodMember.paramSymss.size == 0 && !methodMember.flags.is( + Flags.Synthetic + ) + + val refinedType = refineTypeBySymbols( + TypeRepr.of[HavePropertyTypeProvider[T]], + fields ++ arityZeroMethods + ) + + // This is the way to extract a (reflection) type to a + // type that can be used in some quoted code. + // it's the equivalent of .asExpr for expressions, + // but it's more complicated because in this case the + // exact type is unknown until compilation time. + refinedType.asType match + case '[tpe] => // tpe is the exact refined type + val res = '{ + val p = HavePropertyTypeProvider[T]($subject) + p.asInstanceOf[tpe] + } + res + +end ShouldHaveTypeProvider