Skip to content

tkroman/puree

Repository files navigation

puree

A Scala compiler plugin to warn about unused effects

CircleCI

sbt setup

lazy val pureeV = "0.0.10"
libraryDependencies ++= Seq(
  compilerPlugin("com.tkroman" %% "puree" % pureeV),
  "com.tkroman" %% "puree-api" % pureeV % Provided
)

// very desirable
scalacOptions ++= Seq(
  "-Ywarn-unused:implicits",
  "-Ywarn-unused:imports",
  "-Ywarn-unused:locals",
  "-Ywarn-unused:params",
  "-Ywarn-unused:patvars",
  "-Ywarn-unused:privates",
  "-Werror",
  "-Ywarn-value-discard",
)

Disabling Puree selectively

We also ship the puree-api package which provides an @intended annotation that users can use whenever they want to disable checking for a chunk of code.

Note: @intended also takes optional explanation argument.

import com.tkroman.puree.annotation.intended

@intended
class GoingDirty {
  def f(): Future[Int] = Future(1)
  def g(): Int = {
    f() // I mean... you asked for it
    1
  }
}

This will compile fine.

Another realistic usecase is builders:

import com.tkroman.puree.annotation.intended

val buf = List.newBuilder[Byte]
(buf += 0xf.toByte): @intended("not calling .result() here")
buf.result()

In future some of these use-cases may be heuristically solved by the library but at this point we prefer to remain as flexible as possible.

Strictness configuration

Puree supports (currently) 3 strictness levels:

  • off: Every check is disabled. Same as removing the plugin completely.
  • effects: Only F[_*] checks are performed
  • strict: Any non-unit expression that is not in the return position (i.e. is not the last statement of the enclosing expression) is considered "unused" value. This can be pretty hard on most code so should be enabled at one's own discretion.

Default level is effects. To customize, use one of the following flags:

scalacOptions += Seq("-P:puree:level:off")
scalacOptions += Seq("-P:puree:level:effects")
scalacOptions += Seq("-P:puree:level:strict")

Fine-grained control

It is possible to override behavior for individual methods, which is useful when users want to override some behavior on a system-wide level without individual suppression via @intended.

If there is a puree-settings file on a compilation classpath, fine-grained settings will be read from it. File format:

[off]
foo.bar.Baz.::=

[effect]
foo.bar.Quux.methodName

[strict]
foo.bar.Quack.::

Each section is optional.

Motivation: e.g. scala.collection.mutable.Builder.+= method returns this.type for each builder instance, and since Builder is an F[_, _], code like

val buf = List.newBuilder[Int]
buf += 1
buf.result()

will be flagged as suspicious. To avoid this, just configure puree with this:

[off]
scala.collection.mutable.Builder.+=

Subtyping checks are performed as expected, so e.g. since Builder is a subtype of Growable, a warning will not be raised on Builder instances invoking += if Growable.+='s level is set to Off in settings.

It's possible to always warn on select methods even if a global level is Off:

[strict]
scala.collection.mutable.Builder.+=

means that += invocations will aways trigger warnings.

Why

Effects

In essence, we say that effect is everything that is not a simple value, so

val intIsNotAnEffect = 1
val dateIsNotAnEffect = LocalDate.now()
val stringIsNotAnEffect = "no, I'm not"

val optionIsAnEffect = Some(5)
val listIsAnEffect = List(1, 2, 3)
val taskIsAnEffect = Task(println("yes, I am"))
val ioIsAnEffect = IO("me too")
val programsAreEffectsOfSorts: IO[Unit] = completeAppInIO
// I don't want to mention Future, but...

In a pure FP setting, most effects are pure, i.e. declaring or referring to and effect does not mean any sort of computation being started.

Hence the idea that if somewhere in your code there is this:

def read: Task[String] = Task(in.read())
def write(str: String) = Task(out.write(str))

val prompt: Task[Int = {
    write("Enter a number")
    val number = read()
    number.flatMap(n => Task.fromTry(Try(n.toInt)))
    // use that int
}

you will be surprised by an absence of the prompt string, which will happen because the write(...) expression doesn't actually launch the printing routine.

More than that, in most of the cases a presence of an unused effectful value alone means it's likely a bug, a typo or an omission of sorts. Think of a trivial example:

someCode()
List(1,2,3) // What? Why?
somethingElse()

Normally, scalac will warn you if you use a pure expression in a useless context, e.g

val x = 5
1 // warning here
val y = 10

But it fails to see more complicated examples:

def f = 1
val x = 1
f // no warning
val y = 2

Scala can't help you out here because believing that all functions are pure is not practical in general. However, if you tru writing your code in a more or less principled way, most of the time this will be true for almost all effectful values and functions. Think of it as "If I return an F[_, _*]", I probably wanted to use it.

This plugin is provided specifically as a way to help you with that.

Notes

Works best if you also enable the following scalac flags:

-Ywarn-unused:implicits
-Ywarn-unused:imports
-Ywarn-unused:locals
-Ywarn-unused:params
-Ywarn-unused:patvars
-Ywarn-unused:privates
-Xfatal-warnings
-Ywarn-value-discard

This plugin does not make an attempt to be too smart, the rules are pretty simple: if there is an F[_, _*], it's not assigned to anythings, is not composed with anythings, and is not a last expression in the block, it's considered to be worthy a warning. Making warnings into errors via Xfatal-warnings takes care of the rest.

We also don't try taking over other warings, so there are no additional rules.

A more comprehensive set of scalac flags one should almost always enable if they want to maximize compiler's help can be found here: https://tpolecat.github.io/2017/04/25/scalac-flags.html

License

MIT

Releases

No releases published

Packages

No packages published

Languages