expressir 2.4.2 → 2.4.4

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.cargo/config.toml +3 -0
  3. data/.github/workflows/rust-ext.yml +41 -0
  4. data/.github/workflows/verify_remarks.yml +33 -0
  5. data/.gitignore +6 -0
  6. data/.rubocop.yml +4 -0
  7. data/.rubocop_todo.yml +1 -0
  8. data/Cargo.toml +7 -0
  9. data/README.adoc +65 -0
  10. data/Rakefile +12 -0
  11. data/TODO.max-perf/01-restore-ci-green.md +8 -1
  12. data/TODO.parity-ee/00-overview.md +58 -0
  13. data/TODO.parity-ee/01-annex-g-rule-extraction.md +41 -0
  14. data/TODO.parity-ee/02-eeng-algorithm-inventory.md +8 -0
  15. data/TODO.parity-ee/03-eeng-oracle-harness.md +33 -0
  16. data/TODO.parity-ee/04-shtolo-concatenate.md +44 -0
  17. data/TODO.parity-ee/05-shtolo-longform-flatten.md +53 -0
  18. data/TODO.parity-ee/06-interface-scheduling-parity.md +47 -0
  19. data/TODO.parity-ee/07-semantic-checks-port.md +35 -0
  20. data/TODO.parity-ee/08-pretty-roundtrip-gate.md +27 -0
  21. data/TODO.parity-ee/09-smrl-index-and-listing.md +22 -0
  22. data/TODO.parity-ee/10-interface-dot-graph.md +21 -0
  23. data/TODO.parity-ee/11-import-eeng-tests.md +79 -0
  24. data/TODO.parity-ee/parity-matrix.md +108 -0
  25. data/exe/expressir +6 -0
  26. data/ext/expressir_core/Cargo.toml +24 -0
  27. data/ext/expressir_core/extconf.rb +30 -0
  28. data/ext/expressir_core/src/lib.rs +446 -0
  29. data/lib/expressir/cli.rb +49 -4
  30. data/lib/expressir/commands/check.rb +56 -0
  31. data/lib/expressir/commands/expand.rb +26 -0
  32. data/lib/expressir/commands/fix.rb +33 -0
  33. data/lib/expressir/commands/flatten.rb +29 -0
  34. data/lib/expressir/commands/parity_inputs.rb +85 -0
  35. data/lib/expressir/commands/validate.rb +22 -0
  36. data/lib/expressir/commands.rb +5 -0
  37. data/lib/expressir/express/builder_registry.rb +11 -2
  38. data/lib/expressir/express/builders/entity_decl_builder.rb +17 -7
  39. data/lib/expressir/express/builders/subtype_constraint_builder.rb +3 -1
  40. data/lib/expressir/express/builders/type_builder.rb +8 -0
  41. data/lib/expressir/express/builders/unique_rule_builder.rb +8 -1
  42. data/lib/expressir/express/checker.rb +458 -0
  43. data/lib/expressir/express/concatenator.rb +79 -0
  44. data/lib/expressir/express/core.rb +66 -0
  45. data/lib/expressir/express/formatter.rb +25 -3
  46. data/lib/expressir/express/formatters/data_types_formatter.rb +2 -2
  47. data/lib/expressir/express/formatters/declarations_formatter.rb +1 -0
  48. data/lib/expressir/express/formatters/remark_formatter.rb +30 -6
  49. data/lib/expressir/express/formatters/statements_formatter.rb +7 -1
  50. data/lib/expressir/express/interface_dot.rb +105 -0
  51. data/lib/expressir/express/listing.rb +174 -0
  52. data/lib/expressir/express/model_traversal.rb +42 -0
  53. data/lib/expressir/express/node_position_index.rb +148 -21
  54. data/lib/expressir/express/parser.rb +242 -4
  55. data/lib/expressir/express/pretty_gate.rb +61 -0
  56. data/lib/expressir/express/refs_overlay.rb +47 -0
  57. data/lib/expressir/express/remark_attacher.rb +130 -22
  58. data/lib/expressir/express/remark_overlay.rb +86 -0
  59. data/lib/expressir/express/schema_plain_source_formatter.rb +11 -0
  60. data/lib/expressir/express/schema_source_formatter.rb +5 -5
  61. data/lib/expressir/express/self_schema_reference.rb +126 -0
  62. data/lib/expressir/express/shtolo.rb +522 -0
  63. data/lib/expressir/express.rb +13 -0
  64. data/lib/expressir/model/concerns.rb +15 -1
  65. data/lib/expressir/model/declarations/entity.rb +6 -1
  66. data/lib/expressir/model/declarations/interface.rb +13 -0
  67. data/lib/expressir/model/declarations/schema.rb +74 -23
  68. data/lib/expressir/model/indexes/item_graph.rb +135 -0
  69. data/lib/expressir/model/indexes.rb +1 -0
  70. data/lib/expressir/model/model_element.rb +30 -1
  71. data/lib/expressir/model/remark_info.rb +20 -1
  72. data/lib/expressir/model/remark_placement.rb +15 -2
  73. data/lib/expressir/model/repository.rb +8 -2
  74. data/lib/expressir/model.rb +2 -1
  75. data/lib/expressir/version.rb +1 -1
  76. data/lib/expressir.rb +3 -19
  77. data/lib/tasks/verify_remarks.rake +9 -0
  78. data/rakelib/verify_remarks.rake +16 -0
  79. metadata +42 -2
