expressir 2.4.35-arm-linux → 2.4.36-arm-linux
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/lib/expressir/3.2/expressir_core.so +0 -0
- data/lib/expressir/3.3/expressir_core.so +0 -0
- data/lib/expressir/3.4/expressir_core.so +0 -0
- data/lib/expressir/4.0/expressir_core.so +0 -0
- 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/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
|
|
@@ -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
|