Hello USD - Part 11: Gotta make it easier to write file builders
Right now all I’m asking of my little DSL is to place colored spheres at locations and my two FileBuilder types are both 100+ lines of code long, and contain gems like the following from the x3d builder:
if !shape.transformations.isEmpty {
for transform in shape.transformations {
transformStart(transform:transform)
}
}
//...
if !shape.transformations.isEmpty {
for _ in 0..<shape.transformations.count {
transformClose()
}
}
Where transformClose()
is just returns </transform>
"
Then more chunks like the below in the the .usda builder:
"\t}"
"}"
I have to remember to close out sections and add the indents. Manually. For everything.
What a troubleshooting nightmare.
I’d much prefer to be able to write things like like:
tag(name:"translate", attributeDict:transDict) {
//...
}
//or
Translate(x:2, y:3, z:4) {
//...
}
Code like that would make my builders easier to update and potentially provide a nice tool for other people to potentially write their own.
Code like this cannot be easily backed by any of the builtin collections. Maybe I could have done something fancy with a Deque
from the Collections Package github?
What I reached for instead is a tree, which I plan to get cozy with for the 3D scene information anyway.
I first implemented a tree in Swift for this past December’s Advent of Code Day 13 that looked like:
enum Message: ExpressibleByIntegerLiteral,
ExpressibleByArrayLiteral, Comparable {
case value(Int)
indirect case list([Message])
init(integerLiteral: Int) {
self = .value(integerLiteral)
}
//for compliance with expressible by arrayliteral
init(arrayLiteral: Self...) {
self = .list(arrayLiteral)
}
init(from decoder: Decoder) throws {
do {
let c = try decoder.singleValueContainer()
self = .value(try c.decode(Int.self))
} catch {
self = .list(try [Message](from: decoder))
}
}
static func < (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.value(let l), .value(let r)): return l < r
case (.value(_), .list(_)): return .list([lhs]) < rhs
case (.list(_), .value(_)): return lhs < .list([rhs])
case (.list(let l), .list(let r)):
for (le, re) in zip(l, r) {
if le < re { return true }
if le > re { return false }
}
return l.count < r.count
}
}
}
It uses an enum, explicitly an indirect case. More on Recursive Enums
For this task I don’t need a list, but a container.
Note that stringify
recursively walks the tree.
enum StringNode {
case content(String)
indirect case container((prefix:String, content:StringNode, suffix:String))
init(_ string:String) {
self = .content(string)
}
init(prefix:String, content:String, suffix:String) {
self = .container((prefix: prefix, content: .content(content), suffix: suffix))
}
init(prefix:String, content:StringNode, suffix:String) {
self = .container((prefix: prefix, content: content, suffix: suffix))
}
static func stringify(node:StringNode) -> String {
switch node {
case .content(let s):
return s
case .container(let tuple):
let prefix = tuple.prefix
let suffix = tuple.suffix
let content = stringify(node: tuple.content)
return "\(prefix)\(content)\(suffix)"
}
}
static func stringify(nodeSet:[StringNode]) -> String {
nodeSet.map({ $0.makeString() }).joined()
}
func makeString() -> String {
Self.stringify(node: self)
}
}
let message:StringNode = .content("I have a message")
let bracketedMessage = StringNode(prefix:"<p>", content: message, suffix:"</p>")
let oneMoreDown = StringNode(prefix:"<body>", content: bracketedMessage, suffix:"</body>")
print(bracketedMessage.makeString())
print(oneMoreDown.makeString())
The code above can be pasted into a playground and will result with the following printed to the console:
<p>I have a message</p>
<body><p>I have a message</p></body>
Great start, but those initializers will be tedious to write.
Adding the following to the playground…
@resultBuilder
enum StringNodeBuilder {
static func buildBlock(_ components: [StringNode]...) -> [StringNode] {
buildArray(components)
}
public static func buildExpression(_ expression: StringNode) -> [StringNode] {
[expression]
}
public static func buildArray(_ components: [[StringNode]]) -> [StringNode] {
return components.flatMap { $0 }
}
public static func buildExpression(_ expression: String) -> [StringNode] {
[.content(expression)]
}
static func buildExpression(_ components: String...) -> [StringNode] {
components.compactMap { .content($0) }
}
static func buildExpression(_ components: [String]...) -> [StringNode] {
components.flatMap { $0 }.compactMap { .content($0) }
}
}
struct Document {
let content:[StringNode]
init(@StringNodeBuilder content: () -> [StringNode]) {
self.content = content()
}
func render() -> String {
StringNode.stringify(nodeSet: content)
}
}
let document = Document {
"Header"
oneMoreDown
"Footer"
}
print(document.render())
… writes Header<body><p>I have a message</p></body>Footer
to the output.
This will be great for minified output situations, but will be hard for me to scan for errors. While I would love to add a more extensible output formatter setup for now I just added the following to StringNode. Note that indentStringify
, like stringify
uses recursion.
private static func indentStringify(node: StringNode, level:Int = 0) -> String {
let indent = Indent(count: level, prefix: "")
let ind = indent.value
switch node {
case .content(let s):
return "\(ind)\(s)"
case .container(let tuple):
let prefix = tuple.prefix
let suffix = tuple.suffix
let content = indentStringify(node: tuple.content, level: level + 1)
return "\(ind)\(prefix)\("\n")\(content)\("\n")\(ind)\(suffix)"
}
}
static func indentStringify(nodeSet:[StringNode],
startLevel:Int = 0,
separator:String="") -> String {
nodeSet.map({
$0.indentedString(startLevel: startLevel)
}).joined(separator: separator)
}
func indentedString(startLevel:Int = 0) -> String {
Self.indentStringify(node: self, level: startLevel)
}
The following helper struct to the top of the file:
public struct Indent {
let count:Int
let indentString:String
let prefix:String
public init(count:Int, indentString:String = "\t", prefix:String="") {
self.count = count
self.indentString = indentString
self.prefix = prefix
}
var value:String {
var tmp = ""
for _ in 0..<count {
tmp.append(indentString)
}
return "\(prefix)\(tmp)"
}
}
And update the render
function in Document
:
func render() -> String {
return StringNode.indentStringify(nodeSet: content, separator:"\n")
}
To get:
Header
<body>
<p>
I have a message
</p>
</body>
Footer
To make a custom encloser we can add a protocol Nodeable
struct FunTag:Nodeable {
let prefix:String = "<FUNTAG>"
let suffix:String = "</FUNTAG>"
let content:StringNode
var asStringNode: StringNode {
.container((prefix: prefix, content: content, suffix: suffix))
}
}
extension FunTag {
init(_ content:()->Nodeable) {
self.content = content().asStringNode
}
}
With the following additions to the result builder:
public static func buildExpression(_ expression: Nodeable) -> [StringNode] {
[expression.asStringNode]
}
static func buildExpression(_ components: Nodeable...) -> [StringNode] {
components.compactMap { $0.asStringNode }
}
static func buildExpression(_ components: [Nodeable]...) -> [StringNode] {
components.flatMap { $0 }.compactMap { $0.asStringNode }
}
If I want FunTag to be able to take Strings, I have to comment out the String based expression blocks in the builder and conform String to Nodeable. Otherwise the builder gets confused if it should use the String builder or the Nodeable builder.
extension String:Nodeable {
var asStringNode: StringNode {
.content(self)
}
}
Update the Document:
let document = Document {
"Header"
oneMoreDown
FunTag(content: bracketedMessage)
FunTag { "Test" }
"Footer"
}
Header
<body>
<p>
I have a message
</p>
</body>
<FUNTAG>
<p>
I have a message
</p>
</FUNTAG>
<FUNTAG>
Test
</FUNTAG>
Footer
The one thing I can’t do is add more than one item to the FunTag as siblings. If I tried to run the following
let document = Document {
"Header"
oneMoreDown
FunTag(content: bracketedMessage)
FunTag {
"Test"
bracketedMessage
}
"Footer"
}
It gets the error message Missing return in closure expected to return 'any Nodeable'
Changing the initializer of FunTag
to match what Document uses…
init(@StringNodeBuilder content: () -> [StringNode]) {
self.content = content()
}
… nets the response Cannot assign value of type '[StringNode]' to type 'StringNode'
.
There needs to be a StringNode type that understands siblings that can take a [StringNode]
and make that un-nested list into a StringNode
itself.
Going back to add that list feature after all.
enum StringNode {
case content(String)
indirect case container((prefix:String, content:StringNode, suffix:String))
indirect case list([StringNode])
///...
private static func stringify(node:StringNode) -> String {
switch node {
///...
case .list(let nodeArray):
return Self.stringify(nodeSet: nodeArray)
}
}
private static func indentStringify(node: StringNode, level:Int = 0) -> String {
///...
switch node {
///...
case .list(let nodeArray):
return indentStringify(nodeSet:nodeArray, startLevel:level, separator:"\n")
}
}
Add a RenderStyle
to let me troubleshoot both…
struct Document {
enum RenderStyle {
case minimal
case indented
}
//...
func render(style: RenderStyle = .indented) -> String {
switch style {
case .indented:
return StringNode.indentStringify(nodeSet: content)
case .minimal:
return StringNode.stringify(nodeSet: content)
}
}
}
print(document.render())
print(document.render(style: .minimal))
Success!
Header
<body>
<p>
I have a message
</p>
</body>
<FUNTAG>
<p>
I have a message
</p>
</FUNTAG>
<FUNTAG>
Test
<p>
I have a message
</p>
</FUNTAG>
Footer
One last thing. Now we can make lots of helpers. As long as they conform to Nodeable they will work in the builder.
struct AttributedTag:Nodeable {
let name:String
let id:Int
var prefix: String { "<\(name) attribute=\"\(id)\">" }
var suffix: String { "</\(name)>" }
var content: StringNode
init(name:String, attribute:Int, @StringNodeBuilder content: () -> [StringNode]) {
self.name = name
self.id = attribute
self.content = .list(content())
}
var asStringNode: StringNode {
.container((prefix: prefix, content: content, suffix: suffix))
}
}
struct Repeater:Nodeable {
let count:Int
var content: StringNode
init(count:Int, @StringNodeBuilder content: () -> [StringNode]) {
self.count = count
self.content = .list(content())
}
var asStringNode: StringNode {
var tmp:[StringNode] = []
for _ in 0..<count {
tmp.append(content)
}
return .list(tmp)
}
}
let document = Document {
"Header"
oneMoreDown
FunTag(content: bracketedMessage)
AttributedTag(name: "MEANING", attribute: 42) {
FunTag {
"Test"
bracketedMessage
}
}
Repeater(count: 3) {
"Hi!"
}
"Footer"
}
Again Success!
Header
<body>
<p>
I have a message
</p>
</body>
<FUNTAG>
<p>
I have a message
</p>
</FUNTAG>
<MEANING attribute="42">
<FUNTAG>
Test
<p>
I have a message
</p>
</FUNTAG>
</MEANING>
Hi!
Hi!
Hi!
Footer
This all puts me in great shape to refactor my two file builders next time.
Complete code: