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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/conformance.yml +34 -0
  3. data/.gitignore +3 -0
  4. data/Gemfile +1 -1
  5. data/TODO.sota/00-overview.md +57 -0
  6. data/TODO.sota/01-type-checking.md +115 -0
  7. data/TODO.sota/02-evaluator.md +86 -0
  8. data/TODO.sota/03-conformance.md +53 -0
  9. data/TODO.sota/04-lsp.md +70 -0
  10. data/TODO.sota/05-interop.md +55 -0
  11. data/TODO.sota/06-dialects.md +57 -0
  12. data/TODO.sota/07-reference-artifacts.md +55 -0
  13. data/docs/_guides/interop/index.adoc +25 -0
  14. data/docs/_guides/interop/json-schema.adoc +88 -0
  15. data/docs/_guides/interop/owl-rdf.adoc +87 -0
  16. data/docs/_guides/reference/checker-notes.adoc +55 -0
  17. data/docs/_guides/reference/checker.adoc +36 -0
  18. data/docs/_guides/reference/evaluator.adoc +40 -0
  19. data/docs/_guides/reference/grammar-crosswalk.adoc +299 -0
  20. data/docs/_guides/reference/index.adoc +22 -0
  21. data/docs/_guides/reference/lsp.adoc +41 -0
  22. data/docs/_guides/reference/p21.adoc +30 -0
  23. data/docs/_guides/reference/shtolo.adoc +36 -0
  24. data/exe/expressir-lsp +6 -0
  25. data/expressir.gemspec +2 -2
  26. data/ext/expressir_core/Cargo.toml +1 -1
  27. data/lib/expressir/cli.rb +18 -0
  28. data/lib/expressir/commands/check.rb +3 -1
  29. data/lib/expressir/commands/export_json_schema.rb +29 -0
  30. data/lib/expressir/commands/export_owl.rb +26 -0
  31. data/lib/expressir/commands/validate.rb +3 -0
  32. data/lib/expressir/commands.rb +2 -0
  33. data/lib/expressir/conformance/trend.rb +100 -0
  34. data/lib/expressir/conformance.rb +8 -0
  35. data/lib/expressir/eval/environment.rb +83 -0
  36. data/lib/expressir/eval/expression.rb +610 -0
  37. data/lib/expressir/eval/interpreter.rb +316 -0
  38. data/lib/expressir/eval/rules.rb +88 -0
  39. data/lib/expressir/eval/values.rb +74 -0
  40. data/lib/expressir/eval.rb +17 -0
  41. data/lib/expressir/express/builders/expression_builder.rb +9 -1
  42. data/lib/expressir/express/checker.rb +779 -2
  43. data/lib/expressir/express/dialect.rb +44 -0
  44. data/lib/expressir/express/formatters/expressions_formatter.rb +4 -1
  45. data/lib/expressir/express/parser.rb +9 -4
  46. data/lib/expressir/express.rb +1 -0
  47. data/lib/expressir/interop/json_schema.rb +195 -0
  48. data/lib/expressir/interop/owl.rb +216 -0
  49. data/lib/expressir/interop.rb +13 -0
  50. data/lib/expressir/lsp/document.rb +226 -0
  51. data/lib/expressir/lsp/server.rb +184 -0
  52. data/lib/expressir/lsp/workspace.rb +402 -0
  53. data/lib/expressir/lsp.rb +11 -0
  54. data/lib/expressir/mapping/refpath.rb +65 -4
  55. data/lib/expressir/reference/grammar_crosswalk.rb +122 -0
  56. data/lib/expressir/reference/grammar_crosswalk.yml +800 -0
  57. data/lib/expressir/reference/note_catalog.rb +54 -0
  58. data/lib/expressir/reference.rb +10 -0
  59. data/lib/expressir/version.rb +1 -1
  60. data/lib/expressir.rb +4 -0
  61. data/lib/tasks/conformance.rake +27 -0
  62. data/lib/tasks/reference.rake +25 -0
  63. 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:[^,>]+(?:,[^>]+)?>>|->|<-|<=|=>|\*>|<\*|!?\{|[{}\[\]()=:|!,*\/<>]|'(?:''|[^'])*'|\w+(?:\.\w+)?/
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