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: