Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add compiletime Error type #7951

Closed
wants to merge 1 commit into from
Closed

Conversation

MaximeKjaer
Copy link
Contributor

@MaximeKjaer MaximeKjaer commented Jan 9, 2020

This PR adds support for custom compile-time type error messages, making it possible to write the following:

import scala.compiletime.ops.string.{+, Error}
import scala.compiletime.ops.int.{>=, ToString}

object Test {
  opaque type Positive = Int
  object Positive {
    def apply[T <: Int](value: T)(given RequirePositive[T]): Positive = value

    type RequirePositive[T <: Int] <: Any = (T >= 0) match {
      case true => Any
      case false => Error["The provided value (" + ToString[T] + ") isn't positive"]
    }
  }

  val compiles: Positive = Positive[1](1)
  val doesNotCompile: Positive = Positive[-1](-1) 
                                   // error: ^ The provided value (-1) isn't positive
}

Background

#7628 added compile-time singleton operations. With the ability to write more complex type-level computations comes the need for reporting errors neatly to the user. The Error type is implemented as another compile-time singleton op that reports a compilation error when evaluated by the compiler.

Note that Dotty already has mechanisms for custom errors during implicit search through @scala.annotation.implicitNotFound, and during inlining through scala.compiletime.error.

Discussion

This is a draft PR: it's not ready to be merged as is, as I would also like to discuss a few things:

  • Should Error be somewhere else than scala.compiletime.ops.string? Though it is currenlly implemented as such, it isn't really an operation on strings. Should it perhaps be in scala.compiletime.ops, or scala.compiletime?

  • Should Error have another name? It clashes with java.Error.

  • I think the current implementation of throwing an exception when the type is normalized is wrong. For instance, the following code does not compile:

    type Not1[X <: Int] <: Int = X match {
      case 1 => Error["cannot be 1"]
      case _ => X
    }

    What would be a better way to implement this?

Copy link
Member

@dottybot dottybot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello, and thank you for opening this PR! 🎉

All contributors have signed the CLA, thank you! ❤️

Have an awesome day! ☀️

@soronpo
Copy link
Contributor

soronpo commented Jan 10, 2020

Should Error be somewhere else than scala.compiletime.ops.string? Though it is currenlly implemented as such, it isn't really an operation on strings. Should it perhaps be in scala.compiletime.ops, or scala.compiletime?

I think that under .any is the best in this case. I also think that we will eventually have all combined operations under .ops.all (e.g., + that supports all types, including string concatenation). The ops.any, ops.int, etc., will be considered internal and users will usually want to just use ops.all.

@soronpo
Copy link
Contributor

soronpo commented Jan 10, 2020

Should Error have another name? It clashes with java.Error.

I don't think there is a problem here. I doubt the two will need to interact, and even if so, then people can just refer to java.Error. But if you're worried CompileError will do just as well.

@soronpo
Copy link
Contributor

soronpo commented Jan 10, 2020

As I've discovered from my experience with the singleton-ops library, compile-time errors have a few issues that need to be discussed.

Implicit Error Redirection

There are many cases where the error will be used implicitly, so we need to make sure to redirect it to the enclosing implicit symbol's annotation. For example:

import scala.compiletime.ops.string.{+, Error}
import scala.compiletime.ops.int.{>=, ToString}

object Test {
  opaque type Positive[T <: Int] = T
  object Positive {
    def apply[T <: Int](value: T)(given pos : Positive[T]): Positive[T] = pos
    given [T <: Int](given pos : ValueOf[RequirePositive[T]]) : Positive[T] = pos.value

    type RequirePositive[T <: Int] <: Int= (T >= 0) match {
      case true => T
      case false => Error["The provided value (" + ToString[T] + ") isn't positive"]
    }
  }

  val compiles = Positive(1)
  val doesNotCompile = Positive(-1) 
  val compiles2 = implicitly[Positive[1]]
  val doesNotCompile2 = implicitly[Positive[-1]] //What error is generated here?
}

I don't have time to test your fork, but I'm guessing the doesNotCompile2 example fails with an implicit not found error, and not the error message we intended to have. Relying on just @implicitNotFound annotation is very limiting, and especially if there are several possible conditions for failure.

