archsight 0.4.2 → 0.4.3

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/docs/diagram.md +64 -0
  3. data/docs/modeling.md +3 -1
  4. data/lib/archsight/cli.rb +2 -1
  5. data/lib/archsight/database.rb +4 -3
  6. data/lib/archsight/diagram/cli.rb +11 -1
  7. data/lib/archsight/diagram/errors.rb +1 -0
  8. data/lib/archsight/diagram/includes.rb +168 -0
  9. data/lib/archsight/diagram/parser/ast.rb +5 -0
  10. data/lib/archsight/diagram/parser/table_layout.rb +2 -0
  11. data/lib/archsight/diagram/parser.rb +58 -16
  12. data/lib/archsight/diagram.rb +20 -4
  13. data/lib/archsight/export/confluence/storage.rb +6 -1
  14. data/lib/archsight/helpers/diagram_blocks.rb +34 -19
  15. data/lib/archsight/helpers/diagram_includes.rb +67 -0
  16. data/lib/archsight/helpers.rb +1 -0
  17. data/lib/archsight/linter.rb +10 -2
  18. data/lib/archsight/references.rb +4 -3
  19. data/lib/archsight/resources/application_component.rb +10 -0
  20. data/lib/archsight/resources/application_service.rb +9 -0
  21. data/lib/archsight/resources/technology_service.rb +9 -0
  22. data/lib/archsight/version.rb +1 -1
  23. data/lib/archsight/web/api/json_helpers.rb +3 -2
  24. data/lib/archsight/web/api/openapi/spec.yaml +9 -2
  25. data/lib/archsight/web/api/page_helpers.rb +1 -1
  26. data/lib/archsight/web/api/routes.rb +4 -3
  27. data/lib/archsight/web/application.rb +2 -1
  28. data/lib/archsight/web/public/vue/{DocPage-C8Ysway-.js → DocPage-L0GzDFiV.js} +1 -1
  29. data/lib/archsight/web/public/vue/{EditorPage-pr2MR6_b.js → EditorPage-B3iNbh1u.js} +1 -1
  30. data/lib/archsight/web/public/vue/{ErrorPage-FAkpW-hn.js → ErrorPage-Lro5vwhy.js} +1 -1
  31. data/lib/archsight/web/public/vue/{GraphView-liQJzFyo.js → GraphView-BRKwxa--.js} +1 -1
  32. data/lib/archsight/web/public/vue/{HomePage-DYpzSXG8.js → HomePage-DKgI1DXr.js} +2 -2
  33. data/lib/archsight/web/public/vue/{InstanceRouter-Ca_C8izS.css → InstanceRouter-CJSkGdBG.css} +1 -1
  34. data/lib/archsight/web/public/vue/InstanceRouter-CX6qxVTm.js +1 -0
  35. data/lib/archsight/web/public/vue/{KindList-CrgI4qDb.js → KindList-C556gf23.js} +1 -1
  36. data/lib/archsight/web/public/vue/{PageView-BIxT_t49.js → PageView-DAqHjL1x.js} +1 -1
  37. data/lib/archsight/web/public/vue/{QueryError-ChdyV8B1.js → QueryError-DvspMrsh.js} +1 -1
  38. data/lib/archsight/web/public/vue/{ResourceList-DdtaHY4k.js → ResourceList-BZOYa68X.js} +1 -1
  39. data/lib/archsight/web/public/vue/{SearchResults-BYRAcK_o.js → SearchResults-DaaOPG3t.js} +1 -1
  40. data/lib/archsight/web/public/vue/{WikiPage-CgvRN6R2.js → WikiPage-Bfx92Iig.js} +1 -1
  41. data/lib/archsight/web/public/vue/index-B_YGrWD8.css +1 -0
  42. data/lib/archsight/web/public/vue/{index-yiZbbvAs.js → index-DMhBow5t.js} +2 -2
  43. data/lib/archsight/web/public/vue.html +2 -2
  44. metadata +18 -16
  45. data/lib/archsight/web/public/vue/InstanceRouter-BdkBTmGO.js +0 -1
  46. data/lib/archsight/web/public/vue/index-BLSE5DSv.css +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c249926b6ff6855b19ace38f11b9f256340e4d77cd00fe4d5f981fcbb0fd12f1
4
- data.tar.gz: a0d7267da0223f8faed9a478a2c5ca029cdc23205fdff4feb945adb4d2b78f2e
3
+ metadata.gz: 66e3a2aa9223a9583edf06749e12bafe346bfd562570d299795f2334510ed36a
4
+ data.tar.gz: 52a7997e394247a86da162fb4015d97b755b2ac2fe69cfadd758f6d3e140c33b
5
5
  SHA512:
