Initial commit
This commit is contained in:
commit
7dc37783d8
11 changed files with 503 additions and 0 deletions
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal 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
1
.java-version
Normal file
|
@ -0,0 +1 @@
|
|||
21
|
2
.scalafmt.conf
Normal file
2
.scalafmt.conf
Normal file
|
@ -0,0 +1,2 @@
|
|||
version = 3.7.14
|
||||
runner.dialect = scala3
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
## Lecture 12
|
||||
|
||||
Code for Lecture 12.
|
11
build.sbt
Normal file
11
build.sbt
Normal 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
1
project/build.properties
Normal file
|
@ -0,0 +1 @@
|
|||
sbt.version=1.10.2
|
1
project/plugins.sbt
Normal file
1
project/plugins.sbt
Normal file
|
@ -0,0 +1 @@
|
|||
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")
|
|
@ -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
|
|
@ -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)
|
||||
}
|
46
src/main/scala/ch/usi/si/msde/edsl/lecture10/Main.scala
Normal file
46
src/main/scala/ch/usi/si/msde/edsl/lecture10/Main.scala
Normal 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
|
|
@ -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
|
Loading…
Reference in a new issue