@@ -125,7 +125,7 @@ module Expressir
125
125
 
126
126
  # For scope containers: exclude last remark ONLY if it's a tail remark (END_* remark)
127
127
  # Keep all embedded remarks
128
- if is_scope_container && preamble_remarks.length > 1
128
+ if is_scope_container && preamble_remarks.length >= 1
129
129
  last_remark = preamble_remarks.last
130
130
  # Only exclude if it's a tail remark
131
131
  preamble_remarks = preamble_remarks[0..-2] if last_remark.tail?
@@ -154,15 +154,39 @@ module Expressir
154
154
  end
155
155
  end
156
156
 
157
- # Remarks written after their statement on the same line. Attachment
158
- # only assigns these to single-line statements, so appending keeps
159
- # them on that statement's line.
157
+ # Remarks written after their statement on the same line, which for a
158
+ # single-line statement is where appending puts them back. Opener
159
+ # remarks are excluded: those belong on the first line rather than
160
+ # after the closing keyword, and {#format_opener_remarks} emits them.
161
+ # Mid-keyword remarks (ELSE / OTHERWISE regions) are excluded too:
162
+ # {#format_mid_keyword_remarks} emits them after their keyword.
160
163
  def format_inline_statement_remarks(node)
164
+ formatted_inline_remarks(node) { |remark| remark.region.nil? }
165
+ end
166
+
167
+ # Remarks that trailed the opening line of a statement spanning
168
+ # several lines, as in `IF x THEN -- why`. Where they are written back
169
+ # is Formatter#format's business, not this method's.
170
+ def format_opener_remarks(node)
171
+ formatted_inline_remarks(node, &:opener?)
172
+ end
173
+
174
+ # Remarks that trailed a mid-construct keyword of `node` — the ELSE
175
+ # line of an IF, the OTHERWISE line of a CASE — emitted after that
176
+ # keyword, where they were written.
177
+ def format_mid_keyword_remarks(node, region)
178
+ formatted_inline_remarks(node) { |remark| remark.inline_region?(region) }
179
+ end
180
+
181
+ # The inline remarks of a node that the given block accepts, formatted
182
+ # and joined. The callers partition the same collection, so every
183
+ # inline remark is emitted by exactly one of them.
184
+ def formatted_inline_remarks(node, &)
161
185
  return "" if @no_remarks
162
- return "" unless node.is_a?(Model::Statement)
186
+ return "" unless node.is_a?(Model::TakesInlineRemark)
163
187
 
164
188
  Array(node.untagged_remarks).filter_map do |remark|
165
- next unless remark.inline?
189
+ next unless remark.inline? && yield(remark)
166
190
 
167
191
  formatted = format_untagged_remark(remark)
168
192
  formatted unless formatted.empty?
@@ -98,6 +98,8 @@ module Expressir
98
98
  "OTHERWISE",
99
99
  " ",
100
100
  ":",
101
+ format_mid_keyword_remarks(node,
102
+ Model::RemarkPlacement::OTHERWISE_REGION),
101
103
  ].join,
102
104
  indent(format(node.otherwise_statement)),
103
105
  ]
@@ -167,7 +169,11 @@ module Expressir
167
169
  *(then_trailing if has_else),
168
170
  *if has_else
