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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +319 -0
- data/LICENSE +26 -0
- data/README.md +308 -0
- data/Rakefile +33 -0
- data/bin/tediparse +102 -0
- data/doc/Defining.md +97 -0
- data/doc/Generating-Grammars.md +241 -0
- data/doc/Generating.md +332 -0
- data/doc/Navigating.md +670 -0
- data/doc/Parsing.md +133 -0
- data/doc/README.md +51 -0
- data/doc/Serializing.md +100 -0
- data/doc/Tokenizing.md +136 -0
- data/doc/Validating.md +140 -0
- data/lib/ruby/array.rb +226 -0
- data/lib/ruby/blank.rb +52 -0
- data/lib/ruby/exception.rb +14 -0
- data/lib/ruby/hash.rb +14 -0
- data/lib/ruby/module.rb +60 -0
- data/lib/ruby/object.rb +56 -0
- data/lib/ruby/string.rb +89 -0
- data/lib/ruby/to_d.rb +82 -0
- data/lib/ruby/to_date.rb +26 -0
- data/lib/ruby/to_time.rb +21 -0
- data/lib/ruby/try.rb +46 -0
- data/lib/stupidedi/builder.rb +6 -0
- data/lib/stupidedi/color.rb +94 -0
- data/lib/stupidedi/config/code_list_config.rb +45 -0
- data/lib/stupidedi/config/functional_group_config.rb +72 -0
- data/lib/stupidedi/config/interchange_config.rb +86 -0
- data/lib/stupidedi/config/transaction_set_config.rb +73 -0
- data/lib/stupidedi/config.rb +100 -0
- data/lib/stupidedi/either.rb +286 -0
- data/lib/stupidedi/exceptions/invalid_element_error.rb +7 -0
- data/lib/stupidedi/exceptions/invalid_schema_error.rb +7 -0
- data/lib/stupidedi/exceptions/missing_grammar_error.rb +50 -0
- data/lib/stupidedi/exceptions/output_error.rb +7 -0
- data/lib/stupidedi/exceptions/parse_error.rb +7 -0
- data/lib/stupidedi/exceptions/stupidedi_error.rb +7 -0
- data/lib/stupidedi/exceptions/tokenize_error.rb +7 -0
- data/lib/stupidedi/exceptions/zipper_error.rb +7 -0
- data/lib/stupidedi/exceptions.rb +13 -0
- data/lib/stupidedi/inspect.rb +26 -0
- data/lib/stupidedi/interchanges/element_types/separator_val.rb +79 -0
- data/lib/stupidedi/interchanges/element_types/special_val.rb +48 -0
- data/lib/stupidedi/interchanges/element_types.rb +11 -0
- data/lib/stupidedi/interchanges.rb +21 -0
- data/lib/stupidedi/parser/builder_dsl.rb +292 -0
- data/lib/stupidedi/parser/constraint_table.rb +566 -0
- data/lib/stupidedi/parser/generation.rb +147 -0
- data/lib/stupidedi/parser/identifier_stack.rb +248 -0
- data/lib/stupidedi/parser/instruction.rb +112 -0
- data/lib/stupidedi/parser/instruction_table.rb +222 -0
- data/lib/stupidedi/parser/navigation.rb +786 -0
- data/lib/stupidedi/parser/state_machine.rb +62 -0
- data/lib/stupidedi/parser/states/abstract_state.rb +385 -0
- data/lib/stupidedi/parser/states/failure_state.rb +69 -0
- data/lib/stupidedi/parser/states/functional_group_state.rb +101 -0
- data/lib/stupidedi/parser/states/initial_state.rb +62 -0
- data/lib/stupidedi/parser/states/interchange_state.rb +96 -0
- data/lib/stupidedi/parser/states/loop_state.rb +74 -0
- data/lib/stupidedi/parser/states/table_state.rb +90 -0
- data/lib/stupidedi/parser/states/transaction_set_state.rb +120 -0
- data/lib/stupidedi/parser/states/transmission_state.rb +59 -0
- data/lib/stupidedi/parser/tokenization.rb +195 -0
- data/lib/stupidedi/parser.rb +32 -0
- data/lib/stupidedi/reader/input/abstract_input.rb +137 -0
- data/lib/stupidedi/reader/input/delegated_input.rb +112 -0
- data/lib/stupidedi/reader/input/file_input.rb +157 -0
- data/lib/stupidedi/reader/input.rb +31 -0
- data/lib/stupidedi/reader/position.rb +78 -0
- data/lib/stupidedi/reader/result.rb +172 -0
- data/lib/stupidedi/reader/segment_dict.rb +176 -0
- data/lib/stupidedi/reader/separators.rb +90 -0
- data/lib/stupidedi/reader/stream_reader.rb +173 -0
- data/lib/stupidedi/reader/token_reader.rb +465 -0
- data/lib/stupidedi/reader/tokens/component_element_tok.rb +71 -0
- data/lib/stupidedi/reader/tokens/composite_element_tok.rb +85 -0
- data/lib/stupidedi/reader/tokens/repeated_element_tok.rb +74 -0
- data/lib/stupidedi/reader/tokens/segment_tok.rb +74 -0
- data/lib/stupidedi/reader/tokens/simple_element_tok.rb +76 -0
- data/lib/stupidedi/reader.rb +121 -0
- data/lib/stupidedi/schema/abstract_def.rb +76 -0
- data/lib/stupidedi/schema/abstract_element_def.rb +35 -0
- data/lib/stupidedi/schema/abstract_element_use.rb +47 -0
- data/lib/stupidedi/schema/abstract_use.rb +79 -0
- data/lib/stupidedi/schema/code_list.rb +99 -0
- data/lib/stupidedi/schema/component_element_use.rb +76 -0
- data/lib/stupidedi/schema/composite_element_def.rb +103 -0
- data/lib/stupidedi/schema/composite_element_use.rb +78 -0
- data/lib/stupidedi/schema/element_req.rb +57 -0
- data/lib/stupidedi/schema/functional_group_def.rb +124 -0
- data/lib/stupidedi/schema/generation/definition_generator.rb +139 -0
- data/lib/stupidedi/schema/generation/element_generator.rb +221 -0
- data/lib/stupidedi/schema/generation/flat_file_reader.rb +551 -0
- data/lib/stupidedi/schema/generation/functional_group_generator.rb +64 -0
- data/lib/stupidedi/schema/generation/interchange_generator.rb +145 -0
- data/lib/stupidedi/schema/generation/master_loader_generator.rb +121 -0
- data/lib/stupidedi/schema/generation/models.rb +85 -0
- data/lib/stupidedi/schema/generation/module_loader_generator.rb +64 -0
- data/lib/stupidedi/schema/generation/registration_generator.rb +230 -0
- data/lib/stupidedi/schema/generation/runner.rb +161 -0
- data/lib/stupidedi/schema/generation/segment_generator.rb +130 -0
- data/lib/stupidedi/schema/generation/support.rb +78 -0
- data/lib/stupidedi/schema/generation/support_modules_generator.rb +126 -0
- data/lib/stupidedi/schema/generation/version_modules.rb +35 -0
- data/lib/stupidedi/schema/generation.rb +94 -0
- data/lib/stupidedi/schema/interchange_def.rb +103 -0
- data/lib/stupidedi/schema/loop_def.rb +156 -0
- data/lib/stupidedi/schema/repeat_count.rb +86 -0
- data/lib/stupidedi/schema/segment_def.rb +122 -0
- data/lib/stupidedi/schema/segment_req.rb +46 -0
- data/lib/stupidedi/schema/segment_use.rb +99 -0
- data/lib/stupidedi/schema/simple_element_def.rb +51 -0
- data/lib/stupidedi/schema/simple_element_use.rb +83 -0
- data/lib/stupidedi/schema/syntax_note.rb +52 -0
- data/lib/stupidedi/schema/table_def.rb +178 -0
- data/lib/stupidedi/schema/transaction_set_def.rb +125 -0
- data/lib/stupidedi/schema.rb +30 -0
- data/lib/stupidedi/sets.rb +42 -0
- data/lib/stupidedi/transaction_sets/builder/dsl.rb +192 -0
- data/lib/stupidedi/transaction_sets/builder.rb +188 -0
- data/lib/stupidedi/transaction_sets/common/implementations/element_reqs.rb +37 -0
- data/lib/stupidedi/transaction_sets/common/implementations/segment_reqs.rb +31 -0
- data/lib/stupidedi/transaction_sets/common/implementations.rb +11 -0
- data/lib/stupidedi/transaction_sets/common.rb +8 -0
- data/lib/stupidedi/transaction_sets/validation/ambiguity.rb +395 -0
- data/lib/stupidedi/transaction_sets/validation/implementation.rb +12 -0
- data/lib/stupidedi/transaction_sets/validation.rb +9 -0
- data/lib/stupidedi/transaction_sets.rb +25 -0
- data/lib/stupidedi/values/abstract_element_val.rb +19 -0
- data/lib/stupidedi/values/abstract_val.rb +133 -0
- data/lib/stupidedi/values/composite_element_val.rb +102 -0
- data/lib/stupidedi/values/functional_group_val.rb +105 -0
- data/lib/stupidedi/values/interchange_val.rb +102 -0
- data/lib/stupidedi/values/invalid_envelope_val.rb +61 -0
- data/lib/stupidedi/values/invalid_segment_val.rb +89 -0
- data/lib/stupidedi/values/loop_val.rb +73 -0
- data/lib/stupidedi/values/repeated_element_val.rb +113 -0
- data/lib/stupidedi/values/segment_val.rb +105 -0
- data/lib/stupidedi/values/segment_val_group.rb +20 -0
- data/lib/stupidedi/values/simple_element_val.rb +80 -0
- data/lib/stupidedi/values/table_val.rb +69 -0
- data/lib/stupidedi/values/transaction_set_val.rb +69 -0
- data/lib/stupidedi/values/transmission_val.rb +56 -0
- data/lib/stupidedi/values.rb +22 -0
- data/lib/stupidedi/version.rb +4 -0
- data/lib/stupidedi/versions/common/element_reqs.rb +13 -0
- data/lib/stupidedi/versions/common/element_types/an.rb +386 -0
- data/lib/stupidedi/versions/common/element_types/dt.rb +572 -0
- data/lib/stupidedi/versions/common/element_types/id.rb +304 -0
- data/lib/stupidedi/versions/common/element_types/nn.rb +312 -0
- data/lib/stupidedi/versions/common/element_types/operators.rb +128 -0
- data/lib/stupidedi/versions/common/element_types/r.rb +342 -0
- data/lib/stupidedi/versions/common/element_types/simple_element.rb +73 -0
- data/lib/stupidedi/versions/common/element_types/tm.rb +347 -0
- data/lib/stupidedi/versions/common/element_types.rb +29 -0
- data/lib/stupidedi/versions/common/segment_reqs.rb +15 -0
- data/lib/stupidedi/versions/common/syntax_notes.rb +172 -0
- data/lib/stupidedi/versions/common.rb +11 -0
- data/lib/stupidedi/versions.rb +25 -0
- data/lib/stupidedi/writer/claredi.rb +178 -0
- data/lib/stupidedi/writer/default.rb +119 -0
- data/lib/stupidedi/writer.rb +7 -0
- data/lib/stupidedi/zipper/abstract_cursor.rb +351 -0
- data/lib/stupidedi/zipper/dangling_cursor.rb +103 -0
- data/lib/stupidedi/zipper/edited_cursor.rb +157 -0
- data/lib/stupidedi/zipper/memoized_cursor.rb +133 -0
- data/lib/stupidedi/zipper/path.rb +132 -0
- data/lib/stupidedi/zipper/root_cursor.rb +120 -0
- data/lib/stupidedi/zipper/stack_cursor.rb +107 -0
- data/lib/stupidedi/zipper.rb +45 -0
- data/lib/stupidedi.rb +69 -0
- data/lib/tediparse.rb +1 -0
- 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
|