tediparse 2.0.0

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 (176) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +319 -0
  3. data/LICENSE +26 -0
  4. data/README.md +308 -0
  5. data/Rakefile +33 -0
  6. data/bin/tediparse +102 -0
  7. data/doc/Defining.md +97 -0
  8. data/doc/Generating-Grammars.md +241 -0
  9. data/doc/Generating.md +332 -0
  10. data/doc/Navigating.md +670 -0
  11. data/doc/Parsing.md +133 -0
  12. data/doc/README.md +51 -0
  13. data/doc/Serializing.md +100 -0
  14. data/doc/Tokenizing.md +136 -0
  15. data/doc/Validating.md +140 -0
  16. data/lib/ruby/array.rb +226 -0
  17. data/lib/ruby/blank.rb +52 -0
  18. data/lib/ruby/exception.rb +14 -0
  19. data/lib/ruby/hash.rb +14 -0
  20. data/lib/ruby/module.rb +60 -0
  21. data/lib/ruby/object.rb +56 -0
  22. data/lib/ruby/string.rb +89 -0
  23. data/lib/ruby/to_d.rb +82 -0
  24. data/lib/ruby/to_date.rb +26 -0
  25. data/lib/ruby/to_time.rb +21 -0
  26. data/lib/ruby/try.rb +46 -0
  27. data/lib/stupidedi/builder.rb +6 -0
  28. data/lib/stupidedi/color.rb +94 -0
  29. data/lib/stupidedi/config/code_list_config.rb +45 -0
  30. data/lib/stupidedi/config/functional_group_config.rb +72 -0
  31. data/lib/stupidedi/config/interchange_config.rb +86 -0
  32. data/lib/stupidedi/config/transaction_set_config.rb +73 -0
  33. data/lib/stupidedi/config.rb +100 -0
  34. data/lib/stupidedi/either.rb +286 -0
  35. data/lib/stupidedi/exceptions/invalid_element_error.rb +7 -0
  36. data/lib/stupidedi/exceptions/invalid_schema_error.rb +7 -0
  37. data/lib/stupidedi/exceptions/missing_grammar_error.rb +50 -0
  38. data/lib/stupidedi/exceptions/output_error.rb +7 -0
  39. data/lib/stupidedi/exceptions/parse_error.rb +7 -0
  40. data/lib/stupidedi/exceptions/stupidedi_error.rb +7 -0
  41. data/lib/stupidedi/exceptions/tokenize_error.rb +7 -0
  42. data/lib/stupidedi/exceptions/zipper_error.rb +7 -0
  43. data/lib/stupidedi/exceptions.rb +13 -0
  44. data/lib/stupidedi/inspect.rb +26 -0
  45. data/lib/stupidedi/interchanges/element_types/separator_val.rb +79 -0
  46. data/lib/stupidedi/interchanges/element_types/special_val.rb +48 -0
  47. data/lib/stupidedi/interchanges/element_types.rb +11 -0
  48. data/lib/stupidedi/interchanges.rb +21 -0
  49. data/lib/stupidedi/parser/builder_dsl.rb +292 -0
  50. data/lib/stupidedi/parser/constraint_table.rb +566 -0
  51. data/lib/stupidedi/parser/generation.rb +147 -0
  52. data/lib/stupidedi/parser/identifier_stack.rb +248 -0
  53. data/lib/stupidedi/parser/instruction.rb +112 -0
  54. data/lib/stupidedi/parser/instruction_table.rb +222 -0
  55. data/lib/stupidedi/parser/navigation.rb +786 -0
  56. data/lib/stupidedi/parser/state_machine.rb +62 -0
  57. data/lib/stupidedi/parser/states/abstract_state.rb +385 -0
  58. data/lib/stupidedi/parser/states/failure_state.rb +69 -0
  59. data/lib/stupidedi/parser/states/functional_group_state.rb +101 -0
  60. data/lib/stupidedi/parser/states/initial_state.rb +62 -0
  61. data/lib/stupidedi/parser/states/interchange_state.rb +96 -0
  62. data/lib/stupidedi/parser/states/loop_state.rb +74 -0
  63. data/lib/stupidedi/parser/states/table_state.rb +90 -0
  64. data/lib/stupidedi/parser/states/transaction_set_state.rb +120 -0
  65. data/lib/stupidedi/parser/states/transmission_state.rb +59 -0
  66. data/lib/stupidedi/parser/tokenization.rb +195 -0
  67. data/lib/stupidedi/parser.rb +32 -0
  68. data/lib/stupidedi/reader/input/abstract_input.rb +137 -0
  69. data/lib/stupidedi/reader/input/delegated_input.rb +112 -0
  70. data/lib/stupidedi/reader/input/file_input.rb +157 -0
  71. data/lib/stupidedi/reader/input.rb +31 -0
  72. data/lib/stupidedi/reader/position.rb +78 -0
  73. data/lib/stupidedi/reader/result.rb +172 -0
  74. data/lib/stupidedi/reader/segment_dict.rb +176 -0
  75. data/lib/stupidedi/reader/separators.rb +90 -0
  76. data/lib/stupidedi/reader/stream_reader.rb +173 -0
  77. data/lib/stupidedi/reader/token_reader.rb +465 -0
  78. data/lib/stupidedi/reader/tokens/component_element_tok.rb +71 -0
  79. data/lib/stupidedi/reader/tokens/composite_element_tok.rb +85 -0
  80. data/lib/stupidedi/reader/tokens/repeated_element_tok.rb +74 -0
  81. data/lib/stupidedi/reader/tokens/segment_tok.rb +74 -0
  82. data/lib/stupidedi/reader/tokens/simple_element_tok.rb +76 -0
  83. data/lib/stupidedi/reader.rb +121 -0
  84. data/lib/stupidedi/schema/abstract_def.rb +76 -0
  85. data/lib/stupidedi/schema/abstract_element_def.rb +35 -0
  86. data/lib/stupidedi/schema/abstract_element_use.rb +47 -0
  87. data/lib/stupidedi/schema/abstract_use.rb +79 -0
  88. data/lib/stupidedi/schema/code_list.rb +99 -0
  89. data/lib/stupidedi/schema/component_element_use.rb +76 -0
  90. data/lib/stupidedi/schema/composite_element_def.rb +103 -0
  91. data/lib/stupidedi/schema/composite_element_use.rb +78 -0
  92. data/lib/stupidedi/schema/element_req.rb +57 -0
  93. data/lib/stupidedi/schema/functional_group_def.rb +124 -0
  94. data/lib/stupidedi/schema/generation/definition_generator.rb +139 -0
  95. data/lib/stupidedi/schema/generation/element_generator.rb +221 -0
  96. data/lib/stupidedi/schema/generation/flat_file_reader.rb +551 -0
  97. data/lib/stupidedi/schema/generation/functional_group_generator.rb +64 -0
  98. data/lib/stupidedi/schema/generation/interchange_generator.rb +145 -0
  99. data/lib/stupidedi/schema/generation/master_loader_generator.rb +121 -0
  100. data/lib/stupidedi/schema/generation/models.rb +85 -0
  101. data/lib/stupidedi/schema/generation/module_loader_generator.rb +64 -0
  102. data/lib/stupidedi/schema/generation/registration_generator.rb +230 -0
  103. data/lib/stupidedi/schema/generation/runner.rb +161 -0
  104. data/lib/stupidedi/schema/generation/segment_generator.rb +130 -0
  105. data/lib/stupidedi/schema/generation/support.rb +78 -0
  106. data/lib/stupidedi/schema/generation/support_modules_generator.rb +126 -0
  107. data/lib/stupidedi/schema/generation/version_modules.rb +35 -0
  108. data/lib/stupidedi/schema/generation.rb +94 -0
  109. data/lib/stupidedi/schema/interchange_def.rb +103 -0
  110. data/lib/stupidedi/schema/loop_def.rb +156 -0
  111. data/lib/stupidedi/schema/repeat_count.rb +86 -0
  112. data/lib/stupidedi/schema/segment_def.rb +122 -0
  113. data/lib/stupidedi/schema/segment_req.rb +46 -0
  114. data/lib/stupidedi/schema/segment_use.rb +99 -0
  115. data/lib/stupidedi/schema/simple_element_def.rb +51 -0
  116. data/lib/stupidedi/schema/simple_element_use.rb +83 -0
  117. data/lib/stupidedi/schema/syntax_note.rb +52 -0
  118. data/lib/stupidedi/schema/table_def.rb +178 -0
  119. data/lib/stupidedi/schema/transaction_set_def.rb +125 -0
  120. data/lib/stupidedi/schema.rb +30 -0
  121. data/lib/stupidedi/sets.rb +42 -0
  122. data/lib/stupidedi/transaction_sets/builder/dsl.rb +192 -0
  123. data/lib/stupidedi/transaction_sets/builder.rb +188 -0
  124. data/lib/stupidedi/transaction_sets/common/implementations/element_reqs.rb +37 -0
  125. data/lib/stupidedi/transaction_sets/common/implementations/segment_reqs.rb +31 -0
  126. data/lib/stupidedi/transaction_sets/common/implementations.rb +11 -0
  127. data/lib/stupidedi/transaction_sets/common.rb +8 -0
  128. data/lib/stupidedi/transaction_sets/validation/ambiguity.rb +395 -0
  129. data/lib/stupidedi/transaction_sets/validation/implementation.rb +12 -0
  130. data/lib/stupidedi/transaction_sets/validation.rb +9 -0
  131. data/lib/stupidedi/transaction_sets.rb +25 -0
  132. data/lib/stupidedi/values/abstract_element_val.rb +19 -0
  133. data/lib/stupidedi/values/abstract_val.rb +133 -0
  134. data/lib/stupidedi/values/composite_element_val.rb +102 -0
  135. data/lib/stupidedi/values/functional_group_val.rb +105 -0
  136. data/lib/stupidedi/values/interchange_val.rb +102 -0
  137. data/lib/stupidedi/values/invalid_envelope_val.rb +61 -0
  138. data/lib/stupidedi/values/invalid_segment_val.rb +89 -0
  139. data/lib/stupidedi/values/loop_val.rb +73 -0
  140. data/lib/stupidedi/values/repeated_element_val.rb +113 -0
  141. data/lib/stupidedi/values/segment_val.rb +105 -0
  142. data/lib/stupidedi/values/segment_val_group.rb +20 -0
  143. data/lib/stupidedi/values/simple_element_val.rb +80 -0
  144. data/lib/stupidedi/values/table_val.rb +69 -0
  145. data/lib/stupidedi/values/transaction_set_val.rb +69 -0
  146. data/lib/stupidedi/values/transmission_val.rb +56 -0
  147. data/lib/stupidedi/values.rb +22 -0
  148. data/lib/stupidedi/version.rb +4 -0
  149. data/lib/stupidedi/versions/common/element_reqs.rb +13 -0
  150. data/lib/stupidedi/versions/common/element_types/an.rb +386 -0
  151. data/lib/stupidedi/versions/common/element_types/dt.rb +572 -0
  152. data/lib/stupidedi/versions/common/element_types/id.rb +304 -0
  153. data/lib/stupidedi/versions/common/element_types/nn.rb +312 -0
  154. data/lib/stupidedi/versions/common/element_types/operators.rb +128 -0
  155. data/lib/stupidedi/versions/common/element_types/r.rb +342 -0
  156. data/lib/stupidedi/versions/common/element_types/simple_element.rb +73 -0
  157. data/lib/stupidedi/versions/common/element_types/tm.rb +347 -0
  158. data/lib/stupidedi/versions/common/element_types.rb +29 -0
  159. data/lib/stupidedi/versions/common/segment_reqs.rb +15 -0
  160. data/lib/stupidedi/versions/common/syntax_notes.rb +172 -0
  161. data/lib/stupidedi/versions/common.rb +11 -0
  162. data/lib/stupidedi/versions.rb +25 -0
  163. data/lib/stupidedi/writer/claredi.rb +178 -0
  164. data/lib/stupidedi/writer/default.rb +119 -0
  165. data/lib/stupidedi/writer.rb +7 -0
  166. data/lib/stupidedi/zipper/abstract_cursor.rb +351 -0
  167. data/lib/stupidedi/zipper/dangling_cursor.rb +103 -0
  168. data/lib/stupidedi/zipper/edited_cursor.rb +157 -0
  169. data/lib/stupidedi/zipper/memoized_cursor.rb +133 -0
  170. data/lib/stupidedi/zipper/path.rb +132 -0
  171. data/lib/stupidedi/zipper/root_cursor.rb +120 -0
  172. data/lib/stupidedi/zipper/stack_cursor.rb +107 -0
  173. data/lib/stupidedi/zipper.rb +45 -0
  174. data/lib/stupidedi.rb +69 -0
  175. data/lib/tediparse.rb +1 -0
  176. metadata +249 -0
