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.
- checksums.yaml +4 -4
- data/docs/diagram.md +64 -0
- data/docs/modeling.md +3 -1
- data/lib/archsight/cli.rb +2 -1
- data/lib/archsight/database.rb +4 -3
- data/lib/archsight/diagram/cli.rb +11 -1
- data/lib/archsight/diagram/errors.rb +1 -0
- data/lib/archsight/diagram/includes.rb +168 -0
- data/lib/archsight/diagram/parser/ast.rb +5 -0
- data/lib/archsight/diagram/parser/table_layout.rb +2 -0
- data/lib/archsight/diagram/parser.rb +58 -16
- data/lib/archsight/diagram.rb +20 -4
- data/lib/archsight/export/confluence/storage.rb +6 -1
- data/lib/archsight/helpers/diagram_blocks.rb +34 -19
- data/lib/archsight/helpers/diagram_includes.rb +67 -0
- data/lib/archsight/helpers.rb +1 -0
- data/lib/archsight/linter.rb +10 -2
- data/lib/archsight/references.rb +4 -3
- data/lib/archsight/resources/application_component.rb +10 -0
- data/lib/archsight/resources/application_service.rb +9 -0
- data/lib/archsight/resources/technology_service.rb +9 -0
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/json_helpers.rb +3 -2
- data/lib/archsight/web/api/openapi/spec.yaml +9 -2
- data/lib/archsight/web/api/page_helpers.rb +1 -1
- data/lib/archsight/web/api/routes.rb +4 -3
- data/lib/archsight/web/application.rb +2 -1
- data/lib/archsight/web/public/vue/{DocPage-C8Ysway-.js → DocPage-L0GzDFiV.js} +1 -1
- data/lib/archsight/web/public/vue/{EditorPage-pr2MR6_b.js → EditorPage-B3iNbh1u.js} +1 -1
- data/lib/archsight/web/public/vue/{ErrorPage-FAkpW-hn.js → ErrorPage-Lro5vwhy.js} +1 -1
- data/lib/archsight/web/public/vue/{GraphView-liQJzFyo.js → GraphView-BRKwxa--.js} +1 -1
- data/lib/archsight/web/public/vue/{HomePage-DYpzSXG8.js → HomePage-DKgI1DXr.js} +2 -2
- data/lib/archsight/web/public/vue/{InstanceRouter-Ca_C8izS.css → InstanceRouter-CJSkGdBG.css} +1 -1
- data/lib/archsight/web/public/vue/InstanceRouter-CX6qxVTm.js +1 -0
- data/lib/archsight/web/public/vue/{KindList-CrgI4qDb.js → KindList-C556gf23.js} +1 -1
- data/lib/archsight/web/public/vue/{PageView-BIxT_t49.js → PageView-DAqHjL1x.js} +1 -1
- data/lib/archsight/web/public/vue/{QueryError-ChdyV8B1.js → QueryError-DvspMrsh.js} +1 -1
- data/lib/archsight/web/public/vue/{ResourceList-DdtaHY4k.js → ResourceList-BZOYa68X.js} +1 -1
- data/lib/archsight/web/public/vue/{SearchResults-BYRAcK_o.js → SearchResults-DaaOPG3t.js} +1 -1
- data/lib/archsight/web/public/vue/{WikiPage-CgvRN6R2.js → WikiPage-Bfx92Iig.js} +1 -1
- data/lib/archsight/web/public/vue/index-B_YGrWD8.css +1 -0
- data/lib/archsight/web/public/vue/{index-yiZbbvAs.js → index-DMhBow5t.js} +2 -2
- data/lib/archsight/web/public/vue.html +2 -2
- metadata +18 -16
- data/lib/archsight/web/public/vue/InstanceRouter-BdkBTmGO.js +0 -1
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 66e3a2aa9223a9583edf06749e12bafe346bfd562570d299795f2334510ed36a
|
|
4
|
+
data.tar.gz: 52a7997e394247a86da162fb4015d97b755b2ac2fe69cfadd758f6d3e140c33b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/archsight/database.rb
CHANGED
|
@@ -109,11 +109,12 @@ module Archsight
|
|
|
109
109
|
matches.first if matches.one?
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
#
|
|
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
|
-
|
|
116
|
-
|
|
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)}" }
|
|
@@ -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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
data/lib/archsight/diagram.rb
CHANGED
|
@@ -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)
|
|
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(
|
|
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)
|