I'm building a compiler from a plaintext language to HTML and want to emit an AST as a middle step. When I encounter certain symbols in my code, I need to choose a different class:
"url" --> should instantiate a NamedUrl
"image" --> should instantiate an InlineImage
"math" --> should instantiate an InlineMath
These objects are nodes in my AST and will later be rendered to HTML, so let's simply call them "nodes". I want to have most/all construction logic encapsulated inside each node if possible, because I will add more nodes later and it would be nice to only have to change as few places as possible.
Therefore, I thought I could use companion objects to help with construction. Each companion object defines the string it expects ("url", "image", ...) and also holds information on what arguments it expects for construction. Problem is, this differs by node. A NamedUrl requires a String, the url, while an InlineImage might take a url and also a width.
Every node also takes an implicit TextSegment that tells some visitors down the line where it originally came from. I think that's probably irrelevant to the question, but I included it in case it changes anything.
case class TextSegment(fromLine: Int, untilLine: Int, fromCol: Int, untilCol: Int)
abstract class NamedFormatterObj {
val name: String
}
object NamedUrl extends NamedFormatterObj {
val name = "url"
}
object InlineImage extends NamedFormatterObj {
val name = "image"
}
abstract class Node(using TextSegment)
case class NamedUrl(url: String)(using t: TextSegment) extends Node
case class InlineImage(url: String, width: Int)(using t: TextSegment) extends Node
So we have two companion objects and two case classes for the different node types, and a Node for defining arguments that every node needs. During parsing, the constructor arguments are actually only found after I've already processed the node type, so it would be nice if my parser returned a function that can be applied to the right arguments.
Now it would be very nice if my parser logic could look roughly as follows:
def parseToken(currentToken: String) = {
val allNodes = Seq(NamedUrl, InlineImage, [...])
allNodes.collect {
case node if currentToken == node.name => (args: Any) => new node(args.asInstanceOf[???])
}
}
There's two problems here: The new node syntax obviously doesn't work, and I don't know what what type of argument I should pass into this node constructor. I could solve the instanceOf and new-problem with a generic apply method in the object parent class as so:
abstract class NamedFormatterObj {
val name: String
type Args
def apply(a: Args): Node
}
object InlineImage extends NamedFormatterObj {
val name = "image"
override type Args = (String, Int)
}
case class InlineImage(a: (String, Int)) extends Node
Unfortunately the compiler complains that object InlineImage does not implement the apply method inside NamedFormatterObj. That surprised me a bit, because the case class I added should provide an apply method for its companion object, but I'm probably replacing or shadowing the compiler-generated companion. I can implement this method myself, but then there's no type guarantee that the apply method in the companion object actually matches the apply method in the case class.
Is there an elegant way to solve this issue? Preferably without reflection and with as little per-node overhead as possible, but maybe I'm asking too much here, so I'd be happy with any solution that doesn't require me to copy-paste code all over the place :)