@@ -0,0 +1,551 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Stupidedi
6
+ module Schema
7
+ module Generation
8
+ # Reads ASC X12 Table Data flat files (the official .TXT distribution, CSV
9
+ # despite the extension) and builds the in-memory Models tree the
10
+ # generators consume. This is Layer A - the inverse of the Tediware
11
+ # x12:import importer, with no database.
12
+ #
13
+ # release = FlatFileReader.read("vendor/x12/table_data/005010", "005010")
14
+ #
15
+ # Files consumed: ELEHEAD/ELEDETL (simple elements), COMHEAD/COMDETL
16
+ # (composites), SEGHEAD/SEGDETL (segments + element uses), SETHEAD/SETDETL
17
+ # (transaction sets + structure), FREEFORM (code lists + syntax notes).
18
+ #
19
+ # The distribution's encoding varies by release - see SOURCE_ENCODINGS. It
20
+ # is *not* ISO-8859-1 for any release we support, despite that being the
21
+ # obvious guess: releases through 007010 are Windows-1252 and 008010 is
22
+ # UTF-8.
23
+ class FlatFileReader
24
+ # Declared source encoding per ASC X12 release. CP1252 and ISO-8859-1
25
+ # agree everywhere except 0x80-0x9F, which carries smart punctuation in
26
+ # the former and undefined C1 controls in the latter - so reading a
27
+ # CP1252 distribution as Latin-1 turns a curly apostrophe into U+0092
28
+ # while leaving accented letters intact, which is why the damage hides.
29
+ # 003060's and 004010's .TXT files are pure ASCII, so their entries are
30
+ # a formality - declared anyway, because an undeclared release falls
31
+ # through to the UTF-8 default and that is a decision, not an oversight.
32
+ SOURCE_ENCODINGS = {
33
+ "003060" => "Windows-1252",
34
+ "004010" => "Windows-1252",
35
+ "004060" => "Windows-1252",
36
+ "005010" => "Windows-1252",
37
+ "006010" => "Windows-1252",
38
+ "007010" => "Windows-1252",
39
+ "008010" => "UTF-8"
40
+ }.freeze
41
+
42
+ # An undeclared release decodes as UTF-8, deliberately. Single-byte
43
+ # encodings decode every possible byte, so guessing one can only fail
44
+ # silently; UTF-8 raises on the first byte that is not valid UTF-8, so a
45
+ # new distribution nobody declared stops generation instead of writing
46
+ # mojibake into the grammar.
47
+ DEFAULT_SOURCE_ENCODING = "UTF-8"
48
+
49
+ # CP1252 smart punctuation, normalized to ASCII after decoding. This is
50
+ # not cosmetic: consumers derive identifiers and translated values from
51
+ # these strings with ASCII-only munging (delete("'") and friends), so a
52
+ # U+2019 survives the munging and stays damaged downstream. 008010's own
53
+ # source already uses straight apostrophes, so this converges the older
54
+ # releases onto what the newest one says. Punctuation only - accented
55
+ # letters are left alone ("Fiancee", "Denominacion" and "Marzen" keep
56
+ # their real characters).
57
+ PUNCTUATION = {
58
+ "‘" => "'", # left single quotation mark
59
+ "’" => "'", # right single quotation mark
60
+ "“" => '"', # left double quotation mark
61
+ "”" => '"', # right double quotation mark
62
+ "–" => "-", # en dash
63
+ "—" => "-" # em dash
64
+ }.freeze
65
+
66
+ PUNCTUATION_PATTERN = Regexp.union(PUNCTUATION.keys).freeze
67
+
68
+ # C1 controls are never legitimate in this data. Finding one after a
69
+ # successful decode means the declared encoding is wrong (Latin-1 read
70
+ # of CP1252 smart punctuation lands squarely here), so reject rather
71
+ # than carry it into the generated grammar.
72
+ C1_CONTROLS = (0x80..0x9F).map { |cp| cp.chr(Encoding::UTF_8) }.join.freeze
73
+
74
+ # U+FEFF, spelled numerically because it is invisible in source.
75
+ BOM = 0xFEFF.chr(Encoding::UTF_8).freeze
76
+
77
+ def self.read(dir, release_code)
78
+ new(dir, release_code).read
79
+ end
80
+
81
+ def initialize(dir, release_code)
82
+ @dir = dir
83
+ @release_code = release_code
84
+ @elements_by_code = {}
85
+ @segments_by_code = {}
86
+ @transaction_sets_by_code = {}
87
+ end
88
+
89
+ def read
90
+ read_elements
91
+ read_composites
92
+ read_segments
93
+ read_element_uses
94
+ read_component_uses
95
+ read_transaction_sets
96
+ read_freeform
97
+
98
+ release = Models::Release.new(
99
+ code: @release_code,
100
+ elements: @elements_by_code.values,
101
+ segments: @segments_by_code.values,
102
+ transaction_sets: @transaction_sets_by_code.values
103
+ )
104
+ release.transaction_sets.each { |ts| ts.release = release }
105
+ release
106
+ end
107
+
108
+ private
109
+
110
+ attr_reader :dir, :release_code
111
+
112
+ # ELEHEAD.TXT (code, name) + ELEDETL.TXT (code, type, min, max)
113
+ def read_elements
114
+ headers = {}
115
+ each_csv("ELEHEAD.TXT") { |row| headers[row[0]] = row[1] }
116
+
117
+ each_csv("ELEDETL.TXT") do |row|
118
+ code = row[0]
119
+ next if @elements_by_code.key?(code)
120
+
121
+ x12_type = presence(row[1])
122
+ min_length = positive_or_nil(row[2])
123
+ max_length = positive_or_nil(row[3])
124
+
125
+ digits = nil
126
+ digits = Regexp.last_match(1).to_i if x12_type && x12_type =~ /^N(\d+)$/
127
+
128
+ @elements_by_code[code] = Models::Element.new(
129
+ code: code,
130
+ name: headers[code] || "Element #{code}",
131
+ description: nil,
132
+ x12_type: x12_type,
133
+ is_composite: false,
134
+ min_length: min_length,
135
+ max_length: max_length,
136
+ digits: digits,
137
+ element_codes: [],
138
+ component_uses: []
139
+ )
140
+ end
141
+ end
142
+
143
+ # COMHEAD.TXT (code, name) - composites share the element pool.
144
+ def read_composites
145
+ each_csv("COMHEAD.TXT") do |row|
146
+ code = row[0]
147
+ next if @elements_by_code.key?(code)
148
+
149
+ @elements_by_code[code] = Models::Element.new(
150
+ code: code,
151
+ name: row[1],
152
+ description: nil,
153
+ x12_type: nil,
154
+ is_composite: true,
155
+ min_length: nil,
156
+ max_length: nil,
157
+ digits: nil,
158
+ element_codes: [],
159
+ component_uses: []
160
+ )
161
+ end
162
+ end
163
+
164
+ # SEGHEAD.TXT (code, name)
165
+ def read_segments
166
+ each_csv("SEGHEAD.TXT") do |row|
167
+ code = row[0]
168
+ next if @segments_by_code.key?(code)
169
+
170
+ @segments_by_code[code] = Models::Segment.new(
171
+ code: code,
172
+ name: row[1],
173
+ purpose: nil,
174
+ element_uses: [],
175
+ syntax_notes: []
176
+ )
177
+ end
178
+ end
179
+
180
+ # SEGDETL.TXT (segment, position, element, requirement, repetition count)
181
+ def read_element_uses
182
+ each_csv("SEGDETL.TXT") do |row|
183
+ segment = @segments_by_code[row[0]] or next
184
+ element = @elements_by_code[row[2]] or next
185
+ position = row[1].to_i
186
+
187
+ next if segment.element_uses.any? { |eu| eu.position == position }
188
+
189
+ segment.element_uses << Models::ElementUse.new(
190
+ position: position,
191
+ requirement: map_requirement(row[3]),
192
+ element: element,
193
+ max_reps: parse_element_reps(row[4])
194
+ )
195
+ end
196
+ end
197
+
198
+ # COMDETL.TXT (composite, position, element, requirement)
199
+ def read_component_uses
200
+ each_csv("COMDETL.TXT") do |row|
201
+ composite = @elements_by_code[row[0]] or next
202
+ element = @elements_by_code[row[2]] or next
203
+ position = row[1].to_i
204
+
205
+ next if composite.component_uses.any? { |cu| cu.position == position }
206
+
207
+ composite.component_uses << Models::ComponentUse.new(
208
+ position: position,
209
+ requirement: map_requirement(row[3]),
210
+ element: element
211
+ )
212
+ end
213
+ end
214
+
215
+ # SETHEAD.TXT (code, name, func_group) + SETDETL.TXT structure.
216
+ def read_transaction_sets
217
+ each_csv("SETHEAD.TXT") do |row|
218
+ code = row[0]
219
+ next if @transaction_sets_by_code.key?(code)
220
+
221
+ @transaction_sets_by_code[code] = Models::TransactionSet.new(
222
+ code: code,
223
+ func_group: presence(row[2]),
224
+ name: row[1],
225
+ release: nil, # wired below
226
+ table_definitions: []
227
+ )
228
+ end
229
+
230
+ read_structure
231
+
232
+ # Wire each transaction set back to its release and order its tables.
233
+ @transaction_sets_by_code.each_value do |ts|
234
+ ts.table_definitions.sort_by!(&:position)
235
+ end
236
+ end
237
+
238
+ # SETDETL.TXT: ts, area, sequence, segment, requirement, max_use,
239
+ # loop_level, loop_repeat, loop_id. Reconstructs the table/loop tree by
240
+ # tracking a per-(ts, area) loop stack indexed by level.
241
+ def read_structure
242
+ tables_by_ts = Hash.new { |h, k| h[k] = {} }
243
+ loop_stack_by_context = Hash.new { |h, k| h[k] = [] }
244
+ path = find_file("SETDETL.TXT")
245
+
246
+ each_csv("SETDETL.TXT") do |row|
247
+ ts_code = row[0]
248
+ area = row[1].to_i
249
+ sequence = row[2]
250
+ segment_code = row[3]
251
+ requirement_code = row[4]
252
+ max_use = row[5]
253
+ loop_level = row[6].to_i
254
+ loop_repeat = row[7]
255
+ loop_id = presence(row[8])
256
+
257
+ transaction_set = @transaction_sets_by_code[ts_code] or next
258
+ segment = @segments_by_code[segment_code] or next
259
+
260
+ where = structure_row(path, ts_code, area, sequence, segment_code)
261
+
262
+ table = (tables_by_ts[ts_code][area] ||= build_table(transaction_set, area))
263
+ context_key = "#{ts_code}_#{area}"
264
+ stack = loop_stack_by_context[context_key]
265
+
266
+ parent =
267
+ if loop_level.zero?
268
+ loop_stack_by_context[context_key] = []
269
+ table
270
+ elsif loop_id
271
+ # Every row carrying a loop_id opens a loop (member rows carry
272
+ # an empty loop_id), and loop IDs are only unique within their
273
+ # nesting context — the same ID can open distinct loops at
274
+ # different positions in one area, so no caching by ID here.
275
+ reject_zero_loop_id!(loop_id, where)
276
+ loop_parent = stack[loop_level - 1] || table
277
+
278
+ loop_def = Models::LoopDefinition.new(
279
+ identifier: loop_id,
280
+ max_reps: parse_reps!(loop_repeat, "loop repeat", where),
281
+ position: sequence.to_i,
282
+ children: []
283
+ )
284
+ loop_parent.children << loop_def
285
+
286
+ stack[loop_level] = loop_def
287
+ loop_stack_by_context[context_key] = stack[0..loop_level]
288
+ loop_def
289
+ else
290
+ stack[loop_level] || table
291
+ end
292
+
293
+ position = sequence.to_i
294
+ next if parent.children.any? { |c| c.segment_use? && c.position == position }
295
+
296
+ parent.children << Models::SegmentUse.new(
297
+ segment: segment,
298
+ x12_sequence: sequence,
299
+ position: position,
300
+ requirement: map_requirement(requirement_code),
301
+ max_reps: parse_reps!(max_use, "maximum use", where)
302
+ )
303
+ end
304
+ end
305
+
306
+ # Identifies one SETDETL row the way a human finds it in the file.
307
+ def structure_row(path, ts_code, area, sequence, segment_code)
308
+ "#{path}: transaction set #{ts_code}, area #{area}, sequence #{sequence} " \
309
+ "(#{segment_code})"
310
+ end
311
+
312
+ # A zero repeat count is not representable: RepeatCount.bounded(0)
313
+ # raises, so the emitted grammar dies the moment a consumer loads it,
314
+ # naming neither the release nor the transaction set nor the row - by
315
+ # then the source distribution is a long way behind you. Catch it here,
316
+ # where the row can still be pointed at.
317
+ #
318
+ # Both this and reject_zero_loop_id! guard the same underlying defect: a
319
+ # column shift in the source distribution, where a value lands one field
320
+ # left of where it belongs and the neighbouring release carries the row
321
+ # correctly.
322
+ def parse_reps!(value, column, where)
323
+ reps = parse_reps(value)
324
+ return reps if reps.nil? || reps.positive?
325
+
326
+ raise ArgumentError,
327
+ "#{where}: #{column} #{value.inspect} parses to a repeat count of #{reps}, which no loop " \
328
+ "or segment can carry. The row is defective, usually a column shift - compare it " \
329
+ "against the same row in a neighbouring release and correct it in the table data."
330
+ end
331
+
332
+ # The shift is worse when it lands in the loop id column, because
333
+ # nothing downstream objects: a loop identified as "0" builds, loads and
334
+ # parses, quietly reparenting every row that follows it.
335
+ def reject_zero_loop_id!(loop_id, where)
336
+ return unless loop_id == "0"
337
+
338
+ raise ArgumentError,
339
+ "#{where}: \"0\" is not a loop identifier. The row is defective, usually a column " \
340
+ "shift - compare it against the same row in a neighbouring release and correct it " \
341
+ "in the table data."
342
+ end
343
+
344
+ def build_table(transaction_set, area)
345
+ name = { 1 => "Heading", 2 => "Detail", 3 => "Summary" }[area]
346
+ enum = { 1 => "heading", 2 => "detail", 3 => "summary" }[area]
347
+
348
+ table = Models::TableDefinition.new(area: enum, name: name, position: area, children: [])
349
+ transaction_set.table_definitions << table
350
+ table
351
+ end
352
+
353
+ # FREEFORM.TXT: tagged blocks. *ELECOD -> element code lists,
354
+ # *SEGNTE (note type N) -> syntax notes. A block runs from one *TAG line
355
+ # to the next; the final block is flushed at EOF (the Tediware importer
356
+ # does not, dropping the last block - fixed here).
357
+ def read_freeform
358
+ path = find_file("FREEFORM.TXT")
359
+ return unless path
360
+
361
+ current_tag = nil
362
+ current_data = []
363
+
364
+ flush = lambda do
365
+ process_freeform_block(current_tag, current_data) if current_tag && current_data.size >= 2
366
+ end
367
+
368
+ read_source(path).each_line do |line|
369
+ line = line.strip
370
+
371
+ if line.start_with?("*")
372
+ flush.call
373
+ current_tag = line[1..]
374
+ current_data = []
375
+ elsif !line.empty?
376
+ current_data << line
377
+ end
378
+ end
379
+
380
+ flush.call # EOF flush - emits the final block the importer drops
381
+ end
382
+
383
+ def process_freeform_block(tag, data)
384
+ case tag
385
+ when "ELECOD" then process_element_code(data)
386
+ when "SEGNTE" then process_segment_note(data)
387
+ end
388
+ end
389
+
390
+ def process_element_code(data)
391
+ header = data[0].split(",").map(&:strip)
392
+ element = @elements_by_code[header[0]] or return
393
+
394
+ code_value = header[2]
395
+ partition = presence(header[1])
396
+ paragraph = [header[3].to_i, 1].max
397
+ name = data[1..].join(" ")
398
+
399
+ return if element.element_codes.any? { |ec| ec.code == code_value && ec.partition == partition }
400
+
401
+ element.element_codes << Models::ElementCode.new(
402
+ code: code_value,
403
+ name: name,
404
+ paragraph: paragraph,
405
+ partition: partition
406
+ )
407
+ end
408
+
409
+ SYNTAX_CONDITION = {
410
+ "P" => "paired",
411
+ "R" => "required",
412
+ "C" => "conditional",
413
+ "E" => "excluded",
414
+ "L" => "list_conditional"
415
+ }.freeze
416
+
417
+ def process_segment_note(data)
418
+ header = data[0].split(",").map(&:strip)
419
+ segment = @segments_by_code[header[0]] or return
420
+ note_type_code = header[2] # N = syntax, S = semantic, C = comment
421
+ return unless note_type_code == "N"
422
+
423
+ note_text = data[1..].join(" ")
424
+ code = note_text.split.first
425
+ return unless code && code.length >= 2
426
+
427
+ condition_type = SYNTAX_CONDITION[code[0]] or return
428
+ element_positions = code[1..].scan(/\d{2}/).map(&:to_i)
429
+
430
+ if segment.syntax_notes.any? { |sn| sn.condition_type == condition_type && sn.element_positions == element_positions }
431
+ return
432
+ end
433
+
434
+ segment.syntax_notes << Models::SyntaxNote.new(
435
+ condition_type: condition_type,
436
+ element_positions: element_positions,
437
+ description: note_text
438
+ )
439
+ end
440
+
441
+ # --- helpers -------------------------------------------------------
442
+
443
+ def each_csv(filename)
444
+ path = find_file(filename) or return
445
+ CSV.parse(read_source(path)) { |row| yield row }
446
+ end
447
+
448
+ # Reads one table-data file and returns validated, normalized UTF-8.
449
+ #
450
+ # The whole file is decoded up front rather than streamed: these files
451
+ # are small, and one decode point is what makes the encoding a single
452
+ # declared fact instead of something each read site guesses at.
453
+ def read_source(path)
454
+ text = decode(path)
455
+ text = text.gsub(PUNCTUATION_PATTERN, PUNCTUATION)
456
+ reject_c1_controls!(text, path)
457
+ text
458
+ end
459
+
460
+ def decode(path)
461
+ raw = File.binread(path).force_encoding(source_encoding)
462
+ raise decode_error(path, "invalid byte sequence") unless raw.valid_encoding?
463
+
464
+ # A UTF-8 BOM would otherwise ride along in the first field of the
465
+ # first record and corrupt its key.
466
+ raw.encode("UTF-8").delete_prefix(BOM)
467
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e
468
+ raise decode_error(path, e.message)
469
+ end
470
+
471
+ def source_encoding
472
+ SOURCE_ENCODINGS.fetch(release_code, DEFAULT_SOURCE_ENCODING)
473
+ end
474
+
475
+ def decode_error(path, detail)
476
+ provenance =
477
+ if SOURCE_ENCODINGS.key?(release_code)
478
+ "declared for release #{release_code}: #{detail}. Correct that release's entry in"
479
+ else
480
+ "assumed for release #{release_code}, which is not declared: #{detail}. " \
481
+ "Add the release's real encoding to"
482
+ end
483
+
484
+ ArgumentError.new(
485
+ "Could not decode #{path} as #{source_encoding}, #{provenance} " \
486
+ "#{self.class}::SOURCE_ENCODINGS."
487
+ )
488
+ end
489
+
490
+ def reject_c1_controls!(text, path)
491
+ return if text.count(C1_CONTROLS).zero?
492
+
493
+ text.each_line.with_index(1) do |line, lineno|
494
+ char = line.each_char.find { |c| C1_CONTROLS.include?(c) } or next
495
+
496
+ raise ArgumentError,
497
+ format("C1 control character U+%04X at %s line %d, which means release %s is not " \
498
+ "%s. Correct %s::SOURCE_ENCODINGS.",
499
+ char.ord, path, lineno, release_code, source_encoding, self.class)
500
+ end
501
+ end
502
+
503
+ # Case-insensitive file lookup: X12 table data uses inconsistent casing
504
+ # across releases (ELEHEAD.TXT vs elehead.txt). Returns nil if absent.
505
+ def find_file(filename)
506
+ exact = File.join(dir, filename)
507
+ return exact if File.exist?(exact)
508
+
509
+ Dir.glob(File.join(dir, "*")).find { |f| File.basename(f).casecmp?(filename) }
510
+ end
511
+
512
+ def map_requirement(code)
513
+ case code
514
+ when "M" then "Mandatory"
515
+ when "O" then "Optional"
516
+ when "C" then "Conditional"
517
+ when "N" then "NotUsed"
518
+ else "Optional"
519
+ end
520
+ end
521
+
522
+ # ">1" means unbounded (nil); anything else is a fixed count.
523
+ def parse_reps(value)
524
+ value == ">1" ? nil : value.to_i
525
+ end
526
+
527
+ # SEGDETL field 5 is the element's repetition count. ">1" means unbounded
528
+ # (nil); a blank, zero, or absent count is a single occurrence (1), which
529
+ # the engine represents as RepeatCount.bounded(1) for a non-repeating use.
530
+ def parse_element_reps(value)
531
+ return nil if value == ">1"
532
+
533
+ n = value.to_i
534
+ n.positive? ? n : 1
535
+ end
536
+
537
+ def positive_or_nil(value)
538
+ n = value.to_i
539
+ n.positive? ? n : nil
540
+ end
541
+
542
+ def presence(value)
543
+ return nil if value.nil?
544
+
545
+ stripped = value.strip
546
+ stripped.empty? ? nil : stripped
547
+ end
548
+ end
549
+ end
550
+ end
551
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stupidedi
4
+ module Schema
5
+ module Generation
6
+ # Generates functional_group_def.rb - the GS/GE envelope for a release.
7
+ class FunctionalGroupGenerator
8
+ include Support
9
+
10
+ def initialize(release, namespace: "Edi")
11
+ @release = release
12
+ @namespace = namespace
13
+ end
14
+
15
+ def generate
16
+ validate_release!
17
+ build_ruby_content
18
+ end
19
+
20
+ def output_path
21
+ "#{namespace_path}/#{underscore(version_module)}/functional_group_def.rb"
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :release
27
+
28
+ def release_code
29
+ release.code
30
+ end
31
+
32
+ def build_ruby_content
33
+ <<~RUBY
34
+ # frozen_string_literal: true
35
+ require "tediparse"
36
+
37
+ module #{namespace}
38
+ module #{version_module}
39
+ s = Stupidedi::Schema
40
+ r = #{namespace}::#{version_module}::ElementReqs
41
+ sd = #{namespace}::#{version_module}::SegmentDefs
42
+
43
+ FunctionalGroupDef = Class.new(s::FunctionalGroupDef) do
44
+ # @return [FunctionalGroupVal]
45
+ def empty
46
+ Stupidedi::Values::FunctionalGroupVal.new(self, [])
47
+ end
48
+
49
+ # @return [Module]
50
+ def segment_dict
51
+ #{namespace}::#{version_module}::SegmentDefs
52
+ end
53
+ end.new "#{release_code}",
54
+ [ sd::GS.use(1, r::Mandatory, s::RepeatCount.bounded(1)) ],
55
+ [ sd::GE.use(2, r::Mandatory, s::RepeatCount.bounded(1)) ]
56
+
57
+ end
58
+ end
59
+ RUBY
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end