169
171
  [
170
- "ELSE",
172
+ [
173
+ "ELSE",
174
+ format_mid_keyword_remarks(node,
175
+ Model::RemarkPlacement::ELSE_REGION),
176
+ ].join,
171
177
  indent(node.else_statements.map { |x| format(x) }.join("\n")),
172
178
  ].join("\n")
173
179
  end,
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Interface dependency GraphViz emitter — port of eeng
6
+ # kernel/dot-graph.lisp `:interface` graph (parity-ee stage 10).
7
+ #
8
+ # Nodes are schema names (lowercase); edges are USE FROM (default
9
+ # blue) and REFERENCE FROM (default green). An edge with an item
10
+ # list uses arrowhead=curve; a bare interface uses arrowhead=normal.
11
+ class InterfaceDot
12
+ def initialize(root_schema, repository,
13
+ iface: :both, uf_color: :blue, rf_color: :green,
14
+ depth: nil, prune: [])
15
+ @root = root_schema
16
+ @repository = repository
17
+ @iface = iface
18
+ @uf_color = uf_color
19
+ @rf_color = rf_color
20
+ @depth = depth
21
+ @prune = Array(prune).map { |n| n.to_s.downcase }
22
+ @by_name = repository.schemas.compact.to_h { |s| [s.id.safe_downcase, s] }
23
+ end
24
+
25
+ def write(io = nil)
26
+ owned = io.nil?
27
+ io ||= +""
28
+ schemas, edges = collect
29
+ io << "// -*- Mode: Dot -*-\n\n"
30
+ io << "// Dot File Written by Expressir\n"
31
+ io << "// #{Expressir::VERSION}\n"
32
+ io << "\ndigraph interfaces {\n"
33
+ io << " node [shape=none];\n"
34
+ if %i[both use].include?(@iface)
35
+ io << " edge [color=#{@uf_color}];\n"
36
+ edges.select { |e| e[:kind] == :use }.each do |e|
37
+ head = e[:items] ? "curve" : "normal"
38
+ io << " #{e[:from]} -> #{e[:to]} [arrowhead=#{head}];\n"
39
+ end
40
+ end
41
+ if %i[both ref].include?(@iface)
42
+ io << " edge [color=#{@rf_color}];\n"
43
+ edges.select { |e| e[:kind] == :ref }.each do |e|
44
+ head = e[:items] ? "curve" : "normal"
45
+ io << " #{e[:from]} -> #{e[:to]} [arrowhead=#{head}];\n"
46
+ end
47
+ end
48
+ # Ensure isolated schemas still appear as nodes.
49
+ named = edges.flat_map { |e| [e[:from], e[:to]] }.uniq
50
+ (schemas.map { |s| s.id.downcase } - named).each do |n|
51
+ io << " #{n};\n"
52
+ end
53
+ io << "}\n"
54
+ owned ? io : nil
55
+ end
56
+
57
+ private
58
+
59
+ def collect
60
+ visited = {}
61
+ edges = []
62
+ queue = [[@root, 0]]
63
+ until queue.empty?
64
+ schema, level = queue.shift
65
+ key = schema.id.safe_downcase
66
+ next if visited.key?(key)
67
+ next if @prune.include?(key)
68
+
69
+ visited[key] = schema
70
+
71
+ Array(schema.interfaces).each do |iface|
72
+ target_name = iface_schema_name(iface)
73
+ next unless target_name
74
+
75
+ tkey = target_name.safe_downcase
76
+ next if @prune.include?(tkey)
77
+ next if tkey == key
78
+
79
+ kind = iface.kind == Model::Declarations::Interface::REFERENCE ? :ref : :use
80
+ next if @iface == :use && kind != :use
81
+ next if @iface == :ref && kind != :ref
82
+
83
+ edges << {
84
+ kind: kind,
85
+ items: Array(iface.items).any?,
86
+ from: key,
87
+ to: tkey,
88
+ }
89
+
90
+ target = @by_name[tkey]
91
+ next unless target
92
+ next if @depth && level + 1 >= @depth
93
+
94
+ queue << [target, level + 1]
95
+ end
96
+ end
97
+ [visited.values, edges.uniq]
98
+ end
99
+
100
+ def iface_schema_name(iface)
101
+ iface.schema.is_a?(String) ? iface.schema : iface.schema&.id
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Expressir
6
+ module Express
7
+ # Declaration listing and SMRL index writers — ports of eeng
8
+ # plugins/p11/wo-list.lisp and wo-smrl-xml.lisp (parity-ee stage 09).
9
+ #
10
+ # Two encodings share the same schema walk and declaration ordering
11
+ # (TYPE, ENTITY, SUBTYPE_CONSTRAINT, FUNCTION, RULE, PROCEDURE —
12
+ # each sorted by name):
13
+ # :list — human-readable SCHEMA.name; inventory
14
+ # :smrl_xml — <concatenated_express_file_content_list> index
15
+ class Listing
16
+ DECL_ORDER = %i[types entities subtype_constraints functions rules procedures].freeze
17
+ DECL_LABEL = {
18
+ types: "TYPE",
19
+ entities: "ENTITY",
20
+ subtype_constraints: "SUBTYPE_CONSTRAINT",
21
+ functions: "FUNCTION",
22
+ rules: "RULE",
23
+ procedures: "PROCEDURE",
24
+ }.freeze
25
+ XML_TAG = {
26
+ types: "type",
27
+ entities: "entity",
28
+ subtype_constraints: "subtype_constraint",
29
+ functions: "function",
30
+ rules: "rule",
31
+ procedures: "procedure",
32
+ }.freeze
33
+
34
+ def self.list(schemas, io = nil)
35
+ new(Array(schemas)).write(:list, io)
36
+ end
37
+
38
+ def self.smrl_xml(schemas, io = nil, source: nil)
39
+ new(Array(schemas)).write(:smrl_xml, io, source: source)
40
+ end
41
+
42
+ def initialize(schemas)
43
+ @schemas = schemas.compact.sort_by { |s| s.id.to_s.downcase }
44
+ end
45
+
46
+ def write(encoding, io = nil, source: nil)
47
+ owned = io.nil?
48
+ io ||= +""
49
+ case encoding
50
+ when :list then write_list(io)
51
+ when :smrl_xml then write_smrl_xml(io, source: source)
52
+ else
53
+ raise ArgumentError, "unknown listing encoding #{encoding.inspect}"
54
+ end
55
+ owned ? io : nil
56
+ end
57
+
58
+ private
59
+
60
+ def write_list(io)
61
+ @schemas.each_with_index do |schema, i|
62
+ io << "\n" if i.positive?
63
+ write_schema_list(schema, io)
64
+ end
65
+ end
66
+
67
+ def write_schema_list(schema, io)
68
+ version = schema.version ? " #{schema.version}" : ""
69
+ io << "SCHEMA #{schema.id.downcase}#{version};\n\n"
70
+ counts = decl_counts(schema)
71
+ DECL_ORDER.each do |key|
72
+ io << format("(* %-20s %4d *)\n", DECL_LABEL[key], counts[key])
73
+ end
74
+ ifaces = Array(schema.interfaces)
75
+ if ifaces.any?
76
+ io << "\n"
77
+ ifaces.each do |iface|
78
+ kind = iface.kind == Model::Declarations::Interface::REFERENCE ? "REFERENCE FROM" : "USE FROM"
79
+ target = iface_name(iface)
80
+ suffix = Array(iface.items).any? ? "(...)" : ""
81
+ io << " #{kind} #{target}#{suffix};\n"
82
+ end
83
+ end
84
+ constants = Array(schema.constants).sort_by { |c| c.id.to_s.downcase }
85
+ if constants.any?
86
+ io << "\n"
87
+ constants.each { |c| io << " CONSTANT #{c.id.downcase};\n" }
88
+ end
89
+ decls = ordered_decls(schema)
90
+ if decls.any?
91
+ io << "\n"
92
+ decls.each do |key, name|
93
+ io << " #{DECL_LABEL[key]} #{schema.id}.#{name.downcase};\n"
94
+ end
95
+ end
96
+ end
97
+
98
+ def write_smrl_xml(io, source: nil)
99
+ io << "<!-- Written by Expressir -->\n"
100
+ io << "<!-- #{Expressir::VERSION} -->\n"
101
+ io << "<!-- File: #{source} -->\n" if source
102
+ io << "<concatenated_express_file_content_list>\n\n"
103
+ names = @schemas.map { |s| schema_name_string(s.id) }
104
+ io << "<!-- #{@schemas.size} Schema#{'ta' unless @schemas.size == 1}:\n"
105
+ io << " #{names.join(', ')}\n"
106
+ io << "-->\n"
107
+ @schemas.each do |schema|
108
+ io << "\n\n"
109
+ write_schema_xml(schema, io)
110
+ end
111
+ io << "\n</concatenated_express_file_content_list>\n"
112
+ end
113
+
114
+ def write_schema_xml(schema, io)
115
+ io << "<schema>#{schema_name_string(schema.id)}</schema>\n"
116
+ if schema.version
117
+ io << " <schema_version>#{schema.version}</schema_version>\n"
118
+ end
119
+ counts = decl_counts(schema)
120
+ DECL_ORDER.each do |key|
121
+ io << format(" <!-- %-20s %4d -->\n", DECL_LABEL[key], counts[key])
122
+ end
123
+ Array(schema.interfaces).each do |iface|
124
+ tag = iface.kind == Model::Declarations::Interface::REFERENCE ? "reference-from" : "use-from"
125
+ target = iface_name(iface)
126
+ suffix = Array(iface.items).any? ? "(...)" : ""
127
+ io << " <#{tag}>#{target}#{suffix}</#{tag}>\n"
128
+ end
129
+ Array(schema.constants).sort_by { |c| c.id.to_s.downcase }.each do |c|
130
+ io << " <constant>#{c.id.downcase}</constant>\n"
131
+ end
132
+ ordered_decls(schema).each do |key, name|
133
+ tag = XML_TAG[key] || key.to_s
134
+ cased = if key == :entities && arm_mode?(schema)
135
+ name.split("_").map(&:capitalize).join("_")
136
+ else
137
+ name.downcase
138
+ end
139
+ io << " <#{tag}>#{schema.id}.#{cased}</#{tag}>\n"
140
+ end
141
+ end
142
+
143
+ def decl_counts(schema)
144
+ DECL_ORDER.to_h { |k| [k, Array(schema.public_send(k)).size] }
145
+ end
146
+
147
+ def ordered_decls(schema)
148
+ DECL_ORDER.flat_map do |key|
149
+ Array(schema.public_send(key))
150
+ .sort_by { |d| d.id.to_s.downcase }
151
+ .map { |d| [key, d.id] }
152
+ end
153
+ end
154
+
155
+ def iface_name(iface)
156
+ name = iface.schema.is_a?(String) ? iface.schema : iface.schema&.id
157
+ schema_name_string(name)
158
+ end
159
+
160
+ # eeng schema-name-string: arm/mim/arm_lf/mim_lf tails stay lowercase;
161
+ # everything else downcased.
162
+ def schema_name_string(name)
163
+ return "" unless name
164
+
165
+ name.to_s.downcase
166
+ end
167
+
168
+ def arm_mode?(schema)
169
+ id = schema.id.to_s.downcase
170
+ id.end_with?("arm", "arm_lf")
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Structural traversal over model trees, mirroring {ModelVisitor}:
6
+ # every typed attribute, statements included. Nodes are keyed by
7
+ # attribute path plus array index — stable across a compiled-set
8
+ # round trip because the wire is byte-identical — which is what the
9
+ # remark and reference overlays key on ({Model::ModelElement#path}
10
+ # is empty for id-less nodes).
11
+ module ModelTraversal
12
+ module_function
13
+
14
+ def each_node(node, key, &block)
15
+ yield node, key
16
+ return unless node.is_a?(Model::ModelElement)
17
+
18
+ node.class.attributes.each_key do |attr|
19
+ next if Model::ModelElement::SKIP_ATTRIBUTES.include?(attr) || attr == :parent
20
+
21
+ value = node.public_send(attr)
22
+ case value
23
+ when Array
24
+ value.each_with_index do |item, index|
25
+ next unless item.is_a?(Model::ModelElement)
26
+
27
+ each_node(item, "#{key}/#{attr}[#{index}]", &block)
28
+ end
29
+ when Model::ModelElement
30
+ each_node(value, "#{key}/#{attr}", &block)
31
+ end
32
+ end
33
+ end
34
+
35
+ def each_model(models, &block)
36
+ models.each_with_index do |model, index|
37
+ each_node(model, "model[#{index}]", &block)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -13,6 +13,9 @@ module Expressir
13
13
  # instances; this module turns those spans into a line-keyed index that
