-
Notifications
You must be signed in to change notification settings - Fork 72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Rework AST to be more type safe and allow mutation #95
base: main
Are you sure you want to change the base?
Conversation
Also replace existential types with enums
c765e69
to
876c29b
Compare
They would be necessary in a duck-typed language such as TypeScript but we can check at runtime with casts in Swift
Hey, @bubba! Thanks for all this. The AST visitor was begging for a rework for a long time! At a glance, your rationale makes a lot of sense. I didn't have time to do a proper review, but I'm looking forward to taking some time to thoroughly digest all of the changes. You mentioned no KeyPaths for enums. Are you aware of pointfree.co's CasePath library? |
@paulofaria Thanks, apologies for this being such a large PR. I've indeed heard of some of these enum case path libraries, and I'm not opposed to using them at all. |
Don't worry. I'll do a proper review when I have a chance, then I'll get back to you about this. 🙂 |
It's possible here that typeA is also an interface
To do so, need to make it conform to AnyObject, which in turn forces us to use Swift 5.6 any syntax for existential types throughout
09d32c6
to
edcfcba
Compare
Hey, @bubba. Looks like this PR has conflicts with master. Can you please update from master? The PR is still in draft mode. Please, let us know if you think you're done or if you think there's still work pending. |
Hi @paulofaria , this branch probably needs readjusted as it has become a catch-all for any tweaks needed for use in https://github.com/minm-inc/swiftui-graphql There’s a few extra validations copied over from the spec and some bug fixes. |
This is the result of a three week yak shave that began with trying to use the visitor utility to mutate the AST.
A lot has changed to make things work with my use case, so this PR may not be suitable for this library, but the gist is:
struct
s notclass
es)get
/set
) for traversing the ASTValue
,Definition
) have been removed and replaced withenum
sVisitor
s are now implemented as protocolsenter
andleave
methods are now type safe: You can only return new nodes that are of the same typeTextOutputStreamable
protocol so they can be printed out againUsing structs
I first ran into the fact that the editing part of the
visit
function wasn't yet implemented: returning a new node in the visit function didn't actually change anything.So I began to translate over the
graphql-js
reference implementation when I realised a fundamentally limiting factor of the current implementation:The
visit
function should return a new copy of the AST with the modifications applied, so in the javascript implementation they do a deep copy of each node whenever it's mutated. However in Swift,class
es have no practical way of doing such a deep copy.In Swift, types that need to be copied about with value semantics are typically
struct
s: Copying them then becomes a matter of using doing avar astCopy = ast
.And although this is a significant semantic change, changing this didn't seem to affect much of the existing code nor the test suite.
The next step was to finish implementing the
set
functions on theNode
protocol, which uncovered two more problems:Using
KeyPath
s to traverse the ASTThe
set
functions on the nodes were not implemented yet so I began to fill them out, only to quickly realise that it wasn't possible to assign arbitraryNode
values to the concrete instance variables in a type-safe manner. For reference, the types of theget
andset
functions for traversing the AST were:The value passed to
set
is just any arbitraryNode
, and so would need to be downcasted before being set. On top of this, it would require a lot of boilerplate, matching up eachstring
to each variable.This is less than ideal, since it's using arbitrary strings and duplicates most of the boilerplate that
get
is doing anyway. This seemed like a good use for key paths in Swift, which allows for strongly typed paths to the nested fields in each struct.The immediately obvious solution would be to have each
Node
return an array ofWritableKeyPath
s to its various children. However we would end up with a heterogenous array, which if we were to type erase would prevent us from actually being able to write to the nodes. And even if we were to try this, there is no type-erased version ofWritableKeyPath
: you simply need to know the type of the field you are writing to at compile time, and no you can't just make it aWritableKeyPath<Self, Node>
– UsingSelf
within theNode
protocol definition prevents it from being used as an existential type i.e.let x: Node
(more on this later), and you can't assign a concrete typeVariable
to aNode
either.So instead the resulting function on each
Node
looks like this now:The
Descender
struct contains three generic functions:And this works great for allowing us to traverse the children in the AST, handling nullable children as well as any lists that may occur.
However, some of the
Node
s' children were existentially typed: For example,SelectionSet
has an array ofSelection
s:And because of this, we get the "Protocol 'Selection' as a type cannot conform to 'Node'" error when trying to use the key path.
Replacing existential types with enums
Existential types in Swift – that is, naked uses of protocols in type signatures like
var definitions: [Definition]
– are a bit of a foot-gun, and carry with them some restrictions as mentioned above. There's an evolution proposal to make this syntactically explicit, and a lot of the time generics are a better fit.But until we get generalised existential types, I had to look around for an alternative.
I decided to replace the existential types with enums, essentially turning the AST into an algebraic data type:
Using enums to represent the sum types has sum extra benefits like not needing to downcast to extract the inner types and being able to exhaustively pattern match.
AnyKeyPath
Because we're using key paths to access the fields within the nodes, we could represent the
path
that gets passed to theVisitor
by chaining them together withappending(path: KeyPath)
. This would have been a cool use of key paths, but unfortunately, Swift does not currently have support for key paths on enums.So instead we have to use just an array of
AnyKeyPath
, and thedescend
method is implemented a bit differently on the enum nodes so that they don't actually leave a path.Type safe enter and leave functions
The key paths passed to the
Descender
still need to be strongly typed i.e.WritableKeyPath
with a known type, since Swift needs to be able to type check that we are assigning a value of the type to the path. But if we look at the type signature for theenter
andleave
functions, we find ourselves with two more existential types:The existential type passed as an argument, and the existential type returned from the function. As mentioned earlier, existential types come with a lot of restrictions, one of them being that we can't use a value of
Node
to write to a key path of typeWritableKeyPath<Field, Name>
: We simply need the guarantee that theNode
value inside theVisitResult
actually is of the correct type.And with the current API, there's nothing stopping the user from returning a
Field
to replace aName
node in the tree.Since we need to work with concrete types again, lets start by making
VisitResult
generic so we can make sure that users are replacing the node with the same type of node.Then, we just need to make the closure generic so it takes in a node of type
T
and returns a result of typeVisitResult<T>
– but as it turns out, closures cannot be generic in Swift.Functions can be generic though, and you can define generic function requirements on protocols. So why not try adopting a more protocol-orientated design and make
Visitor
a protocol that users of the library can adopt?This looks like a nice design, now we can just do a quick cast at runtime to differentiate the concrete type:
But we run into a type error when trying to return a
VisitResult
of a concrete type. We know for a fact thatName
is the same type asT
because we matched on it, so unfortunately we have to force downcast it. But we can hide this away from users by doing the down casting within the library, and delegating to functions with concrete types for each node.This is the same approach that SwiftSyntax takes to their visitor:
https://github.com/apple/swift-syntax/blob/7117363d54ed5dfdbcb5eaa4c81697923c9768db/Sources/SwiftSyntax/SyntaxVisitor.swift.gyb#L40-L53
But they are using gyb, and it leaves us with a lot of boilerplate. If anyone has any ideas for a better design/way to improve this, I'd love to hear them.
In any case, the finished design gives us a nice API surface for editing the AST:
In a way thats type-safe, and plays well with value semantics.