Multi-line string nested indentation with interpolation

I'm trying to find out if there is an easy way to preserve indentation of interpolated variables when using multi-line string.

Example:

let nested = """
foo
bar
"""

let nestedIndented = """
baz
qux
"""

let string = """
Hello
\(nested)
    \(nestedIndented)
Bye
"""

print(string)

Expected/desired output:

Hello
foo
bar
    baz
    qux
Bye

Actual output:

Hello
foo
bar
    baz
qux               <-- qux is not indented
Bye

This can be solved by pre-indenting nested strings, before interpolating them in the final output. But this would require nested strings to be aware of the context where they would be nested, which is not desirable.

Any ideas or suggestions?

Possible solution

extension String.StringInterpolation {
    mutating func appendInterpolation(🚲🏠 value: String) {
        let currentIndentation = self.description.split(separator: "\n", omittingEmptySubsequences: false).last?.count ?? 0
        let spaces = String(repeating: " ", count: currentIndentation)
        appendLiteral(value.split(separator: "\n").joined(separator: "\n" + spaces))
    }
}


let nested = """
foo
bar
"""

let nestedIndented = """
baz
qux
"""

let string = """
Hello
\(🚲🏠: nested)
    \(🚲🏠: nestedIndented)
indentedWithText\(🚲🏠: nestedIndented)
Bye
"""
print(string)

You can bikeshed the interpolation parameter name to your heart's content

Beat me to it. Here's a slightly different take, copying the line prefix (preserving tabs, quotation markers, what have you).

extension DefaultStringInterpolation {
    mutating func appendInterpolation(indenting string: String) {
        let indent = description.lastIndex(of: "\n").map { String(description[$0...]) }
            ?? "\n\(description)"
        for (index, line) in string.split(separator: "\n").enumerated() {
            if index > 0 { appendLiteral(indent) }
            appendInterpolation(line)
        }
    }
}
1 Like

Thanks for the ideas, @cukr & @pyrtsa

I went with a mix of both solutions, but using reversed().prefix(while:) to search exclusively for whitespace (spaces and tabs).

extension DefaultStringInterpolation {
    mutating func appendInterpolation(indented string: String) {
        let indent = String(description.reversed().prefix { " \t".contains($0) })
        if indent.isEmpty {
            appendInterpolation(string)
        } else {
            appendLiteral(string.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + indent))
        }
    }
}

This code seems to work in more complex scenarios, like:

let test1 = """
Hello
foo
bar
    333
    444
555\(indented: "\nfoos\n    ball")
"""

let test2 = """
Hello
foo
bar
    333
    444
\t\(indented: "\nfoos\n    ball")
"""

The if/else is not needed, but thought it would help with performance.

Thank you very much for the help!

1 Like

This problem is a nice example for the benefits of a custom string interpolation, thanks all. It should be noted that all solutions posted above use DefaultStringInterpolation.description to inspect the interpolated string as it’s being built up. The format of CustomStringConvertible.description is not guaranteed to be stable and we should generally try to avoid writing code that relies on a specific format.

In fact, quite a few apps broke last year when Apple changed the format of NSData.description in iOS 13 and macOS 10.15.

Unfortunately, DefaultStringInterpolation's _storage property is not public, so I don't see a better solution.

2 Likes

Well instead of accessing description we could as well create a temporary variable from String(stringInterpolation: self), which should be stable.

2 Likes

Good points, @ole

Here is my final version with @pyrtsa's suggestion:

extension DefaultStringInterpolation {
    mutating func appendInterpolation(indented string: String) {
       let indent = String(stringInterpolation: self).reversed().prefix { " \t".contains($0) }
       if indent.isEmpty {
            appendInterpolation(string)
        } else {
            appendLiteral(string.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + indent))
        }
    }
}

Good suggestion, thanks. This should indeed be stable. The documentation advises against calling String.init(stringInterpolation:) directly, but it shouldn't be a problem.

One thing I'm not sure about:

The implementation of the initializer calls DefaultStringInterpolation.make():

extension String {
  @inlinable
  @_effects(readonly)
  public init(stringInterpolation: DefaultStringInterpolation) {
    self = stringInterpolation.make()
  }
}

And DefaultStringInterpolation.make() is marked as __consuming:

extension DefaultStringInterpolation {
  /// Creates a string from this instance, consuming the instance in the
  /// process.
  @inlinable
  internal __consuming func make() -> String {
    return _storage
  }
}

I don't know what semantics __consuming has (or may have in the future). I'm not very familiar with the Ownership stuff yet. Does it somehow mean that you can call this only once and self becomes invalid after the call?

Does this extension trick still work? I've used this in the past, but it doesn't seem to work anymore.

Maybe there is a better way to accomplish this now?