14
14
  # remark attachment can query.
15
15
  class NodePositionIndex
16
+ EMPTY = [].freeze
17
+ private_constant :EMPTY
18
+
16
19
  # Single source of truth for "what collections does this node have"
17
20
  # lives on the model — each class declares its own via the
18
21
  # `collection_attributes` macro. Referenced by both RemarkAttacher
@@ -21,31 +24,133 @@ module Expressir
21
24
 
22
25
  attr_reader :nodes
23
26
 
27
+ # Semantic entries starting on `line` (node order).
28
+ def starting_at(line)
29
+ semantic_by_line[line] || EMPTY
30
+ end
31
+
32
+ # Semantic entries ending on `line` (node order).
33
+ def ending_at(line)
34
+ semantic_by_end_line[line] || EMPTY
35
+ end
36
+
37
+ # Semantic entries whose span covers `line` (node order within the
38
+ # covering band).
39
+ def spanning(line)
40
+ band = line / BAND
41
+ bands = span_bands
42
+ return EMPTY unless bands.key?(band)
43
+
44
+ bands[band].select { |n| line.between?(n[:line], n[:end_line]) }
45
+ end
46
+
47
+ # Semantic entries with end_line < line, ascending by end_line.
48
+ def semantic_ending_before(line)
49
+ sorted = semantic_by_end_line_list
50
+ idx = sorted.bsearch_index { |n| n[:end_line] >= line } || sorted.size
51
+ sorted[0...idx]
52
+ end
53
+
54
+ # The first semantic entry (smallest line), for preamble remarks.
55
+ def first_semantic
56
+ first_by_line = semantic_by_line.keys.min
57
+ first_by_line && semantic_by_line[first_by_line]&.first
58
+ end
59
+
60
+ # Entries owned by `owner` within any of `collections`, in node order.
61
+ # Identity-keyed: ownership is object identity (the tree-walker's
62
+ # `.equal?` semantics), and value-equal model objects must not
63
+ # collide.
64
+ def children_in(owner, collections)
65
+ per_collection = children_index[owner]
66
+ return EMPTY unless per_collection
67
+
68
+ entries = per_collection.values_at(*collections).compact.flatten(1)
69
+ entries.sort_by { |n| node_order(n) }
70
+ end
71
+
72
+ # First node (in node order) that is_a?(type), memoized per type.
73
+ def first_node_of_type(type)
74
+ @first_of_type ||= {}
75
+ @first_of_type[type] ||= @nodes.find { |n| n[:node].is_a?(type) }&.dig(:node)
76
+ end
77
+
78
+ def semantic_entries
79
+ @semantic_entries ||=
80
+ @nodes.select { |n| n[:line] && semantic?(n[:node]) }
81
+ end
82
+
83
+ # Non-vivifying: missed lookups must not materialize keys, or
84
+ # `keys.min`-style queries see phantom lines.
85
+ def semantic_by_line
86
+ @semantic_by_line ||= begin
87
+ h = {}
88
+ semantic_entries.each { |n| (h[n[:line]] ||= []) << n }
89
+ h
90
+ end
91
+ end
92
+
93
+ def semantic_by_end_line
94
+ @semantic_by_end_line ||= begin
95
+ h = {}
96
+ semantic_entries.each { |n| (h[n[:end_line]] ||= []) << n if n[:end_line] }
97
+ h
98
+ end
99
+ end
100
+
101
+ def semantic_by_end_line_list
102
+ @semantic_by_end_line_list ||=
103
+ semantic_entries.select { |n| n[:end_line] }.sort_by { |n| n[:end_line] }
104
+ end
105
+
106
+ def children_index
107
+ @children_index ||= begin
108
+ index = {}.compare_by_identity
109
+ @nodes.each do |n|
110
+ next unless n[:owner] && n[:line]
111
+
112
+ per_owner = (index[n[:owner]] ||= {})
113
+ (per_owner[n[:collection]] ||= []) << n
114
+ end
115
+ index
116
+ end
117
+ end
118
+
119
+ def span_bands
120
+ @span_bands ||= begin
121
+ bands = Hash.new { |hash, key| hash[key] = [] }
122
+ semantic_entries.each do |n|
123
+ next unless n[:end_line]
124
+
125
+ ((n[:line] / BAND)..(n[:end_line] / BAND)).each do |band|
126
+ bands[band] << n
127
+ end
128
+ end
129
+ bands
130
+ end
131
+ end
132
+
133
+ BAND = 1024
134
+ private_constant :BAND
135
+
24
136
  def initialize(model, line_map)
