archsight 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 540f7640d06327e62c658d9bff6cb9e60475f10c63a36ba541170c8ca3077ef1
4
- data.tar.gz: 8cfbe67e126f99e743e45935527b034f980531979ef76ee54295b81e7b27bbdc
3
+ metadata.gz: c8c3016b6533ac9d58b36bbf89d5bb34b6f62abe9579bd091f22e735b53cf3c9
4
+ data.tar.gz: 00e6854efc963f6c8916b0b16f8ffd2d4462ab5ccd6e2f84b1eb1d906e479d2a
5
5
  SHA512:
6
- metadata.gz: 185739ca5a2c52b6c4e1df7ab29f58772908df3bc5bcb53452b7b2114e042ea2a62624fa5d2254c088a36bd2118b3daa81c48eb4b475aa76308ecbe0b32737e4
7
- data.tar.gz: 32384cbe603e19c21e2bdc661979777aeba42e8faeb2a97e88479320bf31fc844ba42c995d11fc316711e9609ab7197a052aa914a74b6808e1cebd6b5cda5a75
6
+ metadata.gz: 9dbeaf9d8f3adf87ee96139436a145e888fbde2ac09feedfe7ddc3835583aa3a5e9d37c7a9e768ba7bb2e44935d2e81723d55af8cc0af7cd0531a0389689162e
7
+ data.tar.gz: 21d812087816f97bd171e5cdc30aea5ca81fec27ecab2e677817081305a721000a2ac604429c0be78d59170ed21ec01a79aa9f54492986ea81ce3e99ff5fd66f
data/docs/diagram.md CHANGED
@@ -153,18 +153,25 @@ hint for near misses), never silently ignored.
153
153
  | `resource` | nodes, containers | a resource name (`Archsight:Web`) or `Kind/Name` (`ApplicationService/Archsight:Web`); see *Linking to resources* |
154
154
  | `extend` | nodes, containers | `true`, `false`; containers also `height`, and `width` on a direct child of a `layer` |
155
155
  | `width` | `text` | characters per line (positive integer); wraps the label |
156
- | `anchor` | containers | `top`, `bottom`, `left`, `right`, `center` |
156
+ | `anchor` | containers; leaves with `no-extend` | `top`, `bottom`, `left`, `right`, `center` |
157
+ | `badge` | nodes, ports | the id of a top-level `badge` definition (see *Badges and line styles*) |
158
+ | `legend` | nodes, containers | the text of the node's tint row in the legend (see *Legend*) |
159
+ | `align` | nodes, containers | `[edge] id` of another node to line up with, e.g. `top worker`; up to one horizontal and one vertical entry, comma-separated (see *Aligning across containers*) |
157
160
 
158
161
  `extend "height"` on a direct child of a `layer` grows it to its tallest sibling. Inside such a box (also through a `boundary` around a `stack`), a `stack` child carrying `extend "height"` takes the extra height and moves the ranks below it down, so e.g. a footer rank lines up across the columns. The child can be a `layer` too; its own `extend "height"` children then fill the layer's new height.
159
162
 
160
163
  `extend "width"` is the same for the other axis. When a `layer` is made wider than its content (for example it sits in a `stack` next to a wider sibling), its children normally stay together as one block placed by `anchor`. A direct child of the layer with `extend "width"` instead takes the spare width and pushes the siblings after it to the right edge, so `main` can sit at the left edge and `aside` at the right edge, and the `aside` columns line up across stacked layers. Several children with `extend "width"` share the spare width equally. The child's own content stays at its `anchor` (`left` by default) at its natural size. The attribute is an error anywhere but on a direct child of a `layer`; edges to boxes inside the pushed siblings follow their new positions.
161
164
 
162
165
  `anchor` says where a container's content sits when its box grows: `bottom` (or `top`) when it is made taller, `right` (or `left`) when it is made wider, `center` for the middle. Default: top for `extend "height"`, left for `extend "width"`, centered otherwise. It is not inherited, and has no effect on a box whose room is taken by a stack child with `extend "height"`.
166
+
167
+ `no-extend` on a leaf keeps it at its own size inside a wider `stack` (which otherwise stretches every rank to the widest one), centered there; `anchor "left"`/`"right"` puts it at that edge instead (an error without `no-extend`).
163
168
  | `shape` | leaves | `rectangle`, `circle`, `cylinder`, `pipe`, `actor`, `file`, `module` |
164
169
  | `gap` | containers | pixels (`40`), a percentage (`150%`) or a signed one (`+20%`, `-50%`) |
