expressir 2.4.16 → 2.4.18
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/lib/expressir/commands/mapping_validate.rb +30 -10
- data/lib/expressir/express/lazy_repository.rb +211 -0
- data/lib/expressir/express.rb +1 -0
- data/lib/expressir/mapping/refpath.rb +338 -0
- data/lib/expressir/mapping.rb +20 -0
- data/lib/expressir/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71c43838ba6c2853f84bfa1faca26760abe4919d35b98895ef224866bd49da62
|
|
4
|
+
data.tar.gz: 715a55be95f4ead656b98e0df59ccf06c2036eb532d6990a8ea036ee5f0f78c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dac93ad5688fc8e2d3814fd6933b352ea35d78b89f157e99f30114ddafb07b53aaeb80aaa45fd63540a9ee400ac1aacc3724800446f868472499d9d4cf501629
|
|
7
|
+
data.tar.gz: 12cd7ef87afa0510215d0c1315e68ba5bbaed5a910255ebfa40e18b328b25f87ef3a24f93ce1073a798ce0e748e6344861f05900f863377ed801d9f1f66e7bab
|
|
@@ -2,27 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
module Expressir
|
|
4
4
|
module Commands
|
|
5
|
-
# `expressir mapping validate` (#88): load a module mapping.yaml
|
|
6
|
-
#
|
|
7
|
-
# interface closure. Exits 1 when unknown
|
|
5
|
+
# `expressir mapping validate` (#88): load a module mapping.yaml,
|
|
6
|
+
# resolve every <<express:...>> link and every reference path
|
|
7
|
+
# against the ARM/MIM interface closure. Exits 1 when unknown
|
|
8
|
+
# links or refpath issues remain.
|
|
8
9
|
class MappingValidate < Base
|
|
9
10
|
def run(path)
|
|
10
11
|
document = Expressir::Mapping.load_file(path)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if unknown.empty?
|
|
14
|
-
say "all links resolve"
|
|
15
|
-
return
|
|
16
|
-
end
|
|
12
|
+
repository = repository_for(path)
|
|
17
13
|
|
|
14
|
+
unknown = Expressir::Mapping.unknown_links(document, repository)
|
|
18
15
|
unknown.each do |link|
|
|
19
16
|
say "unknown: #{link.text}"
|
|
20
17
|
end
|
|
21
|
-
|
|
18
|
+
|
|
19
|
+
issues = refpath_issues(document, repository)
|
|
20
|
+
issues.each do |location, issue|
|
|
21
|
+
say "refpath #{location} [step #{issue.step}]: #{issue.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
total = unknown.size + issues.size
|
|
25
|
+
unless total.zero?
|
|
26
|
+
raise Thor::Error, "#{unknown.size} unknown link(s), " \
|
|
27
|
+
"#{issues.size} refpath issue(s)"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
say "all links and reference paths resolve"
|
|
22
31
|
end
|
|
23
32
|
|
|
24
33
|
private
|
|
25
34
|
|
|
35
|
+
def refpath_issues(document, repository)
|
|
36
|
+
Expressir::Mapping.refpaths(document).flat_map do |location, content|
|
|
37
|
+
parsed = Expressir::Mapping::RefPath.parse(content)
|
|
38
|
+
parsed.parse_errors.map do |error|
|
|
39
|
+
[location, Expressir::Mapping::RefPath::Issue.new(step: nil,
|
|
40
|
+
message: error)]
|
|
41
|
+
end + Expressir::Mapping::RefPath.validate(parsed, repository)
|
|
42
|
+
.map { |issue| [location, issue] }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
26
46
|
def repository_for(path)
|
|
27
47
|
dir = File.dirname(File.expand_path(path))
|
|
28
48
|
arm = Dir[File.join(dir, "arm.exp")].first
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "liquid"
|
|
4
|
+
|
|
5
|
+
module Expressir
|
|
6
|
+
module Express
|
|
7
|
+
# Lazy read path over a compiled-set artifact (M2 slice 2,
|
|
8
|
+
# TODO.parity-ee/19): schemas hydrate individually, on first touch,
|
|
9
|
+
# instead of the whole repository at once. Metanorma schema-doc
|
|
10
|
+
# rendering touches one schema per page — the lazy repository makes
|
|
11
|
+
# that pay: one hydration per page, not one per schema in the set.
|
|
12
|
+
#
|
|
13
|
+
# Duck-types the read surface of Model::Repository used by
|
|
14
|
+
# rendering (schemas, files, each, size, children). Anything beyond
|
|
15
|
+
# the read surface should wait for eager hydration via
|
|
16
|
+
# #to_eager (returns a real Model::Repository).
|
|
17
|
+
class LazyRepository
|
|
18
|
+
attr_reader :set_path
|
|
19
|
+
|
|
20
|
+
# @param set_path [String] compiled-set artifact path
|
|
21
|
+
# @param read_paths [Hash{String => String}] wire path => source path
|
|
22
|
+
def initialize(set_path, read_paths = {})
|
|
23
|
+
@set_path = set_path
|
|
24
|
+
@set = Core.lazy_set(set_path)
|
|
25
|
+
@read_paths = read_paths
|
|
26
|
+
@wire_paths = @set.wire_paths
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
@file_cache = {}
|
|
29
|
+
@hydration_count = 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Wire paths in artifact order — no hydration.
|
|
33
|
+
# @return [Array<String>]
|
|
34
|
+
def wire_paths
|
|
35
|
+
@wire_paths.dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Files in artifact order, each wrapped so it hydrates on first
|
|
39
|
+
# use. The file is the hydration grain: an artifact stores wire
|
|
40
|
+
# JSON per source file.
|
|
41
|
+
# @return [Array<LazyFile>]
|
|
42
|
+
def files
|
|
43
|
+
@wire_paths.map { |wire_path| file_by_wire_path(wire_path) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Hydrated (or hydrating) file for one wire path.
|
|
47
|
+
def file_by_wire_path(wire_path)
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@file_cache[wire_path] ||= LazyFile.new(wire_path) do
|
|
50
|
+
hydrate(wire_path)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Schemas across all files. EAGER by definition: it hydrates
|
|
56
|
+
# every file. Lazy callers iterate #files, or render through
|
|
57
|
+
# #to_liquid, which keeps per-file hydration lazy.
|
|
58
|
+
# @return [Array<Declarations::Schema>]
|
|
59
|
+
def schemas
|
|
60
|
+
files.map(&:__target__).flat_map(&:schemas).compact
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Liquid binding for metanorma (M2): returns a Drop whose
|
|
64
|
+
# `schemas` is a lazy per-file list — each entry hydrates only
|
|
65
|
+
# its own file when the template touches it. Schema count per
|
|
66
|
+
# file is unknown until hydration, so the Drop presents one
|
|
67
|
+
# entry per FILE; single-schema files (the corpus norm) render
|
|
68
|
+
# identically to the eager path.
|
|
69
|
+
def to_liquid
|
|
70
|
+
LazyRepositoryDrop.new(self)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# How many files have actually been hydrated (observability for
|
|
74
|
+
# the laziness contract).
|
|
75
|
+
def hydration_count
|
|
76
|
+
@hydration_count
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Hydrate everything and return a real Model::Repository — the
|
|
80
|
+
# escape hatch for callers needing the full read surface.
|
|
81
|
+
def to_eager
|
|
82
|
+
repo = Model::Repository.new
|
|
83
|
+
repo.files = files.map(&:__target__)
|
|
84
|
+
repo
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def each(&)
|
|
88
|
+
files.each(&)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def size
|
|
92
|
+
@wire_paths.size
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def children
|
|
96
|
+
schemas
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The read path on disk for a wire path (may be nil when the
|
|
100
|
+
# caller did not supply source paths).
|
|
101
|
+
def read_path_for(wire_path)
|
|
102
|
+
@read_paths[wire_path]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def matches_sources?
|
|
106
|
+
@set.matches_sources(@read_paths.dup) == true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def hydrate(wire_path)
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
_wire, model = @set.hydrate_one(wire_path)
|
|
114
|
+
@hydration_count += 1
|
|
115
|
+
model
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Liquid::Drop over a LazyRepository: `schemas` enumerates lazy
|
|
121
|
+
# per-file entries; touching an entry hydrates that file and
|
|
122
|
+
# presents its schemas (single-schema files present the schema
|
|
123
|
+
# itself, matching the eager Drop's element contract).
|
|
124
|
+
class LazyRepositoryDrop < ::Liquid::Drop
|
|
125
|
+
def initialize(repo)
|
|
126
|
+
@repo = repo
|
|
127
|
+
super()
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def schemas
|
|
131
|
+
@repo.files.map do |file|
|
|
132
|
+
LazyFileDrop.new(file)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def size
|
|
137
|
+
@repo.size
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Per-file lazy drop: hydrates on first access, then presents the
|
|
142
|
+
# file's single schema directly (or the schema list for
|
|
143
|
+
# multi-schema files).
|
|
144
|
+
class LazyFileDrop < ::Liquid::Drop
|
|
145
|
+
def initialize(file)
|
|
146
|
+
@file = file
|
|
147
|
+
super()
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def __file__
|
|
151
|
+
@file.__target__
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def method_missing(name, *, &)
|
|
155
|
+
schemas = __file__.schemas
|
|
156
|
+
if schemas.size == 1
|
|
157
|
+
schemas.first.public_send(name, *, &)
|
|
158
|
+
else
|
|
159
|
+
__file__.public_send(name, *, &)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def respond_to_missing?(name, include_private = false)
|
|
164
|
+
__file__.respond_to?(name, include_private) ||
|
|
165
|
+
__file__.schemas.first.respond_to?(name, include_private)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Stands in for a Model::ExpFile until first method access, then
|
|
170
|
+
# hydrates through the block and forwards everything.
|
|
171
|
+
class LazyFile
|
|
172
|
+
def initialize(wire_path, &hydrator)
|
|
173
|
+
@wire_path = wire_path
|
|
174
|
+
@hydrator = hydrator
|
|
175
|
+
@mutex = Mutex.new
|
|
176
|
+
@target = nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
attr_reader :wire_path
|
|
180
|
+
|
|
181
|
+
# The real Schema, hydrating on first call.
|
|
182
|
+
def __target__
|
|
183
|
+
@mutex.synchronize do
|
|
184
|
+
@__target__ ||= @hydrator.call
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def method_missing(name, *, &)
|
|
189
|
+
__target__.public_send(name, *, &)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def respond_to_missing?(name, include_private = false)
|
|
193
|
+
__target__.respond_to?(name, include_private)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Identity through the wrapper: rendering compares and groups by
|
|
197
|
+
# class/is_a?, which the real file answers.
|
|
198
|
+
def is_a?(klass)
|
|
199
|
+
super || __target__.is_a?(klass)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def instance_of?(klass)
|
|
203
|
+
__target__.instance_of?(klass)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def class
|
|
207
|
+
__target__.class
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
data/lib/expressir/express.rb
CHANGED
|
@@ -44,6 +44,7 @@ module Expressir
|
|
|
44
44
|
"#{__dir__}/express/schema_plain_source_formatter"
|
|
45
45
|
autoload :SchemaSourceFormatter, "#{__dir__}/express/schema_source_formatter"
|
|
46
46
|
autoload :ScopeResolver, "#{__dir__}/express/scope_resolver"
|
|
47
|
+
autoload :LazyRepository, "#{__dir__}/express/lazy_repository"
|
|
47
48
|
autoload :SmrlXml, "#{__dir__}/express/smrl_xml"
|
|
48
49
|
autoload :Xsd, "#{__dir__}/express/xsd"
|
|
49
50
|
autoload :SourceFormatter, "#{__dir__}/express/source_formatter"
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Mapping
|
|
5
|
+
# Formalization, parser, and validator for MIM reference paths
|
|
6
|
+
# (expressir#88): the "Reference path" sections of module
|
|
7
|
+
# mapping.yaml files, written in the ISO 10303 module mapping
|
|
8
|
+
# notation. Until now the notation was prose for humans only; this
|
|
9
|
+
# makes it machine-readable and checks it against real schemas.
|
|
10
|
+
#
|
|
11
|
+
# Grammar (corpus-verified over all module mapping.yaml refpaths):
|
|
12
|
+
# path := step+
|
|
13
|
+
# step := [link] node [index] [constraint]
|
|
14
|
+
# | MARKER
|
|
15
|
+
# link := "->" | "<-" | "<=" | "=>" | "=" NAME
|
|
16
|
+
# (relation to the previous node)
|
|
17
|
+
# node := NAME ["." ATTRIBUTE]
|
|
18
|
+
# index := "[" ("i" | DIGITS) "]"
|
|
19
|
+
# MARKER := "{" | "}" | "[" | "]" | "(" | ")" | ":" | "|" | "!"
|
|
20
|
+
# | "," | "/" | "*" (grouping/annotation; no
|
|
21
|
+
# node semantics; never
|
|
22
|
+
# breaks a pending link)
|
|
23
|
+
# constraint := "=" STRING
|
|
24
|
+
#
|
|
25
|
+
# Operator semantics per the module conventions:
|
|
26
|
+
# -> the attribute named before -> references the entity or
|
|
27
|
+
# select type named after ->
|
|
28
|
+
# <- the attribute named before <- is referenced by the type
|
|
29
|
+
# named after <-
|
|
30
|
+
# <= the entity named before <= is a subtype of the entity after
|
|
31
|
+
# => the entity named before => is a supertype of the entity after
|
|
32
|
+
# = the node before = is of the type named after (used inside
|
|
33
|
+
# parenthesized alternatives, e.g. `(unit = named_unit)`)
|
|
34
|
+
# [i] the attribute named before [i] is an aggregate; any element
|
|
35
|
+
# of that aggregate is referred to
|
|
36
|
+
module RefPath
|
|
37
|
+
# Wire-name mapping is declared here so a parsed path can itself
|
|
38
|
+
# be serialized with lutaml-model.
|
|
39
|
+
class Step < Lutaml::Model::Serializable
|
|
40
|
+
attribute :operator, :string
|
|
41
|
+
attribute :name, :string
|
|
42
|
+
attribute :attribute, :string
|
|
43
|
+
attribute :index, :string
|
|
44
|
+
attribute :literal, :string
|
|
45
|
+
|
|
46
|
+
key_value do
|
|
47
|
+
map "operator", to: :operator
|
|
48
|
+
map "name", to: :name
|
|
49
|
+
map "attribute", to: :attribute
|
|
50
|
+
map "index", to: :index
|
|
51
|
+
map "literal", to: :literal
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class Path < Lutaml::Model::Serializable
|
|
56
|
+
attribute :steps, Step, collection: true
|
|
57
|
+
attribute :parse_errors, :string, collection: true
|
|
58
|
+
|
|
59
|
+
key_value do
|
|
60
|
+
map "steps", to: :steps
|
|
61
|
+
map "parse_errors", to: :parse_errors
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
TOKEN = /->|<-|<=|=>|[{}\[\]()=:|!,*\/]|'(?:''|[^'])*'|\w+(?:\.\w+)?/
|
|
66
|
+
MARKERS = ["{", "}", "[", "]", "(", ")", ":", "|", "!", ",", "/",
|
|
67
|
+
"*"].freeze
|
|
68
|
+
LINKS = ["->", "<-", "<=", "=>"].freeze
|
|
69
|
+
DECLARATION_COLLECTIONS = %i[types entities].freeze
|
|
70
|
+
private_constant :TOKEN, :MARKERS, :LINKS, :DECLARATION_COLLECTIONS
|
|
71
|
+
|
|
72
|
+
Issue = Struct.new(:step, :message, keyword_init: true)
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# @param content [String] the mapping.yaml refpath content block
|
|
77
|
+
# @return [Path]
|
|
78
|
+
def parse(content)
|
|
79
|
+
tokens = content.to_s.scan(TOKEN)
|
|
80
|
+
steps = []
|
|
81
|
+
errors = []
|
|
82
|
+
pending_link = nil
|
|
83
|
+
pending_type_assign = false
|
|
84
|
+
i = 0
|
|
85
|
+
while i < tokens.length
|
|
86
|
+
token = tokens[i]
|
|
87
|
+
case token
|
|
88
|
+
when *LINKS
|
|
89
|
+
pending_link = token
|
|
90
|
+
when "="
|
|
91
|
+
literal = tokens[i + 1]
|
|
92
|
+
if literal&.start_with?("'")
|
|
93
|
+
attach(steps, errors, :literal, literal)
|
|
94
|
+
i += 1
|
|
95
|
+
elsif literal.nil?
|
|
96
|
+
errors << "constraint '=' with nothing after it"
|
|
97
|
+
else
|
|
98
|
+
# `x = Type` type assignment: the following node (however
|
|
99
|
+
# many markers it sits behind, e.g. /MAPPING_OF(X)/)
|
|
100
|
+
# carries the "=" link.
|
|
101
|
+
pending_type_assign = true
|
|
102
|
+
end
|
|
103
|
+
when "["
|
|
104
|
+
if index_token?(tokens[i + 1])
|
|
105
|
+
attach(steps, errors, :index, tokens[i + 1])
|
|
106
|
+
i += 2 # the index token and the closing "]"
|
|
107
|
+
else
|
|
108
|
+
steps << Step.new(operator: "[")
|
|
109
|
+
end
|
|
110
|
+
when *MARKERS
|
|
111
|
+
steps << Step.new(operator: token)
|
|
112
|
+
else
|
|
113
|
+
name, _, attribute = token.partition(".")
|
|
114
|
+
if name.casecmp("MAPPING_OF").zero? && tokens[i + 1] == "("
|
|
115
|
+
# notation wrapper: /MAPPING_OF(X)/ refers to X; the
|
|
116
|
+
# wrapper itself carries no node semantics
|
|
117
|
+
steps << Step.new(operator: token)
|
|
118
|
+
else
|
|
119
|
+
operator = pending_link || ("=" if pending_type_assign)
|
|
120
|
+
steps << Step.new(operator: operator, name: name,
|
|
121
|
+
attribute: attribute.empty? ? nil : attribute)
|
|
122
|
+
pending_link = nil
|
|
123
|
+
pending_type_assign = false
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
i += 1
|
|
127
|
+
end
|
|
128
|
+
Path.new(steps: steps, parse_errors: errors)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def index_token?(token)
|
|
132
|
+
token == "i" || token&.match?(/\A\d+\z/)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Attach an index or constraint literal to the most recent node
|
|
136
|
+
# step that does not carry one yet.
|
|
137
|
+
def attach(steps, errors, field, value)
|
|
138
|
+
step = steps.reverse.each.find { |s| s.name && !s.public_send(field) }
|
|
139
|
+
if step
|
|
140
|
+
step.public_send("#{field}=", value)
|
|
141
|
+
else
|
|
142
|
+
case field
|
|
143
|
+
when :index then errors << "aggregate index [#{value}] has no node"
|
|
144
|
+
when :literal then errors << "constraint #{value} has no node"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Validate a parsed path against a repository. Issues reference
|
|
150
|
+
# the step index they attach to.
|
|
151
|
+
# @return [Array<Issue>]
|
|
152
|
+
def validate(path, repository)
|
|
153
|
+
by_name = name_index(repository)
|
|
154
|
+
issues = []
|
|
155
|
+
prev = nil
|
|
156
|
+
path.steps.each_with_index do |step, pos|
|
|
157
|
+
if MARKERS.include?(step.operator)
|
|
158
|
+
prev = nil
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
issues.concat(check_step(step, pos, prev, by_name))
|
|
163
|
+
prev = step if step.name
|
|
164
|
+
end
|
|
165
|
+
issues
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def name_index(repository)
|
|
169
|
+
repository.schemas.flat_map do |schema|
|
|
170
|
+
DECLARATION_COLLECTIONS.flat_map do |coll|
|
|
171
|
+
Array(schema.public_send(coll))
|
|
172
|
+
.select { |d| d.respond_to?(:id) && d.id }
|
|
173
|
+
.map { |d| [d.id.safe_downcase, [schema, d]] }
|
|
174
|
+
end
|
|
175
|
+
end.to_h
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def check_step(step, pos, prev, by_name)
|
|
179
|
+
found = by_name[step.name&.safe_downcase]
|
|
180
|
+
return [Issue.new(step: pos, message: "unknown type '#{step.name}'")] unless found
|
|
181
|
+
|
|
182
|
+
issues = attribute_issues(step, pos, found[1], by_name)
|
|
183
|
+
issues.concat(link_issues(step, pos, prev, by_name))
|
|
184
|
+
issues
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def attribute_issues(step, pos, decl, by_name)
|
|
188
|
+
return [] unless step.attribute
|
|
189
|
+
|
|
190
|
+
unless decl.is_a?(Model::Declarations::Entity)
|
|
191
|
+
return [Issue.new(step: pos,
|
|
192
|
+
message: "'#{step.name}' is not an entity, " \
|
|
193
|
+
"cannot carry '#{step.name}.#{step.attribute}'")]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
attribute = find_attribute(decl, step.attribute, by_name)
|
|
197
|
+
unless attribute
|
|
198
|
+
return [Issue.new(step: pos,
|
|
199
|
+
message: "entity '#{step.name}' has no attribute " \
|
|
200
|
+
"'#{step.attribute}'")]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
aggregate_issue(step, pos, attribute)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Direct or inherited attribute: inheritance follows the
|
|
207
|
+
# subtype_of chain, resolved through the repository-wide index.
|
|
208
|
+
def find_attribute(entity, attribute_id, by_name)
|
|
209
|
+
key = attribute_id.safe_downcase
|
|
210
|
+
supertype_chain(entity, by_name).lazy.flat_map do |candidate|
|
|
211
|
+
Array(candidate.attributes)
|
|
212
|
+
end.find { |a| a.id&.safe_downcase == key }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def supertype_chain(entity, by_name)
|
|
216
|
+
seen = {}.compare_by_identity
|
|
217
|
+
queue = [entity]
|
|
218
|
+
result = []
|
|
219
|
+
until queue.empty?
|
|
220
|
+
current = queue.shift
|
|
221
|
+
next if seen[current]
|
|
222
|
+
|
|
223
|
+
seen[current] = true
|
|
224
|
+
result << current
|
|
225
|
+
current.subtype_of.to_a.each do |ref|
|
|
226
|
+
sup = by_name[ref.respond_to?(:id) ? ref.id&.safe_downcase : nil]&.last
|
|
227
|
+
queue << sup if sup.is_a?(Model::Declarations::Entity)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
result
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def aggregate_issue(step, pos, attribute)
|
|
234
|
+
return [] unless step.index
|
|
235
|
+
return [] if attribute.type.respond_to?(:bound1)
|
|
236
|
+
|
|
237
|
+
[Issue.new(step: pos,
|
|
238
|
+
message: "'#{step.name}.#{step.attribute}' is not an " \
|
|
239
|
+
"aggregate but carries [#{step.index}]")]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def link_issues(step, pos, prev, by_name)
|
|
243
|
+
return [] unless LINKS.include?(step.operator)
|
|
244
|
+
unless prev && step.name
|
|
245
|
+
return [Issue.new(step: pos,
|
|
246
|
+
message: "#{step.operator} with no preceding node")]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
case step.operator
|
|
250
|
+
when "->", "<-" then attribute_link_issues(step, pos, prev)
|
|
251
|
+
when "<=" then subtype_link_issues(step, pos, prev, by_name)
|
|
252
|
+
when "=>" then supertype_link_issues(step, pos, prev, by_name)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def attribute_link_issues(_step, pos, prev)
|
|
257
|
+
unless prev.attribute
|
|
258
|
+
return [Issue.new(step: pos,
|
|
259
|
+
message: "'->' requires an attribute-qualified " \
|
|
260
|
+
"node before it, got '#{prev.name}'")]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
[]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# `a <= b`: a is a subtype of b.
|
|
267
|
+
def subtype_link_issues(step, pos, prev, by_name)
|
|
268
|
+
source = by_name[prev.name&.safe_downcase]&.last
|
|
269
|
+
target = by_name[step.name&.safe_downcase]&.last
|
|
270
|
+
return [] unless source.is_a?(Model::Declarations::Entity) &&
|
|
271
|
+
target.is_a?(Model::Declarations::Entity)
|
|
272
|
+
|
|
273
|
+
return [] if supertype_ids(source, by_name)
|
|
274
|
+
.any? { |id| id&.safe_downcase == step.name&.safe_downcase }
|
|
275
|
+
|
|
276
|
+
[Issue.new(step: pos,
|
|
277
|
+
message: "'#{prev.name}' is not a subtype of '#{step.name}'")]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# `a => b`: a is a supertype of b.
|
|
281
|
+
def supertype_link_issues(step, pos, prev, by_name)
|
|
282
|
+
source = by_name[step.name&.safe_downcase]&.last
|
|
283
|
+
target = by_name[prev.name&.safe_downcase]&.last
|
|
284
|
+
return [] unless source.is_a?(Model::Declarations::Entity) &&
|
|
285
|
+
target.is_a?(Model::Declarations::Entity)
|
|
286
|
+
|
|
287
|
+
return [] if supertype_ids(source, by_name)
|
|
288
|
+
.any? { |id| id&.safe_downcase == prev.name&.safe_downcase }
|
|
289
|
+
|
|
290
|
+
[Issue.new(step: pos,
|
|
291
|
+
message: "'#{prev.name}' is not a supertype of '#{step.name}'")]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Supertype entity names of +entity+: the subtype_of references
|
|
295
|
+
# plus the leaves of the supertype expression tree, resolved
|
|
296
|
+
# through the repository-wide name index so inherited chains
|
|
297
|
+
# (including cross-schema USE FROM) walk correctly.
|
|
298
|
+
def supertype_ids(entity, by_name)
|
|
299
|
+
seen = {}.compare_by_identity
|
|
300
|
+
queue = [entity]
|
|
301
|
+
names = []
|
|
302
|
+
until queue.empty?
|
|
303
|
+
current = queue.shift
|
|
304
|
+
next if seen[current]
|
|
305
|
+
|
|
306
|
+
seen[current] = true
|
|
307
|
+
direct = current.subtype_of.to_a.filter_map do |ref|
|
|
308
|
+
ref.respond_to?(:id) ? ref.id : nil
|
|
309
|
+
end
|
|
310
|
+
leaves = expression_leaves(current.supertype_expression)
|
|
311
|
+
.filter_map { |leaf| leaf.respond_to?(:id) ? leaf.id : nil }
|
|
312
|
+
names.concat(direct)
|
|
313
|
+
names.concat(leaves)
|
|
314
|
+
direct.concat(leaves).each do |id|
|
|
315
|
+
sup = by_name[id&.safe_downcase]&.last
|
|
316
|
+
queue << sup if sup.is_a?(Model::Declarations::Entity)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
names.uniq
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Leaf reference nodes of a supertype expression tree.
|
|
323
|
+
def expression_leaves(expr)
|
|
324
|
+
return [] unless expr
|
|
325
|
+
|
|
326
|
+
if expr.respond_to?(:operands)
|
|
327
|
+
Array(expr.operands).flat_map { |o| expression_leaves(o) }
|
|
328
|
+
elsif expr.respond_to?(:operand1)
|
|
329
|
+
expression_leaves(expr.operand1) + expression_leaves(expr.operand2)
|
|
330
|
+
elsif expr.respond_to?(:id) && expr.id
|
|
331
|
+
[expr]
|
|
332
|
+
else
|
|
333
|
+
[]
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
data/lib/expressir/mapping.rb
CHANGED
|
@@ -13,6 +13,8 @@ module Expressir
|
|
|
13
13
|
# are ignored by the mapping and survive round-trips untouched in
|
|
14
14
|
# the source files.
|
|
15
15
|
module Mapping
|
|
16
|
+
autoload :RefPath, "#{__dir__}/mapping/refpath"
|
|
17
|
+
|
|
16
18
|
EXPRESS_LINK = /<<express:([^,>]+)(?:,([^>]+))?>>/
|
|
17
19
|
|
|
18
20
|
class ReferencePath < Lutaml::Model::Serializable
|
|
@@ -137,5 +139,23 @@ module Expressir
|
|
|
137
139
|
element.aa.to_a.flat_map { |aa| [aa.attribute, aa.aimelt] }
|
|
138
140
|
end.compact
|
|
139
141
|
end
|
|
142
|
+
|
|
143
|
+
# Every reference path in the document as [location, content]
|
|
144
|
+
# pairs; location names the application element (and attribute,
|
|
145
|
+
# for attribute-level paths) the path belongs to.
|
|
146
|
+
def refpaths(document)
|
|
147
|
+
document.ae.to_a.flat_map do |element|
|
|
148
|
+
entries = []
|
|
149
|
+
if element.refpath&.content
|
|
150
|
+
entries << [element.entity.to_s, element.refpath.content]
|
|
151
|
+
end
|
|
152
|
+
element.aa.to_a.each do |aa|
|
|
153
|
+
next unless aa.refpath&.content
|
|
154
|
+
|
|
155
|
+
entries << ["#{element.entity}.#{aa.attribute}", aa.refpath.content]
|
|
156
|
+
end
|
|
157
|
+
entries
|
|
158
|
+
end
|
|
159
|
+
end
|
|
140
160
|
end
|
|
141
161
|
end
|
data/lib/expressir/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: expressir
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.4.
|
|
4
|
+
version: 2.4.18
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -506,6 +506,7 @@ files:
|
|
|
506
506
|
- lib/expressir/express/grammar/parser.rb
|
|
507
507
|
- lib/expressir/express/hyperlink_formatter.rb
|
|
508
508
|
- lib/expressir/express/interface_dot.rb
|
|
509
|
+
- lib/expressir/express/lazy_repository.rb
|
|
509
510
|
- lib/expressir/express/line_map.rb
|
|
510
511
|
- lib/expressir/express/listing.rb
|
|
511
512
|
- lib/expressir/express/model_traversal.rb
|
|
@@ -536,6 +537,7 @@ files:
|
|
|
536
537
|
- lib/expressir/manifest/resolver.rb
|
|
537
538
|
- lib/expressir/manifest/validator.rb
|
|
538
539
|
- lib/expressir/mapping.rb
|
|
540
|
+
- lib/expressir/mapping/refpath.rb
|
|
539
541
|
- lib/expressir/model.rb
|
|
540
542
|
- lib/expressir/model/cache.rb
|
|
541
543
|
- lib/expressir/model/concerns.rb
|