Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Single-package Go library (`package hl7`) for parsing HL7 version 2.x messages i

- **Full serialization or Marshal API.** The library does not provide a general `Marshal` function. Message construction is handled by `MessageBuilder` (from-scratch) and `Transform` (modify existing), both of which produce `*Message` via `ParseMessage`. There are no per-field setters on parsed messages.
- **Built-in schema definitions.** The library does not ship with HL7v2 segment, data type, or table definitions. Users provide their own `Schema` struct to `msg.Validate()`. This keeps the library general-purpose and avoids bundling version-specific definitions.
- **No field location constants.** HL7 field positions (e.g., `PID-5.1`) are not stable across HL7 v2 versions or vendor implementations. The library does not provide named constants for terser-style location strings; callers define their own.

## Building and Testing

Expand All @@ -39,14 +40,14 @@ hl7/
escape.go # Unescape() with zero-alloc fast path
component.go # Component and Subcomponent value types
field.go # Field and Repetition value types
segment.go # Segment type with MSH special-case handling
segment.go # Segment type with MSH/BHS/FHS special-case handling
message.go # Message type, ParseMessage(), segment iterators
accessor.go # Terser-style location parser, Location type, Value type, Get()
charset.go # ValueDecoder type; DecodeString on Field/Repetition/Component/Subcomponent/Value
reader.go # io.Reader wrapper with MLLP and raw mode support
writer.go # io.Writer wrapper with MLLP and raw mode support
ack.go # ACK message generation (AckCode, AckOption, Message.Ack, WithErrors)
batch.go # Batch (BHS/BTS) and File (FHS/FTS) parsing
batch.go # Batch (BHS/BTS) and File (FHS/FTS) parsing; BatchBuilder for constructing batches
transform.go # Transform engine, Change types, workBuf, delimiter re-encoding
builder.go # MessageBuilder for from-scratch message construction
schema.go # Schema types for validation (MessageDef, SegmentDef, etc.)
Expand Down Expand Up @@ -148,6 +149,8 @@ Schema types are defined in `schema.go`:

**MessageBuilder** wraps `workBuf` seeded with a minimal MSH skeleton (instead of a source message). `Set(location, value)` calls the same `applyValueAtLocation` path used by `Transform`. `Build()` calls `ParseMessage` on the buffer to produce an immutable `*Message`.

**BatchBuilder** constructs BHS/BTS-wrapped batch files. `NewBatchBuilder(opts...)` seeds the builder with `DefaultDelimiters()`. `SetHeader(fieldNum, value)` stores plain-text header field values (escaped at `Build` time). `Add(msg)` appends messages. `Build()` pre-calculates total output size, allocates once, and writes BHS + messages + BTS. BTS-1 is set to the message count. `Reset()` clears messages while preserving header fields. BHS-7 defaults to `time.Now()` at build time unless set via `SetHeader(7, ...)`. BHS and FHS segments use the same MSH-style field numbering (field 1 = separator, field 2 = encoding chars, field 3+ = normal fields) via `isHeaderSeg` in `segment.go`.

**Change types** — `Replace`, `Null`, `Omit`, `Move`, `Copy` — are sealed interface implementations (`Change` has an unexported method). `applyOneChange` dispatches on the concrete type.

**Delimiter re-encoding** — `reencodeData` performs a single-pass conversion of all bytes from source to destination delimiters, resolving escape sequences to their literal source values and re-escaping if they collide with destination delimiters.
Expand All @@ -166,18 +169,18 @@ Schema types are defined in `schema.go`:

Delimiters are extracted per-message from MSH-1 (field separator) and MSH-2 positions 1-4 (component, repetition, escape, subcomponent). The standard set `|^~\&` is never assumed — any valid delimiter set is accepted. Validation rejects zero bytes, CR/LF, and duplicate characters.

### MSH Field Numbering
### MSH/BHS/FHS Field Numbering

The MSH segment has unique field numbering because MSH-1 IS the field separator character (it does not appear between delimiters like normal fields):
MSH, BHS, and FHS segments share the same unique field numbering because field 1 IS the field separator character (it does not appear between delimiters like normal fields):

| Index | Content | Notes |
|-------|---------|-------|
| `Field(0)` | `"MSH"` | Segment type (same as all segments) |
| `Field(0)` | `"MSH"` / `"BHS"` / `"FHS"` | Segment type (same as all segments) |
| `Field(1)` | `"\|"` | The field separator character itself |
| `Field(2)` | `"^~\\&"` | Encoding characters (literal, not parsed further) |
| `Field(3+)` | Normal fields | Parsed normally from bytes after encoding chars |

This is implemented in `segment.go` via `mshField()` which handles these three special cases before falling through to `nthSlice` for fields 3+.
This is implemented in `segment.go` via `isHeaderSeg()` (true for MSH, BHS, FHS) and `mshField()`, which handles the three special cases before falling through to `nthSlice` for fields 3+.

### ADD Segment (Continuation)

Expand Down
154 changes: 153 additions & 1 deletion batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@

package hl7

import "bytes"
import (
"bytes"
"strconv"
"time"
)

