@rip-lang/x12 0.1.2

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 (4) hide show
  1. package/README.md +119 -0
  2. package/bin/rip-x12 +13 -0
  3. package/package.json +44 -0
  4. package/x12.rip +687 -0
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ <img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/rip.png" style="width:50px" /> <br>
2
+
3
+ # Rip X12 - @rip-lang/x12
4
+
5
+ > **X12 EDI parser, editor, and query engine**
6
+
7
+ Parse, query, and edit X12 EDI transactions (270/271, 835, 837, etc.) with a path-based addressing system. Auto-detects field, repetition, component, and segment separators from the ISA header.
8
+
9
+ ## Quick Start
10
+
11
+ ```bash
12
+ # Install
13
+ bun add -g @rip-lang/x12
14
+
15
+ # Show fields
16
+ rip-x12 -f message.x12
17
+
18
+ # Query specific values
19
+ rip-x12 -q "ISA-6,GS-2" message.x12
20
+
21
+ # Show message body
22
+ rip-x12 -m message.x12
23
+
24
+ # Recursive directory scan
25
+ rip-x12 -d -f /path/to/edi/
26
+ ```
27
+
28
+ ## Library Usage
29
+
30
+ ```coffee
31
+ import { X12 } from '@rip-lang/x12'
32
+
33
+ # Parse an X12 message
34
+ x12 = new X12 rawString
35
+
36
+ # Get values using path addressing
37
+ sender = x12.get "ISA-6" # ISA segment, field 6
38
+ receiver = x12.get "ISA-8" # ISA segment, field 8
39
+ code = x12.get "EB-1" # First EB segment, field 1
40
+ third = x12.get "EB(3)-4" # 3rd EB segment, field 4
41
+ comp = x12.get "EB(3)-4(2).1" # 3rd EB, field 4, repeat 2, component 1
42
+ count = x12.get "EB(?)" # Count of EB segments
43
+
44
+ # Set values
45
+ x12.set "ISA-6", "NEWSENDER"
46
+ x12.set "GS-2", "NEWID"
47
+
48
+ # Query multiple values at once
49
+ [sender, receiver] = x12.find "ISA-6", "ISA-8"
50
+
51
+ # Display formatted output
52
+ x12.show 'down', 'full' # lowercase segments, show body
53
+
54
+ # Iterate segments
55
+ x12.each (row) -> console.log row[0]
56
+ x12.each 'EB', (row) -> console.log row
57
+
58
+ # Get raw X12 string
59
+ output = x12.raw()
60
+ ```
61
+
62
+ ## Path Addressing
63
+
64
+ ```
65
+ seg(num)-fld(rep).com
66
+
67
+ seg — Segment name (2-3 chars): ISA, GS, EB, CLP, etc.
68
+ (num) — Segment occurrence: (1)=first, (3)=third, (?)=count, (*)=all, (+)=new
69
+ -fld — Field number (1-based)
70
+ (rep) — Repetition within field: (1)=first, (?)=count, (*)=all, (+)=new
71
+ .com — Component within repeat (1-based)
72
+ ```
73
+
74
+ ### Examples
75
+
76
+ | Path | Meaning |
77
+ |------|---------|
78
+ | `ISA-6` | ISA segment, field 6 |
79
+ | `EB(3)-4` | 3rd EB segment, field 4 |
80
+ | `EB(*)-4` | Field 4 from ALL EB segments |
81
+ | `EB(?)-4` | COUNT of EB segments |
82
+ | `EB(3)-4(2)` | 3rd EB, field 4, 2nd repetition |
83
+ | `EB(3)-4(2).1` | 3rd EB, field 4, 2nd rep, 1st component |
84
+
85
+ ## Separators
86
+
87
+ X12 uses four delimiter levels, auto-detected from the ISA header:
88
+
89
+ | Separator | ISA Position | Default | Purpose |
90
+ |-----------|-------------|---------|---------|
91
+ | Field | 4 | `*` | Separates fields within a segment |
92
+ | Repetition | 83 | `^` | Separates repeated values within a field |
93
+ | Component | 105 | `:` | Separates sub-components |
94
+ | Segment | 106 | `~` | Ends a segment |
95
+
96
+ ## CLI Options
97
+
98
+ | Flag | Description |
99
+ |------|-------------|
100
+ | `-a, --after <date>` | Filter files modified after date (YYYYMMDD) |
101
+ | `--ansi` | ANSI color output |
102
+ | `-c, --count` | Count messages |
103
+ | `-d, --dive` | Recursive directory scan |
104
+ | `-f, --fields` | Show fields |
105
+ | `-F, --fields-only` | Fields only, no repeat indicators |
106
+ | `-h, --help` | Help |
107
+ | `-i, --ignore` | Skip malformed files |
108
+ | `-l, --lower` | Lowercase segment names |
109
+ | `-m, --message` | Show message body |
110
+ | `-p, --path` | Show file path |
111
+ | `-q, --query <val>` | Query specific values |
112
+ | `-s, --spacer` | Blank line between messages |
113
+ | `-t, --tsv` | Tab-delimited output |
114
+ | `-v, --version` | Show version |
115
+
116
+ ## Links
117
+
118
+ - [Rip Language](https://github.com/shreeve/rip-lang)
119
+ - [X12 Standard](https://x12.org)
package/bin/rip-x12 ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { execFileSync } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+
7
+ const x12Rip = join(dirname(fileURLToPath(import.meta.url)), '..', 'x12.rip');
8
+
9
+ try {
10
+ execFileSync('rip', [x12Rip, ...process.argv.slice(2)], { stdio: 'inherit' });
11
+ } catch (e) {
12
+ process.exit(e.status || 1);
13
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@rip-lang/x12",
3
+ "version": "0.1.2",
4
+ "description": "X12 EDI parser, editor, and query engine — path-based addressing, separator auto-detection, zero dependencies",
5
+ "type": "module",
6
+ "main": "x12.rip",
7
+ "bin": {
8
+ "rip-x12": "./bin/rip-x12"
9
+ },
10
+ "exports": {
11
+ ".": "./x12.rip"
12
+ },
13
+ "keywords": [
14
+ "x12",
15
+ "edi",
16
+ "healthcare",
17
+ "parser",
18
+ "270",
19
+ "271",
20
+ "835",
21
+ "837",
22
+ "bun",
23
+ "rip"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/shreeve/rip-lang.git",
28
+ "directory": "packages/x12"
29
+ },
30
+ "homepage": "https://github.com/shreeve/rip-lang/tree/main/packages/x12#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/shreeve/rip-lang/issues"
33
+ },
34
+ "author": "Steve Shreeve <steve.shreeve@gmail.com>",
35
+ "license": "MIT",
36
+ "dependencies": {
37
+ "rip-lang": "^3.10.0"
38
+ },
39
+ "files": [
40
+ "x12.rip",
41
+ "bin/rip-x12",
42
+ "README.md"
43
+ ]
44
+ }
package/x12.rip ADDED
@@ -0,0 +1,687 @@
1
+ # ==============================================================================
2
+ # x12.rip: X12 EDI parser, editor, and query engine
3
+ #
4
+ # Author: Steve Shreeve <steve.shreeve@gmail.com>
5
+ # Date: Feb 9, 2026
6
+ #
7
+ # License: MIT
8
+ # ==============================================================================
9
+
10
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
11
+ import { basename, join } from 'path'
12
+
13
+ # ==[ Helpers ]=================================================================
14
+
15
+ escRe = (s) -> s.replace /[.*+?^${}()|[\]\\]/g, '\\$&'
16
+
17
+ ISA_WIDTHS = [3, 2, 10, 2, 10, 2, 15, 2, 15, 6, 4, 1, 5, 9, 1, 1]
18
+
19
+ SELECTOR = /^(..[^-.(]?)(?:\((\d*|[+!?*]?)\))?[-.]?(\d+)?(?:\((\d*|[+!?*]?)\))?[-.]?(\d+)?$/
20
+
21
+ isaWidthsStr = (str) ->
22
+ sep = str[3]
23
+ return str unless sep
24
+ parts = str.split sep
25
+ for part, i in parts
26
+ len = ISA_WIDTHS[i]
27
+ if part and len and part.length isnt len
28
+ parts[i] = part.padEnd(len).slice 0, len
29
+ parts.join sep
30
+
31
+ ansiColor = (str, fg, bgc) ->
32
+ m = fg.match /^([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/
33
+ return str unless m
34
+ r = parseInt m[1], 16
35
+ g = parseInt m[2], 16
36
+ b = parseInt m[3], 16
37
+ pre = "\x1b[38;2;#{r};#{g};#{b}m"
38
+ post = "\x1b[39m"
39
+ pre + str + post
40
+
41
+ # ==[ X12 Class ]===============================================================
42
+
43
+ class X12
44
+ constructor: (obj) ->
45
+ # Initialize from input type
46
+ if typeof obj is 'string'
47
+ if obj.length > 0
48
+ @str = obj
49
+ else if obj instanceof X12
50
+ @str = obj.toString()
51
+
52
+ # Default ISA template
53
+ @str or= isaWidthsStr "ISA*00**00**ZZ**ZZ****^*00501**0*P*:~"
54
+
55
+ # Detect separators from ISA header
56
+ m = @str.match /^ISA(.).{78}(.).{21}(.)(.)/
57
+ throw new Error "malformed X12" unless m
58
+ @fld = m[1]
59
+ @rep = m[2]
60
+ @com = m[3]
61
+ @seg = m[4]
62
+ @rep = "^" if @rep is "U"
63
+
64
+ # Parse based on input type
65
+ if typeof obj is 'string' or not obj?
66
+ @toArray()
67
+ @str = null
68
+ else if Array.isArray obj
69
+ arr = obj.slice 0
70
+ while arr.length >= 2
71
+ p = arr.shift()
72
+ v = arr.shift()
73
+ @set p, v
74
+ else if typeof obj is 'object'
75
+ for own key, val of obj
76
+ @set key, val if val?
77
+
78
+ @toString() unless @str
79
+
80
+ # ==[ Serialization ]=========================================================
81
+
82
+ toArray: ->
83
+ return @ary if @ary
84
+ re = new RegExp "[#{escRe @seg}\\r\\n]+", 'g'
85
+ @ary = @str.trim().split(re).map (s) => s.split @fld, -1
86
+ @ary
87
+
88
+ toString: ->
89
+ return @str if @str
90
+ @str = @ary.map((seg) => seg.join(@fld) + @seg).join("\n")
91
+ if @str.endsWith "\n"
92
+ @str = @str.slice 0, -1
93
+ @str
94
+
95
+ raw: -> @toString().replace(/\n/g, '').toUpperCase()
96
+
97
+ # ==[ ISA field widths ]======================================================
98
+
99
+ isaWidths: (row) ->
100
+ for was, i in row
101
+ len = ISA_WIDTHS[i]
102
+ if was and len and was.length isnt len
103
+ row[i] = was.padEnd(len).slice 0, len
104
+ row
105
+
106
+ # ==[ Display ]===============================================================
107
+
108
+ show: (...opts) ->
109
+ full = opts.includes 'full'
110
+ deep = opts.includes 'deep'
111
+ down = opts.includes 'down'
112
+ asList = opts.includes 'list'
113
+ hide = opts.includes 'hide'
114
+ only = opts.includes 'only'
115
+ useAnsi = opts.includes 'ansi'
116
+ left = 15
117
+
118
+ out = if full then [@toString()] else []
119
+
120
+ unless hide
121
+ out.push "" if full
122
+ nums = {}
123
+ segs = @toArray()
124
+ for flds in segs
125
+ seg = if down then flds[0].toLowerCase() else flds[0].toUpperCase()
126
+ nums[seg] = (nums[seg] or 0) + 1
127
+ num = nums[seg]
128
+ for fld, j in flds
129
+ continue if j is 0
130
+ continue if not fld or fld.length is 0
131
+ if deep
132
+ reps = fld.split @rep
133
+ if reps.length > 1
134
+ for item, k in reps
135
+ suffix = if num > 1 and not only then "(#{num})" else ""
136
+ tag = "#{seg}#{suffix}-#{j}(#{k + 1})"
137
+ out.push tag.padEnd(left) + item
138
+ continue
139
+ suffix = if num > 1 and not only then "(#{num})" else ""
140
+ tag = "#{seg}#{suffix}-#{j}"
141
+ val = if useAnsi then ansiColor(fld, "ffffff", "") else fld
142
+ out.push tag.padEnd(left) + val
143
+
144
+ if asList then out else console.log out.join "\n"
145
+
146
+ # ==[ Normalize ]=============================================================
147
+
148
+ normalize: (obj) ->
149
+ if Array.isArray obj
150
+ for elt, i in obj
151
+ str = if typeof elt is 'string' then elt else String elt
152
+ obj[i] = str.toUpperCase()
153
+ else
154
+ str = if typeof obj is 'string' then obj else String obj
155
+ str.toUpperCase()
156
+
157
+ # ==[ Data access — unified get/set ]=========================================
158
+
159
+ data: (...args) ->
160
+ len = args.length
161
+ return @update args if len > 2
162
+ pos = args[0]
163
+ return @str unless pos?
164
+ val = args[1]
165
+
166
+ # Parse the selector
167
+ m = pos.match SELECTOR
168
+ throw new Error "bad selector '#{pos}'" unless m
169
+ seg = m[1]
170
+ wantPat = "^#{seg}[^#{escRe @seg}\\r\\n]*"
171
+ want = new RegExp wantPat, 'i'
172
+ wantG = new RegExp wantPat, 'gim'
173
+
174
+ numStr = m[2] or ""
175
+ isDigit = /^\d+$/
176
+ num = if numStr.length > 0 and isDigit.test numStr then parseInt numStr else null
177
+ newNum = numStr is "+"
178
+ askNum = numStr is "?"
179
+ allNum = numStr is "*"
180
+
181
+ fldStr = m[3]
182
+ fld = if fldStr? then parseInt fldStr else null
183
+ throw new Error "zero index on field" if len > 1 and fld is 0
184
+
185
+ repStr = m[4] or ""
186
+ rep = if repStr.length > 0 and isDigit.test repStr then parseInt repStr else null
187
+ newRep = repStr is "+"
188
+ askRep = repStr is "?"
189
+ allRep = repStr is "*"
190
+
191
+ comStr = m[5]
192
+ com = if comStr? then parseInt comStr else null
193
+ throw new Error "zero index on component" if len > 1 and com is 0
194
+
195
+ if len <= 1 # GET
196
+ @toString() unless @str
197
+ matches = @str.match(wantG) or []
198
+
199
+ if askNum and not askRep
200
+ return matches.length
201
+
202
+ if allNum
203
+ result = []
204
+ for match in matches
205
+ out = match
206
+ if fld?
207
+ out = out.split(@fld)[fld]
208
+ continue unless out?
209
+ if askRep
210
+ result.push out.split(@rep).length
211
+ continue
212
+ if rep? or (com? and not rep?)
213
+ rep ?= 1
214
+ out = out.split(@rep)[rep - 1]
215
+ continue unless out?
216
+ if com?
217
+ out = out.split(@com)[com - 1]
218
+ continue unless out?
219
+ result.push out
220
+ return result
221
+
222
+ num ?= 1
223
+ out = matches[num - 1]
224
+ return "" unless out?
225
+
226
+ if fld?
227
+ out = out.split(@fld)[fld]
228
+ return "" unless out?
229
+
230
+ if askRep
231
+ return out.split(@rep).length
232
+
233
+ if rep? or (com? and not rep?)
234
+ rep ?= 1
235
+ out = out.split(@rep)[rep - 1]
236
+ return "" unless out?
237
+
238
+ if com?
239
+ out = out.split(@com)[com - 1]
240
+ return "" unless out?
241
+
242
+ return out
243
+
244
+ else # SET
245
+ @toArray() unless @ary
246
+ @str = null
247
+
248
+ our = @ary.filter (now) -> want.test now[0]
249
+
250
+ unless allNum
251
+ num ?= 0
252
+ row = our[num - 1]
253
+ pad = if row then 0 else num - our.length
254
+ pad = 1 if (num is 0 and our.length is 0) or newNum
255
+ if pad > 0
256
+ for _i in [1..pad]
257
+ row = [seg.toUpperCase()]
258
+ @ary.push row
259
+ if newNum and val is 'num'
260
+ val = our.length + pad
261
+ our = [row]
262
+
263
+ # Prepare the value
264
+ val ?= ""
265
+ how = null
266
+
267
+ if not rep? and not com?
268
+ # replace fields
269
+ val = val.join(@fld) if Array.isArray val
270
+ val = String(val).split @fld, -1
271
+ how = 'fld'
272
+ else if fld? and rep? and not com?
273
+ # replace repeats
274
+ val = val.join(@rep) if Array.isArray val
275
+ val = String(val).split @rep, -1
276
+ how = 'rep'
277
+ else if fld? and com?
278
+ # replace components
279
+ val = val.join(@com) if Array.isArray val
280
+ val = String(val).split @com, -1
281
+ how = 'com'
282
+
283
+ throw new Error "invalid fld/rep/com: [#{fld}, #{rep}, #{com}]" unless how
284
+
285
+ val = [""] if val.length is 0
286
+
287
+ # Replace the target in each matching row
288
+ for row in our
289
+ if how is 'fld'
290
+ if fld?
291
+ pad = fld - row.length
292
+ if pad > 0
293
+ for _i in [1..pad]
294
+ row.push ""
295
+ row.splice fld, val.length, ...val
296
+ else
297
+ row.splice 1, row.length - 1, ...val
298
+
299
+ else if how is 'rep'
300
+ row[fld] ?= ""
301
+ was = row[fld]
302
+ if was.length is 0
303
+ newVal = ""
304
+ if rep > 1
305
+ newVal = @rep.repeat(rep - 1)
306
+ newVal += val.join @rep
307
+ row[fld] = newVal
308
+ else
309
+ ufr = was.split @rep, -1
310
+ pad = rep - ufr.length
311
+ if newRep or (rep is 0 and ufr.length is 0)
312
+ pad = 1
313
+ if pad > 0
314
+ for _i in [1..pad]
315
+ ufr.push ""
316
+ ufr.splice rep - 1, val.length, ...val
317
+ row[fld] = ufr.join @rep
318
+
319
+ else if how is 'com'
320
+ rep ?= 0
321
+ row[fld] ?= ""
322
+ one = row[fld]
323
+
324
+ if one.length is 0
325
+ newVal = ""
326
+ if rep > 1
327
+ newVal = @rep.repeat(rep - 1)
328
+ if com > 1
329
+ newVal += @com.repeat(com - 1)
330
+ newVal += val.join @com
331
+ row[fld] = newVal
332
+ else
333
+ ufr = one.split @rep, -1
334
+ pad = rep - ufr.length
335
+ if newRep or (rep is 0 and ufr.length is 0)
336
+ pad = 1
337
+ if pad > 0
338
+ for _i in [1..pad]
339
+ ufr.push ""
340
+
341
+ two = ufr[rep - 1] or ""
342
+ if two.length is 0
343
+ newVal = ""
344
+ if com > 1
345
+ newVal = @com.repeat(com - 1)
346
+ newVal += val.join @com
347
+ ufr[rep - 1] = newVal
348
+ else
349
+ ucr = two.split @com, -1
350
+ pad = com - ucr.length
351
+ if pad > 0
352
+ for _i in [1..pad]
353
+ ucr.push ""
354
+ ucr.splice com - 1, val.length, ...val
355
+ ufr[rep - 1] = ucr.join @com
356
+
357
+ row[fld] = ufr.join @rep
358
+
359
+ # Enforce ISA field widths
360
+ if seg =~ /isa/i
361
+ @isaWidths row
362
+
363
+ null
364
+
365
+ get: (pos) -> @data pos
366
+ set: (pos, val) -> @data pos, val
367
+
368
+ # ==[ Update ]================================================================
369
+
370
+ update: (etc) ->
371
+ if Array.isArray etc
372
+ i = 0
373
+ while i < etc.length - 1
374
+ @data etc[i], etc[i + 1] if etc[i + 1]?
375
+ i += 2
376
+ else if typeof etc is 'object' and etc?
377
+ for own pos, val of etc
378
+ @data pos, val if val?
379
+ this
380
+
381
+ # ==[ Iteration ]=============================================================
382
+
383
+ each: (seg, fn) ->
384
+ if typeof seg is 'function'
385
+ fn = seg
386
+ seg = null
387
+ for row in @toArray()
388
+ if seg?
389
+ if typeof seg is 'string'
390
+ continue unless row[0].toUpperCase() is seg.toUpperCase()
391
+ else
392
+ continue unless seg.test row[0]
393
+ fn row
394
+ this
395
+
396
+ grep: (seg) ->
397
+ result = []
398
+ for row in @toArray()
399
+ if typeof seg is 'string'
400
+ result.push row if row[0].toUpperCase() is seg.toUpperCase()
401
+ else
402
+ result.push row if seg.test row[0]
403
+ result
404
+
405
+ # ==[ Find (multi-query) ]====================================================
406
+
407
+ find: (...ask) ->
408
+ return undefined if ask.length is 0
409
+
410
+ str = @toString()
411
+ say = []
412
+
413
+ for pos in ask
414
+ unless pos?
415
+ say.push null
416
+ continue
417
+
418
+ m = pos.match SELECTOR
419
+ throw new Error "bad selector '#{pos}'" unless m
420
+ seg = m[1]
421
+ wantPat = "^#{seg}[^#{escRe @seg}\\r\\n]*"
422
+ wantG = new RegExp wantPat, 'gim'
423
+
424
+ numStr = m[2] or ""
425
+ isDigit = /^\d+$/
426
+ num = if numStr.length > 0 and isDigit.test numStr then parseInt numStr else null
427
+ askNum = numStr is "?"
428
+ allNum = numStr is "*"
429
+
430
+ fldStr = m[3]
431
+ fld = if fldStr? then parseInt fldStr else null
432
+
433
+ repStr = m[4] or ""
434
+ rep = if repStr.length > 0 and isDigit.test repStr then parseInt repStr else null
435
+ askRep = repStr is "?"
436
+
437
+ comStr = m[5]
438
+ com = if comStr? then parseInt comStr else null
439
+
440
+ matches = str.match(wantG) or []
441
+
442
+ if allNum
443
+ throw new Error "multi query allows only one selector" if ask.length > 1
444
+ result = []
445
+ for match in matches
446
+ out = match
447
+ if fld?
448
+ out = out.split(@fld)[fld]
449
+ continue unless out?
450
+ if askRep
451
+ result.push out.split(@rep).length
452
+ continue
453
+ if rep? or (com? and not rep?)
454
+ rep ?= 1
455
+ out = out.split(@rep)[rep - 1]
456
+ continue unless out?
457
+ if com?
458
+ out = out.split(@com)[com - 1]
459
+ continue unless out?
460
+ result.push out
461
+ return result
462
+
463
+ if askNum and not askRep
464
+ say.push matches.length
465
+ continue
466
+
467
+ num ?= 1
468
+ out = matches[num - 1]
469
+ unless out?
470
+ say.push ""
471
+ continue
472
+
473
+ if fld?
474
+ out = out.split(@fld)[fld]
475
+ unless out?
476
+ say.push ""
477
+ continue
478
+
479
+ if askRep
480
+ say.push out.split(@rep).length
481
+ continue
482
+
483
+ if rep? or (com? and not rep?)
484
+ rep ?= 1
485
+ out = out.split(@rep)[rep - 1]
486
+ unless out?
487
+ say.push ""
488
+ continue
489
+
490
+ if com?
491
+ out = out.split(@com)[com - 1]
492
+ unless out?
493
+ say.push ""
494
+ continue
495
+
496
+ say.push out
497
+
498
+ if say.length > 1 then say else say[0]
499
+
500
+ X12.load = (file) ->
501
+ try
502
+ str = readFileSync file, 'utf-8'
503
+ catch
504
+ throw new Error "unreadable file: #{file}"
505
+ new X12 str
506
+
507
+ export { X12, ISA_WIDTHS, SELECTOR }
508
+
509
+ # ==[ CLI ]=====================================================================
510
+
511
+ if import.meta.main
512
+
513
+ args = process.argv.slice 2
514
+
515
+ if args.includes('-v') or args.includes('--version')
516
+ console.log "rip-x12 0.1.0"
517
+ process.exit 0
518
+
519
+ if args.includes('-h') or args.includes('--help')
520
+ console.log """
521
+ usage: rip-x12 [options] <file> <file> ...
522
+
523
+ Options:
524
+ -a, --after <date> After date (YYYYMMDD)
525
+ --ansi ANSI color output
526
+ -c, --count Count messages
527
+ -d, --dive Recursive directory scan
528
+ -f, --fields Show fields
529
+ -F, --fields-only Fields only, no repeat indicators
530
+ -h, --help Show help
531
+ -i, --ignore Skip malformed files
532
+ -l, --lower Lowercase segment names
533
+ -m, --message Show message body
534
+ -p, --path Show file path
535
+ -q, --query <val> Query specific values
536
+ -s, --spacer Blank line between messages
537
+ -t, --tsv Tab-delimited output
538
+ -v, --version Show version
539
+ """
540
+ process.exit 0
541
+
542
+ # Parse options
543
+ opts = {}
544
+ paths = []
545
+ i = 0
546
+ while i < args.length
547
+ arg = args[i]
548
+ if arg is '-a' or arg is '--after'
549
+ i++
550
+ opts.after = args[i]
551
+ else if arg is '--ansi'
552
+ opts.ansi = true
553
+ else if arg is '-c' or arg is '--count'
554
+ opts.count = true
555
+ else if arg is '-d' or arg is '--dive'
556
+ opts.dive = true
557
+ else if arg is '-f' or arg is '--fields'
558
+ opts.fields = true
559
+ else if arg is '-F' or arg is '--fields-only'
560
+ opts.fields = true
561
+ opts.only = true
562
+ else if arg is '-i' or arg is '--ignore'
563
+ opts.ignore = true
564
+ else if arg is '-l' or arg is '--lower'
565
+ opts.lower = true
566
+ else if arg is '-m' or arg is '--message'
567
+ opts.message = true
568
+ else if arg is '-p' or arg is '--path'
569
+ opts.path = true
570
+ else if arg is '-q' or arg is '--query'
571
+ i++
572
+ opts.query = args[i]
573
+ else if arg is '-s' or arg is '--spacer'
574
+ opts.spacer = true
575
+ else if arg is '-t' or arg is '--tsv'
576
+ opts.tsv = true
577
+ else
578
+ paths.push arg
579
+ i++
580
+
581
+ if paths.length is 0
582
+ console.error "usage: rip-x12 [options] <file> <file> ..."
583
+ process.exit 1
584
+
585
+ # Build show args
586
+ showArgs = []
587
+ showArgs.push 'ansi' if opts.ansi
588
+ showArgs.push 'down' if opts.lower
589
+ noOpts = Object.keys(opts).length is 0
590
+ showArgs.push 'full' if opts.message or noOpts
591
+ showArgs.push 'hide' unless opts.fields
592
+ showArgs.push 'only' if opts.only
593
+
594
+ # Query setup
595
+ quer = null
596
+ seps = if opts.tsv then "\t" else "|"
597
+ if opts.query
598
+ quer = opts.query.split(',').map (s) -> s.trim()
599
+
600
+ # After date filter
601
+ afterTime = null
602
+ if opts.after
603
+ d = opts.after
604
+ iso = "#{d.slice(0,4)}-#{d.slice(4,6)}-#{d.slice(6,8)}"
605
+ afterTime = new Date iso
606
+
607
+ # File discovery
608
+ skipDirs = new Set ['.git', 'node_modules', '.rip-cache']
609
+
610
+ walkDir = (dir) ->
611
+ files = []
612
+ try
613
+ entries = readdirSync dir
614
+ catch
615
+ return files
616
+ for entry in entries
617
+ full = join dir, entry
618
+ try
619
+ stat = statSync full
620
+ catch
621
+ continue
622
+ if stat.isDirectory()
623
+ continue if entry.startsWith('.') or skipDirs.has entry
624
+ if opts.dive
625
+ files = files.concat walkDir full
626
+ else if stat.isFile()
627
+ if afterTime
628
+ files.push full if stat.mtime > afterTime
629
+ else
630
+ files.push full
631
+ files.sort()
632
+
633
+ # Build file list
634
+ list = []
635
+ for p in paths
636
+ unless existsSync p
637
+ console.error "unknown: #{p}"
638
+ continue
639
+ stat = statSync p
640
+ if stat.isDirectory()
641
+ list = list.concat walkDir p
642
+ else if stat.isFile()
643
+ if afterTime
644
+ list.push p if stat.mtime > afterTime
645
+ else
646
+ list.push p
647
+ else
648
+ console.error "unknown: #{p}"
649
+
650
+ # Process files
651
+ msgs = 0
652
+
653
+ for file in list
654
+ console.log() if opts.spacer and msgs > 0
655
+
656
+ if opts.path
657
+ if quer and quer.length is 1
658
+ process.stdout.write "#{file}:"
659
+ else
660
+ console.log "\n==[ #{file} ]==\n"
661
+
662
+ try
663
+ str = readFileSync file, 'utf-8'
664
+ catch
665
+ console.error "ERROR: unable to read: \"#{file}\""
666
+ continue
667
+
668
+ try
669
+ x12 = new X12 str
670
+ catch e
671
+ unless opts.ignore
672
+ console.error "ERROR: malformed X12: \"#{file}\" (#{e.message})"
673
+ continue
674
+
675
+ if quer
676
+ hits = x12.find ...quer
677
+ hits = [hits] unless Array.isArray hits
678
+ hits.unshift file if opts.path
679
+ console.log hits.join seps
680
+ console.log() if opts.path
681
+ continue
682
+
683
+ x12.show ...showArgs
684
+ msgs += 1
685
+
686
+ if opts.count and msgs > 0
687
+ console.log "\nTotal messages: #{msgs}"