Subtle problems with templates in multiline strings and some ways to fix them
These days many languages support multiline string templates. Here's what they look like in Kotlin:
val example = """
This
is a
multiline
string.
"""
But that will create a multiline with a bunch of spaces at the beginning of each line:
This
is a
multiline
string.
So I like to use trimIndent
to remove these spaces.
val example = """
This
is a
multiline
string.
""".trimIndent()
This works the way I expect:
This
is a
multiline
string.
But problems happen when I combine string templating with trimIndent:
val center = """
is a
multiline
""".trimIndent()
val example = """
This
$center
string.
""".trimIndent()
$center
gets expanded to a string consisting of two lines with no
indentation. The first line is indented because $center
itself is indented.
But the second line is not indented.
This
is a
multiline
string.
If you want a workaround that works in kotlin, try this:
val center = """
|is a
|multiline
""".trimMargin()
val example = """
|This
|${center.replace("\n", "\n|")}
|string.
""".trimMargin()
I think the margin character is a little ugly, and the requirement of embedding
with ${center.replace("\n", "\n|")}
is quite ugly. Maybe even worse, you can
use $center
and it will actually work - most of the time. The problem happens
if center
contains lines that actually begin with the |
character:
val center = """
|is a
||multiline beginning with pipe
""".trimMargin()
val example = """
|This
|$center
|string.
""".trimMargin()
Notice that extra |
I added in front of multiline
? That indicates that the
line actually does begin with a |
character:
is a
|multiline beginning with pipe
But because I referenced $center
directly that leading |
gets removed and
|multiline beginning with pipe
turns into multiline beginning with pipe
:
This
is a
multiline beginning with pipe
string.
So it works, if you accept some ugliness, and you're careful. I wanted a better way. And it turns out I can do it in Scala. I just need to define a custom string type:
implicit class AdjustedMultiline(val sc: StringContext) extends AnyVal {
def m(args: Any*): String = {
// Create a string where each arg is 'x'
assert(sc.parts.length == args.length + 1)
val parts = sc.parts
val substitutedArgs = args.map(_ => "x")
val indentString = (parts.init zip substitutedArgs).map { case (part, arg) => part + arg }.mkString + parts.last
// Use this string to compute the indent count
val indentCount = indentString.linesIterator
.filter(_.trim.nonEmpty)
.map(line => line.takeWhile(_ == ' ').length)
.minOption
.getOrElse(0) // Default value when there's no non-empty line
// Add in this indentation to each newline within each arg
val processed = sc.parts.zipAll(args, "", "").map {
case (str, arg) =>
str + arg.toString.split("\n").mkString("\n" + (" " * indentCount))
}
// Strip starting/ending newlines with leading/trailing spaces.
// We do this to support syntax like:
// val str = m"""
// string content
// """
// which should be equal to "string content"
val combined = processed.mkString.replaceAll("^ *\n", "").replaceAll("\n *$", "")
combined.linesIterator.map(line => line.drop(indentCount)).mkString("\n")
}
}
And then I can write:
val center = m"""
is a
multiline
"""
val example = m"""
This
$center
string.
"""
And it works the way I'd hope:
This
is a
multiline
string.
Is there any reason why language designers don't handle multiline string templating like this by default?
If you enjoyed this post, please let me know on Twitter or Bluesky.
Posted November 21, 2023.
Tags: #misc