From 530efb43bc63dbb285fe7dedf5e00753cff32653 Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Sun, 9 Aug 2026 11:56:56 +0200 Subject: [PATCH 1/2] Make D2 serialization safe --- src/D2Connection.cs | 6 +- src/D2Shape.cs | 6 +- src/D2Style.cs | 26 +++-- src/D2Text.cs | 34 +++++-- src/D2Writer.cs | 130 +++++++++++++++++++++++++ src/Utils.cs | 37 +------- test/UnitTests.cs | 227 ++++++++++++++++++++++++++++++++++++++++---- 7 files changed, 390 insertions(+), 76 deletions(-) create mode 100644 src/D2Writer.cs diff --git a/src/D2Connection.cs b/src/D2Connection.cs index e27588a..dc2baa2 100644 --- a/src/D2Connection.cs +++ b/src/D2Connection.cs @@ -9,10 +9,10 @@ public record class D2Connection( { internal IEnumerable Lines() { - var @base = $"{First} {Direction} {Second}"; + var @base = $"{D2Writer.Reference(First)} {Direction} {D2Writer.Reference(Second)}"; if (!string.IsNullOrWhiteSpace(Label)) { - @base += $": {Label}"; + @base += $": {D2Writer.String(Label)}"; } return new List { @base }; @@ -20,4 +20,4 @@ internal IEnumerable Lines() public override string ToString() => string.Join(Environment.NewLine, Lines()); -} \ No newline at end of file +} diff --git a/src/D2Shape.cs b/src/D2Shape.cs index 215f318..97a91cb 100644 --- a/src/D2Shape.cs +++ b/src/D2Shape.cs @@ -32,7 +32,7 @@ internal IEnumerable Lines() if (!string.IsNullOrWhiteSpace(Icon)) { - properties.Add($"icon: {Icon}"); + properties.Add($"icon: {D2Writer.String(Icon)}"); } if (Shape is not null) @@ -42,7 +42,7 @@ internal IEnumerable Lines() if (!string.IsNullOrEmpty(Near)) { - properties.Add($"near: {Near}"); + properties.Add($"near: {D2Writer.String(Near)}"); } if (Style is not null) @@ -50,7 +50,7 @@ internal IEnumerable Lines() properties.AddRange(Style.Lines()); } - return Utils.AddLabelAndProperties(Name, Label, properties); + return D2Writer.Object(Name, Label, properties); } public override string ToString() diff --git a/src/D2Style.cs b/src/D2Style.cs index 2b2aec1..06d72dc 100644 --- a/src/D2Style.cs +++ b/src/D2Style.cs @@ -5,28 +5,34 @@ public record class D2Style( int? StrokeWidth, string? Fill, bool? Shadow, - int? Opacity, + double? Opacity, int? StrokeDash, bool? ThreeD ) { public IEnumerable Lines() { + if (Opacity is { } opacity && + (double.IsNaN(opacity) || double.IsInfinity(opacity) || opacity is < 0 or > 1)) + { + throw new ArgumentOutOfRangeException(nameof(Opacity), Opacity, "Opacity must be between 0 and 1."); + } + var styles = new List(); - if (Stroke is not null) styles.Add($"stroke: {Stroke}"); - if (StrokeWidth is not null) styles.Add($"stroke-width: {StrokeWidth}"); - if (Fill is not null) styles.Add($"fill: {Fill}"); - if (Shadow is not null) styles.Add($"shadow: {Utils.StringifyBoolean(Shadow)}"); - if (Opacity is not null) styles.Add($"opacity: {Opacity}"); - if (StrokeDash is not null) styles.Add($"stroke-dash: {StrokeDash}"); - if (ThreeD is not null) styles.Add($"3d: {Utils.StringifyBoolean(ThreeD)}"); + if (Stroke is not null) styles.Add($"stroke: {D2Writer.String(Stroke)}"); + if (StrokeWidth is not null) styles.Add($"stroke-width: {D2Writer.Integer(StrokeWidth.Value)}"); + if (Fill is not null) styles.Add($"fill: {D2Writer.String(Fill)}"); + if (Shadow is not null) styles.Add($"shadow: {D2Writer.Boolean(Shadow.Value)}"); + if (Opacity is not null) styles.Add($"opacity: {D2Writer.Number(Opacity.Value)}"); + if (StrokeDash is not null) styles.Add($"stroke-dash: {D2Writer.Integer(StrokeDash.Value)}"); + if (ThreeD is not null) styles.Add($"3d: {D2Writer.Boolean(ThreeD.Value)}"); return styles.Count == 0 ? new List() - : Utils.AddLabelAndProperties("style", null, styles); + : D2Writer.Object("style", null, styles); } public override string ToString() => string.Join(Environment.NewLine, Lines()); -} \ No newline at end of file +} diff --git a/src/D2Text.cs b/src/D2Text.cs index 34067c0..fd9536f 100644 --- a/src/D2Text.cs +++ b/src/D2Text.cs @@ -9,14 +9,36 @@ int Pipes { internal IEnumerable Lines() { - var sep = "|".Repeat(Pipes); + if (Pipes < 1) + { + throw new ArgumentOutOfRangeException(nameof(Pipes), Pipes, "A block string delimiter needs at least one pipe."); + } - return new List() - .Append($"{Property}:{sep}{Format}") - .Concat(Text.Split(Environment.NewLine)) - .Append(sep); + if (string.IsNullOrWhiteSpace(Format) || !Format.All(IsAsciiFormatCharacter)) + { + throw new ArgumentException("The block string format must contain only ASCII letters, digits, underscores, or hyphens.", nameof(Format)); + } + + var textLines = D2Writer.Lines(Text); + var pipeCount = Pipes; + while (textLines.Contains(new string('|', pipeCount), StringComparer.Ordinal)) + { + pipeCount++; + } + + var separator = new string('|', pipeCount); + + return new[] { $"{D2Writer.Reference(Property)}:{separator}{Format}" } + .Concat(textLines) + .Append(separator); } + private static bool IsAsciiFormatCharacter(char value) + => value is >= 'a' and <= 'z' + or >= 'A' and <= 'Z' + or >= '0' and <= '9' + or '_' or '-'; + public override string ToString() => string.Join(Environment.NewLine, Lines()); -} \ No newline at end of file +} diff --git a/src/D2Writer.cs b/src/D2Writer.cs new file mode 100644 index 0000000..871eb99 --- /dev/null +++ b/src/D2Writer.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace d2; + +/// +/// Serializes values into D2 syntax. Keeping these rules in one place prevents +/// individual model types from accidentally emitting user input as D2 code. +/// +internal static class D2Writer +{ + private static readonly Regex BareIdentifier = new( + "^[\\p{L}_][\\p{L}\\p{N}_-]*$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + private static readonly Regex BareString = new( + "^[\\p{L}\\p{N}_][\\p{L}\\p{N} _./-]*$", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + internal static string Reference(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("A D2 reference cannot be null, empty, or whitespace.", nameof(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)); + } + + internal static string String(string value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + return BareString.IsMatch(value) ? value : Quoted(value); + } + + internal static string Boolean(bool value) => value ? "true" : "false"; + + internal static string Integer(int value) => value.ToString(CultureInfo.InvariantCulture); + + internal static string Number(double value) => value.ToString("R", CultureInfo.InvariantCulture); + + internal static IEnumerable Object( + string name, + string? label, + IEnumerable? properties) + { + var propertyLines = properties?.ToList() ?? new List(); + var hasProperties = propertyLines.Count > 0; + var firstLine = Reference(name); + + if (label is not null || hasProperties) + { + firstLine += ":"; + } + + if (label is not null) + { + firstLine += $" {String(label)}"; + } + + if (hasProperties) + { + firstLine += " {"; + return new[] { firstLine }.Concat(Indent(propertyLines)).Append("}"); + } + + return new[] { firstLine }; + } + + internal static IEnumerable Indent(IEnumerable lines, int spaces = 2) + { + if (lines is null) + { + throw new ArgumentNullException(nameof(lines)); + } + + if (spaces < 0) + { + throw new ArgumentOutOfRangeException(nameof(spaces), spaces, "Indentation cannot be negative."); + } + + var indentation = new string(' ', spaces); + return lines.Select(line => indentation + line); + } + + internal static string[] Lines(string value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + return value + .Replace("\r\n", "\n") + .Replace('\r', '\n') + .Split(new[] { '\n' }, StringSplitOptions.None); + } + + private static string IdentifierSegment(string value) + => BareIdentifier.IsMatch(value) ? value : Quoted(value); + + private static string Quoted(string value) + { + var result = new StringBuilder(value.Length + 2).Append('"'); + + foreach (var character in value) + { + result.Append(character switch + { + '\\' => "\\\\", + '"' => "\\\"", + '$' => "\\$", + '\r' => "\\r", + '\n' => "\\n", + '\t' => "\\t", + _ => character.ToString() + }); + } + + return result.Append('"').ToString(); + } +} diff --git a/src/Utils.cs b/src/Utils.cs index ad0be42..6c59b03 100644 --- a/src/Utils.cs +++ b/src/Utils.cs @@ -2,49 +2,20 @@ namespace d2; public static class Utils { - public static string StringifyBoolean(bool? value) => value switch - { - true => "true", - _ => "false" - }; + public static string StringifyBoolean(bool? value) => D2Writer.Boolean(value is true); public static IEnumerable AddLabelAndProperties( string name, string? label, IEnumerable properties) { - var hasProperties = properties?.Any() ?? false; - - string firstLine = name ?? string.Empty; - if (label != null || hasProperties) - { - firstLine += ":"; - } - - if (label != null) - { - firstLine += $" {label}"; - } - - if (hasProperties) - { - firstLine += " {"; - } - - if (properties != null && hasProperties) - { - return new List { firstLine } - .Concat(Indent(properties)) - .Append("}"); - } - - return new[] { firstLine }; + return D2Writer.Object(name, label, properties); } public static IEnumerable Indent(IEnumerable items, int times = 2) { - return items.Select(item => " ".Repeat(times) + item); + return D2Writer.Indent(items, times); } public static string Repeat(this string value, int times) @@ -56,4 +27,4 @@ public static string Repeat(this string value, int times) } return result; } -} \ No newline at end of file +} diff --git a/test/UnitTests.cs b/test/UnitTests.cs index 2fa5353..a9e3238 100644 --- a/test/UnitTests.cs +++ b/test/UnitTests.cs @@ -1,40 +1,225 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; + namespace Tests; [TestClass] public class UnitTests { [TestMethod] - public void TestDiagram() + public void Diagram_PreservesSimpleReadableOutput() { var umbrella = new D2Shape("alphabet", "Alphabet Inc", Shape.Rectangle); var company = new D2Shape("google", "Google", Shape.Rectangle) - { - new D2Shape("gmail", "Gmail", Shape.Rectangle), - new D2Shape("meet", "Meet", Shape.Rectangle), - new D2Shape("deepmind", "DeepMind", Shape.Rectangle), - }; + { + new D2Shape("gmail", "Gmail", Shape.Rectangle), + new D2Shape("meet", "Meet", Shape.Rectangle), + new D2Shape("deepmind", "DeepMind", Shape.Rectangle), + }; var connection = new D2Connection(company.Name, umbrella.Name, Direction.TO, "BELONGS_TO"); var diagram = new D2Diagram(new[] { umbrella, company }, new[] { connection }); - var actual = diagram.ToString(); - var expected = @"alphabet: Alphabet Inc { - shape: rectangle -} -google: Google { - gmail: Gmail { - shape: rectangle + var expected = Lines( + "alphabet: Alphabet Inc {", + " shape: rectangle", + "}", + "google: Google {", + " gmail: Gmail {", + " shape: rectangle", + " }", + " meet: Meet {", + " shape: rectangle", + " }", + " deepmind: DeepMind {", + " shape: rectangle", + " }", + " shape: rectangle", + "}", + "google -> alphabet: BELONGS_TO"); + + Assert.AreEqual(expected, diagram.ToString()); } - meet: Meet { - shape: rectangle + + [TestMethod] + public void Shape_QuotesAndEscapesUserControlledSyntax() + { + var shape = new D2Shape( + "service.api", + "API #1 \"public\" ${secret}\nnext", + Shape.Rectangle, + new D2Style("#112233", 2, "color \"blue\" #fff", true, 0.5, 4, false), + "top # center") + { + Icon = "https://example.com/icon \"dark\".svg#v1" + }; + + var expected = Lines( + "service.api: \"API #1 \\\"public\\\" \\${secret}\\nnext\" {", + " icon: \"https://example.com/icon \\\"dark\\\".svg#v1\"", + " shape: rectangle", + " near: \"top # center\"", + " style: {", + " stroke: \"#112233\"", + " stroke-width: 2", + " fill: \"color \\\"blue\\\" #fff\"", + " shadow: true", + " opacity: 0.5", + " stroke-dash: 4", + " 3d: false", + " }", + "}"); + + Assert.AreEqual(expected, shape.ToString()); } - deepmind: DeepMind { - shape: rectangle + + [TestMethod] + public void Shape_EmitsExplicitEmptyLabelAsEmptyString() + { + Assert.AreEqual("item: \"\"", new D2Shape("item", string.Empty).ToString()); + Assert.AreEqual("item", new D2Shape("item", null).ToString()); } - shape: rectangle -} -google -> alphabet: BELONGS_TO"; - Assert.AreEqual(expected, actual); + [TestMethod] + public void Connection_QuotesEndpointsAndLabel() + { + var connection = new D2Connection( + "source.node", + "target # node", + Direction.BOTH, + "uses: \"secure\" ${token}"); + + Assert.AreEqual( + "source.node <-> \"target # node\": \"uses: \\\"secure\\\" \\${token}\"", + connection.ToString()); } + + [TestMethod] + public void Style_UsesInvariantNumericFormatting() + { + var originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var style = new D2Style(null, 2, null, null, 0.25, 3, null); + + StringAssert.Contains(style.ToString(), "opacity: 0.25"); + StringAssert.Contains(style.ToString(), "stroke-width: 2"); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + [TestMethod] + [DataRow(-0.01)] + [DataRow(1.01)] + [DataRow(double.NaN)] + [DataRow(double.PositiveInfinity)] + public void Style_RejectsInvalidOpacity(double opacity) + { + var style = new D2Style(null, null, null, null, opacity, null, null); + Assert.ThrowsExactly(() => style.ToString()); + } + + [TestMethod] + [DataRow(0.0, "opacity: 0")] + [DataRow(1.0, "opacity: 1")] + public void Style_AcceptsOpacityBoundaries(double opacity, string expected) + { + var style = new D2Style(null, null, null, null, opacity, null, null); + StringAssert.Contains(style.ToString(), expected); + } + + [TestMethod] + public void Text_NormalizesAllNewlineConventions() + { + var text = new D2Text("label", "one\r\ntwo\rthree\nfour", "md", 1); + + Assert.AreEqual(Lines("label:|md", "one", "two", "three", "four", "|"), text.ToString()); + } + + [TestMethod] + public void Text_IncreasesDelimiterWhenContentWouldCloseBlock() + { + var text = new D2Text("label.text", "one\n|\ntwo", "md", 1); + + Assert.AreEqual(Lines("label.text:||md", "one", "|", "two", "||"), text.ToString()); + } + + [TestMethod] + public void Shape_QuotesUnsafePathSegmentsWithoutChangingPathSemantics() + { + var shape = new D2Shape("system.api#1.endpoint", null); + + Assert.AreEqual("system.\"api#1\".endpoint", shape.ToString()); + } + + [TestMethod] + public void Text_RejectsInvalidDelimiterAndFormat() + { + Assert.ThrowsExactly(() => new D2Text("label", "text", "md", 0).ToString()); + Assert.ThrowsExactly(() => new D2Text("label", "text", "md|evil", 1).ToString()); + } + + [TestMethod] + public void GeneratedDiagram_PassesD2ValidationWhenCliIsAvailable() + { + var source = new D2Shape( + "service.api", + "API #1 \"public\" ${secret}", + Shape.Rectangle, + new D2Style("#112233", 2, "#ffffff", true, 0.5, 4, false)) + { + Icon = "https://example.com/icon.svg#v1" + }; + source.Add(new D2Text("tooltip", "first line\r\nsecond # line", "md", 1)); + var target = new D2Shape("target#2", "Target: database", Shape.Cylinder); + var diagram = new D2Diagram( + new[] { source, target }, + new[] { new D2Connection(source.Name, target.Name, Direction.TO, "calls # safely") }); + + var path = Path.Combine(Path.GetTempPath(), $"d2lang-cs-{Guid.NewGuid():N}.d2"); + File.WriteAllText(path, diagram.ToString()); + + 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}{diagram}"); + } + finally + { + File.Delete(path); + } + } + + private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines); } From eaaf0ec647250245fc31755aa2fd33325ff39213 Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Sun, 9 Aug 2026 12:05:05 +0200 Subject: [PATCH 2/2] Add an extensible ordered D2 model --- src/D2Board.cs | 60 +++++++ src/D2BoardCollection.cs | 74 +++++++++ src/D2Comment.cs | 25 +++ src/D2Connection.cs | 41 ++++- src/D2Diagram.cs | 79 ++++++++-- src/D2Property.cs | 97 ++++++++++++ src/D2RawStatement.cs | 24 +++ src/D2Shape.cs | 50 +++--- src/D2Statement.cs | 9 ++ src/D2Text.cs | 4 +- src/D2Writer.cs | 44 +++++- test/ExtensibleModelTests.cs | 295 +++++++++++++++++++++++++++++++++++ 12 files changed, 756 insertions(+), 46 deletions(-) create mode 100644 src/D2Board.cs create mode 100644 src/D2BoardCollection.cs create mode 100644 src/D2Comment.cs create mode 100644 src/D2Property.cs create mode 100644 src/D2RawStatement.cs create mode 100644 src/D2Statement.cs create mode 100644 test/ExtensibleModelTests.cs 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); +}