25
137
  @model = model
26
138
  @line_map = line_map
27
139
  @nodes = build_sorted_nodes
140
+ @node_order = nil
28
141
  end
29
142
 
30
143
  # Returns the most-specific node whose span contains `remark_line`,
31
144
  # preferring same-line starts/ends, then smallest containing span.
32
- # Excludes Repository and Cache (not semantic scopes for remarks).
145
+ # Skips nodes that cannot own such a remark; see {#remark_scope?}.
33
146
  def nearest_node_to(remark_line)
34
- same_start = nodes.select do |n|
35
- n[:line] == remark_line && semantic?(n[:node])
36
- end
147
+ same_start = starting_at(remark_line).select { |n| remark_scope?(n[:node]) }
37
148
  return same_start.last[:node] if same_start.any?
38
149
 
39
- same_end = nodes.select do |n|
40
- n[:end_line] == remark_line && semantic?(n[:node])
41
- end
150
+ same_end = ending_at(remark_line).select { |n| remark_scope?(n[:node]) }
42
151
  return same_end.last[:node] if same_end.any?
43
152
 
44
- containing = nodes.select do |n|
45
- n[:line] && n[:end_line] &&
46
- n[:line] <= remark_line && n[:end_line] >= remark_line &&
47
- semantic?(n[:node])
48
- end
153
+ containing = spanning(remark_line).select { |n| remark_scope?(n[:node]) }
49
154
 