170
+ | `padding` | containers | the room between the box and its content, same values as `gap` (default: the theme's) |
165
171
  | `ranks` | containers | `auto`, `on`, `down`, `right`, `off` |
166
172
  | `columns` | containers | a positive integer |
167
173
  | `style` | edges | `straight`, `orthogonal` |
174
+ | `line` | edges | `solid` (default), `dashed`, `dotted`: the stroke only, the relation keeps its arrowhead |
168
175
  | `relation` | edges | `dependency`, `implements`, `control`, `data` |
169
176
  | `color` | dataflows | a hex color such as `#1a56db` |
170
177
 
@@ -182,8 +189,10 @@ empty id, and an edge from a node to itself are errors too.
182
189
  Any label anywhere (node, container, edge, dataflow) supports:
183
190
 
184
191
  - A small inline markdown-lite: `**bold**`, `*italic*`, `__underline__`,
185
- and `[text](url)` for a link on just that run of text (independent of
186
- a node-level `link` — a label can have both, or just one).
192
+ `` `code` `` (monospace, for commands, file names and ports), and
193
+ `[text](url)` for a link on just that run of text (independent of
194
+ a node-level `link` — a label can have both, or just one). Runs don't
195
+ nest: `` **`x`** `` shows the backticks.
187
196
  - Multi-line text, written either with an explicit `\n` escape
188
197
  (`label "Line one\nLine two"`) or by literally spanning multiple lines
189
198
  in the source; in the latter case each line's leading/trailing
@@ -214,6 +223,89 @@ Edge attributes:
214
223
  you pass `--relation=`, though layout always accounts for every edge
215
224
  regardless of what's shown.
216
225
 
226
+ ### Ports
227
+
228
+ A port is a small labelled tab on a leaf's border -- a TCP port, a socket,
229
+ a file a process listens on -- that edges attach to instead of the leaf as a
230
+ whole. Declare it inside the leaf and name it in an edge as `leaf.port`:
231
+
232
+ ```
233
+ component "mgr" {
234
+ label "**manager**"
235
+ port "tcp" { label "tcp :8075"; side "top" }
236
+ port "sock" { label "unix mgr.sock"; side "top"; badge "beta" }
237
+ }
238
+ client -> mgr.tcp { label "requests" }
239
+ agent -> mgr.sock
240
+ mgr.sock -> logger
241
+ ```
242
+
243
+ - `label` is drawn in monospace (default: the id), `side` is `top`,
244
+ `bottom`, `left` or `right`, `at` is `left`, `center`, `right` (`top`,
245
+ `center`, `bottom` on a left/right side) or a percentage of the side
246
+ (`25%`), and `badge` names a badge (see below).
247
+ - Without `side`, the port goes on the side facing what its edges connect
248
+ it to (`top` without edges). Without `at`, it slides along its side to
249
+ line up with what its edges connect it to (the mean of their centers),
250
+ unless that spans the whole side; ports without a preference share the
251
+ side evenly in declaration order. Tabs on one side never overlap.
252
+ - The leaf grows to hold its tabs, and a `stack` leaves room between two
253
+ ranks for tabs sticking out of the sides they face.
254
+ - An edge to a port ends on the tab's outer side and never runs through
255
+ its own leaf; several edges on one tab are spread over it. Tabs are
256
+ obstacles to every other line, so no line ends on top of one.
257
+ - Ports need a box-shaped leaf (`rectangle`, `file`, `module` shapes). A
258
+ port id is local to its leaf and has no `.`. `leaf.port` resolves to a
259
+ node with that whole id first (ids of a namespaced include contain dots),
260
+ and an unknown port is an error listing the leaf's ports. Dataflow hops
261
+ name nodes, not ports.
262
+
263
+ ### Badges and line styles
264
+
265
+ Top-level definitions give the legend its text for a marker:
266
+
267
+ ```
268
+ badge "beta" { tint "orange"; label "beta channel only" }
269
+ line "dashed" { label "read only at start" }
270
+
271
+ component "agent" { badge "beta" }
272
+ config -> mgr { line "dashed"; label "read at start" }
273
+ ```
274
+
275
+ - `badge "id" { tint; label }` defines a dot that nodes and ports carry
276
+ with `badge "id"` (inside a node's top-right corner, at a port tab's right
277
+ end). Each badge used gets a legend row.
278
+ - `line "dashed" { label }` (or `"dotted"`) gives that line style its
279
+ legend row; without the definition, `line "dashed"` edges still draw
280
+ dashed but have no row. A diagram with ports gets a "Port" row.
281
+ - An included diagram's definitions count at the top level; the same
282
+ definition twice is fine, two different ones with one id are an error.
283
+ Inside a container-level include they are ignored with a warning.
284
+
285
+ ### Aligning across containers
286
+
287
+ `align "<edge> other"` moves a node (or container) until that edge of it
288
+ lines up with the same edge of `other`, which may sit in a different
289
+ container -- like the align buttons of a drawing editor:
290
+
291
+ | edge | lines up |
292
+ |---|---|
293
+ | `left`, `center`, `right` | the left edges, centers, right edges (horizontally) |
294
+ | `top`, `middle`, `bottom` | the top edges, centers, bottom edges (vertically) |
295
+
296
+ A bare id means `center` (`align "api"` puts a node under `api`).
297
+ One horizontal and one vertical entry can be combined with a comma:
298
+ `align "left db, top worker"`.
299
+
300
+ It only uses the room its container already has and never grows one.
301
+ Where the container packs along that axis (a `layer` for left/right, a
302
+ `stack` for up/down) the node's siblings keep their order and are pushed
303
+ along only when they would come closer than the sibling gap (plus half a
304
+ port tab on each facing side); elsewhere the node moves within its
305
+ container's inner box (an anonymous `layer`/`stack` lends it the room of
306
+ its own container on its packing axis). It stops as close as it gets. A
307
+ leaf a `stack` stretches has no room to move sideways; give it `no-extend`.
308
+
217
309
  ### Dataflows