Therefore, I propose you do exactly what I do in the singleton-ops library's macro. Before throwing the error I also check the enclosing implicit. If there are enclosing implicits then I set the annotation of its last implicit's symbol. This is how it looks in Scala 2 macro code:

  val defaultAnnotatedSym : Option[TypeSymbol] =
    if (c.enclosingImplicits.isEmpty) None else c.enclosingImplicits.last.pt match {
      case TypeRef(_,sym,_) => Some(sym.asType)
      case x => Some(x.typeSymbol.asType)
    }

  //This is the method I call every time I wish to throw a compile-time error (expected or otherwise)
  def abort(msg: String, annotatedSym : Option[TypeSymbol] = defaultAnnotatedSym): Nothing = {
    if (annotatedSym.isDefined) setAnnotation(msg, annotatedSym.get)
    c.abort(c.enclosingPosition, msg)
  }

  ////////////////////////////////////////////////////////////////////
  // Code thanks to Shapeless
  // https://github.com/milessabin/shapeless/blob/master/core/src/main/scala/shapeless/lazy.scala
  ////////////////////////////////////////////////////////////////////
  def setAnnotation(msg: String, annotatedSym : TypeSymbol): Unit = {
    import c.internal._
    import decorators._
    val tree0 =
      c.typecheck(
        q"""
          new _root_.scala.annotation.implicitNotFound("dummy")
        """,
        silent = false
      )

    class SubstMessage extends Transformer {
      val global = c.universe.asInstanceOf[scala.tools.nsc.Global]

      override def transform(tree: Tree): Tree = {
        super.transform {
          tree match {
            case Literal(Constant("dummy")) => Literal(Constant(msg))
            case t => t
          }
        }
      }
    }

    val tree = new SubstMessage().transform(tree0)

    annotatedSym.setAnnotations(Annotation(tree))
    ()
  }
  ////////////////////////////////////////////////////////////////////

@soronpo
Copy link
Contributor

soronpo commented Jan 10, 2020

Non-Literal Value Handling

What do we do with non-literal values in errors? In your example, what does the following code do:

val one : Int = 1
val negOne : Int = -1
val pos = Positive(one) //what happens here?
val neg = Positive(negOne) //what happens here?

In the singleton-ops library, there is a compile-time error if my Require cannot be evaluated at compile-time. But I also have an IsLiteral fallback option and do some complex mechanism that does compile-time checks and runtime checks as fallback. I propose that we add something similar to the dotty constValueOpt, like ConstOpt[T], so the users can apply match type on and make decisions if the value is not a literal.

Workaround Scalac Eager Widening

A problem will surface when we add a runtime fallback. Scalac (and also dotty is no different, I assume) is very eager to widen types to get things to compile. It's great in most situations, but can cause unexpected results here:

