Scala 3: Custom Scalafix Rule With Metaconfig

Alex Johnson
-
Scala 3: Custom Scalafix Rule With Metaconfig

Hey guys! Diving into the world of Scala 3, many of us are exploring how to leverage the power of Scalafix for custom linting and automated refactoring. One common hurdle is integrating Metaconfig to manage rule configurations. Let's break down the challenges and explore potential solutions for creating custom Scalafix rules in Scala 3 with Metaconfig.

The Challenge: Metaconfig and Scala 3 in Scalafix

The core issue revolves around using Metaconfig within a Scala 3 Scalafix rule. You might encounter errors like the dreaded "Scala 2 macro cannot be used in Dotty" message. This stems from Scalafix's historical reliance on Scala 2 macros, which are incompatible with Scala 3's macro system. While Metaconfig itself is Scala 3 compatible in its latest versions, the integration with Scalafix rules presents a unique challenge.

Understanding the Error Message

When you see the error message:

[error] 17 |  implicit val decoder: ConfDecoder[ParsedRuleConfig] = deriveDecoder(default)
[error]    |                                                        ^
[error]    |Scala 2 macro cannot be used in Dotty. See https://docs.scala-lang.org/scala3/reference/dropped-features/macros.html
[error]    |To turn this error into a warning, pass -Xignore-scala2-macros to the compiler

It's a clear indicator that the deriveDecoder macro from Metaconfig is attempting to use Scala 2 macros, which Scala 3 doesn't support. This is a common stumbling block when trying to define configuration decoders for your Scalafix rules.

Workarounds and Temporary Solutions

One workaround you might have stumbled upon, as mentioned, is adding a specific dependency:

libraryDependencies += ("ch.epfl.scala" %% "scalafix-core" % "0.14.3").cross(CrossVersion.for3Use2_13)

This essentially forces the use of a Scala 2.13 compatible version of scalafix-core. While this might get you started, it's not a long-term solution. It introduces compatibility complexities and doesn't fully leverage the benefits of Scala 3.

Diving Deep: Scalafix, Scala 3, and Metaconfig

To truly grasp the situation, let's break down the key players:

  • Scalafix: This is the powerful tool we're using for linting, refactoring, and automated code fixes. It operates by analyzing your code and applying rules to transform it.
  • Scala 3: The latest iteration of the Scala language, bringing significant improvements in type system, metaprogramming, and overall language design.
  • Metaconfig: A fantastic library for handling configuration in a type-safe and convenient manner. It allows you to define configuration structures and easily parse configurations from various sources (files, command-line arguments, etc.).

The core challenge lies in the impedance mismatch between Scalafix's internal workings (still evolving for full Scala 3 support) and Metaconfig's desire to provide a seamless configuration experience.

Why Can't We Just Use Macros?

Scala 3's macro system is significantly different from Scala 2's. While Scala 3 macros are incredibly powerful, they are not directly compatible with the older macro system that Scalafix historically relied upon. This means that libraries like Metaconfig, which often use macros for automatic derivation of decoders and encoders, need to be adapted for Scala 3's macro system.

Potential Solutions and Paths Forward

So, what can we do to effectively use Metaconfig in Scala 3 Scalafix rules?

  1. Manual Decoder Implementation:

    The most straightforward, albeit more verbose, approach is to implement ConfDecoder instances manually. Instead of relying on deriveDecoder, you define how to decode your configuration case classes from Conf values. This gives you complete control and avoids the macro-related issues.

    import metaconfig._
    
    case class ParsedRuleConfig(param1: String, param2: Int)
    
    object ParsedRuleConfig {
      implicit val decoder: ConfDecoder[ParsedRuleConfig] = new ConfDecoder[ParsedRuleConfig] {
        override def read(conf: Conf): Configured[ParsedRuleConfig] = {
          (conf.get[String]("param1") |@| conf.get[Int]("param2"))
            .map(ParsedRuleConfig.apply)
        }
      }
    }
    

    While this requires more code, it's a robust solution that works reliably in Scala 3.

  2. Explore Scalafix's Scala 3 Support:

    The Scalafix project is actively working on improving Scala 3 support. Keep an eye on the official Scalafix documentation and release notes for updates on Metaconfig integration and Scala 3 compatibility. There might be new APIs or recommended approaches that emerge as the project evolves.

  3. Community Contributions and Extensions:

    The Scala community is incredibly vibrant. There's a good chance that someone might be working on a dedicated library or extension that bridges the gap between Metaconfig and Scalafix in Scala 3. Keep an eye on community forums, GitHub repositories, and discussions to see if any such solutions are available.

  4. Leverage Scala 3's Inline Metaprogramming:

    Scala 3's inline metaprogramming features offer powerful ways to generate code at compile time. While this is an advanced technique, it could potentially be used to create custom decoders or adapt Metaconfig's macro-based approach for Scala 3. This path requires a deeper understanding of Scala 3's metaprogramming capabilities.

Example: Manual Decoder Implementation in Detail

Let's expand on the manual decoder implementation. Imagine you have a configuration case class like this:

case class MyRuleConfig(
  enabled: Boolean = true,
  threshold: Int = 100,
  message: String = "Default Message"
)

You would create a ConfDecoder instance like this:

import metaconfig._

object MyRuleConfig {
  implicit val decoder: ConfDecoder[MyRuleConfig] = new ConfDecoder[MyRuleConfig] {
    override def read(conf: Conf): Configured[MyRuleConfig] = {
      (conf.getOrElse("enabled", true) |@|
        conf.getOrElse("threshold", 100) |@|
        conf.getOrElse("message", "Default Message"))
        .map(MyRuleConfig.apply)
    }
  }
}

Key aspects of this implementation:

  • new ConfDecoder[MyRuleConfig] { ... }: We create an anonymous class implementing the ConfDecoder trait for our specific configuration type.
  • override def read(conf: Conf): Configured[MyRuleConfig]: The read method is the heart of the decoder. It takes a Conf object (Metaconfig's representation of configuration) and attempts to decode it into our MyRuleConfig.
  • conf.getOrElse(...): We use getOrElse to safely retrieve values from the Conf object, providing default values if a particular key is missing. This makes our configuration more flexible.
  • |@|: This is the applicative operator from Metaconfig, allowing us to combine multiple Configured results. It essentially chains together the decoding of each field in our case class.
  • .map(MyRuleConfig.apply): Finally, we map the combined results to the constructor of our MyRuleConfig case class, creating an instance of our configuration.

Conclusion: Embracing the Scala 3 Journey

While integrating Metaconfig with Scalafix in Scala 3 might require a bit more manual work currently, the flexibility and power of Scala 3, combined with Metaconfig's configuration management capabilities, make it a worthwhile endeavor. By understanding the underlying challenges and exploring the solutions outlined above, you can create robust and configurable Scalafix rules in Scala 3.

Keep experimenting, stay tuned for updates from the Scalafix project, and don't hesitate to engage with the Scala community for insights and support. Happy coding, guys!

For more information on ScalaFix, check out the official website Scalafix.

You may also like