218
310
 
219
311
  `dataflow "id" { hop "..."; hop "..."; ...; color "#hex"; label "..." }`
@@ -269,7 +361,12 @@ isn't affected.
269
361
  ### Legend
270
362
 
271
363
  A legend is added automatically, listing only the shapes, relations,
272
- boundary styles and dataflows actually used. It's left out entirely for a
364
+ boundary styles, dataflows, ports, badges and defined line styles actually used.
365
+ A tint set on a leaf gets a row named after the leaves carrying it (the first
366
+ line of each label, without markup, joined with commas), or by the `legend`
367
+ text of one of them; a tint whose leaves all sit inside a container of the
368
+ same tint gets none, the container's row explains it. A tinted container's
369
+ row reads `Boundary <label>` (or `Group <label>`), unless it has a `legend` text. It's left out entirely for a
273
370
  plain diagram of components and dependency edges.
274
371
 
275
372
  It's laid out like the rest of the drawing: a `layer` of `stack`s (its
@@ -24,15 +24,25 @@ module Archsight
24
24
  # container. Keep these tables in step with the readers in
25
25
  # `Graph::Node`/`Graph::Edge`/`Graph::DataFlow` and with `docs/diagram.md`.
26
26
  module Attributes
27
- NODE = %w[label tint link resource extend].freeze
27
+ NODE = %w[label tint link resource extend badge align legend].freeze
28
28
  LEAF_KINDS = %i[component application api database queue actor file text].freeze
29
- LEAF = (NODE + %w[shape]).freeze
29
+ # `anchor` places a leaf that keeps its own size (`extend "false"`) inside a wider stack.
30
+ LEAF = (NODE + %w[shape anchor]).freeze
30
31
  # A `text` has no box to restyle (no `shape`), but may wrap at `width` characters.
31
32
  TEXT = (NODE + %w[width]).freeze
32
33
  WIDTH_FORMAT = /\A[1-9]\d*\z/
33
- CONTAINER = (NODE + %w[gap ranks columns anchor]).freeze
34
- EDGE = %w[label style relation tint].freeze
34
+ CONTAINER = (NODE + %w[gap padding ranks columns anchor]).freeze
35
+ EDGE = %w[label style relation tint line].freeze
35
36
  DATAFLOW = %w[label color].freeze
37
+ PORT = %w[label side at badge].freeze
38
+ BADGE_DEFINITION = %w[label tint].freeze
39
+ LINE_DEFINITION = %w[label].freeze
40
+
41
+ LINE_STYLES = %w[solid dashed dotted].freeze
42
+ PORT_SIDES = %w[top bottom left right].freeze
43
+ # A named spot on the port's side, or a percentage of its length (from the left or top).
44
+ PORT_AT = /\A(left|center|right|top|bottom|(100|[1-9]?\d)(\.\d+)?%)\z/
45
+ PORT_SHAPES = %w[rectangle file module].freeze
36
46
 
37
47
  EDGE_STYLES = %w[straight orthogonal].freeze
38
48
  LEAF_EXTEND = %w[true false].freeze
@@ -73,10 +83,53 @@ module Archsight
73
83
  when "style" then check_value!(key, value, EDGE_STYLES, where, ast_edge.line)
