Initial commit

This commit is contained in:
Andrea Mocci 2024-11-19 14:15:46 +01:00
commit 7dc37783d8
11 changed files with 503 additions and 0 deletions

34
.gitignore vendored Normal file
View file

@ -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/

1
.java-version Normal file
View file

@ -0,0 +1 @@
21

2
.scalafmt.conf Normal file
View file

@ -0,0 +1,2 @@
version = 3.7.14
runner.dialect = scala3

3
README.md Normal file
View file

@ -0,0 +1,3 @@
## Lecture 12
Code for Lecture 12.

11
build.sbt Normal file
View file

@ -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",
)

1
project/build.properties Normal file
View file

@ -0,0 +1 @@
sbt.version=1.10.2

1
project/plugins.sbt Normal file
View file

@ -0,0 +1 @@
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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