50
155
  if containing.any?
51
156
  exp_file_node = containing.find { |n| n[:node].is_a?(Model::ExpFile) }
@@ -62,16 +167,13 @@ module Expressir
62
167
  candidates = containing if candidates.empty?
63
168
  candidates.min_by { |n| n[:end_line] - n[:line] }[:node]
64
169
  else
65
- before = nodes.select do |n|
66
- n[:end_line] && n[:end_line] < remark_line && semantic?(n[:node])
67
- end
170
+ before = semantic_ending_before(remark_line)
68
171
  if before.any?
69
172
  before.max_by { |n| n[:end_line] }[:node]
70
173
  else
71
174
  # Remark is before all nodes (e.g., preamble comment before SCHEMA).
72
175
  # Attach to the first semantic node.
73
- after = nodes.select { |n| n[:line] && semantic?(n[:node]) }
74
- after.min_by { |n| n[:line] }[:node] if after.any?
176
+ first_semantic&.dig(:node)
75
177
  end
76
178
  end
77
179
  end
@@ -90,21 +192,46 @@ module Expressir
90
192
  end
91
193
  return nil unless node_type
92
194
 
93
- matching = nodes.select do |n|
195
+ candidates = (0..2).flat_map do |back|
196
+ ending_at(remark_line - back)
197
+ end.select do |n|
94
198
  n[:node].is_a?(node_type) &&
