archsight 0.3.3 → 0.4.1
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/README.md +7 -1
- data/docs/archimate.md +21 -1
- data/docs/configuration.md +1 -0
- data/docs/diagram.md +15 -3
- data/docs/docker.md +4 -1
- data/docs/icons.md +8 -2
- data/docs/import.md +178 -55
- data/docs/index.md.erb +24 -18
- data/docs/modeling.md +645 -34
- data/docs/pages.md +46 -15
- data/docs/search.md +21 -1
- data/docs/togaf.md +2 -1
- data/lib/archsight/analysis/result.rb +8 -2
- data/lib/archsight/analysis/sandbox.rb +8 -0
- data/lib/archsight/analysis/typst_renderer.rb +245 -0
- data/lib/archsight/analysis.rb +1 -0
- data/lib/archsight/annotations/annotation.rb +30 -3
- data/lib/archsight/annotations/architecture_annotations.rb +47 -0
- data/lib/archsight/annotations/link_annotations.rb +15 -0
- data/lib/archsight/annotations/resource_reference.rb +12 -0
- data/lib/archsight/cli.rb +124 -23
- data/lib/archsight/database.rb +40 -0
- data/lib/archsight/diagram/cli.rb +50 -2
- data/lib/archsight/diagram/formatter.rb +112 -0
- data/lib/archsight/diagram/routing/edge_router/orthogonal_path.rb +38 -10
- data/lib/archsight/diagram/routing/edge_router.rb +29 -4
- data/lib/archsight/diagram/routing/edge_routing.rb +61 -5
- data/lib/archsight/diagram/style/theme.rb +7 -2
- data/lib/archsight/documentation.rb +4 -4
- data/lib/archsight/editor/page_source.rb +13 -1
- data/lib/archsight/export/confluence/analysis_output.rb +100 -0
- data/lib/archsight/export/confluence/client.rb +13 -0
- data/lib/archsight/export/confluence/diagram_links.rb +1 -1
- data/lib/archsight/export/confluence/exporter.rb +43 -14
- data/lib/archsight/export/confluence/labels.rb +25 -0
- data/lib/archsight/export/confluence/page_properties.rb +6 -9
- data/lib/archsight/export/confluence/storage.rb +63 -14
- data/lib/archsight/export/confluence/tables.rb +2 -3
- data/lib/archsight/git_source.rb +81 -0
- data/lib/archsight/helpers/asset_images.rb +3 -4
- data/lib/archsight/helpers/embeds.rb +18 -7
- data/lib/archsight/helpers/resource_resolver.rb +25 -0
- data/lib/archsight/import/executor.rb +140 -16
- data/lib/archsight/import/handler.rb +55 -6
- data/lib/archsight/import/handler_loader.rb +64 -0
- data/lib/archsight/import/handlers/cache_pruner.rb +30 -7
- data/lib/archsight/import/handlers/command.rb +120 -0
- data/lib/archsight/import/handlers/github.rb +4 -1
- data/lib/archsight/import/handlers/gitlab.rb +4 -1
- data/lib/archsight/import/handlers/go_dep_resolver.rb +10 -2
- data/lib/archsight/import/handlers/go_grapher.rb +102 -5
- data/lib/archsight/import/handlers/go_module_parser.rb +88 -0
- data/lib/archsight/import/handlers/grapher.rb +40 -1
- data/lib/archsight/import/handlers/repository.rb +3 -0
- data/lib/archsight/import/handlers/rest_api_index.rb +3 -0
- data/lib/archsight/import/registry.rb +22 -1
- data/lib/archsight/import.rb +89 -1
- data/lib/archsight/linter/structure.rb +239 -0
- data/lib/archsight/linter.rb +123 -16
- data/lib/archsight/markdown.rb +48 -0
- data/lib/archsight/openapi.rb +169 -0
- data/lib/archsight/page_loader.rb +12 -1
- data/lib/archsight/page_tree.rb +2 -3
- data/lib/archsight/query/evaluator.rb +13 -6
- data/lib/archsight/query/parser.rb +73 -6
- data/lib/archsight/query.rb +1 -1
- data/lib/archsight/references.rb +3 -3
- data/lib/archsight/renderer.rb +80 -23
- data/lib/archsight/resources/analysis.rb +3 -2
- data/lib/archsight/resources/application_component.rb +75 -7
- data/lib/archsight/resources/application_interface.rb +12 -0
- data/lib/archsight/resources/application_service.rb +12 -1
- data/lib/archsight/resources/base.rb +58 -5
- data/lib/archsight/resources/business_actor.rb +5 -0
- data/lib/archsight/resources/business_contract.rb +72 -0
- data/lib/archsight/resources/business_product.rb +12 -0
- data/lib/archsight/resources/business_role.rb +1 -0
- data/lib/archsight/resources/implementation_deliverable.rb +1 -0
- data/lib/archsight/resources/implementation_plateau.rb +8 -2
- data/lib/archsight/resources/implementation_work_package.rb +2 -1
- data/lib/archsight/resources/import.rb +37 -11
- data/lib/archsight/resources/location.rb +56 -0
- data/lib/archsight/resources/motivation_requirement.rb +6 -0
- data/lib/archsight/resources/motivation_stakeholder.rb +14 -0
- data/lib/archsight/resources/page.rb +4 -7
- data/lib/archsight/resources/strategy_capability.rb +9 -0
- data/lib/archsight/resources/technology_artifact.rb +11 -4
- data/lib/archsight/resources/technology_communication_network.rb +91 -0
- data/lib/archsight/resources/technology_device.rb +88 -0
- data/lib/archsight/resources/technology_equipment.rb +75 -0
- data/lib/archsight/resources/technology_facility.rb +70 -0
- data/lib/archsight/resources/technology_node.rb +50 -0
- data/lib/archsight/resources/technology_service.rb +33 -0
- data/lib/archsight/resources/technology_system_software.rb +28 -0
- data/lib/archsight/resources.rb +12 -0
- data/lib/archsight/version.rb +1 -1
- data/lib/archsight/web/api/analysis_helpers.rb +55 -0
- data/lib/archsight/web/api/docs.rb +2 -1
- data/lib/archsight/web/api/json_helpers.rb +3 -37
- data/lib/archsight/web/api/openapi/spec.yaml +146 -35
- data/lib/archsight/web/api/page_helpers.rb +1 -8
- data/lib/archsight/web/api/routes.rb +9 -4
- data/lib/archsight/web/application.rb +40 -4
- data/lib/archsight/web/editor/routes.rb +2 -1
- data/lib/archsight/web/public/vendor/redoc/LICENSE +22 -0
- data/lib/archsight/web/public/vendor/redoc/VERSION +1 -0
- data/lib/archsight/web/public/vendor/redoc/api.html +27 -0
- data/lib/archsight/web/public/vendor/redoc/api.js +19 -0
- data/lib/archsight/web/public/vendor/redoc/redoc.standalone.js +1851 -0
- data/lib/archsight/web/public/vendor/redoc/redoc.standalone.js.LICENSE.txt +102 -0
- data/lib/archsight/web/public/vue/DocPage-C2ubTXH7.js +1 -0
- data/lib/archsight/web/public/vue/EditorPage-Dso6PLMn.js +35 -0
- data/lib/archsight/web/public/vue/ErrorPage-DnZ6YJLi.js +2 -0
- data/lib/archsight/web/public/vue/GraphView-CTl03Yre.js +1 -0
- data/lib/archsight/web/public/vue/HomePage-C339nihQ.js +2 -0
- data/lib/archsight/web/public/vue/InstanceRouter-DFW7tOrD.js +1 -0
- data/lib/archsight/web/public/vue/InstanceRouter-ZaCcjlIX.css +1 -0
- data/lib/archsight/web/public/vue/KindList-C0oaM3CF.js +1 -0
- data/lib/archsight/web/public/vue/KindList-yYcRT2BV.css +1 -0
- data/lib/archsight/web/public/vue/PageView-B93woeN3.css +1 -0
- data/lib/archsight/web/public/vue/PageView-OZB6G9Wu.js +1 -0
- data/lib/archsight/web/public/vue/QueryError-C2xLkCEm.js +1 -0
- data/lib/archsight/web/public/vue/ResourceList-D75O87zG.css +1 -0
- data/lib/archsight/web/public/vue/ResourceList-DooWFwQP.js +2 -0
- data/lib/archsight/web/public/vue/SearchResults-BK-HjFnv.js +1 -0
- data/lib/archsight/web/public/vue/{SearchResults-BewvsfOc.css → SearchResults-BV6UHriJ.css} +1 -1
- data/lib/archsight/web/public/vue/WikiPage-B2FYmeHL.js +3 -0
- data/lib/archsight/web/public/vue/WikiPage-TpmM1VAF.css +1 -0
- data/lib/archsight/web/public/vue/architecture-WOLXFQ4H-BGN-TV5r.js +1 -0
- data/lib/archsight/web/public/vue/cynefin-EF2NZ3EQ-CZd6g_tq.js +1 -0
- data/lib/archsight/web/public/vue/eventmodeling-K75KTNOO-CYnbEx8O.js +1 -0
- data/lib/archsight/web/public/vue/gitGraph-VSP46ZUC-DialfQSk.js +1 -0
- data/lib/archsight/web/public/vue/graphviz-1lWBhpEX.js +3 -0
- data/lib/archsight/web/public/vue/index-BLSE5DSv.css +1 -0
- data/lib/archsight/web/public/vue/index-CLuLTE5I.js +3 -0
- data/lib/archsight/web/public/vue/info-OHQRW6UA-BuhOW7ss.js +1 -0
- data/lib/archsight/web/public/vue/katex-B_JcIwqj.js +257 -0
- data/lib/archsight/web/public/vue/mermaid-C3bOamfn.js +3999 -0
- data/lib/archsight/web/public/vue/packet-JDAUHWVQ-CUeRz7w4.js +1 -0
- data/lib/archsight/web/public/vue/pie-XZMESJXO-DnuewJI6.js +1 -0
- data/lib/archsight/web/public/vue/radar-ABXABTNO-Bi9l8bcv.js +1 -0
- data/lib/archsight/web/public/vue/railroad-I3PHUGI6-DSlxFbxk.js +1 -0
- data/lib/archsight/web/public/vue/railroad-abnf-LNEFI6M7-D6Kqu8wu.js +1 -0
- data/lib/archsight/web/public/vue/railroad-ebnf-DWYD2UWJ-MR7uCYT0.js +1 -0
- data/lib/archsight/web/public/vue/railroad-peg-JR7BVNR7-BL7aKIc-.js +1 -0
- data/lib/archsight/web/public/vue/rolldown-runtime-COiEfhsA.js +1 -0
- data/lib/archsight/web/public/vue/treeView-D4JQ5SDB-CamtB04D.js +1 -0
- data/lib/archsight/web/public/vue/treemap-SAJKECNS-DeQxu_HM.js +1 -0
- data/lib/archsight/web/public/vue/{useGraphviz-DlSnFeCL.js → useGraphviz-Dnm2pkon.js} +2 -2
- data/lib/archsight/web/public/vue/useHighlight-BH32C7yt.js +10 -0
- data/lib/archsight/web/public/vue/usePanZoom-HxgEsqq2.js +12 -0
- data/lib/archsight/web/public/vue/wardley-7MLQ67FV-BvKIB6Tv.js +1 -0
- data/lib/archsight/web/public/vue.html +5 -5
- metadata +68 -41
- data/lib/archsight/web/public/vue/ApiDocsPage-BgqnQgwa.js +0 -1
- data/lib/archsight/web/public/vue/ApiDocsPage-DZ0fa5-h.css +0 -1
- data/lib/archsight/web/public/vue/DocPage-CNO71nBH.js +0 -1
- data/lib/archsight/web/public/vue/EditorPage-DR0FCNTz.js +0 -35
- data/lib/archsight/web/public/vue/ErrorPage-BfYArv6s.js +0 -2
- data/lib/archsight/web/public/vue/GraphView-BVTW30Ak.js +0 -1
- data/lib/archsight/web/public/vue/HomePage-Wk9P4Pma.js +0 -2
- data/lib/archsight/web/public/vue/InstanceRouter-BV8ycydz.js +0 -1
- data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +0 -1
- data/lib/archsight/web/public/vue/KindList-C8yMsN5J.js +0 -1
- data/lib/archsight/web/public/vue/PageView-BaN6TyJB.js +0 -1
- data/lib/archsight/web/public/vue/QueryError-D790wH-a.js +0 -1
- data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +0 -1
- data/lib/archsight/web/public/vue/ResourceList-D-66nas2.js +0 -2
- data/lib/archsight/web/public/vue/SearchResults-CHyqIerQ.js +0 -1
- data/lib/archsight/web/public/vue/WikiPage-D2svpk6B.js +0 -13
- data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +0 -1
- data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +0 -1
- data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +0 -1
- data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +0 -1
- data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +0 -1
- data/lib/archsight/web/public/vue/graphviz-SXWTgZH5.js +0 -3
- data/lib/archsight/web/public/vue/index-BXXkUK1V.js +0 -3
- data/lib/archsight/web/public/vue/index-Cov1SnzY.css +0 -1
- data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +0 -1
- data/lib/archsight/web/public/vue/katex-CXMH3UgJ.js +0 -257
- data/lib/archsight/web/public/vue/mermaid-BjEi5URd.js +0 -3334
- data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +0 -1
- data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +0 -1
- data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +0 -1
- data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +0 -1
- data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +0 -1
- data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +0 -1
- data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +0 -1
- data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +0 -1
- data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +0 -1
- data/lib/archsight/web/public/vue/useHighlight-D5Gf8DLl.js +0 -10
- data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +0 -1
data/lib/archsight/linter.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "markdown"
|
|
4
4
|
require_relative "helpers"
|
|
5
|
+
require_relative "linter/structure"
|
|
5
6
|
|
|
6
7
|
module Archsight
|
|
7
8
|
class Linter
|
|
@@ -10,9 +11,15 @@ module Archsight
|
|
|
10
11
|
|
|
11
12
|
# Relations the cycle check leaves out. Relations are followed from the dependent to what it relies on, so
|
|
12
13
|
# the declared ones form a DAG; `dependsOn` is the exception because components may depend on each other at
|
|
13
|
-
# runtime
|
|
14
|
+
# runtime, and `connectedTo` because cabling between devices is cyclic by nature (a ring, a mesh, both ends of
|
|
15
|
+
# a cable). The derived relations (`mentions`, `depicts`) are sideways views and are not declared, so they
|
|
14
16
|
# never enter the check.
|
|
15
|
-
CYCLE_EXEMPT_VERBS = %i[dependsOn].freeze
|
|
17
|
+
CYCLE_EXEMPT_VERBS = %i[dependsOn connectedTo].freeze
|
|
18
|
+
|
|
19
|
+
# Structure relations between elements of one kind (specialization, composition, aggregation). Each verb is
|
|
20
|
+
# checked on its own: a chain of one of them must not lead back to where it started. They stay out of the
|
|
21
|
+
# general check above so that a cycle is reported once.
|
|
22
|
+
HIERARCHY_VERBS = %i[specializes composedOf aggregates].freeze
|
|
16
23
|
|
|
17
24
|
def initialize(database)
|
|
18
25
|
@database = database
|
|
@@ -20,6 +27,7 @@ module Archsight
|
|
|
20
27
|
end
|
|
21
28
|
|
|
22
29
|
def validate
|
|
30
|
+
validate_imports
|
|
23
31
|
@database.instances.each_value do |instances_hash|
|
|
24
32
|
instances_hash.each_value do |instance|
|
|
25
33
|
validate_instance_annotations(instance)
|
|
@@ -29,18 +37,72 @@ module Archsight
|
|
|
29
37
|
end
|
|
30
38
|
end
|
|
31
39
|
validate_relation_cycles
|
|
40
|
+
validate_hierarchy_cycles
|
|
41
|
+
validate_unique_annotations
|
|
32
42
|
|
|
33
43
|
@errors
|
|
34
44
|
end
|
|
35
45
|
|
|
36
|
-
# Things that still work but should be changed: renamed kinds and relation keys that the files still use
|
|
37
|
-
# They are not errors, so they do not make `archsight lint` fail.
|
|
46
|
+
# Things that still work but should be changed: renamed kinds and relation keys that the files still use, and
|
|
47
|
+
# annotations their kind marks `deprecated:`. They are not errors, so they do not make `archsight lint` fail.
|
|
38
48
|
def warnings
|
|
39
|
-
@database.respond_to?(:deprecations) ? @database.deprecations.map(&:to_s) : []
|
|
49
|
+
renamed = @database.respond_to?(:deprecations) ? @database.deprecations.map(&:to_s) : []
|
|
50
|
+
renamed + deprecated_annotations
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Structural modeling checks (rules of "Choosing the Kind", see Linter::Structure). Warnings, never errors.
|
|
54
|
+
def structural_warnings
|
|
55
|
+
Structure.new(@database).warnings
|
|
40
56
|
end
|
|
41
57
|
|
|
42
58
|
private
|
|
43
59
|
|
|
60
|
+
# Each use of an annotation the kind marks `deprecated:`, with what to use instead
|
|
61
|
+
def deprecated_annotations
|
|
62
|
+
@database.instances.each_value.flat_map do |instances_hash|
|
|
63
|
+
instances_hash.each_value.flat_map do |instance|
|
|
64
|
+
instance.annotations.keys.filter_map do |key|
|
|
65
|
+
annotation = instance.class.annotation_matching(key)
|
|
66
|
+
"#{instance.path_ref}: #{instance.klass} '#{instance.name}' uses deprecated annotation '#{key}': #{annotation.deprecated}" if annotation&.deprecated?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Imports: the handlers of the resources repository load (before the `import/handler` names are checked against
|
|
73
|
+
# the registry), and every `import/dependsOn` entry names an import or is a pattern
|
|
74
|
+
def validate_imports
|
|
75
|
+
return unless @database.respond_to?(:instances_by_kind)
|
|
76
|
+
|
|
77
|
+
imports = @database.instances_by_kind("Import")&.values || []
|
|
78
|
+
return if imports.empty?
|
|
79
|
+
|
|
80
|
+
require "archsight/import/handler_loader"
|
|
81
|
+
Archsight::Import::HandlerLoader.load(@database).each { |problem| @errors << problem.to_s }
|
|
82
|
+
names = imports.to_set(&:name)
|
|
83
|
+
imports.each do |import|
|
|
84
|
+
Archsight::Import.declared_dependencies(import).each do |entry|
|
|
85
|
+
next if Archsight::Import.pattern?(entry) || names.include?(entry)
|
|
86
|
+
|
|
87
|
+
@errors << "#{import.path_ref}: Import '#{import.name}' depends on unknown import '#{entry}'"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
validate_command_outputs(imports)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A command import replaces its whole output file on every run, so no other import may write to it
|
|
94
|
+
def validate_command_outputs(imports)
|
|
95
|
+
file_of = ->(import) { File.expand_path(Archsight::Import.output_path(import), "/") }
|
|
96
|
+
imports.select { |import| import.annotations["import/handler"] == "command" }.each do |command|
|
|
97
|
+
others = imports.reject { |import| import.equal?(command) }.select { |import| file_of.call(import) == file_of.call(command) }
|
|
98
|
+
next if others.empty?
|
|
99
|
+
|
|
100
|
+
@errors << "#{command.path_ref}: Import '#{command.name}' runs a command, which replaces its output file " \
|
|
101
|
+
"#{Archsight::Import.output_path(command)}, but #{others.map { |o| "'#{o.name}'" }.join(", ")} " \
|
|
102
|
+
"also write#{"s" if others.one?} to it; give it a file of its own"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
44
106
|
# Pages need exactly one menu and their [[links]] must resolve
|
|
45
107
|
def validate_page(page)
|
|
46
108
|
menus = page.references.map { |r| r[:instance] }.select { |i| i.klass == "PageMenu" }.uniq
|
|
@@ -86,25 +148,62 @@ module Archsight
|
|
|
86
148
|
nil
|
|
87
149
|
end
|
|
88
150
|
|
|
151
|
+
# The values of a unique annotation (aliases) must not be shared by two resources of a kind
|
|
152
|
+
def validate_unique_annotations
|
|
153
|
+
@database.instances.each do |klass, instances_hash|
|
|
154
|
+
next unless klass.respond_to?(:annotations)
|
|
155
|
+
|
|
156
|
+
klass.annotations.select(&:unique?).reject(&:pattern?).each do |annotation|
|
|
157
|
+
owners = {}
|
|
158
|
+
instances_hash.each_value { |instance| check_unique_values(annotation, instance, owners) }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def check_unique_values(annotation, instance, owners)
|
|
164
|
+
Array(annotation.value_for(instance)).each do |value|
|
|
165
|
+
owner = owners[value]
|
|
166
|
+
if owner.nil?
|
|
167
|
+
owners[value] = instance
|
|
168
|
+
elsif !owner.equal?(instance)
|
|
169
|
+
@errors << "#{instance.path_ref}: #{instance.klass} '#{instance.name}': #{annotation.key} '#{value}' " \
|
|
170
|
+
"is already used by '#{owner.name}' (#{owner.path_ref})"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
89
175
|
# Declared relations must not lead back to where they started (see the "Direction of Relations" modeling guide)
|
|
90
176
|
def validate_relation_cycles
|
|
91
|
-
graph = relation_graph
|
|
177
|
+
graph = relation_graph { |verb| !CYCLE_EXEMPT_VERBS.include?(verb) && !HIERARCHY_VERBS.include?(verb) }
|
|
178
|
+
report_cycles(graph) { |start, path| "#{start.path_ref}: #{start.klass} '#{start.name}' is part of a relation cycle (#{path})" }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# `specializes`, `composedOf` and `aggregates` (one chain per verb) must not lead back to where they started
|
|
182
|
+
def validate_hierarchy_cycles
|
|
183
|
+
HIERARCHY_VERBS.each do |hierarchy_verb|
|
|
184
|
+
graph = relation_graph { |verb| verb == hierarchy_verb }
|
|
185
|
+
report_cycles(graph) { |start, path| "#{start.path_ref}: #{start.klass} '#{start.name}' is part of a cycle of #{hierarchy_verb} relations (#{path})" }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Adds one error per group of instances that reach each other; the block builds the message from the first
|
|
190
|
+
# instance of the group and the path of one concrete cycle
|
|
191
|
+
def report_cycles(graph)
|
|
92
192
|
strongly_connected_groups(graph).each do |group|
|
|
93
193
|
cycle = cycle_through(group.first, group, graph)
|
|
94
|
-
names = cycle.map { |step| step[:to].name }
|
|
95
194
|
start = group.first
|
|
96
|
-
path = ([start.name] +
|
|
97
|
-
@errors <<
|
|
195
|
+
path = ([start.name] + cycle.map { |step| step[:to].name }).zip(cycle.map { |step| step[:verb] }).flat_map { |name, verb| [name, verb && "-#{verb}->"] }.compact
|
|
196
|
+
@errors << yield(start, path.join(" "))
|
|
98
197
|
end
|
|
99
198
|
end
|
|
100
199
|
|
|
101
|
-
# { instance => [{ verb:, to: }] } over the declared relations
|
|
200
|
+
# { instance => [{ verb:, to: }] } over the declared relations whose verb the block accepts
|
|
102
201
|
def relation_graph
|
|
103
202
|
graph = {}
|
|
104
203
|
@database.instances.each_value do |instances_hash|
|
|
105
204
|
instances_hash.each_value do |instance|
|
|
106
205
|
graph[instance] = instance.class.declared_relations.flat_map do |verb, key, _kind|
|
|
107
|
-
next []
|
|
206
|
+
next [] unless yield(verb)
|
|
108
207
|
|
|
109
208
|
instance.relations(verb, key).map { |target| { verb: verb, to: target } }
|
|
110
209
|
end
|
|
@@ -201,18 +300,26 @@ module Archsight
|
|
|
201
300
|
next unless annotation.has_validation?
|
|
202
301
|
|
|
203
302
|
# Validate value against annotation schema (type and enum constraints)
|
|
204
|
-
annotation.validate(value)
|
|
205
|
-
|
|
206
|
-
|
|
303
|
+
errors = annotation.validate(value)
|
|
304
|
+
errors.each { |error| @errors << "#{instance.path_ref}: Annotation '#{key}' #{error}" }
|
|
305
|
+
validate_resource_reference(instance, key, value) if annotation.resource? && errors.empty?
|
|
207
306
|
end
|
|
208
307
|
end
|
|
209
308
|
|
|
309
|
+
# The resource a `Name` / `Kind/Name` annotation points to must exist; a bare `Name` means the same kind
|
|
310
|
+
def validate_resource_reference(instance, key, value)
|
|
311
|
+
return if Archsight::Helpers::ResourceResolver.new(@database).find_annotated(value, instance.klass).is_a?(Array)
|
|
312
|
+
|
|
313
|
+
hint = value.to_s.include?("/") ? "" : " (a name without Kind/ is a #{instance.klass}, use Kind/Name for another kind)"
|
|
314
|
+
@errors << "#{instance.path_ref}: Annotation '#{key}' resource '#{value}' not found#{hint}"
|
|
315
|
+
end
|
|
316
|
+
|
|
210
317
|
def validate_markdown(instance, key, value)
|
|
211
318
|
# Skip markdown validation for generated files
|
|
212
319
|
return if instance.annotations.key?("generated/script")
|
|
213
320
|
|
|
214
321
|
begin
|
|
215
|
-
|
|
322
|
+
Archsight::Markdown.document(value)
|
|
216
323
|
rescue StandardError => e
|
|
217
324
|
@errors << "#{instance.path_ref}: Markdown syntax error in annotation '#{key}': #{e.message}"
|
|
218
325
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "kramdown"
|
|
4
|
+
require "kramdown-parser-gfm"
|
|
5
|
+
|
|
6
|
+
module Archsight
|
|
7
|
+
# The one way markdown is parsed, for the web, the export, the linter and the analysis output alike. GFM, but a
|
|
8
|
+
# single newline inside a paragraph is a space, as in CommonMark and GitHub's rendering of `.md` files (the GFM
|
|
9
|
+
# parser of kramdown would make it a `<br />`, as GitHub does in comments). A line break is two trailing spaces,
|
|
10
|
+
# a trailing backslash or `<br>`.
|
|
11
|
+
module Markdown
|
|
12
|
+
OPTIONS = { input: "GFM", hard_wrap: false }.freeze
|
|
13
|
+
|
|
14
|
+
# Raw HTML whose text is kept as it is
|
|
15
|
+
VERBATIM = %w[pre code script style textarea].freeze
|
|
16
|
+
|
|
17
|
+
def self.document(text, **)
|
|
18
|
+
Kramdown::Document.new(text.to_s, **OPTIONS, **)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The document without the newlines of the source in its text, for a target that keeps a newline instead of
|
|
22
|
+
# reading it as a space (Confluence): a soft line break becomes a space, the newline after a line break goes.
|
|
23
|
+
# Inline code over two lines is one line (as in CommonMark). Paragraphs, breaks and code blocks are unchanged.
|
|
24
|
+
def self.unwrapped(text, **)
|
|
25
|
+
document(text, **).tap { |doc| unwrap(doc.root) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.unwrap(element)
|
|
29
|
+
element.children.each_with_index do |child, index|
|
|
30
|
+
if child.type == :text
|
|
31
|
+
previous = index.positive? ? element.children[index - 1] : nil
|
|
32
|
+
joined = child.value.gsub(/[ \t]*\r?\n[ \t]*/, " ")
|
|
33
|
+
child.value = line_break?(previous) ? joined.lstrip : joined
|
|
34
|
+
elsif child.type == :codespan
|
|
35
|
+
child.value = child.value.gsub(/\r?\n/, " ")
|
|
36
|
+
elsif !(child.type == :html_element && VERBATIM.include?(child.value))
|
|
37
|
+
unwrap(child)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
private_class_method :unwrap
|
|
42
|
+
|
|
43
|
+
def self.line_break?(element)
|
|
44
|
+
element && (element.type == :br || (element.type == :html_element && element.value == "br"))
|
|
45
|
+
end
|
|
46
|
+
private_class_method :line_break?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "resources"
|
|
6
|
+
|
|
7
|
+
module Archsight
|
|
8
|
+
# The OpenAPI document of the REST API: the hand-written base (`web/api/openapi/spec.yaml`: the fixed endpoints such
|
|
9
|
+
# as pages, assets, search and the editor, with their prose, examples and error shapes) plus what the model knows and
|
|
10
|
+
# nobody should type twice: one schema per kind with its annotations (type, enum, description, default), its `spec`
|
|
11
|
+
# relations and its relations, the list of kinds, and the instance detail schema that is one of them. A new kind, a
|
|
12
|
+
# changed enum or another relation shows up in the documentation without touching the YAML.
|
|
13
|
+
#
|
|
14
|
+
# Archsight::OpenAPI.document # => Hash, `.to_yaml` is what GET /api/v1/openapi.yaml serves
|
|
15
|
+
module OpenAPI
|
|
16
|
+
BASE = File.join(__dir__, "web", "api", "openapi", "spec.yaml")
|
|
17
|
+
|
|
18
|
+
# The operations that answer with the detail of one instance, whose kind the path names
|
|
19
|
+
DETAIL_PATHS = ["/api/v1/kinds/{kind}/instances/{name}", "/kinds/{kind}/instances/{name}.json"].freeze
|
|
20
|
+
|
|
21
|
+
# What an annotation of a type is in the JSON. The value is the one of the YAML file, so a number may also be its
|
|
22
|
+
# string ("100"); only a computed annotation is always a number. Everything else is a string.
|
|
23
|
+
TYPES = {
|
|
24
|
+
Integer => { "type" => %w[integer string] },
|
|
25
|
+
Float => { "type" => %w[number string] }
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
NAMES = { "type" => "array", "items" => { "type" => "string" } }.freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# @return [Hash] the complete document; built once, the model does not change while the server runs
|
|
32
|
+
def document
|
|
33
|
+
@document ||= build
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Forget the built document (the model was changed, in a test)
|
|
37
|
+
def reset!
|
|
38
|
+
@document = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The name of the schema of a kind
|
|
42
|
+
def schema_name(kind) = "#{kind}Instance"
|
|
43
|
+
|
|
44
|
+
# The schema of one kind: the detail of an instance as GET /api/v1/kinds/{kind}/instances/{name} returns it
|
|
45
|
+
# @param kind [String] name of a kind
|
|
46
|
+
# @return [Hash]
|
|
47
|
+
def kind_schema(kind)
|
|
48
|
+
klass = Archsight::Resources[kind]
|
|
49
|
+
schema = {
|
|
50
|
+
"type" => "object",
|
|
51
|
+
"required" => %w[kind name metadata spec relations references],
|
|
52
|
+
"x-layer" => klass.layer,
|
|
53
|
+
"properties" => {
|
|
54
|
+
"kind" => { "type" => "string", "const" => kind },
|
|
55
|
+
"name" => { "type" => "string", "description" => "Name of the instance" },
|
|
56
|
+
"metadata" => { "type" => "object", "properties" => { "annotations" => annotations_schema(klass) } },
|
|
57
|
+
"annotation_formats" => { "type" => "object", "additionalProperties" => { "type" => "string" },
|
|
58
|
+
"description" => "How the UI shows each annotation that is set (markdown, link, tag_list, ...)" },
|
|
59
|
+
"annotation_links" => { "type" => "object", "additionalProperties" => true,
|
|
60
|
+
"description" => "Annotations whose value names resources, with the resources they resolve to" },
|
|
61
|
+
"spec" => spec_schema(klass),
|
|
62
|
+
"relations" => relations_schema(klass),
|
|
63
|
+
"references" => references_schema,
|
|
64
|
+
"diagram" => { "type" => %w[string null], "description" => "The `architecture/diagram` annotation as inline SVG" }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
schema["description"] = klass.description.strip if klass.description
|
|
68
|
+
schema
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def build
|
|
74
|
+
document = YAML.safe_load_file(BASE, permitted_classes: [], aliases: true)
|
|
75
|
+
kinds = Archsight::Resources.resource_classes.keys.sort
|
|
76
|
+
schemas = document.fetch("components").fetch("schemas")
|
|
77
|
+
add_schemas(schemas, kinds)
|
|
78
|
+
document.dig("components", "parameters", "kind")["schema"] = { "$ref" => "#/components/schemas/KindName" }
|
|
79
|
+
DETAIL_PATHS.each { |path| use_detail_schema(document.fetch("paths").fetch(path)) }
|
|
80
|
+
# the schemas share small hashes: a copy of each keeps YAML from writing anchors and aliases for them
|
|
81
|
+
JSON.parse(JSON.generate(document))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def add_schemas(schemas, kinds)
|
|
85
|
+
kinds.each do |kind|
|
|
86
|
+
name = schema_name(kind)
|
|
87
|
+
raise ArgumentError, "OpenAPI: the schema #{name} of #{kind} clashes with a schema of the base spec" if schemas.key?(name)
|
|
88
|
+
|
|
89
|
+
schemas[name] = kind_schema(kind)
|
|
90
|
+
end
|
|
91
|
+
schemas["KindName"] = {
|
|
92
|
+
"type" => "string", "enum" => kinds,
|
|
93
|
+
"description" => "Name of a resource kind. A kind that was renamed is still accepted under its old name."
|
|
94
|
+
}
|
|
95
|
+
schemas["InstanceDetail"] = {
|
|
96
|
+
"description" => "An instance with its relations and references: the schema depends on the kind",
|
|
97
|
+
"oneOf" => kinds.map { |kind| { "$ref" => "#/components/schemas/#{schema_name(kind)}" } },
|
|
98
|
+
"discriminator" => { "propertyName" => "kind", "mapping" => kinds.to_h { |kind| [kind, "#/components/schemas/#{schema_name(kind)}"] } }
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def use_detail_schema(path_item)
|
|
103
|
+
path_item.dig("get", "responses", "200", "content", "application/json")["schema"] = { "$ref" => "#/components/schemas/InstanceDetail" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The annotations of the kind that are set; every annotation is optional. Other annotations are allowed.
|
|
107
|
+
def annotations_schema(klass)
|
|
108
|
+
fixed, patterns = klass.annotations.partition { |annotation| !annotation.pattern? }
|
|
109
|
+
schema = {
|
|
110
|
+
"type" => "object", "additionalProperties" => true,
|
|
111
|
+
"properties" => fixed.to_h { |annotation| [annotation.key, annotation_schema(annotation)] }
|
|
112
|
+
}
|
|
113
|
+
schema["patternProperties"] = patterns.to_h { |annotation| [pattern_of(annotation.key), annotation_schema(annotation)] } unless patterns.empty?
|
|
114
|
+
schema
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def annotation_schema(annotation)
|
|
118
|
+
schema = (TYPES[annotation.type] || { "type" => "string" }).dup
|
|
119
|
+
description = annotation.description.to_s.strip
|
|
120
|
+
description = "#{description}\n\nRendered to HTML in the response." if annotation.format == :markdown && !description.empty?
|
|
121
|
+
schema["description"] = description unless description.empty?
|
|
122
|
+
if annotation.enum && annotation.list?
|
|
123
|
+
# a list is one string of comma separated values, each of them one of the enum
|
|
124
|
+
schema["description"] = "#{description}\n\nComma separated list of: #{annotation.enum.join(", ")}.".strip
|
|
125
|
+
schema["x-items-enum"] = annotation.enum
|
|
126
|
+
elsif annotation.enum
|
|
127
|
+
schema["enum"] = annotation.enum
|
|
128
|
+
end
|
|
129
|
+
schema["default"] = annotation.default unless annotation.default.nil?
|
|
130
|
+
schema["deprecated"] = true if annotation.deprecated
|
|
131
|
+
schema
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# `link/*` -> a regular expression for the keys it matches
|
|
135
|
+
def pattern_of(key) = "^#{Regexp.escape(key).gsub('\*', ".+")}$"
|
|
136
|
+
|
|
137
|
+
# What the file says: spec.<verb>.<key>: [names]
|
|
138
|
+
def spec_schema(klass)
|
|
139
|
+
properties = {}
|
|
140
|
+
klass.declared_relations.each do |verb, key, target|
|
|
141
|
+
verb_schema = (properties[verb.to_s] ||= { "type" => "object", "properties" => {} })
|
|
142
|
+
verb_schema["properties"][key.to_s] = NAMES.merge("description" => "Names of #{target} resources")
|
|
143
|
+
end
|
|
144
|
+
{ "type" => "object", "additionalProperties" => true, "properties" => properties,
|
|
145
|
+
"description" => "The relations as written in the resource: verb, then the kind's collection, then the names" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The outgoing relations that exist: verb, then the kind of the target, then the names
|
|
149
|
+
def relations_schema(klass)
|
|
150
|
+
properties = {}
|
|
151
|
+
klass.relations.each do |verb, _key, target|
|
|
152
|
+
verb_schema = (properties[verb.to_s] ||= { "type" => "object" })
|
|
153
|
+
if Archsight::Resources::DERIVED_VERBS.include?(verb)
|
|
154
|
+
verb_schema["additionalProperties"] = NAMES # targets of any kind, grouped by the kind they are
|
|
155
|
+
else
|
|
156
|
+
(verb_schema["properties"] ||= {})[target.to_s] = NAMES
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
{ "type" => "object", "additionalProperties" => false, "properties" => properties,
|
|
160
|
+
"description" => "Outgoing relations that have targets, grouped by verb and the kind of the target" }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def references_schema
|
|
164
|
+
{ "type" => "object", "additionalProperties" => { "type" => "object", "additionalProperties" => NAMES },
|
|
165
|
+
"description" => "Incoming relations grouped by the kind of the source and the verb" }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -62,12 +62,23 @@ module Archsight
|
|
|
62
62
|
meta.each do |key, value|
|
|
63
63
|
next if value.nil?
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
if key == "links" && value.is_a?(Hash)
|
|
66
|
+
links(value).each { |name, url| result["link/#{name}"] = url }
|
|
67
|
+
else
|
|
68
|
+
result["page/#{key}"] = format_value(key, value)
|
|
69
|
+
end
|
|
66
70
|
end
|
|
67
71
|
result["page/content"] = body
|
|
68
72
|
result
|
|
69
73
|
end
|
|
70
74
|
|
|
75
|
+
# `links: { confluence: <url> }` of the frontmatter: one link/<name> annotation per entry
|
|
76
|
+
def links(mapping)
|
|
77
|
+
mapping.each_with_object({}) do |(name, url), result|
|
|
78
|
+
result[name.to_s] = url.to_s unless url.nil?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
71
82
|
def format_value(key, value)
|
|
72
83
|
case value
|
|
73
84
|
when Hash then value.map { |k, v| "#{k}: #{v.to_s.gsub(/\s*\n\s*/, " ")}" }.join("\n")
|
data/lib/archsight/page_tree.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "kramdown-parser-gfm"
|
|
3
|
+
require_relative "markdown"
|
|
5
4
|
|
|
6
5
|
module Archsight
|
|
7
6
|
# PageTree builds the sidebar tree, breadcrumbs and tables of contents for Page resources
|
|
@@ -63,7 +62,7 @@ module Archsight
|
|
|
63
62
|
|
|
64
63
|
# Flat table of contents [{level:, id:, text:}] of a markdown body
|
|
65
64
|
def self.toc(markdown)
|
|
66
|
-
doc =
|
|
65
|
+
doc = Archsight::Markdown.document(markdown)
|
|
67
66
|
entries = [] #: Array[Hash[String, untyped]]
|
|
68
67
|
collect_toc(doc.to_toc, entries)
|
|
69
68
|
entries
|
|
@@ -307,17 +307,24 @@ class Archsight::Query::Evaluator
|
|
|
307
307
|
query_values.include?(instance_kind)
|
|
308
308
|
end
|
|
309
309
|
|
|
310
|
+
# The name of a resource and its aliases (architecture/aliases): the name shortcut finds a resource by either
|
|
311
|
+
def names_of(instance)
|
|
312
|
+
aliases = instance.respond_to?(:annotations) ? instance.annotations&.[]("architecture/aliases") : nil
|
|
313
|
+
[instance.name, *aliases.to_s.split(",").map(&:strip).reject(&:empty?)].compact
|
|
314
|
+
end
|
|
315
|
+
|
|
310
316
|
def evaluate_name_condition(node, instance)
|
|
311
|
-
|
|
312
|
-
return false unless name
|
|
317
|
+
return false unless instance.name
|
|
313
318
|
|
|
319
|
+
names = names_of(instance)
|
|
314
320
|
case node.operator
|
|
315
321
|
when "=="
|
|
316
|
-
|
|
322
|
+
names.include?(node.value.value.to_s)
|
|
317
323
|
when "!="
|
|
318
|
-
|
|
324
|
+
names.none?(node.value.value.to_s)
|
|
319
325
|
when "=~"
|
|
320
|
-
|
|
326
|
+
regex = build_regex_from_value(node.value)
|
|
327
|
+
names.any? { |name| name.match?(regex) }
|
|
321
328
|
else
|
|
322
329
|
false
|
|
323
330
|
end
|
|
@@ -328,7 +335,7 @@ class Archsight::Query::Evaluator
|
|
|
328
335
|
return false unless name
|
|
329
336
|
|
|
330
337
|
query_values = node.values.map { |v| v.value.to_s }
|
|
331
|
-
|
|
338
|
+
names_of(instance).intersect?(query_values)
|
|
332
339
|
end
|
|
333
340
|
|
|
334
341
|
# Outgoing relations: what does this resource point to?
|
|
@@ -5,16 +5,21 @@ require_relative "ast"
|
|
|
5
5
|
|
|
6
6
|
# Recursive descent parser for the architecture query language.
|
|
7
7
|
class Archsight::Query::Parser
|
|
8
|
-
|
|
8
|
+
# Characters a resource name is made of, as the lexer reads an identifier
|
|
9
|
+
NAME_CHAR = %r{[a-zA-Z0-9_\-/.]}
|
|
10
|
+
|
|
11
|
+
# @param source [String, nil] the query text; with it, names that contain colons (`Archsight:CLI`, `ITGS:A001`)
|
|
12
|
+
# are read as one name instead of a kind prefix and a condition
|
|
13
|
+
def initialize(tokens, source: nil)
|
|
9
14
|
@tokens = tokens
|
|
15
|
+
@source = source
|
|
10
16
|
@position = 0
|
|
11
17
|
end
|
|
12
18
|
|
|
13
19
|
def parse
|
|
14
20
|
# Check for kind filter prefix: "Kind: expression"
|
|
15
21
|
kind_filter = nil
|
|
16
|
-
|
|
17
|
-
if current_token.type == :IDENTIFIER && peek_token&.type == :COLON && (current_token.value =~ /^[A-Z]/)
|
|
22
|
+
if kind_prefix?
|
|
18
23
|
kind_filter = current_token.value
|
|
19
24
|
advance # consume identifier
|
|
20
25
|
advance # consume colon
|
|
@@ -256,8 +261,7 @@ class Archsight::Query::Parser
|
|
|
256
261
|
|
|
257
262
|
# Parse inner query: optional kind filter + optional expression
|
|
258
263
|
kind_filter = nil
|
|
259
|
-
|
|
260
|
-
if current_token.type == :IDENTIFIER && peek_token&.type == :COLON && (current_token.value =~ /^[A-Z]/)
|
|
264
|
+
if kind_prefix?
|
|
261
265
|
kind_filter = current_token.value
|
|
262
266
|
advance # consume identifier
|
|
263
267
|
advance # consume colon
|
|
@@ -395,8 +399,71 @@ class Archsight::Query::Parser
|
|
|
395
399
|
Archsight::Query::AST::AnnotationExistsCondition.new(path)
|
|
396
400
|
else
|
|
397
401
|
# Bare identifier - treat as name =~ "identifier"
|
|
398
|
-
Archsight::Query::AST::NameCondition.new("=~", Archsight::Query::AST::StringValue.new(path))
|
|
402
|
+
Archsight::Query::AST::NameCondition.new("=~", Archsight::Query::AST::StringValue.new(glue_name(path)))
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Names contain colons (`Archsight:CLI`, `ITGS:A001`). A bare name written without spaces around its colons is
|
|
407
|
+
# one word: `ApplicationService:ITGS:A001` is the kind and the name `ITGS:A001`.
|
|
408
|
+
def glue_name(first)
|
|
409
|
+
name = first
|
|
410
|
+
while (word = word_after_colon)
|
|
411
|
+
name += ":#{word}"
|
|
412
|
+
advance # consume the colon
|
|
413
|
+
advance while current_token.type != :EOF && current_token.position < @colon_end
|
|
414
|
+
end
|
|
415
|
+
name
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# The name word right after the current colon token, when the colon touches a name character on both sides
|
|
419
|
+
def word_after_colon
|
|
420
|
+
return nil unless @source && current_token.type == :COLON
|
|
421
|
+
|
|
422
|
+
colon = current_token.position
|
|
423
|
+
return nil unless colon.positive? && @source[colon - 1]&.match?(NAME_CHAR)
|
|
424
|
+
|
|
425
|
+
word = @source[(colon + 1)..].to_s[/\A#{NAME_CHAR}+/]
|
|
426
|
+
@colon_end = colon + 1 + word.length if word
|
|
427
|
+
word
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# `Kind: expression` or `Kind:`. The prefix is a kind when it starts with a capital letter and the colon is followed
|
|
431
|
+
# by a space or nothing; `Name:Word` without a space, where the prefix is no kind, is a name with a colon
|
|
432
|
+
# (`ITGS:A001`) that the bare word search handles.
|
|
433
|
+
def kind_prefix?
|
|
434
|
+
return false unless current_token.type == :IDENTIFIER && peek_token&.type == :COLON && current_token.value =~ /^[A-Z]/
|
|
435
|
+
return true if known_kind?(current_token.value)
|
|
436
|
+
|
|
437
|
+
!colon_glued?
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def known_kind?(name)
|
|
441
|
+
defined?(Archsight::Resources) && !Archsight::Resources[name].nil?
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Whether the current token starts a name with colons (`ITGS:A001`) that is a bare word, not the start of a
|
|
445
|
+
# condition: the colons touch name characters, and no operator or `?` follows the name
|
|
446
|
+
def colon_glued?
|
|
447
|
+
return false unless @source
|
|
448
|
+
|
|
449
|
+
stop = name_run_end(peek_token.position)
|
|
450
|
+
return false unless stop
|
|
451
|
+
|
|
452
|
+
following = @tokens.find { |token| token.position >= stop }
|
|
453
|
+
!(following && %i[EQ NEQ MATCH GT LT GTE LTE IN QUESTION].include?(following.type))
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Where `a:b:c` ends, starting at its first colon; nil when that colon does not touch a name character on both sides
|
|
457
|
+
def name_run_end(colon)
|
|
458
|
+
stop = nil
|
|
459
|
+
while @source[colon] == ":" && colon.positive? && @source[colon - 1].match?(NAME_CHAR)
|
|
460
|
+
word = @source[(colon + 1)..].to_s[/\A#{NAME_CHAR}+/]
|
|
461
|
+
break unless word
|
|
462
|
+
|
|
463
|
+
stop = colon + 1 + word.length
|
|
464
|
+
colon = stop
|
|
399
465
|
end
|
|
466
|
+
stop
|
|
400
467
|
end
|
|
401
468
|
|
|
402
469
|
def parse_quoted_annotation_path
|
data/lib/archsight/query.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Archsight
|
|
|
52
52
|
def parse(source)
|
|
53
53
|
lexer = Lexer.new(source)
|
|
54
54
|
tokens = lexer.tokenize
|
|
55
|
-
parser = Parser.new(tokens)
|
|
55
|
+
parser = Parser.new(tokens, source: source)
|
|
56
56
|
parser.parse
|
|
57
57
|
rescue LexerError, ParseError, InvalidRegexError => e
|
|
58
58
|
# Re-raise with source context
|
data/lib/archsight/references.rb
CHANGED
|
@@ -32,7 +32,7 @@ module Archsight
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
# The resources a markdown text mentions with `[[...]]` links and `![[
|
|
35
|
+
# The resources a markdown text mentions with `[[...]]` links and `![[Kind/...]]` embeds, outside code.
|
|
36
36
|
# @return [Array<Base>]
|
|
37
37
|
def mentions(markdown, finder)
|
|
38
38
|
visible = markdown.to_s.gsub(FENCE, "").gsub(INLINE_CODE, "")
|
|
@@ -111,10 +111,10 @@ module Archsight
|
|
|
111
111
|
@links.exact_target_for(target)
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
-
# An `![[Kind/Name]]` embed:
|
|
114
|
+
# An `![[Kind/Name]]` embed: any resource but a page
|
|
115
115
|
def embed(reference)
|
|
116
116
|
kind = reference.split("/", 2).first
|
|
117
|
-
return unless Helpers::Embeds
|
|
117
|
+
return unless Helpers::Embeds.embeddable_kind?(kind)
|
|
118
118
|
|
|
119
119
|
reference(reference)
|
|
120
120
|
end
|