This is an excerpt from the Scala Cookbook, 2nd Edition. This is Recipe 2.11, Creating Your Own String Interpolator.
Scala 3 Problem
You want to create your own string interpolator in Scala 3, like the s, f, and raw interpolators that come with Scala.
Solution
To create your own string interpolator, you need to know that when a programmer writes code like foo"a b c", that code is transformed into a foo method call on the Scala StringContext class. Specifically, when you write this code:
val a = "a"
foo"a = $a"
it’s translated into this:
StringContext("a = ", "").foo(a)
Therefore, to create a custom string interpolator, you need to create foo as a Scala 3 extension method on the StringContext class. There are a few additional details you need to know, and I’ll show those in an example.
Suppose that you want to create a string interpolator named caps that capitalizes every word in a string, like this:
caps"john c doe" // "John C Doe"
val b = "b"
caps"a $b c" // "A B C"
To create caps, define it as an extension method on StringContext. Because you’re creating a string interpolator, you know that your method needs to return a String, so you begin writing the solution like this:
extension(sc: StringContext)
def caps(?): String = ???
Because a pre-interpolated string can contain multiple expressions of any type, caps needs to be defined to take a varags parameter of type Any, so you can further write this:
extension(sc: StringContext)
def caps(args: Any*): String = ???
To define the body of caps, the next thing to know is that the original string comes to you in the form of two different variables:
-
sc, which is an instance ofStringContext, and provides it data in an iterator -
args.iterator, which is an instance ofIterator[Any]
This code shows one way to use those iterators to rebuild a String with each word capitalized:
extension(sc: StringContext)
def caps(args: Any*): String =
// [1] create variables for the iterators. note that for an
// input string "a b c", `strings` will be "a b c" at this
// point.
val strings: Iterator[String] = sc.parts.iterator
val expressions: Iterator[Any] = args.iterator
// [2] populate a StringBuilder from the values in the iterators
val sb = StringBuilder(strings.next.trim)
while strings.hasNext do
sb.append(expressions.next.toString)
sb.append(strings.next)
// [3] convert the StringBuilder back to a String,
// then apply an algorithm to capitalize each word in
// the string
sb.toString
.split(" ")
.map(_.trim)
.map(_.capitalize)
.mkString(" ")
end caps
end extension
Here’s a brief description of that code:
-
First, variables are created for the two iterators. The
stringsvariable contains all the string literals in the input string, andexpressionscontains values to represent all of the expressions in the input string, such as a$avariable. -
Next, I populate a
StringBuilderby looping over the two iterators in thewhileloop. This starts to put the string back together, including all of the string literals and expressions. -
Finally, the
StringBuilderis converted back into aString, and then a series of transformation functions are called to capitalize each word in the string.
There are other ways to implement the body of that method, but I use this approach to be clear about the steps involved, specifically that when an interpolator like caps"a $b c ${d*e}" is created, you need to rebuild the string from the two iterators.
Discussion
To understand the solution it helps to understand how string interpolation works, i.e., how the Scala code you type in your IDE is converted into other Scala code. With string interpolation, the consumer of your method writes code like this:
id"text0${expr1}text1 ... ${exprN}textN"
In this code:
-
idis the name of your string interpolation method, which iscapsin my case -
The
textNpieces are string constants in the input (pre-interpolated) string -
The
exprNpieces are the expressions in the input string that are written with the$expror${expr}syntax
When you compile the id code, the compiler translates it into code that looks like this:
StringContext("text0", "text1", ..., "textN").id(expr1, ..., exprN)
As shown, the constant parts of the string — the string literals — are extracted and passed as parameters to the StringContext constructor. The id method of the StringContext instance — caps, in my example — is passed any expressions that are included in the initial string.
As a concrete example of how this works, assume that you have an interpolator named yo and this code:
val b = "b"
val d = "d"
yo"a $b c $d"
In the first step of the compilation phase the last line is converted into this:
val listOfFruits = StringContext("a ", " c ", "").yo(b, d)
Now the yo method needs to be written like the caps method shown in the Solution, handling these two iterators:
args.iterators contains: "a ", " c ", "" // String type
exprs.iterators contains: b, d // Any type
More Scala interpolators
For more details, my Github project for this book shows several examples of interpolators, including my Q interpolator, which converts this multiline string input:
val fruits = Q"""
apples
bananas
cherries
"""
into this resulting list:
List("apples", "bananas", "cherries")
| this post is sponsored by my books: | |||
#1 New Release |
FP Best Seller |
Learn Scala 3 |
Learn FP Fast |
See Also
-
This recipe uses extension methods, which are discussed in [methods-extension-methods-intro]
-
The initial part of the explanation in the Discussion is based on this hootenannylas.blogspot.com blog post