@dynamicCallable with string interpolation arguments cause compilation error

I tried to test drive @dynamicCallable by writing a Logger class but ran into a surprising problem - the playground reports:
error: cannot use mutating member on immutable value: '$interpolation' is immutable
and the compiler reports:
<unknown>:0: error: 'inout DefaultStringInterpolation' is not convertible to 'DefaultStringInterpolation'

here's the code:

@dynamicCallable
class Logger {
    func dynamicallyCall(withArguments args: [Any]) -> Void {
        var output = ""
        args.forEach { print($0, separator: " ", terminator: "", to: &output) }
        print(output)
    }
}
var log = Logger()
let a = 1
log(a) // prints 1
log("a") // prints a
log("\(a)") // error: 'inout DefaultStringInterpolation' is not convertible to 'DefaultStringInterpolation'
log.dynamicallyCall(withArguments: ["\(a)"]) // prints 1

I managed to get string interpolation working with the logger by creating a class that implements ExpressibleByStringInterpolation.StringInterpolation as a class by modifying so:

public final class LogElement: ExpressibleByStringInterpolation, CustomStringConvertible {

    public let description: String
    public init(stringLiteral value: String) {
        self.description = value
    }
    public init(stringInterpolation: StringInterpolation) {
        description = stringInterpolation.output
    }
    final public class StringInterpolation: StringInterpolationProtocol {
        var output = ""
        public init(literalCapacity: Int, interpolationCount: Int) {
            output.reserveCapacity(literalCapacity * 2)
        }
        public func appendLiteral(_ literal: String) {
            output.append(literal)
        }
        public func appendInterpolation(_ string: Any) {
            output.append(String(describing: string))
        }
    }
}
@dynamicCallable
class Logger {
    func dynamicallyCall(withArguments args: [LogElement]) -> Void {
        var output = ""
        args.forEach { print($0, separator: " ", terminator: "", to: &output) }
        print(output)
    }
}

However, this loses the crucial args: [Any] parameter that allows it to log all values.

Is this expected behaviour? Are there any other workarounds?

Cheers,
Daniel

This is a known issue, SR-10753. Sorry about that!

@jrose thanks for the speedy response. Googling didn't find that - too many hits with @dynamicCallable and ExpressibleByStringInterpolation in swift 5!

It looks like my patch from a while ago unintentionally fixed this? [CSApply] Restructure the implicit AST when applying @dynamicCallable by Azoy · Pull Request #23845 · apple/swift · GitHub

1 Like

Well, great, let's retest it and merge it! :-)

I misused ExpressibleByArray in order to coerce [Any] to my implementation. I also faked a #function defaulted parameter to report the call site function. See this thread for a discussion of #file #function literal defaults.

Here's the crufty code:

public final class LogElement: CustomStringConvertible, ExpressibleByStringInterpolation, ExpressibleByArrayLiteral {
    let elements: [Any]
    public let description: String

    public init(arrayLiteral elements: Any...) {
        self.elements = elements
        switch elements.count {
        case 0: self.description = "()"
        case 1:
            if let element = elements[0] as? String {
                self.description = "\"\(element)\""
            } else {
                self.description = "\(elements[0])"
            }
        case _: self.description =
            "Warning: please only use a single element in a LogElement array literal!\n\(elements)"
        }
    }
    public init(stringLiteral value: String) {
        self.description = "\"\(value)\""
        self.elements = [value]
    }
    public init(stringInterpolation: StringInterpolation) {
        description = stringInterpolation.output
        self.elements = [stringInterpolation.output]
    }
    final public class StringInterpolation: StringInterpolationProtocol {
        var output = ""
        public init(literalCapacity: Int, interpolationCount: Int) {
            output.reserveCapacity(literalCapacity * 2)
        }
        public func appendLiteral(_ literal: String) {
            output.append(literal)
        }
        public func appendInterpolation(_ value: Any, label: String = "") {
            if label.isEmpty == false {
                output.append("\(label): ")
            }
            if let string = value as? String {
                output.append("\"\(string)\"")
            } else {
                output.append("\(value)")
            }
        }
    }
}
@dynamicCallable
public struct Logger {
    public init() { }
    private (set) public var doPrintFunction: Bool = false
    public mutating func enableFunctionHeader() {
        doPrintFunction = true
    }
    public mutating func disableFunctionHeader() {
        doPrintFunction = false
    }
    public func dynamicallyCall(withArguments args: [LogElement]) {
        printFunctionName()
        let output = args.map { "\($0)" }.joined(separator: ", ")
        print("ℹ️", output)
    }
    public func dynamicallyCall(withKeywordArguments args: KeyValuePairs<String, LogElement>) {
        printFunctionName()
        var args = args.toArray()
        if let fileIndex = args.firstIndex(where: { $0.key == "file" }),
        let filePath = args[fileIndex].value.elements.first as? String {
            let url = URL(fileURLWithPath: filePath)
            args[fileIndex] = ("file", "\(url.lastPathComponent)")
        }
        let output = args.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
        print("ℹ️", output)
    }
    private let guessCallSiteStackDistance = 3
    @inline(__always) // Doesn't seem to work in Debug.
    private func printFunctionName() {
        guard doPrintFunction else { return }
        if let callSiteSymbol = Thread.callStackSymbols.prefix(guessCallSiteStackDistance).last,
            let mangledSignature = callSiteSymbol.split(separator: " ").prefix(4).last.map({ String($0) }) {
            if let functionName = try? parseMangledSwiftSymbol(mangledSignature, isType: false).print(using: .simplified),
            let preceding = functionName.lastIndex(of: "."),
            let successor = functionName.firstIndex(of: "(") {
                let start = functionName.index(after: preceding)
                let end = functionName.index(before: successor)
                print("🔹", functionName[start...end], "(): ", separator: "", terminator: " ")
            }
        }
    }
}

class DynamicLoggerTests: XCTestCase {

    func testDynamicLogger() {
        var log = Logger()
        log("simply literal")
        log("interp\(0)lat\(1)\(0)n")
        oneLevelDeeper(passing: log)
        log(file: #file, isEmpty: [], isArray: [[1,2,3]])
        log.enableFunctionHeader()
        log(isNested: "\("nesting") \("works")", butOnlyOneLevelDeep: "🤯") // "\("This \("fails")")")
    }
    func oneLevelDeeper(passing log: Logger) {
        var log = log
        log("\(0...1, label: "closed")", "\(2..<3, label: "halfOpen")")
        log(partialUpTo: [...4], partialFrom: [5...])
        log.enableFunctionHeader()
        log(shouldPrintCallingFunction: "Shame we can't have #file:#line propogated")
    }
}

// prints to console:
//    ℹ️ "simply literal"
//    ℹ️ interp0lat10n
//    ℹ️ closed: 0...1, halfOpen: 2..<3
//    ℹ️ partialUpTo: PartialRangeThrough<Int>(upperBound: 4), partialFrom: PartialRangeFrom<Int>(lowerBound: 5)
//    🔹oneLevelDeeper():  ℹ️ shouldPrintCallingFunction: "Shame we can't have #file:#line propogated"
//    ℹ️ file: "ChangesetRegressionData.swift", isEmpty: (), isArray: [1, 2, 3]
//    🔹testDynamicLogger():  ℹ️ isNested: "nesting" "works", butOnlyOneLevelDeep: "🤯"