Search code examples
swiftswift5

How to combine `@dynamicMemberLookup` and `ExpressibleByStringInterpolation` to achieve method chaining?


I have the following enum:

@dynamicMemberLookup
enum JSCall: ExpressibleByStringInterpolation {
    case getElement(String)
    case getInnerHTML(id: String)
    case removeAttribute(id: String, attributeName: String)
    case customScript(String)
    case document
    indirect case chainedCall(JSCall, String)

    var value: String {
        switch self {
        case .document:
            return "document"
        case .getElement(let id):
            return JSCall.chainedCall(.document, "getElementById('\(id)')").value
        case .getInnerHTML(let id):
            return JSCall.getElement(id).innerHTML.value // Using dynamic member lookup here
        case .removeAttribute(let id, let attribute):
            return JSCall.chainedCall(.getElement(id), "removeAttribute('\(attribute)')").value
        case .chainedCall(let prefixJs, let suffix):
            return "\(prefixJs.scriptString)\(STRING_DOT)\(suffix)"
        case .customScript(let script):
            return script
        }
    }

    init(stringLiteral value: String) {
        self = .customScript(value)
    }
    
    subscript(dynamicMember suffix: String) -> JSCall {
        return .chainedCall(.customScript(self.value), suffix)
    }
}

Notice that in computed property value, for getInnerHTML case, I could use a natural syntax for chaining .innerHTML via dot syntax, thanks to @dynamicMemberLookup. However, if I try chaining interpolated string like:

let call = JSCall.document.getElementById('\(id)').value

I am getting compiler error like Cannot call value of non-function type 'JSCall'.

I thought conforming to ExpressibleByStringInterpolation would solve the problem, but it didn't.

Is there any way to achieve natural syntax of chaining method calls in this enum ?


Solution

  • dynamicMemberLookup doesn't handle function calls. To be able to call a looked-up member, you can use @dynamicCallable. You can do something similar with callAsFunction too.

    I think a simpler way to model this is to just have more "fundamental" cases, modelling the JS syntax tree.

    @dynamicMemberLookup
    @dynamicCallable
    enum JSCall {
        case stringLiteral(String)
        case integerLiteral(Int)
        // add more literal cases if you like
        case call(String, args: [JSCall])
        case property(String)
        indirect case chain(JSCall, JSCall)
    
        var value: String {
            switch self {
            case let .call(functionName, args):
                let argList = args.map(\.value)
                    .joined(separator: ", ")
                return "\(functionName)(\(argList))"
            case .property(let name):
                return name
            case .chain(let first, let second):
                return "\(first.value).\(second.value)"
            case .stringLiteral(let s):
                return "'\(s)'" // note that this does not escape quotes in the JS string!
            case .integerLiteral(let i):
                return "\(i)"
            }
        }
    
        // ...
    }
    

    When you look up a member dynamically, create a chain with the second thing being a property. This corresponds to cases like foo.bar

    subscript(dynamicMember propertyName: String) -> JSCall {
        return .chain(self, .property(propertyName))
    }
    

    Then, in cases like foo.bar("bar"), you would implicitly invoke (foo.bar).dynamicallyCall(withArguments: [.stringLiteral("baz")]). This should return a .chain(foo, .call("bar", [.stringLiteral("baz")])).

    func dynamicallyCall(withArguments args: JSCall...) -> JSCall {
        switch self {
        case .property(let name):
            return .call(name, args: args)
        case .chain(let first, let second):
            // note the recursive call here:
            return .chain(first, second.dynamicallyCall(withArguments: args))
        default:
            fatalError("Cannot call this!")
        }
    }
    

    You can conform to ExpressibleByXXXLiteral for the literal cases:

    extension JSCall: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral {
        init(stringLiteral value: String) {
            self = .stringLiteral(value)
        }
        
        init(integerLiteral value: Int) {
            self = .integerLiteral(value)
        }
    }
    

    That's all the infrastructure. Now you can add some convenient static factories and properties:

    static let document = JSCall.property("document")
    
    static func getElement(_ id: String) -> JSCall {
        document.getElementById(.stringLiteral(id))
    }
    
    static func getInnerHTML(_ id: String) -> JSCall {
        getElement(id).innerHTML
    }
    
    static func removeAttribute(id: String, attributeName: String) -> JSCall {
        getElement(id).removeAttribute(.stringLiteral(attributeName))
    }
    

    Usage:

    let one = JSCall.document.getElementById("foo").value
    let two = JSCall.removeAttribute(id: "bar", attributeName: "attr").somethingElse.value
    print(one) // document.getElementById('foo')
    print(two) // document.getElementById('bar').removeAttribute('attr').somethingElse