Packages
hl7v2
2.6.0
3.10.1
3.9.0
3.8.0
3.7.0
3.6.0
3.5.0
3.4.0
3.3.6
3.3.5
3.3.4
3.3.3
3.3.2
3.3.1
3.3.0
3.2.0
3.1.1
3.1.0
3.0.2
3.0.1
3.0.0
2.11.0
2.10.0
2.9.1
2.9.0
2.8.2
2.8.1
2.8.0
2.7.1
2.7.0
2.6.0
2.5.0
2.4.0
2.3.0
2.2.0
2.1.3
2.1.2
2.1.1
2.1.0
1.4.6
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.0
1.2.0
1.1.0
1.0.0
0.6.0
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.1.0
Pure Elixir HL7 v2.x toolkit — schema-driven parsing, typed segments, message builder, MLLP transport
Current section
Files
Jump to
Current section
Files
lib/hl7v2/parser.ex
defmodule HL7v2.Parser do
@moduledoc """
Parses HL7v2 messages into raw representation with canonical round-trip fidelity.
The parser splits the message into segments, fields, repetitions, components,
and sub-components using the delimiters declared in the MSH segment header.
No type coercion or validation is performed. Line endings are normalized to CR
and a trailing CR is always present, so `parse(text) |> encode()` produces the
canonical wire form (which may differ from the input only in line-ending
normalization).
## MSH-1/MSH-2 Special Handling
MSH-1 (field separator) and MSH-2 (encoding characters) are not regular
delimited fields. The parser handles them as special cases:
- MSH-1 is stored as a single-byte binary (the field separator character)
- MSH-2 is stored as a 4- or 5-character literal string (encoding characters)
- Remaining MSH fields start at field index 2 (MSH-3 = index 2)
## Examples
iex> {:ok, msg} = HL7v2.Parser.parse("MSH|^~\\\\&|SEND|FAC||RCV||20240101||ADT^A01|123|P|2.5\\r")
iex> msg.type
{"ADT", "A01"}
iex> length(msg.segments)
1
"""
alias HL7v2.{RawMessage, Separator, TypedParser}
@compile {:inline, parse_field: 2, parse_components: 2, parse_sub_components: 2}
@doc """
Parses an HL7v2 message binary into a `RawMessage` struct.
## Options
- `:mode` — `:raw` (default) or `:typed` for parsed segment structs.
- `:copy` — `true` to copy all parsed binaries (prevents GC reference to original
message binary). Use when storing parsed messages long-term. Default `false`.
Returns `{:ok, raw_message}` or `{:error, reason}`.
"""
@spec parse(binary(), keyword()) ::
{:ok, RawMessage.t() | HL7v2.TypedMessage.t()} | {:error, term()}
def parse(text, opts \\ [])
def parse("", _opts), do: {:error, :empty_message}
def parse(text, opts) do
mode = Keyword.get(opts, :mode, :raw)
copy? = Keyword.get(opts, :copy, false)
case mode do
:raw ->
with {:ok, raw} <- parse_raw(text) do
{:ok, if(copy?, do: deep_copy_binaries(raw), else: raw)}
end
:typed ->
with {:ok, raw} <- parse_raw(text) do
raw = if copy?, do: deep_copy_binaries(raw), else: raw
TypedParser.convert(raw)
end
other ->
{:error, {:unknown_mode, other}}
end
end
defp deep_copy_binaries(%RawMessage{} = msg) do
%{msg | segments: Enum.map(msg.segments, ©_segment/1)}
end
defp copy_segment({name, fields}) do
{:binary.copy(name), Enum.map(fields, ©_value/1)}
end
defp copy_value(v) when is_binary(v), do: :binary.copy(v)
defp copy_value(v) when is_list(v), do: Enum.map(v, ©_value/1)
defp copy_value(v), do: v
defp parse_raw(text) do
text = normalize_line_endings(text)
with {:ok, separators} <- Separator.from_msh(text),
{:ok, segment_texts} <- split_segments(text, separators),
{:ok, segments} <- parse_segments(segment_texts, separators),
{:ok, msg_type} <- extract_message_type(segments, separators) do
{:ok,
%RawMessage{
separators: separators,
type: msg_type,
segments: segments
}}
end
end
# Normalize CRLF and LF to CR (the HL7v2 segment terminator)
defp normalize_line_endings(text) do
text
|> String.replace("\r\n", "\r")
|> String.replace("\n", "\r")
end
defp split_segments(text, %Separator{segment: seg}) do
segments =
text
|> String.split(<<seg>>, trim: true)
|> Enum.reject(&(&1 == ""))
if segments == [] do
{:error, :empty_message}
else
{:ok, segments}
end
end
defp parse_segments(segment_texts, separators) do
segments = Enum.map(segment_texts, &parse_segment(&1, separators))
{:ok, segments}
end
defp parse_segment(segment_text, %Separator{} = sep) do
field_sep = <<sep.field>>
case segment_text do
<<"MSH", _rest::binary>> ->
parse_msh_segment(segment_text, sep)
_ ->
[name | fields] = String.split(segment_text, field_sep)
parsed_fields = Enum.map(fields, &parse_field(&1, sep))
{name, parsed_fields}
end
end
# MSH is special: MSH-1 is the field separator itself (not delimited),
# MSH-2 is the 4 or 5 encoding characters (literal, not delimited).
defp parse_msh_segment(<<"MSH", field_sep, rest::binary>>, %Separator{} = sep) do
# MSH-1 = the field separator character
msh_1 = <<field_sep>>
# MSH-2 = encoding characters (next 4 or 5 bytes, up to the next field separator)
{msh_2, remaining} = extract_msh_2(rest, sep)
# Remaining fields are regular delimited fields (MSH-3 onwards)
remaining_fields =
case remaining do
"" -> []
_ -> remaining |> String.split(<<sep.field>>) |> Enum.map(&parse_field(&1, sep))
end
{"MSH", [msh_1, msh_2 | remaining_fields]}
end
defp extract_msh_2(rest, %Separator{} = sep) do
# MSH-2 is everything up to the next field separator
field_sep = <<sep.field>>
case String.split(rest, field_sep, parts: 2) do
[msh_2, remaining] -> {msh_2, remaining}
[msh_2] -> {msh_2, ""}
end
end
defp parse_field("", _sep), do: ""
defp parse_field(field_text, %Separator{} = sep) do
rep_sep = <<sep.repetition>>
repetitions = String.split(field_text, rep_sep)
case repetitions do
[single] ->
# No repetitions — parse components
parse_components(single, sep)
multiple ->
# Has repetitions — each repetition gets component parsing.
# Normalize: wrap plain-string results in [value] so that every
# repetition is a list. This removes the structural ambiguity
# between "repetitions of simple values" and "a single set of
# components" — the encoder relies on all-lists to detect reps.
Enum.map(multiple, fn rep ->
case parse_components(rep, sep) do
result when is_binary(result) -> [result]
result -> result
end
end)
end
end
defp parse_components(text, %Separator{} = sep) do
comp_sep = <<sep.component>>
components = String.split(text, comp_sep)
case components do
[single] ->
# No components — parse sub-components
parse_sub_components(single, sep)
multiple ->
# Has components — each may have sub-components
Enum.map(multiple, &parse_sub_components(&1, sep))
end
end
defp parse_sub_components(text, %Separator{} = sep) do
sub_sep = <<sep.sub_component>>
subs = String.split(text, sub_sep)
case subs do
[single] -> single
multiple -> multiple
end
end
# Extract message type from MSH-9 (field index 8 in our 0-indexed field list,
# remembering MSH-1 is index 0, MSH-2 is index 1, MSH-3 is index 2, ...)
# MSH-9 = index 8
defp extract_message_type(segments, %Separator{} = _sep) do
case segments do
[{"MSH", fields} | _] when length(fields) > 8 ->
msh_9 = Enum.at(fields, 8)
{:ok, parse_message_type(msh_9)}
[{"MSH", _} | _] ->
{:error, :missing_message_type}
_ ->
{:error, :first_segment_not_msh}
end
end
defp parse_message_type(components) when is_list(components) do
case components do
[code, event, structure | _] -> {code, event, structure}
[code, event] -> {code, event}
[code] -> {code, ""}
[] -> {"", ""}
end
end
defp parse_message_type(value) when is_binary(value) do
{value, ""}
end
end