74
84
  when "relation" then check_value!(key, value, Relations.names, where, ast_edge.line)
75
85
  when "tint" then check_value!(key, value, Tints.names, where, ast_edge.line)
86
+ when "line" then check_value!(key, value, LINE_STYLES, where, ast_edge.line)
87
+ end
88
+ end
89
+ end
90
+
91
+ # A port's attrs, and that its leaf has a box shape to put a tab on.
92
+ def check_port!(ast_port, node)
93
+ where = "port #{ast_port.id.inspect} of #{node.kind} #{node.id.inspect}"
94
+ unless node.leaf? && node.kind != :text && PORT_SHAPES.include?(node.shape)
95
+ raise GraphError, "#{where} (line #{ast_port.line}): ports need a box-shaped leaf (shape #{PORT_SHAPES.join(", ")}), " \
96
+ "not #{node.kind == :text ? "a text" : "shape #{node.shape.inspect}"}"
97
+ end
98
+
99
+ ast_port.attrs.each do |key, value|
100
+ check_name!(key, PORT, where, ast_port.line)
101
+ case key
102
+ when "label" then check_label!(value, where, ast_port.line)
103
+ when "side" then check_value!(key, value, PORT_SIDES, where, ast_port.line)
104
+ when "at" then check_port_at!(value, ast_port.attrs["side"], where, ast_port.line)
76
105
  end
77
106
  end
78
107
  end
79
108
 
109
+ def check_port_at!(value, side, where, line)
110
+ check_format!("at", value, PORT_AT, "left, center, right, top, bottom or a percentage (25%)", where, line)
111
+ along = side.nil? || %w[top bottom].include?(side) ? %w[left right] : %w[top bottom]
112
+ return unless (%w[left right top bottom] - along).include?(value)
113
+
114
+ raise GraphError, "at #{value.inspect} on #{where} (line #{line}) does not lie along its #{side || "top"} side; expected #{along.join(", ")}, center or a percentage"
115
+ end
116
+
117
+ # A top-level `badge`/`line` definition.
118
+ def check_definition!(definition)
119
+ where = "#{definition.kind} #{definition.id.inspect}"
120
+ allowed = definition.kind == :badge ? BADGE_DEFINITION : LINE_DEFINITION
121
+ if definition.kind == :line && !(LINE_STYLES - ["solid"]).include?(definition.id)
122
+ raise GraphError, "unknown line style #{definition.id.inspect} (line #{definition.line}); expected dashed or dotted" \
123
+ "#{suggestion(definition.id, LINE_STYLES - ["solid"])}"
124
+ end
125
+
126
+ definition.attrs.each do |key, value|
127
+ check_name!(key, allowed, where, definition.line)
128
+ check_label!(value, where, definition.line) if key == "label"
129
+ check_value!(key, value, Tints.names, where, definition.line) if key == "tint"
130
+ end
131
+ end
132
+
80
133
  # A dataflow's own attrs and each of its branches'.
81
134
  def check_dataflow!(ast_dataflow)
82
135
  where = "dataflow #{ast_dataflow.id.inspect}"
@@ -119,13 +172,13 @@ module Archsight
119
172
  def check_node_value!(key, value, block, where, ranks_modes)
120
173
  line = block.line
121
174
  case key
122
- when "label" then check_label!(value, where, line)
175
+ when "label", "legend" then check_label!(value, where, line)
123
176
  when "shape" then check_value!(key, value, Representers.names, where, line)
124
177
  when "tint" then check_value!(key, value, Tints.names, where, line)
125
178
  when "ranks" then check_value!(key, value, ranks_modes, where, line)
126
179
  when "extend" then check_value!(key, value, leaf?(block) ? LEAF_EXTEND : CONTAINER_EXTEND, where, line)
127
180
  when "anchor" then check_value!(key, value, ANCHORS, where, line)
128
- when "gap" then check_format!(key, value, GAP_FORMAT, "a pixel count (40), a percentage (150%) or a signed one (+20%, -50%)", where, line)
181
+ when "gap", "padding" then check_format!(key, value, GAP_FORMAT, "a pixel count (40), a percentage (150%) or a signed one (+20%, -50%)", where, line)
129
182
  when "width" then check_format!(key, value, WIDTH_FORMAT, "a positive number of characters per line", where, line)
130
183
  when "link" then check_link!(value, where, line)
131
184
  when "resource" then check_resource!(value, block, where, line)
