expressir 2.4.35 → 2.4.37
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/.github/workflows/conformance.yml +34 -0
- data/.gitignore +3 -0
- data/Gemfile +1 -1
- data/TODO.sota/00-overview.md +57 -0
- data/TODO.sota/01-type-checking.md +115 -0
- data/TODO.sota/02-evaluator.md +86 -0
- data/TODO.sota/03-conformance.md +53 -0
- data/TODO.sota/04-lsp.md +70 -0
- data/TODO.sota/05-interop.md +55 -0
- data/TODO.sota/06-dialects.md +57 -0
- data/TODO.sota/07-reference-artifacts.md +55 -0
- data/docs/_guides/interop/index.adoc +25 -0
- data/docs/_guides/interop/json-schema.adoc +88 -0
- data/docs/_guides/interop/owl-rdf.adoc +87 -0
- data/docs/_guides/reference/checker-notes.adoc +55 -0
- data/docs/_guides/reference/checker.adoc +36 -0
- data/docs/_guides/reference/evaluator.adoc +40 -0
- data/docs/_guides/reference/grammar-crosswalk.adoc +299 -0
- data/docs/_guides/reference/index.adoc +22 -0
- data/docs/_guides/reference/lsp.adoc +41 -0
- data/docs/_guides/reference/p21.adoc +30 -0
- data/docs/_guides/reference/shtolo.adoc +36 -0
- data/exe/expressir-lsp +6 -0
- data/expressir.gemspec +2 -2
- data/ext/expressir_core/Cargo.toml +1 -1
- data/lib/expressir/cli.rb +18 -0
- data/lib/expressir/commands/check.rb +3 -1
- data/lib/expressir/commands/export_json_schema.rb +29 -0
- data/lib/expressir/commands/export_owl.rb +26 -0
- data/lib/expressir/commands/validate.rb +3 -0
- data/lib/expressir/commands.rb +2 -0
- data/lib/expressir/conformance/trend.rb +100 -0
- data/lib/expressir/conformance.rb +8 -0
- data/lib/expressir/eval/environment.rb +83 -0
- data/lib/expressir/eval/expression.rb +610 -0
- data/lib/expressir/eval/interpreter.rb +316 -0
- data/lib/expressir/eval/rules.rb +88 -0
- data/lib/expressir/eval/values.rb +74 -0
- data/lib/expressir/eval.rb +17 -0
- data/lib/expressir/express/builders/expression_builder.rb +9 -1
- data/lib/expressir/express/checker.rb +779 -2
- data/lib/expressir/express/dialect.rb +44 -0
- data/lib/expressir/express/formatters/expressions_formatter.rb +4 -1
- data/lib/expressir/express/parser.rb +9 -4
- data/lib/expressir/express.rb +1 -0
- data/lib/expressir/interop/json_schema.rb +195 -0
- data/lib/expressir/interop/owl.rb +216 -0
- data/lib/expressir/interop.rb +13 -0
- data/lib/expressir/lsp/document.rb +226 -0
- data/lib/expressir/lsp/server.rb +184 -0
- data/lib/expressir/lsp/workspace.rb +402 -0
- data/lib/expressir/lsp.rb +11 -0
- data/lib/expressir/mapping/refpath.rb +65 -4
- data/lib/expressir/reference/grammar_crosswalk.rb +122 -0
- data/lib/expressir/reference/grammar_crosswalk.yml +800 -0
- data/lib/expressir/reference/note_catalog.rb +54 -0
- data/lib/expressir/reference.rb +10 -0
- data/lib/expressir/version.rb +1 -1
- data/lib/expressir.rb +4 -0
- data/lib/tasks/conformance.rake +27 -0
- data/lib/tasks/reference.rake +25 -0
- metadata +50 -4
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Lsp
|
|
5
|
+
# The cross-document view used by the server: open documents, schema
|
|
6
|
+
# resolution through USE/REFERENCE interfaces, diagnostics, navigation
|
|
7
|
+
# and rename computation.
|
|
8
|
+
class Workspace
|
|
9
|
+
SEVERITIES = { error: 1, warning: 2, info: 3, hint: 4 }.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :documents
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@documents = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def open(uri:, text:, path: nil)
|
|
18
|
+
@documents[uri] = Document.new(uri: uri, text: text, path: path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def change(uri:, text:)
|
|
22
|
+
document = @documents[uri]
|
|
23
|
+
return document.update(text) if document
|
|
24
|
+
|
|
25
|
+
self.open(uri: uri, text: text)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def close(uri)
|
|
29
|
+
@documents.delete(uri)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def document(uri)
|
|
33
|
+
@documents[uri]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def symbols(uri)
|
|
37
|
+
document(uri)&.symbols || []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def diagnostics(uri)
|
|
41
|
+
document = @documents[uri]
|
|
42
|
+
return [] unless document
|
|
43
|
+
|
|
44
|
+
return [parse_diagnostic(document)] if document.parse_error
|
|
45
|
+
|
|
46
|
+
checker_notes.filter_map { |note| diagnostic_for(document, note) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def definition(uri, position)
|
|
50
|
+
target = target_at(uri, position)
|
|
51
|
+
return nil unless target
|
|
52
|
+
|
|
53
|
+
location(target[:doc], target[:node])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def references(uri, position, include_declaration: true)
|
|
57
|
+
target = target_at(uri, position)
|
|
58
|
+
return [] unless target
|
|
59
|
+
|
|
60
|
+
declaration = target[:node]
|
|
61
|
+
results = reference_locations(declaration)
|
|
62
|
+
results << location(target[:doc], declaration) if include_declaration
|
|
63
|
+
dedupe_locations(results)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def hover(uri, position)
|
|
67
|
+
target = target_at(uri, position)
|
|
68
|
+
return nil unless target
|
|
69
|
+
|
|
70
|
+
value = Express::PrettyFormatter.new.format(target[:node])
|
|
71
|
+
{
|
|
72
|
+
"contents" => {
|
|
73
|
+
"kind" => "markdown",
|
|
74
|
+
"value" => "```express\n#{value}\n```",
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def prepare_rename(uri, position)
|
|
82
|
+
target = target_at(uri, position)
|
|
83
|
+
return nil unless target
|
|
84
|
+
|
|
85
|
+
document = target[:doc]
|
|
86
|
+
node = target[:node]
|
|
87
|
+
{ "range" => document.range_for(node),
|
|
88
|
+
"placeholder" => node.id.to_s }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns the URI→edits map for a rename, after applying the edits
|
|
92
|
+
# in memory and verifying that every affected document still parses.
|
|
93
|
+
def rename(uri, position, new_name)
|
|
94
|
+
target = target_at(uri, position)
|
|
95
|
+
return nil unless target
|
|
96
|
+
|
|
97
|
+
edits = rename_edits(target[:doc], target[:node], new_name)
|
|
98
|
+
verify_rename!(edits, target[:node], new_name)
|
|
99
|
+
{ "changes" => edits }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def checker_notes
|
|
105
|
+
key = @documents.each_value.map { |doc| doc.text.hash }
|
|
106
|
+
return @checker_notes if key == @checker_notes_key
|
|
107
|
+
|
|
108
|
+
files = @documents.each_value.filter_map(&:file)
|
|
109
|
+
notes = if files.empty?
|
|
110
|
+
[]
|
|
111
|
+
else
|
|
112
|
+
repository = Model::Repository.new(files: files)
|
|
113
|
+
Express::Checker.new(repository).check.notes
|
|
114
|
+
end
|
|
115
|
+
@checker_notes = notes
|
|
116
|
+
@checker_notes_key = key
|
|
117
|
+
notes
|
|
118
|
+
rescue StandardError
|
|
119
|
+
[]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def diagnostic_for(document, note)
|
|
123
|
+
schema = document.schemas.find do |candidate|
|
|
124
|
+
candidate.id.to_s.casecmp?(note.schema.to_s)
|
|
125
|
+
end
|
|
126
|
+
return nil unless schema
|
|
127
|
+
return nil if resolved_note?(document, note)
|
|
128
|
+
|
|
129
|
+
node = note.node
|
|
130
|
+
range = if node&.source_offset
|
|
131
|
+
document.range(node.source_offset,
|
|
132
|
+
node.respond_to?(:id) && node.id ? node.id.to_s.bytesize : 1)
|
|
133
|
+
else
|
|
134
|
+
document.range_for(schema)
|
|
135
|
+
end
|
|
136
|
+
{
|
|
137
|
+
"range" => range,
|
|
138
|
+
"severity" => SEVERITIES.fetch(note.severity, 3),
|
|
139
|
+
"code" => note.id,
|
|
140
|
+
"source" => "expressir",
|
|
141
|
+
"message" => note.message.to_s,
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The checker sees one file at a time; an open document that supplies
|
|
146
|
+
# the referenced schema or interfaced declaration is not unresolved.
|
|
147
|
+
def resolved_note?(document, note)
|
|
148
|
+
return false unless note.id == :check_unresolved_ref
|
|
149
|
+
return false unless note.node.is_a?(Model::References::SimpleReference)
|
|
150
|
+
|
|
151
|
+
!resolve_reference(document, note.node).nil? ||
|
|
152
|
+
!schema_by_id(note.node.id).nil?
|
|
153
|
+
rescue StandardError
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def parse_diagnostic(document)
|
|
158
|
+
error = document.parse_error
|
|
159
|
+
message = error.message.to_s
|
|
160
|
+
line = message[/line (\d+)/, 1].to_i
|
|
161
|
+
character = message[/char (\d+)/, 1].to_i
|
|
162
|
+
line = 1 if line.zero?
|
|
163
|
+
character = 1 if character.zero?
|
|
164
|
+
start = { "line" => line - 1, "character" => character - 1 }
|
|
165
|
+
{
|
|
166
|
+
"range" => { "start" => start,
|
|
167
|
+
"end" => start.merge("character" => start["character"] + 1) },
|
|
168
|
+
"severity" => 1,
|
|
169
|
+
"source" => "expressir",
|
|
170
|
+
"message" => message.lines.first.to_s.strip,
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def target_at(uri, position)
|
|
175
|
+
document = @documents[uri]
|
|
176
|
+
return nil unless document&.file
|
|
177
|
+
|
|
178
|
+
token = document.token_at(position)
|
|
179
|
+
return nil unless token
|
|
180
|
+
|
|
181
|
+
declaration = document.declaration_nodes.find do |node|
|
|
182
|
+
document.name_offset(node) == token.start_byte &&
|
|
183
|
+
node.id.to_s.casecmp?(token.text)
|
|
184
|
+
end
|
|
185
|
+
return { doc: document, node: declaration, token: token } if declaration
|
|
186
|
+
|
|
187
|
+
reference = document.reference_nodes.find do |node|
|
|
188
|
+
node.source_offset == token.start_byte
|
|
189
|
+
end
|
|
190
|
+
return nil unless reference
|
|
191
|
+
|
|
192
|
+
resolved = resolve_reference(document, reference)
|
|
193
|
+
resolved && { doc: resolved[:doc], node: resolved[:node], token: token }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def resolve_reference(document, node)
|
|
197
|
+
schema = schema_of(node)
|
|
198
|
+
return nil unless schema
|
|
199
|
+
|
|
200
|
+
local = node.find(node.id)
|
|
201
|
+
if local
|
|
202
|
+
target_doc = document_for_node(local) || document
|
|
203
|
+
return { doc: target_doc, node: local }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
resolve_interface(schema, node.id)
|
|
207
|
+
rescue StandardError
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def resolve_interface(schema, name)
|
|
212
|
+
Array(schema.interfaces).each do |interface|
|
|
213
|
+
interface_schema = schema_by_id(interface.schema&.id)
|
|
214
|
+
next unless interface_schema
|
|
215
|
+
|
|
216
|
+
items = Array(interface.items)
|
|
217
|
+
if items.empty?
|
|
218
|
+
node = interface_schema[:schema].find(name)
|
|
219
|
+
return { doc: interface_schema[:doc], node: node } if node
|
|
220
|
+
else
|
|
221
|
+
items.each do |item|
|
|
222
|
+
local_name = item.id || item.ref&.id
|
|
223
|
+
next unless local_name.to_s.casecmp?(name.to_s)
|
|
224
|
+
|
|
225
|
+
node = interface_schema[:schema].find(item.ref&.id)
|
|
226
|
+
return { doc: interface_schema[:doc], node: node } if node
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def schema_by_id(id)
|
|
234
|
+
return nil unless id
|
|
235
|
+
|
|
236
|
+
@documents.each_value do |document|
|
|
237
|
+
document.schemas.each do |schema|
|
|
238
|
+
next unless schema.id.to_s.casecmp?(id.to_s)
|
|
239
|
+
|
|
240
|
+
return { doc: document, schema: schema }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def document_for_node(node)
|
|
247
|
+
schema = schema_of(node)
|
|
248
|
+
return nil unless schema
|
|
249
|
+
|
|
250
|
+
@documents.each_value do |document|
|
|
251
|
+
return document if document.schemas.any? { |candidate| candidate.equal?(schema) }
|
|
252
|
+
end
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def schema_of(node)
|
|
257
|
+
current = node
|
|
258
|
+
current = current.parent while current && !current.is_a?(Model::Declarations::Schema)
|
|
259
|
+
current
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def location(document, node)
|
|
263
|
+
{ "uri" => document.uri, "range" => document.range_for(node) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def reference_locations(declaration)
|
|
267
|
+
declaring_schema = schema_of(declaration)
|
|
268
|
+
@documents.each_value.flat_map do |document|
|
|
269
|
+
document.reference_nodes.filter_map do |reference|
|
|
270
|
+
resolved = resolve_reference(document, reference)
|
|
271
|
+
next unless resolved && same_declaration?(resolved[:node], declaration)
|
|
272
|
+
|
|
273
|
+
location(document, reference)
|
|
274
|
+
end + interface_locations(document, declaring_schema, declaration)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def interface_locations(document, declaring_schema, declaration)
|
|
279
|
+
document.schemas.flat_map do |schema|
|
|
280
|
+
Array(schema.interfaces).flat_map do |interface|
|
|
281
|
+
next [] unless interface.schema&.id.to_s.casecmp?(declaring_schema.id.to_s)
|
|
282
|
+
|
|
283
|
+
Array(interface.items).filter_map do |item|
|
|
284
|
+
next unless item.ref&.id.to_s.casecmp?(declaration.id.to_s)
|
|
285
|
+
|
|
286
|
+
location(document, item.ref)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def same_declaration?(left, right)
|
|
293
|
+
left.path.to_s.casecmp?(right.path.to_s)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def rename_edits(declaring_document, declaration, new_name)
|
|
297
|
+
edits = {}
|
|
298
|
+
add_edit(edits, declaring_document, declaration, new_name)
|
|
299
|
+
|
|
300
|
+
@documents.each_value do |document|
|
|
301
|
+
document.schemas.each do |schema|
|
|
302
|
+
rename_references(document, schema, declaration, new_name, edits)
|
|
303
|
+
rename_interface_items(document, schema, declaration, new_name, edits)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
edits
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def rename_references(document, schema, declaration, new_name, edits)
|
|
310
|
+
document.reference_nodes.each do |reference|
|
|
311
|
+
next unless schema_of(reference).equal?(schema)
|
|
312
|
+
next unless reference.id.to_s.casecmp?(declaration.id.to_s)
|
|
313
|
+
|
|
314
|
+
resolved = resolve_reference(document, reference)
|
|
315
|
+
next unless resolved && same_declaration?(resolved[:node], declaration)
|
|
316
|
+
|
|
317
|
+
add_edit(edits, document, reference, new_name)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def rename_interface_items(document, schema, declaration, new_name, edits)
|
|
322
|
+
Array(schema.interfaces).each do |interface|
|
|
323
|
+
next unless interface.schema&.id.to_s.casecmp?(schema_of(declaration).id.to_s)
|
|
324
|
+
|
|
325
|
+
Array(interface.items).each do |item|
|
|
326
|
+
next unless item.ref&.id.to_s.casecmp?(declaration.id.to_s)
|
|
327
|
+
|
|
328
|
+
add_edit(edits, document, item.ref, new_name)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def add_edit(edits, document, node, new_name)
|
|
334
|
+
range = document.range_for(node)
|
|
335
|
+
list = (edits[document.uri] ||= [])
|
|
336
|
+
return if list.any? { |edit| edit["range"] == range }
|
|
337
|
+
|
|
338
|
+
list << { "range" => range, "newText" => new_name }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def verify_rename!(edits, declaration, new_name)
|
|
342
|
+
declaring_schema = schema_of(declaration)
|
|
343
|
+
edits.each do |uri, document_edits|
|
|
344
|
+
document = @documents[uri]
|
|
345
|
+
text = apply_edits(document.text, document_edits)
|
|
346
|
+
probe = Document.new(uri: uri, text: text, path: document.path)
|
|
347
|
+
raise "rename produced invalid EXPRESS in #{uri}" if probe.parse_error
|
|
348
|
+
|
|
349
|
+
verify_schema_after_rename(document, probe, declaring_schema,
|
|
350
|
+
declaration, new_name, uri)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# The renamed declaration may disappear under its old path; every
|
|
355
|
+
# other removal is a structure loss and aborts the rename.
|
|
356
|
+
def verify_schema_after_rename(document, probe, declaring_schema,
|
|
357
|
+
declaration, _new_name, uri)
|
|
358
|
+
index = document.schemas.index(declaring_schema)
|
|
359
|
+
return unless index
|
|
360
|
+
|
|
361
|
+
probe_schema = probe.schemas[index]
|
|
362
|
+
return unless probe_schema
|
|
363
|
+
|
|
364
|
+
old_path = "#{declaring_schema.id}.#{declaration.id}".downcase
|
|
365
|
+
unexpected = Express::SchemaCompare
|
|
366
|
+
.compare(declaring_schema, probe_schema)
|
|
367
|
+
.select do |delta|
|
|
368
|
+
delta.op == :removed && delta.path.to_s.downcase != old_path
|
|
369
|
+
end
|
|
370
|
+
raise "rename removed unrelated declarations in #{uri}" if unexpected.any?
|
|
371
|
+
|
|
372
|
+
formatted = Express::Formatter.format(probe_schema)
|
|
373
|
+
formatted_probe = Document.new(uri: uri, text: formatted,
|
|
374
|
+
path: document.path)
|
|
375
|
+
raise "renamed schema does not format in #{uri}" if formatted_probe.parse_error
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def apply_edits(text, edits)
|
|
379
|
+
scratch = Document.new(uri: "file:///nonexistent", text: text)
|
|
380
|
+
source = text.b
|
|
381
|
+
offsets = edits.map do |edit|
|
|
382
|
+
start_byte = scratch.byte_offset(edit["range"]["start"]["line"],
|
|
383
|
+
edit["range"]["start"]["character"])
|
|
384
|
+
end_byte = scratch.byte_offset(edit["range"]["end"]["line"],
|
|
385
|
+
edit["range"]["end"]["character"])
|
|
386
|
+
[start_byte, end_byte, edit["newText"].b]
|
|
387
|
+
end
|
|
388
|
+
offsets.sort_by { |start_byte, _, _| -start_byte }.each do |start_byte, end_byte, replacement|
|
|
389
|
+
source[start_byte...end_byte] = replacement
|
|
390
|
+
end
|
|
391
|
+
source.force_encoding(Encoding::UTF_8)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def dedupe_locations(locations)
|
|
395
|
+
locations.uniq do |location|
|
|
396
|
+
[location["uri"], location["range"]["start"]["line"],
|
|
397
|
+
location["range"]["start"]["character"]]
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
# Language Server Protocol support: stdio JSON-RPC transport, open
|
|
5
|
+
# documents, and the workspace view that resolves across schemas.
|
|
6
|
+
module Lsp
|
|
7
|
+
autoload :Document, "#{__dir__}/lsp/document"
|
|
8
|
+
autoload :Workspace, "#{__dir__}/lsp/workspace"
|
|
9
|
+
autoload :Server, "#{__dir__}/lsp/server"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -24,10 +24,16 @@ module Expressir
|
|
|
24
24
|
# is referenced)
|
|
25
25
|
# index := "[" ("i" | DIGITS) "]"
|
|
26
26
|
# constraint := ["!"] "{" NAME [NAME "."] "=" STRING "}"
|
|
27
|
+
# supertype := "||" NAME "||" (§5 supertype entity marker)
|
|
28
|
+
# tree := "*" NAME "*" (§5 relationship tree marker)
|
|
27
29
|
# MARKER := "{" | "}" | "[" | "]" | "(" | ")" | ":" | "|" | "!"
|
|
28
30
|
# | "," | "/" | "*" | "<" | ">" (grouping/annotation;
|
|
29
31
|
# never breaks a
|
|
30
|
-
# pending link
|
|
32
|
+
# pending link; the §5
|
|
33
|
+
# required-path marker
|
|
34
|
+
# "<path>" is transparent
|
|
35
|
+
# — its contents validate
|
|
36
|
+
# in the running context)
|
|
31
37
|
#
|
|
32
38
|
# Validation (ELF 5006 §8): a running path context tracks the
|
|
33
39
|
# entity a path is "at"; each step must connect to it, attribute
|
|
@@ -104,7 +110,7 @@ module Expressir
|
|
|
104
110
|
EXPRESS_LINK_PATH = /\A<<express:([^,>]+)(?:,[^>]+)?>>\z/
|
|
105
111
|
private_constant :EXPRESS_LINK_PATH
|
|
106
112
|
|
|
107
|
-
TOKEN = /<<express:[^,>]+(?:,[^>]+)
|
|
113
|
+
TOKEN = /<<express:[^,>]+(?:,[^>]+)?>>|->|<-|<=|=>|\*>|<\*|\|\||!?\{|[{}\[\]()=:|!,*\/<>]|'(?:''|[^'])*'|\w+(?:\.\w+)?/
|
|
108
114
|
MARKERS = ["{", "}", "[", "]", "(", ")", ":", "|", "!", ",", "/",
|
|
109
115
|
"*", "<", ">", "MAPPING_OF", "!{"].freeze
|
|
110
116
|
LINKS = ["->", "<-", "<=", "=>", "*>", "<*"].freeze
|
|
@@ -140,13 +146,19 @@ module Expressir
|
|
|
140
146
|
errors = []
|
|
141
147
|
pending_link = nil
|
|
142
148
|
pending_type_assign = false
|
|
143
|
-
false
|
|
144
149
|
i = 0
|
|
145
150
|
while i < tokens.length
|
|
146
151
|
token = tokens[i]
|
|
147
152
|
case token
|
|
153
|
+
when "||"
|
|
154
|
+
name_token = tokens[i + 1]
|
|
155
|
+
if name_token&.match?(/\A\w+\z/) && tokens[i + 2] == "||"
|
|
156
|
+
steps << Step.new(operator: "||", name: name_token)
|
|
157
|
+
i += 2
|
|
158
|
+
else
|
|
159
|
+
errors << "supertype marker '||' without an entity name"
|
|
160
|
+
end
|
|
148
161
|
when "!{"
|
|
149
|
-
true
|
|
150
162
|
step, consumed = parse_constraint(tokens[i..], negated: true)
|
|
151
163
|
steps << step
|
|
152
164
|
i += consumed
|
|
@@ -182,6 +194,14 @@ module Expressir
|
|
|
182
194
|
else
|
|
183
195
|
steps << Step.new(operator: "[")
|
|
184
196
|
end
|
|
197
|
+
when "*"
|
|
198
|
+
name_token = tokens[i + 1]
|
|
199
|
+
if name_token&.match?(/\A\w+\z/) && tokens[i + 2] == "*"
|
|
200
|
+
steps << Step.new(operator: "*", name: name_token)
|
|
201
|
+
i += 2
|
|
202
|
+
else
|
|
203
|
+
steps << Step.new(operator: token)
|
|
204
|
+
end
|
|
185
205
|
when *MARKERS
|
|
186
206
|
steps << Step.new(operator: token)
|
|
187
207
|
when /\A<<express:/
|
|
@@ -302,6 +322,14 @@ module Expressir
|
|
|
302
322
|
when "*>", "<*"
|
|
303
323
|
issues.concat(select_extension_issues(step, pos, ctx, by_name))
|
|
304
324
|
ctx = step.name&.safe_downcase
|
|
325
|
+
when "||"
|
|
326
|
+
issues.concat(supertype_marker_issues(step, pos, ctx, by_name))
|
|
327
|
+
if step.name
|
|
328
|
+
seen[step.name.safe_downcase] = true
|
|
329
|
+
ctx = step.name.safe_downcase
|
|
330
|
+
end
|
|
331
|
+
when "*"
|
|
332
|
+
issues.concat(tree_marker_issues(step, pos, by_name))
|
|
305
333
|
else
|
|
306
334
|
issues.concat(check_step(step, pos, ctx, last_qualified, by_name))
|
|
307
335
|
if step.name
|
|
@@ -495,6 +523,39 @@ module Expressir
|
|
|
495
523
|
end
|
|
496
524
|
end
|
|
497
525
|
|
|
526
|
+
# ELF 5006 §5 supertype entity marker: `||supertype||` names an
|
|
527
|
+
# entity that must exist and, when the path has a running
|
|
528
|
+
# context, be a supertype of (or equal to) that context entity.
|
|
529
|
+
def supertype_marker_issues(step, pos, ctx, by_name)
|
|
530
|
+
issues = []
|
|
531
|
+
entity = step.name
|
|
532
|
+
check_exists(issues, pos, entity, by_name)
|
|
533
|
+
return issues if entity.nil?
|
|
534
|
+
|
|
535
|
+
decl = by_name[entity.safe_downcase]&.last
|
|
536
|
+
return issues unless decl.is_a?(Model::Declarations::Entity)
|
|
537
|
+
|
|
538
|
+
context_decl = ctx && by_name[ctx]&.last
|
|
539
|
+
if context_decl.is_a?(Model::Declarations::Entity) &&
|
|
540
|
+
!entity_reaches?(context_decl, entity.safe_downcase, by_name,
|
|
541
|
+
{}.compare_by_identity)
|
|
542
|
+
issues << Issue.new(step: pos,
|
|
543
|
+
message: "'#{entity}' is not a supertype of " \
|
|
544
|
+
"'#{ctx}'")
|
|
545
|
+
end
|
|
546
|
+
issues
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# ELF 5006 §5 relationship tree marker: `*relationship*` names an
|
|
550
|
+
# entity whose instances may assemble into a tree; the path
|
|
551
|
+
# between it and the related entities is implied, so only the
|
|
552
|
+
# entity's existence is checkable.
|
|
553
|
+
def tree_marker_issues(step, pos, by_name)
|
|
554
|
+
issues = []
|
|
555
|
+
check_exists(issues, pos, step.name, by_name)
|
|
556
|
+
issues
|
|
557
|
+
end
|
|
558
|
+
|
|
498
559
|
# ELF 5006 §8 navigation validation with a running context: the
|
|
499
560
|
# attribute must exist on the context entity, and its type must
|
|
500
561
|
# be able to reference the step's target entity.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Expressir
|
|
6
|
+
module Reference
|
|
7
|
+
# Rule-by-rule cross-walk between the parsanol grammar and the
|
|
8
|
+
# ISO 10303-11 Annex A/B productions. The mapping data is generated
|
|
9
|
+
# by `rake reference:crosswalk`; the spec fails when a grammar rule
|
|
10
|
+
# has no row or the rendered guide is stale.
|
|
11
|
+
class GrammarCrosswalk
|
|
12
|
+
DATA_PATH = File.expand_path("grammar_crosswalk.yml", __dir__)
|
|
13
|
+
GRAMMAR_RULE = /rule\(:(\w+)/
|
|
14
|
+
PRODUCTION_STATUSES = %w[aligned decomposed].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def data
|
|
18
|
+
@data ||= YAML.load_file(DATA_PATH)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def grammar_path
|
|
22
|
+
Expressir.root_path.join(data.dig("source", "grammar"))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def grammar_rules
|
|
26
|
+
File.read(grammar_path).scan(GRAMMAR_RULE).flatten.uniq.sort
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def rows
|
|
30
|
+
data["rules"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def annex_without_rule
|
|
34
|
+
data["annex_without_rule"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def statuses
|
|
38
|
+
data["statuses"]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the list of inconsistencies; empty means in sync.
|
|
42
|
+
def validate
|
|
43
|
+
errors = []
|
|
44
|
+
declared = rows.map { |row| row["rule"] }
|
|
45
|
+
missing = grammar_rules - declared
|
|
46
|
+
unknown = declared - grammar_rules
|
|
47
|
+
duplicates = declared.tally.select { |_, count| count > 1 }.keys
|
|
48
|
+
errors << "grammar rules without a cross-walk row: #{missing.join(', ')}" if missing.any?
|
|
49
|
+
errors << "cross-walk rows without a grammar rule: #{unknown.join(', ')}" if unknown.any?
|
|
50
|
+
errors << "duplicate cross-walk rows: #{duplicates.join(', ')}" if duplicates.any?
|
|
51
|
+
|
|
52
|
+
rows.each do |row|
|
|
53
|
+
status = row["status"]
|
|
54
|
+
unless statuses.key?(status)
|
|
55
|
+
errors << "#{row['rule']}: unknown status '#{status}'"
|
|
56
|
+
end
|
|
57
|
+
if PRODUCTION_STATUSES.include?(status) && !row["production"]
|
|
58
|
+
errors << "#{row['rule']}: #{status} row needs a production"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
errors
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def render_adoc
|
|
65
|
+
lines = [
|
|
66
|
+
"= Grammar cross-walk",
|
|
67
|
+
"",
|
|
68
|
+
"// Generated by `rake reference:crosswalk` from",
|
|
69
|
+
"// #{data.dig('source', 'grammar')} and the Annex A snapshot.",
|
|
70
|
+
"// Do not edit by hand; the reference spec fails when this file is stale.",
|
|
71
|
+
"",
|
|
72
|
+
"== Purpose",
|
|
73
|
+
"",
|
|
74
|
+
"Every parsanol grammar rule is mapped to the ISO 10303-11:2004",
|
|
75
|
+
"Annex A production it implements. Divergences from the standard",
|
|
76
|
+
"grammar are classified with one of these statuses:",
|
|
77
|
+
"",
|
|
78
|
+
*statuses.map { |name, description| "* `#{name}` — #{description}" },
|
|
79
|
+
"",
|
|
80
|
+
"== Rules",
|
|
81
|
+
"",
|
|
82
|
+
'[cols="2,3,1,4", options="header"]',
|
|
83
|
+
"|===",
|
|
84
|
+
"| Parsanol rule | Annex A production | Status | Note",
|
|
85
|
+
"",
|
|
86
|
+
*rows.map { |row| rule_row(row) },
|
|
87
|
+
"|===",
|
|
88
|
+
"",
|
|
89
|
+
"== Annex A productions without a dedicated rule",
|
|
90
|
+
"",
|
|
91
|
+
"These productions are handled by supporting code rather than a",
|
|
92
|
+
"grammar rule:",
|
|
93
|
+
"",
|
|
94
|
+
'[cols="3,1,5", options="header"]',
|
|
95
|
+
"|===",
|
|
96
|
+
"| Annex A production | Status | Handled by",
|
|
97
|
+
"",
|
|
98
|
+
*annex_without_rule.map { |row| annex_row(row) },
|
|
99
|
+
"|===",
|
|
100
|
+
"",
|
|
101
|
+
]
|
|
102
|
+
lines.join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def rule_row(row)
|
|
108
|
+
note = row["literal"] ? "literal `#{row['literal']}`" : row["note"]
|
|
109
|
+
"| `#{row['rule']}` | #{cell(row['production'])} | #{row['status']} | #{cell(note)}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def annex_row(row)
|
|
113
|
+
"| `#{row['production']}` | #{row['status']} | #{cell(row['note'])}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def cell(text)
|
|
117
|
+
text.to_s.gsub("|", "\\|")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|