// Batch represents an HL7 batch (BHS...BTS) containing one or more messages.
type Batch struct {
Expand Down Expand Up @@ -229,6 +233,154 @@ func parseStandaloneSegment(line []byte) Segment {
return Segment{raw: line, delims: delims}
}

// BatchBuilder constructs a BHS/BTS-wrapped HL7 batch from individual messages.
//
// Example:
//
// bb := hl7.NewBatchBuilder()
// bb.SetHeader(3, "MYAPP").SetHeader(4, "MYFAC")
// bb.Add(msg1).Add(msg2)
// data, err := bb.Build()
type BatchBuilder struct {
delims Delimiters
fields map[int]string // fieldNum (1-based) -> plain-text value
messages []*Message
}

// BatchBuilderOption configures a BatchBuilder.
type BatchBuilderOption func(*BatchBuilder)

// WithBatchDelimiters sets the delimiter set used in the BHS header.
func WithBatchDelimiters(d Delimiters) BatchBuilderOption {
return func(b *BatchBuilder) {
b.delims = d
}
}

// NewBatchBuilder returns a BatchBuilder with a default BHS header using
// DefaultDelimiters(). BHS-7 (date/time) is set to time.Now() at Build time
// unless overridden via SetHeader(7, ...).
func NewBatchBuilder(opts ...BatchBuilderOption) *BatchBuilder {
b := &BatchBuilder{delims: DefaultDelimiters()}
for _, opt := range opts {
opt(b)
}
return b
}

// SetHeader sets BHS field fieldNum (1-based) to the given plain-text value.
// The value is escaped at Build time. Common fields:
// BHS-3 (sending application), BHS-4 (sending facility), BHS-11 (batch control ID).
// Returns the receiver for chaining.
func (b *BatchBuilder) SetHeader(fieldNum int, value string) *BatchBuilder {
if b.fields == nil {
b.fields = make(map[int]string)
}
b.fields[fieldNum] = value
return b
}

// Add appends msg to the batch. msg.Raw() bytes are written verbatim at Build time.
// Returns the receiver for chaining.
func (b *BatchBuilder) Add(msg *Message) *BatchBuilder {
b.messages = append(b.messages, msg)
return b
}

// Reset clears accumulated messages. Header field values set via SetHeader are preserved.
func (b *BatchBuilder) Reset() {
b.messages = nil
}

// Build finalizes the batch and returns BHS + messages + BTS as a byte slice.
// BTS-1 (message count) is set to the number of messages added via Add.
// BatchBuilder is reusable after Build; call Reset to clear messages while
// keeping header field values.
//
// Returns an error if the delimiter set is invalid.
func (b *BatchBuilder) Build() ([]byte, error) {
if err := b.delims.validate(); err != nil {
return nil, err
}

delims := b.delims
now := time.Now()

// Determine the maximum field number written to the BHS segment.
// BHS-7 (datetime) is always included as the minimum.
maxField := 7
for k := range b.fields {
if k > maxField {
maxField = k
}
}

// Collect escaped field values for BHS fields 3..maxField.
fieldVals := make([][]byte, maxField-2) // index 0 = field 3
for i := 3; i <= maxField; i++ {
idx := i - 3
if v, ok := b.fields[i]; ok {
fieldVals[idx] = Escape([]byte(v), delims)
} else if i == 7 {
// Default BHS-7 to current date/time.
fieldVals[idx] = []byte(now.Format("20060102150405"))
}
// All other unset fields remain nil (written as empty between separators).
}

// Pre-calculate BHS segment size:
// "BHS" (3) + fieldSep (1) + 4 encoding chars + (maxField-2) separators + field bytes
bhsSize := 7 + (maxField - 2)
for _, v := range fieldVals {
bhsSize += len(v)
}

// Pre-calculate BTS segment size: "BTS" (3) + fieldSep (1) + count digits
countStr := strconv.Itoa(len(b.messages))
btsSize := 3 + 1 + len(countStr)

// Pre-calculate total output size.
totalSize := bhsSize + 1 // BHS + \r
for _, msg := range b.messages {
raw := msg.Raw()
totalSize += len(raw)
if len(raw) == 0 || raw[len(raw)-1] != '\r' {
totalSize++ // ensure \r terminator after each message
}
}
totalSize += btsSize + 1 // BTS + \r

// Allocate once and build output.
out := make([]byte, 0, totalSize)

// BHS segment: BHS + fieldSep + encoding chars + fields 3..maxField
out = append(out, 'B', 'H', 'S')
out = append(out, delims.Field)
out = append(out, delims.Component, delims.Repetition, delims.Escape, delims.SubComponent)
for _, v := range fieldVals {
out = append(out, delims.Field)
out = append(out, v...)
}
out = append(out, '\r')

// Messages: write each message's raw bytes, ensuring \r termination.
for _, msg := range b.messages {
raw := msg.Raw()
out = append(out, raw...)
if len(raw) == 0 || raw[len(raw)-1] != '\r' {
out = append(out, '\r')
}
}

// BTS segment: BTS + fieldSep + count
out = append(out, 'B', 'T', 'S')
out = append(out, delims.Field)
out = append(out, countStr...)
out = append(out, '\r')

return out, nil
}

// extractHeaderDelimiters extracts delimiters from batch/file header segments
// (BHS, FHS) which share the same encoding character layout as MSH.
func extractHeaderDelimiters(data []byte) (Delimiters, error) {
Expand Down
Loading
Loading