From 1a089f9a47eae48b37d3a8f05e86fce95ebba41e Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Sun, 9 Aug 2026 12:14:08 +0200 Subject: [PATCH 1/2] Add typed D2 features and fluent APIs --- README.md | 64 ++++++- example/cli/Program.cs | 4 +- src/D2Class.cs | 193 +++++++++++++++++++++ src/D2Connection.cs | 37 +++- src/D2DiagramBuilder.cs | 55 ++++++ src/D2SequenceDiagram.cs | 87 ++++++++++ src/D2Shape.cs | 45 ++++- src/D2SqlTable.cs | 142 +++++++++++++++ src/D2Style.cs | 193 +++++++++++++++++++-- src/D2Writer.cs | 14 ++ src/Direction.cs | 25 ++- src/Utils.cs | 2 +- test/ExtensibleModelTests.cs | 16 +- test/TypedFeatureTests.cs | 325 +++++++++++++++++++++++++++++++++++ test/UnitTests.cs | 6 +- 15 files changed, 1164 insertions(+), 44 deletions(-) create mode 100644 src/D2Class.cs create mode 100644 src/D2DiagramBuilder.cs create mode 100644 src/D2SequenceDiagram.cs create mode 100644 src/D2SqlTable.cs create mode 100644 test/TypedFeatureTests.cs diff --git a/README.md b/README.md index 61802be..3f83fb5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ company.Add(new D2Shape("deepmind", "DeepMind", Shape.Rectangle)); company.Icon = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png"; -var connection = new D2Connection(company.Name, umbrella.Name, Direction.TO, "BELONGS_TO"); +var connection = new D2Connection(company.Name, umbrella.Name, Direction.To, "BELONGS_TO"); var diagram = new D2Diagram(new[] { umbrella, company }, new[] { connection }); @@ -56,17 +56,71 @@ google -> alphabet: BELONGS_TO ![Diagram](docs/assets/img/diagram.png) # Documentation + +## Typed diagrams + +Special D2 shapes have typed helpers while still participating in the ordered +`D2Statement` model: + +```csharp +var users = new D2SqlTable("users", "Users") +{ + new D2SqlColumn("id", "int", D2SqlConstraint.PrimaryKey), + new D2SqlColumn("email", "string", D2SqlConstraint.Unique), +}; + +var service = new D2Class("user_service", "User service") +{ + new D2ClassField("repository", "Repository", D2Visibility.Private), + new D2ClassMethod( + "find", + "User", + D2Visibility.Public, + new D2ClassParameter("id", "int")), +}; + +var request = new D2SequenceDiagram("request", "Find user") + .AddParticipant("client", "Client") + .AddParticipant("service", "Service") + .AddMessage("client", "service", "find(42)") + .AddMessage("service", "client", "User", Direction.From); + +var diagram = new D2Diagram(new D2Statement[] { users, service, request }); +``` + +All style arguments are optional and named arguments keep declarations compact: + +```csharp +var api = new D2Shape( + "api", + Shape: Shape.Rectangle, + Style: new D2Style( + Fill: "#f4a261", + BorderRadius: 8, + Font: D2Font.Mono, + Animated: true)) +{ + Link = "https://example.com/docs#api", + Tooltip = "Open API docs", + Width = 240, + Height = 120, +}; +``` + ## Supported - [x] Shapes (nodes) - [x] Connections (edges) -- [x] Styles +- [x] Full documented style catalog - [x] Containers (nodes/links in nodes) - [x] Arrow directions - [x] Markdown / latex / block strings / code in shapes - [x] Shape icons -- [ ] SQL table shapes -- [ ] Class shapes -- [ ] Comments +- [x] Shape dimensions, links, and tooltips +- [x] Connection icons, styles, links, and tooltips +- [x] Typed SQL table shapes and constraints +- [x] Typed UML classes, members, parameters, and visibility +- [x] Ordered sequence-diagram helpers +- [x] Comments, root properties, composition boards, and raw escape hatches # Inspiration & Thanks - [Kreshnik/d2lang-js](https://github.com/Kreshnik/d2lang-js) diff --git a/example/cli/Program.cs b/example/cli/Program.cs index 0fc8269..c8dbfa4 100644 --- a/example/cli/Program.cs +++ b/example/cli/Program.cs @@ -9,8 +9,8 @@ company.Icon = "https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png"; -var connection = new D2Connection(company.Name, umbrella.Name, Direction.TO, "BELONGS_TO"); +var connection = new D2Connection(company.Name, umbrella.Name, Direction.To, "BELONGS_TO"); var diagram = new D2Diagram(new[] { umbrella, company }, new[] { connection }); -Console.WriteLine(diagram.ToString()); \ No newline at end of file +Console.WriteLine(diagram.ToString()); diff --git a/src/D2Class.cs b/src/D2Class.cs new file mode 100644 index 0000000..e3d50ac --- /dev/null +++ b/src/D2Class.cs @@ -0,0 +1,193 @@ +using System.Collections; + +namespace d2; + +/// UML visibility prefixes supported by D2 class members. +public enum D2Visibility +{ + /// No explicit prefix; D2 treats this as public. + Default, + /// Explicit public visibility (+). + Public, + /// Private visibility (-). + Private, + /// Protected visibility (#). + Protected, +} + +/// A typed parameter displayed in a UML class method signature. +public sealed record D2ClassParameter +{ + /// The parameter name. + public string Name { get; } + /// The parameter type. + public string Type { get; } + + /// Creates a class method parameter. + public D2ClassParameter(string name, string type) + { + _ = D2Writer.Identifier(name); + if (string.IsNullOrWhiteSpace(type)) + { + throw new ArgumentException("A class parameter type cannot be null, empty, or whitespace.", nameof(type)); + } + + Name = name; + Type = type; + } + + internal string Display() => $"{Name} {Type}"; +} + +/// A field or method in a typed . +public abstract record D2ClassMember : D2Statement +{ + /// The UML visibility of the member. + public D2Visibility Visibility { get; } + + private protected D2ClassMember(D2Visibility visibility) + { + if (!Enum.IsDefined(typeof(D2Visibility), visibility)) + { + throw new ArgumentOutOfRangeException(nameof(visibility), visibility, "Unknown UML visibility."); + } + Visibility = visibility; + } + + private protected abstract string Signature { get; } + + private protected abstract string? Result { get; } + + internal override IEnumerable Lines() + { + var displayKey = VisibilityPrefix(Visibility) + Signature; + var key = Visibility == D2Visibility.Default && this is D2ClassField + ? D2Writer.ObjectMemberIdentifier(displayKey) + : D2Writer.Identifier(displayKey); + return Result is null + ? new[] { key } + : new[] { $"{key}: {D2Writer.String(Result)}" }; + } + + /// + public override string ToString() => string.Join(Environment.NewLine, Lines()); + + private static string VisibilityPrefix(D2Visibility visibility) => visibility switch + { + D2Visibility.Default => string.Empty, + D2Visibility.Public => "+", + D2Visibility.Private => "-", + D2Visibility.Protected => "#", + _ => throw new ArgumentOutOfRangeException(nameof(visibility), visibility, "Unknown UML visibility."), + }; +} + +/// A typed field in a D2 UML class. +public sealed record D2ClassField : D2ClassMember +{ + /// The field name. + public string Name { get; } + /// The optional field type. + public string? Type { get; } + + /// Creates a class field. + public D2ClassField(string name, string? type = null, D2Visibility visibility = D2Visibility.Default) + : base(visibility) + { + _ = D2Writer.Identifier(name); + Name = name; + Type = type; + } + + private protected override string Signature => Name; + private protected override string? Result => Type; +} + +/// A typed method in a D2 UML class. +public sealed record D2ClassMethod : D2ClassMember +{ + /// The method name. + public string Name { get; } + /// The optional return type; means void. + public string? ReturnType { get; } + /// The method parameters. + public IReadOnlyList Parameters { get; } + + /// Creates a class method. + public D2ClassMethod( + string name, + string? returnType = null, + D2Visibility visibility = D2Visibility.Default, + params D2ClassParameter[] parameters) + : base(visibility) + { + _ = D2Writer.Identifier(name); + if (parameters is null) throw new ArgumentNullException(nameof(parameters)); + if (parameters.Any(parameter => parameter is null)) + { + throw new ArgumentException("A class method cannot contain a null parameter.", nameof(parameters)); + } + + Name = name; + ReturnType = returnType; + Parameters = parameters.ToList(); + } + + private protected override string Signature + => $"{Name}({string.Join(", ", Parameters.Select(parameter => parameter.Display()))})"; + + private protected override string? Result => ReturnType; +} + +/// A typed D2 UML class shape. +public sealed record D2Class : D2Statement, IEnumerable +{ + private readonly List _members = new(); + + /// The class key. + public string Name { get; } + /// An optional displayed class label. + public string? Label { get; set; } + /// Typed styles applied to the class. + public D2Style? Style { get; set; } + /// An optional click destination. + public string? Link { get; set; } + /// Optional hover text. + public string? Tooltip { get; set; } + /// The class's ordered members. + public IReadOnlyList Members => _members; + + /// Creates an empty UML class. + public D2Class(string name, string? label = null) + { + _ = D2Writer.Reference(name); + Name = name; + Label = label; + } + + /// Adds a typed member. Supports collection initializer syntax. + public void Add(D2ClassMember member) + { + if (member is null) throw new ArgumentNullException(nameof(member)); + _members.Add(member); + } + + internal override IEnumerable Lines() + { + var shape = new D2Shape(Name, Label, Shape.Class, Style) + { + Link = Link, + Tooltip = Tooltip, + }; + foreach (var member in _members) shape.Add(member); + return shape.Lines(); + } + + /// + public override string ToString() => string.Join(Environment.NewLine, Lines()); + + /// + public IEnumerator GetEnumerator() => _members.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/D2Connection.cs b/src/D2Connection.cs index 8653cdc..528ab48 100644 --- a/src/D2Connection.cs +++ b/src/D2Connection.cs @@ -13,6 +13,18 @@ public record class D2Connection( public IReadOnlyList Statements => _statements; + /// An optional icon URL displayed on this connection. + public string? Icon { get; set; } + + /// An optional destination opened when this connection is clicked. + public string? Link { get; set; } + + /// Optional text shown when this connection is hovered. + public string? Tooltip { get; set; } + + /// Optional typed styles for this connection. + public D2Style? Style { get; set; } + public void Add(D2Property property) => Add((D2Statement)property); public void Add(D2Statement statement) @@ -34,14 +46,35 @@ internal override IEnumerable Lines() @base += $": {D2Writer.String(Label!)}"; } - if (_statements.Count == 0) + var properties = _statements.SelectMany(statement => statement.Lines()).ToList(); + if (Icon is not null) + { + properties.Add($"icon: {D2Writer.String(Icon)}"); + } + + if (Link is not null) + { + properties.Add($"link: {D2Writer.String(Link)}"); + } + + if (Tooltip is not null) + { + properties.Add($"tooltip: {D2Writer.String(Tooltip)}"); + } + + if (Style is not null) + { + properties.AddRange(Style.Lines()); + } + + if (properties.Count == 0) { return new[] { @base }; } var openingLine = hasLabel ? $"{@base} {{" : $"{@base}: {{"; return new[] { openingLine } - .Concat(D2Writer.Indent(_statements.SelectMany(statement => statement.Lines()))) + .Concat(D2Writer.Indent(properties)) .Append("}"); } diff --git a/src/D2DiagramBuilder.cs b/src/D2DiagramBuilder.cs new file mode 100644 index 0000000..e69aca9 --- /dev/null +++ b/src/D2DiagramBuilder.cs @@ -0,0 +1,55 @@ +using System.Collections; + +namespace d2; + +/// A mutable fluent builder for an ordered . +public sealed class D2DiagramBuilder : IEnumerable +{ + private readonly List _statements = new(); + + /// The statements currently in the builder. + public IReadOnlyList Statements => _statements; + + /// Creates an empty diagram builder. + public D2DiagramBuilder() + { + } + + /// Adds a statement. Supports collection initializer syntax. + public void Add(D2Statement statement) + { + if (statement is null) throw new ArgumentNullException(nameof(statement)); + _statements.Add(statement); + } + + /// Appends any statement and returns this builder. + public D2DiagramBuilder Then(D2Statement statement) + { + Add(statement); + return this; + } + + /// Appends a shape and returns this builder. + public D2DiagramBuilder AddShape( + string name, + string? label = null, + Shape? shape = null, + D2Style? style = null) + => Then(new D2Shape(name, label, shape, style)); + + /// Appends a connection and returns this builder. + public D2DiagramBuilder AddConnection( + string first, + string second, + Direction? direction = null, + string? label = null) + => Then(new D2Connection(first, second, direction ?? Direction.To, label)); + + /// Creates an immutable snapshot of the current statements. + public D2Diagram Build() => new(_statements); + + /// + public IEnumerator GetEnumerator() => _statements.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/D2SequenceDiagram.cs b/src/D2SequenceDiagram.cs new file mode 100644 index 0000000..8a0e999 --- /dev/null +++ b/src/D2SequenceDiagram.cs @@ -0,0 +1,87 @@ +using System.Collections; + +namespace d2; + +/// +/// An ordered D2 sequence diagram with fluent helpers for its common statements. +/// Generic instances can still be added directly. +/// +public sealed record D2SequenceDiagram : D2Statement, IEnumerable +{ + private readonly List _statements = new(); + + /// The sequence diagram key. + public string Name { get; } + /// An optional displayed label. + public string? Label { get; set; } + /// Typed styles applied to the sequence diagram. + public D2Style? Style { get; set; } + /// An optional click destination. + public string? Link { get; set; } + /// Optional hover text. + public string? Tooltip { get; set; } + /// The ordered sequence statements. + public IReadOnlyList Statements => _statements; + + /// Creates an empty sequence diagram. + public D2SequenceDiagram(string name, string? label = null) + { + _ = D2Writer.Reference(name); + Name = name; + Label = label; + } + + /// Adds an ordered statement. Supports collection initializer syntax. + public void Add(D2Statement statement) + { + if (statement is null) throw new ArgumentNullException(nameof(statement)); + _statements.Add(statement); + } + + /// Adds an actor or participant and returns this diagram. + public D2SequenceDiagram AddParticipant(string name, string? label = null, Shape? shape = null) + { + Add(new D2Shape(name, label, shape)); + return this; + } + + /// Adds an ordered message and returns this diagram. + public D2SequenceDiagram AddMessage( + string first, + string second, + string? label = null, + Direction? direction = null) + { + Add(new D2Connection(first, second, direction ?? Direction.To, label)); + return this; + } + + /// Adds a labeled sequence group and returns this diagram. + public D2SequenceDiagram AddGroup(string name, params D2Statement[] statements) + { + if (statements is null) throw new ArgumentNullException(nameof(statements)); + var group = new D2Shape(name); + foreach (var statement in statements) group.Add(statement); + Add(group); + return this; + } + + internal override IEnumerable Lines() + { + var shape = new D2Shape(Name, Label, Shape.SequenceDiagram, Style) + { + Link = Link, + Tooltip = Tooltip, + }; + foreach (var statement in _statements) shape.Add(statement); + return shape.Lines(); + } + + /// + public override string ToString() => string.Join(Environment.NewLine, Lines()); + + /// + public IEnumerator GetEnumerator() => _statements.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/D2Shape.cs b/src/D2Shape.cs index eb7bf76..2faebda 100644 --- a/src/D2Shape.cs +++ b/src/D2Shape.cs @@ -2,9 +2,15 @@ namespace d2; +/// A general D2 shape or container. +/// The shape key or dotted path. +/// An optional displayed label. +/// An optional D2 shape kind. +/// Optional typed styles. +/// An optional D2 relative placement. public record class D2Shape( string Name, - string? Label, + string? Label = null, Shape? Shape = default, D2Style? Style = default, string? Near = default @@ -14,8 +20,21 @@ public record class D2Shape( public IReadOnlyList Statements => _statements; + /// An optional icon URL. public string Icon { get; set; } = string.Empty; + /// An optional destination opened when this shape is clicked. + public string? Link { get; set; } + + /// Optional text shown when this shape is hovered. + public string? Tooltip { get; set; } + + /// An optional fixed width for a non-container shape. + public int? Width { get; set; } + + /// An optional fixed height for a non-container shape. + public int? Height { get; set; } + public void Add(D2Shape shape) => Add((D2Statement)shape); public void Add(D2Connection connection) => Add((D2Statement)connection); @@ -51,6 +70,19 @@ internal override IEnumerable Lines() properties.Add($"near: {D2Writer.String(near)}"); } + AddPositiveDimension(properties, "width", Width); + AddPositiveDimension(properties, "height", Height); + + if (Link is not null) + { + properties.Add($"link: {D2Writer.String(Link)}"); + } + + if (Tooltip is not null) + { + properties.Add($"tooltip: {D2Writer.String(Tooltip)}"); + } + if (Style is not null) { properties.AddRange(Style.Lines()); @@ -73,4 +105,15 @@ IEnumerator IEnumerable.GetEnumerator() IEnumerator IEnumerable.GetEnumerator() => _statements.OfType().GetEnumerator(); + + private static void AddPositiveDimension(ICollection properties, string name, int? value) + { + if (value is null) return; + if (value <= 0) + { + throw new ArgumentOutOfRangeException(name, value, $"{name} must be greater than zero."); + } + + properties.Add($"{name}: {D2Writer.Integer(value.Value)}"); + } } diff --git a/src/D2SqlTable.cs b/src/D2SqlTable.cs new file mode 100644 index 0000000..e6d1ce9 --- /dev/null +++ b/src/D2SqlTable.cs @@ -0,0 +1,142 @@ +using System.Collections; + +namespace d2; + +/// A SQL-table constraint understood by D2. +public sealed record D2SqlConstraint +{ + /// A primary-key constraint. + public static readonly D2SqlConstraint PrimaryKey = new("primary_key"); + /// A foreign-key constraint. + public static readonly D2SqlConstraint ForeignKey = new("foreign_key"); + /// A uniqueness constraint. + public static readonly D2SqlConstraint Unique = new("unique"); + + /// The D2 constraint value. + public string Value { get; } + + private D2SqlConstraint(string value) + { + Value = value; + } + + /// Creates a custom SQL constraint value. + /// The constraint text shown by D2. + public static D2SqlConstraint Custom(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("A SQL constraint cannot be null, empty, or whitespace.", nameof(value)); + } + + _ = D2Writer.String(value); + return new D2SqlConstraint(value); + } + + /// + public override string ToString() => Value; +} + +/// A typed column in a . +public sealed record D2SqlColumn : D2Statement +{ + /// The column key. + public string Name { get; } + /// The SQL type displayed by D2. + public string Type { get; } + /// The column's constraints. + public IReadOnlyList Constraints { get; } + + /// Creates a SQL column. + public D2SqlColumn(string name, string type, params D2SqlConstraint[] constraints) + { + _ = D2Writer.Identifier(name); + if (string.IsNullOrWhiteSpace(type)) + { + throw new ArgumentException("A SQL column type cannot be null, empty, or whitespace.", nameof(type)); + } + if (constraints is null) + { + throw new ArgumentNullException(nameof(constraints)); + } + if (constraints.Any(constraint => constraint is null)) + { + throw new ArgumentException("A SQL column cannot contain a null constraint.", nameof(constraints)); + } + + Name = name; + Type = type; + Constraints = constraints.ToList(); + } + + internal override IEnumerable Lines() + { + var column = $"{D2Writer.ObjectMemberIdentifier(Name)}: {D2Writer.String(Type)}"; + if (Constraints.Count == 0) + { + return new[] { column }; + } + + var values = Constraints.Select(constraint => D2Writer.String(constraint.Value)); + var constraintValue = Constraints.Count == 1 + ? values.Single() + : $"[{string.Join("; ", values)}]"; + return new[] { $"{column} {{ constraint: {constraintValue} }}" }; + } + + /// + public override string ToString() => string.Join(Environment.NewLine, Lines()); +} + +/// A typed D2 sql_table shape. +public sealed record D2SqlTable : D2Statement, IEnumerable +{ + private readonly List _columns = new(); + + /// The table key. + public string Name { get; } + /// An optional table label. + public string? Label { get; set; } + /// Typed styles applied to the table. + public D2Style? Style { get; set; } + /// An optional click destination. + public string? Link { get; set; } + /// Optional hover text. + public string? Tooltip { get; set; } + /// The table's ordered columns. + public IReadOnlyList Columns => _columns; + + /// Creates an empty SQL table. + public D2SqlTable(string name, string? label = null) + { + _ = D2Writer.Reference(name); + Name = name; + Label = label; + } + + /// Adds a typed column. Supports collection initializer syntax. + public void Add(D2SqlColumn column) + { + if (column is null) throw new ArgumentNullException(nameof(column)); + _columns.Add(column); + } + + internal override IEnumerable Lines() + { + var shape = new D2Shape(Name, Label, Shape.SqlTable, Style) + { + Link = Link, + Tooltip = Tooltip, + }; + foreach (var column in _columns) shape.Add(column); + return shape.Lines(); + } + + /// + public override string ToString() => string.Join(Environment.NewLine, Lines()); + + /// + public IEnumerator GetEnumerator() => _columns.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/D2Style.cs b/src/D2Style.cs index 06d72dc..de281f6 100644 --- a/src/D2Style.cs +++ b/src/D2Style.cs @@ -1,17 +1,89 @@ namespace d2; +/// Patterns supported by D2's fill-pattern style. +public enum D2FillPattern +{ + /// A dotted fill. + Dots, + /// A lined fill. + Lines, + /// A grain texture. + Grain, + /// Disables a fill pattern supplied by a theme. + None, +} + +/// Fonts supported by D2's font style. +public enum D2Font +{ + /// The D2 monospaced font. + Mono, +} + +/// Transforms supported by D2's text-transform style. +public enum D2TextTransform +{ + /// Converts text to uppercase. + Uppercase, + /// Converts text to lowercase. + Lowercase, + /// Capitalizes words. + Capitalize, + /// Disables a transform supplied by a theme. + None, +} + +/// +/// The documented D2 style catalog. Every setting is optional, so callers can +/// use named arguments to specify only the styles they need. +/// +/// A CSS color or supported gradient. +/// Stroke width from 1 through 15. +/// A CSS color or supported gradient. +/// Whether a shape has a shadow. +/// Opacity from 0 through 1. +/// Dash amount from 0 through 10. +/// Whether a rectangle or square uses the 3D effect. +/// The shape fill pattern. +/// A nonnegative corner radius. +/// Whether a shape uses the multiple-object effect. +/// Whether a supported shape has a double border. +/// The label font. +/// Font size from 8 through 100. +/// A CSS color or supported gradient. +/// Whether a connection or shape is animated. +/// Whether label text is bold. +/// Whether label text is italic. +/// Whether label text is underlined. +/// The label casing transform. public record class D2Style( - string? Stroke, - int? StrokeWidth, - string? Fill, - bool? Shadow, - double? Opacity, - int? StrokeDash, - bool? ThreeD -) + string? Stroke = null, + int? StrokeWidth = null, + string? Fill = null, + bool? Shadow = null, + double? Opacity = null, + int? StrokeDash = null, + bool? ThreeD = null, + D2FillPattern? FillPattern = null, + int? BorderRadius = null, + bool? Multiple = null, + bool? DoubleBorder = null, + D2Font? Font = null, + int? FontSize = null, + string? FontColor = null, + bool? Animated = null, + bool? Bold = null, + bool? Italic = null, + bool? Underline = null, + D2TextTransform? TextTransform = null) { + /// Serializes this style as a D2 style block. public IEnumerable Lines() { + ValidateRange(StrokeWidth, 1, 15, nameof(StrokeWidth)); + ValidateRange(StrokeDash, 0, 10, nameof(StrokeDash)); + ValidateMinimum(BorderRadius, 0, nameof(BorderRadius)); + ValidateRange(FontSize, 8, 100, nameof(FontSize)); if (Opacity is { } opacity && (double.IsNaN(opacity) || double.IsInfinity(opacity) || opacity is < 0 or > 1)) { @@ -20,19 +92,104 @@ public IEnumerable Lines() var styles = new List(); - 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)}"); + AddString(styles, "stroke", Stroke); + AddInteger(styles, "stroke-width", StrokeWidth); + AddString(styles, "fill", Fill); + AddBoolean(styles, "shadow", Shadow); + AddNumber(styles, "opacity", Opacity); + AddInteger(styles, "stroke-dash", StrokeDash); + AddBoolean(styles, "3d", ThreeD); + AddEnum(styles, "fill-pattern", FillPattern, FillPatternValue); + AddInteger(styles, "border-radius", BorderRadius); + AddBoolean(styles, "multiple", Multiple); + AddBoolean(styles, "double-border", DoubleBorder); + AddEnum(styles, "font", Font, FontValue); + AddInteger(styles, "font-size", FontSize); + AddString(styles, "font-color", FontColor); + AddBoolean(styles, "animated", Animated); + AddBoolean(styles, "bold", Bold); + AddBoolean(styles, "italic", Italic); + AddBoolean(styles, "underline", Underline); + AddEnum(styles, "text-transform", TextTransform, TextTransformValue); return styles.Count == 0 - ? new List() + ? Array.Empty() : D2Writer.Object("style", null, styles); } - public override string ToString() - => string.Join(Environment.NewLine, Lines()); + /// + public override string ToString() => string.Join(Environment.NewLine, Lines()); + + private static void AddString(ICollection styles, string name, string? value) + { + if (value is not null) styles.Add($"{name}: {D2Writer.String(value)}"); + } + + private static void AddInteger(ICollection styles, string name, int? value) + { + if (value is not null) styles.Add($"{name}: {D2Writer.Integer(value.Value)}"); + } + + private static void AddNumber(ICollection styles, string name, double? value) + { + if (value is not null) styles.Add($"{name}: {D2Writer.Number(value.Value)}"); + } + + private static void AddBoolean(ICollection styles, string name, bool? value) + { + if (value is not null) styles.Add($"{name}: {D2Writer.Boolean(value.Value)}"); + } + + private static void AddEnum( + ICollection styles, + string name, + T? value, + Func serialize) + where T : struct + { + if (value is not null) styles.Add($"{name}: {serialize(value.Value)}"); + } + + private static void ValidateRange(int? value, int minimum, int maximum, string name) + { + if (value is { } actual && (actual < minimum || actual > maximum)) + { + throw new ArgumentOutOfRangeException(name, actual, $"{name} must be between {minimum} and {maximum}."); + } + } + + private static void ValidateMinimum(int? value, int minimum, string name) + { + if (value is { } actual && actual < minimum) + { + throw new ArgumentOutOfRangeException(name, actual, $"{name} must be at least {minimum}."); + } + } + + private static string FillPatternValue(D2FillPattern value) => value switch + { + D2FillPattern.Dots => "dots", + D2FillPattern.Lines => "lines", + D2FillPattern.Grain => "grain", + D2FillPattern.None => "none", + _ => throw UnknownEnum(nameof(FillPattern), value), + }; + + private static string FontValue(D2Font value) => value switch + { + D2Font.Mono => "mono", + _ => throw UnknownEnum(nameof(Font), value), + }; + + private static string TextTransformValue(D2TextTransform value) => value switch + { + D2TextTransform.Uppercase => "uppercase", + D2TextTransform.Lowercase => "lowercase", + D2TextTransform.Capitalize => "capitalize", + D2TextTransform.None => "none", + _ => throw UnknownEnum(nameof(TextTransform), value), + }; + + private static ArgumentOutOfRangeException UnknownEnum(string name, T value) + => new(name, value, $"Unknown {name} value."); } diff --git a/src/D2Writer.cs b/src/D2Writer.cs index f3c4901..f6f269a 100644 --- a/src/D2Writer.cs +++ b/src/D2Writer.cs @@ -18,6 +18,14 @@ internal static class D2Writer "^[\\p{L}\\p{N}_][\\p{L}\\p{N} _./-]*$", RegexOptions.CultureInvariant | RegexOptions.Compiled); + private static readonly HashSet ReservedObjectKeys = new( + new[] + { + "class", "constraint", "direction", "height", "icon", "label", "link", + "near", "shape", "style", "tooltip", "width", + }, + StringComparer.OrdinalIgnoreCase); + internal static string Reference(string value) { if (string.IsNullOrWhiteSpace(value)) @@ -47,6 +55,12 @@ internal static string Identifier(string value) return IdentifierSegment(value); } + internal static string ObjectMemberIdentifier(string value) + { + var identifier = Identifier(value); + return ReservedObjectKeys.Contains(value) ? Quoted(value) : identifier; + } + internal static string String(string value) { if (value is null) diff --git a/src/Direction.cs b/src/Direction.cs index d3012b2..33c3d31 100644 --- a/src/Direction.cs +++ b/src/Direction.cs @@ -2,10 +2,27 @@ namespace d2; public abstract record Direction(string Value) { - public readonly static To TO = new(); - public readonly static From FROM = new(); - public readonly static Both BOTH = new(); - public readonly static None NONE = new(); + /// A connection pointing from the first endpoint to the second. + public static readonly To To = new(); + /// A connection pointing from the second endpoint to the first. + public static readonly From From = new(); + /// A connection with arrowheads at both endpoints. + public static readonly Both Both = new(); + /// A connection with no arrowhead. + public static readonly None None = new(); + + /// Legacy alias for . + [Obsolete("Use Direction.To instead.")] + public static readonly To TO = To; + /// Legacy alias for . + [Obsolete("Use Direction.From instead.")] + public static readonly From FROM = From; + /// Legacy alias for . + [Obsolete("Use Direction.Both instead.")] + public static readonly Both BOTH = Both; + /// Legacy alias for . + [Obsolete("Use Direction.None instead.")] + public static readonly None NONE = None; public sealed override string ToString() => Value; } diff --git a/src/Utils.cs b/src/Utils.cs index 6c59b03..696f889 100644 --- a/src/Utils.cs +++ b/src/Utils.cs @@ -1,6 +1,6 @@ namespace d2; -public static class Utils +internal static class Utils { public static string StringifyBoolean(bool? value) => D2Writer.Boolean(value is true); diff --git a/test/ExtensibleModelTests.cs b/test/ExtensibleModelTests.cs index e5032d9..ff1a169 100644 --- a/test/ExtensibleModelTests.cs +++ b/test/ExtensibleModelTests.cs @@ -14,7 +14,7 @@ public void Diagram_PreservesMixedStatementOrder() new D2Property("direction", "right"), new D2Shape("actor", "Actor"), new D2Comment("the request happens next"), - new D2Connection("actor", "service", Direction.TO, "request"), + new D2Connection("actor", "service", Direction.To, "request"), new D2Shape("service", "Service"), }); @@ -35,9 +35,9 @@ public void Shape_PreservesMixedBodyStatementOrder() { new D2Shape("user", "User"), new D2Shape("api", "API"), - new D2Connection("user", "api", Direction.TO, "sign in"), + new D2Connection("user", "api", Direction.To, "sign in"), new D2Comment("the response must remain after the request"), - new D2Connection("api", "user", Direction.TO, "session"), + new D2Connection("api", "user", Direction.To, "session"), }; Assert.AreEqual( @@ -109,7 +109,7 @@ public void RootProperties_SafelySerializeNestedConfiguration() [TestMethod] public void Connection_SupportsOrderedGenericProperties() { - var connection = new D2Connection("client", "server", Direction.TO, "call") + var connection = new D2Connection("client", "server", Direction.To, "call") { new D2Property("style", new D2Statement[] { @@ -139,7 +139,7 @@ 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) }, + new D2Board("2") { new D2Connection("queued", "done", Direction.To) }, }; var scenarios = new D2BoardCollection(D2BoardKind.Scenarios) { @@ -154,7 +154,7 @@ public void Boards_SupportNestedLayersScenariosAndSteps() new D2Board("detail") { new D2Property("direction", "right"), - new D2Connection("client", "worker", Direction.TO), + new D2Connection("client", "worker", Direction.To), scenarios, }, }; @@ -201,7 +201,7 @@ public void GenericNamesAreValidatedAndNumbersMustBeFinite() [TestMethod] public void ExtensibleDocument_PassesD2ValidationWhenCliIsAvailable() { - var connection = new D2Connection("client", "server", Direction.TO, "calls # safely") + var connection = new D2Connection("client", "server", Direction.To, "calls # safely") { new D2Property("style", new D2Statement[] { @@ -220,7 +220,7 @@ public void ExtensibleDocument_PassesD2ValidationWhenCliIsAvailable() { new D2Board("failover") { - new D2Connection("database", "replica", Direction.TO, "replicates"), + new D2Connection("database", "replica", Direction.To, "replicates"), }, }, }, diff --git a/test/TypedFeatureTests.cs b/test/TypedFeatureTests.cs new file mode 100644 index 0000000..0c7ab5d --- /dev/null +++ b/test/TypedFeatureTests.cs @@ -0,0 +1,325 @@ +using System.ComponentModel; +using System.Diagnostics; + +namespace Tests; + +[TestClass] +public class TypedFeatureTests +{ + [TestMethod] + public void Style_SerializesTheDocumentedCatalog() + { + var style = new D2Style( + Stroke: "#123456", + StrokeWidth: 2, + Fill: "linear-gradient(#fff, #000)", + Shadow: true, + Opacity: 0.75, + StrokeDash: 3, + ThreeD: false, + FillPattern: D2FillPattern.Dots, + BorderRadius: 999, + Multiple: true, + DoubleBorder: true, + Font: D2Font.Mono, + FontSize: 24, + FontColor: "#abcdef", + Animated: true, + Bold: false, + Italic: true, + Underline: true, + TextTransform: D2TextTransform.Capitalize); + + Assert.AreEqual( + Lines( + "style: {", + " stroke: \"#123456\"", + " stroke-width: 2", + " fill: \"linear-gradient(#fff, #000)\"", + " shadow: true", + " opacity: 0.75", + " stroke-dash: 3", + " 3d: false", + " fill-pattern: dots", + " border-radius: 999", + " multiple: true", + " double-border: true", + " font: mono", + " font-size: 24", + " font-color: \"#abcdef\"", + " animated: true", + " bold: false", + " italic: true", + " underline: true", + " text-transform: capitalize", + "}"), + style.ToString()); + } + + [TestMethod] + public void Style_ValidatesDocumentedRangesAndEnumValues() + { + Assert.ThrowsExactly(() => new D2Style(StrokeWidth: 0).ToString()); + Assert.ThrowsExactly(() => new D2Style(StrokeWidth: 16).ToString()); + Assert.ThrowsExactly(() => new D2Style(StrokeDash: -1).ToString()); + Assert.ThrowsExactly(() => new D2Style(StrokeDash: 11).ToString()); + Assert.ThrowsExactly(() => new D2Style(BorderRadius: -1).ToString()); + Assert.ThrowsExactly(() => new D2Style(FontSize: 7).ToString()); + Assert.ThrowsExactly(() => new D2Style(FontSize: 101).ToString()); + Assert.ThrowsExactly(() => new D2Style(FillPattern: (D2FillPattern)100).ToString()); + Assert.ThrowsExactly(() => new D2Style(Font: (D2Font)100).ToString()); + Assert.ThrowsExactly(() => new D2Style(TextTransform: (D2TextTransform)100).ToString()); + } + + [TestMethod] + public void ShapeAndConnection_HaveTypedInteractiveAndLayoutProperties() + { + var shape = new D2Shape("docs", Shape: Shape.Page, Style: new D2Style(Fill: "#ffffff")) + { + Icon = "https://example.com/icon.svg#logo", + Link = "https://example.com/docs#start", + Tooltip = "Open: ${safe}", + Width = 320, + Height = 180, + }; + var connection = new D2Connection("client", "docs", Direction.To, "read") + { + Icon = "https://example.com/edge.svg#read", + Link = "https://example.com/edge#read", + Tooltip = "Read # docs", + Style = new D2Style(Stroke: "#123456", Animated: true), + }; + + Assert.AreEqual( + Lines( + "docs: {", + " icon: \"https://example.com/icon.svg#logo\"", + " shape: page", + " width: 320", + " height: 180", + " link: \"https://example.com/docs#start\"", + " tooltip: \"Open: \\${safe}\"", + " style: {", + " fill: \"#ffffff\"", + " }", + "}"), + shape.ToString()); + Assert.AreEqual( + Lines( + "client -> docs: read {", + " icon: \"https://example.com/edge.svg#read\"", + " link: \"https://example.com/edge#read\"", + " tooltip: \"Read # docs\"", + " style: {", + " stroke: \"#123456\"", + " animated: true", + " }", + "}"), + connection.ToString()); + + Assert.ThrowsExactly(() => new D2Shape("bad") { Width = 0 }.ToString()); + Assert.ThrowsExactly(() => new D2Shape("bad") { Height = -1 }.ToString()); + } + + [TestMethod] + public void SqlTable_SerializesTypedColumnsConstraintsAndReservedNames() + { + var table = new D2SqlTable("users", "User records") + { + new D2SqlColumn("id", "int", D2SqlConstraint.PrimaryKey, D2SqlConstraint.Unique), + new D2SqlColumn("account_id", "uuid", D2SqlConstraint.ForeignKey), + new D2SqlColumn("label", "timestamp with time zone", D2SqlConstraint.Custom("not null")), + }; + + Assert.AreEqual( + Lines( + "users: User records {", + " id: int { constraint: [primary_key; unique] }", + " account_id: uuid { constraint: foreign_key }", + " \"label\": timestamp with time zone { constraint: not null }", + " shape: sql_table", + "}"), + table.ToString()); + } + + [TestMethod] + public void Class_SerializesFieldsMethodsParametersAndVisibility() + { + var @class = new D2Class("parser", "D2 Parser") + { + new D2ClassField("reader", "io.RuneReader", D2Visibility.Public), + new D2ClassField("lookahead", "[]rune", D2Visibility.Private), + new D2ClassField("label", "string", D2Visibility.Protected), + new D2ClassMethod( + "peek", + "(r rune, eof bool)", + D2Visibility.Public, + new D2ClassParameter("count", "uint64")), + new D2ClassMethod("commit"), + }; + + Assert.AreEqual( + Lines( + "parser: D2 Parser {", + " \"+reader\": io.RuneReader", + " \"-lookahead\": \"[]rune\"", + " \"#label\": string", + " \"+peek(count uint64)\": \"(r rune, eof bool)\"", + " \"commit()\"", + " shape: class", + "}"), + @class.ToString()); + } + + [TestMethod] + public void SequenceDiagram_PreservesFluentStatementOrder() + { + var sequence = new D2SequenceDiagram("login", "Login") + .AddParticipant("user", "User", Shape.Person) + .AddParticipant("api", "API") + .AddMessage("user", "api", "sign in") + .AddGroup( + "retry", + new D2Connection("api", "api", Direction.To, "refresh")) + .AddMessage("api", "user", "session", Direction.From); + + Assert.AreEqual( + Lines( + "login: Login {", + " user: User {", + " shape: person", + " }", + " api: API", + " user -> api: sign in", + " retry: {", + " api -> api: refresh", + " }", + " api <- user: session", + " shape: sequence_diagram", + "}"), + sequence.ToString()); + } + + [TestMethod] + public void Builder_ProvidesFluentAndCollectionInitializerPaths() + { + var builder = new D2DiagramBuilder + { + new D2Comment("start") + }; + var diagram = builder + .AddShape("client", "Client") + .AddShape("server") + .AddConnection("client", "server", label: "call") + .Build(); + + Assert.AreEqual( + Lines("# start", "client: Client", "server", "client -> server: call"), + diagram.ToString()); + } + + [TestMethod] + public void PascalCaseDirectionsPreserveLegacyInstances() + { +#pragma warning disable CS0618 + Assert.AreSame(Direction.To, Direction.TO); + Assert.AreSame(Direction.From, Direction.FROM); + Assert.AreSame(Direction.Both, Direction.BOTH); + Assert.AreSame(Direction.None, Direction.NONE); +#pragma warning restore CS0618 + } + + [TestMethod] + public void TypedFeatures_PassD2ValidationWhenCliIsAvailable() + { + var styled = new D2Shape("styled", Style: new D2Style( + Stroke: "#123456", + StrokeWidth: 2, + Fill: "#ffffff", + Shadow: true, + Opacity: 0.8, + StrokeDash: 2, + ThreeD: true, + FillPattern: D2FillPattern.Grain, + BorderRadius: 12, + Multiple: true, + DoubleBorder: true, + Font: D2Font.Mono, + FontSize: 20, + FontColor: "#222222", + Animated: true, + Bold: true, + Italic: false, + Underline: true, + TextTransform: D2TextTransform.Uppercase)); + var table = new D2SqlTable("users") + { + new D2SqlColumn("id", "int", D2SqlConstraint.PrimaryKey), + new D2SqlColumn("email", "string", D2SqlConstraint.Unique), + new D2SqlColumn("label", "timestamp with time zone", D2SqlConstraint.Custom("not null")), + }; + var @class = new D2Class("service") + { + new D2ClassField("repository", "Repository", D2Visibility.Private), + new D2ClassMethod("find", "User", D2Visibility.Public, new D2ClassParameter("id", "int")), + }; + var sequence = new D2SequenceDiagram("request") + .AddParticipant("client") + .AddParticipant("server") + .AddMessage("client", "server", "GET # user") + .AddMessage("server", "client", "User", Direction.From); + sequence.Link = "https://example.com/docs#sequence"; + sequence.Tooltip = "Request flow"; + var edge = new D2Connection("styled", "users", Direction.To, "opens") + { + Icon = "https://example.com/edge.svg#users", + Link = "https://example.com/docs#users", + Tooltip = "Open users", + Style = new D2Style(Stroke: "#ff0000", Animated: true), + }; + var diagram = new D2Diagram(new D2Statement[] { styled, table, @class, sequence, edge }); + + ValidateWithD2(diagram.ToString()); + } + + private static void ValidateWithD2(string source) + { + var path = Path.Combine(Path.GetTempPath(), $"d2lang-cs-typed-{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 output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert.AreEqual(0, process.ExitCode, $"d2 validate failed.{Environment.NewLine}{output}{error}{Environment.NewLine}{source}"); + } + finally + { + File.Delete(path); + } + } + + private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines); +} diff --git a/test/UnitTests.cs b/test/UnitTests.cs index a9e3238..9a67c4d 100644 --- a/test/UnitTests.cs +++ b/test/UnitTests.cs @@ -18,7 +18,7 @@ public void Diagram_PreservesSimpleReadableOutput() new D2Shape("deepmind", "DeepMind", Shape.Rectangle), }; - var connection = new D2Connection(company.Name, umbrella.Name, Direction.TO, "BELONGS_TO"); + var connection = new D2Connection(company.Name, umbrella.Name, Direction.To, "BELONGS_TO"); var diagram = new D2Diagram(new[] { umbrella, company }, new[] { connection }); var expected = Lines( @@ -87,7 +87,7 @@ public void Connection_QuotesEndpointsAndLabel() var connection = new D2Connection( "source.node", "target # node", - Direction.BOTH, + Direction.Both, "uses: \"secure\" ${token}"); Assert.AreEqual( @@ -179,7 +179,7 @@ public void GeneratedDiagram_PassesD2ValidationWhenCliIsAvailable() 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") }); + 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()); From ab59e76f8dfb895c724eac7676fa7d3161cacb00 Mon Sep 17 00:00:00 2001 From: Stephan van Stekelenburg Date: Sun, 9 Aug 2026 12:23:44 +0200 Subject: [PATCH 2/2] Align UML members across target frameworks --- src/D2Class.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/D2Class.cs b/src/D2Class.cs index e3d50ac..17a5b22 100644 --- a/src/D2Class.cs +++ b/src/D2Class.cs @@ -40,7 +40,7 @@ public D2ClassParameter(string name, string type) } /// A field or method in a typed . -public abstract record D2ClassMember : D2Statement +public abstract class D2ClassMember { /// The UML visibility of the member. public D2Visibility Visibility { get; } @@ -58,7 +58,7 @@ private protected D2ClassMember(D2Visibility visibility) private protected abstract string? Result { get; } - internal override IEnumerable Lines() + internal IEnumerable Lines() { var displayKey = VisibilityPrefix(Visibility) + Signature; var key = Visibility == D2Visibility.Default && this is D2ClassField @@ -83,7 +83,7 @@ internal override IEnumerable Lines() } /// A typed field in a D2 UML class. -public sealed record D2ClassField : D2ClassMember +public sealed class D2ClassField : D2ClassMember { /// The field name. public string Name { get; } @@ -104,7 +104,7 @@ public D2ClassField(string name, string? type = null, D2Visibility visibility = } /// A typed method in a D2 UML class. -public sealed record D2ClassMethod : D2ClassMember +public sealed class D2ClassMethod : D2ClassMember { /// The method name. public string Name { get; } @@ -179,7 +179,7 @@ internal override IEnumerable Lines() Link = Link, Tooltip = Tooltip, }; - foreach (var member in _members) shape.Add(member); + foreach (var member in _members) shape.Add(new MemberStatement(member)); return shape.Lines(); } @@ -190,4 +190,9 @@ internal override IEnumerable Lines() public IEnumerator GetEnumerator() => _members.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private sealed record MemberStatement(D2ClassMember Member) : D2Statement + { + internal override IEnumerable Lines() => Member.Lines(); + } }