6
- metadata.gz: 18ae964be21eded1e7be2560cd70a0ea63cb714c76b2d5295b3ec4b1f2b1fc46f5ec418591f53c5856babe389696b96be1436e905ee5ee2bc4b922ecee43d816
7
- data.tar.gz: c8c8fbcba5d1f9db3b9d22c9fb2b1182569354fd08e2fd2cf44ba0bfe4b787ffac0446d48db5bd802f01269ea9ff6746383b2887bdf0a0f4b8d9e97e2ee2f9f6
6
+ metadata.gz: adc6fb4fad180ae14920ecfbccebfb242c9b3d48bc6bfb196b645de67f4dc490e4922274175fa9f942cfe4d80a327b8a4cf171ad9ccc1edd195c5d283c636bba
7
+ data.tar.gz: e82af637b926e46db4b61c23269d8f5b630222b78f6e169d413b21aa2f12858cbc80cacbc8325ab1d550174e11fedc8ca1780489d025c3dabe5c1943d1fd887b
data/docs/diagram.md CHANGED
@@ -43,6 +43,7 @@ archsight diagram three_tier.asd --relation=all # also show control/data-flow
43
43
  archsight diagram overview.asd --theme=compact # denser spacing + smaller fonts (or cozy, tight)
44
44
  archsight diagram overview.asd --legend=bottom # legend below (or right/left/top/none; default auto)
45
45
  archsight diagram *.asd # render every file to its own .svg
46
+ archsight diagram app.asd --resources=model/ # resolve include \"file://...\" paths from this directory
46
47
  archsight diagram --format *.asd # re-indent the sources in place
47
48
  archsight diagram --format --check *.asd # list unformatted sources, exit 1 if any (CI)
48
49
  ```
@@ -298,6 +299,69 @@ no broken styling. Library users pass a `resolver:` to `Diagram.render`
298
299
  (any `call(reference)` returning a URL, `nil` for a resource that exists but has nowhere to link to (the node stays as it is), `:missing` or `:ambiguous`, see
299
300
  `Archsight::Helpers::ResourceResolver`) and, to collect failures, `unresolved: []`.
300
301
 
302
+ ## Including diagrams
303
+
304
+ `include "<path>"` splices another diagram in at that position, so a part
305
+ that several diagrams share (a platform, a network, a team's services) is
306
+ written once:
307
+
308
+ ```
309
+ group "app" {
310
+ component "web" { label "Web server" }
311
+ stack {
312
+ include "archsight://ApplicationComponent/Platform:Database"
313
+ }
314
+ }
315
+ include "file://shared/network.asd"
316
+ web -> db
317
+ ```
318
+
319
+ The options of an `include` go in braces after the path (`namespace`, see below).
320
+
321
+ | Path | Included diagram |
322
+ |------|------------------|
323
+ | `archsight://<Kind>/<name>` | the `architecture/diagram` annotation of that resource |
324
+ | `file://<path>` | an `.asd` file, relative to the resources directory (also in nested includes) |
325
+
326
+ A path that leaves the resources directory (`..`, absolute paths, symlinks
327
+ pointing out) or is not an `.asd` file is refused. An `include` stands
328
+ wherever a `layer` or `stack` can: at the top level or inside a container
329
+ (not inside a leaf like `component`, and not together with `columns`). Included
330
+ diagrams may include others (up to 8 levels, 100 includes in all; a cycle is an
331
+ error). Nodes are one namespace across all of it, so an id defined twice is an
332
+ error, and an edge in either part may name a node of the other.
333
+
334
+ ### Including a diagram more than once
335
+
336
+ Ids are one namespace, so including the same diagram twice would define
337
+ every node twice. A `namespace` puts `<namespace>.` in front of every id the
338
+ included diagram defines (nodes and dataflows, and the references to them inside
339
+ it):
340
+
341
+ ```
342
+ include "archsight://ApplicationComponent/Platform:Server" { namespace "a" }
343
+ include "archsight://ApplicationComponent/Platform:Server" { namespace "b" }
344
+ a.web -> b.db
345
+ ```
346
+
347
+ A node without a `label` keeps showing its own id (`web`, not `a.web`), and the same
348
+ id from different includes no longer clashes. A reference inside the included diagram to an id it
349
+ does not define names a node of the including diagram and stays as it is. Namespaces
350
+ nest: a diagram included with `namespace "o"` that itself includes one with `namespace "in"` has
351
+ `o.in.…` ids. A namespace is a name of letters, digits, `_` and `-`, joined by dots.
352
+
353
+ Dataflows and `theme`/`legend`/`ranks` apply to the whole diagram: an included
354
+ diagram holding them keeps them when it is included at the top level, and they are
355
+ ignored, with a warning, when it is included inside a container. The warning is
356
+ written to the server log and listed by `archsight lint`.
357
+
358
+ A missing resource or file, a bad path or a broken included diagram fails the
359
+ diagram with an error that names the include (`archsight lint` reports it). Standalone
360
+ `archsight diagram` takes `file://` paths relative to `--resources DIR` (default:
361
+ the directory of the input) and has no `archsight://`. Library users pass `includes:` to
362
+ `Diagram.render`, any `call(path)` returning the source or `:missing`, `:forbidden` or `:invalid`
363
+ (see `Archsight::Helpers::DiagramIncludes`), and `warnings: []` to collect the warnings.
364
+
301
365
  ## Diagrams in markdown