type RequirePositive[T <: Int] <: Boolean = ConstOps[T] match {
  case Some[t] => (t > 0) match {
     case true => false //return false to indicate no runtime check is needed
     case false => Error["The provided value (" + ToString[t] + ") isn't positive"]
  case None => true //return true to indicate a runtime check is needed
}
def check[T <: Int](t : T)(given checkPos : ValueOf[RequirePositive[T]]) : Unit = 
  if (checkPos.value) assert(checkPos.value > 0, s"The provided value (${checkPos.value}) isn't positive")

val check1 = check(1) //should compile and run (not doing any runtime checks)
val checkNeg1 = check(-1) //should fail at compile-time
val one : Int = 1
val negOne : Int = -1
val checkOne = check(one) //should compile and run
val checkNegOne= check(negOne) //should compile and throw an exception   

The problem (I'm assuming) you will see in the example above without working around Scalac's widening is that the compiler will know that it can widen -1 to get checkNeg1 to compile. This is a problem. I solved it by further exploring the tree of T, typechecked it and used the constant literal type internally.

@soronpo
Copy link
Contributor

soronpo commented Jan 10, 2020

Warn[T]

In addition to Error I propose also adding Warn[T <: String] to generate compiler warnings. You asked if there's a better way than throwing a TypeError exception. I bet when you find how to generate a warning, you find it's the same with an error.

Copy link
Contributor

@soronpo soronpo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import scala.compiletime.ops.string.{+, Error}
import scala.compiletime.ops.int.{>=, ToString}

object Test {
  opaque type Positive = Int
  object Positive {
    def apply[T <: Int](value: T)(given RequirePositive[T]): Positive = value

    type RequirePositive[T <: Int] <: Any = (T >= 0) match {
      case true => Any
      case false => Error["The provided value (" + ToString[T] + ") isn't positive"]
    }
  }

  val compiles: Positive = Positive[1](1)
  val doesNotCompile: Positive = Positive[-1](-1) 
                                   // error: ^ The provided value (-1) isn't positive
}

I'm confused from my syntax of singleton-ops, but shouldn't this code look like follows:

import scala.compiletime.ops.string.{+, Error}
import scala.compiletime.ops.int.{>=, ToString}

object Test {
  opaque type Positive[T <: Int] = Positive.RequirePositive[T]
  object Positive {
    def apply[T <: Int](value : RequirePositive[T]) : Positive[T] = value

    type RequirePositive[T <: Int] <: Int = (T > 0) match {
      case true => T
      case false => Error["The provided value (" + ToString[T] + ") isn't positive"]
    }
  }

  val compiles: Positive = Positive[1](1)
  val doesNotCompile: Positive = Positive[-1](-1) 
                                   // error: ^ The provided value (-1) isn't positive
}


object Positive {
type RequirePositive[T <: Int] = Require[T >= 0, "The provided value (" + ToString[T] + ") isn't positive"]
def apply[T <: Int](value: T)(given RequirePositive[T]): Positive = value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After thinking about this, I'm actually surprised this is working. This style is similar to the singleton-ops library and assumes that there is an implicit for RequirePositive[T <: Int], but why should there be? If there is no error, RequirePositive[T <: Int] is actually Any, so how come it works?

opaque type Positive = Int

object Positive {
type RequirePositive[T <: Int] = Require[T >= 0, "The provided value (" + ToString[T] + ") isn't positive"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be Require[T > 0, ...

@MaximeKjaer
Copy link
Contributor Author

Thank you for the feedback @soronpo! It will take another couple of weeks before I have some free time to invest into this PR, but I will get to it.

@b-studios
Copy link
Contributor

Hey @MaximeKjaer, what is the current status on this PR?

@MaximeKjaer
Copy link
Contributor Author

I still think this feature would be nice to have. But it proved to be a bit too big of a challenge for me to implement, both in terms of time and of my knowledge of the compiler. So if anyone else is interested in taking this on, you can go ahead if you'd like!

A year on, the main conclusion I've come to is that I think this feature shouldn't be a compiletime op, but it should instead be tied exclusively to match types. This is for two reasons:

  • Errors could provide a useful alternative to stuck match types
  • It's unclear when and where to report errors outside of match types (e.g. should def foo(): Error["test"] report an error at the call-site or at the definition?)

For these reasons, I think the feature should only be allowed in the right-hand side of a match type case; using it anywhere else should be disallowed (or at least, ignored).

I've spoken a bit about this with @OlivierBlanvillain, and I think he agrees with the above (but do correct me if I'm wrong, @OlivierBlanvillain!).

@soronpo
Copy link
Contributor

soronpo commented Apr 27, 2021

I may take this on later this year, trying to port singleton-ops to Scala 3 (or removing the need for it in by doing everything in Scala 3).

@b-studios
Copy link
Contributor

Thanks @MaximeKjaer! I am closing this PR then for the time being. Maybe @soronpo can bookmark it to pick it up later.

@b-studios b-studios closed this Apr 27, 2021
@dorranh
Copy link

dorranh commented Aug 14, 2023

Hi @b-studios, @soronpo. I wanted to see if there were any updates regarding this PR? Such a feature would be useful for a project I am currently working on 🙂.

@soronpo
Copy link
Contributor

soronpo commented Aug 14, 2023

@dorranh Unfortunately, I do not have time for this. There is also an issue of how to get this API addition approved, even if there was a PR. Currently there is no committee for the Scala standard library (which compiletime-ops is part of).

@dorranh
Copy link

dorranh commented Aug 14, 2023

Hi @soronpo, thanks for your response. No worries - it's good to know that this is not planned for the time being.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants