suma 0.4.0 → 0.5.1

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.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expressir"
4
+
5
+ module Suma
6
+ class LinkValidator
7
+ # Per-node-type navigation strategies for +validate_deep_path+.
8
+ #
9
+ # Each Step handles one EXPRESS construct (entity, type, ...) and
10
+ # knows how to walk one path segment from it. The dispatcher in
11
+ # LinkValidator picks the right Step by +handles?+.
12
+ #
13
+ # Adding a new navigable construct means adding a new Step class
14
+ # and registering it in REGISTRY — no edits to existing Steps or
15
+ # to validate_deep_path (open/closed principle).
16
+ module Step
17
+ Context = Struct.new(:file, :line, :link, :schema, :path,
18
+ keyword_init: true)
19
+
20
+ # Common failure-result builder. Step subclasses call this to
21
+ # surface an unresolved link.
22
+ module Failure
23
+ def failure(reason, context)
24
+ LinkValidationResult.new(
25
+ file: context.file,
26
+ line: context.line + 1,
27
+ link: context.link,
28
+ reason: reason,
29
+ )
30
+ end
31
+ end
32
+
33
+ # Navigates one path segment from an Entity by attribute name.
34
+ class Entity
35
+ include Failure
36
+
37
+ def self.handles?(node)
38
+ node.is_a?(Expressir::Model::Declarations::Entity)
39
+ end
40
+
41
+ def navigate(node, part, context)
42
+ attribute = node.attributes&.find { |a| a.id.downcase == part.downcase }
43
+ unless attribute
44
+ return failure("Attribute '#{part}' not found in entity '#{context.path}'",
45
+ context)
46
+ end
47
+
48
+ attribute
49
+ end
50
+ end
51
+
52
+ # Navigates one path segment from a Type by either enumeration
53
+ # value (for enum types) or by re-targeting to the underlying
54
+ # named type (for non-enum types).
55
+ class Type
56
+ include Failure
57
+
58
+ PRIMITIVE_TYPE_NAMES = %w[INTEGER REAL STRING BOOLEAN NUMBER BINARY
59
+ LOGICAL].freeze
60
+
61
+ def self.handles?(node)
62
+ node.is_a?(Expressir::Model::Declarations::Type)
63
+ end
64
+
65
+ def navigate(node, part, context)
66
+ underlying = node.underlying_type
67
+ unless underlying
68
+ return failure("Cannot navigate deeper from type '#{context.path}'",
69
+ context)
70
+ end
71
+
72
+ if underlying.is_a?(Expressir::Model::DataTypes::Enumeration)
73
+ navigate_enum(underlying, part, context)
74
+ else
75
+ navigate_underlying(underlying, context)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def navigate_enum(underlying, part, context)
82
+ enum_value = underlying.items.find { |e| e.id.downcase == part.downcase }
83
+ unless enum_value
84
+ return failure("Enumeration value '#{part}' not found in type '#{context.path}'",
85
+ context)
86
+ end
87
+
88
+ enum_value
89
+ end
90
+
91
+ def navigate_underlying(underlying, context)
92
+ base_type = resolve_base_type(context.schema, underlying)
93
+ unless base_type
94
+ return failure("Base type not found for '#{context.path}'",
95
+ context)
96
+ end
97
+
98
+ base_type
99
+ end
100
+
101
+ def resolve_base_type(schema, type_ref)
102
+ return nil if PRIMITIVE_TYPE_NAMES.include?(type_ref.to_s.upcase)
103
+ return find_schema_element(schema, type_ref) if type_ref.is_a?(String)
104
+
105
+ type_ref if type_ref.is_a?(Expressir::Model::ModelElement)
106
+ end
107
+
108
+ def find_schema_element(schema, name)
109
+ SchemaElementLookup.find(schema, name)
110
+ end
111
+ end
112
+
113
+ # Lookup helper shared across Step classes that need to resolve
114
+ # a named schema element (used by Type for underlying-type
115
+ # resolution). Lives behind its own module so Steps don't reach
116
+ # into LinkValidator's private methods.
117
+ module SchemaElementLookup
118
+ def self.find(schema, name)
119
+ collections(schema).each do |collection|
120
+ element = collection&.find { |e| e.id.downcase == name.downcase }
121
+ return element if element
122
+ end
123
+ nil
124
+ end
125
+
126
+ def self.collections(schema)
127
+ [
128
+ schema.entities,
129
+ schema.types,
130
+ schema.constants,
131
+ schema.functions,
132
+ schema.rules,
133
+ schema.procedures,
134
+ schema.subtype_constraints,
135
+ ]
136
+ end
137
+ private_class_method :collections
138
+ end
139
+
140
+ REGISTRY = [Entity, Type].freeze
141
+
142
+ # Returns the Step class that handles +node+, or +nil+ if no
143
+ # registered Step matches.
144
+ def self.for(node)
145
+ REGISTRY.find { |step| step.handles?(node) }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -8,6 +8,8 @@ module Suma
8
8
  keyword_init: true)
9
9
 
10
10
  class LinkValidator
11
+ autoload :Step, "suma/link_validator/step"
12
+
11
13
  def initialize(index)
12
14
  @index = index
13
15
  end
@@ -100,113 +102,34 @@ module Suma
100
102
  end
101
103
 
102
104
  def validate_deep_path(schema, element, path_parts, file, line_idx,
103
- full_link)
105
+ full_link)
104
106
  current = element
105
107
  current_path = "#{schema.id}.#{element.id}"
108
+ context = Step::Context.new(file: file, line: line_idx, link: full_link,
109
+ schema: schema, path: current_path)
106
110
 
107
111
  path_parts.each do |part|
108
- case current
109
- when Expressir::Model::Declarations::Entity
110
- attribute = current.attributes&.find do |a|
111
- a.id.downcase == part.downcase
112
- end
113
-
114
- unless attribute
115
- return LinkValidationResult.new(
116
- file: file,
117
- line: line_idx + 1,
118
- link: full_link,
119
- reason: "Attribute '#{part}' not found in entity '#{current_path}'",
120
- )
121
- end
122
-
123
- current = attribute
124
- current_path += ".#{part}"
125
-
126
- when Expressir::Model::Declarations::Type
127
- underlying = current.underlying_type
128
-
129
- if underlying.is_a?(Expressir::Model::DataTypes::Enumeration)
130
- enum_value = underlying.items.find do |e|
131
- e.id.downcase == part.downcase
132
- end
133
-
134
- unless enum_value
135
- return LinkValidationResult.new(
136
- file: file,
137
- line: line_idx + 1,
138
- link: full_link,
139
- reason: "Enumeration value '#{part}' not found in type '#{current_path}'",
140
- )
141
- end
142
-
143
- current = enum_value
144
- current_path += ".#{part}"
145
-
146
- elsif underlying
147
- base_type = find_base_type(schema, underlying)
148
-
149
- unless base_type
150
- return LinkValidationResult.new(
151
- file: file,
152
- line: line_idx + 1,
153
- link: full_link,
154
- reason: "Base type not found for '#{current_path}'",
155
- )
156
- end
157
-
158
- current = base_type
159
-
160
- else
161
- return LinkValidationResult.new(
162
- file: file,
163
- line: line_idx + 1,
164
- link: full_link,
165
- reason: "Cannot navigate deeper from type '#{current_path}'",
166
- )
167
- end
112
+ step = Step.for(current)
113
+ return unsupported_step_failure(context) unless step
168
114
 
169
- else
170
- return LinkValidationResult.new(
171
- file: file,
172
- line: line_idx + 1,
173
- link: full_link,
174
- reason: "Cannot navigate deeper from '#{current_path}'",
175
- )
176
- end
115
+ outcome = step.new.navigate(current, part, context)
116
+ return outcome if outcome.is_a?(LinkValidationResult)
117
+
118
+ current = outcome
119
+ current_path += ".#{part}"
120
+ context.path = current_path
177
121
  end
178
122
 
179
123
  nil
180
124
  end
181
125
 
182
- def find_base_type(schema, type_ref)
183
- return nil if %w[INTEGER REAL STRING BOOLEAN NUMBER BINARY
184
- LOGICAL].include?(type_ref.to_s.upcase)
185
-
186
- if type_ref.is_a?(String)
187
- find_schema_element(schema, type_ref)
188
- elsif type_ref.is_a?(Expressir::Model::ModelElement)
189
- type_ref
190
- end
191
- end
192
-
193
- def find_schema_element(schema, element_name)
194
- [
195
- schema.entities,
196
- schema.types,
197
- schema.constants,
198
- schema.functions,
199
- schema.rules,
200
- schema.procedures,
201
- schema.subtype_constraints,
202
- ].each do |collection|
203
- element = collection&.find do |e|
204
- e.id.downcase == element_name.downcase
205
- end
206
- return element if element
207
- end
208
-
209
- nil
126
+ def unsupported_step_failure(context)
127
+ LinkValidationResult.new(
128
+ file: context.file,
129
+ line: context.line + 1,
130
+ link: context.link,
131
+ reason: "Cannot navigate deeper from '#{context.path}'",
132
+ )
210
133
  end
211
134
  end
212
135
  end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "glossarist"
4
+
5
+ module Suma
6
+ # Cleans raw EXPRESS entity remarks into a list of
7
+ # +Glossarist::V3::DetailedDefinition+ notes.
8
+ #
9
+ # Pipeline:
10
+ # 1. trim to first sentence / first complete list
11
+ # 2. convert express xrefs to URN mentions
12
+ # 3. drop redundant "X is a type of {{...}}" opening notes
13
+ # 4. drop notes containing image references or <<...>> blocks
14
+ # 5. drop notes that duplicate the first definition
15
+ #
16
+ # Pure: no I/O, no schema knowledge. The schema domain label and
17
+ # URN composition are injected via +xref_to_mention+ so this module
18
+ # stays independent of +Suma::Urn+ and the surrounding extractor.
19
+ class NoteProcessor
20
+ REDUNDANT_NOTE_REGEX =
21
+ %r{
22
+ ^An? # Starts with "A" or "An"
23
+ \s.*?\sis\sa\stype\sof # Text followed by "is a type of"
24
+ (\sa|\san)? # Optional " a" or " an"
25
+ \s\{\{[^\}]*\}\} # Text in double curly braces
26
+ \s*?\.?$ # Optional whitespace and period at the end
27
+ }x
28
+
29
+ IMAGE_REF_SUBSTRING = "image::"
30
+ DOUBLE_ANGLE_PATTERN = /<<(.*?){1,999}>>/
31
+ SEE_TAIL_PATTERN = /\s+\(see(.*?){1,999}\)/
32
+ XREF_WITH_DISPLAY = /<<express:([\w.]+),([\w. ][\w. ]*)>>/
33
+ XREF_WITHOUT_DISPLAY = /<<express:([\w.]+)>>/
34
+ INLINE_COMMENT_PATTERN = /\n\/\/.*?\n/
35
+ BULLET_LIST_PATTERN = /^\s*[*\-+]\s+/
36
+ NUMBERED_LIST_PATTERN = /^\s*\d+\.\s+/
37
+ CONTINUATION_PLUS = /^\+\s*$/
38
+ CONTINUATION_DASHES = /^--\s*$/
39
+ INDENTED_PATTERN = /^\s{2,}/
40
+ SENTENCE_BREAK_NEWLINE = /\.\n/
41
+ SENTENCE_BREAK_SPACE = /\. /
42
+
43
+ attr_reader :remarks, :definitions, :xref_to_mention
44
+
45
+ def initialize(remarks, definitions:, xref_to_mention:)
46
+ @remarks = remarks
47
+ @definitions = definitions
48
+ @xref_to_mention = xref_to_mention
49
+ end
50
+
51
+ def self.call(remarks, definitions:, xref_to_mention:)
52
+ new(remarks, definitions: definitions, xref_to_mention: xref_to_mention).call
53
+ end
54
+
55
+ def call
56
+ notes = build_initial_notes
57
+ notes = only_keep_first_sentence(notes)
58
+ notes = remove_see_content(notes)
59
+ notes = remove_redundant_note(notes)
60
+ notes = remove_invalid_references(notes)
61
+ compare_with_definitions(notes, definitions)
62
+ end
63
+
64
+ private
65
+
66
+ def build_initial_notes
67
+ trimmed = trim_definition(remarks)
68
+ return [] unless trimmed && !trimmed.empty?
69
+
70
+ [Glossarist::V3::DetailedDefinition.new(content: trimmed)]
71
+ end
72
+
73
+ def trim_definition(definition)
74
+ return nil if definition.nil? || definition.empty?
75
+
76
+ definition_str = array_to_paragraphs(definition)
77
+ return nil if definition_str.empty?
78
+
79
+ paragraphs = definition_str.split("\n\n")
80
+ combine_paragraphs(paragraphs)
81
+ end
82
+
83
+ def array_to_paragraphs(definition)
84
+ definition.is_a?(Array) ? definition.join("\n\n") : definition.to_s
85
+ end
86
+
87
+ def combine_paragraphs(paragraphs)
88
+ combined = first_block(paragraphs)
89
+ combined = "#{combined}\n"
90
+ combined = combined.gsub(INLINE_COMMENT_PATTERN, "\n")
91
+ combined.strip!
92
+ convert_xref(combined)
93
+ end
94
+
95
+ def first_block(paragraphs)
96
+ first_paragraph = paragraphs.first
97
+ return apply_first_sentence_logic(first_paragraph) unless list_header?(paragraphs)
98
+
99
+ complete_list = extract_complete_list(paragraphs, 1)
100
+ "#{first_paragraph}\n\n#{complete_list}"
101
+ end
102
+
103
+ def list_header?(paragraphs)
104
+ paragraphs.length > 1 &&
105
+ paragraphs.first.end_with?(":") &&
106
+ starts_with_list?(paragraphs[1])
107
+ end
108
+
109
+ def apply_first_sentence_logic(paragraph)
110
+ new_content = paragraph
111
+ .split(SENTENCE_BREAK_NEWLINE).first.strip
112
+ .split(SENTENCE_BREAK_SPACE).first.strip
113
+
114
+ new_content.end_with?(".") ? new_content : "#{new_content}."
115
+ end
116
+
117
+ def extract_complete_list(paragraphs, start_index)
118
+ return paragraphs[start_index] if start_index >= paragraphs.length
119
+
120
+ reduce_list_paragraphs(paragraphs, start_index)
121
+ end
122
+
123
+ def reduce_list_paragraphs(paragraphs, start_index)
124
+ combined = paragraphs[start_index].dup
125
+ state = ContinuationState.new(combined.include?("--") && !combined.match?(/--.*--/m))
126
+
127
+ ((start_index + 1)...paragraphs.length).each do |i|
128
+ break unless apply_paragraph_step?(combined, paragraphs[i], state)
129
+ end
130
+
131
+ combined
132
+ end
133
+
134
+ # Returns true if iteration should continue, false to stop.
135
+ def apply_paragraph_step?(combined, para, state)
136
+ case classify_paragraph(para, state)
137
+ when :continuation_toggle
138
+ state.toggle!
139
+ append!(combined, para)
140
+ when :continuation
141
+ append!(combined, para)
142
+ when :list_member
143
+ append!(combined, para)
144
+ state.toggle! if opens_continuation_block?(para)
145
+ else
146
+ return false
147
+ end
148
+ true
149
+ end
150
+
151
+ def append!(combined, para)
152
+ combined << "\n\n#{para}"
153
+ end
154
+
155
+ def opens_continuation_block?(para)
156
+ para.include?("--") && !para.match?(/--.*--/m)
157
+ end
158
+
159
+ def classify_paragraph(para, state)
160
+ return :continuation_toggle if para.match?(CONTINUATION_DASHES) || para.end_with?("--")
161
+ return :continuation if state.in_block?
162
+ return :list_member if starts_with_list?(para) || list_continuation?(para)
163
+
164
+ :stop
165
+ end
166
+
167
+ # Mutable flag tracking whether we are inside a "-- ... --"
168
+ # continuation block while walking paragraphs.
169
+ class ContinuationState
170
+ def initialize(in_block)
171
+ @in_block = in_block
172
+ end
173
+
174
+ def in_block?
175
+ @in_block
176
+ end
177
+
178
+ def toggle!
179
+ @in_block = !@in_block
180
+ end
181
+ end
182
+ private_constant :ContinuationState
183
+
184
+ def list_continuation?(content)
185
+ return false if content.nil? || content.empty?
186
+
187
+ content.match?(CONTINUATION_PLUS) ||
188
+ content.match?(CONTINUATION_DASHES) ||
189
+ content.match?(INDENTED_PATTERN) ||
190
+ content.start_with?("which", "where", "that")
191
+ end
192
+
193
+ def starts_with_list?(content)
194
+ return false if content.nil? || content.empty?
195
+
196
+ content.match?(BULLET_LIST_PATTERN) || content.match?(NUMBERED_LIST_PATTERN)
197
+ end
198
+
199
+ def only_keep_first_sentence(notes)
200
+ notes.each do |note|
201
+ next unless note&.content
202
+ next if preserve_complete_structure?(note.content)
203
+
204
+ new_content = note.content
205
+ .split(SENTENCE_BREAK_NEWLINE).first.strip
206
+ .split(SENTENCE_BREAK_SPACE).first.strip
207
+ note.content = new_content.end_with?(".") ? new_content : "#{new_content}."
208
+ end
209
+ end
210
+
211
+ def preserve_complete_structure?(content)
212
+ return false if content.nil? || content.empty?
213
+
214
+ lines = content.split("\n")
215
+ return false unless first_line_is_header?(lines)
216
+ return false if first_line_has_period?(lines.first.strip)
217
+
218
+ remaining_content = lines[1..].join("\n")
219
+ starts_with_list?(remaining_content.strip)
220
+ end
221
+
222
+ def first_line_is_header?(lines)
223
+ lines.first&.strip&.end_with?(":") && lines.length > 1
224
+ end
225
+
226
+ def first_line_has_period?(first_line)
227
+ first_line.count(".").positive?
228
+ end
229
+
230
+ def remove_see_content(notes)
231
+ notes.each do |note|
232
+ note.content = note.content.gsub(SEE_TAIL_PATTERN, "")
233
+ end
234
+ end
235
+
236
+ def remove_redundant_note(notes)
237
+ notes.reject do |note|
238
+ note.content.match?(REDUNDANT_NOTE_REGEX) &&
239
+ !note.content.include?("\n")
240
+ end
241
+ end
242
+
243
+ def remove_invalid_references(notes)
244
+ notes.reject do |note|
245
+ note.content.include?(IMAGE_REF_SUBSTRING) ||
246
+ note.content.match?(DOUBLE_ANGLE_PATTERN)
247
+ end
248
+ end
249
+
250
+ def compare_with_definitions(notes, definitions)
251
+ return [] if notes&.first&.content == definitions&.first&.content
252
+
253
+ notes
254
+ end
255
+
256
+ def convert_xref(content)
257
+ content
258
+ .gsub(XREF_WITHOUT_DISPLAY) do |_match|
259
+ full_ref = Regexp.last_match(1)
260
+ display = full_ref.split(".").last
261
+ xref_to_mention.call(full_ref, display)
262
+ end
263
+ .gsub(XREF_WITH_DISPLAY) do |_match|
264
+ full_ref = Regexp.last_match(1)
265
+ display = Regexp.last_match(2)
266
+ xref_to_mention.call(full_ref, display)
267
+ end
268
+ end
269
+ end
270
+ end
@@ -4,15 +4,21 @@ require "metanorma"
4
4
 
5
5
  module Suma
6
6
  class Processor
7
+ # Emitted collection manifest that both the normal and staged builds render.
8
+ COLLECTION_OUTPUT_PATH = "collection-output.yaml"
9
+
7
10
  attr_reader :metanorma_yaml_path, :output_directory, :schemas_all_path,
8
11
  :compile_flag
9
12
 
10
13
  def initialize(metanorma_yaml_path:, schemas_all_path:, compile: true,
11
- output_directory: "_site")
14
+ output_directory: "_site", staged: false)
12
15
  @metanorma_yaml_path = metanorma_yaml_path
13
16
  @schemas_all_path = schemas_all_path
14
17
  @compile_flag = compile
15
18
  @output_directory = output_directory
19
+ # Opt-in memory-bounded staged build (metanorma/suma#94); the default
20
+ # single-process build below is unchanged when false.
21
+ @staged = staged
16
22
  end
17
23
 
18
24
  def run
@@ -64,11 +70,21 @@ module Suma
64
70
  collection_config, output_directory
65
71
  )
66
72
 
67
- metanorma_collection.render(collection_opts)
73
+ if @staged
74
+ # build_collection has written the emitted manifest; stage each member in
75
+ # its own process, then reinflate. Bounds peak memory to a single member.
76
+ StagedCollectionBuilder.new(
77
+ collection_config_path: COLLECTION_OUTPUT_PATH,
78
+ output_directory: output_directory,
79
+ coverpage: collection_opts[:coverpage],
80
+ ).build
81
+ else
82
+ metanorma_collection.render(collection_opts)
83
+ end
68
84
  end
69
85
 
70
86
  def build_collection(collection_config, output_directory)
71
- new_collection_config_path = "collection-output.yaml"
87
+ new_collection_config_path = COLLECTION_OUTPUT_PATH
72
88
  ManifestTraverser.new(collection_config.manifest).remove_schemas_only_sources
73
89
  collection_config.to_file(new_collection_config_path)
74
90
 
@@ -91,7 +91,7 @@ module Suma
91
91
  existing_schema = Expressir::Changes::SchemaChange.from_file(output_path)
92
92
  end
93
93
 
94
- converter = EengineConverter.new(xml_path, schema_name)
94
+ converter = EengineConverter.new(schema_name, File.read(xml_path))
95
95
  change_schema = converter.convert(
96
96
  version: options[:version],
97
97
  existing_change_schema: existing_schema,