From 7fc67fd8e42adb0e2773ec3a2aebf78bf8be092a Mon Sep 17 00:00:00 2001 From: robby Date: Fri, 23 Oct 2026 11:13:31 -0400 Subject: [PATCH] Build D2 text from typed syntax nodes and parse values at construction All D2 text is now produced in one module, d2._syntax, from frozen dataclasses (Key, Label, Url, Quoted, Keyword, Color, Array, Text, Field, BlockField, Edge, Comment) and a single emit function. Quoting and escaping rules live there only. helpers.py is removed. Style colors are parsed into d2.Color (CSS names, 3 or 6 digit hex, gradients, ${variables}); numeric ranges D2 enforces raise ValueError at construction; the fill-pattern, text-transform and label position sets match D2 0.9.0. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + src/d2/__init__.py | 2 + src/d2/_limits.py | 13 + src/d2/_render.py | 4 +- src/d2/_syntax.py | 504 ++++++++++++++++++++++++++++++++++ src/d2/class_shape.py | 32 +-- src/d2/connection.py | 124 +++++---- src/d2/diagram.py | 172 ++++++------ src/d2/helpers.py | 122 -------- src/d2/shape.py | 149 +++++----- src/d2/sql_table.py | 76 +++-- src/d2/style.py | 78 +++--- src/d2/text.py | 36 +-- tests/test_d2/test_helpers.py | 102 ------- tests/test_d2/test_syntax.py | 363 ++++++++++++++++++++++++ 15 files changed, 1186 insertions(+), 593 deletions(-) create mode 100644 src/d2/_limits.py create mode 100644 src/d2/_syntax.py delete mode 100644 src/d2/helpers.py delete mode 100644 tests/test_d2/test_helpers.py create mode 100644 tests/test_d2/test_syntax.py diff --git a/README.md b/README.md index abc7f1d..7b88114 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ See the [tests](/tests/test_d2) for more detailed usage examples. Names and labels that D2 cannot read bare (such as `a.b`, `x: y`, or the keyword `shape`) are quoted for you. Connection endpoints are written as given, so they can be dotted paths. Style values you set are always written, even `0` or `False`. `None` means unset. +Colors are checked when the `Style` is built. Pass a CSS color name, `transparent`, a 3 or 6 digit hex code with or without `#`, a `linear-gradient(...)`, a `radial-gradient(...)`, a `${variable}` or a `d2.Color`. Hex codes are lowercased and written quoted, and anything else raises `ValueError`. +Numbers that D2 limits are checked too: `opacity` 0 to 1, `stroke_width` 0 to 15, `stroke_dash` 0 to 10, `font_size` 8 to 100, and non-negative `border_radius`, grid gaps, `top` and `left`. ```python import d2 diff --git a/src/d2/__init__.py b/src/d2/__init__.py index 3a36490..69cdf82 100644 --- a/src/d2/__init__.py +++ b/src/d2/__init__.py @@ -1,4 +1,5 @@ from ._render import D2Error, fmt, render, validate +from ._syntax import Color from .class_shape import ClassField, ClassMethod, ClassShape from .connection import Arrowhead, ArrowheadShape, Connection, Direction from .diagram import Config, Diagram, Layer, Legend @@ -12,6 +13,7 @@ "ClassField", "ClassMethod", "ClassShape", + "Color", "Config", "Connection", "D2Error", diff --git a/src/d2/_limits.py b/src/d2/_limits.py new file mode 100644 index 0000000..3fa6f10 --- /dev/null +++ b/src/d2/_limits.py @@ -0,0 +1,13 @@ +from typing import TypeVar + +Number = TypeVar("Number", bound=float) + + +def bounded( + name: str, value: Number | None, low: float, high: float | None = None +) -> Number | None: + if value is None or (value >= low and (high is None or value <= high)): + return value + if high is None: + raise ValueError(f"{name} must be at least {low}, got {value}") + raise ValueError(f"{name} must be between {low} and {high}, got {value}") diff --git a/src/d2/_render.py b/src/d2/_render.py index f50dc4e..ac8c040 100644 --- a/src/d2/_render.py +++ b/src/d2/_render.py @@ -2,7 +2,7 @@ import subprocess from typing import Literal -from d2.diagram import Diagram +from d2.diagram import Diagram, LayoutEngine RenderFormat = Literal["svg", "png", "pdf", "txt", "gif", "pptx"] @@ -48,7 +48,7 @@ def render( diagram: Diagram | str, format: RenderFormat = "svg", *, - layout: Literal["dagre", "elk", "tala"] | None = None, + layout: LayoutEngine | None = None, theme: int | None = None, target: str = "", dark_theme: int | None = None, diff --git a/src/d2/_syntax.py b/src/d2/_syntax.py new file mode 100644 index 0000000..435865e --- /dev/null +++ b/src/d2/_syntax.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Final, TypeAlias, TypeVar, assert_never + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + + +INDENT: Final = " " + +RESERVED_KEYWORDS: Final = frozenset( + { + "label", + "shape", + "icon", + "constraint", + "tooltip", + "link", + "near", + "width", + "height", + "direction", + "top", + "left", + "grid-rows", + "grid-columns", + "grid-gap", + "vertical-gap", + "horizontal-gap", + "class", + "vars", + "style", + "source-arrowhead", + "target-arrowhead", + "classes", + "opacity", + "stroke", + "fill", + "fill-pattern", + "stroke-width", + "stroke-dash", + "border-radius", + "font", + "font-size", + "font-color", + "bold", + "italic", + "underline", + "text-transform", + "shadow", + "multiple", + "double-border", + "3d", + "animated", + "filled", + "layers", + "scenarios", + "steps", + } +) + +CSS_COLORS: Final = frozenset( + [ + "aliceblue", + "antiquewhite", + "aqua", + "aquamarine", + "azure", + "beige", + "bisque", + "black", + "blanchedalmond", + "blue", + "blueviolet", + "brown", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "cyan", + "darkblue", + "darkcyan", + "darkgoldenrod", + "darkgray", + "darkgreen", + "darkgrey", + "darkkhaki", + "darkmagenta", + "darkolivegreen", + "darkorange", + "darkorchid", + "darkred", + "darksalmon", + "darkseagreen", + "darkslateblue", + "darkslategray", + "darkslategrey", + "darkturquoise", + "darkviolet", + "deeppink", + "deepskyblue", + "dimgray", + "dimgrey", + "dodgerblue", + "firebrick", + "floralwhite", + "forestgreen", + "fuchsia", + "gainsboro", + "ghostwhite", + "gold", + "goldenrod", + "gray", + "green", + "greenyellow", + "grey", + "honeydew", + "hotpink", + "indianred", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderblush", + "lawngreen", + "lemonchiffon", + "lightblue", + "lightcoral", + "lightcyan", + "lightgoldenrodyellow", + "lightgray", + "lightgreen", + "lightgrey", + "lightpink", + "lightsalmon", + "lightseagreen", + "lightskyblue", + "lightslategray", + "lightslategrey", + "lightsteelblue", + "lightyellow", + "lime", + "limegreen", + "linen", + "magenta", + "maroon", + "mediumaquamarine", + "mediumblue", + "mediumorchid", + "mediumpurple", + "mediumseagreen", + "mediumslateblue", + "mediumspringgreen", + "mediumturquoise", + "mediumvioletred", + "midnightblue", + "mintcream", + "mistyrose", + "moccasin", + "navajowhite", + "navy", + "oldlace", + "olive", + "olivedrab", + "orange", + "orangered", + "orchid", + "palegoldenrod", + "palegreen", + "paleturquoise", + "palevioletred", + "papayawhip", + "peachpuff", + "peru", + "pink", + "plum", + "powderblue", + "purple", + "rebeccapurple", + "red", + "rosybrown", + "royalblue", + "saddlebrown", + "salmon", + "sandybrown", + "seagreen", + "seashell", + "sienna", + "silver", + "skyblue", + "slateblue", + "slategray", + "slategrey", + "snow", + "springgreen", + "steelblue", + "tan", + "teal", + "thistle", + "tomato", + "turquoise", + "violet", + "wheat", + "white", + "whitesmoke", + "yellow", + "yellowgreen", + "transparent", + "currentcolor", + ] +) + +KEY_SPECIAL: Final = re.compile( + r"""^$|[.:{}\[\]#;|"'\\&<>\n]|--|^\s|\s$|^[@(!]|-$|^_$""" +) +LABEL_SPECIAL: Final = re.compile( + r"""^$|[:{}\[\]#;|"\\$\n]|^\s|\s$|^['@]|^(?i:null)$""" +) +URL_SPECIAL: Final = re.compile(r"""^$|[{}\[\]#;|"\\$\n]|^\s|\s$|^['@]""") +LONE_DOLLAR: Final = re.compile(r"\$(?!\{)") +PIPE_RUN: Final = re.compile(r"\|+") +BLOCK_TAG_SPECIAL: Final = re.compile(r"[\s|`]") +QUOTED_COLOR: Final = re.compile(r'"(.*)"', re.DOTALL) +HEX_COLOR: Final = re.compile(r"#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})") +GRADIENT_COLOR: Final = re.compile( + r"""(?:linear|radial)-gradient\([^()"\\\n]+(?:\([^()"\\\n]*\)[^()"\\\n]*)*\)""" +) +VARIABLE_COLOR: Final = re.compile(r"\$\{[^{}\"\\\n]+\}") + + +def quote(text: str) -> str: + escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + escaped = LONE_DOLLAR.sub(r"\\$", escaped) + return f'"{escaped}"' + + +class Direction(Enum): + TO = "->" + FROM = "<-" + BOTH = "<->" + NONE = "--" + + +@dataclass(frozen=True, slots=True) +class Key: + name: str + + @override + def __str__(self) -> str: + if self.name.lower() in RESERVED_KEYWORDS or KEY_SPECIAL.search(self.name): + return quote(self.name) + return self.name + + +@dataclass(frozen=True, slots=True) +class Keyword: + text: str + + @classmethod + def of(cls, text: str | None) -> Keyword | None: + return None if text is None else cls(text) + + @override + def __str__(self) -> str: + return self.text + + +@dataclass(frozen=True, slots=True) +class Label: + text: str + + @classmethod + def of(cls, text: str | None) -> Label | None: + return None if text is None else cls(text) + + @override + def __str__(self) -> str: + return quote(self.text) if LABEL_SPECIAL.search(self.text) else self.text + + +@dataclass(frozen=True, slots=True) +class Url: + text: str + + @classmethod + def of(cls, text: str | None) -> Url | None: + return None if text is None else cls(text) + + @override + def __str__(self) -> str: + return quote(self.text) if URL_SPECIAL.search(self.text) else self.text + + +@dataclass(frozen=True, slots=True) +class Quoted: + text: str + + @override + def __str__(self) -> str: + return quote(self.text) + + +@dataclass(frozen=True, slots=True) +class Color: + value: str + + def __post_init__(self) -> None: + text = self.value.strip() + if (unquoted := QUOTED_COLOR.fullmatch(text)) is not None: + text = unquoted.group(1) + if text.lower() in CSS_COLORS: + parsed = text.lower() + elif (hex_digits := HEX_COLOR.fullmatch(text)) is not None: + parsed = f"#{hex_digits.group(1).lower()}" + elif GRADIENT_COLOR.fullmatch(text) or VARIABLE_COLOR.fullmatch(text): + parsed = text + else: + raise ValueError( + f"{self.value!r} is not a D2 color: use a CSS color name, " + "a 3 or 6 digit hex code, a linear-gradient(...) or " + "radial-gradient(...), or a ${variable}" + ) + object.__setattr__(self, "value", parsed) + + @classmethod + def of(cls, value: str | Color | None) -> Color | None: + if value is None or value == "": + return None + return value if isinstance(value, Color) else cls(value) + + @override + def __str__(self) -> str: + return self.value if self.value.isalpha() else quote(self.value) + + +Scalar: TypeAlias = ( + Key | Keyword | Label | Url | Quoted | Color | Enum | bool | int | float +) + + +@dataclass(frozen=True, slots=True) +class Array: + items: tuple[Scalar, ...] + + @override + def __str__(self) -> str: + return f"[{'; '.join(render(item) for item in self.items)}]" + + +Value: TypeAlias = Scalar | Array + +Item = TypeVar("Item", bound=Scalar) + + +def render(value: Value) -> str: + match value: + case bool(): + return "true" if value else "false" + case int() | float(): + return str(value) + case Enum(): + return str(value.value) + case Key() | Keyword() | Label() | Url() | Quoted() | Color() | Array(): + return str(value) + case _: + assert_never(value) + + +def one_or_array(items: Sequence[Item]) -> Item | Array: + return items[0] if len(items) == 1 else Array(tuple(items)) + + +@dataclass(frozen=True, slots=True, repr=False) +class Text: + text: str + formatting: str + pipes: int = 1 + + def __post_init__(self) -> None: + if BLOCK_TAG_SPECIAL.search(self.formatting): + raise ValueError( + f"formatting must not have whitespace, | or `: {self.formatting!r}" + ) + longest = max((len(run) for run in PIPE_RUN.findall(self.text)), default=0) + object.__setattr__(self, "pipes", max(self.pipes, longest + 1)) + + def lines(self) -> list[str]: + bar = "|" * self.pipes + return [f"{bar}{self.formatting}", *self.text.split("\n"), bar] + + def block_lines(self, key: str) -> list[str]: + return emit((BlockField(Keyword(key), self),)) + + @override + def __repr__(self) -> str: + return "\n".join(self.lines()) + + +@dataclass(frozen=True, slots=True) +class Field: + key: Key | Keyword + value: Value | None = None + body: tuple[Statement, ...] = () + inline: bool = False + + +@dataclass(frozen=True, slots=True) +class BlockField: + key: Key | Keyword + text: Text + + +@dataclass(frozen=True, slots=True) +class Edge: + source: Keyword + operator: Direction + target: Keyword + label: Label | None = None + body: tuple[Statement, ...] = () + + +@dataclass(frozen=True, slots=True) +class Comment: + text: str + + +Statement: TypeAlias = Field | BlockField | Edge | Comment + + +def properties(entries: Iterable[tuple[str, Value | None]]) -> tuple[Field, ...]: + return tuple( + Field(Keyword(key), value) for key, value in entries if value is not None + ) + + +def split_label( + label: str | Text | None, +) -> tuple[Label | None, tuple[BlockField, ...]]: + if isinstance(label, Text): + return None, (BlockField(Keyword("label"), label),) + return Label.of(label), () + + +def class_references(names: Sequence[str]) -> tuple[Field, ...]: + if not names: + return () + return (Field(Keyword("class"), one_or_array([Key(name) for name in names])),) + + +def nest( + head: str, body: Sequence[Statement], depth: int, *, inline: bool +) -> list[str]: + pad = INDENT * depth + if not body: + return [f"{pad}{head}"] + if inline: + return [f"{pad}{head} {{{'; '.join(emit(body))}}}"] + return [f"{pad}{head} {{", *emit(body, depth + 1), f"{pad}}}"] + + +def emit(statements: Iterable[Statement], depth: int = 0) -> list[str]: + pad = INDENT * depth + lines: list[str] = [] + for statement in statements: + match statement: + case Comment(text): + lines += [f"{pad}# {line}" for line in text.split("\n")] + case BlockField(key, text): + opening, *content, closing = text.lines() + lines += [ + f"{pad}{key}: {opening}", + *(f"{pad}{INDENT}{line}" for line in content), + f"{pad}{closing}", + ] + case Field(key, value, body, inline): + head = str(key) + if value is not None: + head += f": {render(value)}" + elif body: + head += ":" + lines += nest(head, body, depth, inline=inline) + case Edge(source, operator, target, label, body): + head = f"{source} {operator.value} {target}" + if label is not None: + head += f": {label}" + elif body: + head += ":" + lines += nest(head, body, depth, inline=False) + case _: + assert_never(statement) + return lines diff --git a/src/d2/class_shape.py b/src/d2/class_shape.py index 18467b5..fb6c3b1 100644 --- a/src/d2/class_shape.py +++ b/src/d2/class_shape.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Literal -from d2.helpers import quote +from d2._syntax import Field, Key, Quoted, Statement, emit from d2.shape import Shape, ShapeType if TYPE_CHECKING: @@ -19,11 +19,9 @@ Visibility = Literal["+", "-", "#"] -def member_line(name: str, visibility: Visibility | None, type_: str | None) -> str: - key = name if visibility is None else f"{visibility}{name}" - if visibility == "#": - key = quote(key) - return key if type_ is None else f"{key}: {quote(type_)}" +def member(name: str, visibility: Visibility | None, type_: str | None) -> Field: + key = Key(name if visibility is None else f"{visibility}{name}") + return Field(key, None if type_ is None else Quoted(type_)) @dataclass(frozen=True) @@ -32,8 +30,11 @@ class ClassField: type: str | None = None visibility: Visibility | None = None + def statement(self) -> Field: + return member(self.name, self.visibility, self.type) + def lines(self) -> list[str]: - return [member_line(self.name, self.visibility, self.type)] + return emit((self.statement(),)) @dataclass(frozen=True) @@ -43,12 +44,13 @@ class ClassMethod: return_type: str | None = None visibility: Visibility | None = None + def statement(self) -> Field: + return member( + f"{self.name}({self.parameters})", self.visibility, self.return_type + ) + def lines(self) -> list[str]: - return [ - member_line( - f"{self.name}({self.parameters})", self.visibility, self.return_type - ) - ] + return emit((self.statement(),)) class ClassShape(Shape): @@ -82,7 +84,5 @@ def add_method(self, method: ClassMethod) -> None: self.methods.append(method) @override - def member_lines(self) -> list[str]: - return [ - line for member in (*self.fields, *self.methods) for line in member.lines() - ] + def members(self) -> tuple[Statement, ...]: + return tuple(item.statement() for item in (*self.fields, *self.methods)) diff --git a/src/d2/connection.py b/src/d2/connection.py index 2056260..0fa4341 100644 --- a/src/d2/connection.py +++ b/src/d2/connection.py @@ -2,17 +2,23 @@ import sys from dataclasses import dataclass -from enum import Enum, StrEnum -from typing import TYPE_CHECKING - -from d2.helpers import ( - add_label_and_properties, - indent_lines, - non_empty, - quote_label, - quote_name, +from enum import StrEnum +from typing import TYPE_CHECKING, Literal + +from d2._syntax import ( + Direction, + Edge, + Field, + Keyword, + Label, + Statement, + Text, + Url, + class_references, + emit, + properties, + split_label, ) -from d2.text import Text if TYPE_CHECKING: from d2.style import Style @@ -22,12 +28,12 @@ else: from typing_extensions import override - -class Direction(Enum): - TO = "->" - FROM = "<-" - BOTH = "<->" - NONE = "--" +__all__ = [ + "Arrowhead", + "ArrowheadShape", + "Connection", + "Direction", +] class ArrowheadShape(StrEnum): @@ -49,13 +55,23 @@ class Arrowhead: label: str | None = None filled: bool | None = None - def lines(self, key: str) -> list[str]: - properties = [f"shape: {self.shape.value}"] - if (label := non_empty(self.label)) is not None: - properties.append(f"label: {quote_label(label)}") - if self.filled is not None: - properties.append(f"style.filled: {'true' if self.filled else 'false'}") - return [f"{key}: {{", *indent_lines(properties), "}"] + def __post_init__(self) -> None: + object.__setattr__(self, "label", self.label or None) + + def statement(self, key: Literal["source-arrowhead", "target-arrowhead"]) -> Field: + return Field( + Keyword(key), + body=properties( + ( + ("shape", self.shape), + ("label", Label.of(self.label)), + ("style.filled", self.filled), + ) + ), + ) + + def lines(self, key: Literal["source-arrowhead", "target-arrowhead"]) -> list[str]: + return emit((self.statement(key),)) class Connection: @@ -76,41 +92,47 @@ def __init__( ) -> None: self.shape_1 = shape_1 self.shape_2 = shape_2 - self.label = label + self.label = label or None self.direction = direction self.style = style self.source_arrowhead = source_arrowhead self.target_arrowhead = target_arrowhead - self.tooltip = tooltip - self.icon = icon - self.link = link + self.tooltip = tooltip or None + self.icon = icon or None + self.link = link or None self.classes = classes or [] + def statements(self) -> tuple[Edge, ...]: + label, label_block = split_label(self.label) + body: tuple[Statement, ...] = ( + *label_block, + *properties((("tooltip", Label.of(self.tooltip)),)), + *( + head.statement(key) + for key, head in ( + ("source-arrowhead", self.source_arrowhead), + ("target-arrowhead", self.target_arrowhead), + ) + if head is not None + ), + *(self.style.statements() if self.style is not None else ()), + *properties( + (("icon", Url.of(self.icon)), ("link", Url.of(self.link))), + ), + *class_references(self.classes), + ) + return ( + Edge( + Keyword(self.shape_1), + self.direction, + Keyword(self.shape_2), + label, + body, + ), + ) + def lines(self) -> list[str]: - properties: list[str] = [] - if (tooltip := non_empty(self.tooltip)) is not None: - properties.append(f"tooltip: {quote_label(tooltip)}") - if self.source_arrowhead is not None: - properties += self.source_arrowhead.lines("source-arrowhead") - if self.target_arrowhead is not None: - properties += self.target_arrowhead.lines("target-arrowhead") - if self.style is not None: - properties += self.style.lines() - if (icon := non_empty(self.icon)) is not None: - properties.append(f"icon: {icon}") - if (link := non_empty(self.link)) is not None: - properties.append(f"link: {link}") - if self.classes: - names = [quote_name(name) for name in self.classes] - joined = names[0] if len(names) == 1 else f"[{'; '.join(names)}]" - properties.append(f"class: {joined}") - - label = self.label - if isinstance(label, Text): - properties = [*label.block_lines("label"), *properties] - label = None - base = f"{self.shape_1} {self.direction.value} {self.shape_2}" - return add_label_and_properties(base, non_empty(label), properties) + return emit(self.statements()) @override def __repr__(self) -> str: diff --git a/src/d2/diagram.py b/src/d2/diagram.py index 71e78c8..74aece2 100644 --- a/src/d2/diagram.py +++ b/src/d2/diagram.py @@ -4,12 +4,17 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal -from d2.helpers import ( - add_label_and_properties, - indent, - indent_lines, - quote_label, - quote_name, +from d2._limits import bounded +from d2._syntax import ( + Comment, + Field, + Key, + Keyword, + Label, + Statement, + Value, + emit, + properties, ) if TYPE_CHECKING: @@ -25,52 +30,43 @@ from typing_extensions import override +LayoutEngine = Literal["dagre", "elk", "tala"] + + @dataclass(frozen=True, kw_only=True) class Config: - layout_engine: Literal["dagre", "elk", "tala"] | None = None + layout_engine: LayoutEngine | None = None theme_id: int | None = None dark_theme_id: int | None = None pad: int | None = None sketch: bool | None = None center: bool | None = None - def lines(self) -> list[str]: - entries = [ - ("layout-engine", self.layout_engine), + def __post_init__(self) -> None: + bounded("theme_id", self.theme_id, 0) + bounded("dark_theme_id", self.dark_theme_id, 0) + + def statements(self) -> tuple[Field, ...]: + entries: tuple[tuple[str, Value | None], ...] = ( + ("layout-engine", Keyword.of(self.layout_engine)), ("theme-id", self.theme_id), ("dark-theme-id", self.dark_theme_id), ("pad", self.pad), ("sketch", self.sketch), ("center", self.center), - ] - properties = [ - f"{key}: {str(value).lower() if isinstance(value, bool) else value}" - for key, value in entries - if value is not None - ] - if not properties: - return [] - return [ - "vars: {", - *indent_lines(add_label_and_properties("d2-config", None, properties)), - "}", - ] - + ) + config = properties(entries) + if not config: + return () + return ( + Field(Keyword("vars"), body=(Field(Keyword("d2-config"), body=config),)), + ) -VarValue = str | int | float | bool + def lines(self) -> list[str]: + return emit(self.statements()) -def var_lines(variables: Mapping[str, VarValue]) -> list[str]: - entries = [ - f"{quote_name(name)}: " - + ( - str(value).lower() - if isinstance(value, bool) - else ('""' if value == "" else quote_label(str(value))) - ) - for name, value in variables.items() - ] - return ["vars: {", *indent_lines(entries), "}"] if entries else [] +VarValue = str | int | float | bool @dataclass(frozen=True, kw_only=True) @@ -80,13 +76,16 @@ class Legend: label: str | None = None near: NearPosition | None = None + def statements(self) -> tuple[Field, ...]: + body: tuple[Statement, ...] = ( + *properties((("near", Keyword.of(self.near)),)), + *(s for shape in self.shapes for s in shape.statements()), + *(s for connection in self.connections for s in connection.statements()), + ) + return (Field(Keyword("legend"), Label.of(self.label), body),) + def lines(self) -> list[str]: - properties = [f"near: {self.near}"] if self.near is not None else [] - properties += [line for shape in self.shapes for line in shape.lines()] - properties += [ - line for connection in self.connections for line in connection.lines() - ] - return add_label_and_properties("legend", self.label, properties) + return emit(self.statements()) class Layer: @@ -104,32 +103,23 @@ def __init__( def set_diagram(self, diagram: Diagram) -> None: self.diagram = diagram - def lines(self, depth: int = 1) -> list[str]: - lines = self.diagram.lines() - - if len(lines) == 0: - return [] - - outer_indent_size = depth * 2 - inner_indent_size = outer_indent_size + 2 + def statements(self) -> tuple[Field, ...]: + body = self.diagram.statements() + if not body: + return () + return (Field(Key(self.name), Label.of(self.label), body),) - title = f" {quote_label(self.label)}" if self.label is not None else "" - return [ - indent(f"{quote_name(self.name)}:{title} {{", outer_indent_size), - *indent_lines(lines, inner_indent_size), - indent("}", outer_indent_size), - ] + def lines(self, depth: int = 1) -> list[str]: + return emit(self.statements(), depth) - @override @override def __repr__(self) -> str: - lines = self.lines() - return "\n".join(lines) + return "\n".join(self.lines()) -def board_lines(key: str, boards: list[Layer]) -> list[str]: - lines = [line for board in boards for line in board.lines()] - return [f"{key}: {{", *lines, "}"] if lines else [] +def boards(key: str, layers: list[Layer]) -> tuple[Field, ...]: + body = tuple(s for layer in layers for s in layer.statements()) + return (Field(Keyword(key), body=body),) if body else () class Diagram: @@ -178,43 +168,39 @@ def add_step(self, step: Layer) -> None: def add_comment(self, comment: str) -> None: self.comments.append(comment) - def class_lines(self) -> list[str]: - classes = [ - line + def class_statements(self) -> tuple[Field, ...]: + body = tuple( + Field(Key(name), body=style.statements()) for name, style in self.classes.items() - if style.lines() - for line in indent_lines( - add_label_and_properties(quote_name(name), None, style.lines()) - ) - ] - return ["classes: {", *classes, "}"] if classes else [] + if style.statements() + ) + return (Field(Keyword("classes"), body=body),) if body else () - def lines(self) -> list[str]: - comments = [ - f"# {line}" for comment in self.comments for line in comment.split("\n") - ] - config = self.vars.lines() if self.vars is not None else [] - direction = ( - [f"direction: {self.direction}"] if self.direction is not None else [] + def variable_statements(self) -> tuple[Field, ...]: + body = tuple( + Field(Key(name), Label(value) if isinstance(value, str) else value) + for name, value in self.variables.items() ) - shapes = [str(shape) for shape in self.shapes] - connections = [str(connection) for connection in self.connections] + return (Field(Keyword("vars"), body=body),) if body else () + def statements(self) -> tuple[Statement, ...]: return ( - comments - + config - + var_lines(self.variables) - + direction - + self.class_lines() - + shapes - + connections - + (self.legend.lines() if self.legend is not None else []) - + board_lines("layers", self.layers) - + board_lines("scenarios", self.scenarios) - + board_lines("steps", self.steps) + *(Comment(comment) for comment in self.comments), + *(self.vars.statements() if self.vars is not None else ()), + *self.variable_statements(), + *properties((("direction", Keyword.of(self.direction)),)), + *self.class_statements(), + *(s for shape in self.shapes for s in shape.statements()), + *(s for connection in self.connections for s in connection.statements()), + *(self.legend.statements() if self.legend is not None else ()), + *boards("layers", self.layers), + *boards("scenarios", self.scenarios), + *boards("steps", self.steps), ) + def lines(self) -> list[str]: + return emit(self.statements()) + @override def __repr__(self) -> str: - lines = self.lines() - return "\n".join(lines) + return "\n".join(self.lines()) diff --git a/src/d2/helpers.py b/src/d2/helpers.py deleted file mode 100644 index 3d1dadb..0000000 --- a/src/d2/helpers.py +++ /dev/null @@ -1,122 +0,0 @@ -import re - -RESERVED_KEYWORDS = frozenset( - { - "label", - "shape", - "icon", - "constraint", - "tooltip", - "link", - "near", - "width", - "height", - "direction", - "top", - "left", - "grid-rows", - "grid-columns", - "grid-gap", - "vertical-gap", - "horizontal-gap", - "class", - "vars", - "style", - "source-arrowhead", - "target-arrowhead", - "classes", - "opacity", - "stroke", - "fill", - "fill-pattern", - "stroke-width", - "stroke-dash", - "border-radius", - "font", - "font-size", - "font-color", - "bold", - "italic", - "underline", - "text-transform", - "shadow", - "multiple", - "double-border", - "3d", - "animated", - "filled", - "layers", - "scenarios", - "steps", - } -) - -QUOTED_PATTERN = re.compile(r'"(?:[^"\\\n]|\\.)*"') -NAME_SPECIAL_PATTERN = re.compile(r"[.:{}#;|\"'\\\n]|->|<-|--|^\s|\s$") -LABEL_SPECIAL_PATTERN = re.compile(r"[:{}#;|\"\n]|^\s|\s$|^'") - - -def non_empty(value: str | None) -> str | None: - if value is None or value == "": - return None - return value - - -def quote(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") - return f'"{escaped}"' - - -def quote_name(name: str) -> str: - if QUOTED_PATTERN.fullmatch(name): - return name - if ( - not name - or name.lower() in RESERVED_KEYWORDS - or NAME_SPECIAL_PATTERN.search(name) - ): - return quote(name) - return name - - -def quote_label(label: str) -> str: - if QUOTED_PATTERN.fullmatch(label): - return label - if LABEL_SPECIAL_PATTERN.search(label): - return quote(label) - return label - - -def indent(line: str, n: int = 2) -> str: - return f"{' ' * n}{line}" - - -def indent_lines(items: list[str], n: int = 2) -> list[str]: - return [indent(item, n) for item in items] - - -def add_label_and_properties( - name: str, label: str | None = None, properties: list[str] | None = None -) -> list[str]: - has_properties: bool = properties is not None and len(properties) > 0 - - first_line = name - if label is not None or has_properties: - first_line += ":" - - if label == "": - first_line += ' ""' - elif label is not None: - first_line += f" {quote_label(label)}" - - if has_properties: - first_line += " {" - - if properties is not None and has_properties: - return [first_line, *indent_lines(properties), "}"] - - return [first_line] - - -def flatten(items: list[list[str]]) -> list[str]: - return [item for sublist in items for item in sublist] diff --git a/src/d2/shape.py b/src/d2/shape.py index 7e9d032..90a1b9b 100644 --- a/src/d2/shape.py +++ b/src/d2/shape.py @@ -4,14 +4,22 @@ from enum import Enum from typing import TYPE_CHECKING, Literal -from d2.helpers import ( - add_label_and_properties, - flatten, - non_empty, - quote_label, - quote_name, +from d2._limits import bounded +from d2._syntax import ( + BlockField, + Field, + Key, + Keyword, + Label, + Statement, + Text, + Url, + Value, + class_references, + emit, + properties, + split_label, ) -from d2.text import Text if TYPE_CHECKING: from d2.connection import Connection @@ -78,15 +86,15 @@ class ShapeType(Enum): "border-bottom-left", "border-bottom-center", "border-bottom-right", - "inside-top-left", - "inside-top-center", - "inside-top-right", - "inside-middle-left", - "inside-middle-center", - "inside-middle-right", - "inside-bottom-left", - "inside-bottom-center", - "inside-bottom-right", + "top-left", + "top-center", + "top-right", + "center-left", + "center-center", + "center-right", + "bottom-left", + "bottom-center", + "bottom-right", ] @@ -102,12 +110,6 @@ class ShapeType(Enum): ] -def label_parts(label: str | Text | None) -> tuple[str | None, list[str]]: - if isinstance(label, Text): - return None, label.block_lines("label") - return label, [] - - class Shape: def __init__( self, @@ -150,24 +152,24 @@ def __init__( self.shape = shape self.shapes = shapes or [] self.style = style - self.icon = icon + self.icon = icon or None self.connections = connections or [] - self.near = near - self.link = link - self.tooltip = tooltip + self.near = near or None + self.link = link or None + self.tooltip = tooltip or None self.width = width self.height = height - self.grid_rows = grid_rows - self.grid_columns = grid_columns - self.grid_gap = grid_gap + self.grid_rows = bounded("grid_rows", grid_rows, 1) + self.grid_columns = bounded("grid_columns", grid_columns, 1) + self.grid_gap = bounded("grid_gap", grid_gap, 0) self.direction = direction self.classes = classes or [] self.label_near = label_near self.icon_near = icon_near - self.top = top - self.left = left - self.vertical_gap = vertical_gap - self.horizontal_gap = horizontal_gap + self.top = bounded("top", top, 0) + self.left = bounded("left", left, 0) + self.vertical_gap = bounded("vertical_gap", vertical_gap, 0) + self.horizontal_gap = bounded("horizontal_gap", horizontal_gap, 0) self.kwargs = kwargs def add_shape(self, shape: Shape) -> None: @@ -176,71 +178,48 @@ def add_shape(self, shape: Shape) -> None: def add_connection(self, connection: Connection) -> None: self.connections.append(connection) - def member_lines(self) -> list[str]: - return [] + def members(self) -> tuple[Statement, ...]: + return () - def attribute_lines(self) -> list[str]: - properties: list[str] = [] - for key, value in ( + def attributes(self) -> tuple[Statement, ...]: + entries: tuple[tuple[str, Value | None], ...] = ( ("width", self.width), ("height", self.height), ("grid-rows", self.grid_rows), ("grid-columns", self.grid_columns), ("grid-gap", self.grid_gap), - ("direction", self.direction), + ("direction", Keyword.of(self.direction)), ("vertical-gap", self.vertical_gap), ("horizontal-gap", self.horizontal_gap), ("top", self.top), ("left", self.left), - ("label.near", self.label_near), - ("icon.near", self.icon_near), - ): - if value is not None: - properties.append(f"{key}: {value}") - - if self.classes: - names = [quote_name(name) for name in self.classes] - joined = names[0] if len(names) == 1 else f"[{'; '.join(names)}]" - properties.append(f"class: {joined}") + ("label.near", Keyword.of(self.label_near)), + ("icon.near", Keyword.of(self.icon_near)), + ) + return (*properties(entries), *class_references(self.classes)) + + def body(self) -> tuple[Statement, ...]: + return ( + *(s for shape in self.shapes for s in shape.statements()), + *(s for connection in self.connections for s in connection.statements()), + *properties((("shape", self.shape),)), + *self.members(), + *properties((("near", Keyword.of(self.near)), ("link", Url.of(self.link)))), + *(self.style.statements() if self.style is not None else ()), + *properties( + (("icon", Url.of(self.icon)), ("tooltip", Label.of(self.tooltip))) + ), + *self.attributes(), + *(BlockField(Keyword(key), text) for key, text in self.kwargs.items()), + ) - return properties + def statements(self) -> tuple[Field, ...]: + label, label_block = split_label(self.label) + return (Field(Key(self.name), label, (*label_block, *self.body())),) def lines(self) -> list[str]: - shapes = flatten([shape.lines() for shape in self.shapes]) - connections = flatten([connection.lines() for connection in self.connections]) - properties = shapes + connections - - if self.shape is not None: - properties.append(f"shape: {self.shape.value}") - - properties += self.member_lines() - - if (near := non_empty(self.near)) is not None: - properties.append(f"near: {near}") - - if (link := non_empty(self.link)) is not None: - properties.append(f"link: {link}") - - if self.style is not None: - properties += self.style.lines() - - if (icon := non_empty(self.icon)) is not None: - properties.append(f"icon: {icon}") - - if (tooltip := non_empty(self.tooltip)) is not None: - properties.append(f"tooltip: {quote_label(tooltip)}") - - properties += self.attribute_lines() - - for key, value in self.kwargs.items(): - properties += value.block_lines(key) - - label, label_lines = label_parts(self.label) - return add_label_and_properties( - quote_name(self.name), label, [*label_lines, *properties] - ) + return emit(self.statements()) @override def __repr__(self) -> str: - lines = self.lines() - return "\n".join(lines) + return "\n".join(self.lines()) diff --git a/src/d2/sql_table.py b/src/d2/sql_table.py index 9d39e94..41803ee 100644 --- a/src/d2/sql_table.py +++ b/src/d2/sql_table.py @@ -4,9 +4,19 @@ from enum import Enum from typing import TYPE_CHECKING, TypeAlias, TypedDict +from d2._syntax import ( + Field, + Key, + Keyword, + Label, + Statement, + Url, + emit, + one_or_array, + properties, +) from d2.connection import Connection, Direction -from d2.helpers import add_label_and_properties, non_empty, quote_name -from d2.shape import Shape, ShapeType, label_parts +from d2.shape import Shape, ShapeType if TYPE_CHECKING: from collections.abc import Mapping @@ -50,20 +60,20 @@ def __init__( else: self.constraints = [constraint] - def to_d2_format(self) -> str: - if not self.constraints: - return f"{self.name}: {self.data_type}" - - constraints_str = "; ".join( - c.value if isinstance(c, SQLConstraint) else c for c in self.constraints + def statement(self) -> Field: + constraints = [ + Label(c.value if isinstance(c, SQLConstraint) else c) + for c in self.constraints + ] + body = ( + (Field(Keyword("constraint"), one_or_array(constraints)),) + if constraints + else () ) + return Field(Key(self.name), Label(self.data_type), body, inline=True) - if len(self.constraints) == 1: - constraint_part = constraints_str - else: - constraint_part = f"[{constraints_str}]" - - return f"{self.name}: {self.data_type} {{constraint: {constraint_part}}}" + def to_d2_format(self) -> str: + return emit((self.statement(),))[0] class SQLTable(Shape): @@ -111,35 +121,15 @@ def add_field( return field @override - def lines(self) -> list[str]: - properties: list[str] = [] - - if self.shape is not None: - properties.append(f"shape: {self.shape.value}") - - properties.extend(field.to_d2_format() for field in self.fields) - - if (near := non_empty(self.near)) is not None: - properties.append(f"near: {near}") - - if (link := non_empty(self.link)) is not None: - properties.append(f"link: {link}") - - if self.style is not None: - properties.extend(self.style.lines()) - - if (icon := non_empty(self.icon)) is not None: - properties.append(f"icon: {icon}") - - for shape in self.shapes: - properties.extend(shape.lines()) - - for connection in self.connections: - properties.extend(connection.lines()) - - label, label_lines = label_parts(self.label) - return add_label_and_properties( - quote_name(self.name), label, [*label_lines, *properties] + def body(self) -> tuple[Statement, ...]: + return ( + *properties((("shape", self.shape),)), + *(field.statement() for field in self.fields), + *properties((("near", Keyword.of(self.near)), ("link", Url.of(self.link)))), + *(self.style.statements() if self.style is not None else ()), + *properties((("icon", Url.of(self.icon)),)), + *(s for shape in self.shapes for s in shape.statements()), + *(s for connection in self.connections for s in connection.statements()), ) diff --git a/src/d2/style.py b/src/d2/style.py index 6dffd11..6aafadc 100644 --- a/src/d2/style.py +++ b/src/d2/style.py @@ -1,42 +1,32 @@ +from __future__ import annotations + import sys from typing import Literal -from d2.helpers import add_label_and_properties, quote_label +from d2._limits import bounded +from d2._syntax import Color, Field, Keyword, Value, emit, properties if sys.version_info >= (3, 12): from typing import override else: from typing_extensions import override - -FillPattern = Literal["dots", "lines", "grain", "none"] -TextTransform = Literal["uppercase", "lowercase", "title", "none"] - - -def stringify_bool(val: bool) -> str: - return "true" if val else "false" - - -def render_value(value: str | float | bool) -> str | None: - if isinstance(value, bool): - return stringify_bool(value) - if isinstance(value, str): - return quote_label(value) if value != "" else None - return str(value) +FillPattern = Literal["dots", "lines", "grain", "paper", "none"] +TextTransform = Literal["uppercase", "lowercase", "capitalize", "none"] class Style: def __init__( self, - stroke: str | None = None, + stroke: str | Color | None = None, stroke_width: int | None = None, - fill: str | None = None, + fill: str | Color | None = None, shadow: bool | None = None, opacity: float | None = None, stroke_dash: int | None = None, three_d: bool | None = None, - font_color: str | None = None, - border_radius: str | None = None, + font_color: str | Color | None = None, + border_radius: int | str | None = None, *, font: Literal["mono"] | None = None, font_size: int | None = None, @@ -49,17 +39,21 @@ def __init__( multiple: bool | None = None, fill_pattern: FillPattern | None = None, ) -> None: - self.stroke = stroke - self.stroke_width = stroke_width - self.fill = fill + self.stroke = Color.of(stroke) + self.stroke_width = bounded("stroke_width", stroke_width, 0, 15) + self.fill = Color.of(fill) self.shadow = shadow - self.opacity = opacity - self.stroke_dash = stroke_dash + self.opacity = bounded("opacity", opacity, 0, 1) + self.stroke_dash = bounded("stroke_dash", stroke_dash, 0, 10) self.three_d = three_d - self.font_color = font_color - self.border_radius = border_radius + self.font_color = Color.of(font_color) + self.border_radius = bounded( + "border_radius", + None if border_radius in (None, "") else int(border_radius), + 0, + ) self.font = font - self.font_size = font_size + self.font_size = bounded("font_size", font_size, 8, 100) self.bold = bold self.italic = italic self.underline = underline @@ -69,8 +63,8 @@ def __init__( self.multiple = multiple self.fill_pattern = fill_pattern - def lines(self) -> list[str]: - entries: list[tuple[str, str | float | bool | None]] = [ + def statements(self) -> tuple[Field, ...]: + entries: tuple[tuple[str, Value | None], ...] = ( ("stroke", self.stroke), ("stroke-width", self.stroke_width), ("fill", self.fill), @@ -80,29 +74,23 @@ def lines(self) -> list[str]: ("3d", self.three_d), ("font-color", self.font_color), ("border-radius", self.border_radius), - ("font", self.font), + ("font", Keyword.of(self.font)), ("font-size", self.font_size), ("bold", self.bold), ("italic", self.italic), ("underline", self.underline), - ("text-transform", self.text_transform), + ("text-transform", Keyword.of(self.text_transform)), ("animated", self.animated), ("double-border", self.double_border), ("multiple", self.multiple), - ("fill-pattern", self.fill_pattern), - ] - styles = [ - f"{key}: {rendered}" - for key, value in entries - if value is not None and (rendered := render_value(value)) is not None - ] + ("fill-pattern", Keyword.of(self.fill_pattern)), + ) + styles = properties(entries) + return (Field(Keyword("style"), body=styles),) if styles else () - if len(styles) == 0: - return [] - - return add_label_and_properties("style", None, styles) + def lines(self) -> list[str]: + return emit(self.statements()) @override def __repr__(self) -> str: - lines = self.lines() - return "\n".join(lines) + return "\n".join(self.lines()) diff --git a/src/d2/text.py b/src/d2/text.py index becfb3a..bb9cc00 100644 --- a/src/d2/text.py +++ b/src/d2/text.py @@ -1,35 +1,3 @@ -import sys +from d2._syntax import Text -from d2.helpers import indent_lines - -if sys.version_info >= (3, 12): - from typing import override -else: - from typing_extensions import override - - -class Text: - def __init__( - self, - # The actual text body (multiline is fine) - text: str, - # The format, eg) md, tex, html, css etc - formatting: str, - # The number of pipes to use - pipes: int = 1, - ) -> None: - self.text = text - self.formatting = formatting - self.pipes = pipes - - def lines(self) -> list[str]: - sep = "|" * self.pipes - return [f"{sep}{self.formatting}", *self.text.split("\n"), sep] - - def block_lines(self, key: str) -> list[str]: - lines = self.lines() - return [f"{key}: {lines[0]}", *indent_lines(lines[1:-1]), lines[-1]] - - @override - def __repr__(self) -> str: - return "\n".join(self.lines()) +__all__ = ["Text"] diff --git a/tests/test_d2/test_helpers.py b/tests/test_d2/test_helpers.py deleted file mode 100644 index 00fd269..0000000 --- a/tests/test_d2/test_helpers.py +++ /dev/null @@ -1,102 +0,0 @@ -from d2.helpers import add_label_and_properties, quote, quote_label, quote_name - - -def test_add_label_and_properties() -> None: - assert add_label_and_properties("shape_name", None, []) == ["shape_name"] - assert add_label_and_properties("shape_name", "shape_label", []) == [ - "shape_name: shape_label" - ] - assert add_label_and_properties( - "shape_name", None, ["style: {", " fill: red", "}"] - ) == [ - "shape_name: {", - " style: {", - " fill: red", - " }", - "}", - ] - assert add_label_and_properties( - "shape_name", "shape_label", ["style: {", " fill: red", "}"] - ) == [ - "shape_name: shape_label {", - " style: {", - " fill: red", - " }", - "}", - ] - - -def test_quote_escapes_backslash_quote_and_newline() -> None: - assert quote('a "b" \\ c\nd') == '"a \\"b\\" \\\\ c\\nd"' - - -def test_quote_name_leaves_plain_names() -> None: - assert quote_name("shape_name") == "shape_name" - assert quote_name("my shape") == "my shape" - assert quote_name("a-b") == "a-b" - - -def test_quote_name_quotes_unreadable_names() -> None: - assert quote_name("a.b") == '"a.b"' - assert quote_name("a:b") == '"a:b"' - assert quote_name("a#b") == '"a#b"' - assert quote_name("a{b}") == '"a{b}"' - assert quote_name("a;b") == '"a;b"' - assert quote_name("a|b") == '"a|b"' - assert quote_name('a"b') == '"a\\"b"' - assert quote_name(" a") == '" a"' - assert quote_name("a ") == '"a "' - assert quote_name("a\nb") == '"a\\nb"' - assert quote_name("a--b") == '"a--b"' - - -def test_quote_name_quotes_reserved_keywords() -> None: - for keyword in [ - "shape", - "style", - "label", - "near", - "layers", - "scenarios", - "steps", - "vars", - "classes", - "direction", - ]: - assert quote_name(keyword) == f'"{keyword}"' - assert quote_name("Shape") == '"Shape"' - - -def test_quote_name_keeps_already_quoted_names() -> None: - assert quote_name('"a.b"') == '"a.b"' - - -def test_quote_label_leaves_plain_labels() -> None: - assert quote_label("hello world") == "hello world" - assert quote_label("shape") == "shape" - assert quote_label("it's") == "it's" - assert quote_label("a.b") == "a.b" - - -def test_quote_label_quotes_unreadable_labels() -> None: - assert quote_label("a: b") == '"a: b"' - assert quote_label("a # b") == '"a # b"' - assert quote_label("{a}") == '"{a}"' - assert quote_label("a;b") == '"a;b"' - assert quote_label("a|b") == '"a|b"' - assert quote_label(" a") == '" a"' - assert quote_label("'a") == '"\'a"' - assert quote_label("a\nb") == '"a\\nb"' - - -def test_quote_label_keeps_already_quoted_labels() -> None: - assert quote_label('"#274e13"') == '"#274e13"' - - -def test_add_label_and_properties_quotes_label() -> None: - assert add_label_and_properties("a", "x: y", []) == ['a: "x: y"'] - - -def test_quote_name_quotes_style_keywords() -> None: - assert quote_name("shadow") == '"shadow"' - assert quote_name("Fill") == '"Fill"' diff --git a/tests/test_d2/test_syntax.py b/tests/test_d2/test_syntax.py new file mode 100644 index 0000000..0474734 --- /dev/null +++ b/tests/test_d2/test_syntax.py @@ -0,0 +1,363 @@ +from collections.abc import Callable + +import pytest + +import d2 +from d2._syntax import ( + Array, + BlockField, + Color, + Comment, + Direction, + Edge, + Field, + Key, + Keyword, + Label, + Quoted, + Text, + Url, + emit, + one_or_array, + quote, +) + + +def test_quote__escapes_backslash_quote_and_newline() -> None: + assert quote('a "b" \\ c\nd') == '"a \\"b\\" \\\\ c\\nd"' + + +def test_quote__escapes_dollars_that_do_not_start_a_substitution() -> None: + assert quote("cost $5") == '"cost \\$5"' + assert quote("${env} costs $5") == '"${env} costs \\$5"' + + +@pytest.mark.parametrize("name", ["shape_name", "my shape", "a-b", "a$b", "*", "1"]) +def test_key__leaves_plain_names(name: str) -> None: + assert str(Key(name)) == name + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("a.b", '"a.b"'), + ("a:b", '"a:b"'), + ("a#b", '"a#b"'), + ("a{b}", '"a{b}"'), + ("a[0]", '"a[0]"'), + ("a;b", '"a;b"'), + ("a|b", '"a|b"'), + ("a&b", '"a&b"'), + ("a>b", '"a>b"'), + ('a"b', '"a\\"b"'), + ("a'b", '"a\'b"'), + (" a", '" a"'), + ("a ", '"a "'), + ("a\nb", '"a\\nb"'), + ("a--b", '"a--b"'), + ("@a", '"@a"'), + ("(a)", '"(a)"'), + ("!", '"!"'), + ("a-", '"a-"'), + ("_", '"_"'), + ("", '""'), + ], +) +def test_key__quotes_unreadable_names(name: str, expected: str) -> None: + assert str(Key(name)) == expected + + +@pytest.mark.parametrize( + "keyword", + [ + "shape", + "style", + "label", + "near", + "layers", + "scenarios", + "steps", + "vars", + "classes", + "direction", + "shadow", + "Shape", + "Fill", + ], +) +def test_key__quotes_reserved_keywords(keyword: str) -> None: + assert str(Key(keyword)) == f'"{keyword}"' + + +def test_key__does_not_keep_already_quoted_names() -> None: + assert str(Key('"a.b"')) == '"\\"a.b\\""' + + +@pytest.mark.parametrize("text", ["hello world", "shape", "it's", "a.b", "a, b", "1"]) +def test_label__leaves_plain_labels(text: str) -> None: + assert str(Label(text)) == text + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("a: b", '"a: b"'), + ("a # b", '"a # b"'), + ("{a}", '"{a}"'), + ("a;b", '"a;b"'), + ("a|b", '"a|b"'), + ("a[0]", '"a[0]"'), + ("a\\b", '"a\\\\b"'), + ("$5", '"\\$5"'), + (" a", '" a"'), + ("'a", '"\'a"'), + ("@a", '"@a"'), + ("a\nb", '"a\\nb"'), + ("null", '"null"'), + ("NULL", '"NULL"'), + ("", '""'), + ], +) +def test_label__quotes_unreadable_labels(text: str, expected: str) -> None: + assert str(Label(text)) == expected + + +def test_label__does_not_keep_already_quoted_labels() -> None: + assert str(Label('"x"')) == '"\\"x\\""' + + +def test_url__keeps_colons_and_quotes_comment_starts() -> None: + assert str(Url("https://x.test/i.svg?a=1&b=2")) == "https://x.test/i.svg?a=1&b=2" + assert str(Url("https://x.test/a#top")) == '"https://x.test/a#top"' + assert str(Url("")) == '""' + + +def test_quoted__always_quotes() -> None: + assert str(Quoted("string")) == '"string"' + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("red", "red"), + ("Red", "red"), + ("transparent", "transparent"), + ("currentColor", "currentcolor"), + ("#DBEAFE", "#dbeafe"), + ("dbeafe", "#dbeafe"), + ("#FFF", "#fff"), + ("abc", "#abc"), + ('"#274e13"', "#274e13"), + ("linear-gradient(#f69d3c, #3f87a6)", "linear-gradient(#f69d3c, #3f87a6)"), + ( + "radial-gradient(circle, red 10%, blue)", + "radial-gradient(circle, red 10%, blue)", + ), + ("${primary}", "${primary}"), + ], +) +def test_color__normalizes_valid_colors(text: str, expected: str) -> None: + assert Color(text).value == expected + + +@pytest.mark.parametrize( + "text", + [ + "", + "notacolor", + "#abcd", + "#aabbccdd", + "#abcde", + "#ggg", + "rgb(1, 2, 3)", + "linear-gradient(red", + "linear-gradient()", + "conic-gradient(red, blue)", + "LINEAR-GRADIENT(red, blue)", + "red; blue", + ], +) +def test_color__rejects_invalid_colors(text: str) -> None: + with pytest.raises(ValueError, match="is not a D2 color"): + Color(text) + + +def test_color__is_written_quoted_unless_it_is_a_name() -> None: + assert str(Color("red")) == "red" + assert str(Color("#abc")) == '"#abc"' + assert str(Color("linear-gradient(red, blue)")) == '"linear-gradient(red, blue)"' + + +def test_color__of_treats_empty_as_unset() -> None: + assert Color.of(None) is None + assert Color.of("") is None + assert Color.of("red") == Color("red") + assert Color.of(Color("red")) == Color("red") + + +def test_style__accepts_colors_and_strings() -> None: + assert str(d2.Style(fill=d2.Color("#DBEAFE"))) == 'style: {\n fill: "#dbeafe"\n}' + assert str(d2.Style(stroke="dbeafe")) == 'style: {\n stroke: "#dbeafe"\n}' + assert str(d2.Style(fill="")) == "" + + +def test_style__rejects_invalid_colors() -> None: + with pytest.raises(ValueError, match="is not a D2 color"): + d2.Style(fill="not-a-color") + with pytest.raises(ValueError, match="is not a D2 color"): + d2.Style(font_color="#12") + + +@pytest.mark.parametrize( + ("option", "build"), + [ + ("opacity", lambda: d2.Style(opacity=-0.1)), + ("opacity", lambda: d2.Style(opacity=1.5)), + ("stroke_width", lambda: d2.Style(stroke_width=-1)), + ("stroke_width", lambda: d2.Style(stroke_width=16)), + ("stroke_dash", lambda: d2.Style(stroke_dash=11)), + ("font_size", lambda: d2.Style(font_size=7)), + ("font_size", lambda: d2.Style(font_size=101)), + ("border_radius", lambda: d2.Style(border_radius=-1)), + ], +) +def test_style__rejects_values_outside_the_d2_range( + option: str, build: Callable[[], d2.Style] +) -> None: + with pytest.raises(ValueError, match=option): + build() + + +def test_style__accepts_the_ends_of_each_range() -> None: + style = d2.Style( + opacity=1, stroke_width=15, stroke_dash=10, font_size=8, border_radius=0 + ) + + assert style.lines() == [ + "style: {", + " stroke-width: 15", + " opacity: 1", + " stroke-dash: 10", + " border-radius: 0", + " font-size: 8", + "}", + ] + + +def test_style__border_radius_parses_numbers_written_as_text() -> None: + assert d2.Style(border_radius="12").border_radius == 12 + with pytest.raises(ValueError, match="invalid literal"): + d2.Style(border_radius="round") + + +def test_shape__rejects_grid_and_position_values_outside_the_d2_range() -> None: + with pytest.raises(ValueError, match="grid_rows"): + d2.Shape(name="a", grid_rows=0) + with pytest.raises(ValueError, match="grid_columns"): + d2.Shape(name="a", grid_columns=0) + with pytest.raises(ValueError, match="grid_gap"): + d2.Shape(name="a", grid_gap=-1) + with pytest.raises(ValueError, match="top"): + d2.Shape(name="a", top=-1) + with pytest.raises(ValueError, match="horizontal_gap"): + d2.Shape(name="a", horizontal_gap=-1) + + +def test_config__rejects_negative_theme_ids() -> None: + with pytest.raises(ValueError, match="theme_id"): + d2.Config(theme_id=-1) + with pytest.raises(ValueError, match="dark_theme_id"): + d2.Config(dark_theme_id=-1) + + +def test_text__grows_the_pipe_count_so_the_content_cannot_close_it() -> None: + assert Text("a | b", "md").pipes == 2 + assert Text("a || b", "md").pipes == 3 + assert Text("a || b", "md", pipes=5).pipes == 5 + assert Text("plain", "md").pipes == 1 + assert Text("a | b", "md").lines() == ["||md", "a | b", "||"] + + +def test_text__rejects_a_format_that_breaks_the_opening_line() -> None: + with pytest.raises(ValueError, match="formatting"): + Text("x", "m d") + with pytest.raises(ValueError, match="formatting"): + Text("x", "m|d") + + +def test_emit__indents_nested_fields() -> None: + statements = ( + Field( + Key("a"), + Label("x"), + (Field(Keyword("width"), 3), Field(Key("b"), None, (Field(Key("c")),))), + ), + ) + + assert emit(statements) == ["a: x {", " width: 3", " b: {", " c", " }", "}"] + assert emit(statements, 1)[0] == " a: x {" + + +def test_emit__writes_inline_maps_on_one_line() -> None: + field = Field( + Key("id"), + Label("int"), + (Field(Keyword("constraint"), Array((Label("unique"), Label("not null")))),), + inline=True, + ) + + assert emit((field,)) == ["id: int {constraint: [unique; not null]}"] + + +def test_emit__edges_keep_endpoints_as_given() -> None: + edge = Edge(Keyword("a.b"), Direction.BOTH, Keyword("c.d"), Label("x: y")) + + assert emit((edge,)) == ['a.b <-> c.d: "x: y"'] + assert emit( + (Edge(Keyword("a"), Direction.TO, Keyword("b"), None, (Comment("x"),)),) + ) == [ + "a -> b: {", + " # x", + "}", + ] + + +def test_emit__comments_have_one_prefix_per_line() -> None: + assert emit((Comment("one\ntwo"),)) == ["# one", "# two"] + + +def test_emit__block_fields_close_at_the_key_indent() -> None: + field = BlockField(Keyword("label"), Text("a\nb", "md")) + + assert emit((field,), 1) == [" label: |md", " a", " b", " |"] + + +def test_one_or_array__writes_a_single_item_bare() -> None: + assert str(one_or_array([Key("x")])) == "x" + assert str(one_or_array([Key("x"), Key("shape")])) == '[x; "shape"]' + + +def test_variables__are_labels_and_quote_dollars() -> None: + diagram = d2.Diagram(variables={"price": "$5", "n": 2}) + + assert diagram.lines() == ["vars: {", ' price: "\\$5"', " n: 2", "}"] + + +def test_shape__empty_strings_mean_unset_for_links_and_tooltips() -> None: + shape = d2.Shape(name="a", icon="", near="", link="", tooltip="") + + assert shape.icon is None + assert str(shape) == "a" + + +def test_connection__empty_label_means_no_label() -> None: + connection = d2.Connection("a", "b", "") + + assert connection.label is None + assert str(connection) == "a -> b" + + +def test_class_shape__quotes_member_names_that_are_keywords() -> None: + shape = d2.ClassShape("A", fields=[d2.ClassField("label", "str")]) + + assert str(shape) == 'A: {\n shape: class\n "label": "str"\n}'