diff --git a/.gitignore b/.gitignore
index 7af7b3572..e9e020ff5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@
*.got.svg
e2e_report.html
bin
+out
diff --git a/README.md b/README.md
index 2818ec5ce..5541306b0 100644
--- a/README.md
+++ b/README.md
@@ -222,7 +222,7 @@ let us know and we'll be happy to include it here!
- **Python D2 diagram builder**: [https://github.com/MrBlenny/py-d2](https://github.com/MrBlenny/py-d2)
- **Clojure D2 transpiler**: [https://github.com/judepayne/dictim](https://github.com/judepayne/dictim)
- **JavaScript D2 diagram builder**: [https://github.com/Kreshnik/d2lang-js](https://github.com/Kreshnik/d2lang-js)
-- **Maven plugin**: [https://github.com/andrinmeier/unofficial-d2lang-confluence-plugin](https://github.com/andrinmeier/unofficial-d2lang-confluence-plugin)
+- **Maven plugin**: [https://github.com/andrinmeier/unofficial-d2lang-maven-plugin](https://github.com/andrinmeier/unofficial-d2lang-maven-plugin)
- **Confluence plugin**: [https://github.com/andrinmeier/unofficial-d2lang-confluence-plugin](https://github.com/andrinmeier/unofficial-d2lang-confluence-plugin)
- **CIL (C#, Visual Basic, F#, C++ CLR) to D2**: [https://github.com/HugoVG/AppDiagram](https://github.com/HugoVG/AppDiagram)
diff --git a/ci/cov.sh b/ci/cov.sh
new file mode 100755
index 000000000..63d5bb812
--- /dev/null
+++ b/ci/cov.sh
@@ -0,0 +1,19 @@
+#!/bin/sh
+set -eu
+cd -- "$(dirname "$0")/.."
+. ./ci/sub/lib.sh
+
+main() {
+ if [ "$*" = "" ]; then
+ set ./...
+ fi
+
+ mkdir -p out
+ capcode ./ci/test.sh -covermode=atomic -coverprofile=out/cov.prof "$@"
+ go tool cover -html=out/cov.prof -o=out/cov.html
+ go tool cover -func=out/cov.prof | grep '^total:' \
+ | sed 's#^total:.*(statements)[[:space:]]*\([0-9.%]*\)#TOTAL:\t\1#'
+ return "$code"
+}
+
+main "$@"
diff --git a/ci/e2ereport.sh b/ci/e2ereport.sh
index ebf45b912..02500ac58 100755
--- a/ci/e2ereport.sh
+++ b/ci/e2ereport.sh
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
-export REPORT_OUTPUT="out/e2e_report.html"
+export REPORT_OUTPUT="./e2etests/out/e2e_report.html"
rm -f $REPORT_OUTPUT
export E2E_REPORT=1
diff --git a/ci/release/Dockerfile b/ci/release/Dockerfile
index 623a0f490..3d7f843bc 100644
--- a/ci/release/Dockerfile
+++ b/ci/release/Dockerfile
@@ -7,7 +7,9 @@ RUN apt-get update && apt-get install -y ca-certificates curl dumb-init sudo
RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash -s - && \
apt-get install -y nodejs
-RUN npx playwright install-deps
+# https://github.com/microsoft/playwright/issues/18319
+# Hopefully soon.
+RUN if [ "$TARGETARCH" = amd64 ]; then npx playwright install-deps; fi
RUN adduser --gecos '' --disabled-password debian \
&& echo "debian ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/nopasswd
@@ -26,7 +28,7 @@ RUN mkdir -p /usr/local/lib/d2 \
&& rm -Rf /tmp/d2-*-linux-"$TARGETARCH".tar.gz
USER debian:debian
-RUN d2 init-playwright
+RUN if [ "$TARGETARCH" = amd64 ]; then d2 init-playwright; fi
WORKDIR /home/debian/src
EXPOSE 8080
diff --git a/ci/release/aws/ssh.sh b/ci/release/aws/ssh.sh
index 387bdf38c..cab458d22 100755
--- a/ci/release/aws/ssh.sh
+++ b/ci/release/aws/ssh.sh
@@ -33,11 +33,11 @@ main() {
done
shift "$FLAGSHIFT"
- REMOTE_HOST=$CI_HOST_D2_LINUX_AMD64 && runjob linux-amd64 ssh "$REMOTE_HOST" "$@"
- REMOTE_HOST=$CI_HOST_D2_LINUX_ARM64 && runjob linux-arm64 ssh "$REMOTE_HOST" "$@"
- REMOTE_HOST=$CI_HOST_D2_MACOS_AMD64 && runjob macos-amd64 ssh "$REMOTE_HOST" "$@"
- REMOTE_HOST=$CI_HOST_D2_MACOS_ARM64 && runjob macos-arm64 ssh "$REMOTE_HOST" "$@"
- REMOTE_HOST=$CI_HOST_D2_WINDOWS_AMD64 && runjob macos-arm64 ssh "$REMOTE_HOST" "$@"
+ REMOTE_HOST=$CI_D2_LINUX_AMD64 && runjob linux-amd64 ssh "$REMOTE_HOST" "$@"
+ REMOTE_HOST=$CI_D2_LINUX_ARM64 && runjob linux-arm64 ssh "$REMOTE_HOST" "$@"
+ REMOTE_HOST=$CI_D2_MACOS_AMD64 && runjob macos-amd64 ssh "$REMOTE_HOST" "$@"
+ REMOTE_HOST=$CI_D2_MACOS_ARM64 && runjob macos-arm64 ssh "$REMOTE_HOST" "$@"
+ REMOTE_HOST=$CI_D2_WINDOWS_AMD64 && runjob windows-amd64 ssh "$REMOTE_HOST" "$@"
}
main "$@"
diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index 72587383b..bfadf2bd1 100644
--- a/ci/release/changelogs/next.md
+++ b/ci/release/changelogs/next.md
@@ -2,14 +2,33 @@
- `double-border` keyword implemented. [#565](https://github.com/terrastruct/d2/pull/565)
- The [Dockerfile](./docs/INSTALL.md#docker) now supports rendering PNGs [#594](https://github.com/terrastruct/d2/issues/594)
+
- There was a minor breaking change as part of this where the default working directory of the Dockerfile is now `/home/debian/src` instead of `/root/src` to allow UID remapping with [`fixuid`](https://github.com/boxboat/fixuid).
- `d2 fmt` accepts multiple files to be formatted [#718](https://github.com/terrastruct/d2/issues/718)
+- You can now use the reserved keywords `layers`/`scenarios`/`steps` to define diagrams
+ with multiple levels of abstractions. [#714](https://github.com/terrastruct/d2/pull/714)
+ Docs to come soon
+ - [#416](https://github.com/terrastruct/d2/issues/416) was also fixed so you can no
+ longer use keywords intended for use under `style` outside and vice versa. e.g.
+ `obj.style.shape` and `obj.double-border` are now illegal. The correct uses are
+ `obj.shape` and `obj.style.double-border`.
+ - Many other minor compiler bugs were fixed.
+
#### Improvements 🧹
- Code snippets use bold and italic font styles as determined by highlighter [#710](https://github.com/terrastruct/d2/issues/710), [#741](https://github.com/terrastruct/d2/issues/741)
+- Reduces default padding of shapes. [#702](https://github.com/terrastruct/d2/pull/702)
+- Ensures labels fit inside shapes with shape-specific inner bounding boxes. [#702](https://github.com/terrastruct/d2/pull/702)
+- Improves package shape dimensions with short height. [#702](https://github.com/terrastruct/d2/pull/702)
+- Keeps person shape from becoming too distorted. [#702](https://github.com/terrastruct/d2/pull/702)
+- Ensures shapes with icons have enough padding for their labels. [#702](https://github.com/terrastruct/d2/pull/702)
#### Bugfixes ⛑️
- Fixes groups overlapping in sequence diagrams when they end in a self loop. [#728](https://github.com/terrastruct/d2/pull/728)
+- Fixes dimensions of unlabeled squares or circles with only a set width or height. [#702](https://github.com/terrastruct/d2/pull/702)
+- Fixes scaling of actor shapes in sequence diagrams. [#702](https://github.com/terrastruct/d2/pull/702)
+- Images can now be set to sizes smaller than 128x128. [#702](https://github.com/terrastruct/d2/pull/702)
+- Fixes class height when there are no rows. [#756](https://github.com/terrastruct/d2/pull/756)
diff --git a/ci/sub b/ci/sub
index 8ac704818..2009cdd52 160000
--- a/ci/sub
+++ b/ci/sub
@@ -1 +1 @@
-Subproject commit 8ac704818b5d7ab519e4b87caf5eb79716493709
+Subproject commit 2009cdd523e00cc2e9b8ba804095f71ca70d5671
diff --git a/d2ast/d2ast.go b/d2ast/d2ast.go
index 2ffb448f1..85d6f8620 100644
--- a/d2ast/d2ast.go
+++ b/d2ast/d2ast.go
@@ -1,3 +1,5 @@
+// TODO: Remove boxes and cleanup like d2ir
+//
// d2ast implements the d2 language's abstract syntax tree.
//
// Special characters to think about in parser:
@@ -149,6 +151,10 @@ func (r *Range) UnmarshalText(b []byte) (err error) {
return r.End.UnmarshalText(end)
}
+func (r Range) Before(r2 Range) bool {
+ return r.Start.Before(r2.Start)
+}
+
// Position represents a line:column and byte position in a file.
//
// note: Line and Column are zero indexed.
@@ -257,6 +263,10 @@ func (p Position) SubtractString(s string, byUTF16 bool) Position {
return p
}
+func (p Position) Before(p2 Position) bool {
+ return p.Byte < p2.Byte
+}
+
// MapNode is implemented by nodes that may be children of Maps.
type MapNode interface {
Node
@@ -402,7 +412,7 @@ func (s *SingleQuotedString) scalar() {}
func (s *BlockString) scalar() {}
// TODO: mistake, move into parse.go
-func (n *Null) ScalarString() string { return n.Type() }
+func (n *Null) ScalarString() string { return "" }
func (b *Boolean) ScalarString() string { return strconv.FormatBool(b.Value) }
func (n *Number) ScalarString() string { return n.Raw }
func (s *UnquotedString) ScalarString() string {
@@ -648,6 +658,21 @@ type KeyPath struct {
Path []*StringBox `json:"path"`
}
+func MakeKeyPath(a []string) *KeyPath {
+ kp := &KeyPath{}
+ for _, el := range a {
+ kp.Path = append(kp.Path, MakeValueBox(RawString(el, true)).StringBox())
+ }
+ return kp
+}
+
+func (kp *KeyPath) IDA() (ida []string) {
+ for _, el := range kp.Path {
+ ida = append(ida, el.Unbox().ScalarString())
+ }
+ return ida
+}
+
type Edge struct {
Range Range `json:"range"`
@@ -729,6 +754,37 @@ type ArrayNodeBox struct {
Map *Map `json:"map,omitempty"`
}
+func MakeArrayNodeBox(an ArrayNode) ArrayNodeBox {
+ var ab ArrayNodeBox
+ switch an := an.(type) {
+ case *Comment:
+ ab.Comment = an
+ case *BlockComment:
+ ab.BlockComment = an
+ case *Substitution:
+ ab.Substitution = an
+ case *Null:
+ ab.Null = an
+ case *Boolean:
+ ab.Boolean = an
+ case *Number:
+ ab.Number = an
+ case *UnquotedString:
+ ab.UnquotedString = an
+ case *DoubleQuotedString:
+ ab.DoubleQuotedString = an
+ case *SingleQuotedString:
+ ab.SingleQuotedString = an
+ case *BlockString:
+ ab.BlockString = an
+ case *Array:
+ ab.Array = an
+ case *Map:
+ ab.Map = an
+ }
+ return ab
+}
+
func (ab ArrayNodeBox) Unbox() ArrayNode {
switch {
case ab.Comment != nil:
diff --git a/d2chaos/d2chaos.go b/d2chaos/d2chaos.go
index 7729454a8..c26ea3c80 100644
--- a/d2chaos/d2chaos.go
+++ b/d2chaos/d2chaos.go
@@ -18,10 +18,11 @@ import (
func GenDSL(maxi int) (_ string, err error) {
gs := &dslGenState{
rand: mathrand.New(mathrand.NewSource(time.Now().UnixNano())),
- g: d2graph.NewGraph(&d2ast.Map{}),
+ g: d2graph.NewGraph(),
nodeShapes: make(map[string]string),
nodeContainer: make(map[string]string),
}
+ gs.g.AST = &d2ast.Map{}
err = gs.gen(maxi)
if err != nil {
return "", err
diff --git a/d2compiler/compile.go b/d2compiler/compile.go
index aa3c0adfa..584dd20af 100644
--- a/d2compiler/compile.go
+++ b/d2compiler/compile.go
@@ -1,7 +1,6 @@
package d2compiler
import (
- "errors"
"fmt"
"io"
"net/url"
@@ -13,12 +12,11 @@ import (
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/d2ir"
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/d2target"
)
-// TODO: should Parse even be exported? guess not. IR should contain list of files and
-// their AST.
type CompileOptions struct {
UTF16 bool
}
@@ -28,370 +26,180 @@ func Compile(path string, r io.RuneReader, opts *CompileOptions) (*d2graph.Graph
opts = &CompileOptions{}
}
- var pe d2parser.ParseError
-
ast, err := d2parser.Parse(path, r, &d2parser.ParseOptions{
UTF16: opts.UTF16,
})
if err != nil {
- if !errors.As(err, &pe) {
- return nil, err
- }
+ return nil, err
}
- return compileAST(path, pe, ast)
+ ir, err := d2ir.Compile(ast)
+ if err != nil {
+ return nil, err
+ }
+
+ g, err := compileIR(ast, ir)
+ if err != nil {
+ return nil, err
+ }
+ g.SortObjectsByAST()
+ g.SortEdgesByAST()
+ return g, nil
}
-func compileAST(path string, pe d2parser.ParseError, ast *d2ast.Map) (*d2graph.Graph, error) {
- g := d2graph.NewGraph(ast)
-
- c := &compiler{
- path: path,
- err: pe,
- }
-
- c.compileKeys(g.Root, ast)
- if len(c.err.Errors) == 0 {
- c.validateKeys(g.Root, ast)
- }
- c.compileEdges(g.Root, ast)
- // TODO: simplify removeContainer by running before compileEdges
- c.compileShapes(g.Root)
- c.validateNear(g)
+func compileIR(ast *d2ast.Map, m *d2ir.Map) (*d2graph.Graph, error) {
+ c := &compiler{}
+ g := d2graph.NewGraph()
+ g.AST = ast
+ c.compileBoard(g, m)
if len(c.err.Errors) > 0 {
return nil, c.err
}
return g, nil
}
+func (c *compiler) compileBoard(g *d2graph.Graph, ir *d2ir.Map) *d2graph.Graph {
+ ir = ir.Copy(nil).(*d2ir.Map)
+ // c.preprocessSeqDiagrams(ir)
+ c.compileMap(g.Root, ir)
+ if len(c.err.Errors) == 0 {
+ c.validateKeys(g.Root, ir)
+ }
+ c.validateNear(g)
+
+ c.compileBoardsField(g, ir, "layers")
+ c.compileBoardsField(g, ir, "scenarios")
+ c.compileBoardsField(g, ir, "steps")
+ return g
+}
+
+func (c *compiler) compileBoardsField(g *d2graph.Graph, ir *d2ir.Map, fieldName string) {
+ layers := ir.GetField(fieldName)
+ if layers.Map() == nil {
+ return
+ }
+ for _, f := range layers.Map().Fields {
+ if f.Map() == nil {
+ continue
+ }
+ if g.GetBoard(f.Name) != nil {
+ c.errorf(f.References[0].AST(), "board name %v already used by another board", f.Name)
+ continue
+ }
+ g2 := d2graph.NewGraph()
+ g2.AST = g.AST
+ c.compileBoard(g2, f.Map())
+ g2.Name = f.Name
+ switch fieldName {
+ case "layers":
+ g.Layers = append(g.Layers, g2)
+ case "scenarios":
+ g.Scenarios = append(g.Scenarios, g2)
+ case "steps":
+ g.Steps = append(g.Steps, g2)
+ }
+ }
+}
+
type compiler struct {
- path string
- err d2parser.ParseError
+ inEdgeGroup bool
+ err d2parser.ParseError
}
-func (c *compiler) errorf(start d2ast.Position, end d2ast.Position, f string, v ...interface{}) {
- r := d2ast.Range{
- Path: c.path,
- Start: start,
- End: end,
+func (c *compiler) errorf(n d2ast.Node, f string, v ...interface{}) {
+ c.err.Errors = append(c.err.Errors, d2parser.Errorf(n, f, v...).(d2ast.Error))
+}
+
+func (c *compiler) compileMap(obj *d2graph.Object, m *d2ir.Map) {
+ shape := m.GetField("shape")
+ if shape != nil {
+ c.compileField(obj, shape)
}
- f = "%v: " + f
- v = append([]interface{}{r}, v...)
- c.err.Errors = append(c.err.Errors, d2ast.Error{
- Range: r,
- Message: fmt.Sprintf(f, v...),
- })
-}
-
-func (c *compiler) compileKeys(obj *d2graph.Object, m *d2ast.Map) {
- for _, n := range m.Nodes {
- if n.MapKey != nil && n.MapKey.Key != nil && len(n.MapKey.Edges) == 0 {
- c.compileKey(obj, m, n.MapKey)
+ for _, f := range m.Fields {
+ if f.Name == "shape" {
+ continue
}
+ if _, ok := d2graph.BoardKeywords[f.Name]; ok {
+ continue
+ }
+ c.compileField(obj, f)
+ }
+
+ switch obj.Attributes.Shape.Value {
+ case d2target.ShapeClass:
+ c.compileClass(obj)
+ case d2target.ShapeSQLTable:
+ c.compileSQLTable(obj)
+ }
+
+ for _, e := range m.Edges {
+ c.compileEdge(obj, e)
}
}
-func (c *compiler) compileEdges(obj *d2graph.Object, m *d2ast.Map) {
- for _, n := range m.Nodes {
- if n.MapKey != nil {
- if len(n.MapKey.Edges) > 0 {
- obj := obj
- if n.MapKey.Key != nil {
- ida := d2graph.Key(n.MapKey.Key)
- parent, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
- if err != nil {
- c.errorf(n.MapKey.Range.Start, n.MapKey.Range.End, err.Error())
- return
- }
- unresolvedObj := obj
- obj = parent.EnsureChild(resolvedIDA)
+func (c *compiler) compileField(obj *d2graph.Object, f *d2ir.Field) {
+ keyword := strings.ToLower(f.Name)
+ _, isStyleReserved := d2graph.StyleKeywords[keyword]
+ if isStyleReserved {
+ c.errorf(f.LastRef().AST(), "%v must be style.%v", f.Name, f.Name)
+ return
+ }
+ _, isReserved := d2graph.SimpleReservedKeywords[keyword]
+ if isReserved {
+ c.compileReserved(obj.Attributes, f)
+ return
+ } else if f.Name == "style" {
+ if f.Map() == nil {
+ return
+ }
+ c.compileStyle(obj.Attributes, f.Map())
+ if obj.Attributes.Style.Animated != nil {
+ c.errorf(obj.Attributes.Style.Animated.MapKey, `key "animated" can only be applied to edges`)
+ }
+ return
+ }
- parent.AppendReferences(ida, d2graph.Reference{
- Key: n.MapKey.Key,
+ obj = obj.EnsureChild(d2graphIDA([]string{f.Name}))
+ if f.Primary() != nil {
+ c.compileLabel(obj.Attributes, f)
+ }
+ if f.Map() != nil {
+ c.compileMap(obj, f.Map())
+ }
- MapKey: n.MapKey,
- Scope: m,
- }, unresolvedObj)
- }
- c.compileEdgeMapKey(obj, m, n.MapKey)
- }
- if n.MapKey.Key != nil && n.MapKey.Value.Map != nil {
- c.compileEdges(obj.EnsureChild(d2graph.Key(n.MapKey.Key)), n.MapKey.Value.Map)
+ if obj.Attributes.Label.MapKey == nil {
+ obj.Attributes.Label.MapKey = f.LastPrimaryKey()
+ }
+ for _, fr := range f.References {
+ if fr.Primary() {
+ if fr.Context.Key.Value.Map != nil {
+ obj.Map = fr.Context.Key.Value.Map
}
}
+ scopeObjIDA := d2ir.IDA(fr.Context.ScopeMap)
+ scopeObj, _ := obj.Graph.Root.HasChild(scopeObjIDA)
+ obj.References = append(obj.References, d2graph.Reference{
+ Key: fr.KeyPath,
+ KeyPathIndex: fr.KeyPathIndex(),
+
+ MapKey: fr.Context.Key,
+ MapKeyEdgeIndex: fr.Context.EdgeIndex(),
+ Scope: fr.Context.Scope,
+ ScopeObj: scopeObj,
+ })
}
}
-// compileArrowheads compiles keywords for edge arrowhead attributes by
-// 1. creating a fake, detached parent
-// 2. compiling the arrowhead field as a fake object onto that fake parent
-// 3. transferring the relevant attributes onto the edge
-func (c *compiler) compileArrowheads(edge *d2graph.Edge, m *d2ast.Map, mk *d2ast.Key) bool {
- arrowheadKey := mk.Key
- if mk.EdgeKey != nil {
- arrowheadKey = mk.EdgeKey
- }
- if arrowheadKey == nil || len(arrowheadKey.Path) == 0 {
- return false
- }
- key := arrowheadKey.Path[0].Unbox().ScalarString()
- var field *d2graph.Attributes
- if key == "source-arrowhead" {
- if edge.SrcArrowhead == nil {
- edge.SrcArrowhead = &d2graph.Attributes{}
- }
- field = edge.SrcArrowhead
- } else if key == "target-arrowhead" {
- if edge.DstArrowhead == nil {
- edge.DstArrowhead = &d2graph.Attributes{}
- }
- field = edge.DstArrowhead
- } else {
- return false
- }
- fakeParent := &d2graph.Object{
- Children: make(map[string]*d2graph.Object),
- }
- detachedMK := &d2ast.Key{
- Key: arrowheadKey,
- Primary: mk.Primary,
- Value: mk.Value,
- }
- c.compileKey(fakeParent, m, detachedMK)
- fakeObj := fakeParent.ChildrenArray[0]
- c.compileShapes(fakeObj)
-
- if fakeObj.Attributes.Shape.Value != "" {
- field.Shape = fakeObj.Attributes.Shape
- }
- if fakeObj.Attributes.Label.Value != "" && fakeObj.Attributes.Label.Value != "source-arrowhead" && fakeObj.Attributes.Label.Value != "target-arrowhead" {
- field.Label = fakeObj.Attributes.Label
- }
- if fakeObj.Attributes.Style.Filled != nil {
- field.Style.Filled = fakeObj.Attributes.Style.Filled
- }
-
- return true
-}
-
-func (c *compiler) compileAttributes(attrs *d2graph.Attributes, mk *d2ast.Key) {
- var reserved string
- var ok bool
-
- if mk.EdgeKey != nil {
- _, reserved, ok = c.compileFlatKey(mk.EdgeKey)
- } else if mk.Key != nil {
- _, reserved, ok = c.compileFlatKey(mk.Key)
- }
- if !ok {
- return
- }
-
- if reserved == "" || reserved == "label" {
- attrs.Label.MapKey = mk
- } else if reserved == "shape" {
- attrs.Shape.MapKey = mk
- } else if reserved == "opacity" {
- attrs.Style.Opacity = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "stroke" {
- attrs.Style.Stroke = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "fill" {
- attrs.Style.Fill = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "stroke-width" {
- attrs.Style.StrokeWidth = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "stroke-dash" {
- attrs.Style.StrokeDash = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "border-radius" {
- attrs.Style.BorderRadius = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "shadow" {
- attrs.Style.Shadow = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "3d" {
- // TODO this should be movd to validateKeys, as shape may not be set yet
- if attrs.Shape.Value != "" && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeSquare) && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeRectangle) {
- c.errorf(mk.Range.Start, mk.Range.End, `key "3d" can only be applied to squares and rectangles`)
- return
- }
- attrs.Style.ThreeDee = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "multiple" {
- attrs.Style.Multiple = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "font" {
- attrs.Style.Font = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "font-size" {
- attrs.Style.FontSize = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "font-color" {
- attrs.Style.FontColor = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "animated" {
- attrs.Style.Animated = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "bold" {
- attrs.Style.Bold = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "italic" {
- attrs.Style.Italic = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "underline" {
- attrs.Style.Underline = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "filled" {
- attrs.Style.Filled = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "width" {
- attrs.Width = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "height" {
- attrs.Height = &d2graph.Scalar{MapKey: mk}
- } else if reserved == "double-border" {
- if attrs.Shape.Value != "" && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeSquare) && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeRectangle) && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeCircle) && !strings.EqualFold(attrs.Shape.Value, d2target.ShapeOval) {
- c.errorf(mk.Range.Start, mk.Range.End, `key "double-border" can only be applied to squares, rectangles, circles, ovals`)
- return
- }
- attrs.Style.DoubleBorder = &d2graph.Scalar{MapKey: mk}
- }
-}
-
-func (c *compiler) compileKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
- ida, reserved, ok := c.compileFlatKey(mk.Key)
- if !ok {
- return
- }
- if reserved == "desc" {
- return
- }
-
- resolvedObj, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
- if err != nil {
- c.errorf(mk.Range.Start, mk.Range.End, err.Error())
- return
- }
-
- parent := resolvedObj
- if len(resolvedIDA) > 0 {
- unresolvedObj := obj
- obj = parent.EnsureChild(resolvedIDA)
- parent.AppendReferences(ida, d2graph.Reference{
- Key: mk.Key,
-
- MapKey: mk,
- Scope: m,
- }, unresolvedObj)
- } else if obj.Parent == nil {
- // Top level reserved key set on root.
- c.compileAttributes(&obj.Attributes, mk)
- c.applyScalar(&obj.Attributes, reserved, mk.Value.ScalarBox())
- return
- }
-
- if len(mk.Edges) > 0 {
- return
- }
-
- c.compileAttributes(&obj.Attributes, mk)
- if obj.Attributes.Style.Animated != nil {
- c.errorf(mk.Range.Start, mk.Range.End, `key "animated" can only be applied to edges`)
- return
- }
-
- c.applyScalar(&obj.Attributes, reserved, mk.Value.ScalarBox())
- if mk.Value.Map != nil {
- if reserved != "" {
- c.errorf(mk.Range.Start, mk.Range.End, "cannot set reserved key %q to a map", reserved)
- return
- }
- obj.Map = mk.Value.Map
- c.compileKeys(obj, mk.Value.Map)
- }
-
- c.applyScalar(&obj.Attributes, reserved, mk.Primary)
-}
-
-func (c *compiler) applyScalar(attrs *d2graph.Attributes, reserved string, box d2ast.ScalarBox) {
- scalar := box.Unbox()
- if scalar == nil {
- return
- }
-
- switch reserved {
- case "shape":
- in := d2target.IsShape(scalar.ScalarString())
- _, isArrowhead := d2target.Arrowheads[scalar.ScalarString()]
- if !in && !isArrowhead {
- c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "unknown shape %q", scalar.ScalarString())
- return
- }
- if box.Null != nil {
- attrs.Shape.Value = ""
- } else {
- attrs.Shape.Value = scalar.ScalarString()
- }
- if attrs.Shape.Value == d2target.ShapeCode {
- // Explicit code shape is plaintext.
- attrs.Language = d2target.ShapeText
- }
- return
- case "icon":
- iconURL, err := url.Parse(scalar.ScalarString())
- if err != nil {
- c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "bad icon url %#v: %s", scalar.ScalarString(), err)
- return
- }
- attrs.Icon = iconURL
- return
- case "near":
- nearKey, err := d2parser.ParseKey(scalar.ScalarString())
- if err != nil {
- c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "bad near key %#v: %s", scalar.ScalarString(), err)
- return
- }
- attrs.NearKey = nearKey
- return
- case "tooltip":
- attrs.Tooltip = scalar.ScalarString()
- return
- case "width":
- _, err := strconv.Atoi(scalar.ScalarString())
- if err != nil {
- c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "non-integer width %#v: %s", scalar.ScalarString(), err)
- return
- }
- attrs.Width.Value = scalar.ScalarString()
- return
- case "height":
- _, err := strconv.Atoi(scalar.ScalarString())
- if err != nil {
- c.errorf(scalar.GetRange().Start, scalar.GetRange().End, "non-integer height %#v: %s", scalar.ScalarString(), err)
- return
- }
- attrs.Height.Value = scalar.ScalarString()
- return
- case "link":
- attrs.Link = scalar.ScalarString()
- return
- case "direction":
- dirs := []string{"up", "down", "right", "left"}
- if !go2.Contains(dirs, scalar.ScalarString()) {
- c.errorf(scalar.GetRange().Start, scalar.GetRange().End, `direction must be one of %v, got %q`, strings.Join(dirs, ", "), scalar.ScalarString())
- return
- }
- attrs.Direction.Value = scalar.ScalarString()
- return
- case "constraint":
- // Compilation for shape-specific keywords happens elsewhere
- return
- }
-
- if _, ok := d2graph.StyleKeywords[reserved]; ok {
- if err := attrs.Style.Apply(reserved, scalar.ScalarString()); err != nil {
- c.errorf(scalar.GetRange().Start, scalar.GetRange().End, err.Error())
- }
- return
- }
-
- if box.Null != nil {
- // TODO: delete obj
- attrs.Label.Value = ""
- } else {
+func (c *compiler) compileLabel(attrs *d2graph.Attributes, f d2ir.Node) {
+ scalar := f.Primary().Value
+ switch scalar := scalar.(type) {
+ case *d2ast.Null:
+ // TODO: Delete instaed.
attrs.Label.Value = scalar.ScalarString()
- }
-
- bs := box.BlockString
- if bs != nil && reserved == "" {
- attrs.Language = bs.Tag
- fullTag, ok := ShortToFullLanguageAliases[bs.Tag]
+ case *d2ast.BlockString:
+ attrs.Language = scalar.Tag
+ fullTag, ok := ShortToFullLanguageAliases[scalar.Tag]
if ok {
attrs.Language = fullTag
}
@@ -400,168 +208,243 @@ func (c *compiler) applyScalar(attrs *d2graph.Attributes, reserved string, box d
} else {
attrs.Shape.Value = d2target.ShapeCode
}
+ attrs.Label.Value = scalar.ScalarString()
+ default:
+ attrs.Label.Value = scalar.ScalarString()
}
+ attrs.Label.MapKey = f.LastPrimaryKey()
}
-func (c *compiler) compileEdgeMapKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
- if mk.EdgeIndex != nil {
- edge, ok := obj.HasEdge(mk)
- if ok {
- c.appendEdgeReferences(obj, m, mk)
- edge.References = append(edge.References, d2graph.EdgeReference{
- Edge: mk.Edges[0],
-
- MapKey: mk,
- MapKeyEdgeIndex: 0,
- Scope: m,
- ScopeObj: obj,
- })
- c.compileEdge(edge, m, mk)
+func (c *compiler) compileReserved(attrs *d2graph.Attributes, f *d2ir.Field) {
+ if f.Primary() == nil {
+ if f.Composite != nil {
+ c.errorf(f.LastPrimaryKey(), "reserved field %v does not accept composite", f.Name)
}
return
}
- for i, e := range mk.Edges {
- if e.Src == nil || e.Dst == nil {
- continue
+ scalar := f.Primary().Value
+ switch f.Name {
+ case "label":
+ c.compileLabel(attrs, f)
+ case "shape":
+ in := d2target.IsShape(scalar.ScalarString())
+ _, isArrowhead := d2target.Arrowheads[scalar.ScalarString()]
+ if !in && !isArrowhead {
+ c.errorf(scalar, "unknown shape %q", scalar.ScalarString())
+ return
}
- edge, err := obj.Connect(d2graph.Key(e.Src), d2graph.Key(e.Dst), e.SrcArrow == "<", e.DstArrow == ">", "")
+ attrs.Shape.Value = scalar.ScalarString()
+ if attrs.Shape.Value == d2target.ShapeCode {
+ // Explicit code shape is plaintext.
+ attrs.Language = d2target.ShapeText
+ }
+ attrs.Shape.MapKey = f.LastPrimaryKey()
+ case "icon":
+ iconURL, err := url.Parse(scalar.ScalarString())
if err != nil {
- c.errorf(e.Range.Start, e.Range.End, err.Error())
+ c.errorf(scalar, "bad icon url %#v: %s", scalar.ScalarString(), err)
+ return
+ }
+ attrs.Icon = iconURL
+ case "near":
+ nearKey, err := d2parser.ParseKey(scalar.ScalarString())
+ if err != nil {
+ c.errorf(scalar, "bad near key %#v: %s", scalar.ScalarString(), err)
+ return
+ }
+ nearKey.Range = scalar.GetRange()
+ attrs.NearKey = nearKey
+ case "tooltip":
+ attrs.Tooltip = scalar.ScalarString()
+ case "width":
+ _, err := strconv.Atoi(scalar.ScalarString())
+ if err != nil {
+ c.errorf(scalar, "non-integer width %#v: %s", scalar.ScalarString(), err)
+ return
+ }
+ attrs.Width = &d2graph.Scalar{}
+ attrs.Width.Value = scalar.ScalarString()
+ attrs.Width.MapKey = f.LastPrimaryKey()
+ case "height":
+ _, err := strconv.Atoi(scalar.ScalarString())
+ if err != nil {
+ c.errorf(scalar, "non-integer height %#v: %s", scalar.ScalarString(), err)
+ return
+ }
+ attrs.Height = &d2graph.Scalar{}
+ attrs.Height.Value = scalar.ScalarString()
+ attrs.Height.MapKey = f.LastPrimaryKey()
+ case "link":
+ attrs.Link = scalar.ScalarString()
+ case "direction":
+ dirs := []string{"up", "down", "right", "left"}
+ if !go2.Contains(dirs, scalar.ScalarString()) {
+ c.errorf(scalar, `direction must be one of %v, got %q`, strings.Join(dirs, ", "), scalar.ScalarString())
+ return
+ }
+ attrs.Direction.Value = scalar.ScalarString()
+ attrs.Direction.MapKey = f.LastPrimaryKey()
+ case "constraint":
+ if _, ok := scalar.(d2ast.String); !ok {
+ c.errorf(f.LastPrimaryKey(), "constraint value must be a string")
+ return
+ }
+ attrs.Constraint.Value = scalar.ScalarString()
+ attrs.Constraint.MapKey = f.LastPrimaryKey()
+ }
+}
+
+func (c *compiler) compileStyle(attrs *d2graph.Attributes, m *d2ir.Map) {
+ for _, f := range m.Fields {
+ c.compileStyleField(attrs, f)
+ }
+}
+
+func (c *compiler) compileStyleField(attrs *d2graph.Attributes, f *d2ir.Field) {
+ if f.Primary() == nil {
+ return
+ }
+ compileStyleFieldInit(attrs, f)
+ scalar := f.Primary().Value
+ err := attrs.Style.Apply(f.Name, scalar.ScalarString())
+ if err != nil {
+ c.errorf(scalar, err.Error())
+ return
+ }
+}
+
+func compileStyleFieldInit(attrs *d2graph.Attributes, f *d2ir.Field) {
+ switch f.Name {
+ case "opacity":
+ attrs.Style.Opacity = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "stroke":
+ attrs.Style.Stroke = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "fill":
+ attrs.Style.Fill = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "stroke-width":
+ attrs.Style.StrokeWidth = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "stroke-dash":
+ attrs.Style.StrokeDash = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "border-radius":
+ attrs.Style.BorderRadius = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "shadow":
+ attrs.Style.Shadow = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "3d":
+ attrs.Style.ThreeDee = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "multiple":
+ attrs.Style.Multiple = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "font":
+ attrs.Style.Font = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "font-size":
+ attrs.Style.FontSize = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "font-color":
+ attrs.Style.FontColor = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "animated":
+ attrs.Style.Animated = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "bold":
+ attrs.Style.Bold = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "italic":
+ attrs.Style.Italic = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "underline":
+ attrs.Style.Underline = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "filled":
+ attrs.Style.Filled = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "width":
+ attrs.Width = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "height":
+ attrs.Height = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ case "double-border":
+ attrs.Style.DoubleBorder = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
+ }
+}
+
+func (c *compiler) compileEdge(obj *d2graph.Object, e *d2ir.Edge) {
+ edge, err := obj.Connect(d2graphIDA(e.ID.SrcPath), d2graphIDA(e.ID.DstPath), e.ID.SrcArrow, e.ID.DstArrow, "")
+ if err != nil {
+ c.errorf(e.References[0].AST(), err.Error())
+ return
+ }
+
+ if e.Primary() != nil {
+ c.compileLabel(edge.Attributes, e)
+ }
+ if e.Map() != nil {
+ for _, f := range e.Map().Fields {
+ _, ok := d2graph.ReservedKeywords[f.Name]
+ if !ok {
+ c.errorf(f.References[0].AST(), `edge map keys must be reserved keywords`)
+ continue
+ }
+ c.compileEdgeField(edge, f)
+ }
+ }
+
+ edge.Attributes.Label.MapKey = e.LastPrimaryKey()
+ for _, er := range e.References {
+ scopeObjIDA := d2ir.IDA(er.Context.ScopeMap)
+ scopeObj, _ := edge.Src.Graph.Root.HasChild(d2graphIDA(scopeObjIDA))
+ edge.References = append(edge.References, d2graph.EdgeReference{
+ Edge: er.Context.Edge,
+ MapKey: er.Context.Key,
+ MapKeyEdgeIndex: er.Context.EdgeIndex(),
+ Scope: er.Context.Scope,
+ ScopeObj: scopeObj,
+ })
+ }
+}
+
+func (c *compiler) compileEdgeField(edge *d2graph.Edge, f *d2ir.Field) {
+ keyword := strings.ToLower(f.Name)
+ _, isReserved := d2graph.SimpleReservedKeywords[keyword]
+ if isReserved {
+ c.compileReserved(edge.Attributes, f)
+ return
+ } else if f.Name == "style" {
+ if f.Map() == nil {
+ return
+ }
+ c.compileStyle(edge.Attributes, f.Map())
+ return
+ }
+
+ if f.Name == "source-arrowhead" || f.Name == "target-arrowhead" {
+ if f.Map() != nil {
+ c.compileArrowheads(edge, f)
+ }
+ }
+}
+
+func (c *compiler) compileArrowheads(edge *d2graph.Edge, f *d2ir.Field) {
+ var attrs *d2graph.Attributes
+ if f.Name == "source-arrowhead" {
+ edge.SrcArrowhead = &d2graph.Attributes{}
+ attrs = edge.SrcArrowhead
+ } else {
+ edge.DstArrowhead = &d2graph.Attributes{}
+ attrs = edge.DstArrowhead
+ }
+
+ if f.Primary() != nil {
+ c.compileLabel(attrs, f)
+ }
+
+ for _, f2 := range f.Map().Fields {
+ keyword := strings.ToLower(f2.Name)
+ _, isReserved := d2graph.SimpleReservedKeywords[keyword]
+ if isReserved {
+ c.compileReserved(attrs, f2)
+ continue
+ } else if f2.Name == "style" {
+ if f2.Map() == nil {
+ continue
+ }
+ c.compileStyle(attrs, f2.Map())
+ continue
+ } else {
+ c.errorf(f2.LastRef().AST(), `source-arrowhead/target-arrowhead map keys must be reserved keywords`)
continue
}
- edge.References = append(edge.References, d2graph.EdgeReference{
- Edge: e,
-
- MapKey: mk,
- MapKeyEdgeIndex: i,
- Scope: m,
- ScopeObj: obj,
- })
- c.compileEdge(edge, m, mk)
}
- c.appendEdgeReferences(obj, m, mk)
-}
-
-func (c *compiler) compileEdge(edge *d2graph.Edge, m *d2ast.Map, mk *d2ast.Key) {
- if mk.Key == nil && mk.EdgeKey == nil {
- if len(mk.Edges) == 1 {
- edge.Attributes.Label.MapKey = mk
- }
- c.applyScalar(&edge.Attributes, "", mk.Value.ScalarBox())
- c.applyScalar(&edge.Attributes, "", mk.Primary)
- } else {
- c.compileEdgeKey(edge, m, mk)
- }
- if mk.Value.Map != nil && mk.EdgeKey == nil {
- for _, n := range mk.Value.Map.Nodes {
- if n.MapKey == nil {
- continue
- }
- if len(n.MapKey.Edges) > 0 {
- c.errorf(mk.Range.Start, mk.Range.End, `edges cannot be nested within another edge`)
- continue
- }
- if n.MapKey.Key == nil {
- continue
- }
- for _, p := range n.MapKey.Key.Path {
- _, ok := d2graph.ReservedKeywords[strings.ToLower(p.Unbox().ScalarString())]
- if !ok {
- c.errorf(mk.Range.Start, mk.Range.End, `edge map keys must be reserved keywords`)
- return
- }
- }
- c.compileEdgeKey(edge, m, n.MapKey)
- }
- }
-}
-
-func (c *compiler) compileEdgeKey(edge *d2graph.Edge, m *d2ast.Map, mk *d2ast.Key) {
- var r string
- var ok bool
-
- // Give precedence to EdgeKeys
- // x.(a -> b)[0].style.opacity: 0.4
- // We want to compile the style.opacity, not the x
- if mk.EdgeKey != nil {
- _, r, ok = c.compileFlatKey(mk.EdgeKey)
- } else if mk.Key != nil {
- _, r, ok = c.compileFlatKey(mk.Key)
- }
- if !ok {
- return
- }
-
- ok = c.compileArrowheads(edge, m, mk)
- if ok {
- return
- }
- c.compileAttributes(&edge.Attributes, mk)
- c.applyScalar(&edge.Attributes, r, mk.Value.ScalarBox())
- if mk.Value.Map != nil {
- for _, n := range mk.Value.Map.Nodes {
- if n.MapKey != nil {
- c.compileEdgeKey(edge, m, n.MapKey)
- }
- }
- }
-}
-
-func (c *compiler) appendEdgeReferences(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
- for i, e := range mk.Edges {
- if e.Src != nil {
- ida := d2graph.Key(e.Src)
-
- parent, _, err := d2graph.ResolveUnderscoreKey(ida, obj)
- if err != nil {
- c.errorf(mk.Range.Start, mk.Range.End, err.Error())
- return
- }
- parent.AppendReferences(ida, d2graph.Reference{
- Key: e.Src,
-
- MapKey: mk,
- MapKeyEdgeIndex: i,
- Scope: m,
- }, obj)
- }
- if e.Dst != nil {
- ida := d2graph.Key(e.Dst)
-
- parent, _, err := d2graph.ResolveUnderscoreKey(ida, obj)
- if err != nil {
- c.errorf(mk.Range.Start, mk.Range.End, err.Error())
- return
- }
- parent.AppendReferences(ida, d2graph.Reference{
- Key: e.Dst,
-
- MapKey: mk,
- MapKeyEdgeIndex: i,
- Scope: m,
- }, obj)
- }
- }
-}
-
-func (c *compiler) compileFlatKey(k *d2ast.KeyPath) ([]string, string, bool) {
- k2 := *k
- var reserved string
- for i, s := range k.Path {
- keyword := strings.ToLower(s.Unbox().ScalarString())
- _, isReserved := d2graph.ReservedKeywords[keyword]
- _, isReservedHolder := d2graph.ReservedKeywordHolders[keyword]
- if isReserved && !isReservedHolder {
- reserved = keyword
- k2.Path = k2.Path[:i]
- break
- }
- }
- if len(k2.Path) < len(k.Path)-1 {
- c.errorf(k.Range.Start, k.Range.End, "reserved key %q cannot have children", reserved)
- return nil, "", false
- }
- return d2graph.Key(&k2), reserved, true
}
// TODO add more, e.g. C, bash
@@ -576,56 +459,9 @@ var ShortToFullLanguageAliases = map[string]string{
}
var FullToShortLanguageAliases map[string]string
-func (c *compiler) compileShapes(obj *d2graph.Object) {
- for _, obj := range obj.ChildrenArray {
- switch obj.Attributes.Shape.Value {
- case d2target.ShapeClass:
- c.compileClass(obj)
- case d2target.ShapeSQLTable:
- c.compileSQLTable(obj)
- case d2target.ShapeImage:
- c.compileImage(obj)
- }
- c.compileShapes(obj)
- }
-
- for i := 0; i < len(obj.ChildrenArray); i++ {
- ch := obj.ChildrenArray[i]
- switch ch.Attributes.Shape.Value {
- case d2target.ShapeClass, d2target.ShapeSQLTable:
- flattenContainer(obj.Graph, ch)
- }
- if ch.IDVal == "style" {
- obj.Attributes.Style = ch.Attributes.Style
- if obj.Graph != nil {
- flattenContainer(obj.Graph, ch)
- for i := 0; i < len(obj.Graph.Objects); i++ {
- if obj.Graph.Objects[i] == ch {
- obj.Graph.Objects = append(obj.Graph.Objects[:i], obj.Graph.Objects[i+1:]...)
- break
- }
- }
- delete(obj.Children, ch.ID)
- obj.ChildrenArray = append(obj.ChildrenArray[:i], obj.ChildrenArray[i+1:]...)
- i--
- }
- }
- }
-}
-
-func (c *compiler) compileImage(obj *d2graph.Object) {
- if obj.Attributes.Icon == nil {
- c.errorf(obj.Attributes.Shape.MapKey.Range.Start, obj.Attributes.Shape.MapKey.Range.End, `image shape must include an "icon" field`)
- }
-}
-
func (c *compiler) compileClass(obj *d2graph.Object) {
obj.Class = &d2target.Class{}
-
for _, f := range obj.ChildrenArray {
- if f.IDVal == "style" {
- continue
- }
visiblity := "public"
name := f.IDVal
// See https://www.uml-diagrams.org/visibility.html
@@ -666,17 +502,22 @@ func (c *compiler) compileClass(obj *d2graph.Object) {
})
}
}
+
+ for _, ch := range obj.ChildrenArray {
+ for i := 0; i < len(obj.Graph.Objects); i++ {
+ if obj.Graph.Objects[i] == ch {
+ obj.Graph.Objects = append(obj.Graph.Objects[:i], obj.Graph.Objects[i+1:]...)
+ i--
+ }
+ }
+ }
+ obj.Children = nil
+ obj.ChildrenArray = nil
}
func (c *compiler) compileSQLTable(obj *d2graph.Object) {
obj.SQLTable = &d2target.SQLTable{}
-
- parentID := obj.Parent.AbsID()
- tableIDPrefix := obj.AbsID() + "."
for _, col := range obj.ChildrenArray {
- if col.IDVal == "style" {
- continue
- }
typ := col.Attributes.Label.Value
if typ == col.IDVal {
// Not great, AST should easily allow specifying alternate primary field
@@ -687,182 +528,87 @@ func (c *compiler) compileSQLTable(obj *d2graph.Object) {
Name: d2target.Text{Label: col.IDVal},
Type: d2target.Text{Label: typ},
}
- // The only map a sql table field could have is to specify constraint
- if col.Map != nil {
- for _, n := range col.Map.Nodes {
- if n.MapKey.Key == nil || len(n.MapKey.Key.Path) == 0 {
- continue
- }
- if n.MapKey.Key.Path[0].Unbox().ScalarString() == "constraint" {
- if n.MapKey.Value.StringBox().Unbox() == nil {
- c.errorf(n.MapKey.GetRange().Start, n.MapKey.GetRange().End, "constraint value must be a string")
- return
- }
- d2Col.Constraint = n.MapKey.Value.StringBox().Unbox().ScalarString()
- }
- }
+ if col.Attributes.Constraint.Value != "" {
+ d2Col.Constraint = col.Attributes.Constraint.Value
}
-
- absID := col.AbsID()
- for _, e := range obj.Graph.Edges {
- srcID := e.Src.AbsID()
- dstID := e.Dst.AbsID()
- // skip edges between columns of the same table
- if strings.HasPrefix(srcID, tableIDPrefix) && strings.HasPrefix(dstID, tableIDPrefix) {
- continue
- }
- if srcID == absID {
- d2Col.Reference = strings.TrimPrefix(dstID, parentID+".")
- e.SrcTableColumnIndex = new(int)
- *e.SrcTableColumnIndex = len(obj.SQLTable.Columns)
- } else if dstID == absID {
- e.DstTableColumnIndex = new(int)
- *e.DstTableColumnIndex = len(obj.SQLTable.Columns)
- }
- }
-
obj.SQLTable.Columns = append(obj.SQLTable.Columns, d2Col)
}
+
+ for _, ch := range obj.ChildrenArray {
+ for i := 0; i < len(obj.Graph.Objects); i++ {
+ if obj.Graph.Objects[i] == ch {
+ obj.Graph.Objects = append(obj.Graph.Objects[:i], obj.Graph.Objects[i+1:]...)
+ i--
+ }
+ }
+ }
+ obj.Children = nil
+ obj.ChildrenArray = nil
}
-func flattenContainer(g *d2graph.Graph, obj *d2graph.Object) {
- absID := obj.AbsID()
-
- toRemove := map[*d2graph.Edge]struct{}{}
- toAdd := []*d2graph.Edge{}
- for i := 0; i < len(g.Edges); i++ {
- e := g.Edges[i]
- srcID := e.Src.AbsID()
- dstID := e.Dst.AbsID()
-
- srcIsChild := strings.HasPrefix(srcID, absID+".")
- dstIsChild := strings.HasPrefix(dstID, absID+".")
- if srcIsChild && dstIsChild {
- toRemove[e] = struct{}{}
- } else if srcIsChild {
- toRemove[e] = struct{}{}
- if dstID == absID {
- continue
- }
- toAdd = append(toAdd, e)
- } else if dstIsChild {
- toRemove[e] = struct{}{}
- if srcID == absID {
- continue
- }
- toAdd = append(toAdd, e)
- }
- }
- for _, e := range toAdd {
- var newEdge *d2graph.Edge
- if strings.HasPrefix(e.Src.AbsID(), absID+".") {
- newEdge, _ = g.Root.Connect(obj.AbsIDArray(), e.Dst.AbsIDArray(), e.SrcArrow, e.DstArrow, e.Attributes.Label.Value)
- } else {
- newEdge, _ = g.Root.Connect(e.Src.AbsIDArray(), obj.AbsIDArray(), e.SrcArrow, e.DstArrow, e.Attributes.Label.Value)
- }
- // TODO more attributes
- if e.SrcTableColumnIndex != nil {
- newEdge.SrcTableColumnIndex = new(int)
- newEdge.SrcArrowhead = e.SrcArrowhead
- *newEdge.SrcTableColumnIndex = *e.SrcTableColumnIndex
- }
- if e.DstTableColumnIndex != nil {
- newEdge.DstTableColumnIndex = new(int)
- newEdge.DstArrowhead = e.DstArrowhead
- *newEdge.DstTableColumnIndex = *e.DstTableColumnIndex
- }
- newEdge.Attributes = e.Attributes
- newEdge.References = e.References
- }
- updatedEdges := []*d2graph.Edge{}
- for _, e := range g.Edges {
- if _, is := toRemove[e]; is {
+func (c *compiler) validateKeys(obj *d2graph.Object, m *d2ir.Map) {
+ for _, f := range m.Fields {
+ if _, ok := d2graph.BoardKeywords[f.Name]; ok {
continue
}
- updatedEdges = append(updatedEdges, e)
+ c.validateKey(obj, f)
}
- g.Edges = updatedEdges
+}
- for i := 0; i < len(g.Objects); i++ {
- child := g.Objects[i]
- if strings.HasPrefix(child.AbsID(), absID+".") {
- g.Objects = append(g.Objects[:i], g.Objects[i+1:]...)
- i--
- delete(obj.Children, child.ID)
- for i, child2 := range obj.ChildrenArray {
- if child == child2 {
- obj.ChildrenArray = append(obj.ChildrenArray[:i], obj.ChildrenArray[i+1:]...)
- break
- }
+func (c *compiler) validateKey(obj *d2graph.Object, f *d2ir.Field) {
+ keyword := strings.ToLower(f.Name)
+ _, isReserved := d2graph.ReservedKeywords[keyword]
+ if isReserved {
+ switch obj.Attributes.Shape.Value {
+ case d2target.ShapeSQLTable, d2target.ShapeClass:
+ default:
+ if len(obj.Children) > 0 && (f.Name == "width" || f.Name == "height") {
+ c.errorf(f.LastPrimaryKey(), fmt.Sprintf("%s cannot be used on container: %s", f.Name, obj.AbsID()))
}
}
- }
-}
-func (c *compiler) validateKey(obj *d2graph.Object, m *d2ast.Map, mk *d2ast.Key) {
- ida, reserved, ok := c.compileFlatKey(mk.Key)
- if !ok {
+ switch obj.Attributes.Shape.Value {
+ case d2target.ShapeCircle, d2target.ShapeSquare:
+ checkEqual := (keyword == "width" && obj.Attributes.Height != nil) || (keyword == "height" && obj.Attributes.Width != nil)
+ if checkEqual && obj.Attributes.Width.Value != obj.Attributes.Height.Value {
+ c.errorf(f.LastPrimaryKey(), "width and height must be equal for %s shapes", obj.Attributes.Shape.Value)
+ }
+ }
+
+ switch f.Name {
+ case "style":
+ if obj.Attributes.Style.ThreeDee != nil {
+ if !strings.EqualFold(obj.Attributes.Shape.Value, d2target.ShapeSquare) && !strings.EqualFold(obj.Attributes.Shape.Value, d2target.ShapeRectangle) {
+ c.errorf(obj.Attributes.Style.ThreeDee.MapKey, `key "3d" can only be applied to squares and rectangles`)
+ }
+ }
+ if obj.Attributes.Style.DoubleBorder != nil {
+ if obj.Attributes.Shape.Value != "" && obj.Attributes.Shape.Value != d2target.ShapeSquare && obj.Attributes.Shape.Value != d2target.ShapeRectangle && obj.Attributes.Shape.Value != d2target.ShapeCircle && obj.Attributes.Shape.Value != d2target.ShapeOval {
+ c.errorf(obj.Attributes.Style.DoubleBorder.MapKey, `key "double-border" can only be applied to squares, rectangles, circles, ovals`)
+ }
+ }
+ case "shape":
+ if obj.Attributes.Shape.Value == d2target.ShapeImage && obj.Attributes.Icon == nil {
+ c.errorf(f.LastPrimaryKey(), `image shape must include an "icon" field`)
+ }
+
+ in := d2target.IsShape(obj.Attributes.Shape.Value)
+ _, arrowheadIn := d2target.Arrowheads[obj.Attributes.Shape.Value]
+ if !in && arrowheadIn {
+ c.errorf(f.LastPrimaryKey(), fmt.Sprintf(`invalid shape, can only set "%s" for arrowheads`, obj.Attributes.Shape.Value))
+ }
+ }
return
}
- switch strings.ToLower(obj.Attributes.Shape.Value) {
- case d2target.ShapeImage:
- if reserved == "" {
- c.errorf(mk.Range.Start, mk.Range.End, "image shapes cannot have children.")
- }
- case d2target.ShapeCircle, d2target.ShapeSquare:
- checkEqual := (reserved == "width" && obj.Attributes.Height != nil) ||
- (reserved == "height" && obj.Attributes.Width != nil)
-
- if checkEqual && obj.Attributes.Width.Value != obj.Attributes.Height.Value {
- c.errorf(mk.Range.Start, mk.Range.End, fmt.Sprintf("width and height must be equal for %s shapes", obj.Attributes.Shape.Value))
- }
- }
-
- in := d2target.IsShape(obj.Attributes.Shape.Value)
- _, arrowheadIn := d2target.Arrowheads[obj.Attributes.Shape.Value]
- if !in && arrowheadIn {
- c.errorf(mk.Range.Start, mk.Range.End, fmt.Sprintf(`invalid shape, can only set "%s" for arrowheads`, obj.Attributes.Shape.Value))
- }
-
- resolvedObj, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
- if err != nil {
- c.errorf(mk.Range.Start, mk.Range.End, err.Error())
- return
- }
- if resolvedObj != obj {
- obj = resolvedObj
- }
-
- parent := obj
- if len(resolvedIDA) > 0 {
- obj, _ = parent.HasChild(resolvedIDA)
- } else if obj.Parent == nil {
+ if obj.Attributes.Shape.Value == d2target.ShapeImage {
+ c.errorf(f.LastRef().AST(), "image shapes cannot have children.")
return
}
- switch strings.ToLower(obj.Attributes.Shape.Value) {
- case d2target.ShapeSQLTable, d2target.ShapeClass:
- default:
- if len(obj.Children) > 0 && !(len(obj.Children) == 1 && obj.ChildrenArray[0].ID == "style") && (reserved == "width" || reserved == "height") {
- c.errorf(mk.Range.Start, mk.Range.End, fmt.Sprintf("%s cannot be used on container: %s", reserved, obj.AbsID()))
- }
- }
-
- if len(mk.Edges) > 0 {
- return
- }
-
- if mk.Value.Map != nil {
- c.validateKeys(obj, mk.Value.Map)
- }
-}
-
-func (c *compiler) validateKeys(obj *d2graph.Object, m *d2ast.Map) {
- for _, n := range m.Nodes {
- if n.MapKey != nil && n.MapKey.Key != nil && len(n.MapKey.Edges) == 0 {
- c.validateKey(obj, m, n.MapKey)
- }
+ obj, ok := obj.HasChild([]string{f.Name})
+ if ok && f.Map() != nil {
+ c.validateKeys(obj, f.Map())
}
}
@@ -872,15 +618,15 @@ func (c *compiler) validateNear(g *d2graph.Graph) {
_, isKey := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
_, isConst := d2graph.NearConstants[d2graph.Key(obj.Attributes.NearKey)[0]]
if !isKey && !isConst {
- c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "near key %#v must be the absolute path to a shape or one of the following constants: %s", d2format.Format(obj.Attributes.NearKey), strings.Join(d2graph.NearConstantsArray, ", "))
+ c.errorf(obj.Attributes.NearKey, "near key %#v must be the absolute path to a shape or one of the following constants: %s", d2format.Format(obj.Attributes.NearKey), strings.Join(d2graph.NearConstantsArray, ", "))
continue
}
if !isKey && isConst && obj.Parent != g.Root {
- c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "constant near keys can only be set on root level shapes")
+ c.errorf(obj.Attributes.NearKey, "constant near keys can only be set on root level shapes")
continue
}
if !isKey && isConst && len(obj.ChildrenArray) > 0 {
- c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "constant near keys cannot be set on shapes with children")
+ c.errorf(obj.Attributes.NearKey, "constant near keys cannot be set on shapes with children")
continue
}
if !isKey && isConst {
@@ -892,7 +638,7 @@ func (c *compiler) validateNear(g *d2graph.Graph) {
}
}
if is {
- c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "constant near keys cannot be set on connected shapes")
+ c.errorf(obj.Attributes.NearKey, "constant near keys cannot be set on connected shapes")
continue
}
}
@@ -906,3 +652,137 @@ func init() {
FullToShortLanguageAliases[v] = k
}
}
+
+func d2graphIDA(irIDA []string) (ida []string) {
+ for _, el := range irIDA {
+ n := &d2ast.KeyPath{
+ Path: []*d2ast.StringBox{d2ast.MakeValueBox(d2ast.RawString(el, true)).StringBox()},
+ }
+ ida = append(ida, d2format.Format(n))
+ }
+ return ida
+}
+
+// Unused for now until shape: edge_group
+func (c *compiler) preprocessSeqDiagrams(m *d2ir.Map) {
+ for _, f := range m.Fields {
+ if f.Name == "shape" && f.Primary_.Value.ScalarString() == d2target.ShapeSequenceDiagram {
+ c.preprocessEdgeGroup(m, m)
+ return
+ }
+ if f.Map() != nil {
+ c.preprocessSeqDiagrams(f.Map())
+ }
+ }
+}
+
+func (c *compiler) preprocessEdgeGroup(seqDiagram, m *d2ir.Map) {
+ // Any child of a sequence diagram can be either an actor, edge group or a span.
+ // 1. Actors are shapes without edges inside them defined at the top level scope of a
+ // sequence diagram.
+ // 2. Spans are the children of actors. For our purposes we can ignore them.
+ // 3. Edge groups are defined as having at least one connection within them and also not
+ // being connected to anything. All direct children of an edge group are either edge
+ // groups or top level actors.
+
+ // Go through all the fields and hoist actors from edge groups while also processing
+ // the edge groups recursively.
+ for _, f := range m.Fields {
+ if isEdgeGroup(f) {
+ if f.Map() != nil {
+ c.preprocessEdgeGroup(seqDiagram, f.Map())
+ }
+ } else {
+ if m == seqDiagram {
+ // Ignore for root.
+ continue
+ }
+ hoistActor(seqDiagram, f)
+ }
+ }
+
+ // We need to adjust all edges recursively to point to actual actors instead.
+ for _, e := range m.Edges {
+ if isCrossEdgeGroupEdge(m, e) {
+ c.errorf(e.References[0].AST(), "illegal edge between edge groups")
+ continue
+ }
+
+ if m == seqDiagram {
+ // Root edges between actors directly do not require hoisting.
+ continue
+ }
+
+ srcParent := seqDiagram
+ for i, el := range e.ID.SrcPath {
+ f := srcParent.GetField(el)
+ if !isEdgeGroup(f) {
+ for j := 0; j < i+1; j++ {
+ e.ID.SrcPath = append([]string{"_"}, e.ID.SrcPath...)
+ e.ID.DstPath = append([]string{"_"}, e.ID.DstPath...)
+ }
+ break
+ }
+ srcParent = f.Map()
+ }
+ }
+}
+
+func hoistActor(seqDiagram *d2ir.Map, f *d2ir.Field) {
+ f2 := seqDiagram.GetField(f.Name)
+ if f2 == nil {
+ seqDiagram.Fields = append(seqDiagram.Fields, f.Copy(seqDiagram).(*d2ir.Field))
+ } else {
+ d2ir.OverlayField(f2, f)
+ d2ir.ParentMap(f).DeleteField(f.Name)
+ }
+}
+
+func isCrossEdgeGroupEdge(m *d2ir.Map, e *d2ir.Edge) bool {
+ srcParent := m
+ for _, el := range e.ID.SrcPath {
+ f := srcParent.GetField(el)
+ if f == nil {
+ // Hoisted already.
+ break
+ }
+ if isEdgeGroup(f) {
+ return true
+ }
+ srcParent = f.Map()
+ }
+
+ dstParent := m
+ for _, el := range e.ID.DstPath {
+ f := dstParent.GetField(el)
+ if f == nil {
+ // Hoisted already.
+ break
+ }
+ if isEdgeGroup(f) {
+ return true
+ }
+ dstParent = f.Map()
+ }
+
+ return false
+}
+
+func isEdgeGroup(n d2ir.Node) bool {
+ return n.Map().EdgeCountRecursive() > 0
+}
+
+func parentSeqDiagram(n d2ir.Node) *d2ir.Map {
+ for {
+ m := d2ir.ParentMap(n)
+ if m == nil {
+ return nil
+ }
+ for _, f := range m.Fields {
+ if f.Name == "shape" && f.Primary_.Value.ScalarString() == d2target.ShapeSequenceDiagram {
+ return m
+ }
+ }
+ n = m
+ }
+}
diff --git a/d2compiler/compile_test.go b/d2compiler/compile_test.go
index a432f078e..37e68632c 100644
--- a/d2compiler/compile_test.go
+++ b/d2compiler/compile_test.go
@@ -8,12 +8,13 @@ import (
tassert "github.com/stretchr/testify/assert"
+ "oss.terrastruct.com/util-go/assert"
+ "oss.terrastruct.com/util-go/diff"
+
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
- "oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
)
func TestCompile(t *testing.T) {
@@ -123,8 +124,7 @@ x: {
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/equal_dimensions_on_circle.d2:3:2: width and height must be equal for circle shapes
-d2/testdata/d2compiler/TestCompile/equal_dimensions_on_circle.d2:4:2: width and height must be equal for circle shapes
-`,
+d2/testdata/d2compiler/TestCompile/equal_dimensions_on_circle.d2:4:2: width and height must be equal for circle shapes`,
},
{
name: "single_dimension_on_circle",
@@ -207,8 +207,7 @@ d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:16:3: height c
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:25:3: width cannot be used on container: containers.oval container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:26:3: height cannot be used on container: containers.oval container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:36:3: width cannot be used on container: containers.hexagon container
-d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:37:3: height cannot be used on container: containers.hexagon container
-`,
+d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:37:3: height cannot be used on container: containers.hexagon container`,
},
{
name: "dimension_with_style",
@@ -241,8 +240,7 @@ d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:37:3: height c
}
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/shape_unquoted_hex.d2:3:10: missing value after colon
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/shape_unquoted_hex.d2:3:10: missing value after colon`,
},
{
name: "edge_unquoted_hex",
@@ -253,8 +251,7 @@ d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:37:3: height c
}
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/edge_unquoted_hex.d2:3:10: missing value after colon
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/edge_unquoted_hex.d2:3:10: missing value after colon`,
},
{
name: "blank_underscore",
@@ -264,8 +261,7 @@ d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:37:3: height c
_
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/blank_underscore.d2:3:3: invalid use of parent "_"
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/blank_underscore.d2:3:3: field key must contain more than underscores`,
},
{
name: "image_non_style",
@@ -276,8 +272,7 @@ d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:37:3: height c
name: y
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/image_non_style.d2:4:3: image shapes cannot have children.
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/image_non_style.d2:4:3: image shapes cannot have children.`,
},
{
name: "stroke-width",
@@ -302,8 +297,7 @@ d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:37:3: height c
style.stroke-width: -1
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/illegal-stroke-width.d2:2:23: expected "stroke-width" to be a number between 0 and 15
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/illegal-stroke-width.d2:2:23: expected "stroke-width" to be a number between 0 and 15`,
},
{
name: "underscore_parent_create",
@@ -340,8 +334,7 @@ x: {
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
tassert.Equal(t, "y", g.Objects[1].ID)
- tassert.Equal(t, g.Root.AbsID(), g.Objects[1].References[0].ScopeObj.AbsID())
- tassert.Equal(t, g.Objects[0].AbsID(), g.Objects[1].References[0].UnresolvedScopeObj.AbsID())
+ tassert.Equal(t, g.Objects[0].AbsID(), g.Objects[1].References[0].ScopeObj.AbsID())
},
},
{
@@ -456,8 +449,7 @@ x: {
text: `
_.x
`,
- expErr: `d2/testdata/d2compiler/TestCompile/underscore_parent_root.d2:2:1: parent "_" cannot be used in the root scope
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/underscore_parent_root.d2:2:1: invalid underscore: no parent`,
},
{
name: "underscore_parent_middle_path",
@@ -467,8 +459,7 @@ x: {
y._.z
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/underscore_parent_middle_path.d2:3:3: parent "_" can only be used in the beginning of paths, e.g. "_.x"
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/underscore_parent_middle_path.d2:3:5: parent "_" can only be used in the beginning of paths, e.g. "_.x"`,
},
{
name: "underscore_parent_sandwich_path",
@@ -478,8 +469,7 @@ x: {
_.z._
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/underscore_parent_sandwich_path.d2:3:3: parent "_" can only be used in the beginning of paths, e.g. "_.x"
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/underscore_parent_sandwich_path.d2:3:7: parent "_" can only be used in the beginning of paths, e.g. "_.x"`,
},
{
name: "underscore_edge",
@@ -996,8 +986,7 @@ x -> y: {
text: `x: {shape: triangle}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/object_arrowhead_shape.d2:1:5: invalid shape, can only set "triangle" for arrowheads
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/object_arrowhead_shape.d2:1:5: invalid shape, can only set "triangle" for arrowheads`,
},
{
name: "edge_flat_label_arrowhead",
@@ -1083,8 +1072,7 @@ x -> y: {
space -> stars
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/nested_edge.d2:1:1: edges cannot be nested within another edge
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/nested_edge.d2:2:3: cannot create edge inside edge`,
},
{
name: "shape_edge_style",
@@ -1094,8 +1082,7 @@ x: {
style.animated: true
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/shape_edge_style.d2:3:2: key "animated" can only be applied to edges
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/shape_edge_style.d2:3:2: key "animated" can only be applied to edges`,
},
{
name: "edge_chain_map",
@@ -1351,8 +1338,7 @@ x -> y: {
z
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/edge_map_non_reserved.d2:2:1: edge map keys must be reserved keywords
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/edge_map_non_reserved.d2:3:3: edge map keys must be reserved keywords`,
},
{
name: "url_link",
@@ -1397,8 +1383,7 @@ x -> y: {
text: `x.near: txop-center
`,
- expErr: `d2/testdata/d2compiler/TestCompile/near_bad_constant.d2:1:1: near key "txop-center" must be the absolute path to a shape or one of the following constants: top-left, top-center, top-right, center-left, center-right, bottom-left, bottom-center, bottom-right
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/near_bad_constant.d2:1:9: near key "txop-center" must be the absolute path to a shape or one of the following constants: top-left, top-center, top-right, center-left, center-right, bottom-left, bottom-center, bottom-right`,
},
{
name: "near_bad_container",
@@ -1408,8 +1393,7 @@ x -> y: {
y
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/near_bad_container.d2:1:1: constant near keys cannot be set on shapes with children
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/near_bad_container.d2:2:9: constant near keys cannot be set on shapes with children`,
},
{
name: "near_bad_connected",
@@ -1419,16 +1403,14 @@ x -> y: {
}
x -> y
`,
- expErr: `d2/testdata/d2compiler/TestCompile/near_bad_connected.d2:1:1: constant near keys cannot be set on connected shapes
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/near_bad_connected.d2:2:9: constant near keys cannot be set on connected shapes`,
},
{
name: "nested_near_constant",
text: `x.y.near: top-center
`,
- expErr: `d2/testdata/d2compiler/TestCompile/nested_near_constant.d2:1:1: constant near keys can only be set on root level shapes
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/nested_near_constant.d2:1:11: constant near keys can only be set on root level shapes`,
},
{
name: "reserved_icon_near_style",
@@ -1474,17 +1456,14 @@ y
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:3:9: bad icon url "::????:::%%orange": parse "::????:::%%orange": missing protocol scheme
-d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:4:18: expected "opacity" to be a number between 0.0 and 1.0
d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:5:18: expected "opacity" to be a number between 0.0 and 1.0
-d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:1:1: near key "y" must be the absolute path to a shape or one of the following constants: top-left, top-center, top-right, center-left, center-right, bottom-left, bottom-center, bottom-right
-`,
+d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:2:9: near key "y" must be the absolute path to a shape or one of the following constants: top-left, top-center, top-right, center-left, center-right, bottom-left, bottom-center, bottom-right`,
},
{
name: "errors/missing_shape_icon",
- text: `x.shape: image`,
- expErr: `d2/testdata/d2compiler/TestCompile/errors/missing_shape_icon.d2:1:1: image shape must include an "icon" field
-`,
+ text: `x.shape: image`,
+ expErr: `d2/testdata/d2compiler/TestCompile/errors/missing_shape_icon.d2:1:1: image shape must include an "icon" field`,
},
{
name: "edge_in_column",
@@ -1500,8 +1479,7 @@ d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:1:1: near key "
text: `x: {style.opacity: 0.4}
y -> x.style
`,
- expErr: `d2/testdata/d2compiler/TestCompile/edge_to_style.d2:2:1: cannot connect to reserved keyword
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/edge_to_style.d2:2:8: reserved keywords are prohibited in edges`,
},
{
name: "escaped_id",
@@ -1581,7 +1559,7 @@ b`, g.Objects[0].Attributes.Label.Value)
GetType(): string
style: {
opacity: 0.4
- color: blue
+ font-color: blue
}
}
`,
@@ -1680,10 +1658,9 @@ x.y -> a.b: {
{
name: "3d_oval",
- text: `SVP1.style.shape: oval
+ text: `SVP1.shape: oval
SVP1.style.3d: true`,
- expErr: `d2/testdata/d2compiler/TestCompile/3d_oval.d2:2:1: key "3d" can only be applied to squares and rectangles
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/3d_oval.d2:2:1: key "3d" can only be applied to squares and rectangles`,
}, {
name: "edge_column_index",
text: `src: {
@@ -1740,8 +1717,7 @@ dst.id <-> src.dst_id
}
b -> x.a
`,
- expErr: `d2/testdata/d2compiler/TestCompile/leaky_sequence.d2:5:1: connections within sequence diagrams can connect only to other objects within the same sequence diagram
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/leaky_sequence.d2:5:1: connections within sequence diagrams can connect only to other objects within the same sequence diagram`,
},
{
name: "sequence_scoping",
@@ -1775,6 +1751,35 @@ choo: {
tassert.Equal(t, 3, len(g.Root.ChildrenArray))
},
},
+ {
+ name: "sequence_container",
+
+ text: `shape: sequence_diagram
+x.y.q -> j.y.p
+ok: {
+ x.y.q -> j.y.p
+}
+`,
+ assertions: func(t *testing.T, g *d2graph.Graph) {
+ tassert.Equal(t, 7, len(g.Objects))
+ tassert.Equal(t, 3, len(g.Root.ChildrenArray))
+ },
+ },
+ {
+ name: "sequence_container_2",
+
+ text: `shape: sequence_diagram
+x.y.q
+ok: {
+ x.y.q -> j.y.p
+ meow
+}
+`,
+ assertions: func(t *testing.T, g *d2graph.Graph) {
+ tassert.Equal(t, 8, len(g.Objects))
+ tassert.Equal(t, 2, len(g.Root.ChildrenArray))
+ },
+ },
{
name: "root_direction",
@@ -1818,8 +1823,7 @@ choo: {
text: `x: {
direction: diagonal
}`,
- expErr: `d2/testdata/d2compiler/TestCompile/invalid_direction.d2:2:14: direction must be one of up, down, right, left, got "diagonal"
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/invalid_direction.d2:2:14: direction must be one of up, down, right, left, got "diagonal"`,
},
{
name: "self-referencing",
@@ -1868,8 +1872,7 @@ choo: {
test_id: varchar(64) {constraint: [primary_key, foreign_key]}
}
`,
- expErr: `d2/testdata/d2compiler/TestCompile/sql-panic.d2:3:27: constraint value must be a string
-`,
+ expErr: `d2/testdata/d2compiler/TestCompile/sql-panic.d2:3:27: reserved field constraint does not accept composite`,
},
{
name: "wrong_column_index",
@@ -1939,3 +1942,167 @@ Chinchillas_Collectibles.chinchilla -> Chinchillas.id`,
})
}
}
+
+func TestCompile2(t *testing.T) {
+ t.Parallel()
+
+ t.Run("boards", testBoards)
+ t.Run("seqdiagrams", testSeqDiagrams)
+}
+
+func testBoards(t *testing.T) {
+ t.Parallel()
+
+ tca := []struct {
+ name string
+ run func(t *testing.T)
+ }{
+ {
+ name: "root",
+ run: func(t *testing.T) {
+ g := assertCompile(t, `base
+
+layers: {
+ one: {
+ santa
+ }
+ two: {
+ clause
+ }
+}
+`, "")
+ assert.JSON(t, 2, len(g.Layers))
+ assert.JSON(t, "one", g.Layers[0].Name)
+ assert.JSON(t, "two", g.Layers[1].Name)
+ },
+ },
+ {
+ name: "recursive",
+ run: func(t *testing.T) {
+ g := assertCompile(t, `base
+
+layers: {
+ one: {
+ santa
+ }
+ two: {
+ clause
+ steps: {
+ seinfeld: {
+ reindeer
+ }
+ missoula: {
+ montana
+ }
+ }
+ }
+}
+`, "")
+ assert.Equal(t, 2, len(g.Layers))
+ assert.Equal(t, "one", g.Layers[0].Name)
+ assert.Equal(t, "two", g.Layers[1].Name)
+ assert.Equal(t, 2, len(g.Layers[1].Steps))
+ },
+ },
+ {
+ name: "errs/duplicate_board",
+ run: func(t *testing.T) {
+ assertCompile(t, `base
+
+layers: {
+ one: {
+ santa
+ }
+}
+steps: {
+ one: {
+ clause
+ }
+}
+`, `d2/testdata/d2compiler/TestCompile2/boards/errs/duplicate_board.d2:9:2: board name one already used by another board`)
+ },
+ },
+ }
+
+ for _, tc := range tca {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ tc.run(t)
+ })
+ }
+}
+
+func testSeqDiagrams(t *testing.T) {
+ t.Parallel()
+
+ t.Run("errs", func(t *testing.T) {
+ t.Parallel()
+
+ tca := []struct {
+ name string
+ skip bool
+ run func(t *testing.T)
+ }{
+ {
+ name: "sequence_diagram_edge_between_edge_groups",
+ // New sequence diagram scoping implementation is disabled.
+ skip: true,
+ run: func(t *testing.T) {
+ assertCompile(t, `
+Office chatter: {
+ shape: sequence_diagram
+ alice: Alice
+ bob: Bobby
+ awkward small talk: {
+ alice -> bob: uhm, hi
+ bob -> alice: oh, hello
+ icebreaker attempt: {
+ alice -> bob: what did you have for lunch?
+ }
+ unfortunate outcome: {
+ bob -> alice: that's personal
+ }
+ }
+ awkward small talk.icebreaker attempt.alice -> awkward small talk.unfortunate outcome.bob
+}
+`, "d2/testdata/d2compiler/TestCompile2/seqdiagrams/errs/sequence_diagram_edge_between_edge_groups.d2:16:3: edges between edge groups are not allowed")
+ },
+ },
+ }
+
+ for _, tc := range tca {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ if tc.skip {
+ t.SkipNow()
+ }
+ tc.run(t)
+ })
+ }
+ })
+}
+
+func assertCompile(t *testing.T, text string, expErr string) *d2graph.Graph {
+ d2Path := fmt.Sprintf("d2/testdata/d2compiler/%v.d2", t.Name())
+ g, err := d2compiler.Compile(d2Path, strings.NewReader(text), nil)
+ if expErr != "" {
+ assert.Error(t, err)
+ assert.ErrorString(t, err, expErr)
+ } else {
+ assert.Success(t, err)
+ }
+
+ got := struct {
+ Graph *d2graph.Graph `json:"graph"`
+ Err error `json:"err"`
+ }{
+ Graph: g,
+ Err: err,
+ }
+
+ err = diff.TestdataJSON(filepath.Join("..", "testdata", "d2compiler", t.Name()), got)
+ assert.Success(t, err)
+ return g
+}
diff --git a/d2exporter/export.go b/d2exporter/export.go
index 8a636759e..5a57f0881 100644
--- a/d2exporter/export.go
+++ b/d2exporter/export.go
@@ -4,18 +4,20 @@ import (
"context"
"strconv"
+ "oss.terrastruct.com/util-go/go2"
+
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
- "oss.terrastruct.com/util-go/go2"
)
func Export(ctx context.Context, g *d2graph.Graph, themeID int64, fontFamily *d2fonts.FontFamily) (*d2target.Diagram, error) {
theme := d2themescatalog.Find(themeID)
diagram := d2target.NewDiagram()
+ diagram.Name = g.Name
if fontFamily == nil {
fontFamily = go2.Pointer(d2fonts.SourceSansPro)
}
@@ -141,6 +143,7 @@ func toShape(obj *d2graph.Object, theme *d2themes.Theme) d2target.Shape {
}
shape.Label = text.Text
shape.LabelWidth = text.Dimensions.Width
+
shape.LabelHeight = text.Dimensions.Height
if obj.LabelPosition != nil {
shape.LabelPosition = *obj.LabelPosition
diff --git a/d2format/format.go b/d2format/format.go
index a45d55f63..e979a6fea 100644
--- a/d2format/format.go
+++ b/d2format/format.go
@@ -397,3 +397,15 @@ func (p *printer) edgeIndex(ei *d2ast.EdgeIndex) {
}
p.sb.WriteByte(']')
}
+
+func KeyPath(kp *d2ast.KeyPath) (ida []string) {
+ for _, s := range kp.Path {
+ // We format each string of the key to ensure the resulting strings can be parsed
+ // correctly.
+ n := &d2ast.KeyPath{
+ Path: []*d2ast.StringBox{d2ast.MakeValueBox(d2ast.RawString(s.Unbox().ScalarString(), true)).StringBox()},
+ }
+ ida = append(ida, Format(n))
+ }
+ return ida
+}
diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go
index e7f9035e8..0b95146e9 100644
--- a/d2graph/d2graph.go
+++ b/d2graph/d2graph.go
@@ -5,6 +5,7 @@ import (
"fmt"
"math"
"net/url"
+ "sort"
"strconv"
"strings"
@@ -18,31 +19,34 @@ import (
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/geo"
+ "oss.terrastruct.com/d2/lib/shape"
"oss.terrastruct.com/d2/lib/textmeasure"
)
const INNER_LABEL_PADDING int = 5
-const DEFAULT_SHAPE_PADDING = 100.
+const DEFAULT_SHAPE_SIZE = 100.
+const MIN_SHAPE_SIZE = 5
-// TODO: Refactor with a light abstract layer on top of AST implementing scenarios,
-// variables, imports, substitutions and then a final set of structures representing
-// a final graph.
type Graph struct {
- AST *d2ast.Map `json:"ast"`
+ Name string `json:"name"`
+ AST *d2ast.Map `json:"ast"`
Root *Object `json:"root"`
Edges []*Edge `json:"edges"`
Objects []*Object `json:"objects"`
+
+ Layers []*Graph `json:"layers,omitempty"`
+ Scenarios []*Graph `json:"scenarios,omitempty"`
+ Steps []*Graph `json:"steps,omitempty"`
}
-func NewGraph(ast *d2ast.Map) *Graph {
- d := &Graph{
- AST: ast,
- }
+func NewGraph() *Graph {
+ d := &Graph{}
d.Root = &Object{
- Graph: d,
- Parent: nil,
- Children: make(map[string]*Object),
+ Graph: d,
+ Parent: nil,
+ Children: make(map[string]*Object),
+ Attributes: &Attributes{},
}
return d
}
@@ -82,7 +86,7 @@ type Object struct {
Children map[string]*Object `json:"-"`
ChildrenArray []*Object `json:"-"`
- Attributes Attributes `json:"attributes"`
+ Attributes *Attributes `json:"attributes,omitempty"`
ZIndex int `json:"zIndex"`
}
@@ -105,7 +109,8 @@ type Attributes struct {
// TODO: default to ShapeRectangle instead of empty string
Shape Scalar `json:"shape"`
- Direction Scalar `json:"direction"`
+ Direction Scalar `json:"direction"`
+ Constraint Scalar `json:"constraint"`
}
// TODO references at the root scope should have their Scope set to root graph AST
@@ -116,9 +121,7 @@ type Reference struct {
MapKey *d2ast.Key `json:"-"`
MapKeyEdgeIndex int `json:"map_key_edge_index"`
Scope *d2ast.Map `json:"-"`
- // The ScopeObj and UnresolvedScopeObj are the same except when the key contains underscores
- ScopeObj *Object `json:"-"`
- UnresolvedScopeObj *Object `json:"-"`
+ ScopeObj *Object `json:"-"`
}
func (r Reference) MapKeyEdgeDest() bool {
@@ -500,10 +503,13 @@ func (obj *Object) newObject(id string) *Object {
child := &Object{
ID: id,
IDVal: idval,
- Attributes: Attributes{
+ Attributes: &Attributes{
Label: Scalar{
Value: idval,
},
+ Shape: Scalar{
+ Value: d2target.ShapeRectangle,
+ },
},
Graph: obj.Graph,
@@ -523,6 +529,9 @@ func (obj *Object) newObject(id string) *Object {
}
func (obj *Object) HasChild(ids []string) (*Object, bool) {
+ if len(ids) == 0 {
+ return obj, true
+ }
if len(ids) == 1 && ids[0] != "style" {
_, ok := ReservedKeywords[ids[0]]
if ok {
@@ -557,6 +566,7 @@ func (obj *Object) HasEdge(mk *d2ast.Key) (*Edge, bool) {
return nil, false
}
+// TODO: remove once not used anywhere
func ResolveUnderscoreKey(ida []string, obj *Object) (resolvedObj *Object, resolvedIDA []string, _ error) {
if len(ida) > 0 && !obj.IsSequenceDiagram() {
objSD := obj.OuterSequenceDiagram()
@@ -637,34 +647,71 @@ func (obj *Object) FindEdges(mk *d2ast.Key) ([]*Edge, bool) {
return ea, true
}
+func (obj *Object) ensureChildEdge(ida []string) *Object {
+ for i := range ida {
+ switch obj.Attributes.Shape.Value {
+ case d2target.ShapeClass, d2target.ShapeSQLTable:
+ // This will only be called for connecting edges where we want to truncate to the
+ // container.
+ return obj
+ default:
+ obj = obj.EnsureChild(ida[i : i+1])
+ }
+ }
+ return obj
+}
+
// EnsureChild grabs the child by ids or creates it if it does not exist including all
// intermediate nodes.
-func (obj *Object) EnsureChild(ids []string) *Object {
- _, is := ReservedKeywordHolders[ids[0]]
- if len(ids) == 1 && !is {
- _, ok := ReservedKeywords[ids[0]]
+func (obj *Object) EnsureChild(ida []string) *Object {
+ seq := obj.OuterSequenceDiagram()
+ if seq != nil {
+ for _, c := range seq.ChildrenArray {
+ if c.ID == ida[0] {
+ if obj.ID == ida[0] {
+ // In cases of a.a where EnsureChild is called on the parent a, the second a should
+ // be created as a child of a and not as a child of the diagram. This is super
+ // unfortunate code but alas.
+ break
+ }
+ obj = seq
+ break
+ }
+ }
+ }
+
+ if len(ida) == 0 {
+ return obj
+ }
+
+ _, is := ReservedKeywordHolders[ida[0]]
+ if len(ida) == 1 && !is {
+ _, ok := ReservedKeywords[ida[0]]
if ok {
return obj
}
}
- id := ids[0]
- ids = ids[1:]
+ id := ida[0]
+ ida = ida[1:]
+
+ if id == "_" {
+ return obj.Parent.EnsureChild(ida)
+ }
child, ok := obj.Children[strings.ToLower(id)]
if !ok {
child = obj.newObject(id)
}
- if len(ids) >= 1 {
- return child.EnsureChild(ids)
+ if len(ida) >= 1 {
+ return child.EnsureChild(ida)
}
return child
}
func (obj *Object) AppendReferences(ida []string, ref Reference, unresolvedObj *Object) {
- ref.ScopeObj = obj
- ref.UnresolvedScopeObj = unresolvedObj
+ ref.ScopeObj = unresolvedObj
numUnderscores := 0
for i := range ida {
if ida[i] == "_" {
@@ -730,9 +777,14 @@ func (obj *Object) GetLabelSize(mtexts []*d2target.MText, ruler *textmeasure.Rul
return dims, nil
}
-func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.Ruler, fontFamily *d2fonts.FontFamily, labelDims d2target.TextDimensions) (*d2target.TextDimensions, error) {
+func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.Ruler, fontFamily *d2fonts.FontFamily, labelDims d2target.TextDimensions, withLabelPadding bool) (*d2target.TextDimensions, error) {
dims := d2target.TextDimensions{}
+ if withLabelPadding {
+ labelDims.Width += INNER_LABEL_PADDING
+ labelDims.Height += INNER_LABEL_PADDING
+ }
+
switch strings.ToLower(obj.Attributes.Shape.Value) {
default:
return d2target.NewTextDimensions(labelDims.Width, labelDims.Height), nil
@@ -748,22 +800,22 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
if fdims == nil {
return nil, fmt.Errorf("dimensions for class field %#v not found", f.Text())
}
- lineWidth := fdims.Width
- if maxWidth < lineWidth {
- maxWidth = lineWidth
- }
+ maxWidth = go2.Max(maxWidth, fdims.Width)
}
for _, m := range obj.Class.Methods {
mdims := GetTextDimensions(mtexts, ruler, m.Text(), go2.Pointer(d2fonts.SourceCodePro))
if mdims == nil {
return nil, fmt.Errorf("dimensions for class method %#v not found", m.Text())
}
- lineWidth := mdims.Width
- if maxWidth < lineWidth {
- maxWidth = lineWidth
- }
+ maxWidth = go2.Max(maxWidth, mdims.Width)
}
- dims.Width = maxWidth
+ // ┌─PrefixWidth ┌─CenterPadding
+ // ┌─┬─┬───────┬──────┬───┬──┐
+ // │ + getJobs() Job[] │
+ // └─┴─┴───────┴──────┴───┴──┘
+ // └─PrefixPadding └──TypePadding
+ // ├───────┤ + ├───┤ = maxWidth
+ dims.Width = d2target.PrefixPadding + d2target.PrefixWidth + maxWidth + d2target.CenterPadding + d2target.TypePadding
// All rows should be the same height
var anyRowText *d2target.MText
@@ -773,11 +825,10 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
anyRowText = obj.Class.Methods[0].Text()
}
if anyRowText != nil {
- // 10px of padding top and bottom so text doesn't look squished
- rowHeight := GetTextDimensions(mtexts, ruler, anyRowText, go2.Pointer(d2fonts.SourceCodePro)).Height + 20
+ rowHeight := GetTextDimensions(mtexts, ruler, anyRowText, go2.Pointer(d2fonts.SourceCodePro)).Height + d2target.VerticalPadding
dims.Height = rowHeight * (len(obj.Class.Fields) + len(obj.Class.Methods) + 2)
} else {
- dims.Height = go2.Max(12, labelDims.Height)
+ dims.Height = 2*go2.Max(12, labelDims.Height) + d2target.VerticalPadding
}
case d2target.ShapeSQLTable:
@@ -796,9 +847,7 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
}
c.Name.LabelWidth = nameDims.Width
c.Name.LabelHeight = nameDims.Height
- if maxNameWidth < nameDims.Width {
- maxNameWidth = nameDims.Width
- }
+ maxNameWidth = go2.Max(maxNameWidth, nameDims.Width)
typeDims := GetTextDimensions(mtexts, ruler, ctexts[1], fontFamily)
if typeDims == nil {
@@ -809,6 +858,7 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
if maxTypeWidth < typeDims.Width {
maxTypeWidth = typeDims.Width
}
+ maxTypeWidth = go2.Max(maxTypeWidth, typeDims.Width)
if c.Constraint != "" {
// covers UNQ constraint with padding
@@ -826,21 +876,6 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
return &dims, nil
}
-func (obj *Object) GetPadding() (x, y float64) {
- switch strings.ToLower(obj.Attributes.Shape.Value) {
- case d2target.ShapeImage,
- d2target.ShapeSQLTable,
- d2target.ShapeText,
- d2target.ShapeCode:
- return 0., 0.
- case d2target.ShapeClass:
- // TODO fix class row width measurements (see SQL table)
- return 100., 0.
- default:
- return DEFAULT_SHAPE_PADDING, DEFAULT_SHAPE_PADDING
- }
-}
-
type Edge struct {
Index int `json:"index"`
@@ -866,7 +901,7 @@ type Edge struct {
DstArrowhead *Attributes `json:"dstArrowhead,omitempty"`
References []EdgeReference `json:"references,omitempty"`
- Attributes Attributes `json:"attributes"`
+ Attributes *Attributes `json:"attributes,omitempty"`
ZIndex int `json:"zIndex"`
}
@@ -938,15 +973,6 @@ func (e *Edge) AbsID() string {
}
func (obj *Object) Connect(srcID, dstID []string, srcArrow, dstArrow bool, label string) (*Edge, error) {
- srcObj, srcID, err := ResolveUnderscoreKey(srcID, obj)
- if err != nil {
- return nil, err
- }
- dstObj, dstID, err := ResolveUnderscoreKey(dstID, obj)
- if err != nil {
- return nil, err
- }
-
for _, id := range [][]string{srcID, dstID} {
for _, p := range id {
if _, ok := ReservedKeywords[p]; ok {
@@ -955,15 +981,15 @@ func (obj *Object) Connect(srcID, dstID []string, srcArrow, dstArrow bool, label
}
}
- src := srcObj.EnsureChild(srcID)
- dst := dstObj.EnsureChild(dstID)
+ src := obj.ensureChildEdge(srcID)
+ dst := obj.ensureChildEdge(dstID)
if src.OuterSequenceDiagram() != dst.OuterSequenceDiagram() {
return nil, errors.New("connections within sequence diagrams can connect only to other objects within the same sequence diagram")
}
- edge := &Edge{
- Attributes: Attributes{
+ e := &Edge{
+ Attributes: &Attributes{
Label: Scalar{
Value: label,
},
@@ -973,10 +999,47 @@ func (obj *Object) Connect(srcID, dstID []string, srcArrow, dstArrow bool, label
Dst: dst,
DstArrow: dstArrow,
}
- edge.initIndex()
+ e.initIndex()
- obj.Graph.Edges = append(obj.Graph.Edges, edge)
- return edge, nil
+ addSQLTableColumnIndices(e, srcID, dstID, obj, src, dst)
+
+ obj.Graph.Edges = append(obj.Graph.Edges, e)
+ return e, nil
+}
+
+func addSQLTableColumnIndices(e *Edge, srcID, dstID []string, obj, src, dst *Object) {
+ if src.Attributes.Shape.Value == d2target.ShapeSQLTable {
+ if src == dst {
+ // Ignore edge to column inside table.
+ return
+ }
+ objAbsID := obj.AbsIDArray()
+ srcAbsID := src.AbsIDArray()
+ if len(objAbsID)+len(srcID) > len(srcAbsID) {
+ for i, d2col := range src.SQLTable.Columns {
+ if d2col.Name.Label == srcID[len(srcID)-1] {
+ d2col.Reference = dst.AbsID()
+ e.SrcTableColumnIndex = new(int)
+ *e.SrcTableColumnIndex = i
+ break
+ }
+ }
+ }
+ }
+ if dst.Attributes.Shape.Value == d2target.ShapeSQLTable {
+ objAbsID := obj.AbsIDArray()
+ dstAbsID := dst.AbsIDArray()
+ if len(objAbsID)+len(dstID) > len(dstAbsID) {
+ for i, d2col := range dst.SQLTable.Columns {
+ if d2col.Name.Label == dstID[len(dstID)-1] {
+ d2col.Reference = dst.AbsID()
+ e.DstTableColumnIndex = new(int)
+ *e.DstTableColumnIndex = i
+ break
+ }
+ }
+ }
+ }
}
// TODO: Treat undirectional/bidirectional edge here and in HasEdge flipped. Same with
@@ -1108,29 +1171,41 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler
desiredHeight, _ = strconv.Atoi(obj.Attributes.Height.Value)
}
+ dslShape := strings.ToLower(obj.Attributes.Shape.Value)
+
if obj.Attributes.Label.Value == "" &&
- obj.Attributes.Shape.Value != d2target.ShapeImage &&
- obj.Attributes.Shape.Value != d2target.ShapeSQLTable &&
- obj.Attributes.Shape.Value != d2target.ShapeClass {
- obj.Width = DEFAULT_SHAPE_PADDING
- obj.Height = DEFAULT_SHAPE_PADDING
- if desiredWidth != 0 {
- obj.Width = float64(desiredWidth)
- }
- if desiredHeight != 0 {
- obj.Height = float64(desiredHeight)
+ dslShape != d2target.ShapeImage &&
+ dslShape != d2target.ShapeSQLTable &&
+ dslShape != d2target.ShapeClass {
+
+ if dslShape == d2target.ShapeCircle || dslShape == d2target.ShapeSquare {
+ sideLength := DEFAULT_SHAPE_SIZE
+ if desiredWidth != 0 || desiredHeight != 0 {
+ sideLength = float64(go2.Max(desiredWidth, desiredHeight))
+ }
+ obj.Width = sideLength
+ obj.Height = sideLength
+ } else {
+ obj.Width = DEFAULT_SHAPE_SIZE
+ obj.Height = DEFAULT_SHAPE_SIZE
+ if desiredWidth != 0 {
+ obj.Width = float64(desiredWidth)
+ }
+ if desiredHeight != 0 {
+ obj.Height = float64(desiredHeight)
+ }
}
+
continue
}
- shapeType := strings.ToLower(obj.Attributes.Shape.Value)
-
labelDims, err := obj.GetLabelSize(mtexts, ruler, fontFamily)
if err != nil {
return err
}
+ obj.LabelDimensions = *labelDims
- switch shapeType {
+ switch dslShape {
case d2target.ShapeText, d2target.ShapeClass, d2target.ShapeSQLTable, d2target.ShapeCode:
// no labels
default:
@@ -1140,39 +1215,65 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler
}
}
- if shapeType != d2target.ShapeText && obj.Attributes.Label.Value != "" {
- labelDims.Width += INNER_LABEL_PADDING
- labelDims.Height += INNER_LABEL_PADDING
- }
- obj.LabelDimensions = *labelDims
-
- defaultDims, err := obj.GetDefaultSize(mtexts, ruler, fontFamily, *labelDims)
+ // if there is a desired width or height, fit to content box without inner label padding for smallest minimum size
+ withInnerLabelPadding := desiredWidth == 0 && desiredHeight == 0 &&
+ dslShape != d2target.ShapeText && obj.Attributes.Label.Value != ""
+ defaultDims, err := obj.GetDefaultSize(mtexts, ruler, fontFamily, *labelDims, withInnerLabelPadding)
if err != nil {
return err
}
- obj.Width = float64(go2.Max(defaultDims.Width, desiredWidth))
- obj.Height = float64(go2.Max(defaultDims.Height, desiredHeight))
-
- paddingX, paddingY := obj.GetPadding()
-
- switch shapeType {
- case d2target.ShapeSquare, d2target.ShapeCircle:
- if desiredWidth != 0 || desiredHeight != 0 {
- paddingX = 0.
- paddingY = 0.
- }
-
- sideLength := math.Max(obj.Width+paddingX, obj.Height+paddingY)
- obj.Width = sideLength
- obj.Height = sideLength
-
- default:
+ if dslShape == d2target.ShapeImage {
if desiredWidth == 0 {
- obj.Width += float64(paddingX)
+ desiredWidth = defaultDims.Width
}
if desiredHeight == 0 {
- obj.Height += float64(paddingY)
+ desiredHeight = defaultDims.Height
+ }
+ obj.Width = float64(go2.Max(MIN_SHAPE_SIZE, desiredWidth))
+ obj.Height = float64(go2.Max(MIN_SHAPE_SIZE, desiredHeight))
+ // images don't need further processing
+ continue
+ }
+
+ contentBox := geo.NewBox(geo.NewPoint(0, 0), float64(defaultDims.Width), float64(defaultDims.Height))
+ shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[dslShape]
+ s := shape.NewShape(shapeType, contentBox)
+
+ paddingX, paddingY := s.GetDefaultPadding()
+ if desiredWidth != 0 || desiredHeight != 0 {
+ paddingX = 0.
+ paddingY = 0.
+ } else {
+ // give shapes with icons extra padding to fit their label
+ if obj.Attributes.Icon != nil {
+ labelHeight := float64(labelDims.Height + INNER_LABEL_PADDING)
+ // Evenly pad enough to fit label above icon
+ paddingX += labelHeight
+ paddingY += labelHeight
+ }
+ switch shapeType {
+ case shape.TABLE_TYPE, shape.CLASS_TYPE, shape.CODE_TYPE, shape.IMAGE_TYPE:
+ default:
+ if obj.Attributes.Link != "" {
+ paddingX += 32
+ }
+ if obj.Attributes.Tooltip != "" {
+ paddingX += 32
+ }
+ }
+ }
+
+ fitWidth, fitHeight := s.GetDimensionsToFit(contentBox.Width, contentBox.Height, paddingX, paddingY)
+ obj.Width = math.Max(float64(desiredWidth), fitWidth)
+ obj.Height = math.Max(float64(desiredHeight), fitHeight)
+ if s.AspectRatio1() {
+ sideLength := math.Max(obj.Width, obj.Height)
+ obj.Width = sideLength
+ obj.Height = sideLength
+ } else if desiredHeight == 0 || desiredWidth == 0 {
+ if s.GetType() == shape.PERSON_TYPE {
+ obj.Width, obj.Height = shape.LimitAR(obj.Width, obj.Height, shape.PERSON_AR_LIMIT)
}
}
}
@@ -1252,19 +1353,17 @@ func (g *Graph) Texts() []*d2target.MText {
}
func Key(k *d2ast.KeyPath) []string {
- var ids []string
- for _, s := range k.Path {
- // We format each string of the key to ensure the resulting strings can be parsed
- // correctly.
- n := &d2ast.KeyPath{
- Path: []*d2ast.StringBox{d2ast.MakeValueBox(d2ast.RawString(s.Unbox().ScalarString(), true)).StringBox()},
- }
- ids = append(ids, d2format.Format(n))
- }
- return ids
+ return d2format.KeyPath(k)
}
-var ReservedKeywords = map[string]struct{}{
+// All reserved keywords. See init below.
+var ReservedKeywords map[string]struct{}
+
+// All reserved keywords not including style keywords.
+var ReservedKeywords2 map[string]struct{}
+
+// Non Style/Holder keywords.
+var SimpleReservedKeywords = map[string]struct{}{
"label": {},
"desc": {},
"shape": {},
@@ -1331,15 +1430,88 @@ var NearConstantsArray = []string{
}
var NearConstants map[string]struct{}
+// BoardKeywords contains the keywords that create new boards.
+var BoardKeywords = map[string]struct{}{
+ "layers": {},
+ "scenarios": {},
+ "steps": {},
+}
+
func init() {
+ ReservedKeywords = make(map[string]struct{})
+ for k, v := range SimpleReservedKeywords {
+ ReservedKeywords[k] = v
+ }
for k, v := range StyleKeywords {
ReservedKeywords[k] = v
}
for k, v := range ReservedKeywordHolders {
ReservedKeywords[k] = v
}
+ for k, v := range BoardKeywords {
+ ReservedKeywords[k] = v
+ }
+
+ ReservedKeywords2 = make(map[string]struct{})
+ for k, v := range SimpleReservedKeywords {
+ ReservedKeywords2[k] = v
+ }
+ for k, v := range ReservedKeywordHolders {
+ ReservedKeywords2[k] = v
+ }
+ for k, v := range BoardKeywords {
+ ReservedKeywords2[k] = v
+ }
+
NearConstants = make(map[string]struct{}, len(NearConstantsArray))
for _, k := range NearConstantsArray {
NearConstants[k] = struct{}{}
}
}
+
+func (g *Graph) GetBoard(name string) *Graph {
+ for _, l := range g.Layers {
+ if l.Name == name {
+ return l
+ }
+ }
+ for _, l := range g.Scenarios {
+ if l.Name == name {
+ return l
+ }
+ }
+ for _, l := range g.Steps {
+ if l.Name == name {
+ return l
+ }
+ }
+ return nil
+}
+
+func (g *Graph) SortObjectsByAST() {
+ objects := append([]*Object(nil), g.Objects...)
+ sort.Slice(objects, func(i, j int) bool {
+ o1 := objects[i]
+ o2 := objects[j]
+ if len(o1.References) == 0 || len(o2.References) == 0 {
+ return i < j
+ }
+ r1 := o1.References[0]
+ r2 := o2.References[0]
+ return r1.Key.Path[r1.KeyPathIndex].Unbox().GetRange().Before(r2.Key.Path[r2.KeyPathIndex].Unbox().GetRange())
+ })
+ g.Objects = objects
+}
+
+func (g *Graph) SortEdgesByAST() {
+ edges := append([]*Edge(nil), g.Edges...)
+ sort.Slice(edges, func(i, j int) bool {
+ e1 := edges[i]
+ e2 := edges[j]
+ if len(e1.References) == 0 || len(e2.References) == 0 {
+ return i < j
+ }
+ return e1.References[0].Edge.Range.Before(e2.References[0].Edge.Range)
+ })
+ g.Edges = edges
+}
diff --git a/d2graph/seqdiagram.go b/d2graph/seqdiagram.go
index 15d7bc266..b9cddac8d 100644
--- a/d2graph/seqdiagram.go
+++ b/d2graph/seqdiagram.go
@@ -3,7 +3,7 @@ package d2graph
import "oss.terrastruct.com/d2/d2target"
func (obj *Object) IsSequenceDiagram() bool {
- return obj != nil && obj.Attributes.Shape.Value == d2target.ShapeSequenceDiagram
+ return obj != nil && obj.Attributes != nil && obj.Attributes.Shape.Value == d2target.ShapeSequenceDiagram
}
func (obj *Object) OuterSequenceDiagram() *Object {
@@ -65,7 +65,7 @@ func (obj *Object) ContainsAnyObject(objects []*Object) bool {
func (o *Object) ContainedBy(obj *Object) bool {
for _, ref := range o.References {
- curr := ref.UnresolvedScopeObj
+ curr := ref.ScopeObj
for curr != nil {
if curr == obj {
return true
diff --git a/d2ir/compile.go b/d2ir/compile.go
new file mode 100644
index 000000000..03cc04b85
--- /dev/null
+++ b/d2ir/compile.go
@@ -0,0 +1,252 @@
+package d2ir
+
+import (
+ "oss.terrastruct.com/d2/d2ast"
+ "oss.terrastruct.com/d2/d2parser"
+)
+
+type compiler struct {
+ err d2parser.ParseError
+}
+
+func (c *compiler) errorf(n d2ast.Node, f string, v ...interface{}) {
+ c.err.Errors = append(c.err.Errors, d2parser.Errorf(n, f, v...).(d2ast.Error))
+}
+
+func Compile(ast *d2ast.Map) (*Map, error) {
+ c := &compiler{}
+ m := &Map{}
+ m.initRoot()
+ m.parent.(*Field).References[0].Context.Scope = ast
+ c.compileMap(m, ast)
+ c.compileScenarios(m)
+ c.compileSteps(m)
+ if !c.err.Empty() {
+ return nil, c.err
+ }
+ return m, nil
+}
+
+func (c *compiler) compileScenarios(m *Map) {
+ scenariosf := m.GetField("scenarios")
+ if scenariosf == nil {
+ return
+ }
+ scenarios := scenariosf.Map()
+ if scenarios == nil {
+ return
+ }
+
+ for _, sf := range scenarios.Fields {
+ if sf.Map() == nil {
+ continue
+ }
+ base := m.CopyBase(sf)
+ OverlayMap(base, sf.Map())
+ sf.Composite = base
+ c.compileScenarios(sf.Map())
+ c.compileSteps(sf.Map())
+ }
+}
+
+func (c *compiler) compileSteps(m *Map) {
+ stepsf := m.GetField("steps")
+ if stepsf == nil {
+ return
+ }
+ steps := stepsf.Map()
+ if steps == nil {
+ return
+ }
+ for i, sf := range steps.Fields {
+ if sf.Map() == nil {
+ continue
+ }
+ var base *Map
+ if i == 0 {
+ base = m.CopyBase(sf)
+ } else {
+ base = steps.Fields[i-1].Map().CopyBase(sf)
+ }
+ OverlayMap(base, sf.Map())
+ sf.Composite = base
+ c.compileScenarios(sf.Map())
+ c.compileSteps(sf.Map())
+ }
+}
+
+func (c *compiler) compileMap(dst *Map, ast *d2ast.Map) {
+ for _, n := range ast.Nodes {
+ switch {
+ case n.MapKey != nil:
+ c.compileKey(&RefContext{
+ Key: n.MapKey,
+ Scope: ast,
+ ScopeMap: dst,
+ })
+ case n.Substitution != nil:
+ panic("TODO")
+ }
+ }
+}
+
+func (c *compiler) compileKey(refctx *RefContext) {
+ if len(refctx.Key.Edges) == 0 {
+ c.compileField(refctx.ScopeMap, refctx.Key.Key, refctx)
+ } else {
+ c.compileEdges(refctx)
+ }
+}
+
+func (c *compiler) compileField(dst *Map, kp *d2ast.KeyPath, refctx *RefContext) {
+ f, err := dst.EnsureField(kp, refctx)
+ if err != nil {
+ c.err.Errors = append(c.err.Errors, err.(d2ast.Error))
+ return
+ }
+
+ if refctx.Key.Primary.Unbox() != nil {
+ f.Primary_ = &Scalar{
+ parent: f,
+ Value: refctx.Key.Primary.Unbox(),
+ }
+ }
+ if refctx.Key.Value.Array != nil {
+ a := &Array{
+ parent: f,
+ }
+ c.compileArray(a, refctx.Key.Value.Array)
+ f.Composite = a
+ } else if refctx.Key.Value.Map != nil {
+ if f.Map() == nil {
+ f.Composite = &Map{
+ parent: f,
+ }
+ }
+ c.compileMap(f.Map(), refctx.Key.Value.Map)
+ } else if refctx.Key.Value.ScalarBox().Unbox() != nil {
+ f.Primary_ = &Scalar{
+ parent: f,
+ Value: refctx.Key.Value.ScalarBox().Unbox(),
+ }
+ }
+}
+
+func (c *compiler) compileEdges(refctx *RefContext) {
+ if refctx.Key.Key != nil {
+ f, err := refctx.ScopeMap.EnsureField(refctx.Key.Key, refctx)
+ if err != nil {
+ c.err.Errors = append(c.err.Errors, err.(d2ast.Error))
+ return
+ }
+ if _, ok := f.Composite.(*Array); ok {
+ c.errorf(refctx.Key.Key, "cannot index into array")
+ return
+ }
+ if f.Map() == nil {
+ f.Composite = &Map{
+ parent: f,
+ }
+ }
+ refctx.ScopeMap = f.Map()
+ }
+
+ eida := NewEdgeIDs(refctx.Key)
+ for i, eid := range eida {
+ refctx = refctx.Copy()
+ refctx.Edge = refctx.Key.Edges[i]
+
+ var e *Edge
+ if eid.Index != nil {
+ ea := refctx.ScopeMap.GetEdges(eid)
+ if len(ea) == 0 {
+ c.errorf(refctx.Edge, "indexed edge does not exist")
+ continue
+ }
+ e = ea[0]
+ e.References = append(e.References, &EdgeReference{
+ Context: refctx,
+ })
+ refctx.ScopeMap.appendFieldReferences(0, refctx.Edge.Src, refctx)
+ refctx.ScopeMap.appendFieldReferences(0, refctx.Edge.Dst, refctx)
+ } else {
+ _, err := refctx.ScopeMap.EnsureField(refctx.Edge.Src, refctx)
+ if err != nil {
+ c.err.Errors = append(c.err.Errors, err.(d2ast.Error))
+ continue
+ }
+ _, err = refctx.ScopeMap.EnsureField(refctx.Edge.Dst, refctx)
+ if err != nil {
+ c.err.Errors = append(c.err.Errors, err.(d2ast.Error))
+ continue
+ }
+
+ e, err = refctx.ScopeMap.CreateEdge(eid, refctx)
+ if err != nil {
+ c.err.Errors = append(c.err.Errors, err.(d2ast.Error))
+ continue
+ }
+ }
+
+ if refctx.Key.EdgeKey != nil {
+ if e.Map_ == nil {
+ e.Map_ = &Map{
+ parent: e,
+ }
+ }
+ c.compileField(e.Map_, refctx.Key.EdgeKey, refctx)
+ } else {
+ if refctx.Key.Primary.Unbox() != nil {
+ e.Primary_ = &Scalar{
+ parent: e,
+ Value: refctx.Key.Primary.Unbox(),
+ }
+ }
+ if refctx.Key.Value.Array != nil {
+ c.errorf(refctx.Key.Value.Unbox(), "edges cannot be assigned arrays")
+ continue
+ } else if refctx.Key.Value.Map != nil {
+ if e.Map_ == nil {
+ e.Map_ = &Map{
+ parent: e,
+ }
+ }
+ c.compileMap(e.Map_, refctx.Key.Value.Map)
+ } else if refctx.Key.Value.ScalarBox().Unbox() != nil {
+ e.Primary_ = &Scalar{
+ parent: e,
+ Value: refctx.Key.Value.ScalarBox().Unbox(),
+ }
+ }
+ }
+ }
+}
+
+func (c *compiler) compileArray(dst *Array, a *d2ast.Array) {
+ for _, an := range a.Nodes {
+ var irv Value
+ switch v := an.Unbox().(type) {
+ case *d2ast.Array:
+ ira := &Array{
+ parent: dst,
+ }
+ c.compileArray(ira, v)
+ irv = ira
+ case *d2ast.Map:
+ irm := &Map{
+ parent: dst,
+ }
+ c.compileMap(irm, v)
+ irv = irm
+ case d2ast.Scalar:
+ irv = &Scalar{
+ parent: dst,
+ Value: v,
+ }
+ case *d2ast.Substitution:
+ // panic("TODO")
+ }
+
+ dst.Values = append(dst.Values, irv)
+ }
+}
diff --git a/d2ir/compile_test.go b/d2ir/compile_test.go
new file mode 100644
index 000000000..bd21d9ae2
--- /dev/null
+++ b/d2ir/compile_test.go
@@ -0,0 +1,476 @@
+package d2ir_test
+
+import (
+ "fmt"
+ "math/big"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "oss.terrastruct.com/util-go/assert"
+ "oss.terrastruct.com/util-go/diff"
+
+ "oss.terrastruct.com/d2/d2ast"
+ "oss.terrastruct.com/d2/d2ir"
+ "oss.terrastruct.com/d2/d2parser"
+)
+
+func TestCompile(t *testing.T) {
+ t.Parallel()
+
+ t.Run("fields", testCompileFields)
+ t.Run("edges", testCompileEdges)
+ t.Run("layers", testCompileLayers)
+ t.Run("scenarios", testCompileScenarios)
+ t.Run("steps", testCompileSteps)
+}
+
+type testCase struct {
+ name string
+ run func(testing.TB)
+}
+
+func runa(t *testing.T, tca []testCase) {
+ for _, tc := range tca {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ tc.run(t)
+ })
+ }
+}
+
+func compile(t testing.TB, text string) (*d2ir.Map, error) {
+ t.Helper()
+
+ d2Path := fmt.Sprintf("%v.d2", t.Name())
+ ast, err := d2parser.Parse(d2Path, strings.NewReader(text), nil)
+ assert.Success(t, err)
+
+ m, err := d2ir.Compile(ast)
+ if err != nil {
+ return nil, err
+ }
+
+ err = diff.TestdataJSON(filepath.Join("..", "testdata", "d2ir", t.Name()), m)
+ if err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+func assertQuery(t testing.TB, n d2ir.Node, nfields, nedges int, primary interface{}, idStr string) d2ir.Node {
+ t.Helper()
+
+ m := n.Map()
+ p := n.Primary()
+
+ if idStr != "" {
+ var err error
+ n, err = m.Query(idStr)
+ assert.Success(t, err)
+ assert.NotEqual(t, n, nil)
+
+ p = n.Primary()
+ m = n.Map()
+ }
+
+ assert.Equal(t, nfields, m.FieldCountRecursive())
+ assert.Equal(t, nedges, m.EdgeCountRecursive())
+ if !makeScalar(p).Equal(makeScalar(primary)) {
+ t.Fatalf("expected primary %#v but got %s", primary, p)
+ }
+
+ return n
+}
+
+func makeScalar(v interface{}) *d2ir.Scalar {
+ s := &d2ir.Scalar{}
+ switch v := v.(type) {
+ case *d2ir.Scalar:
+ if v == nil {
+ s.Value = &d2ast.Null{}
+ return s
+ }
+ return v
+ case bool:
+ s.Value = &d2ast.Boolean{
+ Value: v,
+ }
+ case float64:
+ bv := &big.Rat{}
+ bv.SetFloat64(v)
+ s.Value = &d2ast.Number{
+ Value: bv,
+ }
+ case int:
+ s.Value = &d2ast.Number{
+ Value: big.NewRat(int64(v), 1),
+ }
+ case string:
+ s.Value = d2ast.FlatDoubleQuotedString(v)
+ default:
+ if v != nil {
+ panic(fmt.Sprintf("d2ir: unexpected type to makeScalar: %#v", v))
+ }
+ s.Value = &d2ast.Null{}
+ }
+ return s
+}
+
+func testCompileFields(t *testing.T) {
+ t.Parallel()
+ tca := []testCase{
+ {
+ name: "root",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x`)
+ assert.Success(t, err)
+ assertQuery(t, m, 1, 0, nil, "")
+
+ assertQuery(t, m, 0, 0, nil, "x")
+ },
+ },
+ {
+ name: "label",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x: yes`)
+ assert.Success(t, err)
+ assertQuery(t, m, 1, 0, nil, "")
+
+ assertQuery(t, m, 0, 0, "yes", "x")
+ },
+ },
+ {
+ name: "nested",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x.y: yes`)
+ assert.Success(t, err)
+ assertQuery(t, m, 2, 0, nil, "")
+
+ assertQuery(t, m, 1, 0, nil, "x")
+ assertQuery(t, m, 0, 0, "yes", "x.y")
+ },
+ },
+ {
+ name: "array",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x: [1;2;3;4]`)
+ assert.Success(t, err)
+ assertQuery(t, m, 1, 0, nil, "")
+
+ f := assertQuery(t, m, 0, 0, nil, "x").(*d2ir.Field)
+ assert.String(t, `[1; 2; 3; 4]`, f.Composite.String())
+ },
+ },
+ {
+ name: "null",
+ run: func(t testing.TB) {
+ m, err := compile(t, `pq: pq
+pq: null`)
+ assert.Success(t, err)
+ assertQuery(t, m, 1, 0, nil, "")
+ // null doesn't delete pq from *Map so that for language tooling
+ // we maintain the references.
+ // Instead d2compiler will ensure it doesn't get rendered.
+ assertQuery(t, m, 0, 0, nil, "pq")
+ },
+ },
+ }
+ runa(t, tca)
+ t.Run("primary", func(t *testing.T) {
+ t.Parallel()
+ tca := []testCase{
+ {
+ name: "root",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x: yes { pqrs }`)
+ assert.Success(t, err)
+ assertQuery(t, m, 2, 0, nil, "")
+
+ assertQuery(t, m, 1, 0, "yes", "x")
+ assertQuery(t, m, 0, 0, nil, "x.pqrs")
+ },
+ },
+ {
+ name: "nested",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x.y: yes { pqrs }`)
+ assert.Success(t, err)
+ assertQuery(t, m, 3, 0, nil, "")
+
+ assertQuery(t, m, 2, 0, nil, "x")
+ assertQuery(t, m, 1, 0, "yes", "x.y")
+ assertQuery(t, m, 0, 0, nil, "x.y.pqrs")
+ },
+ },
+ }
+ runa(t, tca)
+ })
+}
+
+func testCompileEdges(t *testing.T) {
+ t.Parallel()
+ tca := []testCase{
+ {
+ name: "root",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x -> y`)
+ assert.Success(t, err)
+ assertQuery(t, m, 2, 1, nil, "")
+ assertQuery(t, m, 0, 0, nil, `(x -> y)[0]`)
+
+ assertQuery(t, m, 0, 0, nil, "x")
+ assertQuery(t, m, 0, 0, nil, "y")
+ },
+ },
+ {
+ name: "nested",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x.y -> z.p`)
+ assert.Success(t, err)
+ assertQuery(t, m, 4, 1, nil, "")
+
+ assertQuery(t, m, 1, 0, nil, "x")
+ assertQuery(t, m, 0, 0, nil, "x.y")
+
+ assertQuery(t, m, 1, 0, nil, "z")
+ assertQuery(t, m, 0, 0, nil, "z.p")
+
+ assertQuery(t, m, 0, 0, nil, "(x.y -> z.p)[0]")
+ },
+ },
+ {
+ name: "underscore",
+ run: func(t testing.TB) {
+ m, err := compile(t, `p: { _.x -> z }`)
+ assert.Success(t, err)
+ assertQuery(t, m, 3, 1, nil, "")
+
+ assertQuery(t, m, 0, 0, nil, "x")
+ assertQuery(t, m, 1, 0, nil, "p")
+
+ assertQuery(t, m, 0, 0, nil, "(x -> p.z)[0]")
+ },
+ },
+ {
+ name: "chain",
+ run: func(t testing.TB) {
+ m, err := compile(t, `a -> b -> c -> d`)
+ assert.Success(t, err)
+ assertQuery(t, m, 4, 3, nil, "")
+
+ assertQuery(t, m, 0, 0, nil, "a")
+ assertQuery(t, m, 0, 0, nil, "b")
+ assertQuery(t, m, 0, 0, nil, "c")
+ assertQuery(t, m, 0, 0, nil, "d")
+ assertQuery(t, m, 0, 0, nil, "(a -> b)[0]")
+ assertQuery(t, m, 0, 0, nil, "(b -> c)[0]")
+ assertQuery(t, m, 0, 0, nil, "(c -> d)[0]")
+ },
+ },
+ }
+ runa(t, tca)
+ t.Run("errs", func(t *testing.T) {
+ t.Parallel()
+ tca := []testCase{
+ {
+ name: "bad_edge",
+ run: func(t testing.TB) {
+ _, err := compile(t, `(x -> y): { p -> q }`)
+ assert.ErrorString(t, err, `TestCompile/edges/errs/bad_edge.d2:1:13: cannot create edge inside edge`)
+ },
+ },
+ }
+ runa(t, tca)
+ })
+}
+
+func testCompileLayers(t *testing.T) {
+ t.Parallel()
+ tca := []testCase{
+ {
+ name: "root",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x -> y
+layers: {
+ bingo: { p.q.z }
+}`)
+ assert.Success(t, err)
+
+ assertQuery(t, m, 7, 1, nil, "")
+ assertQuery(t, m, 0, 0, nil, `(x -> y)[0]`)
+
+ assertQuery(t, m, 0, 0, nil, "x")
+ assertQuery(t, m, 0, 0, nil, "y")
+
+ assertQuery(t, m, 3, 0, nil, "layers.bingo")
+ },
+ },
+ }
+ runa(t, tca)
+ t.Run("errs", func(t *testing.T) {
+ t.Parallel()
+ tca := []testCase{
+ {
+ name: "1/bad_edge",
+ run: func(t testing.TB) {
+ _, err := compile(t, `layers.x -> layers.y`)
+ assert.ErrorString(t, err, `TestCompile/layers/errs/1/bad_edge.d2:1:1: cannot create edges between boards`)
+ },
+ },
+ {
+ name: "2/bad_edge",
+ run: func(t testing.TB) {
+ _, err := compile(t, `layers -> scenarios`)
+ assert.ErrorString(t, err, `TestCompile/layers/errs/2/bad_edge.d2:1:1: edge with board keyword alone doesn't make sense`)
+ },
+ },
+ {
+ name: "3/bad_edge",
+ run: func(t testing.TB) {
+ _, err := compile(t, `layers.x.y -> steps.z.p`)
+ assert.ErrorString(t, err, `TestCompile/layers/errs/3/bad_edge.d2:1:1: cannot create edges between boards`)
+ },
+ },
+ {
+ name: "4/good_edge",
+ run: func(t testing.TB) {
+ _, err := compile(t, `layers.x.y -> layers.x.y`)
+ assert.Success(t, err)
+ },
+ },
+ }
+ runa(t, tca)
+ })
+}
+
+func testCompileScenarios(t *testing.T) {
+ t.Parallel()
+ tca := []testCase{
+ {
+ name: "root",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x -> y
+scenarios: {
+ bingo: { p.q.z }
+ nuclear: { quiche }
+}`)
+ assert.Success(t, err)
+
+ assertQuery(t, m, 13, 3, nil, "")
+
+ assertQuery(t, m, 0, 0, nil, "x")
+ assertQuery(t, m, 0, 0, nil, "y")
+ assertQuery(t, m, 0, 0, nil, `(x -> y)[0]`)
+
+ assertQuery(t, m, 5, 1, nil, "scenarios.bingo")
+ assertQuery(t, m, 0, 0, nil, "scenarios.bingo.x")
+ assertQuery(t, m, 0, 0, nil, "scenarios.bingo.y")
+ assertQuery(t, m, 0, 0, nil, `scenarios.bingo.(x -> y)[0]`)
+ assertQuery(t, m, 2, 0, nil, "scenarios.bingo.p")
+ assertQuery(t, m, 1, 0, nil, "scenarios.bingo.p.q")
+ assertQuery(t, m, 0, 0, nil, "scenarios.bingo.p.q.z")
+
+ assertQuery(t, m, 3, 1, nil, "scenarios.nuclear")
+ assertQuery(t, m, 0, 0, nil, "scenarios.nuclear.x")
+ assertQuery(t, m, 0, 0, nil, "scenarios.nuclear.y")
+ assertQuery(t, m, 0, 0, nil, `scenarios.nuclear.(x -> y)[0]`)
+ assertQuery(t, m, 0, 0, nil, "scenarios.nuclear.quiche")
+ },
+ },
+ }
+ runa(t, tca)
+}
+
+func testCompileSteps(t *testing.T) {
+ t.Parallel()
+ tca := []testCase{
+ {
+ name: "root",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x -> y
+steps: {
+ bingo: { p.q.z }
+ nuclear: { quiche }
+}`)
+ assert.Success(t, err)
+
+ assertQuery(t, m, 16, 3, nil, "")
+
+ assertQuery(t, m, 0, 0, nil, "x")
+ assertQuery(t, m, 0, 0, nil, "y")
+ assertQuery(t, m, 0, 0, nil, `(x -> y)[0]`)
+
+ assertQuery(t, m, 5, 1, nil, "steps.bingo")
+ assertQuery(t, m, 0, 0, nil, "steps.bingo.x")
+ assertQuery(t, m, 0, 0, nil, "steps.bingo.y")
+ assertQuery(t, m, 0, 0, nil, `steps.bingo.(x -> y)[0]`)
+ assertQuery(t, m, 2, 0, nil, "steps.bingo.p")
+ assertQuery(t, m, 1, 0, nil, "steps.bingo.p.q")
+ assertQuery(t, m, 0, 0, nil, "steps.bingo.p.q.z")
+
+ assertQuery(t, m, 6, 1, nil, "steps.nuclear")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.x")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.y")
+ assertQuery(t, m, 0, 0, nil, `steps.nuclear.(x -> y)[0]`)
+ assertQuery(t, m, 2, 0, nil, "steps.nuclear.p")
+ assertQuery(t, m, 1, 0, nil, "steps.nuclear.p.q")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.p.q.z")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.quiche")
+ },
+ },
+ {
+ name: "recursive",
+ run: func(t testing.TB) {
+ m, err := compile(t, `x -> y
+steps: {
+ bingo: { p.q.z }
+ nuclear: {
+ quiche
+ scenarios: {
+ bavarian: {
+ perseverance
+ }
+ }
+ }
+}`)
+ assert.Success(t, err)
+
+ assertQuery(t, m, 25, 4, nil, "")
+
+ assertQuery(t, m, 0, 0, nil, "x")
+ assertQuery(t, m, 0, 0, nil, "y")
+ assertQuery(t, m, 0, 0, nil, `(x -> y)[0]`)
+
+ assertQuery(t, m, 5, 1, nil, "steps.bingo")
+ assertQuery(t, m, 0, 0, nil, "steps.bingo.x")
+ assertQuery(t, m, 0, 0, nil, "steps.bingo.y")
+ assertQuery(t, m, 0, 0, nil, `steps.bingo.(x -> y)[0]`)
+ assertQuery(t, m, 2, 0, nil, "steps.bingo.p")
+ assertQuery(t, m, 1, 0, nil, "steps.bingo.p.q")
+ assertQuery(t, m, 0, 0, nil, "steps.bingo.p.q.z")
+
+ assertQuery(t, m, 15, 2, nil, "steps.nuclear")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.x")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.y")
+ assertQuery(t, m, 0, 0, nil, `steps.nuclear.(x -> y)[0]`)
+ assertQuery(t, m, 2, 0, nil, "steps.nuclear.p")
+ assertQuery(t, m, 1, 0, nil, "steps.nuclear.p.q")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.p.q.z")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.quiche")
+
+ assertQuery(t, m, 7, 1, nil, "steps.nuclear.scenarios.bavarian")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.scenarios.bavarian.x")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.scenarios.bavarian.y")
+ assertQuery(t, m, 0, 0, nil, `steps.nuclear.scenarios.bavarian.(x -> y)[0]`)
+ assertQuery(t, m, 2, 0, nil, "steps.nuclear.scenarios.bavarian.p")
+ assertQuery(t, m, 1, 0, nil, "steps.nuclear.scenarios.bavarian.p.q")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.scenarios.bavarian.p.q.z")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.scenarios.bavarian.quiche")
+ assertQuery(t, m, 0, 0, nil, "steps.nuclear.scenarios.bavarian.perseverance")
+ },
+ },
+ }
+ runa(t, tca)
+}
diff --git a/d2ir/d2ir.go b/d2ir/d2ir.go
new file mode 100644
index 000000000..1a90d8d5c
--- /dev/null
+++ b/d2ir/d2ir.go
@@ -0,0 +1,1061 @@
+// Package d2ir implements a tree data structure to keep track of the resolved value of D2
+// keys.
+package d2ir
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "oss.terrastruct.com/util-go/go2"
+
+ "oss.terrastruct.com/d2/d2ast"
+ "oss.terrastruct.com/d2/d2format"
+ "oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/d2parser"
+)
+
+// Most errors returned by a node should be created with d2parser.Errorf
+// to indicate the offending AST node.
+type Node interface {
+ node()
+ Copy(newParent Node) Node
+ Parent() Node
+ Primary() *Scalar
+ Map() *Map
+
+ ast() d2ast.Node
+ fmt.Stringer
+
+ LastRef() Reference
+ LastPrimaryKey() *d2ast.Key
+}
+
+var _ Node = &Scalar{}
+var _ Node = &Field{}
+var _ Node = &Edge{}
+var _ Node = &Array{}
+var _ Node = &Map{}
+
+type Value interface {
+ Node
+ value()
+}
+
+var _ Value = &Scalar{}
+var _ Value = &Array{}
+var _ Value = &Map{}
+
+type Composite interface {
+ Node
+ Value
+ composite()
+}
+
+var _ Composite = &Array{}
+var _ Composite = &Map{}
+
+func (n *Scalar) node() {}
+func (n *Field) node() {}
+func (n *Edge) node() {}
+func (n *Array) node() {}
+func (n *Map) node() {}
+
+func (n *Scalar) Parent() Node { return n.parent }
+func (n *Field) Parent() Node { return n.parent }
+func (n *Edge) Parent() Node { return n.parent }
+func (n *Array) Parent() Node { return n.parent }
+func (n *Map) Parent() Node { return n.parent }
+
+func (n *Scalar) Primary() *Scalar { return n }
+func (n *Field) Primary() *Scalar { return n.Primary_ }
+func (n *Edge) Primary() *Scalar { return n.Primary_ }
+func (n *Array) Primary() *Scalar { return nil }
+func (n *Map) Primary() *Scalar { return nil }
+
+func (n *Scalar) Map() *Map { return nil }
+func (n *Field) Map() *Map {
+ if n == nil {
+ return nil
+ }
+ if n.Composite == nil {
+ return nil
+ }
+ return n.Composite.Map()
+}
+func (n *Edge) Map() *Map {
+ if n == nil {
+ return nil
+ }
+ return n.Map_
+}
+func (n *Array) Map() *Map { return nil }
+func (n *Map) Map() *Map { return n }
+
+func (n *Scalar) value() {}
+func (n *Array) value() {}
+func (n *Map) value() {}
+
+func (n *Array) composite() {}
+func (n *Map) composite() {}
+
+func (n *Scalar) String() string { return d2format.Format(n.ast()) }
+func (n *Field) String() string { return d2format.Format(n.ast()) }
+func (n *Edge) String() string { return d2format.Format(n.ast()) }
+func (n *Array) String() string { return d2format.Format(n.ast()) }
+func (n *Map) String() string { return d2format.Format(n.ast()) }
+
+func (n *Scalar) LastRef() Reference { return parentRef(n) }
+func (n *Map) LastRef() Reference { return parentRef(n) }
+func (n *Array) LastRef() Reference { return parentRef(n) }
+
+func (n *Scalar) LastPrimaryKey() *d2ast.Key { return parentPrimaryKey(n) }
+func (n *Map) LastPrimaryKey() *d2ast.Key { return parentPrimaryKey(n) }
+func (n *Array) LastPrimaryKey() *d2ast.Key { return parentPrimaryKey(n) }
+
+type Reference interface {
+ reference()
+ // Most specific AST node for the reference.
+ AST() d2ast.Node
+ Primary() bool
+}
+
+var _ Reference = &FieldReference{}
+var _ Reference = &EdgeReference{}
+
+func (r *FieldReference) reference() {}
+func (r *EdgeReference) reference() {}
+
+type Scalar struct {
+ parent Node
+ Value d2ast.Scalar `json:"value"`
+}
+
+func (s *Scalar) Copy(newParent Node) Node {
+ tmp := *s
+ s = &tmp
+
+ s.parent = newParent
+ return s
+}
+
+func (s *Scalar) Equal(s2 *Scalar) bool {
+ if _, ok := s.Value.(d2ast.String); ok {
+ if _, ok = s2.Value.(d2ast.String); ok {
+ return s.Value.ScalarString() == s2.Value.ScalarString()
+ }
+ }
+ return s.Value.Type() == s2.Value.Type() && s.Value.ScalarString() == s2.Value.ScalarString()
+}
+
+type Map struct {
+ parent Node
+ Fields []*Field `json:"fields"`
+ Edges []*Edge `json:"edges"`
+}
+
+func (m *Map) initRoot() {
+ m.parent = &Field{
+ Name: "",
+ References: []*FieldReference{{
+ Context: &RefContext{
+ ScopeMap: m,
+ },
+ }},
+ }
+}
+
+func (m *Map) Copy(newParent Node) Node {
+ tmp := *m
+ m = &tmp
+
+ m.parent = newParent
+ pfields := m.Fields
+ m.Fields = make([]*Field, 0, len(pfields))
+ for _, f := range pfields {
+ m.Fields = append(m.Fields, f.Copy(m).(*Field))
+ }
+ m.Edges = append([]*Edge(nil), m.Edges...)
+ for i := range m.Edges {
+ m.Edges[i] = m.Edges[i].Copy(m).(*Edge)
+ }
+ if m.parent == nil {
+ m.initRoot()
+ }
+ return m
+}
+
+// CopyBase copies the map m without layers/scenarios/steps.
+func (m *Map) CopyBase(newParent Node) *Map {
+ layers := m.DeleteField("layers")
+ scenarios := m.DeleteField("scenarios")
+ steps := m.DeleteField("steps")
+ m2 := m.Copy(newParent).(*Map)
+ if layers != nil {
+ m.Fields = append(m.Fields, layers)
+ }
+ if scenarios != nil {
+ m.Fields = append(m.Fields, scenarios)
+ }
+ if steps != nil {
+ m.Fields = append(m.Fields, steps)
+ }
+ return m2
+}
+
+// Root reports whether the Map is the root of the D2 tree.
+func (m *Map) Root() bool {
+ // m.parent exists even on the root map as we store the root AST in
+ // m.parent.References[0].Context.Map for reporting error messages about the whole IR.
+ // Or if otherwise needed.
+ f, ok := m.parent.(*Field)
+ if !ok {
+ return false
+ }
+ return f.Root()
+}
+
+func (f *Field) Root() bool {
+ return f.parent == nil
+}
+
+type BoardKind string
+
+const (
+ BoardLayer BoardKind = "layer"
+ BoardScenario BoardKind = "scenario"
+ BoardStep BoardKind = "step"
+)
+
+// NodeBoardKind reports whether n represents the root of a board.
+// n should be *Field or *Map
+func NodeBoardKind(n Node) BoardKind {
+ var f *Field
+ switch n := n.(type) {
+ case *Field:
+ if n.Name == "" {
+ return BoardLayer
+ }
+ f = ParentField(n)
+ case *Map:
+ f = ParentField(n)
+ if f.Root() {
+ return BoardLayer
+ }
+ f = ParentField(f)
+ }
+ if f == nil {
+ return ""
+ }
+ switch f.Name {
+ case "layers":
+ return BoardLayer
+ case "scenarios":
+ return BoardScenario
+ case "steps":
+ return BoardStep
+ default:
+ return ""
+ }
+}
+
+type Field struct {
+ // *Map.
+ parent Node
+
+ Name string `json:"name"`
+
+ // Primary_ to avoid clashing with Primary(). We need to keep it exported for
+ // encoding/json to marshal it so cannot prefix _ instead.
+ Primary_ *Scalar `json:"primary,omitempty"`
+ Composite Composite `json:"composite,omitempty"`
+
+ References []*FieldReference `json:"references,omitempty"`
+}
+
+func (f *Field) Copy(newParent Node) Node {
+ tmp := *f
+ f = &tmp
+
+ f.parent = newParent
+ f.References = append([]*FieldReference(nil), f.References...)
+ if f.Primary_ != nil {
+ f.Primary_ = f.Primary_.Copy(f).(*Scalar)
+ }
+ if f.Composite != nil {
+ f.Composite = f.Composite.Copy(f).(Composite)
+ }
+ return f
+}
+
+func (f *Field) lastPrimaryRef() *FieldReference {
+ for i := len(f.References) - 1; i >= 0; i-- {
+ if f.References[i].Primary() {
+ return f.References[i]
+ }
+ }
+ return nil
+}
+
+func (f *Field) LastPrimaryKey() *d2ast.Key {
+ fr := f.lastPrimaryRef()
+ if fr == nil {
+ return nil
+ }
+ return fr.Context.Key
+}
+
+func (f *Field) LastRef() Reference {
+ return f.References[len(f.References)-1]
+}
+
+type EdgeID struct {
+ SrcPath []string `json:"src_path"`
+ SrcArrow bool `json:"src_arrow"`
+
+ DstPath []string `json:"dst_path"`
+ DstArrow bool `json:"dst_arrow"`
+
+ // If nil, then any EdgeID with equal src/dst/arrows matches.
+ Index *int `json:"index"`
+}
+
+func NewEdgeIDs(k *d2ast.Key) (eida []*EdgeID) {
+ for _, ke := range k.Edges {
+ eid := &EdgeID{
+ SrcPath: ke.Src.IDA(),
+ SrcArrow: ke.SrcArrow == "<",
+ DstPath: ke.Dst.IDA(),
+ DstArrow: ke.DstArrow == ">",
+ }
+ if k.EdgeIndex != nil {
+ eid.Index = k.EdgeIndex.Int
+ }
+ eida = append(eida, eid)
+ }
+ return eida
+}
+
+func (eid *EdgeID) Copy() *EdgeID {
+ tmp := *eid
+ eid = &tmp
+
+ eid.SrcPath = append([]string(nil), eid.SrcPath...)
+ eid.DstPath = append([]string(nil), eid.DstPath...)
+ return eid
+}
+
+func (eid *EdgeID) Match(eid2 *EdgeID) bool {
+ if eid.Index != nil && eid2.Index != nil {
+ if *eid.Index != *eid2.Index {
+ return false
+ }
+ }
+
+ if len(eid.SrcPath) != len(eid2.SrcPath) {
+ return false
+ }
+ if eid.SrcArrow != eid2.SrcArrow {
+ return false
+ }
+ for i, s := range eid.SrcPath {
+ if !strings.EqualFold(s, eid2.SrcPath[i]) {
+ return false
+ }
+ }
+
+ if len(eid.DstPath) != len(eid2.DstPath) {
+ return false
+ }
+ if eid.DstArrow != eid2.DstArrow {
+ return false
+ }
+ for i, s := range eid.DstPath {
+ if !strings.EqualFold(s, eid2.DstPath[i]) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func (eid *EdgeID) resolveUnderscores(m *Map) (*EdgeID, *Map, error) {
+ eid = eid.Copy()
+ maxUnderscores := go2.Max(countUnderscores(eid.SrcPath), countUnderscores(eid.DstPath))
+ for i := 0; i < maxUnderscores; i++ {
+ if eid.SrcPath[0] == "_" {
+ eid.SrcPath = eid.SrcPath[1:]
+ } else {
+ mf := ParentField(m)
+ eid.SrcPath = append([]string{mf.Name}, eid.SrcPath...)
+ }
+ if eid.DstPath[0] == "_" {
+ eid.DstPath = eid.DstPath[1:]
+ } else {
+ mf := ParentField(m)
+ eid.DstPath = append([]string{mf.Name}, eid.DstPath...)
+ }
+ m = ParentMap(m)
+ if m == nil {
+ return nil, nil, errors.New("invalid underscore")
+ }
+ }
+ return eid, m, nil
+}
+
+func (eid *EdgeID) trimCommon() (common []string, _ *EdgeID) {
+ eid = eid.Copy()
+ for len(eid.SrcPath) > 1 && len(eid.DstPath) > 1 {
+ if !strings.EqualFold(eid.SrcPath[0], eid.DstPath[0]) {
+ return common, eid
+ }
+ common = append(common, eid.SrcPath[0])
+ eid.SrcPath = eid.SrcPath[1:]
+ eid.DstPath = eid.DstPath[1:]
+ }
+ return common, eid
+}
+
+type Edge struct {
+ // *Map
+ parent Node
+
+ ID *EdgeID `json:"edge_id"`
+
+ Primary_ *Scalar `json:"primary,omitempty"`
+ Map_ *Map `json:"map,omitempty"`
+
+ References []*EdgeReference `json:"references,omitempty"`
+}
+
+func (e *Edge) Copy(newParent Node) Node {
+ tmp := *e
+ e = &tmp
+
+ e.parent = newParent
+ e.References = append([]*EdgeReference(nil), e.References...)
+ if e.Primary_ != nil {
+ e.Primary_ = e.Primary_.Copy(e).(*Scalar)
+ }
+ if e.Map_ != nil {
+ e.Map_ = e.Map_.Copy(e).(*Map)
+ }
+ return e
+}
+
+func (e *Edge) lastPrimaryRef() *EdgeReference {
+ for i := len(e.References) - 1; i >= 0; i-- {
+ fr := e.References[i]
+ if fr.Context.Key.EdgeKey == nil {
+ return fr
+ }
+ }
+ return nil
+}
+
+func (e *Edge) LastPrimaryKey() *d2ast.Key {
+ er := e.lastPrimaryRef()
+ if er == nil {
+ return nil
+ }
+ return er.Context.Key
+}
+
+func (e *Edge) LastRef() Reference {
+ return e.References[len(e.References)-1]
+}
+
+type Array struct {
+ parent Node
+ Values []Value `json:"values"`
+}
+
+func (a *Array) Copy(newParent Node) Node {
+ tmp := *a
+ a = &tmp
+
+ a.parent = newParent
+ a.Values = append([]Value(nil), a.Values...)
+ for i := range a.Values {
+ a.Values[i] = a.Values[i].Copy(a).(Value)
+ }
+ return a
+}
+
+type FieldReference struct {
+ String d2ast.String `json:"string"`
+ KeyPath *d2ast.KeyPath `json:"key_path"`
+
+ Context *RefContext `json:"context"`
+}
+
+// Primary returns true if the Value in Context.Key.Value corresponds to the Field
+// represented by String.
+func (fr *FieldReference) Primary() bool {
+ if fr.KeyPath == fr.Context.Key.Key {
+ return len(fr.Context.Key.Edges) == 0 && fr.KeyPathIndex() == len(fr.KeyPath.Path)-1
+ } else if fr.KeyPath == fr.Context.Key.EdgeKey {
+ return len(fr.Context.Key.Edges) == 1 && fr.KeyPathIndex() == len(fr.KeyPath.Path)-1
+ }
+ return false
+}
+
+func (fr *FieldReference) KeyPathIndex() int {
+ for i, sb := range fr.KeyPath.Path {
+ if sb.Unbox() == fr.String {
+ return i
+ }
+ }
+ panic("d2ir.KeyReference.KeyPathIndex: String not in KeyPath?")
+}
+
+func (fr *FieldReference) EdgeDest() bool {
+ return fr.KeyPath == fr.Context.Edge.Dst
+}
+
+func (fr *FieldReference) InEdge() bool {
+ return fr.Context.Edge != nil
+}
+
+func (fr *FieldReference) AST() d2ast.Node {
+ if fr.String == nil {
+ // Root map.
+ return fr.Context.Scope
+ }
+ return fr.String
+}
+
+type EdgeReference struct {
+ Context *RefContext `json:"context"`
+}
+
+func (er *EdgeReference) AST() d2ast.Node {
+ return er.Context.Edge
+}
+
+// Primary returns true if the Value in Context.Key.Value corresponds to the *Edge
+// represented by Context.Edge
+func (er *EdgeReference) Primary() bool {
+ return len(er.Context.Key.Edges) == 1 && er.Context.Key.EdgeKey == nil
+}
+
+type RefContext struct {
+ Edge *d2ast.Edge `json:"edge"`
+ Key *d2ast.Key `json:"key"`
+ Scope *d2ast.Map `json:"-"`
+ ScopeMap *Map `json:"-"`
+}
+
+func (rc *RefContext) Copy() *RefContext {
+ tmp := *rc
+ return &tmp
+}
+
+func (rc *RefContext) EdgeIndex() int {
+ for i, e := range rc.Key.Edges {
+ if e == rc.Edge {
+ return i
+ }
+ }
+ return -1
+}
+
+func (m *Map) FieldCountRecursive() int {
+ if m == nil {
+ return 0
+ }
+ acc := len(m.Fields)
+ for _, f := range m.Fields {
+ if f.Map() != nil {
+ acc += f.Map().FieldCountRecursive()
+ }
+ }
+ for _, e := range m.Edges {
+ if e.Map_ != nil {
+ acc += e.Map_.FieldCountRecursive()
+ }
+ }
+ return acc
+}
+
+func (m *Map) EdgeCountRecursive() int {
+ if m == nil {
+ return 0
+ }
+ acc := len(m.Edges)
+ for _, f := range m.Fields {
+ if f.Map() != nil {
+ acc += f.Map().EdgeCountRecursive()
+ }
+ }
+ for _, e := range m.Edges {
+ if e.Map_ != nil {
+ acc += e.Map_.EdgeCountRecursive()
+ }
+ }
+ return acc
+}
+
+func (m *Map) GetField(ida ...string) *Field {
+ for len(ida) > 0 && ida[0] == "_" {
+ m = ParentMap(m)
+ if m == nil {
+ return nil
+ }
+ }
+ return m.getField(ida)
+}
+
+func (m *Map) getField(ida []string) *Field {
+ if len(ida) == 0 {
+ return nil
+ }
+
+ s := ida[0]
+ rest := ida[1:]
+
+ if s == "_" {
+ return nil
+ }
+
+ for _, f := range m.Fields {
+ if !strings.EqualFold(f.Name, s) {
+ continue
+ }
+ if len(rest) == 0 {
+ return f
+ }
+ if f.Map() != nil {
+ return f.Map().getField(rest)
+ }
+ }
+ return nil
+}
+
+func (m *Map) EnsureField(kp *d2ast.KeyPath, refctx *RefContext) (*Field, error) {
+ i := 0
+ for kp.Path[i].Unbox().ScalarString() == "_" {
+ m = ParentMap(m)
+ if m == nil {
+ return nil, d2parser.Errorf(kp.Path[i].Unbox(), "invalid underscore: no parent")
+ }
+ if i+1 == len(kp.Path) {
+ return nil, d2parser.Errorf(kp.Path[i].Unbox(), "field key must contain more than underscores")
+ }
+ i++
+ }
+ return m.ensureField(i, kp, refctx)
+}
+
+func (m *Map) ensureField(i int, kp *d2ast.KeyPath, refctx *RefContext) (*Field, error) {
+ head := kp.Path[i].Unbox().ScalarString()
+
+ if head == "_" {
+ return nil, d2parser.Errorf(kp.Path[i].Unbox(), `parent "_" can only be used in the beginning of paths, e.g. "_.x"`)
+ }
+
+ if findBoardKeyword(head) != -1 && NodeBoardKind(m) == "" {
+ return nil, d2parser.Errorf(kp.Path[i].Unbox(), "%s is only allowed at a board root", head)
+ }
+
+ for _, f := range m.Fields {
+ if !strings.EqualFold(f.Name, head) {
+ continue
+ }
+
+ // Don't add references for fake common KeyPath from trimCommon in CreateEdge.
+ if refctx != nil {
+ f.References = append(f.References, &FieldReference{
+ String: kp.Path[i].Unbox(),
+ KeyPath: kp,
+ Context: refctx,
+ })
+ }
+
+ if i+1 == len(kp.Path) {
+ return f, nil
+ }
+ if _, ok := f.Composite.(*Array); ok {
+ return nil, d2parser.Errorf(kp.Path[i].Unbox(), "cannot index into array")
+ }
+ if f.Map() == nil {
+ f.Composite = &Map{
+ parent: f,
+ }
+ }
+ return f.Map().ensureField(i+1, kp, refctx)
+ }
+
+ f := &Field{
+ parent: m,
+ Name: head,
+ }
+ // Don't add references for fake common KeyPath from trimCommon in CreateEdge.
+ if refctx != nil {
+ f.References = append(f.References, &FieldReference{
+ String: kp.Path[i].Unbox(),
+ KeyPath: kp,
+ Context: refctx,
+ })
+ }
+ m.Fields = append(m.Fields, f)
+ if i+1 == len(kp.Path) {
+ return f, nil
+ }
+ f.Composite = &Map{
+ parent: f,
+ }
+ return f.Map().ensureField(i+1, kp, refctx)
+}
+
+func (m *Map) DeleteField(ida ...string) *Field {
+ if len(ida) == 0 {
+ return nil
+ }
+
+ s := ida[0]
+ rest := ida[1:]
+
+ for i, f := range m.Fields {
+ if !strings.EqualFold(f.Name, s) {
+ continue
+ }
+ if len(rest) == 0 {
+ m.Fields = append(m.Fields[:i], m.Fields[i+1:]...)
+ return f
+ }
+ if f.Map() != nil {
+ return f.Map().DeleteField(rest...)
+ }
+ }
+ return nil
+}
+
+func (m *Map) GetEdges(eid *EdgeID) []*Edge {
+ eid, m, err := eid.resolveUnderscores(m)
+ if err != nil {
+ return nil
+ }
+ common, eid := eid.trimCommon()
+ if len(common) > 0 {
+ f := m.GetField(common...)
+ if f == nil {
+ return nil
+ }
+ if f.Map() != nil {
+ return f.Map().GetEdges(eid)
+ }
+ return nil
+ }
+
+ var ea []*Edge
+ for _, e := range m.Edges {
+ if e.ID.Match(eid) {
+ ea = append(ea, e)
+ }
+ }
+ return ea
+}
+
+func (m *Map) CreateEdge(eid *EdgeID, refctx *RefContext) (*Edge, error) {
+ if ParentEdge(m) != nil {
+ return nil, d2parser.Errorf(refctx.Edge, "cannot create edge inside edge")
+ }
+
+ eid, m, err := eid.resolveUnderscores(m)
+ if err != nil {
+ return nil, d2parser.Errorf(refctx.Edge, err.Error())
+ }
+ common, eid := eid.trimCommon()
+ if len(common) > 0 {
+ tmp := *refctx.Edge.Src
+ kp := &tmp
+ kp.Path = kp.Path[:len(common)]
+ f, err := m.EnsureField(kp, nil)
+ if err != nil {
+ return nil, err
+ }
+ if _, ok := f.Composite.(*Array); ok {
+ return nil, d2parser.Errorf(refctx.Edge.Src, "cannot index into array")
+ }
+ if f.Map() == nil {
+ f.Composite = &Map{
+ parent: f,
+ }
+ }
+ return f.Map().CreateEdge(eid, refctx)
+ }
+
+ ij := findProhibitedEdgeKeyword(eid.SrcPath...)
+ if ij != -1 {
+ return nil, d2parser.Errorf(refctx.Edge.Src.Path[ij].Unbox(), "reserved keywords are prohibited in edges")
+ }
+ ij = findBoardKeyword(eid.SrcPath...)
+ if ij == len(eid.SrcPath)-1 {
+ return nil, d2parser.Errorf(refctx.Edge.Src.Path[ij].Unbox(), "edge with board keyword alone doesn't make sense")
+ }
+ src := m.GetField(eid.SrcPath...)
+ if NodeBoardKind(src) != "" {
+ return nil, d2parser.Errorf(refctx.Edge.Src, "cannot create edges between boards")
+ }
+
+ ij = findProhibitedEdgeKeyword(eid.DstPath...)
+ if ij != -1 {
+ return nil, d2parser.Errorf(refctx.Edge.Dst.Path[ij].Unbox(), "reserved keywords are prohibited in edges")
+ }
+ ij = findBoardKeyword(eid.DstPath...)
+ if ij == len(eid.DstPath)-1 {
+ return nil, d2parser.Errorf(refctx.Edge.Dst.Path[ij].Unbox(), "edge with board keyword alone doesn't make sense")
+ }
+ dst := m.GetField(eid.DstPath...)
+ if NodeBoardKind(dst) != "" {
+ return nil, d2parser.Errorf(refctx.Edge.Dst, "cannot create edges between boards")
+ }
+
+ if ParentBoard(src) != ParentBoard(dst) {
+ return nil, d2parser.Errorf(refctx.Edge, "cannot create edges between boards")
+ }
+
+ eid.Index = nil
+ ea := m.GetEdges(eid)
+ index := len(ea)
+ eid.Index = &index
+ e := &Edge{
+ parent: m,
+ ID: eid,
+ References: []*EdgeReference{{
+ Context: refctx,
+ }},
+ }
+ m.Edges = append(m.Edges, e)
+
+ return e, nil
+}
+
+func (s *Scalar) ast() d2ast.Node {
+ return s.Value
+}
+
+func (f *Field) ast() d2ast.Node {
+ k := &d2ast.Key{
+ Key: &d2ast.KeyPath{
+ Path: []*d2ast.StringBox{
+ d2ast.MakeValueBox(d2ast.RawString(f.Name, true)).StringBox(),
+ },
+ },
+ }
+
+ if f.Primary_ != nil {
+ k.Primary = d2ast.MakeValueBox(f.Primary_.ast().(d2ast.Value)).ScalarBox()
+ }
+ if f.Composite != nil {
+ k.Value = d2ast.MakeValueBox(f.Composite.ast().(d2ast.Value))
+ }
+
+ return k
+}
+
+func (e *Edge) ast() d2ast.Node {
+ astEdge := &d2ast.Edge{}
+
+ astEdge.Src = d2ast.MakeKeyPath(e.ID.SrcPath)
+ if e.ID.SrcArrow {
+ astEdge.SrcArrow = "<"
+ }
+ astEdge.Dst = d2ast.MakeKeyPath(e.ID.DstPath)
+ if e.ID.DstArrow {
+ astEdge.DstArrow = ">"
+ }
+
+ k := &d2ast.Key{
+ Edges: []*d2ast.Edge{astEdge},
+ }
+
+ if e.Primary_ != nil {
+ k.Primary = d2ast.MakeValueBox(e.Primary_.ast().(d2ast.Value)).ScalarBox()
+ }
+ if e.Map_ != nil {
+ k.Value = d2ast.MakeValueBox(e.Map_.ast().(*d2ast.Map))
+ }
+
+ return k
+}
+
+func (a *Array) ast() d2ast.Node {
+ if a == nil {
+ return nil
+ }
+ astArray := &d2ast.Array{}
+ for _, av := range a.Values {
+ astArray.Nodes = append(astArray.Nodes, d2ast.MakeArrayNodeBox(av.ast().(d2ast.ArrayNode)))
+ }
+ return astArray
+}
+
+func (m *Map) ast() d2ast.Node {
+ if m == nil {
+ return nil
+ }
+ astMap := &d2ast.Map{}
+ if m.Root() {
+ astMap.Range = d2ast.MakeRange(",0:0:0-1:0:0")
+ } else {
+ astMap.Range = d2ast.MakeRange(",1:0:0-2:0:0")
+ }
+ for _, f := range m.Fields {
+ astMap.Nodes = append(astMap.Nodes, d2ast.MakeMapNodeBox(f.ast().(d2ast.MapNode)))
+ }
+ for _, e := range m.Edges {
+ astMap.Nodes = append(astMap.Nodes, d2ast.MakeMapNodeBox(e.ast().(d2ast.MapNode)))
+ }
+ return astMap
+}
+
+func (m *Map) appendFieldReferences(i int, kp *d2ast.KeyPath, refctx *RefContext) {
+ sb := kp.Path[i]
+ f := m.GetField(sb.Unbox().ScalarString())
+ if f == nil {
+ return
+ }
+
+ f.References = append(f.References, &FieldReference{
+ String: sb.Unbox(),
+ KeyPath: kp,
+ Context: refctx,
+ })
+ if i+1 == len(kp.Path) {
+ return
+ }
+ if f.Map() != nil {
+ f.Map().appendFieldReferences(i+1, kp, refctx)
+ }
+}
+
+func ParentMap(n Node) *Map {
+ for {
+ n = n.Parent()
+ if n == nil {
+ return nil
+ }
+ if m, ok := n.(*Map); ok {
+ return m
+ }
+ }
+}
+
+func ParentField(n Node) *Field {
+ for {
+ n = n.Parent()
+ if n == nil {
+ return nil
+ }
+ if f, ok := n.(*Field); ok {
+ return f
+ }
+ }
+}
+
+func ParentBoard(n Node) Node {
+ for {
+ n = n.Parent()
+ if n == nil {
+ return nil
+ }
+ if NodeBoardKind(n) != "" {
+ return n
+ }
+ }
+}
+
+func ParentEdge(n Node) *Edge {
+ for {
+ n = n.Parent()
+ if n == nil {
+ return nil
+ }
+ if e, ok := n.(*Edge); ok {
+ return e
+ }
+ }
+}
+
+func countUnderscores(p []string) int {
+ for i, el := range p {
+ if el != "_" {
+ return i
+ }
+ }
+ return 0
+}
+
+func findBoardKeyword(ida ...string) int {
+ for i := range ida {
+ if _, ok := d2graph.BoardKeywords[ida[i]]; ok {
+ return i
+ }
+ }
+ return -1
+}
+
+func findProhibitedEdgeKeyword(ida ...string) int {
+ for i := range ida {
+ if _, ok := d2graph.SimpleReservedKeywords[ida[i]]; ok {
+ return i
+ }
+ if _, ok := d2graph.ReservedKeywordHolders[ida[i]]; ok {
+ return i
+ }
+ }
+ return -1
+}
+
+func parentRef(n Node) Reference {
+ f := ParentField(n)
+ if f != nil {
+ return f.LastRef()
+ }
+ e := ParentEdge(n)
+ if e != nil {
+ return e.LastRef()
+ }
+ return nil
+}
+
+func parentPrimaryKey(n Node) *d2ast.Key {
+ f := ParentField(n)
+ if f != nil {
+ return f.LastPrimaryKey()
+ }
+ e := ParentEdge(n)
+ if e != nil {
+ return e.LastPrimaryKey()
+ }
+ return nil
+}
+
+func IDA(n Node) (ida []string) {
+ for {
+ f, ok := n.(*Field)
+ if ok {
+ if f.Root() {
+ reverseIDA(ida)
+ return ida
+ }
+ ida = append(ida, f.Name)
+ }
+ f = ParentField(n)
+ if f == nil {
+ reverseIDA(ida)
+ return ida
+ }
+ n = f
+ }
+}
+
+func reverseIDA(ida []string) {
+ for i := 0; i < len(ida)/2; i++ {
+ tmp := ida[i]
+ ida[i] = ida[len(ida)-i-1]
+ ida[len(ida)-i-1] = tmp
+ }
+}
diff --git a/d2ir/d2ir_test.go b/d2ir/d2ir_test.go
new file mode 100644
index 000000000..c8b34dbc0
--- /dev/null
+++ b/d2ir/d2ir_test.go
@@ -0,0 +1,69 @@
+package d2ir_test
+
+import (
+ "testing"
+
+ "oss.terrastruct.com/util-go/assert"
+
+ "oss.terrastruct.com/d2/d2ast"
+ "oss.terrastruct.com/d2/d2ir"
+)
+
+func TestCopy(t *testing.T) {
+ t.Parallel()
+
+ const scalStr = `Those who claim the dead never return to life haven't ever been around.`
+ s := &d2ir.Scalar{
+ Value: d2ast.FlatUnquotedString(scalStr),
+ }
+ a := &d2ir.Array{
+ Values: []d2ir.Value{
+ &d2ir.Scalar{
+ Value: &d2ast.Boolean{
+ Value: true,
+ },
+ },
+ },
+ }
+ m2 := &d2ir.Map{
+ Fields: []*d2ir.Field{
+ {Primary_: s},
+ },
+ }
+
+ const keyStr = `Absence makes the heart grow frantic.`
+ f := &d2ir.Field{
+ Name: keyStr,
+
+ Primary_: s,
+ Composite: a,
+ }
+ e := &d2ir.Edge{
+ Primary_: s,
+ Map_: m2,
+ }
+ m := &d2ir.Map{
+ Fields: []*d2ir.Field{f},
+ Edges: []*d2ir.Edge{e},
+ }
+
+ m = m.Copy(nil).(*d2ir.Map)
+ f.Name = `Many a wife thinks her husband is the world's greatest lover.`
+
+ assert.Equal(t, m, m.Fields[0].Parent())
+ assert.Equal(t, keyStr, m.Fields[0].Name)
+ assert.Equal(t, m.Fields[0], m.Fields[0].Primary_.Parent())
+ assert.Equal(t, m.Fields[0], m.Fields[0].Composite.(*d2ir.Array).Parent())
+
+ assert.Equal(t,
+ m.Fields[0].Composite,
+ m.Fields[0].Composite.(*d2ir.Array).Values[0].(*d2ir.Scalar).Parent(),
+ )
+
+ assert.Equal(t, m, m.Edges[0].Parent())
+ assert.Equal(t, m.Edges[0], m.Edges[0].Primary_.Parent())
+ assert.Equal(t, m.Edges[0], m.Edges[0].Map_.Parent())
+
+ assert.Equal(t, m.Edges[0].Map_, m.Edges[0].Map_.Fields[0].Parent())
+ assert.Equal(t, m.Edges[0].Map_.Fields[0], m.Edges[0].Map_.Fields[0].Primary_.Parent())
+}
diff --git a/d2ir/merge.go b/d2ir/merge.go
new file mode 100644
index 000000000..15ba2a9ec
--- /dev/null
+++ b/d2ir/merge.go
@@ -0,0 +1,52 @@
+package d2ir
+
+func OverlayMap(base, overlay *Map) {
+ for _, of := range overlay.Fields {
+ bf := base.GetField(of.Name)
+ if bf == nil {
+ base.Fields = append(base.Fields, of.Copy(base).(*Field))
+ continue
+ }
+ OverlayField(bf, of)
+ }
+
+ for _, oe := range overlay.Edges {
+ bea := base.GetEdges(oe.ID)
+ if len(bea) == 0 {
+ base.Edges = append(base.Edges, oe.Copy(base).(*Edge))
+ continue
+ }
+ be := bea[0]
+ OverlayEdge(be, oe)
+ }
+}
+
+func OverlayField(bf, of *Field) {
+ if of.Primary_ != nil {
+ bf.Primary_ = of.Primary_.Copy(bf).(*Scalar)
+ }
+
+ if of.Composite != nil {
+ if bf.Map() != nil && of.Map() != nil {
+ OverlayMap(bf.Map(), of.Map())
+ } else {
+ bf.Composite = of.Composite.Copy(bf).(*Map)
+ }
+ }
+
+ bf.References = append(bf.References, of.References...)
+}
+
+func OverlayEdge(be, oe *Edge) {
+ if oe.Primary_ != nil {
+ be.Primary_ = oe.Primary_.Copy(be).(*Scalar)
+ }
+ if oe.Map_ != nil {
+ if be.Map_ != nil {
+ OverlayMap(be.Map(), oe.Map_)
+ } else {
+ be.Map_ = oe.Map_.Copy(be).(*Map)
+ }
+ }
+ be.References = append(be.References, oe.References...)
+}
diff --git a/d2ir/query.go b/d2ir/query.go
new file mode 100644
index 000000000..e70ad88df
--- /dev/null
+++ b/d2ir/query.go
@@ -0,0 +1,62 @@
+package d2ir
+
+import (
+ "fmt"
+
+ "oss.terrastruct.com/d2/d2parser"
+)
+
+// QueryAll is only for tests and debugging.
+func (m *Map) QueryAll(idStr string) (na []Node, _ error) {
+ k, err := d2parser.ParseMapKey(idStr)
+ if err != nil {
+ return nil, err
+ }
+
+ if k.Key != nil {
+ f := m.GetField(k.Key.IDA()...)
+ if f == nil {
+ return nil, nil
+ }
+ if len(k.Edges) == 0 {
+ na = append(na, f)
+ return na, nil
+ }
+ m = f.Map()
+ if m == nil {
+ return nil, nil
+ }
+ }
+
+ eida := NewEdgeIDs(k)
+ for _, eid := range eida {
+ ea := m.GetEdges(eid)
+ for _, e := range ea {
+ if k.EdgeKey == nil {
+ na = append(na, e)
+ } else if e.Map_ != nil {
+ f := e.Map_.GetField(k.EdgeKey.IDA()...)
+ if f != nil {
+ na = append(na, f)
+ }
+ }
+ }
+ }
+ return na, nil
+}
+
+// Query is only for tests and debugging.
+func (m *Map) Query(idStr string) (Node, error) {
+ na, err := m.QueryAll(idStr)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(na) == 0 {
+ return nil, nil
+ }
+ if len(na) > 1 {
+ return nil, fmt.Errorf("expected only one query result but got: %#v", err)
+ }
+ return na[0], nil
+}
diff --git a/d2layouts/d2sequence/layout.go b/d2layouts/d2sequence/layout.go
index 14e4c6f80..8f2c6e9e3 100644
--- a/d2layouts/d2sequence/layout.go
+++ b/d2layouts/d2sequence/layout.go
@@ -5,11 +5,12 @@ import (
"sort"
"strings"
+ "oss.terrastruct.com/util-go/go2"
+
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
- "oss.terrastruct.com/util-go/go2"
)
func WithoutSequenceDiagrams(ctx context.Context, g *d2graph.Graph) (map[string]*sequenceDiagram, map[string]int, map[string]int, error) {
@@ -113,6 +114,7 @@ func layoutSequenceDiagram(g *d2graph.Graph, obj *d2graph.Object) (*sequenceDiag
func getLayoutEdges(g *d2graph.Graph, toRemove map[*d2graph.Edge]struct{}) ([]*d2graph.Edge, map[string]int) {
edgeOrder := make(map[string]int)
layoutEdges := make([]*d2graph.Edge, 0, len(g.Edges)-len(toRemove))
+
for i, edge := range g.Edges {
edgeOrder[edge.AbsID()] = i
if _, exists := toRemove[edge]; !exists {
diff --git a/d2layouts/d2sequence/layout_test.go b/d2layouts/d2sequence/layout_test.go
index 50c1e3ffe..20220ce03 100644
--- a/d2layouts/d2sequence/layout_test.go
+++ b/d2layouts/d2sequence/layout_test.go
@@ -377,7 +377,7 @@ container -> c: edge 1
}
func TestSelfEdges(t *testing.T) {
- g := d2graph.NewGraph(nil)
+ g := d2graph.NewGraph()
g.Root.Attributes.Shape = d2graph.Scalar{Value: d2target.ShapeSequenceDiagram}
n1 := g.Root.EnsureChild([]string{"n1"})
n1.Box = geo.NewBox(nil, 100, 100)
@@ -387,7 +387,7 @@ func TestSelfEdges(t *testing.T) {
Src: n1,
Dst: n1,
Index: 0,
- Attributes: d2graph.Attributes{
+ Attributes: &d2graph.Attributes{
Label: d2graph.Scalar{Value: "left to right"},
},
},
@@ -413,11 +413,11 @@ func TestSelfEdges(t *testing.T) {
}
func TestSequenceToDescendant(t *testing.T) {
- g := d2graph.NewGraph(nil)
+ g := d2graph.NewGraph()
g.Root.Attributes.Shape = d2graph.Scalar{Value: d2target.ShapeSequenceDiagram}
a := g.Root.EnsureChild([]string{"a"})
a.Box = geo.NewBox(nil, 100, 100)
- a.Attributes = d2graph.Attributes{
+ a.Attributes = &d2graph.Attributes{
Shape: d2graph.Scalar{Value: shape.PERSON_TYPE},
}
a_t1 := a.EnsureChild([]string{"t1"})
@@ -425,13 +425,15 @@ func TestSequenceToDescendant(t *testing.T) {
g.Edges = []*d2graph.Edge{
{
- Src: a,
- Dst: a_t1,
- Index: 0,
+ Src: a,
+ Dst: a_t1,
+ Index: 0,
+ Attributes: &d2graph.Attributes{},
}, {
- Src: a_t1,
- Dst: a,
- Index: 0,
+ Src: a_t1,
+ Dst: a,
+ Index: 0,
+ Attributes: &d2graph.Attributes{},
},
}
diff --git a/d2layouts/d2sequence/sequence_diagram.go b/d2layouts/d2sequence/sequence_diagram.go
index 22d94d17f..8a61e9e8b 100644
--- a/d2layouts/d2sequence/sequence_diagram.go
+++ b/d2layouts/d2sequence/sequence_diagram.go
@@ -105,6 +105,12 @@ func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) *se
sd.objectRank[actor] = rank
if actor.Width < MIN_ACTOR_WIDTH {
+ dslShape := strings.ToLower(actor.Attributes.Shape.Value)
+ switch dslShape {
+ case d2target.ShapePerson, d2target.ShapeSquare, d2target.ShapeCircle:
+ // scale shape up to min width uniformly
+ actor.Height *= MIN_ACTOR_WIDTH / actor.Width
+ }
actor.Width = MIN_ACTOR_WIDTH
}
sd.maxActorHeight = math.Max(sd.maxActorHeight, actor.Height)
@@ -225,7 +231,7 @@ func (sd *sequenceDiagram) placeGroup(group *d2graph.Object) {
for _, n := range sd.notes {
inGroup := false
for _, ref := range n.References {
- curr := ref.UnresolvedScopeObj
+ curr := ref.ScopeObj
for curr != nil {
if curr == group {
inGroup = true
@@ -324,7 +330,7 @@ func (sd *sequenceDiagram) addLifelineEdges() {
actorLifelineEnd := actor.Center()
actorLifelineEnd.Y = endY
sd.lifelines = append(sd.lifelines, &d2graph.Edge{
- Attributes: d2graph.Attributes{
+ Attributes: &d2graph.Attributes{
Style: d2graph.Style{
StrokeDash: &d2graph.Scalar{Value: fmt.Sprintf("%d", LIFELINE_STROKE_DASH)},
StrokeWidth: &d2graph.Scalar{Value: fmt.Sprintf("%d", LIFELINE_STROKE_WIDTH)},
diff --git a/d2lib/d2.go b/d2lib/d2.go
index bfe9713a9..fbd8222d5 100644
--- a/d2lib/d2.go
+++ b/d2lib/d2.go
@@ -44,32 +44,65 @@ func Compile(ctx context.Context, input string, opts *CompileOptions) (*d2target
return nil, nil, err
}
+ d, err := compile(ctx, g, opts)
+ if err != nil {
+ return nil, nil, err
+ }
+ return d, g, nil
+}
+
+func compile(ctx context.Context, g *d2graph.Graph, opts *CompileOptions) (*d2target.Diagram, error) {
if len(g.Objects) > 0 {
- err = g.SetDimensions(opts.MeasuredTexts, opts.Ruler, opts.FontFamily)
+ err := g.SetDimensions(opts.MeasuredTexts, opts.Ruler, opts.FontFamily)
if err != nil {
- return nil, nil, err
+ return nil, err
}
coreLayout, err := getLayout(opts)
if err != nil {
- return nil, nil, err
+ return nil, err
}
constantNears := d2near.WithoutConstantNears(ctx, g)
err = d2sequence.Layout(ctx, g, coreLayout)
if err != nil {
- return nil, nil, err
+ return nil, err
}
err = d2near.Layout(ctx, g, constantNears)
if err != nil {
- return nil, nil, err
+ return nil, err
}
}
- diagram, err := d2exporter.Export(ctx, g, opts.ThemeID, opts.FontFamily)
- return diagram, g, err
+ d, err := d2exporter.Export(ctx, g, opts.ThemeID, opts.FontFamily)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, l := range g.Layers {
+ ld, err := compile(ctx, l, opts)
+ if err != nil {
+ return nil, err
+ }
+ d.Layers = append(d.Layers, ld)
+ }
+ for _, l := range g.Scenarios {
+ ld, err := compile(ctx, l, opts)
+ if err != nil {
+ return nil, err
+ }
+ d.Scenarios = append(d.Scenarios, ld)
+ }
+ for _, l := range g.Steps {
+ ld, err := compile(ctx, l, opts)
+ if err != nil {
+ return nil, err
+ }
+ d.Steps = append(d.Steps, ld)
+ }
+ return d, nil
}
func getLayout(opts *CompileOptions) (func(context.Context, *d2graph.Graph) error, error) {
diff --git a/d2oracle/edit.go b/d2oracle/edit.go
index 48f7d0692..1d3103a28 100644
--- a/d2oracle/edit.go
+++ b/d2oracle/edit.go
@@ -396,7 +396,6 @@ func Delete(g *d2graph.Graph, key string) (_ *d2graph.Graph, err error) {
if g != g2 {
return g2, nil
}
- g = g2
if len(mk.Edges) == 1 {
obj := g.Root
@@ -1109,7 +1108,7 @@ func move(g *d2graph.Graph, key, newKey string) (*d2graph.Graph, error) {
Key: detachedMK.Key,
MapKey: detachedMK,
Scope: mostNestedRef.Scope,
- }, mostNestedRef.UnresolvedScopeObj)
+ }, mostNestedRef.ScopeObj)
}
}
@@ -1284,8 +1283,8 @@ func move(g *d2graph.Graph, key, newKey string) (*d2graph.Graph, error) {
// We don't want this to be underscore-resolved scope. We want to ignore underscores
var scopeak []string
- if ref.UnresolvedScopeObj != g.Root {
- scopek, err := d2parser.ParseKey(ref.UnresolvedScopeObj.AbsID())
+ if ref.ScopeObj != g.Root {
+ scopek, err := d2parser.ParseKey(ref.ScopeObj.AbsID())
if err != nil {
return nil, err
}
diff --git a/d2oracle/edit_test.go b/d2oracle/edit_test.go
index 7feba4572..dd3b430fc 100644
--- a/d2oracle/edit_test.go
+++ b/d2oracle/edit_test.go
@@ -1373,12 +1373,12 @@ more.(ok.q.z -> p.k): "furbling, v.:"
{
name: "complex_edge_1",
- text: `a.b.(x -> y).q.z
+ text: `a.b.(x -> y).style.animated
`,
key: "a.b",
newName: "ooo",
- exp: `a.ooo.(x -> y).q.z
+ exp: `a.ooo.(x -> y).style.animated
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
if len(g.Objects) != 4 {
@@ -1392,12 +1392,12 @@ more.(ok.q.z -> p.k): "furbling, v.:"
{
name: "complex_edge_2",
- text: `a.b.(x -> y).q.z
+ text: `a.b.(x -> y).style.animated
`,
key: "a.b.x",
newName: "papa",
- exp: `a.b.(papa -> y).q.z
+ exp: `a.b.(papa -> y).style.animated
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
if len(g.Objects) != 4 {
@@ -1454,12 +1454,12 @@ more.(ok.q.z -> p.k): "furbling, v.:"
{
name: "arrows_complex",
- text: `a.b.(x -- y).q.z
+ text: `a.b.(x -- y).style.animated
`,
key: "a.b.(x -- y)[0]",
newName: "(x <-> y)[0]",
- exp: `a.b.(x <-> y).q.z
+ exp: `a.b.(x <-> y).style.animated
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
if len(g.Objects) != 4 {
@@ -3025,7 +3025,7 @@ d
if err == nil {
objectsAfter := len(g.Objects)
if objectsBefore != objectsAfter {
- println(d2format.Format(g.AST))
+ t.Log(d2format.Format(g.AST))
return nil, fmt.Errorf("move cannot destroy or create objects: found %d objects before and %d objects after", objectsBefore, objectsAfter)
}
}
diff --git a/d2parser/parse.go b/d2parser/parse.go
index d8351dcbc..b4c3bf9e8 100644
--- a/d2parser/parse.go
+++ b/d2parser/parse.go
@@ -45,7 +45,7 @@ func Parse(path string, r io.RuneReader, opts *ParseOptions) (*d2ast.Map, error)
}
m := p.parseMap(true)
- if !p.err.empty() {
+ if !p.err.Empty() {
return m, p.err
}
return m, nil
@@ -57,7 +57,7 @@ func ParseKey(key string) (*d2ast.KeyPath, error) {
}
k := p.parseKey()
- if !p.err.empty() {
+ if !p.err.Empty() {
return nil, fmt.Errorf("failed to parse key %q: %w", key, p.err)
}
if k == nil {
@@ -72,7 +72,7 @@ func ParseMapKey(mapKey string) (*d2ast.Key, error) {
}
mk := p.parseMapKey()
- if !p.err.empty() {
+ if !p.err.Empty() {
return nil, fmt.Errorf("failed to parse map key %q: %w", mapKey, p.err)
}
if mk == nil {
@@ -87,7 +87,7 @@ func ParseValue(value string) (d2ast.Value, error) {
}
v := p.parseValue()
- if !p.err.empty() {
+ if !p.err.Empty() {
return nil, fmt.Errorf("failed to parse value %q: %w", value, p.err)
}
if v.Unbox() == nil {
@@ -130,7 +130,16 @@ type ParseError struct {
Errors []d2ast.Error `json:"errs"`
}
-func (pe ParseError) empty() bool {
+func Errorf(n d2ast.Node, f string, v ...interface{}) error {
+ f = "%v: " + f
+ v = append([]interface{}{n.GetRange()}, v...)
+ return d2ast.Error{
+ Range: n.GetRange(),
+ Message: fmt.Sprintf(f, v...),
+ }
+}
+
+func (pe ParseError) Empty() bool {
return pe.IOError == nil && len(pe.Errors) == 0
}
@@ -138,11 +147,12 @@ func (pe ParseError) Error() string {
var sb strings.Builder
if pe.IOError != nil {
sb.WriteString(pe.IOError.Error())
- sb.WriteByte('\n')
}
- for _, err := range pe.Errors {
+ for i, err := range pe.Errors {
+ if pe.IOError != nil || i > 0 {
+ sb.WriteByte('\n')
+ }
sb.WriteString(err.Error())
- sb.WriteByte('\n')
}
return sb.String()
}
diff --git a/d2renderers/d2sketch/sketch_test.go b/d2renderers/d2sketch/sketch_test.go
index f431a62a2..ba733435a 100644
--- a/d2renderers/d2sketch/sketch_test.go
+++ b/d2renderers/d2sketch/sketch_test.go
@@ -63,7 +63,7 @@ func TestSketch(t *testing.T) {
}
People discovery: "People discovery \nservice"
admixer: Ad mixer {
- fill: "#c1a2f3"
+ style.fill: "#c1a2f3"
}
onboarding service: "Onboarding \nservice"
@@ -107,7 +107,7 @@ Android: {
web -> twitter fe
timeline scorer: "Timeline\nScorer" {
- fill: "#ffdef1"
+ style.fill "#ffdef1"
}
home ranker: Home Ranker
@@ -119,7 +119,7 @@ timeline mixer -> home ranker: {
}
timeline mixer -> timeline service
home mixer: Home mixer {
- # fill: "#c1a2f3"
+ # style.fill "#c1a2f3"
}
container0.graphql -> home mixer: {
style.stroke-dash: 4
@@ -146,7 +146,7 @@ prediction service2: Prediction Service {
icon: https://cdn-icons-png.flaticon.com/512/6461/6461819.png
}
home scorer: Home Scorer {
- fill: "#ffdef1"
+ style.fill "#ffdef1"
}
manhattan: Manhattan
memcache: Memcache {
@@ -154,15 +154,15 @@ memcache: Memcache {
}
fetch: Fetch {
- multiple: true
+ style.multiple: true
shape: step
}
feature: Feature {
- multiple: true
+ style.multiple: true
shape: step
}
scoring: Scoring {
- multiple: true
+ style.multiple: true
shape: step
}
fetch -> feature
diff --git a/d2renderers/d2sketch/testdata/all_shapes/sketch.exp.svg b/d2renderers/d2sketch/testdata/all_shapes/sketch.exp.svg
index f0a8aabf2..6e4e30c89 100644
--- a/d2renderers/d2sketch/testdata/all_shapes/sketch.exp.svg
+++ b/d2renderers/d2sketch/testdata/all_shapes/sketch.exp.svg
@@ -3,7 +3,7 @@
id="d2-svg"
style="background: white;"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
-width="1597" height="835" viewBox="-102 -102 1597 835">
Oldest message
-Offset
-Last message
-Next message will be Oldest message Offset Last message Next message will be
+
inserted here
Oldest message
-Offset
-Last message
-Next message will be Oldest message Offset Last message Next message will be
+
inserted here
linux: because a PC is a terrible thing to waste
-linux: because a PC is a terrible thing to waste
+linux: because a PC is a terrible thing to waste
-linux: because a PC is a terrible thing to waste
+If we can't fix it, it ain't broke.
Dieters live life in the fasting lane.
-If we can't fix it, it ain't broke.
Dieters live life in the fasting lane.
-Unlike a pre-formatted code block, a code span indicates code within a normal paragraph. For example:
-Unlike a pre-formatted code block, a code span indicates code within a normal paragraph. For example:
-Note: This document is itself written using Markdown; you
+ Note: This document is itself written using Markdown; you
can see the source for it by adding '.text' to the URL.
Overview
-
Note: This document is itself written using Markdown; you
+ Note: This document is itself written using Markdown; you
can see the source for it by adding '.text' to the URL.
Overview
-
List items may consist of multiple paragraphs. Each subsequent
+ List items may consist of multiple paragraphs. Each subsequent
paragraph in a list item must be indented by either 4 spaces
or one tab: Another item in the same list.
@@ -827,8 +827,8 @@ sit amet, consectetuer adipiscing elit.
List items may consist of multiple paragraphs. Each subsequent
+ List items may consist of multiple paragraphs. Each subsequent
paragraph in a list item must be indented by either 4 spaces
or one tab: Another item in the same list.
@@ -827,8 +827,8 @@ sit amet, consectetuer adipiscing elit.
Festivity Level 1: Your guests are chatting amiably with each other.
-Festivity Level 1: Your guests are chatting amiably with each other.
-Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Lorem ipsum dolor sit amet, consectetur adipiscing elit,
sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
{
+{
fenced: "block",
of: "json",
}
-a b
-
+ {
+{
fenced: "block",
of: "json",
}
-a b
-
+ a line of text and an
+a line of text and an
{
indented: "block",
of: "json",
}
-a line of text and an
+a line of text and an
{
indented: "block",
of: "json",
}
-code
code
code
code
A paragraph is simply one or more consecutive lines of text, separated
+ A paragraph is simply one or more consecutive lines of text, separated
by one or more blank lines. (A blank line is any line that looks like a
blank line -- a line containing nothing but spaces or tabs is considered
blank.) Normal paragraphs should not be indented with spaces or tabs.
A paragraph is simply one or more consecutive lines of text, separated
+ A paragraph is simply one or more consecutive lines of text, separated
by one or more blank lines. (A blank line is any line that looks like a
blank line -- a line containing nothing but spaces or tabs is considered
blank.) Normal paragraphs should not be indented with spaces or tabs.
Here is an example of AppleScript:
+Here is an example of AppleScript:
tell application "Foo"
beep
end tell
A code block continues until it reaches a line that is not indented (or the end of the article).
-Here is an example of AppleScript:
+Here is an example of AppleScript:
tell application "Foo"
beep
end tell
A code block continues until it reaches a line that is not indented (or the end of the article).
-