Not all "normal languages" (or even "dynamic scripting languages") are created equal. While I wouldn't want to aim for something equivalent to the whole d2 syntax as an internal DSL in Ruby, if you wanted to just create edges with optional labels with a syntax where "bare" edges are:
x >> y
and edges with labels are:
x >> y << "hello world"
you can do it like this:
class Diagram
def initialize
@nodes = Hash.new { |h, k| h[k] = Node.new(self, k) }
@edges = {}
end
def node(name)
@nodes[name]
end
def add_edge(edge)
@edges[edge.from_node] ||= {}
@edges[edge.from_node][edge.to_node] = edge
end
def all_edges
@edges.values.flat_map(&:values)
end
def interpret &block
interpreter = D2.new(self)
interpreter.instance_eval(&block)
self
end
def to_s
all_edges.map(&:to_s)
end
def inspect
to_s
end
end
class D2
def initialize(diagram = nil)
@diagram = diagram || Diagram.new
end
def method_missing(name, *args)
@diagram.node(name)
end
end
class Node
def initialize(diagram, name)
@diagram = diagram
@name = name
end
def >>(other_node)
Edge.new(self, other_node).tap do |edge|
@diagram.add_edge(edge)
end
end
def to_s
@name
end
def inspect
"Node(#{to_s})"
end
end
class Edge
def initialize(from_node, to_node, label = nil)
@from_node = from_node
@to_node = to_node
@label = label
end
def <<(label)
@label = label
end
def from_node
@from_node
end
def to_node
@to_node
end
def to_s
"#{@from_node.to_s} -> #{@to_node.to_s}" + (@label ? ":#@label" : "")
end
def inspect
"Edge(#{to_s})"
end
end
And use it like this:
irb(main):090:0> d = Diagram.new
=> []
irb(main):091:1* d.interpret {
irb(main):092:1* x >> y << "hello, world!"
irb(main):093:1* y >> z << "goodbye, cruel world!"
irb(main):094:0> }
=> ["x -> y:hello, world!", "y -> z:goodbye, cruel world!"]
OF course, this only supports a trivial subset of the functionality, and only "renders" it to a text form more like the original d2 syntax. But it does create an object model from the DSL in the Diagram class for which you could build a renderer.