95
- (n[:end_line] == remark_line ||
96
- (n[:end_line] && n[:end_line] <= remark_line && n[:end_line] >= remark_line - 2))
199
+ n[:end_line] <= remark_line && n[:end_line] >= remark_line - 2
97
200
  end
98
201
 
99
- matching.first&.dig(:node) || nodes.find { |n| n[:node].is_a?(node_type) }&.dig(:node)
202
+ candidates.min_by { |n| node_order(n) }&.dig(:node) ||
203
+ first_node_of_type(node_type)
100
204
  end
101
205
 
102
206
  private
103
207
 
208
+ def node_order(entry)
209
+ @node_order ||= begin
210
+ map = {}.compare_by_identity
211
+ @nodes.each_with_index { |n, i| map[n] = i }
212
+ map
213
+ end
214
+ @node_order[entry]
215
+ end
216
+
104
217
  def semantic?(node)
105
218
  !node.is_a?(Model::Repository) && !node.is_a?(Model::Cache)
106
219
  end
107
220
 
221
+ # Whether a node can own a remark sitting on a line of its own.
222
+ # Distinct from Model::TakesInlineRemark, which asks whether a node can
223
+ # own a remark written AFTER it on the same line: a statement answers
224
+ # yes to both; an interface clause only to that one.
225
+ #
226
+ # An interface clause is indexed (so a remark trailing it can find it
227
+ # via the same-line lookup), but it encloses nothing: an own-line
228
+ # remark below `REFERENCE FROM x;` introduces whatever comes next, and
229
+ # giving it to the clause would both misplace it and lose it, since
230
+ # nothing renders remarks there.
231
+ def remark_scope?(node)
232
+ semantic?(node) && !node.is_a?(Model::Declarations::Interface)
233
+ end
234
+
108
235
  def build_sorted_nodes
109
236
  result = []
110
237
  collect_nodes(@model, result, Set.new)