diff --git a/src/D2Board.cs b/src/D2Board.cs
new file mode 100644
index 0000000..5c4e91d
--- /dev/null
+++ b/src/D2Board.cs
@@ -0,0 +1,60 @@
+using System.Collections;
+
+namespace d2;
+
+///
+/// A named board within a layer, scenario, or step collection.
+///
+public sealed record D2Board : D2Statement, IEnumerable
+{
+ private readonly List _statements;
+
+ public string Name { get; }
+
+ public IReadOnlyList Statements => _statements;
+
+ public D2Board(string name)
+ : this(name, Array.Empty())
+ {
+ }
+
+ public D2Board(string name, IEnumerable statements)
+ {
+ Name = ValidateName(name);
+ if (statements is null)
+ {
+ throw new ArgumentNullException(nameof(statements));
+ }
+
+ _statements = statements.ToList();
+ if (_statements.Any(statement => statement is null))
+ {
+ throw new ArgumentException("A board cannot contain a null statement.", nameof(statements));
+ }
+ }
+
+ public void Add(D2Statement statement)
+ {
+ if (statement is null)
+ {
+ throw new ArgumentNullException(nameof(statement));
+ }
+
+ _statements.Add(statement);
+ }
+
+ internal override IEnumerable Lines()
+ => D2Writer.BlockIdentifier(Name, _statements.SelectMany(statement => statement.Lines()));
+
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+
+ public IEnumerator GetEnumerator() => _statements.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ private static string ValidateName(string name)
+ {
+ _ = D2Writer.Identifier(name);
+ return name;
+ }
+}
diff --git a/src/D2BoardCollection.cs b/src/D2BoardCollection.cs
new file mode 100644
index 0000000..4cf66e7
--- /dev/null
+++ b/src/D2BoardCollection.cs
@@ -0,0 +1,74 @@
+using System.Collections;
+
+namespace d2;
+
+public enum D2BoardKind
+{
+ Layers,
+ Scenarios,
+ Steps,
+}
+
+///
+/// A D2 composition block containing layers, scenarios, or steps.
+///
+public sealed record D2BoardCollection : D2Statement, IEnumerable
+{
+ private readonly List _boards;
+
+ public D2BoardKind Kind { get; }
+
+ public IReadOnlyList Boards => _boards;
+
+ public D2BoardCollection(D2BoardKind kind)
+ : this(kind, Array.Empty())
+ {
+ }
+
+ public D2BoardCollection(D2BoardKind kind, IEnumerable boards)
+ {
+ if (!Enum.IsDefined(typeof(D2BoardKind), kind))
+ {
+ throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown D2 board kind.");
+ }
+
+ if (boards is null)
+ {
+ throw new ArgumentNullException(nameof(boards));
+ }
+
+ Kind = kind;
+ _boards = boards.ToList();
+ if (_boards.Any(board => board is null))
+ {
+ throw new ArgumentException("A board collection cannot contain null.", nameof(boards));
+ }
+ }
+
+ public void Add(D2Board board)
+ {
+ if (board is null)
+ {
+ throw new ArgumentNullException(nameof(board));
+ }
+
+ _boards.Add(board);
+ }
+
+ internal override IEnumerable Lines()
+ => D2Writer.Block(Keyword(Kind), _boards.SelectMany(board => board.Lines()));
+
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+
+ public IEnumerator GetEnumerator() => _boards.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ private static string Keyword(D2BoardKind kind) => kind switch
+ {
+ D2BoardKind.Layers => "layers",
+ D2BoardKind.Scenarios => "scenarios",
+ D2BoardKind.Steps => "steps",
+ _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown D2 board kind."),
+ };
+}
diff --git a/src/D2Comment.cs b/src/D2Comment.cs
new file mode 100644
index 0000000..d82abb0
--- /dev/null
+++ b/src/D2Comment.cs
@@ -0,0 +1,25 @@
+namespace d2;
+
+///
+/// A D2 line comment. Every input line is prefixed so multiline text cannot
+/// escape the comment.
+///
+public sealed record D2Comment : D2Statement
+{
+ public string Text { get; }
+
+ public D2Comment(string text)
+ {
+ if (text is null)
+ {
+ throw new ArgumentNullException(nameof(text));
+ }
+
+ Text = text;
+ }
+
+ internal override IEnumerable Lines()
+ => D2Writer.Lines(Text).Select(line => line.Length == 0 ? "#" : $"# {line}");
+
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+}
diff --git a/src/D2Connection.cs b/src/D2Connection.cs
index dc2baa2..8653cdc 100644
--- a/src/D2Connection.cs
+++ b/src/D2Connection.cs
@@ -1,3 +1,5 @@
+using System.Collections;
+
namespace d2;
public record class D2Connection(
@@ -5,19 +7,48 @@ public record class D2Connection(
string Second,
Direction Direction,
string? Label = ""
-)
+) : D2Statement, IEnumerable
{
- internal IEnumerable Lines()
+ private readonly List _statements = new();
+
+ public IReadOnlyList Statements => _statements;
+
+ public void Add(D2Property property) => Add((D2Statement)property);
+
+ public void Add(D2Statement statement)
+ {
+ if (statement is null)
+ {
+ throw new ArgumentNullException(nameof(statement));
+ }
+
+ _statements.Add(statement);
+ }
+
+ internal override IEnumerable Lines()
{
var @base = $"{D2Writer.Reference(First)} {Direction} {D2Writer.Reference(Second)}";
- if (!string.IsNullOrWhiteSpace(Label))
+ var hasLabel = !string.IsNullOrWhiteSpace(Label);
+ if (hasLabel)
{
- @base += $": {D2Writer.String(Label)}";
+ @base += $": {D2Writer.String(Label!)}";
}
- return new List { @base };
+ if (_statements.Count == 0)
+ {
+ return new[] { @base };
+ }
+
+ var openingLine = hasLabel ? $"{@base} {{" : $"{@base}: {{";
+ return new[] { openingLine }
+ .Concat(D2Writer.Indent(_statements.SelectMany(statement => statement.Lines())))
+ .Append("}");
}
public override string ToString()
=> string.Join(Environment.NewLine, Lines());
+
+ public IEnumerator GetEnumerator() => _statements.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
diff --git a/src/D2Diagram.cs b/src/D2Diagram.cs
index 049c18c..9bc32a6 100644
--- a/src/D2Diagram.cs
+++ b/src/D2Diagram.cs
@@ -1,23 +1,72 @@
namespace d2;
-public record class D2Diagram(
- IEnumerable Shapes,
- IEnumerable Connections
-)
+///
+/// An ordered D2 document. Statement order is retained exactly, which is
+/// significant for features such as sequence diagrams and composition boards.
+///
+public record class D2Diagram
{
- public D2Diagram Add(D2Shape shape)
- => this with { Shapes = Shapes.Append(shape) };
- public D2Diagram Add(D2Connection connection)
- => this with { Connections = Connections.Append(connection) };
+ private readonly IReadOnlyList _statements;
- internal IEnumerable Lines()
+ public IReadOnlyList Statements => _statements;
+
+ public IEnumerable Shapes => _statements.OfType();
+
+ public IEnumerable Connections => _statements.OfType();
+
+ public D2Diagram(IEnumerable Shapes, IEnumerable Connections)
+ : this(Combine(Shapes, Connections))
+ {
+ }
+
+ public D2Diagram(IEnumerable statements)
{
- var shapes = Shapes.SelectMany(s => s.Lines()).ToList();
- var connections = Connections.SelectMany(c => c.Lines()).ToList();
+ if (statements is null)
+ {
+ throw new ArgumentNullException(nameof(statements));
+ }
+
+ var materialized = statements.ToList();
+ if (materialized.Any(statement => statement is null))
+ {
+ throw new ArgumentException("A diagram cannot contain a null statement.", nameof(statements));
+ }
+
+ _statements = materialized;
+ }
- return shapes.Concat(connections);
+ public D2Diagram Add(D2Shape shape) => Add((D2Statement)shape);
+
+ public D2Diagram Add(D2Connection connection) => Add((D2Statement)connection);
+
+ public D2Diagram Add(D2Statement statement)
+ {
+ if (statement is null)
+ {
+ throw new ArgumentNullException(nameof(statement));
+ }
+ return new D2Diagram(_statements.Append(statement));
}
- public override string ToString()
- => string.Join(Environment.NewLine, Lines());
-}
\ No newline at end of file
+ internal IEnumerable Lines()
+ => _statements.SelectMany(statement => statement.Lines());
+
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+
+ private static IEnumerable Combine(
+ IEnumerable shapes,
+ IEnumerable connections)
+ {
+ if (shapes is null)
+ {
+ throw new ArgumentNullException(nameof(shapes));
+ }
+
+ if (connections is null)
+ {
+ throw new ArgumentNullException(nameof(connections));
+ }
+
+ return shapes.Cast().Concat(connections);
+ }
+}
diff --git a/src/D2Property.cs b/src/D2Property.cs
new file mode 100644
index 0000000..c26e1fd
--- /dev/null
+++ b/src/D2Property.cs
@@ -0,0 +1,97 @@
+namespace d2;
+
+///
+/// A safely serialized D2 property. Values are always treated as data, never as
+/// D2 source code. Use when raw D2 is intentional.
+///
+public sealed record D2Property : D2Statement
+{
+ private readonly IReadOnlyList? _statements;
+
+ public string Name { get; }
+
+ public object? Value { get; }
+
+ public bool IsBlock => _statements is not null;
+
+ public IReadOnlyList Statements => _statements ?? Array.Empty();
+
+ public D2Property(string name, string value)
+ {
+ Name = ValidateName(name);
+ if (value is null)
+ {
+ throw new ArgumentNullException(nameof(value));
+ }
+ Value = value;
+ }
+
+ public D2Property(string name, bool value)
+ {
+ Name = ValidateName(name);
+ Value = value;
+ }
+
+ public D2Property(string name, int value)
+ {
+ Name = ValidateName(name);
+ Value = value;
+ }
+
+ public D2Property(string name, double value)
+ {
+ Name = ValidateName(name);
+ Value = value;
+ }
+
+ public D2Property(string name, IEnumerable statements)
+ {
+ Name = ValidateName(name);
+ _statements = Materialize(statements, nameof(statements));
+ }
+
+ internal override IEnumerable Lines()
+ {
+ if (_statements is null)
+ {
+ return new[] { $"{D2Writer.Reference(Name)}: {SerializeValue()}" };
+ }
+
+ return D2Writer.Block(Name, _statements.SelectMany(statement => statement.Lines()));
+ }
+
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+
+ private string SerializeValue() => Value switch
+ {
+ string value => D2Writer.String(value),
+ bool value => D2Writer.Boolean(value),
+ int value => D2Writer.Integer(value),
+ double value => D2Writer.Number(value),
+ _ => throw new InvalidOperationException("A scalar D2 property must have a supported value."),
+ };
+
+ private static string ValidateName(string name)
+ {
+ _ = D2Writer.Reference(name);
+ return name;
+ }
+
+ private static IReadOnlyList Materialize(
+ IEnumerable statements,
+ string parameterName)
+ {
+ if (statements is null)
+ {
+ throw new ArgumentNullException(parameterName);
+ }
+
+ var result = statements.ToList();
+ if (result.Any(statement => statement is null))
+ {
+ throw new ArgumentException("A D2 statement collection cannot contain null.", parameterName);
+ }
+
+ return result;
+ }
+}
diff --git a/src/D2RawStatement.cs b/src/D2RawStatement.cs
new file mode 100644
index 0000000..77087a2
--- /dev/null
+++ b/src/D2RawStatement.cs
@@ -0,0 +1,24 @@
+namespace d2;
+
+///
+/// An explicit escape hatch for inserting unescaped D2 source. The caller is
+/// responsible for ensuring that the source is valid and trusted.
+///
+public sealed record D2RawStatement : D2Statement
+{
+ public string Source { get; }
+
+ public D2RawStatement(string source)
+ {
+ if (source is null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ Source = source;
+ }
+
+ internal override IEnumerable Lines() => D2Writer.Lines(Source);
+
+ public override string ToString() => string.Join(Environment.NewLine, Lines());
+}
diff --git a/src/D2Shape.cs b/src/D2Shape.cs
index 97a91cb..eb7bf76 100644
--- a/src/D2Shape.cs
+++ b/src/D2Shape.cs
@@ -8,27 +8,33 @@ public record class D2Shape(
Shape? Shape = default,
D2Style? Style = default,
string? Near = default
-) : IEnumerable, IEnumerable, IEnumerable
+) : D2Statement, IEnumerable, IEnumerable, IEnumerable
{
- private readonly List _shapes = new();
- private readonly List _connections = new();
- private readonly List _texts = new();
+ private readonly List _statements = new();
+
+ public IReadOnlyList Statements => _statements;
public string Icon { get; set; } = string.Empty;
- public void Add(D2Shape shape) => _shapes.Add(shape);
+ public void Add(D2Shape shape) => Add((D2Statement)shape);
- public void Add(D2Connection connection) => _connections.Add(connection);
+ public void Add(D2Connection connection) => Add((D2Statement)connection);
- public void Add(D2Text text) => _texts.Add(text);
+ public void Add(D2Text text) => Add((D2Statement)text);
- internal IEnumerable Lines()
+ public void Add(D2Statement statement)
{
- var shapes = _shapes.SelectMany(s => s.Lines()).ToList();
- var connections = _connections.SelectMany(c => c.Lines()).ToList();
- var texts = _texts.SelectMany(t => t.Lines()).ToList();
+ if (statement is null)
+ {
+ throw new ArgumentNullException(nameof(statement));
+ }
- var properties = shapes.Concat(connections).Concat(texts).ToList();
+ _statements.Add(statement);
+ }
+
+ internal override IEnumerable Lines()
+ {
+ var properties = _statements.SelectMany(statement => statement.Lines()).ToList();
if (!string.IsNullOrWhiteSpace(Icon))
{
@@ -40,9 +46,9 @@ internal IEnumerable Lines()
properties.Add($"shape: {Shape}");
}
- if (!string.IsNullOrEmpty(Near))
+ if (Near is { Length: > 0 } near)
{
- properties.Add($"near: {D2Writer.String(Near)}");
+ properties.Add($"near: {D2Writer.String(near)}");
}
if (Style is not null)
@@ -56,15 +62,15 @@ internal IEnumerable Lines()
public override string ToString()
=> string.Join(Environment.NewLine, Lines());
- public IEnumerator GetEnumerator()
- => _shapes.GetEnumerator();
+ public IEnumerator GetEnumerator()
+ => _statements.OfType().GetEnumerator();
- IEnumerator IEnumerable.GetEnumerator()
- => GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator()
+ => GetEnumerator();
- IEnumerator IEnumerable.GetEnumerator()
- => _texts.GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator()
+ => _statements.OfType().GetEnumerator();
- IEnumerator IEnumerable.GetEnumerator()
- => _connections.GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator()
+ => _statements.OfType().GetEnumerator();
}
diff --git a/src/D2Statement.cs b/src/D2Statement.cs
new file mode 100644
index 0000000..a75920d
--- /dev/null
+++ b/src/D2Statement.cs
@@ -0,0 +1,9 @@
+namespace d2;
+
+///
+/// A statement that can be written in a D2 document or object body.
+///
+public abstract record D2Statement
+{
+ internal abstract IEnumerable Lines();
+}
diff --git a/src/D2Text.cs b/src/D2Text.cs
index fd9536f..ead295d 100644
--- a/src/D2Text.cs
+++ b/src/D2Text.cs
@@ -5,9 +5,9 @@ public record D2Text(
string Text,
string Format,
int Pipes
-)
+) : D2Statement
{
- internal IEnumerable Lines()
+ internal override IEnumerable Lines()
{
if (Pipes < 1)
{
diff --git a/src/D2Writer.cs b/src/D2Writer.cs
index 871eb99..f3c4901 100644
--- a/src/D2Writer.cs
+++ b/src/D2Writer.cs
@@ -28,7 +28,23 @@ internal static string Reference(string value)
// A dot is meaningful in D2: it navigates to a nested object. Quote each
// segment independently so path semantics survive without allowing a
// segment's contents to become syntax.
- return string.Join(".", value.Split('.').Select(IdentifierSegment));
+ var segments = value.Split('.');
+ if (segments.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new ArgumentException("A D2 reference cannot contain an empty path segment.", nameof(value));
+ }
+
+ return string.Join(".", segments.Select(IdentifierSegment));
+ }
+
+ internal static string Identifier(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new ArgumentException("A D2 identifier cannot be null, empty, or whitespace.", nameof(value));
+ }
+
+ return IdentifierSegment(value);
}
internal static string String(string value)
@@ -45,7 +61,31 @@ internal static string String(string value)
internal static string Integer(int value) => value.ToString(CultureInfo.InvariantCulture);
- internal static string Number(double value) => value.ToString("R", CultureInfo.InvariantCulture);
+ internal static string Number(double value)
+ {
+ if (double.IsNaN(value) || double.IsInfinity(value))
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), value, "A D2 number must be finite.");
+ }
+
+ return value.ToString("R", CultureInfo.InvariantCulture);
+ }
+
+ internal static IEnumerable Block(string name, IEnumerable statements)
+ => BlockSerialized(Reference(name), statements);
+
+ internal static IEnumerable BlockIdentifier(string name, IEnumerable statements)
+ => BlockSerialized(Identifier(name), statements);
+
+ private static IEnumerable BlockSerialized(string name, IEnumerable statements)
+ {
+ if (statements is null)
+ {
+ throw new ArgumentNullException(nameof(statements));
+ }
+
+ return new[] { $"{name}: {{" }.Concat(Indent(statements)).Append("}");
+ }
internal static IEnumerable Object(
string name,
diff --git a/test/ExtensibleModelTests.cs b/test/ExtensibleModelTests.cs
new file mode 100644
index 0000000..e5032d9
--- /dev/null
+++ b/test/ExtensibleModelTests.cs
@@ -0,0 +1,295 @@
+using System.ComponentModel;
+using System.Diagnostics;
+
+namespace Tests;
+
+[TestClass]
+public class ExtensibleModelTests
+{
+ [TestMethod]
+ public void Diagram_PreservesMixedStatementOrder()
+ {
+ var diagram = new D2Diagram(new D2Statement[]
+ {
+ new D2Property("direction", "right"),
+ new D2Shape("actor", "Actor"),
+ new D2Comment("the request happens next"),
+ new D2Connection("actor", "service", Direction.TO, "request"),
+ new D2Shape("service", "Service"),
+ });
+
+ Assert.AreEqual(
+ Lines(
+ "direction: right",
+ "actor: Actor",
+ "# the request happens next",
+ "actor -> service: request",
+ "service: Service"),
+ diagram.ToString());
+ }
+
+ [TestMethod]
+ public void Shape_PreservesMixedBodyStatementOrder()
+ {
+ var sequence = new D2Shape("login", "Login", Shape.SequenceDiagram)
+ {
+ new D2Shape("user", "User"),
+ new D2Shape("api", "API"),
+ new D2Connection("user", "api", Direction.TO, "sign in"),
+ new D2Comment("the response must remain after the request"),
+ new D2Connection("api", "user", Direction.TO, "session"),
+ };
+
+ Assert.AreEqual(
+ Lines(
+ "login: Login {",
+ " user: User",
+ " api: API",
+ " user -> api: sign in",
+ " # the response must remain after the request",
+ " api -> user: session",
+ " shape: sequence_diagram",
+ "}"),
+ sequence.ToString());
+ }
+
+ [TestMethod]
+ public void Comment_PrefixesEveryLineAndRawStatementIsExplicitlyUnescaped()
+ {
+ var diagram = new D2Diagram(new D2Statement[]
+ {
+ new D2Comment("safe\r\nx -> injected\rempty next\n"),
+ new D2RawStatement("raw -> syntax\r\nraw.style.opacity: 0.5"),
+ });
+
+ Assert.AreEqual(
+ Lines(
+ "# safe",
+ "# x -> injected",
+ "# empty next",
+ "#",
+ "raw -> syntax",
+ "raw.style.opacity: 0.5"),
+ diagram.ToString());
+ }
+
+ [TestMethod]
+ public void RootProperties_SafelySerializeNestedConfiguration()
+ {
+ var config = new D2Property("vars", new D2Statement[]
+ {
+ new D2Property("d2-config", new D2Statement[]
+ {
+ new D2Property("theme-id", 300),
+ new D2Property("center", true),
+ new D2Property("layout-engine", "elk"),
+ }),
+ });
+ var diagram = new D2Diagram(new D2Statement[]
+ {
+ new D2Property("direction", "right"),
+ config,
+ new D2Property("style.fill", "#f4a261"),
+ });
+
+ Assert.AreEqual(
+ Lines(
+ "direction: right",
+ "vars: {",
+ " d2-config: {",
+ " theme-id: 300",
+ " center: true",
+ " layout-engine: elk",
+ " }",
+ "}",
+ "style.fill: \"#f4a261\""),
+ diagram.ToString());
+ }
+
+ [TestMethod]
+ public void Connection_SupportsOrderedGenericProperties()
+ {
+ var connection = new D2Connection("client", "server", Direction.TO, "call")
+ {
+ new D2Property("style", new D2Statement[]
+ {
+ new D2Property("stroke", "#112233"),
+ new D2Property("opacity", 0.5),
+ }),
+ new D2Property("link", "https://example.com/path#details"),
+ new D2Property("tooltip", "uses: ${token}"),
+ };
+
+ Assert.AreEqual(
+ Lines(
+ "client -> server: call {",
+ " style: {",
+ " stroke: \"#112233\"",
+ " opacity: 0.5",
+ " }",
+ " link: \"https://example.com/path#details\"",
+ " tooltip: \"uses: \\${token}\"",
+ "}"),
+ connection.ToString());
+ }
+
+ [TestMethod]
+ public void Boards_SupportNestedLayersScenariosAndSteps()
+ {
+ var steps = new D2BoardCollection(D2BoardKind.Steps)
+ {
+ new D2Board("1") { new D2Shape("queued", null) },
+ new D2Board("2") { new D2Connection("queued", "done", Direction.TO) },
+ };
+ var scenarios = new D2BoardCollection(D2BoardKind.Scenarios)
+ {
+ new D2Board("happy.path")
+ {
+ new D2Shape("worker", null),
+ steps,
+ },
+ };
+ var layers = new D2BoardCollection(D2BoardKind.Layers)
+ {
+ new D2Board("detail")
+ {
+ new D2Property("direction", "right"),
+ new D2Connection("client", "worker", Direction.TO),
+ scenarios,
+ },
+ };
+ var diagram = new D2Diagram(new D2Statement[]
+ {
+ new D2Shape("overview", null),
+ layers,
+ });
+
+ Assert.AreEqual(
+ Lines(
+ "overview",
+ "layers: {",
+ " detail: {",
+ " direction: right",
+ " client -> worker",
+ " scenarios: {",
+ " \"happy.path\": {",
+ " worker",
+ " steps: {",
+ " \"1\": {",
+ " queued",
+ " }",
+ " \"2\": {",
+ " queued -> done",
+ " }",
+ " }",
+ " }",
+ " }",
+ " }",
+ "}"),
+ diagram.ToString());
+ }
+
+ [TestMethod]
+ public void GenericNamesAreValidatedAndNumbersMustBeFinite()
+ {
+ Assert.ThrowsExactly(() => new D2Property(" ", "value"));
+ Assert.ThrowsExactly(() => new D2Property("style..fill", "value"));
+ Assert.ThrowsExactly(() => new D2Board(string.Empty));
+ Assert.ThrowsExactly(() => new D2Property("opacity", double.NaN).ToString());
+ }
+
+ [TestMethod]
+ public void ExtensibleDocument_PassesD2ValidationWhenCliIsAvailable()
+ {
+ var connection = new D2Connection("client", "server", Direction.TO, "calls # safely")
+ {
+ new D2Property("style", new D2Statement[]
+ {
+ new D2Property("stroke", "#112233"),
+ new D2Property("opacity", 0.5),
+ }),
+ new D2Property("link", "https://example.com/docs#api"),
+ new D2Property("tooltip", "API: ${not-code}"),
+ };
+ var layers = new D2BoardCollection(D2BoardKind.Layers)
+ {
+ new D2Board("detail")
+ {
+ new D2Shape("database", "Primary #1", Shape.Cylinder),
+ new D2BoardCollection(D2BoardKind.Scenarios)
+ {
+ new D2Board("failover")
+ {
+ new D2Connection("database", "replica", Direction.TO, "replicates"),
+ },
+ },
+ },
+ };
+ var diagram = new D2Diagram(new D2Statement[]
+ {
+ new D2Comment("generated through ordered statements"),
+ new D2Property("direction", "right"),
+ new D2Property("vars", new D2Statement[]
+ {
+ new D2Property("d2-config", new D2Statement[]
+ {
+ new D2Property("pad", 0),
+ new D2Property("center", true),
+ }),
+ }),
+ new D2Shape("client", "Client"),
+ new D2Shape("server", "Server"),
+ connection,
+ layers,
+ });
+
+ ValidateWithD2(diagram.ToString());
+ }
+
+ private static void ValidateWithD2(string source)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"d2lang-cs-model-{Guid.NewGuid():N}.d2");
+ File.WriteAllText(path, source);
+
+ try
+ {
+ using var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "d2",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ }
+ };
+ process.StartInfo.ArgumentList.Add("validate");
+ process.StartInfo.ArgumentList.Add(path);
+
+ try
+ {
+ process.Start();
+ }
+ catch (Win32Exception)
+ {
+ Assert.Inconclusive("The D2 CLI is not installed; parser-backed validation was skipped.");
+ return;
+ }
+
+ var standardOutput = process.StandardOutput.ReadToEnd();
+ var standardError = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+
+ Assert.AreEqual(
+ 0,
+ process.ExitCode,
+ $"d2 validate failed.{Environment.NewLine}{standardOutput}{standardError}{Environment.NewLine}{source}");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines);
+}