Current section

Files

Jump to
hl7v2 lib hl7v2 parser.ex
Raw

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, &copy_segment/1)}
end
defp copy_segment({name, fields}) do
{:binary.copy(name), Enum.map(fields, &copy_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, &copy_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