Scalafix

Ingar Abrahamsen
Ingar Abrahamsen
Published 2024-06-07
scalacode

One of the things I love with the Scala ecosystem is the tooling. One of the tools I believe we should have used more is Scalafix. It's an awesome tool that can be used for all from linting to automatic rewrites. In the blog post we-love-scala3 we have written some rules to help adaption of the new Scala 3 syntax. Now let's explore why we ended up with rewrites that did not compile.

Writing Scalafix rules

This will not be a howto on writing Scalafix rules. If you're not familiar with this topic then I advise you to read the scalafix tutorial before continuing.

Many rules have a clear pattern and a specific, isolated scope where we want to rewrite. Typically, this involves renaming something or restructuring code patterns within a library. For linting rules, we may prefer one code pattern over another. These rules usually consist of small, isolated changes that can be done without a lot of context.

This post is about the more complex case, where the rewrite first need to identify what can be patched before applying them. This is the issue we encountered in the GivenAndUsing in we-love-scala3.

Rewrites that does not always compile

Let's dive into the issue with the GivenAndUsing rule from we-love-scala3 post. The problem with the rule, is that in some cases the rewrites cause compilation errors after being applied. We have already established that we expect some false-positive-rate, and we're ok with it. The case here is that the rewrite works on smaller codebases without any hiccups. The rule is proven to work since the test passes, and we trust our tests, right?

To start of we need to understand what we want to rewrite. We want to swap the keyword implicit with given and using where it's applicable. We can not just do this all the places because that will for sure give compilation errors. Part of the rewrite is to correct the imports by specifying the usage of givens.

Alright, let's look at some code!

import cats.Show

case class Person(name: String)
object ShowInstances {
  implicit val personShow: Show[Person] = _.name  
}
object ShowMe {
  import ShowInstances.*
  def nameOfPerson(p: Person)(implicit show: Show[Person]): String =
    show.show(p)
}

Let's break down what we need to rewrite

# Before After
1 implicit val personShow: Show[Person] given personShow: Show[Person]
2 implicit show: Show[Person] using show: Show[Person]
3 import ShowInstances.* import ShowInstances.{*, given}

The scenarios

  • If we only rewrite the code to given then we'll end up with a compile error in nameOfPerson where it does not find the implicit instance.
  • If we only rewrite the code to using then we'll end up with a compile error in nameOfPerson where it does not find the given instance.
  • If we rewrite both but do not correct the imports then we're ending up not finding the given instance.

To ensure we're catching all the scenarios above we need to first find all the candidates where we can rewrite to using and given. We cross-references them and apply the patch. This requires us to do two passes over the codebase. And here I think we're getting into trouble.

Multi pass rewrite rules

Doing multiple passes is needed to be able to get the correct context over what we want to rewrite. This need to be done in a full scope over where we want to apply the rules to. The full codebase in other words. The first pass is to find all the references we want to rewrite first, before using the references to create patches. This is similar to what an editor/IDE need to do when renaming a method.

I do not think the authors of scalafix had this in mind when designing the tool. One of the hints is that it periodically writes how many files it has applied the rule to. So if all the references that need to be rewritten end up within that scope of files then the rule is applied successfully. Unfortunately if some references ends up in two or more scopes then we end up with compilation errors.

Conclusion

Scalafix is a very cool and useful too, but there are some limitations to be aware of:

  1. Has the author of the rule thought of every possible crazy thing out there? Probably not.
  2. Depending on the rule, there might be some (false-positive-rate).
  3. It can't create new files (scalafix#1469).
  4. More complex IDE-like features such as multi-stage rewrites aren't possible.

I don't expect Scalafix to be perfect, and if it can do up to 90% of the work, that's already fantastic. We have found a case where we're probably pushing the limits of the design of the tooling itself.

So, does it need to be fixed? Probably not. If we want to support more IDE-like features, then I would assume it would be a bigger change, possibly in collaboration with other tooling like metals.