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 ?
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