302
366
 
303
367
  A fenced code block tagged `asd` in any markdown the web UI renders (docs,
data/docs/modeling.md CHANGED
@@ -36,7 +36,7 @@ There are two families of verbs:
36
36
 
37
37
  | Family | Verbs | Written on | Points to |
38
38
  |--------|-------|------------|-----------|
39
- | Responsibility and provider (down) | `performedBy`, `ownedBy`, `executedBy`, `servedBy`, `guidedBy`, `maintainedBy`, `contributedBy`, `hasConcern`, `representedBy`, `realizedThrough`, `exposes`, `provisions`, `connects`, `mitigatedBy`, `influences`, `triggers`, `affects`, `assesses`, `contains`, `closedBy`, `compares` | the element that needs, uses or is concerned with something | what provides it or is responsible for it |
39
+ | Responsibility and provider (down) | `performedBy`, `ownedBy`, `operatedBy`, `executedBy`, `servedBy`, `guidedBy`, `maintainedBy`, `contributedBy`, `hasConcern`, `representedBy`, `realizedThrough`, `exposes`, `provisions`, `connects`, `mitigatedBy`, `influences`, `triggers`, `affects`, `assesses`, `contains`, `closedBy`, `compares` | the element that needs, uses or is concerned with something | what provides it or is responsible for it |
40
40
  | Realization (up) | `realizes`, `partiallyRealizes`, `plans`, `satisfies`, `evidencedBy` (on the evidenced element), `provides` | the concrete element | the abstract element it answers to (requirement, goal, capability, plateau) |
41
41
 
42
42
  Rules:
@@ -616,6 +616,8 @@ Relations are written on the element that is placed (see [Direction of Relations
616
616
  | `TechnologyFacility` | `composedOf` | `TechnologyFacility` (nested sites, the rooms of a data center) |
617
617
  | `TechnologyFacility` | `servedBy` | `BusinessActor` (the operator) |
618
618
  | `TechnologyDevice`, `TechnologyEquipment`, `TechnologyNode`, `TechnologyCommunicationNetwork` | `ownedBy` | `BusinessActor`, `BusinessRole` (the team that operates it) |
619
+ | `ApplicationComponent`, `ApplicationService`, `TechnologyService` | `operatedBy` | `BusinessActor`, `BusinessRole` (the team that runs it in production) |
620
+ | `ApplicationComponent`, `ApplicationService`, `TechnologyService` | `maintainedBy` | `BusinessActor` (the team that builds and changes it) |
619
621
  | `TechnologyDevice`, `TechnologyEquipment`, `TechnologyNode` | `servedBy` | `BusinessActor` (the team it is dedicated to, e.g. a test partition) |
620
622
 
621
623
  `locatedAt` with many targets is the point: a service that runs in three data centers is **one** service with three
data/lib/archsight/cli.rb CHANGED
@@ -279,6 +279,7 @@ module Archsight
279
279
  option :theme, type: :string, desc: "Spacing/font-size theme (default: the file's own `theme` statement)"
280
280
  option :legend, type: :string, desc: "Legend placement (default: the file's own `legend` statement, else auto)"
281
281
  option :profile, aliases: "-p", type: :boolean, desc: "Print stage timing stats after each render"
282
+ option :resources, aliases: "-r", type: :string, desc: "Directory that `include \"file://...\"` paths are relative to (default: the input's directory)"
282
283
  option :format, type: :boolean, desc: "Re-indent the inputs in place instead of rendering them (-o: write a single input there)"
283
284
  option :check, type: :boolean, desc: "With --format: only list the inputs that are not formatted, exit 1 if any"
284
285
  def diagram(*inputs)
@@ -295,7 +296,7 @@ module Archsight
295
296
 
296
297
  status = diagram::CLI.new.run_with(
297
298
  inputs: inputs, output: options[:output], watch: options[:watch], profile: options[:profile],
298
- format: options[:format], check: options[:check],
299
+ format: options[:format], check: options[:check], resources: options[:resources],
299
300
  style: options[:style], theme: options[:theme], legend: options[:legend],
300
301
  relation_filter: if relation.nil?
301
302
  diagram::Relations::DEFAULT_FILTER
@@ -109,11 +109,12 @@ module Archsight
109
109
  matches.first if matches.one?
110
110
  end
111
111
 
112
- # Collect unique annotation values across all instances of a kind
112
+ # Annotation values across all instances of a kind with the number of instances using each,
113
+ # most used first (ties by value): [[value, count], ...]
113
114
  def annotation_values(kind, annotation)
114
115
  instances = instances_by_kind(kind).values
115
- values = instances.flat_map { |inst| Array(annotation.value_for(inst)) }
116
- values.compact.uniq.sort
116
+ counts = instances.flat_map { |inst| Array(annotation.value_for(inst)).compact.uniq }.tally
117
+ counts.sort_by { |value, count| [-count, value.to_s] }
117
118
  end
118
119
 
119
120
  # Get filterable annotations with their values for a kind (excludes empty)
@@ -3,6 +3,7 @@
3
3
  require "optparse"
4
4
  require_relative "."
5
5
  require_relative "formatter"
6
+ require_relative "../helpers/diagram_includes"
6
7
 
7
8
  module Archsight
8
9
  module Diagram
@@ -79,6 +80,8 @@ module Archsight
79
80
  opts.on("-p", "--profile", "Print stage timing stats after each render") do
80
81
  options[:profile] = true
81
82
  end
83
+ opts.on("--resources=DIR", "Directory that include \"file://...\" paths are relative to " \
84
+ "(default: the input file's directory)") { |v| options[:resources] = v }
82
85
  opts.on("--format", "Re-indent the inputs in place instead of rendering them (-o: write a single input there)") do
83
86
  options[:format] = true
84
87
  end
@@ -155,7 +158,9 @@ module Archsight
155
158
  total_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profile
156
159
 
157
160
  svg = Archsight::Diagram.render(File.read(input), relation_filter: options[:relation_filter], profile: stats,
158
- style: options[:style], theme: options[:theme], legend: options[:legend])
161
+ style: options[:style], theme: options[:theme], legend: options[:legend],
162
+ includes: includes_for(input, options), warnings: warnings = [])
163
+ warnings.each { |message| warn "archsight-diagram: #{input}: #{message}" }
159
164
 
160
165
  write_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profile
161
166
  File.write(output, svg)
@@ -172,6 +177,11 @@ module Archsight
172
177
  1
173
178
  end
174
179
 
180
+ # Standalone, `include` reads `file://` paths only (there is no model for `archsight://`)
181
+ def includes_for(input, options)
182
+ Helpers::DiagramIncludes.new(nil, resources_dir: options[:resources] || File.dirname(File.expand_path(input)))
183
+ end
184
+
175
185
  def print_stats(stats, total_start)
176
186
  total = Process.clock_gettime(Process::CLOCK_MONOTONIC) - total_start
177
187
  parts = stats.map { |key, seconds| "#{key}=#{format("%.1fms", seconds * 1000)}" }
@@ -5,5 +5,6 @@ module Archsight
5
5
  class Error < StandardError; end
6
6
  class ParseError < Error; end
7
7
  class GraphError < Error; end
8
+ class IncludeError < Error; end
8
9
  end
9
10
  end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Diagram
5
+ # Expands `include "<path>"` statements: each one is replaced, at its position, by the statements of the
6
+ # diagram it names. The renderer knows nothing about where diagrams live; the caller supplies a loader
7
+ # (`archsight://` and `file://` paths, see `Helpers::DiagramIncludes`).
8
+ #
9
+ # `loader.call(path)` returns the source of the included diagram, or `:missing` / `:forbidden` / `:invalid`.
10
+ # Included sources may include others. An include inside a container takes blocks and edges only: a
11
+ # `dataflow` or a `theme`/`legend`/`ranks` setting belongs to the whole diagram, so it is left out there
12
+ # and reported in `Result#warnings`.
13
+ # `include "x" { namespace "cs" }` puts `cs.` in front of every id the included diagram defines (nodes and
14
+ # dataflows, and the references to them inside it), so the same diagram can be included more than once;
15
+ # a node without a `label` keeps showing its unprefixed id. References to ids the diagram does not define
16
+ # are left alone: they name nodes of the including diagram.
17
+ # Edges need no such care, they are resolved by id once the whole graph is built, so a fragment may link to
18
+ # nodes of the diagram that includes it and the other way round.
19
+ class Includes
20
+ MAX_DEPTH = 8
21
+ MAX_INCLUDES = 100
22
+ REASONS = { missing: "not found", forbidden: "is not allowed", invalid: "is not a valid include path" }.freeze
23
+
24
+ # The expanded statements, the sources that were loaded (`{ path => source }`, in order) and what was
25
+ # left out of them (warning messages).
26
+ Result = Struct.new(:statements, :sources, :warnings, keyword_init: true)
27
+
28
+ # @param statements [Array] parsed statements of the main source
29
+ # @param loader [#call, nil]
30
+ # @raise [IncludeError]
31
+ def self.expand(statements, loader)
32
+ new(loader).expand(statements)
33
+ end
34
+
35
+ def self.includes?(statements)
36
+ statements.any? { |s| s.is_a?(AST::Include) || (s.is_a?(AST::Block) && includes?(s.children)) }
37
+ end
38
+
39
+ def initialize(loader)
40
+ @loader = loader
41
+ @sources = {}
42
+ @warnings = []
43
+ @count = 0
44
+ end
45
+
46
+ def expand(statements)
47
+ Result.new(statements: splice(statements, [], top: true), sources: @sources, warnings: @warnings)
48
+ end
49
+
50
+ private
51
+
52
+ def splice(statements, stack, top:)
53
+ statements.flat_map do |stmt|
54
+ case stmt
55
+ when AST::Include then included(stmt, stack, top: top)
56
+ when AST::Block
57
+ stmt.children = splice(stmt.children, stack, top: false)
58
+ [stmt]
59
+ else [stmt]
60
+ end
61
+ end
62
+ end
63
+
64
+ def included(stmt, stack, top:)
65
+ path = stmt.path
66
+ raise IncludeError, "include cycle: #{(stack + [path]).join(" -> ")} (line #{stmt.line})" if stack.include?(path)
67
+ raise IncludeError, "include #{path.inspect} (line #{stmt.line}) is nested deeper than #{MAX_DEPTH} levels" if stack.length >= MAX_DEPTH
68
+
69
+ @count += 1
70
+ raise IncludeError, "too many includes (more than #{MAX_INCLUDES})" if @count > MAX_INCLUDES
71
+
72
+ source = load(path, stmt.line)
73
+ @sources[path] = source
74
+ parsed = parse(path, source)
75
+ parsed = drop_whole_diagram_statements(path, parsed, stmt.line) unless top
76
+ statements = splice(parsed, stack + [path], top: top)
77
+ stmt.namespace ? Namespacer.new(stmt.namespace).apply(statements) : statements
78
+ end
79
+
80
+ def load(path, line)
81
+ raise IncludeError, "include #{path.inspect} (line #{line}) is not available here" unless @loader
82
+
83
+ result = @loader.call(path)
84
+ return result if result.is_a?(String)
85
+
86
+ reason = REASONS.fetch(result) { raise ArgumentError, "include loader returned #{result.inspect} for #{path.inspect}" }
87
+ raise IncludeError, "include #{path.inspect} #{reason} (line #{line})"
88
+ end
89
+
90
+ def parse(path, source)
91
+ Parser.parse(source, anon_scope: "i#{@count}_")
92
+ rescue ParseError, GraphError => e
93
+ raise IncludeError, "in include #{path.inspect}: #{e.message}"
94
+ end
95
+
96
+ # Dataflows and settings apply to the whole diagram, so inside a container they are ignored (with a warning)
97
+ 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.each do |s|
100
+ what = s.is_a?(AST::DataFlow) ? "dataflow #{s.id.inspect}" : "#{s.key} setting"
101
+ @warnings << "include #{path.inspect} (line #{line}): the #{what} (line #{s.line}) is ignored, it only applies when included at the top level"
102
+ end
103
+ kept
104
+ end
105
+
106
+ # Puts `<namespace>.` in front of the ids a list of statements defines, and of the references to them
107
+ class Namespacer
108
+ def initialize(namespace)
109
+ @prefix = "#{namespace}."
110
+ @defined = {}
111
+ end
112
+
113
+ def apply(statements)
114
+ collect(statements)
115
+ statements.each { |stmt| rename(stmt) }
116
+ statements
117
+ end
118
+
119
+ private
120
+
121
+ # Anonymous layers/stacks have ids no one can reference and that are unique already
122
+ def collect(statements)
123
+ statements.each do |stmt|
124
+ case stmt
125
+ when AST::Block
126
+ @defined[stmt.id] = true unless stmt.anonymous?
127
+ collect(stmt.children)
128
+ when AST::DataFlow then @defined[stmt.id] = true
129
+ end
130
+ end
131
+ end
132
+
133
+ def rename(stmt)
134
+ case stmt
135
+ when AST::Block then rename_block(stmt)
136
+ when AST::Edge
137
+ stmt.from = ref(stmt.from)
138
+ stmt.to = ref(stmt.to)
139
+ when AST::DataFlow then rename_dataflow(stmt)
140
+ end
141
+ end
142
+
143
+ def rename_block(block)
144
+ unless block.anonymous?
145
+ block.attrs["label"] ||= block.id # the label defaults to the id
146
+ block.id = ref(block.id)
147
+ end
148
+ block.children.each { |child| rename(child) }
149
+ end
150
+
151
+ def rename_dataflow(flow)
152
+ flow.attrs["label"] ||= flow.id
153
+ flow.id = ref(flow.id)
154
+ flow.hops.map! { |hop| hop.is_a?(AST::DataFlowGroup) ? rename_group(hop) : ref(hop) }
155
+ end
156
+
157
+ def rename_group(group)
158
+ group.branches.each { |branch| branch.hops.map! { |hop| ref(hop) } }
159
+ group
160
+ end
161
+
162
+ def ref(id)
163
+ @defined[id] ? "#{@prefix}#{id}" : id
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -43,6 +43,11 @@ module Archsight
43
43
  # own attrs at graph-resolution time).
44
44
  DataFlowBranch = Struct.new(:hops, :attrs, :line, keyword_init: true)
45
45
 
46
+ # `include "<path>" [{ namespace "cs" }]` -- another diagram spliced in at this position (see `Includes`).
47
+ # Parsed like any statement, replaced by the included statements before the graph is built, so nothing
48
+ # downstream sees it. `namespace`, if any, puts `<namespace>.` in front of every id the included diagram defines.
49
+ Include = Struct.new(:path, :namespace, :line, keyword_init: true)
50
+
46
51
  # A top-level diagram-wide `key "value"` setting -- currently only
47
52
  # `theme "compact"` (see `Theme`).
48
53
  Setting = Struct.new(:key, :value, :line, keyword_init: true)
@@ -20,6 +20,8 @@ module Archsight
20
20
 
21
21
  raise ParseError, "#{label} at line #{line}: columns must be a positive integer, got #{raw.inspect}" unless raw.match?(/\A[1-9]\d*\z/)
22
22
 
23
+ raise ParseError, "#{label} at line #{line}: columns cannot be combined with include (the included blocks are not known yet)" if children.any?(AST::Include)
24
+
23
25
  blocks, edges = children.partition { |c| c.is_a?(AST::Block) }
24
26
  count = [raw.to_i, blocks.length].min
25
27
  return children if blocks.empty?
@@ -14,9 +14,11 @@ module Archsight
14
14
  #
15
15
  # Grammar (informal):
16
16
  # program := statement*
17
- # statement := block | dataflow | edge | setting
17
+ # statement := block | dataflow | edge | setting | include
18
+ # include := "include" STRING ["{" "namespace" STRING "}"] [";"]
19
+ # -- top level, or where a container may hold blocks and edges
18
20
  # setting := ("theme" | "legend" | "ranks") STRING [";"] -- top level only
19
- # block := (CONTAINER_KEYWORD | LEAF_KEYWORD) [STRING] "{" (attr | block | edge)* "}"
21
+ # block := (CONTAINER_KEYWORD | LEAF_KEYWORD) [STRING] "{" (attr | block | edge | include)* "}"
20
22
  # -- the id is required except for ANONYMOUS_KEYWORDS
21
23
  # dataflow := "dataflow" STRING "{" (hop | hop_group | attr)* "}"
22
24
  # -- needs >= 2 hops (per branch, if a hop_group is present);
@@ -45,6 +47,7 @@ module Archsight
45
47
  # `layer`/`stack` may be used without an id, purely as a layout hint
46
48
  # (see AST::Block#anonymous) — a `group`/`boundary` always renders a
47
49
  # visible box, so an unnamed one wouldn't make sense the same way.
50
+ NAMESPACE = /\A[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*\z/
48
51
  ANONYMOUS_KEYWORDS = %w[layer stack].freeze
49
52
  SETTING_KEYWORDS = %w[theme legend ranks].freeze
50
53
  EDGE_ARROWS = {
@@ -55,13 +58,16 @@ module Archsight
55
58
 
56
59
  attr_reader :cursor
57
60
 
58
- def self.parse(source)
59
- new(source).parse
61
+ # `anon_scope` namespaces the ids of anonymous layers/stacks, so the statements of an included
62
+ # source (parsed on its own) never collide with those of the including one.
63
+ def self.parse(source, anon_scope: nil)
64
+ new(source, anon_scope: anon_scope).parse
60
65
  end
61
66
 
62
- def initialize(source)
67
+ def initialize(source, anon_scope: nil)
63
68
  @cursor = TokenCursor.new(Lexer.new(source).tokenize)
64
69
  @anon_counter = 0
70
+ @anon_scope = anon_scope
65
71
  end
66
72
 
67
73
  def parse
@@ -75,6 +81,8 @@ module Archsight
75
81
  def statement
76
82
  if check_block_keyword?
77
83
  block
84
+ elsif include_directive?
85
+ include_statement
78
86
  elsif cursor.check(:ident, "dataflow") && cursor.peek_type(1) == :string
79
87
  DataflowParser.new(cursor).parse
80
88
  elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
@@ -108,22 +116,16 @@ module Archsight
108
116
  children = []
109
117
 
110
118
  parse_attrs_until_rbrace(attrs, label, expected: "an attribute, a nested block, an edge") do
111
- if check_block_keyword?
112
- children << block
113
- true
114
- elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
115
- children << edge
116
- true
117
- else
118
- false
119
- end
119
+ child = container_child
120
+ children << child if child
121
+ child
120
122
  end
121
123
 
122
124
  cursor.expect(:rbrace, "expected '}' to close #{label}")
123
125
 
124
126
  if LEAF_KEYWORDS.include?(kind_token.value) && !children.empty?
125
127
  raise ParseError, "#{label} at line #{kind_token.line} cannot " \
126
- "contain nested blocks or edges (did you mean 'group'?)"
128
+ "contain nested blocks, edges or includes (did you mean 'group'?)"
127
129
  end
128
130
 
129
131
  raise ParseError, "#{label} at line #{kind_token.line}: columns only applies to containers" if LEAF_KEYWORDS.include?(kind_token.value) && attrs.key?("columns")
@@ -133,6 +135,46 @@ module Archsight
133
135
  AST::Block.new(kind: kind, id: id, anonymous: anonymous, attrs: attrs, children: children, line: kind_token.line)
134
136
  end
135
137
 
138
+ # The nested block, edge or include at the cursor, nil if it is something else (an attribute)
139
+ def container_child
140
+ if check_block_keyword?
141
+ block
142
+ elsif include_directive?
143
+ include_statement
144
+ elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
145
+ edge
146
+ end
147
+ end
148
+
149
+ # `include "<path>"` -- `include -> x` is still an edge, so a node may be named "include"
150
+ def include_directive?
151
+ cursor.check(:ident, "include") && cursor.peek_type(1) == :string
152
+ end
153
+
154
+ def include_statement
155
+ keyword = cursor.advance
156
+ path = cursor.advance
157
+ namespace = include_options(path.value) if cursor.check(:lbrace)
158
+ cursor.consume(:semi)
159
+ AST::Include.new(path: path.value, namespace: namespace, line: keyword.line)
160
+ end
161
+
162
+ # `{ namespace "cs" }` after an include: the only option
163
+ def include_options(path)
164
+ cursor.advance
165
+ options = {}
166
+ until cursor.check(:rbrace)
167
+ line = cursor.current.line
168
+ key, value = cursor.check(:ident) ? attr : cursor.expect(:ident, "expected 'namespace' in the options of include #{path.inspect}")
169
+ raise ParseError, "unknown option '#{key}' of include #{path.inspect} at line #{line} (only 'namespace')" unless key == "namespace"
170
+ raise ParseError, "the namespace #{value.inspect} of include #{path.inspect} at line #{line} must be a name like \"cs\" or \"a.b\"" unless value.match?(NAMESPACE)
171
+
172
+ store_attr(options, key, value, line, "include #{path.inspect}")
173
+ end
174
+ cursor.expect(:rbrace, "expected '}' to close the options of include #{path.inspect}")
175
+ options["namespace"]
176
+ end
177
+
136
178
  # `theme "compact"`, `legend "right"` -- `theme -> x` is still an edge
137
179
  # (an arrow, not a string, follows the id), so a node may still be
138
180
  # named "theme" or "legend".
@@ -145,7 +187,7 @@ module Archsight
145
187
 
146
188
  def next_anonymous_id
147
189
  @anon_counter += 1
148
- "__asd_anon_#{@anon_counter}"
190
+ "__asd_anon_#{@anon_scope}#{@anon_counter}"
149
191
  end
150
192
 
151
193
  # What a human should see identifying this block in an error message
@@ -5,6 +5,7 @@ require_relative "diagram/style/theme"
5
5
  require_relative "diagram/parser/lexer"
6
6
  require_relative "diagram/parser"
7
7
  require_relative "diagram/parser/ast"
8
+ require_relative "diagram/includes"
8
9
  require_relative "diagram/graph"
9
10
  require_relative "diagram/resource_links"
10
11
  require_relative "diagram/layout"
@@ -33,9 +34,14 @@ module Archsight
33
34
  # `resolver` turns a node's `resource "..."` reference into a link (see
34
35
  # `ResourceLinks`); `unresolved`, if an Array, collects the references it
35
36
  # couldn't resolve.
37
+ # `includes` loads the diagrams that `include "<path>"` statements name (see `Includes`); without it
38
+ # a source that includes anything does not render. `warnings`, if an Array, collects what the includes left
39
+ # out (see `Includes`).
36
40
  def self.render(source, relation_filter: Relations::DEFAULT_FILTER, profile: nil, style: nil, theme: nil, legend: nil, id_prefix: nil,
37
- resolver: nil, unresolved: nil)
38
- statements = time(profile, :parse) { Parser.parse(source) }
41
+ resolver: nil, unresolved: nil, includes: nil, warnings: nil)
42
+ statements = time(profile, :parse) do
43
+ expand(source, includes: includes).tap { |expanded| warnings&.concat(expanded.warnings) }.statements
44
+ end
39
45
  graph = time(profile, :graph) do
40
46
  Graph.build(statements).tap { |g| ResourceLinks.apply(g, resolver, unresolved) }
41
47
  end
@@ -47,8 +53,18 @@ module Archsight
47
53
  # The `resource "..."` references of the nodes of a diagram source, in order and without duplicates. Parses and
48
54
  # builds the graph but does not lay it out, so it is cheap enough to run over every diagram of a model.
49
55
  # @raise [Archsight::Diagram::Error] if the source is not a valid diagram
50
- def self.resource_references(source)
51
- Graph.build(Parser.parse(source)).nodes_by_id.values.filter_map { |node| node.attrs["resource"] }.uniq
56
+ def self.resource_references(source, includes: nil)
57
+ Graph.build(expand(source, includes: includes).statements).nodes_by_id.values.filter_map { |node| node.attrs["resource"] }.uniq
58
+ end
59
+
60
+ # Parses `source` and splices in the diagrams it includes.
61
+ # @return [Includes::Result] the statements, and the sources that were included (`{ path => source }`)
62
+ # @raise [Archsight::Diagram::Error] if the source or an included one is not a valid diagram
63
+ def self.expand(source, includes: nil)
64
+ statements = Parser.parse(source)
65
+ return Includes::Result.new(statements: statements, sources: {}, warnings: []) unless Includes.includes?(statements)
66
+
67
+ Includes.expand(statements, includes)
52
68
  end
53
69
 
54
70
  def self.time(profile, key)
@@ -146,12 +146,17 @@ module Archsight
146
146
  # A diagram from asd source (fenced block, .asd file) as block XML
147
147
  def diagram(source, stem)
148
148
  stem ||= "#{@page_name}-diagram-#{@diagrams += 1}"
149
- figure(Archsight::Diagram.render(source, resolver: @diagram_links), stem)
149
+ figure(Archsight::Diagram.render(source, resolver: @diagram_links, includes: diagram_includes), stem)
150
150
  rescue Archsight::Diagram::Error => e
151
151
  problem("diagram #{stem}: #{e.message}")
152
152
  ""
153
153
  end
154
154
 
155
+ # `include "file://..."` / `archsight://...` in a diagram: the page's database, else only files of the resources directory
156
+ def diagram_includes
157
+ Helpers::DiagramIncludes.new(@database, resources_dir: @resources_dir)
158
+ end
159
+
155
160
  # An image that must not sit inside a paragraph (a diagram), nil for ordinary images
156
161
  def block_image(attr)
157
162
  path = asset_path(attr["src"].to_s)