@@ -10,6 +10,9 @@ module Archsight
10
10
  raise GraphError, "#{block.kind} has an empty id (line #{block.line})" if block.id.strip.empty?
11
11
 
12
12
  Attributes.check_node!(block, ranks_modes: RANKS_MODES)
13
+ if Attributes.leaf?(block) && block.attrs.key?("anchor") && block.attrs["extend"] != "false"
14
+ raise GraphError, "anchor on #{block.kind} #{block.id.inspect} (line #{block.line}) only places a leaf that keeps its own size: add no-extend"
15
+ end
13
16
  raise GraphError, "extend \"width\" on #{block.kind} #{block.id.inspect} (line #{block.line}) only applies to a direct child of a layer" if block.attrs["extend"] == "width" && !parent&.layer?
14
17
 
15
18
  node = Node.for(id: block.id, kind: block.kind, attrs: block.attrs, children: [], parent: parent,
@@ -22,12 +25,82 @@ module Archsight
22
25
  node.children << build_node(child, node, raw_edges)
23
26
  when AST::Edge
24
27
  raw_edges << child
28
+ when AST::Port
29
+ add_port(node, child)
25
30
  end
26
31
  end
27
32
 
28
33
  node
29
34
  end
30
35
 
36
+ def add_port(node, ast_port)
37
+ Attributes.check_port!(ast_port, node)
38
+ if (first = node.port(ast_port.id))
39
+ raise GraphError, "duplicate port #{ast_port.id.inspect} on #{node.id.inspect} (line #{ast_port.line}, first defined at line #{first.line})"
40
+ end
41
+
42
+ node.ports << Port.new(id: ast_port.id, node: node, attrs: ast_port.attrs, line: ast_port.line)
43
+ end
44
+
45
+ # A top-level `badge "id" { ... }` / `line "dashed" { ... }`: each id once per kind.
46
+ def add_definition(ast_definition)
47
+ Attributes.check_definition!(ast_definition)
48
+ key = [ast_definition.kind, ast_definition.id]
49
+ if (first = @definitions[key])
50
+ return if first.attrs == ast_definition.attrs # the same definition, e.g. from two included diagrams
51
+
52
+ raise GraphError, "#{ast_definition.kind} #{ast_definition.id.inspect} already defined at line #{first.line} (line #{ast_definition.line})"
53
+ end
54
+
55
+ @definitions[key] = Definition.new(kind: ast_definition.kind, id: ast_definition.id, attrs: ast_definition.attrs, line: ast_definition.line)
56
+ end
57
+
58
+ # `badge "id"` on nodes and ports names a definition, `align "id"` another node.
59
+ def check_references!
60
+ @nodes_by_id.each_value do |node|
61
+ check_badge!(node.badge, "#{node.kind} #{node.id.inspect}", node.line) if node.badge
62
+ node.ports.each { |port| check_badge!(port.badge, "port #{port.ref.inspect}", port.line) if port.badge }
63
+ check_align!(node) if node.align
64
+ end
65
+ end
66
+
67
+ def check_badge!(id, where, line)
68
+ return if @definitions.key?([:badge, id])
69
+
70
+ known = @definitions.values.select { |d| d.kind == :badge }.map(&:id)
71
+ hint = known.empty? ? " (define it at the top level: badge #{id.inspect} { tint \"orange\"; label \"...\" })" : Attributes.suggestion(id, known)
72
+ raise GraphError, "unknown badge #{id.inspect} on #{where} (line #{line})#{hint}"
73
+ end
74
+
75
+ def check_align!(node)
76
+ where = "#{node.kind} #{node.id.inspect} (line #{node.line})"
77
+ entries = node.alignments
78
+ raise GraphError, "align #{node.align.inspect} on #{where} has an empty entry" if entries.any? { |_, _, id| id.empty? }
79
+
80
+ entries.group_by(&:first).each_value do |same_axis|
81
+ next if same_axis.length == 1
82
+
83
+ raise GraphError, "align #{node.align.inspect} on #{where} lines up #{same_axis.map { |_, e, _| e }.join(" and ")} on one axis; " \
84
+ "use one of left/center/right and one of top/middle/bottom"
85
+ end
86
+ entries.each { |_, _, id| check_align_target!(node, id, where) }
87
+ end
88
+
89
+ def check_align_target!(node, id, where)
90
+ target = @nodes_by_id[id]
91
+ unless target
92
+ word, rest = id.split(/\s+/, 2)
93
+ if rest
94
+ raise GraphError, "unknown edge #{word.inspect} in align #{node.align.inspect} on #{where}; expected one of " \
95
+ "#{Node::ALIGN_EDGES.keys.join(", ")}#{Attributes.suggestion(word, Node::ALIGN_EDGES.keys)}"
96
+ end
97
+ raise GraphError, "align #{node.align.inspect} on #{where} names no node #{id.inspect}#{Attributes.suggestion(id, @nodes_by_id.keys)}"
98
+ end
99
+ return unless target.ancestors.include?(node) || node.ancestors.include?(target)
100
+
101
+ raise GraphError, "align #{node.align.inspect} on #{where} names #{target == node ? "the node itself" : "a node it is nested with"}"
102
+ end
103
+
31
104
  # A top-level `theme "..."`/`legend "..."` statement: each may appear
32
105
  # once, with one of its own allowed values.
33
106
  def apply_setting(setting)
@@ -49,13 +122,38 @@ module Archsight
49
122
  end
50
123
 
51
124
  def resolve_edge(ast_edge)
52
- from = node(ast_edge.from)
53
- to = node(ast_edge.to)
54
- Edge.new(from: from, to: to, attrs: ast_edge.attrs, direction: ast_edge.direction, line: ast_edge.line)
125
+ from, from_port = endpoint(ast_edge.from)
126
+ to, to_port = endpoint(ast_edge.to)
127
+ raise GraphError, "connects #{from.id.inspect} to itself, which has no line to draw" if from == to
128
+
129
+ Edge.new(from: from, to: to, attrs: ast_edge.attrs, direction: ast_edge.direction, line: ast_edge.line,
130
+ from_port: from_port, to_port: to_port)
55
131
  rescue GraphError => e
56
132
  raise GraphError, "#{e.message} (edge at line #{ast_edge.line})"
57
133
  end
58
134
 
135
+ # An edge end: a node id, or `node.port` when no node has that whole id (namespaced ids contain dots too).
136
+ def endpoint(ref)
137
+ return [@nodes_by_id[ref], nil] if @nodes_by_id.key?(ref)
138
+
139
+ owner = port_owner(ref)
140
+ return [node(ref), nil] unless owner
141
+
142
+ port_id = ref.delete_prefix("#{owner.id}.")
143
+ port = owner.port(port_id)
144
+ return [owner, port] if port
145
+
146
+ known = owner.ports.map(&:id)
147
+ listed = known.empty? ? "it has no ports" : "its ports: #{known.join(", ")}"
148
+ raise GraphError, "unknown port #{port_id.inspect} on #{owner.id.inspect} (#{listed})#{Attributes.suggestion(port_id, known)}"
149
+ end
150
+
151
+ # The node `node.port` names, `nil` if the part before the last dot is no node
152
+ def port_owner(ref)
153
+ owner_id, dot, = ref.rpartition(".")
154
+ dot.empty? ? nil : @nodes_by_id[owner_id]
155
+ end
156
+
59
157
  def resolve_dataflow(ast_dataflow)
60
158
  group_index = ast_dataflow.hops.index { |h| h.is_a?(AST::DataFlowGroup) }
61
159
 
@@ -3,7 +3,8 @@
3
3
  module Archsight
4
4
  module Diagram
5
5
  class Graph
6
- Edge = Struct.new(:from, :to, :attrs, :direction, :line, keyword_init: true) do
6
+ # `from_port`/`to_port` are the `Graph::Port` an end attaches to (`a -> b.port`), `nil` for the node itself.
7
+ Edge = Struct.new(:from, :to, :attrs, :direction, :line, :from_port, :to_port, keyword_init: true) do
7
8
  def label
8
9
  attrs["label"]
9
10
  end
@@ -16,6 +17,11 @@ module Archsight
16
17
  attrs.fetch("relation", "dependency")
17
18
  end
18
19
 
20
+ # `solid` (the relation's own stroke), `dashed` or `dotted`.
21
+ def line_style
22
+ attrs.fetch("line", "solid")
23
+ end
24
+
19
25
  # The explicit `tint` attr, if any -- `nil` when unset, meaning
20
26
  # "use this relation's own fixed color" rather than falling back to
21
27
  # some default tint the way `Node#effective_tint` does (an edge
@@ -165,6 +165,30 @@ module Archsight
165
165
  attrs["link"]
166
166
  end
167
167
 
168
+ # The leaf's `port "id" { ... }` tabs, in declaration order (see `Graph::Port`).
169
+ def ports = (@ports ||= [])
170
+ def port(id) = ports.find { |p| p.id == id }
171
+
172
+ # The id of the top-level `badge` definition this node shows, if any.
173
+ def badge = attrs["badge"]
174
+
175
+ # The explicit text of this node's legend row (`legend "..."`), if any.
176
+ def legend_label = attrs["legend"]
177
+
178
+ # The raw `align` attr (see `Layout::Alignment`), if any.
179
+ def align = attrs["align"]
180
+
181
+ # `align "top a, left b"` as `[[axis, edge, id], ...]`; a bare id means `center`. The first word is an
182
+ # edge only when an id follows it, so a node named "top" can still be aligned to.
183
+ def alignments
184
+ return [] unless align
185
+
186
+ align.split(",").map(&:strip).map do |entry|
187
+ word, rest = entry.split(/\s+/, 2)
188
+ rest && Node::ALIGN_EDGES.key?(word) ? [Node::ALIGN_EDGES[word], word, rest.strip] : [:x, "center", entry]
189
+ end
190
+ end
191
+
168
192
  # Why this node's `resource` reference couldn't be resolved (see
169
193
  # `ResourceLinks`), or `nil` when it has none or it resolved.
170
194
  def broken_link
@@ -174,7 +198,8 @@ module Archsight
174
198
  # The padding and title band `Layout` reserves inside a container's
175
199
  # box -- none for an anonymous `layer`/`stack`, which never draws a
176
200
  # box of its own; `Legend::Frame` has its own fixed ones.
177
- def layout_padding(theme) = anonymous? ? 0.0 : theme.padding
201
+ # `padding` (same syntax as `gap`) overrides the theme's on a named container.
202
+ def layout_padding(theme) = anonymous? ? 0.0 : GapSpec.parse(attrs["padding"]).apply(theme.padding)
178
203
  def layout_title_height(theme) = anonymous? ? 0.0 : theme.title_height
179
204
 
180
205
  # See `Legend::Entry`, sized by `Layout` from its row instead of from
@@ -267,6 +292,9 @@ module Archsight
267
292
  def main_axis = Axis::HEIGHT
268
293
  end
269
294
 
295
+ # Which edge of another node an `align` entry lines a node up with, and on which axis.
296
+ Node::ALIGN_EDGES = { "left" => :x, "center" => :x, "right" => :x, "top" => :y, "middle" => :y, "bottom" => :y }.freeze
297
+
270
298
  Node::KIND_CLASSES = {
271
299
  component: Component, application: Application, api: Api, database: Database,
272
300
  queue: Queue, actor: Actor, file: FileNode, text: TextNode, group: Group, boundary: Boundary, layer: Layer, stack: Stack
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Diagram
5
+ class Graph
6
+ # A labelled tab on a leaf's border (`port "id" { ... }`) that edges attach to with
7
+ # `leaf.id`. `side`/`at` are the explicit placement, `nil` when left to `PortPlacement`.
8
+ Port = Struct.new(:id, :node, :attrs, :line, keyword_init: true) do
9
+ def label = attrs["label"] || id
10
+ def side = attrs["side"]
11
+ def at = attrs["at"]
12
+ def badge = attrs["badge"]
13
+
14
+ # `node.port`, as an edge names it
15
+ def ref = "#{node.id}.#{id}"
16
+ end
17
+
18
+ # A top-level `badge "id" { tint; label }` or `line "style" { label }` (see `AST::Definition`).
19
+ Definition = Struct.new(:kind, :id, :attrs, :line, keyword_init: true) do
20
+ def label = attrs["label"] || id
21
+ def tint = attrs.fetch("tint", "gray")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -8,6 +8,7 @@ require_relative "style/relations"
8
8
  require_relative "style/theme"
9
9
  require_relative "legend/modes"
10
10
  require_relative "graph/node"
11
+ require_relative "graph/port"
11
12
  require_relative "graph/edge"
12
13
  require_relative "graph/dataflow"
13
14
  require_relative "graph/attributes"
@@ -30,7 +31,7 @@ module Archsight
30
31
  SETTING_VALUES = { "theme" => -> { Theme.names }, "legend" => -> { Legend::MODES }, "ranks" => -> { RANKS_MODES } }.freeze
31
32
  SETTING_IVARS = { "theme" => :@theme_name, "legend" => :@legend_mode, "ranks" => :@ranks_mode }.freeze
32
33
 
33
- attr_reader :roots, :nodes_by_id, :edges, :dataflows, :theme_name, :legend_mode, :ranks_mode
34
+ attr_reader :roots, :nodes_by_id, :edges, :dataflows, :theme_name, :legend_mode, :ranks_mode, :definitions
34
35
 
35
36
  def self.build(statements)
36
37
  new(statements)
@@ -38,6 +39,7 @@ module Archsight
38
39
 
39
40
  def initialize(statements)
40
41
  @nodes_by_id = {}
42
+ @definitions = {}
41
43
  @roots = []
42
44
  raw_edges = []
43
45
  raw_dataflows = []
@@ -52,8 +54,11 @@ module Archsight
52
54
  raw_dataflows << stmt
53
55
  when AST::Setting
54
56
  apply_setting(stmt)
57
+ when AST::Definition
58
+ add_definition(stmt)
55
59
  end
56
60
  end
61
+ check_references!
57
62
 
58
63
  raw_edges.each { |e| Attributes.check_edge!(e) }
59
64
  raw_dataflows.each_with_object({}) do |df, seen|
@@ -70,9 +75,21 @@ module Archsight
70
75
 
71
76
  def node(id)
72
77
  @nodes_by_id.fetch(id) do
78
+ owner = port_owner(id)
79
+ raise GraphError, "#{id.inspect} is a port of #{owner.id.inspect}; only edges attach to ports" if owner&.port(id.delete_prefix("#{owner.id}."))
80
+
73
81
  raise GraphError, "unknown id #{id.inspect}"
74
82
  end
75
83
  end
84
+
85
+ # The `badge "id"` definition, `nil` if there is none.
86
+ def badge_definition(id) = @definitions[[:badge, id]]
87
+
88
+ # The legend text a top-level `line "dashed" { label }` gives that line style, `nil` without one.
89
+ def line_definition(style) = @definitions[[:line, style]]
90
+
91
+ # Every port of every leaf, in declaration order.
92
+ def ports = @nodes_by_id.values.flat_map(&:ports)
76
93
  end
77
94
  end
78
95
  end
@@ -93,11 +93,16 @@ module Archsight
93
93
  raise IncludeError, "in include #{path.inspect}: #{e.message}"
94
94
  end
95
95
 
96
- # Dataflows and settings apply to the whole diagram, so inside a container they are ignored (with a warning)
96
+ # Dataflows, settings and badge/line definitions apply to the whole diagram, so inside a container they are
97
+ # ignored (with a warning)
97
98
  def drop_whole_diagram_statements(path, parsed, line)
98
- dropped, kept = parsed.partition { |s| s.is_a?(AST::DataFlow) || s.is_a?(AST::Setting) }
99
+ dropped, kept = parsed.partition { |s| s.is_a?(AST::DataFlow) || s.is_a?(AST::Setting) || s.is_a?(AST::Definition) }
99
100
  dropped.each do |s|
100
- what = s.is_a?(AST::DataFlow) ? "dataflow #{s.id.inspect}" : "#{s.key} setting"
101
+ what = case s
102
+ when AST::DataFlow then "dataflow #{s.id.inspect}"
103
+ when AST::Definition then "#{s.kind} #{s.id.inspect}"
104
+ else "#{s.key} setting"
105
+ end
101
106
  @warnings << "include #{path.inspect} (line #{line}): the #{what} (line #{s.line}) is ignored, it only applies when included at the top level"
102
107
  end
103
108
  kept
@@ -145,9 +150,18 @@ module Archsight
145
150
  block.attrs["label"] ||= block.id # the label defaults to the id
146
151
  block.id = ref(block.id)
147
152
  end
153
+ block.attrs["align"] = rename_align(block.attrs["align"]) if block.attrs.key?("align")
148
154
  block.children.each { |child| rename(child) }
149
155
  end
150
156
 
157
+ # `top a, b` -> `top ns.a, ns.b`: the id of each entry, after its edge word if it has one
158
+ def rename_align(value)
159
+ value.split(",").map(&:strip).map do |entry|
160
+ word, rest = entry.split(/\s+/, 2)
161
+ rest && Graph::Node::ALIGN_EDGES.key?(word) ? "#{word} #{ref(rest.strip)}" : ref(entry)
162
+ end.join(", ")
163
+ end
164
+
151
165
  def rename_dataflow(flow)
152
166
  flow.attrs["label"] ||= flow.id
153
167
  flow.id = ref(flow.id)
@@ -159,8 +173,12 @@ module Archsight
159
173
  group
160
174
  end
161
175
 
176
+ # A defined id, or `node.port` of a defined node
162
177
  def ref(id)
163
- @defined[id] ? "#{@prefix}#{id}" : id
178
+ return "#{@prefix}#{id}" if @defined[id]
179
+
180
+ owner, dot, = id.rpartition(".")
181
+ !dot.empty? && @defined[owner] ? "#{@prefix}#{id}" : id
164
182
  end
165
183
  end
166
184
  end