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); +}